diff --git a/README.md b/README.md index 1ec482ba..f1817bf3 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,15 @@ Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
-[ +[ ](https://github.com/lightpanda-io/demo)   -[ +[ ](https://github.com/lightpanda-io/demo)
-_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance. -See [benchmark details](https://github.com/lightpanda-io/demo)._ +_chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance. +See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._ Lightpanda is the open-source browser made for headless usage: diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig index 2e3f25a4..a48e00a7 100644 --- a/src/ArenaPool.zig +++ b/src/ArenaPool.zig @@ -155,6 +155,11 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void { _ = arena.reset(.{ .retain_with_limit = retain }); } +pub fn resetRetain(_: *const ArenaPool, allocator: Allocator) void { + const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr)); + _ = arena.reset(.retain_capacity); +} + const testing = std.testing; test "arena pool - basic acquire and use" { diff --git a/src/Config.zig b/src/Config.zig index 0bec5b7a..e95d3afc 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -163,6 +163,20 @@ pub fn cdpTimeout(self: *const Config) usize { }; } +pub fn port(self: *const Config) u16 { + return switch (self.mode) { + .serve => |opts| opts.port, + else => unreachable, + }; +} + +pub fn advertiseHost(self: *const Config) []const u8 { + return switch (self.mode) { + .serve => |opts| opts.advertise_host orelse opts.host, + else => unreachable, + }; +} + pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ @@ -199,6 +213,7 @@ pub const Mode = union(RunMode) { pub const Serve = struct { host: []const u8 = "127.0.0.1", port: u16 = 9222, + advertise_host: ?[]const u8 = null, timeout: u31 = 10, cdp_max_connections: u16 = 16, cdp_max_pending_connections: u16 = 128, @@ -221,7 +236,7 @@ pub const WaitUntil = enum { load, domcontentloaded, networkidle, - fixed, + done, }; pub const Fetch = struct { @@ -400,8 +415,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Defaults to 5000. \\ \\--wait_until Wait until the specified event. - \\ Supported events: load, domcontentloaded, networkidle, fixed. - \\ Defaults to 'load'. + \\ Supported events: load, domcontentloaded, networkidle, done. + \\ Defaults to 'done'. \\ ++ common_options ++ \\ @@ -416,6 +431,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\--port Port of the CDP server \\ Defaults to 9222 \\ + \\--advertise_host + \\ The host to advertise, e.g. in the /json/version response. + \\ Useful, for example, when --host is 0.0.0.0. + \\ Defaults to --host value + \\ \\--timeout Inactivity timeout in seconds before disconnecting clients \\ Defaults to 10 (seconds). Limited to 604800 (1 week). \\ @@ -557,6 +577,15 @@ fn parseServeArgs( continue; } + if (std.mem.eql(u8, "--advertise_host", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = "--advertise_host" }); + return error.InvalidArgument; + }; + serve.advertise_host = try allocator.dupe(u8, str); + continue; + } + if (std.mem.eql(u8, "--timeout", opt)) { const str = args.next() orelse { log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index af8720e9..6366890f 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -47,7 +47,15 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { + var visibility_cache: Element.VisibilityCache = .empty; + var pointer_events_cache: Element.PointerEventsCache = .empty; + var ctx: WalkContext = .{ + .xpath_buffer = &xpath_buffer, + .listener_targets = listener_targets, + .visibility_cache = &visibility_cache, + .pointer_events_cache = &pointer_events_cache, + }; + self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -60,7 +68,15 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v log.err(.app, "listener map failed", .{ .err = err }); return error.WriteFailed; }; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { + var visibility_cache: Element.VisibilityCache = .empty; + var pointer_events_cache: Element.PointerEventsCache = .empty; + var ctx: WalkContext = .{ + .xpath_buffer = &xpath_buffer, + .listener_targets = listener_targets, + .visibility_cache = &visibility_cache, + .pointer_events_cache = &pointer_events_cache, + }; + self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -84,7 +100,22 @@ const NodeData = struct { node_name: []const u8, }; -fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void { +const WalkContext = struct { + xpath_buffer: *std.ArrayList(u8), + listener_targets: interactive.ListenerTargetMap, + visibility_cache: *Element.VisibilityCache, + pointer_events_cache: *Element.PointerEventsCache, +}; + +fn walk( + self: @This(), + ctx: *WalkContext, + node: *Node, + parent_name: ?[]const u8, + visitor: anytype, + index: usize, + current_depth: u32, +) !void { if (current_depth > self.max_depth) return; // 1. Skip non-content nodes @@ -96,7 +127,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (tag == .datalist or tag == .option or tag == .optgroup) return; // Check visibility using the engine's checkVisibility which handles CSS display: none - if (!el.checkVisibility(self.page)) { + if (!el.checkVisibilityCached(ctx.visibility_cache, self.page)) { return; } @@ -137,7 +168,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } if (el.is(Element.Html)) |html_el| { - if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) { + if (interactive.classifyInteractivity(self.page, el, html_el, ctx.listener_targets, ctx.pointer_events_cache) != null) { is_interactive = true; } } @@ -145,9 +176,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam node_name = "root"; } - const initial_xpath_len = xpath_buffer.items.len; - try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); - const xpath = xpath_buffer.items; + const initial_xpath_len = ctx.xpath_buffer.items.len; + try appendXPathSegment(node, ctx.xpath_buffer.writer(self.arena), index); + const xpath = ctx.xpath_buffer.items; var name = try axn.getName(self.page, self.arena); @@ -165,18 +196,6 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam name = null; } - var data = NodeData{ - .id = cdp_node.id, - .axn = axn, - .role = role, - .name = name, - .value = value, - .options = options, - .xpath = xpath, - .is_interactive = is_interactive, - .node_name = node_name, - }; - var should_visit = true; if (self.interactive_only) { var keep = false; @@ -208,6 +227,18 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam var did_visit = false; var should_walk_children = true; + var data: NodeData = .{ + .id = cdp_node.id, + .axn = axn, + .role = role, + .name = name, + .value = value, + .options = options, + .xpath = xpath, + .is_interactive = is_interactive, + .node_name = node_name, + }; + if (should_visit) { should_walk_children = try visitor.visit(node, &data); did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures @@ -233,7 +264,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam } gop.value_ptr.* += 1; - try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1); + try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1); } } @@ -241,11 +272,11 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam try visitor.leave(); } - xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); + ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); } fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData { - var options = std.ArrayListUnmanaged(OptionData){}; + var options: std.ArrayList(OptionData) = .empty; var it = node.childrenIterator(); while (it.next()) |child| { if (child.is(Element)) |el| { diff --git a/src/Server.zig b/src/Server.zig index d172f6dd..d9c8a455 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -45,7 +45,7 @@ clients_pool: std.heap.MemoryPool(Client), pub fn init(app: *App, address: net.Address) !*Server { const allocator = app.allocator; - const json_version_response = try buildJSONVersionResponse(allocator, address); + const json_version_response = try buildJSONVersionResponse(app); errdefer allocator.free(json_version_response); const self = try allocator.create(Server); @@ -302,15 +302,8 @@ pub const Client = struct { var ms_remaining = self.ws.timeout_ms; while (true) { - switch (cdp.pageWait(ms_remaining)) { - .cdp_socket => { - if (self.readSocket() == false) { - return; - } - last_message = milliTimestamp(.monotonic); - ms_remaining = self.ws.timeout_ms; - }, - .no_page => { + const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) { + error.NoPage => { const status = http.tick(ms_remaining) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; @@ -324,6 +317,18 @@ pub const Client = struct { } last_message = milliTimestamp(.monotonic); ms_remaining = self.ws.timeout_ms; + continue; + }, + else => return wait_err, + }; + + switch (result) { + .cdp_socket => { + if (self.readSocket() == false) { + return; + } + last_message = milliTimestamp(.monotonic); + ms_remaining = self.ws.timeout_ms; }, .done => { const now = milliTimestamp(.monotonic); @@ -484,11 +489,17 @@ pub const Client = struct { // -------- fn buildJSONVersionResponse( - allocator: Allocator, - address: net.Address, + app: *const App, ) ![]const u8 { - const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}"; - const body_len = std.fmt.count(body_format, .{address}); + const port = app.config.port(); + const host = app.config.advertiseHost(); + if (std.mem.eql(u8, host, "0.0.0.0")) { + log.info(.cdp, "unreachable advertised host", .{ + .message = "when --host is set to 0.0.0.0 consider setting --advertise_host to a reachable address", + }); + } + const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"}}"; + const body_len = std.fmt.count(body_format, .{ host, port }); // We send a Connection: Close (and actually close the connection) // because chromedp (Go driver) sends a request to /json/version and then @@ -502,23 +513,22 @@ fn buildJSONVersionResponse( "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ body_format; - return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address }); + return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port }); } pub const timestamp = @import("datetime.zig").timestamp; pub const milliTimestamp = @import("datetime.zig").milliTimestamp; -const testing = std.testing; +const testing = @import("testing.zig"); test "server: buildJSONVersionResponse" { - const address = try net.Address.parseIp4("127.0.0.1", 9001); - const res = try buildJSONVersionResponse(testing.allocator, address); - defer testing.allocator.free(res); + const res = try buildJSONVersionResponse(testing.test_app); + defer testing.test_app.allocator.free(res); - try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++ + try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ "Content-Length: 48\r\n" ++ "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res); + "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}", res); } test "Client: http invalid request" { @@ -526,7 +536,7 @@ test "Client: http invalid request" { defer c.deinit(); const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n"); - try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++ + try testing.expectEqual("HTTP/1.1 413 \r\n" ++ "Connection: Close\r\n" ++ "Content-Length: 17\r\n\r\n" ++ "Request too large", res); @@ -595,7 +605,7 @@ test "Client: http valid handshake" { "Custom: Header-Value\r\n\r\n"; const res = try c.httpRequest(request); - try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ + try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ "Upgrade: websocket\r\n" ++ "Connection: upgrade\r\n" ++ "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); @@ -723,7 +733,7 @@ test "server: 404" { defer c.deinit(); const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); - try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++ + try testing.expectEqual("HTTP/1.1 404 \r\n" ++ "Connection: Close\r\n" ++ "Content-Length: 9\r\n\r\n" ++ "Not found", res); @@ -735,7 +745,7 @@ test "server: get /json/version" { "Content-Length: 48\r\n" ++ "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ - "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}"; + "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}"; { // twice on the same connection @@ -743,7 +753,7 @@ test "server: get /json/version" { defer c.deinit(); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); - try testing.expectEqualStrings(expected_response, res1); + try testing.expectEqual(expected_response, res1); } { @@ -752,7 +762,7 @@ test "server: get /json/version" { defer c.deinit(); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); - try testing.expectEqualStrings(expected_response, res1); + try testing.expectEqual(expected_response, res1); } } @@ -770,7 +780,7 @@ fn assertHTTPError( .{ expected_status, expected_body.len, expected_body }, ); - try testing.expectEqualStrings(expected_response, res); + try testing.expectEqual(expected_response, res); } fn assertWebSocketError(close_code: u16, input: []const u8) !void { @@ -914,7 +924,7 @@ const TestClient = struct { "Custom: Header-Value\r\n\r\n"; const res = try self.httpRequest(request); - try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ + try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ "Upgrade: websocket\r\n" ++ "Connection: upgrade\r\n" ++ "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index e23d48a2..eca97a2d 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -386,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool { return self.content_type == .text_html; } +pub fn isText(mime: *const Mime) bool { + return switch (mime.content_type) { + .text_xml, .text_html, .text_javascript, .text_plain, .text_css => true, + .application_json => true, + else => false, + }; +} + // we expect value to be lowercase fn parseContentType(value: []const u8) !struct { ContentType, usize } { const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; diff --git a/src/browser/Page.zig b/src/browser/Page.zig index eef70bbc..bfff78ce 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -35,6 +35,7 @@ const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); const EventManager = @import("EventManager.zig"); const ScriptManager = @import("ScriptManager.zig"); +const StyleManager = @import("StyleManager.zig"); const Parser = @import("parser/Parser.zig"); @@ -144,6 +145,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. _to_load: std.ArrayList(*Element.Html) = .{}, +_style_manager: StyleManager, _script_manager: ScriptManager, // List of active live ranges (for mutation updates per DOM spec) @@ -269,6 +271,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._factory = factory, ._pending_loads = 1, // always 1 for the ScriptManager ._type = if (parent == null) .root else .frame, + ._style_manager = undefined, ._script_manager = undefined, ._event_manager = EventManager.init(session.page_arena, self), }; @@ -298,6 +301,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void ._visual_viewport = visual_viewport, }); + self._style_manager = try StyleManager.init(self); + errdefer self._style_manager.deinit(); + const browser = session.browser; self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); @@ -360,6 +366,7 @@ pub fn deinit(self: *Page, abort_http: bool) void { } self._script_manager.deinit(); + self._style_manager.deinit(); session.releaseArena(self.call_arena); } @@ -441,6 +448,12 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi if (is_about_blank or is_blob) { self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url); + // even though this might be the same _data_ as `default_location`, we + // have to do this to make sure window.location is at a unique _address_. + // If we don't do this, mulitple window._location will have the same + // address and thus be mapped to the same v8::Object in the identity map. + self.window._location = try Location.init(self.url, self); + if (is_blob) { // strip out blob: self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]); @@ -587,13 +600,34 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp // page that it's acting on. fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void { const resolved_url, const is_about_blank = blk: { + if (URL.isCompleteHTTPUrl(request_url)) { + break :blk .{ try arena.dupeZ(u8, request_url), false }; + } + if (std.mem.eql(u8, request_url, "about:blank")) { // navigate will handle this special case break :blk .{ "about:blank", true }; } + + // request_url isn't a "complete" URL, so it has to be resolved with the + // originator's base. Unless, originator's base is "about:blank", in which + // case we have to walk up the parents and find a real base. + const page_base = base_blk: { + var maybe_not_blank_page = originator; + while (true) { + const maybe_base = maybe_not_blank_page.base(); + if (std.mem.eql(u8, maybe_base, "about:blank") == false) { + break :base_blk maybe_base; + } + // The orelse here is probably an invalid case, but there isn't + // anything we can do about it. It should never happen? + maybe_not_blank_page = maybe_not_blank_page.parent orelse break :base_blk ""; + } + }; + const u = try URL.resolve( arena, - originator.base(), + page_base, request_url, .{ .always_dupe = true, .encode = true }, ); @@ -2561,6 +2595,17 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts } Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); + + // If a + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index baa09918..8874bbfe 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -29,10 +29,12 @@ testing.expectEqual('', img.src); testing.expectEqual('', img.alt); + testing.expectEqual('', img.currentSrc); img.src = 'test.png'; // src property returns resolved absolute URL testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src); + testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.currentSrc); // getAttribute returns the raw attribute value testing.expectEqual('test.png', img.getAttribute('src')); diff --git a/src/browser/tests/element/html/media.html b/src/browser/tests/element/html/media.html index 15cd9b33..e97a0d67 100644 --- a/src/browser/tests/element/html/media.html +++ b/src/browser/tests/element/html/media.html @@ -236,9 +236,11 @@ { const audio = document.createElement('audio'); testing.expectEqual('', audio.src); + testing.expectEqual('', audio.currentSrc); audio.src = 'test.mp3'; testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src); + testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.currentSrc); } diff --git a/src/browser/tests/element/html/style.html b/src/browser/tests/element/html/style.html index 8abbb229..ee393846 100644 --- a/src/browser/tests/element/html/style.html +++ b/src/browser/tests/element/html/style.html @@ -131,3 +131,17 @@ testing.eventually(() => testing.expectEqual(true, result)); } + + diff --git a/src/browser/tests/element/replace_children.html b/src/browser/tests/element/replace_children.html new file mode 100644 index 00000000..fdadcb77 --- /dev/null +++ b/src/browser/tests/element/replace_children.html @@ -0,0 +1,139 @@ + + + + + element.replaceChildren Tests + + +
Original content
+ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index f31e0d31..096afc78 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -140,8 +140,19 @@ }); + + diff --git a/src/browser/tests/mcp_wait_for_selector.html b/src/browser/tests/mcp_wait_for_selector.html index 111aadaf..01706885 100644 --- a/src/browser/tests/mcp_wait_for_selector.html +++ b/src/browser/tests/mcp_wait_for_selector.html @@ -8,7 +8,7 @@ el.id = "delayed"; el.textContent = "Appeared after delay"; document.body.appendChild(el); - }, 200); + }, 20); diff --git a/src/browser/tests/node/append_child.html b/src/browser/tests/node/append_child.html index 0736fa03..151815b9 100644 --- a/src/browser/tests/node/append_child.html +++ b/src/browser/tests/node/append_child.html @@ -28,3 +28,40 @@ d1.appendChild(p2); assertChildren(['p1', 'p2'], d1); + +
+ diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index f8074422..3b1d2add 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -591,6 +591,35 @@ testing.expectEqual('/new/path', url.pathname); } +// Pathname setter must percent-encode spaces and special characters +{ + const url = new URL('http://a/'); + url.pathname = 'c d'; + testing.expectEqual('http://a/c%20d', url.href); +} + +{ + const url = new URL('https://example.com/path'); + url.pathname = '/path with spaces/file name'; + testing.expectEqual('https://example.com/path%20with%20spaces/file%20name', url.href); + testing.expectEqual('/path%20with%20spaces/file%20name', url.pathname); +} + +// Already-encoded sequences should not be double-encoded +{ + const url = new URL('https://example.com/path'); + url.pathname = '/already%20encoded'; + testing.expectEqual('https://example.com/already%20encoded', url.href); +} + +// This is the exact check the URL polyfill uses to decide if native URL is sufficient +{ + const url = new URL('b', 'http://a'); + url.pathname = 'c d'; + testing.expectEqual('http://a/c%20d', url.href); + testing.expectEqual(true, !!url.searchParams); +} + { const url = new URL('https://example.com/path'); url.search = '?a=b'; @@ -656,6 +685,20 @@ testing.expectEqual('', url.hash); } +{ + const url = new URL('https://example.com/path'); + url.hash = '#a b'; + testing.expectEqual('https://example.com/path#a%20b', url.href); + testing.expectEqual('#a%20b', url.hash); +} + +{ + const url = new URL('https://example.com/path'); + url.hash = 'a b'; + testing.expectEqual('https://example.com/path#a%20b', url.href); + testing.expectEqual('#a%20b', url.hash); +} + { const url = new URL('https://example.com/path?a=b'); url.search = ''; @@ -673,6 +716,20 @@ testing.expectEqual(null, url.searchParams.get('a')); } +{ + const url = new URL('https://example.com/path?a=b'); + const sp = url.searchParams; + testing.expectEqual('b', sp.get('a')); + url.search = 'c=d b'; + + testing.expectEqual('d b', url.searchParams.get('c')); + testing.expectEqual(null, url.searchParams.get('a')); + + url.search = 'c d=d b'; + testing.expectEqual('d b', url.searchParams.get('c d')); + testing.expectEqual(null, url.searchParams.get('c')); +} + { const url = new URL('https://example.com/path?a=b'); const sp = url.searchParams; @@ -798,3 +855,19 @@ testing.expectEqual(true, url2.startsWith('blob:')); } + + diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 6d2244fb..d61be42c 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -548,35 +548,8 @@ pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !vo } pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void { - try validateDocumentNodes(self, nodes, true); - - page.domChanged(); - const parent = self.asNode(); - - // Remove all existing children - var it = parent.childrenIterator(); - while (it.next()) |child| { - page.removeNode(parent, child, .{ .will_be_reconnected = false }); - } - - // Append new children - const parent_is_connected = parent.isConnected(); - for (nodes) |node_or_text| { - const child = try node_or_text.toNode(page); - - // DocumentFragments are special - append all their children - if (child.is(Node.DocumentFragment)) |_| { - try page.appendAllChildren(child, parent); - continue; - } - - var child_connected = false; - if (child._parent) |previous_parent| { - child_connected = child.isConnected(); - page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); - } - try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); - } + try validateDocumentNodes(self, nodes, false); + return self.asNode().replaceChildren(nodes, page); } pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element { @@ -591,7 +564,7 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element while (stack.items.len > 0) { const node = stack.pop() orelse break; if (node.is(Element)) |element| { - if (element.checkVisibility(page)) { + if (element.checkVisibilityCached(null, page)) { const rect = element.getBoundingClientRectForVisible(page); if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) { topmost = element; @@ -717,9 +690,16 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void { } // Determine insertion point: - // - If _write_insertion_point is set, continue from there (subsequent write) - // - Otherwise, start after the script (first write) - var insert_after: ?*Node = self._write_insertion_point orelse script.asNode(); + // - If _write_insertion_point is set and still parented correctly, continue from there + // - Otherwise, start after the script (first write, or previous insertion point was removed) + var insert_after: ?*Node = blk: { + if (self._write_insertion_point) |wip| { + if (wip._parent == parent) { + break :blk wip; + } + } + break :blk script.asNode(); + }; for (children_to_insert.items) |child| { // Clear parent pointer (child is currently parented to fragment/HTML wrapper) @@ -896,6 +876,10 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti if (has_doctype) { return error.HierarchyError; } + if (has_element) { + // Doctype cannot be inserted if document already has an element + return error.HierarchyError; + } has_doctype = true; }, .cdata => |cd| switch (cd._type) { @@ -918,6 +902,10 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti if (has_doctype) { return error.HierarchyError; } + if (has_element) { + // Doctype cannot be inserted if document already has an element + return error.HierarchyError; + } has_doctype = true; }, .cdata => |cd| switch (cd._type) { diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index 004ee916..cab73cb4 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -143,25 +143,7 @@ pub fn prepend(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *P } pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void { - page.domChanged(); - var parent = self.asNode(); - - var it = parent.childrenIterator(); - while (it.next()) |child| { - page.removeNode(parent, child, .{ .will_be_reconnected = false }); - } - - const parent_is_connected = parent.isConnected(); - for (nodes) |node_or_text| { - const child = try node_or_text.toNode(page); - - // If the new children has already a parent, remove from it. - if (child._parent) |p| { - page.removeNode(p, child, .{ .will_be_reconnected = true }); - } - - try page.appendNode(parent, child, .{ .child_already_connected = parent_is_connected }); - } + return self.asNode().replaceChildren(nodes, page); } pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 2b5db1f7..e9f77e45 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -24,6 +24,7 @@ const String = @import("../../string.zig").String; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const StyleManager = @import("../StyleManager.zig"); const reflect = @import("../reflect.zig"); const Node = @import("Node.zig"); @@ -784,24 +785,7 @@ pub fn getDataset(self: *Element, page: *Page) !*DOMStringMap { } pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { - page.domChanged(); - var parent = self.asNode(); - - var it = parent.childrenIterator(); - while (it.next()) |child| { - page.removeNode(parent, child, .{ .will_be_reconnected = false }); - } - - const parent_is_connected = parent.isConnected(); - for (nodes) |node_or_text| { - var child_connected = false; - const child = try node_or_text.toNode(page); - if (child._parent) |previous_parent| { - child_connected = child.isConnected(); - page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); - } - try page.appendNode(parent, child, .{ .child_already_connected = child_connected }); - } + return self.asNode().replaceChildren(nodes, page); } pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { @@ -1041,20 +1025,32 @@ pub fn parentElement(self: *Element) ?*Element { return self._proto.parentElement(); } -pub fn checkVisibility(self: *Element, page: *Page) bool { - var current: ?*Element = self; +/// Cache for visibility checks - re-exported from StyleManager for convenience. +pub const VisibilityCache = StyleManager.VisibilityCache; - while (current) |el| { - if (el.getStyle(page)) |style| { - const display = style.asCSSStyleDeclaration().getPropertyValue("display", page); - if (std.mem.eql(u8, display, "none")) { - return false; - } - } - current = el.parentElement(); - } +/// Cache for pointer-events checks - re-exported from StyleManager for convenience. +pub const PointerEventsCache = StyleManager.PointerEventsCache; - return true; +pub fn hasPointerEventsNone(self: *Element, cache: ?*PointerEventsCache, page: *Page) bool { + return page._style_manager.hasPointerEventsNone(self, cache); +} + +pub fn checkVisibilityCached(self: *Element, cache: ?*VisibilityCache, page: *Page) bool { + return !page._style_manager.isHidden(self, cache, .{}); +} + +const CheckVisibilityOpts = struct { + checkOpacity: bool = false, + opacityProperty: bool = false, + checkVisibilityCSS: bool = false, + visibilityProperty: bool = false, +}; +pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, page: *Page) bool { + const opts = opts_ orelse CheckVisibilityOpts{}; + return !page._style_manager.isHidden(self, null, .{ + .check_opacity = opts.checkOpacity or opts.opacityProperty, + .check_visibility = opts.visibilityProperty or opts.checkVisibilityCSS, + }); } fn getElementDimensions(self: *Element, page: *Page) struct { width: f64, height: f64 } { @@ -1091,7 +1087,7 @@ fn getElementDimensions(self: *Element, page: *Page) struct { width: f64, height } pub fn getClientWidth(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1099,7 +1095,7 @@ pub fn getClientWidth(self: *Element, page: *Page) f64 { } pub fn getClientHeight(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1107,7 +1103,7 @@ pub fn getClientHeight(self: *Element, page: *Page) f64 { } pub fn getBoundingClientRect(self: *Element, page: *Page) DOMRect { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return .{ ._x = 0.0, ._y = 0.0, @@ -1137,7 +1133,7 @@ pub fn getBoundingClientRectForVisible(self: *Element, page: *Page) DOMRect { } pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return &.{}; } const rects = try page.call_arena.alloc(DOMRect, 1); @@ -1182,7 +1178,7 @@ pub fn getScrollWidth(self: *Element, page: *Page) f64 { } pub fn getOffsetHeight(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1190,7 +1186,7 @@ pub fn getOffsetHeight(self: *Element, page: *Page) f64 { } pub fn getOffsetWidth(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } const dims = self.getElementDimensions(page); @@ -1198,14 +1194,14 @@ pub fn getOffsetWidth(self: *Element, page: *Page) f64 { } pub fn getOffsetTop(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } return calculateDocumentPosition(self.asNode()); } pub fn getOffsetLeft(self: *Element, page: *Page) f64 { - if (!self.checkVisibility(page)) { + if (!self.checkVisibilityCached(null, page)) { return 0.0; } return calculateSiblingPosition(self.asNode()); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 2eca4047..0e7c2ffe 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1005,6 +1005,49 @@ pub fn getElementsByClassName(self: *Node, class_name: []const u8, page: *Page) return collections.NodeLive(.class_name).init(self, class_names.items, page); } +/// Shared implementation of replaceChildren for Element, Document, and DocumentFragment. +/// Validates all nodes, removes existing children, then appends new children. +pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, page: *Page) !void { + // First pass: validate all nodes and collect them + // We need to collect because DocumentFragments contribute their children, not themselves + var children_to_add: std.ArrayList(*Node) = .empty; + + for (nodes) |node_or_text| { + const child = try node_or_text.toNode(page); + + // DocumentFragments contribute their children, not themselves + if (child.is(DocumentFragment)) |frag| { + var frag_it = frag.asNode().childrenIterator(); + while (frag_it.next()) |frag_child| { + try validateNodeInsertion(self, frag_child); + try children_to_add.append(page.call_arena, frag_child); + } + } else { + try validateNodeInsertion(self, child); + try children_to_add.append(page.call_arena, child); + } + } + + page.domChanged(); + + // Remove all existing children + var it = self.childrenIterator(); + while (it.next()) |child| { + page.removeNode(self, child, .{ .will_be_reconnected = false }); + } + + // Append new children + const parent_is_connected = self.isConnected(); + for (children_to_add.items) |child| { + var child_connected = false; + if (child._parent) |previous_parent| { + child_connected = child.isConnected(); + page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected }); + } + try page.appendNode(self, child, .{ .child_already_connected = child_connected }); + } +} + // Writes a JSON representation of the node and its children pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void { // stupid json api requires this to be const, diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index e9263319..cb8e5d11 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -37,6 +37,14 @@ pub const resolve = @import("../URL.zig").resolve; pub const eqlDocument = @import("../URL.zig").eqlDocument; pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { + const arena = page.arena; + + if (std.mem.eql(u8, url, "about:blank")) { + return page._factory.create(URL{ + ._raw = "about:blank", + ._arena = arena, + }); + } const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url); const base = if (base_) |b| blk: { @@ -53,7 +61,6 @@ pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { return error.TypeError; } else page.url; - const arena = page.arena; const raw = try resolve(arena, base, url, .{ .always_dupe = true }); return page._factory.create(URL{ diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index bbed1010..b89e7383 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -285,23 +285,23 @@ pub fn queueMicrotask(_: *Window, cb: js.Function, page: *Page) void { } pub fn clearTimeout(self: *Window, id: u32) void { - var sc = self._timers.get(id) orelse return; - sc.removed = true; + var sc = self._timers.fetchRemove(id) orelse return; + sc.value.removed = true; } pub fn clearInterval(self: *Window, id: u32) void { - var sc = self._timers.get(id) orelse return; - sc.removed = true; + var sc = self._timers.fetchRemove(id) orelse return; + sc.value.removed = true; } pub fn clearImmediate(self: *Window, id: u32) void { - var sc = self._timers.get(id) orelse return; - sc.removed = true; + var sc = self._timers.fetchRemove(id) orelse return; + sc.value.removed = true; } pub fn cancelAnimationFrame(self: *Window, id: u32) void { - var sc = self._timers.get(id) orelse return; - sc.removed = true; + var sc = self._timers.fetchRemove(id) orelse return; + sc.value.removed = true; } const RequestIdleCallbackOpts = struct { @@ -319,8 +319,8 @@ pub fn requestIdleCallback(self: *Window, cb: js.Function.Temp, opts_: ?RequestI } pub fn cancelIdleCallback(self: *Window, id: u32) void { - var sc = self._timers.get(id) orelse return; - sc.removed = true; + var sc = self._timers.fetchRemove(id) orelse return; + sc.value.removed = true; } pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { @@ -704,7 +704,6 @@ const ScheduleCallback = struct { const window = page.window; if (self.removed) { - _ = window._timers.remove(self.timer_id); self.deinit(); return null; } diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index a55420e7..4b262761 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -62,7 +62,7 @@ const Filters = union(Mode) { selected_options, links, anchors, - form: *Form, + form: struct { form: *Form, form_id: ?[]const u8 }, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -304,9 +304,13 @@ pub fn NodeLive(comptime mode: Mode) type { return false; } - if (el.getAttributeSafe(comptime .wrap("form"))) |form_attr| { - const form_id = self._filter.asElement().getAttributeSafe(comptime .wrap("id")) orelse return false; - return std.mem.eql(u8, form_attr, form_id); + if (self._filter.form_id) |form_id| { + if (el.getAttributeSafe(comptime .wrap("form"))) |element_form_attr| { + return std.mem.eql(u8, element_form_attr, form_id); + } + } else if (el.hasAttributeSafe(comptime .wrap("form"))) { + // Form has no id, element explicitly references another form + return false; } // No form attribute - match if descendant of our form @@ -324,7 +328,7 @@ pub fn NodeLive(comptime mode: Mode) type { // This trades one O(form_size) reverse walk for N O(depth) ancestor // checks, where N = number of controls. For forms with many nested // controls, this could be significantly faster. - return self._filter.asNode().contains(node); + return self._filter.form.asNode().contains(node); }, } } diff --git a/src/browser/webapi/css/CSSRule.zig b/src/browser/webapi/css/CSSRule.zig index dcf41db9..a96fe82b 100644 --- a/src/browser/webapi/css/CSSRule.zig +++ b/src/browser/webapi/css/CSSRule.zig @@ -2,29 +2,42 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const CSSStyleRule = @import("CSSStyleRule.zig"); + const CSSRule = @This(); -pub const Type = enum(u16) { - style = 1, - charset = 2, - import = 3, - media = 4, - font_face = 5, - page = 6, - keyframes = 7, - keyframe = 8, - margin = 9, - namespace = 10, - counter_style = 11, - supports = 12, - document = 13, - font_feature_values = 14, - viewport = 15, - region_style = 16, +pub const Type = union(enum) { + style: *CSSStyleRule, + charset: void, + import: void, + media: void, + font_face: void, + page: void, + keyframes: void, + keyframe: void, + margin: void, + namespace: void, + counter_style: void, + supports: void, + document: void, + font_feature_values: void, + viewport: void, + region_style: void, }; _type: Type, +pub fn as(self: *CSSRule, comptime T: type) *T { + return self.is(T).?; +} + +pub fn is(self: *CSSRule, comptime T: type) ?*T { + switch (self._type) { + .style => |r| return if (T == CSSStyleRule) r else null, + else => return null, + } +} + pub fn init(rule_type: Type, page: *Page) !*CSSRule { return page._factory.create(CSSRule{ ._type = rule_type, @@ -32,23 +45,14 @@ pub fn init(rule_type: Type, page: *Page) !*CSSRule { } pub fn getType(self: *const CSSRule) u16 { - return @intFromEnum(self._type); + return @as(u16, @intFromEnum(std.meta.activeTag(self._type))) + 1; } -pub fn getCssText(self: *const CSSRule, page: *Page) []const u8 { - _ = self; - _ = page; +pub fn getCssText(_: *const CSSRule, _: *Page) []const u8 { return ""; } -pub fn setCssText(self: *CSSRule, text: []const u8, page: *Page) !void { - _ = self; - _ = text; - _ = page; -} - -pub fn getParentRule(self: *const CSSRule) ?*CSSRule { - _ = self; +pub fn getParentRule(_: *const CSSRule) ?*CSSRule { return null; } @@ -62,8 +66,8 @@ pub const JsApi = struct { pub const Meta = struct { pub const name = "CSSRule"; - pub var class_id: bridge.ClassId = undefined; pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; }; pub const STYLE_RULE = 1; @@ -84,7 +88,7 @@ pub const JsApi = struct { pub const REGION_STYLE_RULE = 16; pub const @"type" = bridge.accessor(CSSRule.getType, null, .{}); - pub const cssText = bridge.accessor(CSSRule.getCssText, CSSRule.setCssText, .{}); + pub const cssText = bridge.accessor(CSSRule.getCssText, null, .{}); pub const parentRule = bridge.accessor(CSSRule.getParentRule, null, .{}); pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{}); }; diff --git a/src/browser/webapi/css/CSSRuleList.zig b/src/browser/webapi/css/CSSRuleList.zig index 7e727a56..6c159aa2 100644 --- a/src/browser/webapi/css/CSSRuleList.zig +++ b/src/browser/webapi/css/CSSRuleList.zig @@ -5,21 +5,39 @@ const CSSRule = @import("CSSRule.zig"); const CSSRuleList = @This(); -_rules: []*CSSRule = &.{}, +_rules: std.ArrayList(*CSSRule) = .empty, pub fn init(page: *Page) !*CSSRuleList { return page._factory.create(CSSRuleList{}); } pub fn length(self: *const CSSRuleList) u32 { - return @intCast(self._rules.len); + return @intCast(self._rules.items.len); } pub fn item(self: *const CSSRuleList, index: usize) ?*CSSRule { - if (index >= self._rules.len) { + if (index >= self._rules.items.len) { return null; } - return self._rules[index]; + return self._rules.items[index]; +} + +pub fn insert(self: *CSSRuleList, index: u32, rule: *CSSRule, page: *Page) !void { + if (index > self._rules.items.len) { + return error.IndexSizeError; + } + try self._rules.insert(page.arena, index, rule); +} + +pub fn remove(self: *CSSRuleList, index: u32) !void { + if (index >= self._rules.items.len) { + return error.IndexSizeError; + } + _ = self._rules.orderedRemove(index); +} + +pub fn clear(self: *CSSRuleList) void { + self._rules.clearRetainingCapacity(); } pub const JsApi = struct { diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index ebaafbe0..8644a75d 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -77,10 +77,11 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 { pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse { + const wrapped = String.wrap(normalized); + const prop = self.findProperty(wrapped) orelse { // Only return default values for computed styles if (self._is_computed) { - return getDefaultPropertyValue(self, normalized); + return getDefaultPropertyValue(self, wrapped); } return ""; }; @@ -89,7 +90,7 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse return ""; + const prop = self.findProperty(.wrap(normalized)) orelse return ""; return if (prop._important) "important" else ""; } @@ -120,7 +121,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value); // Find existing property - if (self.findProperty(normalized)) |existing| { + if (self.findProperty(.wrap(normalized))) |existing| { existing._value = try String.init(page.arena, normalized_value, .{}); existing._important = important; return; @@ -144,7 +145,7 @@ pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, pag fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 { const normalized = normalizePropertyName(property_name, &page.buf); - const prop = self.findProperty(normalized) orelse return ""; + const prop = self.findProperty(.wrap(normalized)) orelse return ""; // the value might not be on the heap (it could be inlined in the small string // optimization), so we need to dupe it. @@ -172,16 +173,12 @@ pub fn setFloat(self: *CSSStyleDeclaration, value_: ?[]const u8, page: *Page) !v } pub fn getCssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 { - if (self._element == null) return ""; - var buf = std.Io.Writer.Allocating.init(page.call_arena); try self.format(&buf.writer); return buf.written(); } pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void { - if (self._element == null) return; - // Clear existing properties var node = self._properties.first; while (node) |n| { @@ -212,11 +209,11 @@ pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void { } } -fn findProperty(self: *const CSSStyleDeclaration, name: []const u8) ?*Property { +pub fn findProperty(self: *const CSSStyleDeclaration, name: String) ?*Property { var node = self._properties.first; while (node) |n| { const prop = Property.fromNodeLink(n); - if (prop._name.eqlSlice(name)) { + if (prop._name.eql(name)) { return prop; } node = n.next; @@ -259,8 +256,16 @@ fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: [] } // Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword - if (std.mem.indexOf(u8, value, "anchor-size(") != null) { - return try canonicalizeAnchorSize(arena, value); + if (std.mem.indexOf(u8, value, "anchor-size(")) |idx| { + return canonicalizeAnchorSize(arena, value, idx); + } + + // Canonicalize anchor() function: anchor name (dashed ident) comes before position keyword + // Note: indexOf finds first occurrence, so we check it's not part of "anchor-size(" + if (std.mem.indexOf(u8, value, "anchor(")) |idx| { + if (idx == 0 or value[idx - 1] != '-') { + return canonicalizeAnchor(arena, value, idx); + } } return value; @@ -268,9 +273,13 @@ fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: [] // Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword. // e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)" -fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { +fn canonicalizeAnchorSize(arena: Allocator, value: []const u8, start_index: usize) ![]const u8 { var buf = std.Io.Writer.Allocating.init(arena); - var i: usize = 0; + + // Copy everything before the first anchor-size( + try buf.writer.writeAll(value[0..start_index]); + + var i: usize = start_index; while (i < value.len) { // Look for "anchor-size(" @@ -279,7 +288,7 @@ fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { i += "anchor-size(".len; // Parse and canonicalize the arguments - i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer); + i = try canonicalizeAnchorFnArgs(value, i, &buf.writer, .anchor_size); } else { try buf.writer.writeByte(value[i]); i += 1; @@ -289,21 +298,24 @@ fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { return buf.written(); } -// Parse anchor-size arguments and write them in canonical order -fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize { +const AnchorFnKind = enum { anchor, anchor_size }; + +// Parse anchor/anchor-size arguments and write them in canonical order +fn canonicalizeAnchorFnArgs(value: []const u8, start: usize, writer: *std.Io.Writer, kind: AnchorFnKind) !usize { var i = start; var depth: usize = 1; // Skip leading whitespace while (i < value.len and value[i] == ' ') : (i += 1) {} - // Collect tokens before the comma or close paren - var first_token_start: ?usize = null; - var first_token_end: usize = 0; - var second_token_start: ?usize = null; - var second_token_end: usize = 0; - var comma_pos: ?usize = null; var token_count: usize = 0; + var comma_pos: ?usize = null; + + var first_token_end: usize = 0; + var first_token_start: ?usize = null; + + var second_token_end: usize = 0; + var second_token_start: ?usize = null; const args_start = i; var in_token = false; @@ -381,13 +393,16 @@ fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.W const first_token = value[first_start..first_token_end]; const second_token = value[second_start..second_token_end]; - // If second token is a dashed ident and first is a size keyword, swap them - if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) { + // If second token is a dashed ident, it should come first + // For anchor-size, also check that first token is a size keyword + const should_swap = std.mem.startsWith(u8, second_token, "--") and + (kind == .anchor or isAnchorSizeKeyword(first_token)); + + if (should_swap) { try writer.writeAll(second_token); try writer.writeByte(' '); try writer.writeAll(first_token); } else { - // Keep original order try writer.writeAll(first_token); try writer.writeByte(' '); try writer.writeAll(second_token); @@ -397,20 +412,26 @@ fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.W try writer.writeAll(value[fts..first_token_end]); } - // Handle comma and fallback value (may contain nested anchor-size) + // Handle comma and fallback value (may contain nested functions) if (comma_pos) |cp| { try writer.writeAll(", "); i = cp + 1; // Skip whitespace after comma while (i < value.len and value[i] == ' ') : (i += 1) {} - // Copy the fallback, recursively handling nested anchor-size + // Copy the fallback, recursively handling nested anchor/anchor-size while (i < value.len and depth > 0) { if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { try writer.writeAll("anchor-size("); i += "anchor-size(".len; depth += 1; - i = try canonicalizeAnchorSizeArgs(value, i, writer); + i = try canonicalizeAnchorFnArgs(value, i, writer, .anchor_size); + depth -= 1; + } else if (std.mem.startsWith(u8, value[i..], "anchor(")) { + try writer.writeAll("anchor("); + i += "anchor(".len; + depth += 1; + i = try canonicalizeAnchorFnArgs(value, i, writer, .anchor); depth -= 1; } else if (value[i] == '(') { depth += 1; @@ -446,6 +467,33 @@ fn isAnchorSizeKeyword(token: []const u8) bool { return keywords.has(token); } +// Canonicalize anchor() so that the dashed ident (anchor name) comes before the position keyword. +// e.g. "anchor(left --foo)" -> "anchor(--foo left)" +fn canonicalizeAnchor(arena: Allocator, value: []const u8, start_index: usize) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(arena); + + // Copy everything before the first anchor( + try buf.writer.writeAll(value[0..start_index]); + + var i: usize = start_index; + + while (i < value.len) { + // Look for "anchor(" but not "anchor-size(" + if (std.mem.startsWith(u8, value[i..], "anchor(") and (i == 0 or value[i - 1] != '-')) { + try buf.writer.writeAll("anchor("); + i += "anchor(".len; + + // Parse and canonicalize the arguments + i = try canonicalizeAnchorFnArgs(value, i, &buf.writer, .anchor); + } else { + try buf.writer.writeByte(value[i]); + i += 1; + } + } + + return buf.written(); +} + // Check if a value is "X X" (duplicate) and return just "X" fn collapseDuplicateValue(value: []const u8) ?[]const u8 { const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; @@ -621,26 +669,36 @@ fn isLengthProperty(name: []const u8) bool { return length_properties.has(name); } -fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { - if (std.mem.eql(u8, normalized_name, "visibility")) { - return "visible"; +fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []const u8 { + switch (name.len) { + 5 => { + if (name.eql(comptime .wrap("color"))) { + const element = self._element orelse return ""; + return getDefaultColor(element); + } + }, + 7 => { + if (name.eql(comptime .wrap("opacity"))) { + return "1"; + } + if (name.eql(comptime .wrap("display"))) { + const element = self._element orelse return ""; + return getDefaultDisplay(element); + } + }, + 10 => { + if (name.eql(comptime .wrap("visibility"))) { + return "visible"; + } + }, + 16 => { + if (name.eqlSlice("background-color")) { + // transparent + return "rgba(0, 0, 0, 0)"; + } + }, + else => {}, } - if (std.mem.eql(u8, normalized_name, "opacity")) { - return "1"; - } - if (std.mem.eql(u8, normalized_name, "display")) { - const element = self._element orelse return ""; - return getDefaultDisplay(element); - } - if (std.mem.eql(u8, normalized_name, "color")) { - const element = self._element orelse return ""; - return getDefaultColor(element); - } - if (std.mem.eql(u8, normalized_name, "background-color")) { - // transparent - return "rgba(0, 0, 0, 0)"; - } - return ""; } @@ -738,8 +796,7 @@ pub const JsApi = struct { pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{}); }; -const testing = @import("std").testing; - +const testing = @import("../../../testing.zig"); test "normalizePropertyValue: unitless zero to 0px" { const cases = .{ .{ "width", "0", "0px" }, @@ -760,16 +817,16 @@ test "normalizePropertyValue: unitless zero to 0px" { }; inline for (cases) |case| { const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); - try testing.expectEqualStrings(case[2], result); + try testing.expectEqual(case[2], result); } } test "normalizePropertyValue: first baseline to baseline" { const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline"); - try testing.expectEqualStrings("baseline", result); + try testing.expectEqual("baseline", result); const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline"); - try testing.expectEqualStrings("last baseline", result2); + try testing.expectEqual("last baseline", result2); } test "normalizePropertyValue: collapse duplicate two-value shorthands" { @@ -786,6 +843,27 @@ test "normalizePropertyValue: collapse duplicate two-value shorthands" { }; inline for (cases) |case| { const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); - try testing.expectEqualStrings(case[2], result); + try testing.expectEqual(case[2], result); + } +} + +test "normalizePropertyValue: anchor() canonical order" { + defer testing.reset(); + const cases = .{ + // Dashed ident should come before keyword + .{ "left", "anchor(left --foo)", "anchor(--foo left)" }, + .{ "left", "anchor(inside --foo)", "anchor(--foo inside)" }, + .{ "left", "anchor(50% --foo)", "anchor(--foo 50%)" }, + // Already canonical order - keep as-is + .{ "left", "anchor(--foo left)", "anchor(--foo left)" }, + .{ "left", "anchor(left)", "anchor(left)" }, + // With fallback + .{ "left", "anchor(left --foo, 1px)", "anchor(--foo left, 1px)" }, + // Nested anchor in fallback + .{ "left", "anchor(left --foo, anchor(right --bar))", "anchor(--foo left, anchor(--bar right))" }, + }; + inline for (cases) |case| { + const result = try normalizePropertyValue(testing.arena_allocator, case[0], case[1]); + try testing.expectEqual(case[2], result); } } diff --git a/src/browser/webapi/css/CSSStyleRule.zig b/src/browser/webapi/css/CSSStyleRule.zig index cff5ebae..561d39c1 100644 --- a/src/browser/webapi/css/CSSStyleRule.zig +++ b/src/browser/webapi/css/CSSStyleRule.zig @@ -2,19 +2,20 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const CSSRule = @import("CSSRule.zig"); -const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig"); +const CSSStyleProperties = @import("CSSStyleProperties.zig"); const CSSStyleRule = @This(); _proto: *CSSRule, _selector_text: []const u8 = "", -_style: ?*CSSStyleDeclaration = null, +_style: ?*CSSStyleProperties = null, pub fn init(page: *Page) !*CSSStyleRule { - const rule = try CSSRule.init(.style, page); - return page._factory.create(CSSStyleRule{ - ._proto = rule, + const style_rule = try page._factory.create(CSSStyleRule{ + ._proto = undefined, }); + style_rule._proto = try CSSRule.init(.{ .style = style_rule }, page); + return style_rule; } pub fn getSelectorText(self: *const CSSStyleRule) []const u8 { @@ -25,24 +26,35 @@ pub fn setSelectorText(self: *CSSStyleRule, text: []const u8, page: *Page) !void self._selector_text = try page.dupeString(text); } -pub fn getStyle(self: *CSSStyleRule, page: *Page) !*CSSStyleDeclaration { +pub fn getStyle(self: *CSSStyleRule, page: *Page) !*CSSStyleProperties { if (self._style) |style| { return style; } - const style = try CSSStyleDeclaration.init(null, false, page); + const style = try CSSStyleProperties.init(null, false, page); self._style = style; return style; } +pub fn getCssText(self: *CSSStyleRule, page: *Page) ![]const u8 { + const style_props = try self.getStyle(page); + const style = style_props.asCSSStyleDeclaration(); + var buf = std.Io.Writer.Allocating.init(page.call_arena); + try buf.writer.print("{s} {{ ", .{self._selector_text}); + try style.format(&buf.writer); + try buf.writer.writeAll(" }"); + return buf.written(); +} + pub const JsApi = struct { pub const bridge = js.Bridge(CSSStyleRule); pub const Meta = struct { pub const name = "CSSStyleRule"; - pub const prototype_chain = bridge.prototypeChain(CSSRule); + pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{}); pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{}); + pub const cssText = bridge.accessor(CSSStyleRule.getCssText, null, .{}); }; diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index 5500f63c..675e87f9 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -4,9 +4,19 @@ const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const CSSRuleList = @import("CSSRuleList.zig"); const CSSRule = @import("CSSRule.zig"); +const CSSStyleRule = @import("CSSStyleRule.zig"); +const Parser = @import("../../css/Parser.zig"); const CSSStyleSheet = @This(); +pub const CSSError = error{ + OutOfMemory, + IndexSizeError, + WriteFailed, + StringTooLarge, + SyntaxError, +}; + _href: ?[]const u8 = null, _title: []const u8 = "", _disabled: bool = false, @@ -44,8 +54,17 @@ pub fn setDisabled(self: *CSSStyleSheet, disabled: bool) void { pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList { if (self._css_rules) |rules| return rules; + const rules = try CSSRuleList.init(page); self._css_rules = rules; + + if (self.getOwnerNode()) |owner| { + if (owner.is(Element.Html.Style)) |style| { + const text = try style.asNode().getTextContentAlloc(page.call_arena); + try self.replaceSync(text, page); + } + } + return rules; } @@ -53,31 +72,60 @@ pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule { return self._owner_rule; } -pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, index: u32, page: *Page) !u32 { - _ = self; - _ = rule; - _ = index; - _ = page; - return 0; +pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, page: *Page) !u32 { + const index = maybe_index orelse 0; + var it = Parser.parseStylesheet(rule); + const parsed_rule = it.next() orelse return error.SyntaxError; + + const style_rule = try CSSStyleRule.init(page); + try style_rule.setSelectorText(parsed_rule.selector, page); + + const style_props = try style_rule.getStyle(page); + const style = style_props.asCSSStyleDeclaration(); + try style.setCssText(parsed_rule.block, page); + + const rules = try self.getCssRules(page); + try rules.insert(index, style_rule._proto, page); + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); + + return index; } pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void { - _ = self; - _ = index; - _ = page; + const rules = try self.getCssRules(page); + try rules.remove(index); + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); } -pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise { - _ = self; - _ = text; - // TODO: clear self.css_rules - return page.js.local.?.resolvePromise({}); +pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) CSSError!js.Promise { + try self.replaceSync(text, page); + return page.js.local.?.resolvePromise(self); } -pub fn replaceSync(self: *CSSStyleSheet, text: []const u8) !void { - _ = self; - _ = text; - // TODO: clear self.css_rules +pub fn replaceSync(self: *CSSStyleSheet, text: []const u8, page: *Page) CSSError!void { + const rules = try self.getCssRules(page); + rules.clear(); + + var it = Parser.parseStylesheet(text); + var index: u32 = 0; + while (it.next()) |parsed_rule| { + const style_rule = try CSSStyleRule.init(page); + try style_rule.setSelectorText(parsed_rule.selector, page); + + const style_props = try style_rule.getStyle(page); + const style = style_props.asCSSStyleDeclaration(); + try style.setCssText(parsed_rule.block, page); + + try rules.insert(index, style_rule._proto, page); + index += 1; + } + + // Notify StyleManager that rules have changed + page._style_manager.sheetModified(); } pub const JsApi = struct { @@ -96,13 +144,15 @@ pub const JsApi = struct { pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{}); pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{}); pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{}); - pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{}); - pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{}); + pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{ .dom_exception = true }); + pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{ .dom_exception = true }); pub const replace = bridge.function(CSSStyleSheet.replace, .{}); pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{}); }; const testing = @import("../../../testing.zig"); test "WebApi: CSSStyleSheet" { + const filter: testing.LogFilter = .init(&.{.js}); + defer filter.deinit(); try testing.htmlRunner("css/stylesheet.html", .{}); } diff --git a/src/browser/webapi/css/StyleSheetList.zig b/src/browser/webapi/css/StyleSheetList.zig index c44dc601..c0732c73 100644 --- a/src/browser/webapi/css/StyleSheetList.zig +++ b/src/browser/webapi/css/StyleSheetList.zig @@ -5,19 +5,32 @@ const CSSStyleSheet = @import("CSSStyleSheet.zig"); const StyleSheetList = @This(); -_sheets: []*CSSStyleSheet = &.{}, +_sheets: std.ArrayList(*CSSStyleSheet) = .empty, pub fn init(page: *Page) !*StyleSheetList { return page._factory.create(StyleSheetList{}); } pub fn length(self: *const StyleSheetList) u32 { - return @intCast(self._sheets.len); + return @intCast(self._sheets.items.len); } pub fn item(self: *const StyleSheetList, index: usize) ?*CSSStyleSheet { - if (index >= self._sheets.len) return null; - return self._sheets[index]; + if (index >= self._sheets.items.len) return null; + return self._sheets.items[index]; +} + +pub fn add(self: *StyleSheetList, sheet: *CSSStyleSheet, page: *Page) !void { + try self._sheets.append(page.arena, sheet); +} + +pub fn remove(self: *StyleSheetList, sheet: *CSSStyleSheet) void { + for (self._sheets.items, 0..) |s, i| { + if (s == sheet) { + _ = self._sheets.orderedRemove(i); + return; + } + } } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index b86744da..e8857e48 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -73,18 +73,22 @@ pub fn setMethod(self: *Form, method: []const u8, page: *Page) !void { } pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsCollection { + const node_live = self.iterator(page); + const html_collection = try node_live.runtimeGenericWrap(page); + + return page._factory.create(collections.HTMLFormControlsCollection{ + ._proto = html_collection, + }); +} + +pub fn iterator(self: *Form, page: *Page) collections.NodeLive(.form) { const form_id = self.asElement().getAttributeSafe(comptime .wrap("id")); const root = if (form_id != null) self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls else self.asNode(); // No ID: walk only form subtree (no external controls possible) - const node_live = collections.NodeLive(.form).init(root, self, page); - const html_collection = try node_live.runtimeGenericWrap(page); - - return page._factory.create(collections.HTMLFormControlsCollection{ - ._proto = html_collection, - }); + return collections.NodeLive(.form).init(root, .{ .form = self, .form_id = form_id }, page); } pub fn getAction(self: *Form, page: *Page) ![]const u8 { diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index c8fba91e..ad5c17f5 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -150,6 +150,7 @@ pub const JsApi = struct { pub const constructor = bridge.constructor(Image.constructor, .{}); pub const src = bridge.accessor(Image.getSrc, Image.setSrc, .{}); + pub const currentSrc = bridge.accessor(Image.getSrc, null, .{}); pub const alt = bridge.accessor(Image.getAlt, Image.setAlt, .{}); pub const width = bridge.accessor(Image.getWidth, Image.setWidth, .{}); pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{}); diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 310e942b..71013e71 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -308,6 +308,7 @@ pub const JsApi = struct { pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA), .{ .template = true }); pub const src = bridge.accessor(Media.getSrc, Media.setSrc, .{}); + pub const currentSrc = bridge.accessor(Media.getSrc, null, .{}); pub const autoplay = bridge.accessor(Media.getAutoplay, Media.setAutoplay, .{}); pub const controls = bridge.accessor(Media.getControls, Media.setControls, .{}); pub const loop = bridge.accessor(Media.getLoop, Media.setLoop, .{}); diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index 131b7634..e6cac8c3 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -94,10 +94,20 @@ pub fn getSheet(self: *Style, page: *Page) !?*CSSStyleSheet { if (self._sheet) |sheet| return sheet; const sheet = try CSSStyleSheet.initWithOwner(self.asElement(), page); self._sheet = sheet; + + const sheets = try page.document.getStyleSheets(page); + try sheets.add(sheet, page); + return sheet; } pub fn styleAddedCallback(self: *Style, page: *Page) !void { + // Force stylesheet initialization so rules are parsed immediately + if (self.getSheet(page) catch null) |_| { + // Notify StyleManager about the new stylesheet + page._style_manager.sheetModified(); + } + // if we're planning on navigating to another page, don't trigger load event. if (page.isGoingAway()) { return; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 1c499a9e..98a7969e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -33,6 +33,7 @@ const Page = @import("../browser/Page.zig"); const Incrementing = @import("id.zig").Incrementing; const Notification = @import("../Notification.zig"); const InterceptState = @import("domains/fetch.zig").InterceptState; +const Mime = @import("../browser/Mime.zig"); pub const URL_BASE = "chrome://newtab/"; @@ -129,9 +130,10 @@ pub fn CDPT(comptime TypeProvider: type) type { // A bit hacky right now. The main server loop doesn't unblock for // scheduled task. So we run this directly in order to process any // timeouts (or http events) which are ready to be processed. - pub fn pageWait(self: *Self, ms: u32) Session.WaitResult { - const session = &(self.browser.session orelse return .no_page); - return session.wait(.{ .timeout_ms = ms }); + pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult { + const session = &(self.browser.session orelse return error.NoPage); + var runner = try session.runner(.{}); + return runner.waitCDP(.{ .ms = ms }); } // Called from above, in processMessage which handles client messages @@ -189,19 +191,10 @@ pub fn CDPT(comptime TypeProvider: type) type { // (I can imagine this logic will become driver-specific) fn dispatchStartupCommand(command: anytype, method: []const u8) !void { // Stagehand parses the response and error if we don't return a - // correct one for this call. + // correct one for Page.getFrameTree on startup call. if (std.mem.eql(u8, method, "Page.getFrameTree")) { - return command.sendResult(.{ - .frameTree = .{ - .frame = .{ - .id = "TID-STARTUP", - .loaderId = "LOADERID24DD2FD56CF1EF33C965C79C", - .securityOrigin = URL_BASE, - .url = "about:blank", - .secureContextType = "Secure", - }, - }, - }, .{}); + // The Page.getFrameTree handles startup response gracefully. + return dispatchCommand(command, method); } return command.sendResult(null, .{}); @@ -324,6 +317,11 @@ pub fn BrowserContext(comptime CDP_T: type) type { const Node = @import("Node.zig"); const AXNode = @import("AXNode.zig"); + const CapturedResponse = struct { + must_encode: bool, + data: std.ArrayList(u8), + }; + return struct { id: []const u8, cdp: *CDP_T, @@ -384,7 +382,7 @@ pub fn BrowserContext(comptime CDP_T: type) type { // ever streamed. So if CDP is the only thing that needs bodies in // memory for an arbitrary amount of time, then that's where we're going // to store the, - captured_responses: std.AutoHashMapUnmanaged(usize, std.ArrayList(u8)), + captured_responses: std.AutoHashMapUnmanaged(usize, CapturedResponse), notification: *Notification, @@ -637,6 +635,35 @@ pub fn BrowserContext(comptime CDP_T: type) type { pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void { const self: *Self = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); + + const arena = self.page_arena; + + // Prepare the captured response value. + const id = msg.transfer.id; + const gop = try self.captured_responses.getOrPut(arena, id); + if (!gop.found_existing) { + gop.value_ptr.* = .{ + .data = .empty, + // Encode the data in base64 by default, but don't encode + // for well known content-type. + .must_encode = blk: { + const transfer = msg.transfer; + if (transfer.response_header.?.contentType()) |ct| { + const mime = try Mime.parse(ct); + + if (!mime.isText()) { + break :blk true; + } + + if (std.mem.eql(u8, "UTF-8", mime.charsetString())) { + break :blk false; + } + } + break :blk true; + }, + }; + } + return @import("domains/network.zig").httpResponseHeaderDone(self.notification_arena, self, msg); } @@ -650,11 +677,9 @@ pub fn BrowserContext(comptime CDP_T: type) type { const arena = self.page_arena; const id = msg.transfer.id; - const gop = try self.captured_responses.getOrPut(arena, id); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data)); + const resp = self.captured_responses.getPtr(id) orelse lp.assert(false, "onHttpResponseData missinf captured response", .{}); + + return resp.data.appendSlice(arena, msg.data); } pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 509500bd..43b1b47b 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -36,6 +36,7 @@ pub fn processMessage(cmd: anytype) !void { clickNode, fillNode, scrollNode, + waitForSelector, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -47,6 +48,7 @@ pub fn processMessage(cmd: anytype) !void { .clickNode => return clickNode(cmd), .fillNode => return fillNode(cmd), .scrollNode => return scrollNode(cmd), + .waitForSelector => return waitForSelector(cmd), } } @@ -257,6 +259,32 @@ fn scrollNode(cmd: anytype) !void { return cmd.sendResult(.{}, .{}); } + +fn waitForSelector(cmd: anytype) !void { + const Params = struct { + selector: []const u8, + timeout: ?u32 = null, + }; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + _ = bc.session.currentPage() orelse return error.PageNotLoaded; + + const timeout_ms = params.timeout orelse 5000; + const selector_z = try cmd.arena.dupeZ(u8, params.selector); + + const node = lp.actions.waitForSelector(selector_z, timeout_ms, bc.session) catch |err| { + if (err == error.InvalidSelector) return error.InvalidParam; + if (err == error.Timeout) return error.InternalError; + return error.InternalError; + }; + + const registered = try bc.node_registry.register(node); + return cmd.sendResult(.{ + .backendNodeId = registered.id, + }, .{}); +} + const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); @@ -315,7 +343,8 @@ test "cdp.lp: action tools" { const page = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = bc.session.wait(.{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); // Test Click const btn = page.document.getElementById("btn", page).?.asNode(); @@ -366,3 +395,44 @@ test "cdp.lp: action tools" { try testing.expect(result.isTrue()); } + +test "cdp.lp: waitForSelector" { + var ctx = testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + // 1. Existing element + try ctx.processMessage(.{ + .id = 1, + .method = "LP.waitForSelector", + .params = .{ .selector = "#existing", .timeout = 2000 }, + }); + var result = ctx.client.?.sent.items[0].object.get("result").?.object; + try testing.expect(result.get("backendNodeId") != null); + ctx.client.?.sent.clearRetainingCapacity(); + + // 2. Delayed element + try ctx.processMessage(.{ + .id = 2, + .method = "LP.waitForSelector", + .params = .{ .selector = "#delayed", .timeout = 5000 }, + }); + result = ctx.client.?.sent.items[0].object.get("result").?.object; + try testing.expect(result.get("backendNodeId") != null); + ctx.client.?.sent.clearRetainingCapacity(); + + // 3. Timeout error + try ctx.processMessage(.{ + .id = 3, + .method = "LP.waitForSelector", + .params = .{ .selector = "#nonexistent", .timeout = 100 }, + }); + const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object; + try testing.expect(err_obj.get("code") != null); +} diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 5b9a49df..11e0142c 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -208,11 +208,22 @@ fn getResponseBody(cmd: anytype) !void { const request_id = try idFromRequestId(params.requestId); const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const buf = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound; + const resp = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound; - try cmd.sendResult(.{ - .body = buf.items, - .base64Encoded = false, + if (!resp.must_encode) { + return cmd.sendResult(.{ + .body = resp.data.items, + .base64Encoded = false, + }, .{}); + } + + const encoded_len = std.base64.standard.Encoder.calcSize(resp.data.items.len); + const encoded = try cmd.arena.alloc(u8, encoded_len); + _ = std.base64.standard.Encoder.encode(encoded, resp.data.items); + + return cmd.sendResult(.{ + .body = encoded, + .base64Encoded = true, }, .{}); } diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index f40d54a8..96932810 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -75,8 +75,21 @@ const Frame = struct { }; fn getFrameTree(cmd: anytype) !void { - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const target_id = bc.target_id orelse return error.TargetNotLoaded; + // Stagehand parses the response and error if we don't return a + // correct one for this call when browser context or target id are missing. + const startup = .{ + .frameTree = .{ + .frame = .{ + .id = "TID-STARTUP", + .loaderId = "LID-STARTUP", + .securityOrigin = @import("../cdp.zig").URL_BASE, + .url = "about:blank", + .secureContextType = "Secure", + }, + }, + }; + const bc = cmd.browser_context orelse return cmd.sendResult(startup, .{}); + const target_id = bc.target_id orelse return cmd.sendResult(startup, .{}); return cmd.sendResult(.{ .frameTree = .{ @@ -633,8 +646,18 @@ test "cdp.page: getFrameTree" { defer ctx.deinit(); { - try ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }); - try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); + // no browser context - should return TID-STARTUP + try ctx.processMessage(.{ .id = 1, .method = "Page.getFrameTree", .sessionId = "STARTUP" }); + try ctx.expectSentResult(.{ + .frameTree = .{ + .frame = .{ + .id = "TID-STARTUP", + .loaderId = "LID-STARTUP", + .url = "about:blank", + .secureContextType = "Secure", + }, + }, + }, .{ .id = 1, .session_id = "STARTUP" }); } const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); @@ -659,6 +682,29 @@ test "cdp.page: getFrameTree" { }, }, .{ .id = 11 }); } + + { + // STARTUP sesion is handled when a broweser context and a target id exists. + try ctx.processMessage(.{ .id = 12, .method = "Page.getFrameTree", .session_id = "STARTUP" }); + try ctx.expectSentResult(.{ + .frameTree = .{ + .frame = .{ + .id = "FID-000000000X", + .loaderId = "LID-0000000001", + .url = "http://127.0.0.1:9582/src/browser/tests/hi.html", + .domainAndRegistry = "", + .securityOrigin = bc.security_origin, + .mimeType = "text/html", + .adFrameStatus = .{ + .adFrameType = "none", + }, + .secureContextType = bc.secure_context_type, + .crossOriginIsolatedContextType = "NotIsolated", + .gatedAPIFeatures = [_][]const u8{}, + }, + }, + }, .{ .id = 12 }); + } } test "cdp.page: captureScreenshot" { diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 34f28c94..744104d1 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -136,7 +136,8 @@ const TestContext = struct { 0, ); try page.navigate(full_url, .{}); - _ = bc.session.wait(.{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } return bc; } diff --git a/src/lightpanda.zig b/src/lightpanda.zig index fa3365a0..8600f6d4 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -109,7 +109,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { .reason = .address_bar, .kind = .{ .push = null }, }); - _ = session.wait(.{ .timeout_ms = opts.wait_ms, .until = opts.wait_until }); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = opts.wait_ms, .until = opts.wait_until }); const writer = opts.writer orelse return; if (opts.dump_mode) |mode| { diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 0805e100..15075642 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -106,7 +106,8 @@ pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { defer try_catch.deinit(); try page.navigate(url, .{}); - _ = session.wait(.{}); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const caught = try_catch.caughtOrError(allocator, err); diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 2bc6f9e8..1375cd00 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) { InvalidParams = -32602, InternalError = -32603, PageNotLoaded = -32604, + NotFound = -32605, }; pub const Notification = struct { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 9701ea5a..93b4cc7f 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -115,7 +115,7 @@ pub const tool_list = [_]protocol.Tool{ }, .{ .name = "click", - .description = "Click on an interactive element.", + .description = "Click on an interactive element. Returns the current page URL and title after the click.", .inputSchema = protocol.minify( \\{ \\ "type": "object", @@ -128,7 +128,7 @@ pub const tool_list = [_]protocol.Tool{ }, .{ .name = "fill", - .description = "Fill text into an input element.", + .description = "Fill text into an input element. Returns the filled value and current page URL and title.", .inputSchema = protocol.minify( \\{ \\ "type": "object", @@ -142,7 +142,7 @@ pub const tool_list = [_]protocol.Tool{ }, .{ .name = "scroll", - .description = "Scroll the page or a specific element.", + .description = "Scroll the page or a specific element. Returns the scroll position and current page URL and title.", .inputSchema = protocol.minify( \\{ \\ "type": "object", @@ -543,7 +543,13 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar return server.sendError(id, .InternalError, "Failed to click element"); }; - const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; + const page_title = page.getTitle() catch null; + const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ + args.backendNodeId, + page.url, + page_title orelse "(none)", + }); + const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } @@ -569,7 +575,14 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return server.sendError(id, .InternalError, "Failed to fill element"); }; - const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; + const page_title = page.getTitle() catch null; + const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{ + args.backendNodeId, + args.text, + page.url, + page_title orelse "(none)", + }); + const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } @@ -600,9 +613,17 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a return server.sendError(id, .InternalError, "Failed to scroll"); }; - const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }}; + const page_title = page.getTitle() catch null; + const result_text = try std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{ + args.x orelse 0, + args.y orelse 0, + page.url, + page_title orelse "(none)", + }); + const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } + fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const WaitParams = struct { selector: [:0]const u8, @@ -610,33 +631,26 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json }; const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector"); - const page = server.session.currentPage() orelse { + _ = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const timeout_ms = args.timeout orelse 5000; - var timer = try std.time.Timer.start(); - while (true) { - const element = Selector.querySelector(page.document.asNode(), args.selector, page) catch { + const node = lp.actions.waitForSelector(args.selector, timeout_ms, server.session) catch |err| { + if (err == error.InvalidSelector) { return server.sendError(id, .InvalidParams, "Invalid selector"); - }; - - if (element) |el| { - const registered = try server.node_registry.register(el.asNode()); - const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; - - const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; - return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); - } - - const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); - if (elapsed >= timeout_ms) { + } else if (err == error.Timeout) { return server.sendError(id, .InternalError, "Timeout waiting for selector"); } + return server.sendError(id, .InternalError, "Failed waiting for selector"); + }; - _ = server.session.wait(.{ .timeout_ms = @min(100, timeout_ms - elapsed) }); - } + const registered = try server.node_registry.register(node); + const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; + + const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; + return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { @@ -665,25 +679,18 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { return error.NavigationFailed; }; - _ = server.session.wait(.{}); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } -const testing = @import("../testing.zig"); const router = @import("router.zig"); +const testing = @import("../testing.zig"); test "MCP - evaluate error reporting" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("about:blank", &out.writer); defer server.deinit(); - _ = try server.session.createPage(); - - const aa = testing.arena_allocator; // Call evaluate with a script that throws an error const msg = @@ -700,69 +707,74 @@ test "MCP - evaluate error reporting" { \\} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "isError": true, - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ + .isError = true, + .content = &.{.{ .type = "text" }}, + } }, out.written()); } test "MCP - Actions: click, fill, scroll" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; + const aa = testing.arena_allocator; - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(aa); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); + const page = &server.session.page.?; - // Test Click - const btn = page.document.getElementById("btn", page).?.asNode(); - const btn_id = (try server.node_registry.register(btn)).id; - var btn_id_buf: [12]u8 = undefined; - const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; - const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); - try router.handleMessage(server, aa, click_msg); + { + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try server.node_registry.register(btn)).id; + var btn_id_buf: [12]u8 = undefined; + const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; + const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); + try router.handleMessage(server, aa, click_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null); + out.clearRetainingCapacity(); + } - // Test Fill Input - const inp = page.document.getElementById("inp", page).?.asNode(); - const inp_id = (try server.node_registry.register(inp)).id; - var inp_id_buf: [12]u8 = undefined; - const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; - const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); - try router.handleMessage(server, aa, fill_msg); + { + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try server.node_registry.register(inp)).id; + var inp_id_buf: [12]u8 = undefined; + const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; + const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); + try router.handleMessage(server, aa, fill_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"hello\\\"") != null); + out.clearRetainingCapacity(); + } - // Test Fill Select - const sel = page.document.getElementById("sel", page).?.asNode(); - const sel_id = (try server.node_registry.register(sel)).id; - var sel_id_buf: [12]u8 = undefined; - const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; - const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); - try router.handleMessage(server, aa, fill_sel_msg); + { + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try server.node_registry.register(sel)).id; + var sel_id_buf: [12]u8 = undefined; + const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; + const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); + try router.handleMessage(server, aa, fill_sel_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"opt2\\\"") != null); + out.clearRetainingCapacity(); + } - // Test Scroll - const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); - const scrollbox_id = (try server.node_registry.register(scrollbox)).id; - var scroll_id_buf: [12]u8 = undefined; - const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; - const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); - try router.handleMessage(server, aa, scroll_msg); + { + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try server.node_registry.register(scrollbox)).id; + var scroll_id_buf: [12]u8 = undefined; + const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; + const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); + try router.handleMessage(server, aa, scroll_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled to x: 0, y: 50") != null); + out.clearRetainingCapacity(); + } // Evaluate assertions var ls: js.Local.Scope = undefined; @@ -773,108 +785,79 @@ test "MCP - Actions: click, fill, scroll" { try_catch.init(&ls.local); defer try_catch.deinit(); - const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + const result = try ls.local.exec( + \\ window.clicked === true && window.inputVal === 'hello' && + \\ window.changed === true && window.selChanged === 'opt2' && + \\ window.scrolled === true + , null); try testing.expect(result.isTrue()); } test "MCP - waitForSelector: existing element" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector on an element that already exists returns immediately const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#existing","timeout":2000}}} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written()); } test "MCP - waitForSelector: delayed element" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector on an element added after 200ms via setTimeout const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#delayed","timeout":5000}}} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written()); } test "MCP - waitForSelector: timeout" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector with a short timeout on a non-existent element should error const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}} ; - try router.handleMessage(server, aa, msg); - - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "error": {} - \\} - , out_alloc.writer.buffered()); + try router.handleMessage(server, testing.arena_allocator, msg); + try testing.expectJson(.{ + .id = 1, + .@"error" = struct {}{}, + }, out.written()); +} + +fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server { + var server = try Server.init(testing.allocator, testing.test_app, writer); + errdefer server.deinit(); + + const page = try server.session.createPage(); + try page.navigate(url, .{}); + + var runner = try server.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + return server; } diff --git a/src/test_runner.zig b/src/test_runner.zig index 5d9d1cdd..cd449a51 100644 --- a/src/test_runner.zig +++ b/src/test_runner.zig @@ -73,6 +73,8 @@ const Runner = struct { var slowest = SlowTracker.init(self.allocator, 5); defer slowest.deinit(); + var fail_list: std.ArrayList([]const u8) = .empty; + var pass: usize = 0; var fail: usize = 0; var skip: usize = 0; @@ -175,6 +177,7 @@ const Runner = struct { if (self.env.fail_first) { break; } + try fail_list.append(self.allocator, try self.allocator.dupe(u8, friendly_name)); }, } @@ -210,9 +213,9 @@ const Runner = struct { Printer.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" }); } Printer.fmt("\n", .{}); + try slowest.display(); Printer.fmt("\n", .{}); - // stats if (self.env.metrics) { var stdout = std.fs.File.stdout(); @@ -232,6 +235,15 @@ const Runner = struct { .alloc_size = v8_peak_memory, } }, }, .{ .whitespace = .indent_2 }, &writer.interface); + Printer.fmt("\n", .{}); + } + + if (fail_list.items.len > 0) { + Printer.status(.fail, "Failed Test Summary: \n", .{}); + for (fail_list.items) |name| { + Printer.status(.fail, "- {s}\n", .{name}); + } + Printer.fmt("\n", .{}); } std.posix.exit(if (fail == 0) 0 else 1); diff --git a/src/testing.zig b/src/testing.zig index bdfeb915..050c9849 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -415,7 +415,8 @@ fn runWebApiTest(test_file: [:0]const u8) !void { defer try_catch.deinit(); try page.navigate(url, .{}); - _ = test_session.wait(.{}); + var runner = try test_session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); test_browser.runMicrotasks(); @@ -439,7 +440,8 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { ); try page.navigate(url, .{}); - _ = test_session.wait(.{}); + var runner = try test_session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); return page; }