diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 3f8f069a..f609be49 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -22,79 +22,19 @@ const Allocator = std.mem.Allocator; pub const Id = u32; +const log = std.log.scoped(.cdp_node); + const Node = @This(); id: Id, -parent_id: ?Id = null, -node_type: u32, -backend_node_id: Id, -node_name: []const u8, -local_name: []const u8, -node_value: []const u8, -child_node_count: u32, -children: []const *Node, -document_url: ?[]const u8, -base_url: ?[]const u8, -xml_version: []const u8, -compatibility_mode: CompatibilityMode, -is_scrollable: bool, _node: *parser.Node, -const CompatibilityMode = enum { - NoQuirksMode, -}; - -pub fn jsonStringify(self: *const Node, writer: anytype) !void { - try writer.beginObject(); - try writer.objectField("nodeId"); - try writer.write(self.id); - - try writer.objectField("parentId"); - try writer.write(self.parent_id); - - try writer.objectField("backendNodeId"); - try writer.write(self.backend_node_id); - - try writer.objectField("nodeType"); - try writer.write(self.node_type); - - try writer.objectField("nodeName"); - try writer.write(self.node_name); - - try writer.objectField("localName"); - try writer.write(self.local_name); - - try writer.objectField("nodeValue"); - try writer.write(self.node_value); - - try writer.objectField("childNodeCount"); - try writer.write(self.child_node_count); - - try writer.objectField("children"); - try writer.write(self.children); - - try writer.objectField("documentURL"); - try writer.write(self.document_url); - - try writer.objectField("baseURL"); - try writer.write(self.base_url); - - try writer.objectField("xmlVersion"); - try writer.write(self.xml_version); - - try writer.objectField("compatibilityMode"); - try writer.write(self.compatibility_mode); - - try writer.objectField("isScrollable"); - try writer.write(self.is_scrollable); - try writer.endObject(); -} - // Whenever we send a node to the client, we register it here for future lookup. // We maintain a node -> id and id -> node lookup. pub const Registry = struct { node_id: u32, allocator: Allocator, + arena: std.heap.ArenaAllocator, node_pool: std.heap.MemoryPool(Node), lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node), lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage), @@ -102,9 +42,10 @@ pub const Registry = struct { pub fn init(allocator: Allocator) Registry { return .{ .node_id = 0, - .allocator = allocator, .lookup_by_id = .{}, .lookup_by_node = .{}, + .allocator = allocator, + .arena = std.heap.ArenaAllocator.init(allocator), .node_pool = std.heap.MemoryPool(Node).init(allocator), }; } @@ -114,12 +55,14 @@ pub const Registry = struct { self.lookup_by_id.deinit(allocator); self.lookup_by_node.deinit(allocator); self.node_pool.deinit(); + self.arena.deinit(); } pub fn reset(self: *Registry) void { self.lookup_by_id.clearRetainingCapacity(); self.lookup_by_node.clearRetainingCapacity(); - _ = self.node_pool.reset(.{ .retain_capacity = {} }); + _ = self.arena.reset(.{ .retain_with_limit = 1024 }); + _ = self.node_pool.reset(.{ .retain_with_limit = 1024 }); } pub fn register(self: *Registry, n: *parser.Node) !*Node { @@ -132,38 +75,17 @@ pub const Registry = struct { // but, just in case, let's try to keep things tidy. errdefer _ = self.lookup_by_node.remove(n); - const children = try parser.nodeGetChildNodes(n); - const children_count = try parser.nodeListLength(children); - - const id = self.node_id; - defer self.node_id = id + 1; - const node = try self.node_pool.create(); errdefer self.node_pool.destroy(node); + const id = self.node_id; + self.node_id = id + 1; + node.* = .{ ._node = n, .id = id, - .parent_id = null, // TODO - .backend_node_id = id, // ?? - .node_name = try parser.nodeName(n), - .local_name = try parser.nodeLocalName(n), - .node_value = try parser.nodeValue(n) orelse "", - .node_type = @intFromEnum(try parser.nodeType(n)), - .child_node_count = children_count, - .children = &.{}, // TODO - .document_url = null, - .base_url = null, - .xml_version = "", - .compatibility_mode = .NoQuirksMode, - .is_scrollable = false, }; - // if (try parser.nodeParentNode(n)) |pn| { - // _ = pn; - // // TODO - // } - node_lookup_gop.value_ptr.* = node; try self.lookup_by_id.putNoClobber(self.allocator, id, node); return node; @@ -271,8 +193,107 @@ pub const Search = struct { }; }; +// Need a custom writer, because we can't just serialize the node as-is. +// Sometimes we want to serializ the node without chidren, sometimes with just +// its direct children, and sometimes the entire tree. +// (For now, we only support direct children) + +pub const Writer = struct { + opts: Opts, + node: *const Node, + registry: *Registry, + + pub const Opts = struct {}; + + pub fn jsonStringify(self: *const Writer, w: anytype) !void { + self.toJSON(w) catch |err| { + // The only error our jsonStringify method can return is + // @TypeOf(w).Error. In other words, our code can't return its own + // error, we can only return a writer error. Kinda sucks. + log.err("json stringify: {}", .{err}); + return error.OutOfMemory; + }; + } + + fn toJSON(self: *const Writer, w: anytype) !void { + try w.beginObject(); + try writeCommon(self.node, false, w); + + { + var registry = self.registry; + const child_nodes = try parser.nodeGetChildNodes(self.node._node); + const child_count = try parser.nodeListLength(child_nodes); + + var i: usize = 0; + try w.objectField("children"); + try w.beginArray(); + for (0..child_count) |_| { + const child = (try parser.nodeListItem(child_nodes, @intCast(i))) orelse break; + const child_node = try registry.register(child); + try w.beginObject(); + try writeCommon(child_node, true, w); + try w.endObject(); + i += 1; + } + try w.endArray(); + + try w.objectField("childNodeCount"); + try w.write(i); + } + + try w.endObject(); + } + + fn writeCommon(node: *const Node, include_child_count: bool, w: anytype) !void { + try w.objectField("nodeId"); + try w.write(node.id); + + try w.objectField("backendNodeId"); + try w.write(node.id); + + const n = node._node; + + // TODO: + // try w.objectField("parentId"); + // try w.write(pid); + + try w.objectField("nodeType"); + try w.write(@intFromEnum(try parser.nodeType(n))); + + try w.objectField("nodeName"); + try w.write(try parser.nodeName(n)); + + try w.objectField("localName"); + try w.write(try parser.nodeLocalName(n)); + + try w.objectField("nodeValue"); + try w.write((try parser.nodeValue(n)) orelse ""); + + if (include_child_count) { + try w.objectField("childNodeCount"); + const child_nodes = try parser.nodeGetChildNodes(n); + try w.write(try parser.nodeListLength(child_nodes)); + } + + try w.objectField("documentURL"); + try w.write(null); + + try w.objectField("baseURL"); + try w.write(null); + + try w.objectField("xmlVersion"); + try w.write(""); + + try w.objectField("compatibilityMode"); + try w.write("NoQuirksMode"); + + try w.objectField("isScrollable"); + try w.write(false); + } +}; + const testing = @import("testing.zig"); -test "CDP Node: Registry register" { +test "cdp Node: Registry register" { var registry = Registry.init(testing.allocator); defer registry.deinit(); @@ -291,19 +312,6 @@ test "CDP Node: Registry register" { try testing.expectEqual(node, n1c); try testing.expectEqual(0, node.id); - try testing.expectEqual(null, node.parent_id); - try testing.expectEqual(1, node.node_type); - try testing.expectEqual(0, node.backend_node_id); - try testing.expectEqual("A", node.node_name); - try testing.expectEqual("a", node.local_name); - try testing.expectEqual("", node.node_value); - try testing.expectEqual(1, node.child_node_count); - try testing.expectEqual(0, node.children.len); - try testing.expectEqual(null, node.document_url); - try testing.expectEqual(null, node.base_url); - try testing.expectEqual("", node.xml_version); - try testing.expectEqual(.NoQuirksMode, node.compatibility_mode); - try testing.expectEqual(false, node.is_scrollable); try testing.expectEqual(n, node._node); } @@ -316,24 +324,11 @@ test "CDP Node: Registry register" { try testing.expectEqual(node, n1c); try testing.expectEqual(1, node.id); - try testing.expectEqual(null, node.parent_id); - try testing.expectEqual(1, node.node_type); - try testing.expectEqual(1, node.backend_node_id); - try testing.expectEqual("P", node.node_name); - try testing.expectEqual("p", node.local_name); - try testing.expectEqual("", node.node_value); - try testing.expectEqual(1, node.child_node_count); - try testing.expectEqual(0, node.children.len); - try testing.expectEqual(null, node.document_url); - try testing.expectEqual(null, node.base_url); - try testing.expectEqual("", node.xml_version); - try testing.expectEqual(.NoQuirksMode, node.compatibility_mode); - try testing.expectEqual(false, node.is_scrollable); try testing.expectEqual(n, node._node); } } -test "CDP Node: search list" { +test "cdp Node: search list" { var registry = Registry.init(testing.allocator); defer registry.deinit(); @@ -383,3 +378,102 @@ test "CDP Node: search list" { try testing.expectEqual(2, registry.lookup_by_node.count()); } } + +test "cdp Node: Writer" { + var registry = Registry.init(testing.allocator); + defer registry.deinit(); + + var doc = try testing.Document.init(""); + defer doc.deinit(); + + { + const node = try registry.register(doc.asNode()); + const json = try std.json.stringifyAlloc(testing.allocator, Writer{ + .node = node, + .opts = .{}, + .registry = ®istry, + }, .{}); + defer testing.allocator.free(json); + + try testing.expectJson(.{ + .nodeId = 0, + .backendNodeId = 0, + .nodeType = 9, + .nodeName = "#document", + .localName = "", + .nodeValue = "", + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .isScrollable = false, + .compatibilityMode = "NoQuirksMode", + .childNodeCount = 1, + .children = &.{.{ + .nodeId = 1, + .backendNodeId = 1, + .nodeType = 1, + .nodeName = "HTML", + .localName = "html", + .nodeValue = "", + .childNodeCount = 2, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + }}, + }, json); + } + + { + const node = registry.lookup_by_id.get(1).?; + const json = try std.json.stringifyAlloc(testing.allocator, Writer{ + .node = node, + .opts = .{}, + .registry = ®istry, + }, .{}); + defer testing.allocator.free(json); + + try testing.expectJson(.{ + .nodeId = 1, + .backendNodeId = 1, + .nodeType = 1, + .nodeName = "HTML", + .localName = "html", + .nodeValue = "", + .childNodeCount = 2, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + .children = &.{ .{ + .nodeId = 2, + .backendNodeId = 2, + .nodeType = 1, + .nodeName = "HEAD", + .localName = "head", + .nodeValue = "", + .childNodeCount = 0, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + }, .{ + .nodeId = 3, + .backendNodeId = 3, + .nodeType = 1, + .nodeName = "BODY", + .localName = "body", + .nodeValue = "", + .childNodeCount = 2, + .documentURL = null, + .baseURL = null, + .xmlVersion = "", + .compatibilityMode = "NoQuirksMode", + .isScrollable = false, + } }, + }, json); + } +} diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 763d1b4d..253442bb 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -325,6 +325,14 @@ pub fn BrowserContext(comptime CDP_T: type) type { self.node_search_list.reset(); } + pub fn nodeWriter(self: *Self, node: *const Node, opts: Node.Writer.Opts) Node.Writer { + return .{ + .node = node, + .opts = opts, + .registry = &self.node_registry, + }; + } + pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { // msg should be {"id":,... diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 3c97b078..97cdc40d 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -51,9 +51,7 @@ fn getDocument(cmd: anytype) !void { const doc = page.doc orelse return error.DocumentNotLoaded; const node = try bc.node_registry.register(parser.documentToNode(doc)); - return cmd.sendResult(.{ - .root = node, - }, .{}); + return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{}) }, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch @@ -118,6 +116,7 @@ fn getSearchResults(cmd: anytype) !void { } const testing = @import("../testing.zig"); + test "cdp.dom: getSearchResults unknown search id" { var ctx = testing.context(); defer ctx.deinit(); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 759c3a0e..a8b0e7d9 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -26,11 +26,12 @@ const main = @import("cdp.zig"); const parser = @import("netsurf"); const App = @import("../app.zig").App; -pub const allocator = @import("../testing.zig").allocator; - -pub const expectEqual = @import("../testing.zig").expectEqual; -pub const expectError = @import("../testing.zig").expectError; -pub const expectEqualSlices = @import("../testing.zig").expectEqualSlices; +const base = @import("../testing.zig"); +pub const allocator = base.allocator; +pub const expectJson = base.expectJson; +pub const expectEqual = base.expectEqual; +pub const expectError = base.expectError; +pub const expectEqualSlices = base.expectEqualSlices; pub const Document = @import("../testing.zig").Document; @@ -284,6 +285,7 @@ const TestContext = struct { _ = self.client.?.serialized.orderedRemove(i); return; } + std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized}); for (self.client.?.serialized.items, 0..) |sent, i| { std.debug.print("#{d}\n{s}\n\n", .{ i, sent }); @@ -310,47 +312,5 @@ pub fn context() TestContext { fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool { const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{}); defer expected_value.deinit(); - return compareJsonValues(expected_value.value, actual); -} - -fn compareJsonValues(a: std.json.Value, b: std.json.Value) bool { - if (!std.mem.eql(u8, @tagName(a), @tagName(b))) { - return false; - } - - switch (a) { - .null => return true, - .bool => return a.bool == b.bool, - .integer => return a.integer == b.integer, - .float => return a.float == b.float, - .number_string => return std.mem.eql(u8, a.number_string, b.number_string), - .string => return std.mem.eql(u8, a.string, b.string), - .array => { - const a_len = a.array.items.len; - const b_len = b.array.items.len; - if (a_len != b_len) { - return false; - } - for (a.array.items, b.array.items) |a_item, b_item| { - if (compareJsonValues(a_item, b_item) == false) { - return false; - } - } - return true; - }, - .object => { - var it = a.object.iterator(); - while (it.next()) |entry| { - const key = entry.key_ptr.*; - if (b.object.get(key)) |b_item| { - if (compareJsonValues(entry.value_ptr.*, b_item) == false) { - return false; - } - } else { - return false; - } - } - return true; - }, - } + return base.isEqualJson(expected_value.value, actual); } diff --git a/src/testing.zig b/src/testing.zig index 309938c6..633d9087 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -17,14 +17,15 @@ // along with this program. If not, see . const std = @import("std"); -const parser = @import("netsurf"); +const parser = @import("netsurf"); pub const allocator = std.testing.allocator; pub const expectError = std.testing.expectError; pub const expectString = std.testing.expectEqualStrings; pub const expectEqualSlices = std.testing.expectEqualSlices; const App = @import("app.zig").App; +const Allocator = std.mem.Allocator; // Merged std.testing.expectEqual and std.testing.expectString // can be useful when testing fields of an anytype an you don't know @@ -217,12 +218,135 @@ pub const Document = struct { pub fn querySelectorAll(self: *Document, selector: []const u8) ![]const *parser.Node { const css = @import("dom/css.zig"); - const node_list = try css.querySelectorAll(self.arena.allocator(), parser.documentToNode(self.doc), selector); + const node_list = try css.querySelectorAll(self.arena.allocator(), self.asNode(), selector); return node_list.nodes.items; } pub fn querySelector(self: *Document, selector: []const u8) !?*parser.Node { const css = @import("dom/css.zig"); - return css.querySelector(self.arena.allocator(), parser.documentToNode(self.doc), selector); + return css.querySelector(self.arena.allocator(), self.asNode(), selector); + } + + pub fn asNode(self: *const Document) *parser.Node { + return parser.documentToNode(self.doc); } }; + +pub fn expectJson(a: anytype, b: anytype) !void { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const aa = arena.allocator(); + + const a_value = try convertToJson(aa, a); + const b_value = try convertToJson(aa, b); + + errdefer { + const a_json = std.json.stringifyAlloc(aa, a_value, .{ .whitespace = .indent_2 }) catch unreachable; + const b_json = std.json.stringifyAlloc(aa, b_value, .{ .whitespace = .indent_2 }) catch unreachable; + std.debug.print("== Expected ==\n{s}\n\n== Actual ==\n{s}", .{ a_json, b_json }); + } + + try expectJsonValue(a_value, b_value); +} + +pub fn isEqualJson(a: anytype, b: anytype) !bool { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const aa = arena.allocator(); + const a_value = try convertToJson(aa, a); + const b_value = try convertToJson(aa, b); + return isJsonValue(a_value, b_value); +} + +fn convertToJson(arena: Allocator, value: anytype) !std.json.Value { + const T = @TypeOf(value); + if (T == std.json.Value) { + return value; + } + + var str: []const u8 = undefined; + if (T == []u8 or T == []const u8 or comptime isStringArray(T)) { + str = value; + } else { + str = try std.json.stringifyAlloc(arena, value, .{}); + } + return std.json.parseFromSliceLeaky(std.json.Value, arena, str, .{}); +} + +fn expectJsonValue(a: std.json.Value, b: std.json.Value) !void { + try expectEqual(@tagName(a), @tagName(b)); + + // at this point, we know that if a is an int, b must also be an int + switch (a) { + .null => return, + .bool => try expectEqual(a.bool, b.bool), + .integer => try expectEqual(a.integer, b.integer), + .float => try expectEqual(a.float, b.float), + .number_string => try expectEqual(a.number_string, b.number_string), + .string => try expectEqual(a.string, b.string), + .array => { + const a_len = a.array.items.len; + const b_len = b.array.items.len; + try expectEqual(a_len, b_len); + for (a.array.items, b.array.items) |a_item, b_item| { + try expectJsonValue(a_item, b_item); + } + }, + .object => { + var it = a.object.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (b.object.get(key)) |b_item| { + try expectJsonValue(entry.value_ptr.*, b_item); + } else { + return error.MissingKey; + } + } + }, + } +} + +fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { + if (std.mem.eql(u8, @tagName(a), @tagName(b)) == false) { + return false; + } + + // at this point, we know that if a is an int, b must also be an int + switch (a) { + .null => return true, + .bool => return a.bool == b.bool, + .integer => return a.integer == b.integer, + .float => return a.float == b.float, + .number_string => return std.mem.eql(u8, a.number_string, b.number_string), + .string => return std.mem.eql(u8, a.string, b.string), + .array => { + const a_len = a.array.items.len; + const b_len = b.array.items.len; + if (a_len != b_len) { + return false; + } + for (a.array.items, b.array.items) |a_item, b_item| { + if (isJsonValue(a_item, b_item) == false) { + return false; + } + } + return true; + }, + .object => { + var it = a.object.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (b.object.get(key)) |b_item| { + if (isJsonValue(entry.value_ptr.*, b_item) == false) { + return false; + } + } else { + return false; + } + } + return true; + }, + } +}