Merge branch 'main' into osc/feat-mcp-detect-forms

This commit is contained in:
Adrià Arrufat
2026-03-24 09:25:47 +09:00
54 changed files with 3006 additions and 731 deletions

View File

@@ -18,15 +18,15 @@ Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
</div> </div>
<div align="center"> <div align="center">
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg"> [<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time-v2.svg">
](https://github.com/lightpanda-io/demo) ](https://github.com/lightpanda-io/demo)
&emsp; &emsp;
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg"> [<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame-v2.svg">
](https://github.com/lightpanda-io/demo) ](https://github.com/lightpanda-io/demo)
</div> </div>
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance. _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)._ 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: Lightpanda is the open-source browser made for headless usage:

View File

@@ -155,6 +155,11 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
_ = arena.reset(.{ .retain_with_limit = retain }); _ = 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; const testing = std.testing;
test "arena pool - basic acquire and use" { test "arena pool - basic acquire and use" {

View File

@@ -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 { pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
@@ -199,6 +213,7 @@ pub const Mode = union(RunMode) {
pub const Serve = struct { pub const Serve = struct {
host: []const u8 = "127.0.0.1", host: []const u8 = "127.0.0.1",
port: u16 = 9222, port: u16 = 9222,
advertise_host: ?[]const u8 = null,
timeout: u31 = 10, timeout: u31 = 10,
cdp_max_connections: u16 = 16, cdp_max_connections: u16 = 16,
cdp_max_pending_connections: u16 = 128, cdp_max_pending_connections: u16 = 128,
@@ -221,7 +236,7 @@ pub const WaitUntil = enum {
load, load,
domcontentloaded, domcontentloaded,
networkidle, networkidle,
fixed, done,
}; };
pub const Fetch = struct { pub const Fetch = struct {
@@ -400,8 +415,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Defaults to 5000. \\ Defaults to 5000.
\\ \\
\\--wait_until Wait until the specified event. \\--wait_until Wait until the specified event.
\\ Supported events: load, domcontentloaded, networkidle, fixed. \\ Supported events: load, domcontentloaded, networkidle, done.
\\ Defaults to 'load'. \\ Defaults to 'done'.
\\ \\
++ common_options ++ ++ common_options ++
\\ \\
@@ -416,6 +431,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--port Port of the CDP server \\--port Port of the CDP server
\\ Defaults to 9222 \\ 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 \\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week). \\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\ \\
@@ -557,6 +577,15 @@ fn parseServeArgs(
continue; 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)) { if (std.mem.eql(u8, "--timeout", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });

View File

@@ -47,7 +47,15 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
log.err(.app, "listener map failed", .{ .err = err }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; 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 }); log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed; 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 }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; 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 }); log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -84,7 +100,22 @@ const NodeData = struct {
node_name: []const u8, 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; if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes // 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; if (tag == .datalist or tag == .option or tag == .optgroup) return;
// Check visibility using the engine's checkVisibility which handles CSS display: none // 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; 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 (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; is_interactive = true;
} }
} }
@@ -145,9 +176,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
node_name = "root"; node_name = "root";
} }
const initial_xpath_len = xpath_buffer.items.len; const initial_xpath_len = ctx.xpath_buffer.items.len;
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); try appendXPathSegment(node, ctx.xpath_buffer.writer(self.arena), index);
const xpath = xpath_buffer.items; const xpath = ctx.xpath_buffer.items;
var name = try axn.getName(self.page, self.arena); 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; 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; var should_visit = true;
if (self.interactive_only) { if (self.interactive_only) {
var keep = false; 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 did_visit = false;
var should_walk_children = true; 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) { if (should_visit) {
should_walk_children = try visitor.visit(node, &data); should_walk_children = try visitor.visit(node, &data);
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures 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; 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(); 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 { 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(); var it = node.childrenIterator();
while (it.next()) |child| { while (it.next()) |child| {
if (child.is(Element)) |el| { if (child.is(Element)) |el| {

View File

@@ -45,7 +45,7 @@ clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !*Server { pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator; 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); errdefer allocator.free(json_version_response);
const self = try allocator.create(Server); const self = try allocator.create(Server);
@@ -302,15 +302,8 @@ pub const Client = struct {
var ms_remaining = self.ws.timeout_ms; var ms_remaining = self.ws.timeout_ms;
while (true) { while (true) {
switch (cdp.pageWait(ms_remaining)) { const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
.cdp_socket => { error.NoPage => {
if (self.readSocket() == false) {
return;
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.no_page => {
const status = http.tick(ms_remaining) catch |err| { const status = http.tick(ms_remaining) catch |err| {
log.err(.app, "http tick", .{ .err = err }); log.err(.app, "http tick", .{ .err = err });
return; return;
@@ -324,6 +317,18 @@ pub const Client = struct {
} }
last_message = milliTimestamp(.monotonic); last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms; 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 => { .done => {
const now = milliTimestamp(.monotonic); const now = milliTimestamp(.monotonic);
@@ -484,11 +489,17 @@ pub const Client = struct {
// -------- // --------
fn buildJSONVersionResponse( fn buildJSONVersionResponse(
allocator: Allocator, app: *const App,
address: net.Address,
) ![]const u8 { ) ![]const u8 {
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}"; const port = app.config.port();
const body_len = std.fmt.count(body_format, .{address}); 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) // We send a Connection: Close (and actually close the connection)
// because chromedp (Go driver) sends a request to /json/version and then // because chromedp (Go driver) sends a request to /json/version and then
@@ -502,23 +513,22 @@ fn buildJSONVersionResponse(
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format; 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 timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp; pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = std.testing; const testing = @import("testing.zig");
test "server: buildJSONVersionResponse" { test "server: buildJSONVersionResponse" {
const address = try net.Address.parseIp4("127.0.0.1", 9001); const res = try buildJSONVersionResponse(testing.test_app);
const res = try buildJSONVersionResponse(testing.allocator, address); defer testing.test_app.allocator.free(res);
defer testing.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" ++ "Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\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" { test "Client: http invalid request" {
@@ -526,7 +536,7 @@ test "Client: http invalid request" {
defer c.deinit(); defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n"); 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" ++ "Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++ "Content-Length: 17\r\n\r\n" ++
"Request too large", res); "Request too large", res);
@@ -595,7 +605,7 @@ test "Client: http valid handshake" {
"Custom: Header-Value\r\n\r\n"; "Custom: Header-Value\r\n\r\n";
const res = try c.httpRequest(request); 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" ++ "Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++ "Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
@@ -723,7 +733,7 @@ test "server: 404" {
defer c.deinit(); defer c.deinit();
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); 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" ++ "Connection: Close\r\n" ++
"Content-Length: 9\r\n\r\n" ++ "Content-Length: 9\r\n\r\n" ++
"Not found", res); "Not found", res);
@@ -735,7 +745,7 @@ test "server: get /json/version" {
"Content-Length: 48\r\n" ++ "Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\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 // twice on the same connection
@@ -743,7 +753,7 @@ test "server: get /json/version" {
defer c.deinit(); defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); 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(); defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); 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 }, .{ 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 { fn assertWebSocketError(close_code: u16, input: []const u8) !void {
@@ -914,7 +924,7 @@ const TestClient = struct {
"Custom: Header-Value\r\n\r\n"; "Custom: Header-Value\r\n\r\n";
const res = try self.httpRequest(request); 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" ++ "Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++ "Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);

View File

@@ -386,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html; 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 // we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } { fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;

View File

@@ -35,6 +35,7 @@ const Factory = @import("Factory.zig");
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig"); const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig"); const ScriptManager = @import("ScriptManager.zig");
const StyleManager = @import("StyleManager.zig");
const Parser = @import("parser/Parser.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. /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element.Html) = .{}, _to_load: std.ArrayList(*Element.Html) = .{},
_style_manager: StyleManager,
_script_manager: ScriptManager, _script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec) // 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, ._factory = factory,
._pending_loads = 1, // always 1 for the ScriptManager ._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame, ._type = if (parent == null) .root else .frame,
._style_manager = undefined,
._script_manager = undefined, ._script_manager = undefined,
._event_manager = EventManager.init(session.page_arena, self), ._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, ._visual_viewport = visual_viewport,
}); });
self._style_manager = try StyleManager.init(self);
errdefer self._style_manager.deinit();
const browser = session.browser; const browser = session.browser;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit(); errdefer self._script_manager.deinit();
@@ -360,6 +366,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
} }
self._script_manager.deinit(); self._script_manager.deinit();
self._style_manager.deinit();
session.releaseArena(self.call_arena); 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) { if (is_about_blank or is_blob) {
self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url); 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) { if (is_blob) {
// strip out blob: // strip out blob:
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]); 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. // page that it's acting on.
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void { fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
const resolved_url, const is_about_blank = blk: { 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")) { if (std.mem.eql(u8, request_url, "about:blank")) {
// navigate will handle this special case // navigate will handle this special case
break :blk .{ "about:blank", true }; 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( const u = try URL.resolve(
arena, arena,
originator.base(), page_base,
request_url, request_url,
.{ .always_dupe = true, .encode = true }, .{ .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); Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
// If a <style> element is being removed, remove its sheet from the list
if (el.is(Element.Html.Style)) |style| {
if (style._sheet) |sheet| {
if (self.document._style_sheets) |sheets| {
sheets.remove(sheet);
}
style._sheet = null;
}
self._style_manager.sheetModified();
}
} }
} }
@@ -2572,8 +2617,10 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
self.domChanged(); self.domChanged();
const dest_connected = target.isConnected(); const dest_connected = target.isConnected();
var it = parent.childrenIterator(); // Use firstChild() instead of iterator to handle cases where callbacks
while (it.next()) |child| { // (like custom element connectedCallback) modify the parent during iteration.
// The iterator captures "next" pointers that can become stale.
while (parent.firstChild()) |child| {
// Check if child was connected BEFORE removing it from parent // Check if child was connected BEFORE removing it from parent
const child_was_connected = child.isConnected(); const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected }); self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
@@ -2585,8 +2632,10 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_
self.domChanged(); self.domChanged();
const dest_connected = parent.isConnected(); const dest_connected = parent.isConnected();
var it = fragment.childrenIterator(); // Use firstChild() instead of iterator to handle cases where callbacks
while (it.next()) |child| { // (like custom element connectedCallback) modify the fragment during iteration.
// The iterator captures "next" pointers that can become stale.
while (fragment.firstChild()) |child| {
// Check if child was connected BEFORE removing it from fragment // Check if child was connected BEFORE removing it from fragment
const child_was_connected = child.isConnected(); const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected }); self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
@@ -3536,10 +3585,12 @@ test "WebApi: Page" {
} }
test "WebApi: Frames" { test "WebApi: Frames" {
const filter: testing.LogFilter = .init(&.{.js}); // TOO FLAKY, disabled for now
defer filter.deinit();
try testing.htmlRunner("frames", .{}); // const filter: testing.LogFilter = .init(&.{.js});
// defer filter.deinit();
// try testing.htmlRunner("frames", .{});
} }
test "WebApi: Integration" { test "WebApi: Integration" {

241
src/browser/Runner.zig Normal file
View File

@@ -0,0 +1,241 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const App = @import("../App.zig");
const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const HttpClient = @import("HttpClient.zig");
const IS_DEBUG = builtin.mode == .Debug;
const Runner = @This();
page: *Page,
session: *Session,
http_client: *HttpClient,
pub const Opts = struct {};
pub fn init(session: *Session, _: Opts) !Runner {
const page = &(session.page orelse return error.NoPage);
return .{
.page = page,
.session = session,
.http_client = session.browser.http_client,
};
}
pub const WaitOpts = struct {
ms: u32,
until: lp.Config.WaitUntil = .done,
};
pub fn wait(self: *Runner, opts: WaitOpts) !void {
_ = try self._wait(false, opts);
}
pub const CDPWaitResult = enum {
done,
cdp_socket,
};
pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
return self._wait(true, opts);
}
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = opts.ms;
const tick_opts = TickOpts{
.ms = 200,
.until = opts.until,
};
while (true) {
const tick_result = self._tick(is_cdp, tick_opts) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = self.page.url,
}),
}
return err;
};
const next_ms = switch (tick_result) {
.ok => |next_ms| next_ms,
.done => return .done,
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
};
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
}
}
pub const TickOpts = struct {
ms: u32,
until: lp.Config.WaitUntil = .done,
};
pub const TickResult = union(enum) {
done,
ok: u32,
};
pub fn tick(self: *Runner, opts: TickOpts) !TickResult {
return switch (try self._tick(false, opts)) {
.ok => |ms| .{ .ok = ms },
.done => .done,
.cdp_socket => unreachable,
};
}
pub const CDPTickResult = union(enum) {
done,
cdp_socket,
ok: u32,
};
pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult {
return self._tick(true, opts);
}
fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
const page = self.page;
const http_client = self.http_client;
switch (page._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and (comptime is_cdp) == false) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
const http_result = try http_client.tick(@intCast(opts.ms));
if ((comptime is_cdp) and http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
},
.html, .complete => {
const session = self.session;
if (session.queued_navigation.items.len != 0) {
try session.processQueuedNavigation();
self.page = &session.page.?; // might have changed
return .{ .ok = 0 };
}
const browser = session.browser;
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and (comptime is_cdp == false)) {
// we don't need to consider http_client.intercepted here
// because is_cdp is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
}
switch (opts.until) {
.done => {},
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
return .done;
},
.load => if (page._load_state == .complete) {
return .done;
},
.networkidle => if (page._notified_network_idle == .done) {
return .done;
},
}
// We never advertise a wait time of more than 20, there can
// always be new background tasks to run.
if (browser.msToNextMacrotask()) |ms_to_next_task| {
return .{ .ok = @min(ms_to_next_task, 20) };
}
return .done;
}
// We're here because we either have active HTTP
// connections, or is_cdp == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(opts.ms, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
const http_result = try http_client.tick(@intCast(@min(opts.ms, ms_to_wait)));
if ((comptime is_cdp) and http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => return .done,
}
}

View File

@@ -30,6 +30,7 @@ const Navigation = @import("webapi/navigation/Navigation.zig");
const History = @import("webapi/History.zig"); const History = @import("webapi/History.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
pub const Runner = @import("Runner.zig");
const Browser = @import("Browser.zig"); const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig"); const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");
@@ -76,7 +77,15 @@ arena_pool: *ArenaPool,
page: ?Page, page: ?Page,
queued_navigation: std.ArrayList(*Page), // Double buffer so that, as we process one list of queued navigations, new entries
// are added to the separate buffer. This ensures that we don't end up with
// endless navigation loops AND that we don't invalidate the list while iterating
// if a new entry gets appended
queued_navigation_1: std.ArrayList(*Page),
queued_navigation_2: std.ArrayList(*Page),
// pointer to either queued_navigation_1 or queued_navigation_2
queued_navigation: *std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing. // Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync // We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation). // about:blank navigations (which may add to queued_navigation).
@@ -106,11 +115,14 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.navigation = .{ ._proto = undefined }, .navigation = .{ ._proto = undefined },
.storage_shed = .{}, .storage_shed = .{},
.browser = browser, .browser = browser,
.queued_navigation = .{}, .queued_navigation = undefined,
.queued_navigation_1 = .{},
.queued_navigation_2 = .{},
.queued_queued_navigation = .{}, .queued_queued_navigation = .{},
.notification = notification, .notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator), .cookie_jar = storage.Cookie.Jar.init(allocator),
}; };
self.queued_navigation = &self.queued_navigation_1;
} }
pub fn deinit(self: *Session) void { pub fn deinit(self: *Session) void {
@@ -258,12 +270,6 @@ pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null); return &(self.page orelse return null);
} }
pub const WaitResult = enum {
done,
no_page,
cdp_socket,
};
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page { pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null; const page = self.currentPage() orelse return null;
return findPageBy(page, "_frame_id", frame_id); return findPageBy(page, "_frame_id", frame_id);
@@ -284,208 +290,12 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
return null; return null;
} }
const WaitOpts = struct { pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
timeout_ms: u32 = 5000, return Runner.init(self, opts);
until: lp.Config.WaitUntil = .load,
};
pub fn wait(self: *Session, opts: WaitOpts) WaitResult {
var page = &(self.page orelse return .no_page);
while (true) {
const wait_result = self._wait(page, opts) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = page.url,
}),
}
return .done;
};
switch (wait_result) {
.done => {
if (self.queued_navigation.items.len == 0) {
return .done;
}
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
},
else => |result| return result,
}
}
}
fn _wait(self: *Session, page: *Page, opts: WaitOpts) !WaitResult {
const wait_until = opts.until;
var timer = try std.time.Timer.start();
var ms_remaining = opts.timeout_ms;
const browser = self.browser;
var http_client = browser.http_client;
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
while (true) {
switch (page._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
if (wait_until != .fixed) {
return .done;
}
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (self.queued_navigation.items.len != 0) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
const is_event_done = switch (wait_until) {
.fixed => false,
.domcontentloaded => (page._load_state == .load or page._load_state == .complete),
.load => (page._load_state == .complete),
.networkidle => (page._notified_network_idle == .done),
};
var ms = blk: {
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
const next_task = browser.msToNextMacrotask();
if (next_task == null and is_event_done) {
return .done;
}
break :blk next_task orelse 20;
};
if (ms > ms_remaining) {
if (is_event_done) {
return .done;
}
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
}
// We're still wait for our wait_until. Not sure for what
// but let's keep waiting. Worst case, we'll timeout.
ms = 20;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
} }
pub fn scheduleNavigation(self: *Session, page: *Page) !void { pub fn scheduleNavigation(self: *Session, page: *Page) !void {
const list = &self.queued_navigation; const list = self.queued_navigation;
// Check if page is already queued // Check if page is already queued
for (list.items) |existing| { for (list.items) |existing| {
@@ -498,8 +308,13 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void {
return list.append(self.arena, page); return list.append(self.arena, page);
} }
fn processQueuedNavigation(self: *Session) !void { pub fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation; const navigations = self.queued_navigation;
if (self.queued_navigation == &self.queued_navigation_1) {
self.queued_navigation = &self.queued_navigation_2;
} else {
self.queued_navigation = &self.queued_navigation_1;
}
if (self.page.?._queued_navigation != null) { if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the // This is both an optimization and a simplification of sorts. If the
@@ -515,7 +330,6 @@ fn processQueuedNavigation(self: *Session) !void {
defer about_blank_queue.clearRetainingCapacity(); defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank) // First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| { for (navigations.items) |page| {
const qn = page._queued_navigation.?; const qn = page._queued_navigation.?;
@@ -530,7 +344,6 @@ fn processQueuedNavigation(self: *Session) !void {
}; };
} }
// Clear the queue after first pass
navigations.clearRetainingCapacity(); navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank) // Second pass: process synchronous navigations (about:blank)
@@ -540,15 +353,17 @@ fn processQueuedNavigation(self: *Session) !void {
try self.processFrameNavigation(page, qn); try self.processFrameNavigation(page, qn);
} }
// Safety: Remove any about:blank navigations that were queued during the // Safety: Remove any about:blank navigations that were queued during
// second pass to prevent infinite loops // processing to prevent infinite loops. New navigations have been queued
// in the other buffer.
const new_navigations = self.queued_navigation;
var i: usize = 0; var i: usize = 0;
while (i < navigations.items.len) { while (i < new_navigations.items.len) {
const page = navigations.items[i]; const page = new_navigations.items[i];
if (page._queued_navigation) |qn| { if (page._queued_navigation) |qn| {
if (qn.is_about_blank) { if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{}); log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i); _ = self.queued_navigation.swapRemove(i);
continue; continue;
} }
} }

View File

@@ -0,0 +1,855 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../log.zig");
const String = @import("../string.zig").String;
const Page = @import("Page.zig");
const CssParser = @import("css/Parser.zig");
const Element = @import("webapi/Element.zig");
const Selector = @import("webapi/selector/Selector.zig");
const SelectorParser = @import("webapi/selector/Parser.zig");
const SelectorList = @import("webapi/selector/List.zig");
const CSSStyleRule = @import("webapi/css/CSSStyleRule.zig");
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CSSStyleProperties = @import("webapi/css/CSSStyleProperties.zig");
const CSSStyleProperty = @import("webapi/css/CSSStyleDeclaration.zig").Property;
const Allocator = std.mem.Allocator;
pub const VisibilityCache = std.AutoHashMapUnmanaged(*Element, bool);
pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
// Tracks visibility-relevant CSS rules from <style> elements.
// Rules are bucketed by their rightmost selector part for fast lookup.
const StyleManager = @This();
const Tag = Element.Tag;
const RuleList = std.MultiArrayList(VisibilityRule);
page: *Page,
arena: Allocator,
// Bucketed rules for fast lookup - keyed by rightmost selector part
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
// Document order counter for tie-breaking equal specificity
next_doc_order: u32 = 0,
// When true, rules need to be rebuilt
dirty: bool = false,
pub fn init(page: *Page) !StyleManager {
return .{
.page = page,
.arena = try page.getArena(.{ .debug = "StyleManager" }),
};
}
pub fn deinit(self: *StyleManager) void {
self.page.releaseArena(self.arena);
}
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
if (sheet._css_rules) |css_rules| {
for (css_rules._rules.items) |rule| {
const style_rule = rule.is(CSSStyleRule) orelse continue;
try self.addRule(style_rule);
}
return;
}
const owner_node = sheet.getOwnerNode() orelse return;
if (owner_node.is(Element.Html.Style)) |style| {
const text = try style.asNode().getTextContentAlloc(self.arena);
var it = CssParser.parseStylesheet(text);
while (it.next()) |parsed_rule| {
try self.addRawRule(parsed_rule.selector, parsed_rule.block);
}
}
}
fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []const u8) !void {
if (selector_text.len == 0) return;
var props = VisibilityProperties{};
var it = CssParser.parseDeclarationsList(block_text);
while (it.next()) |decl| {
const name = decl.name;
const val = decl.value;
if (std.ascii.eqlIgnoreCase(name, "display")) {
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
props.opacity_zero = std.ascii.eqlIgnoreCase(val, "0");
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
props.pointer_events_none = std.ascii.eqlIgnoreCase(val, "none");
}
}
if (!props.isRelevant()) return;
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
for (selectors) |selector| {
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
const bucket_key = getBucketKey(rightmost) orelse continue;
const rule = VisibilityRule{
.props = props,
.selector = selector,
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
};
self.next_doc_order += 1;
switch (bucket_key) {
.id => |id| {
const gop = try self.id_rules.getOrPut(self.arena, id);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.class => |class| {
const gop = try self.class_rules.getOrPut(self.arena, class);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.tag => |tag| {
const gop = try self.tag_rules.getOrPut(self.arena, tag);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.other => {
try self.other_rules.append(self.arena, rule);
},
}
}
}
pub fn sheetRemoved(self: *StyleManager) void {
self.dirty = true;
}
pub fn sheetModified(self: *StyleManager) void {
self.dirty = true;
}
/// Rebuilds the rule list from all document stylesheets.
/// Called lazily when dirty flag is set and rules are needed.
fn rebuildIfDirty(self: *StyleManager) !void {
if (!self.dirty) {
return;
}
self.dirty = false;
errdefer self.dirty = true;
const id_rules_count = self.id_rules.count();
const class_rules_count = self.class_rules.count();
const tag_rules_count = self.tag_rules.count();
const other_rules_count = self.other_rules.len;
self.page._session.arena_pool.resetRetain(self.arena);
self.next_doc_order = 0;
self.id_rules = .empty;
try self.id_rules.ensureTotalCapacity(self.arena, id_rules_count);
self.class_rules = .empty;
try self.class_rules.ensureTotalCapacity(self.arena, class_rules_count);
self.tag_rules = .empty;
try self.tag_rules.ensureTotalCapacity(self.arena, tag_rules_count);
self.other_rules = .{};
try self.other_rules.ensureTotalCapacity(self.arena, other_rules_count);
const sheets = self.page.document._style_sheets orelse return;
for (sheets._sheets.items) |sheet| {
self.parseSheet(sheet) catch |err| {
log.err(.browser, "StyleManager parseSheet", .{ .err = err });
return err;
};
}
}
// Check if an element is hidden based on options.
// By default only checks display:none.
// Walks up the tree to check ancestors.
pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, options: CheckVisibilityOptions) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
// Check cache first (only when checking all properties for caching consistency)
if (cache) |c| {
if (c.get(elem)) |hidden| {
if (hidden) {
return true;
}
current = elem.parentElement();
continue;
}
}
const hidden = self.isElementHidden(elem, options);
// Store in cache
if (cache) |c| {
c.put(self.page.call_arena, elem, hidden) catch {};
}
if (hidden) {
return true;
}
current = elem.parentElement();
}
return false;
}
/// Check if a single element (not ancestors) is hidden.
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
// Track best match per property (value + priority)
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
var display_none: ?bool = null;
var display_priority: u64 = 0;
var visibility_hidden: ?bool = null;
var visibility_priority: u64 = 0;
var opacity_zero: ?bool = null;
var opacity_priority: u64 = 0;
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true; // Early exit for hiding value
}
display_none = false;
display_priority = INLINE_PRIORITY;
}
if (options.check_visibility) {
if (getInlineStyleProperty(el, comptime .wrap("visibility"), self.page)) |property| {
if (property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"))) {
return true;
}
visibility_hidden = false;
visibility_priority = INLINE_PRIORITY;
}
} else {
// This can't be beat. Setting this means that, when checking rules
// we no longer have to check if options.check_visibility is enabled.
// We can just compare the priority.
visibility_priority = INLINE_PRIORITY;
}
if (options.check_opacity) {
if (getInlineStyleProperty(el, comptime .wrap("opacity"), self.page)) |property| {
if (property._value.eql(comptime .wrap("0"))) {
return true;
}
opacity_zero = false;
opacity_priority = INLINE_PRIORITY;
}
} else {
opacity_priority = INLINE_PRIORITY;
}
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
return false;
}
// Helper to check a single rule
const Ctx = struct {
display_none: *?bool,
display_priority: *u64,
visibility_hidden: *?bool,
visibility_priority: *u64,
opacity_zero: *?bool,
opacity_priority: *u64,
el: *Element,
page: *Page,
fn checkRules(ctx: @This(), rules: *const RuleList) void {
if (ctx.display_priority.* == INLINE_PRIORITY and
ctx.visibility_priority.* == INLINE_PRIORITY and
ctx.opacity_priority.* == INLINE_PRIORITY)
{
return;
}
const priorities = rules.items(.priority);
const props_list = rules.items(.props);
const selectors = rules.items(.selector);
for (priorities, props_list, selectors) |p, props, selector| {
// Fast skip using packed u64 priority
if (p <= ctx.display_priority.* and p <= ctx.visibility_priority.* and p <= ctx.opacity_priority.*) {
continue;
}
// Logic for property dominance
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
if (dominated) continue;
if (matchesSelector(ctx.el, selector, ctx.page)) {
// Update best priorities
if (props.display_none != null and p > ctx.display_priority.*) {
ctx.display_none.* = props.display_none;
ctx.display_priority.* = p;
}
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
ctx.visibility_hidden.* = props.visibility_hidden;
ctx.visibility_priority.* = p;
}
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
ctx.opacity_zero.* = props.opacity_zero;
ctx.opacity_priority.* = p;
}
}
}
}
};
const ctx = Ctx{
.display_none = &display_none,
.display_priority = &display_priority,
.visibility_hidden = &visibility_hidden,
.visibility_priority = &visibility_priority,
.opacity_zero = &opacity_zero,
.opacity_priority = &opacity_priority,
.el = el,
.page = self.page,
};
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
ctx.checkRules(&rules);
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
ctx.checkRules(&rules);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
ctx.checkRules(&rules);
}
ctx.checkRules(&self.other_rules);
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
}
/// Check if an element has pointer-events:none.
/// Checks inline style first - if set, skips stylesheet lookup.
/// Walks up the tree to check ancestors.
pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerEventsCache) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
// Check cache first
if (cache) |c| {
if (c.get(elem)) |pe_none| {
if (pe_none) return true;
current = elem.parentElement();
continue;
}
}
const pe_none = self.elementHasPointerEventsNone(elem);
if (cache) |c| {
c.put(self.page.call_arena, elem, pe_none) catch {};
}
if (pe_none) {
return true;
}
current = elem.parentElement();
}
return false;
}
/// Check if a single element (not ancestors) has pointer-events:none.
fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
const page = self.page;
// Check inline style first
if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true;
}
return false;
}
var result: ?bool = null;
var best_priority: u64 = 0;
// Helper to check a single rule
const checkRules = struct {
fn check(rules: *const RuleList, res: *?bool, current_priority: *u64, elem: *Element, p: *Page) void {
if (current_priority.* == INLINE_PRIORITY) return;
const priorities = rules.items(.priority);
const props_list = rules.items(.props);
const selectors = rules.items(.selector);
for (priorities, props_list, selectors) |priority, props, selector| {
if (priority <= current_priority.*) continue;
if (props.pointer_events_none == null) continue;
if (matchesSelector(elem, selector, p)) {
res.* = props.pointer_events_none;
current_priority.* = priority;
}
}
}
}.check;
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
checkRules(&self.other_rules, &result, &best_priority, el, page);
return result orelse false;
}
// Extracts visibility-relevant rules from a CSS rule.
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
// Buckets rules by their rightmost selector part for fast lookup.
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
const selector_text = style_rule._selector_text;
if (selector_text.len == 0) {
return;
}
// Check if the rule has visibility-relevant properties
const style = style_rule._style orelse return;
const props = extractVisibilityProperties(style);
if (!props.isRelevant()) {
return;
}
// Parse the selector list
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
if (selectors.len == 0) {
return;
}
// Create one rule per selector - each has its own specificity
// e.g., "#id, .class { display: none }" becomes two rules with different specificities
for (selectors) |selector| {
// Get the rightmost compound (last segment, or first if no segments)
const rightmost = if (selector.segments.len > 0)
selector.segments[selector.segments.len - 1].compound
else
selector.first;
// Find the bucketing key from rightmost compound
const bucket_key = getBucketKey(rightmost) orelse continue; // skip if dynamic pseudo-class
const rule = VisibilityRule{
.props = props,
.selector = selector,
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
};
self.next_doc_order += 1;
// Add to appropriate bucket
switch (bucket_key) {
.id => |id| {
const gop = try self.id_rules.getOrPut(self.arena, id);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.class => |class| {
const gop = try self.class_rules.getOrPut(self.arena, class);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.tag => |tag| {
const gop = try self.tag_rules.getOrPut(self.arena, tag);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.other => {
try self.other_rules.append(self.arena, rule);
},
}
}
}
const BucketKey = union(enum) {
id: []const u8,
class: []const u8,
tag: Tag,
other,
};
/// Returns the best bucket key for a compound selector, or null if it contains
/// a dynamic pseudo-class we should skip (hover, active, focus, etc.)
/// Priority: id > class > tag > other
fn getBucketKey(compound: Selector.Compound) ?BucketKey {
var best_key: BucketKey = .other;
for (compound.parts) |part| {
switch (part) {
.id => |id| {
best_key = .{ .id = id };
},
.class => |class| {
if (best_key != .id) {
best_key = .{ .class = class };
}
},
.tag => |tag| {
if (best_key == .other) {
best_key = .{ .tag = tag };
}
},
.tag_name => {
// Custom tag - put in other bucket (can't efficiently look up)
// Keep current best_key if we have something better
},
.pseudo_class => |pc| {
// Skip dynamic pseudo-classes - they depend on interaction state
switch (pc) {
.hover, .active, .focus, .focus_within, .focus_visible, .visited, .target => {
return null; // Skip this selector entirely
},
else => {},
}
},
.universal, .attribute => {},
}
}
return best_key;
}
/// Extracts visibility-relevant properties from a style declaration.
fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties {
var props = VisibilityProperties{};
const decl = style.asCSSStyleDeclaration();
if (decl.findProperty(comptime .wrap("display"))) |property| {
props.display_none = property._value.eql(comptime .wrap("none"));
}
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
props.visibility_hidden = property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"));
}
if (decl.findProperty(comptime .wrap("opacity"))) |property| {
props.opacity_zero = property._value.eql(comptime .wrap("0"));
}
if (decl.findProperty(.wrap("pointer-events"))) |property| {
props.pointer_events_none = property._value.eql(comptime .wrap("none"));
}
return props;
}
// Computes CSS specificity for a selector.
// Returns packed value: (id_count << 20) | (class_count << 10) | element_count
pub fn computeSpecificity(selector: Selector.Selector) u32 {
var ids: u32 = 0;
var classes: u32 = 0; // includes classes, attributes, pseudo-classes
var elements: u32 = 0; // includes elements, pseudo-elements
// Count specificity for first compound
countCompoundSpecificity(selector.first, &ids, &classes, &elements);
// Count specificity for subsequent segments
for (selector.segments) |segment| {
countCompoundSpecificity(segment.compound, &ids, &classes, &elements);
}
// Pack into single u32: (ids << 20) | (classes << 10) | elements
// This gives us 10 bits each, supporting up to 1023 of each type
return (@as(u32, @min(ids, 1023)) << 20) | (@as(u32, @min(classes, 1023)) << 10) | @min(elements, 1023);
}
fn countCompoundSpecificity(compound: Selector.Compound, ids: *u32, classes: *u32, elements: *u32) void {
for (compound.parts) |part| {
switch (part) {
.id => ids.* += 1,
.class => classes.* += 1,
.tag, .tag_name => elements.* += 1,
.universal => {}, // zero specificity
.attribute => classes.* += 1,
.pseudo_class => |pc| {
switch (pc) {
// :where() has zero specificity
.where => {},
// :not(), :is(), :has() take specificity of their most specific argument
.not, .is, .has => |nested| {
var max_nested: u32 = 0;
for (nested) |nested_sel| {
const spec = computeSpecificity(nested_sel);
if (spec > max_nested) max_nested = spec;
}
// Unpack and add to our counts
ids.* += (max_nested >> 20) & 0x3FF;
classes.* += (max_nested >> 10) & 0x3FF;
elements.* += max_nested & 0x3FF;
},
// All other pseudo-classes count as class-level specificity
else => classes.* += 1,
}
},
}
}
}
fn matchesSelector(el: *Element, selector: Selector.Selector, page: *Page) bool {
const node = el.asNode();
return SelectorList.matches(node, selector, node, page);
}
const VisibilityProperties = struct {
display_none: ?bool = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
pointer_events_none: ?bool = null,
// returne true if any field in VisibilityProperties is not null
fn isRelevant(self: VisibilityProperties) bool {
return self.display_none != null or
self.visibility_hidden != null or
self.opacity_zero != null or
self.pointer_events_none != null;
}
};
const VisibilityRule = struct {
selector: Selector.Selector, // Single selector, not a list
props: VisibilityProperties,
// Packed priority: (specificity << 32) | doc_order
priority: u64,
};
const CheckVisibilityOptions = struct {
check_opacity: bool = false,
check_visibility: bool = false,
};
// Inline styles always win over stylesheets - use max u64 as sentinel
const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
fn getInlineStyleProperty(el: *Element, property_name: String, page: *Page) ?*CSSStyleProperty {
const style = el.getOrCreateStyle(page) catch |err| {
log.err(.browser, "StyleManager getOrCreateStyle", .{ .err = err });
return null;
};
return style.asCSSStyleDeclaration().findProperty(property_name);
}
const testing = @import("../testing.zig");
test "StyleManager: computeSpecificity: element selector" {
// div -> (0, 0, 1)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .tag = .div }} },
.segments = &.{},
};
try testing.expectEqual(1, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: class selector" {
// .foo -> (0, 1, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: id selector" {
// #bar -> (1, 0, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .id = "bar" }} },
.segments = &.{},
};
try testing.expectEqual(1 << 20, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: combined selector" {
// div.foo#bar -> (1, 1, 1)
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .tag = .div },
.{ .class = "foo" },
.{ .id = "bar" },
} },
.segments = &.{},
};
try testing.expectEqual((1 << 20) | (1 << 10) | 1, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: universal selector" {
// * -> (0, 0, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.universal} },
.segments = &.{},
};
try testing.expectEqual(0, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: multiple classes" {
// .a.b.c -> (0, 3, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .class = "a" },
.{ .class = "b" },
.{ .class = "c" },
} },
.segments = &.{},
};
try testing.expectEqual(3 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: descendant combinator" {
// div span -> (0, 0, 2)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .tag = .div }} },
.segments = &.{
.{ .combinator = .descendant, .compound = .{ .parts = &.{.{ .tag = .span }} } },
},
};
try testing.expectEqual(2, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :where() has zero specificity" {
// :where(.foo) -> (0, 0, 0) regardless of what's inside
const inner_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .where = &.{inner_selector} } },
} },
.segments = &.{},
};
try testing.expectEqual(0, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :not() takes inner specificity" {
// :not(.foo) -> (0, 1, 0) - takes specificity of .foo
const inner_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .not = &.{inner_selector} } },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :is() takes most specific inner" {
// :is(.foo, #bar) -> (1, 0, 0) - takes the most specific (#bar)
const class_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const id_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .id = "bar" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .is = &.{ class_selector, id_selector } } },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 20, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: pseudo-class (general)" {
// :hover -> (0, 1, 0) - pseudo-classes count as class-level
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .hover },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: document order tie-breaking" {
// When specificity is equal, higher doc_order (later in document) wins
const beats = struct {
fn f(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
}
}.f;
// Higher specificity always wins regardless of doc_order
try testing.expect(beats(2, 0, 1, 10));
try testing.expect(!beats(1, 10, 2, 0));
// Equal specificity: higher doc_order wins
try testing.expect(beats(1, 5, 1, 3)); // doc_order 5 > 3
try testing.expect(!beats(1, 3, 1, 5)); // doc_order 3 < 5
// Equal specificity and doc_order: no win
try testing.expect(!beats(1, 5, 1, 5));
}

View File

@@ -204,7 +204,7 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
return buf.items[0 .. buf.items.len - 1 :0]; return buf.items[0 .. buf.items.len - 1 :0];
} }
const EncodeSet = enum { path, query, userinfo }; const EncodeSet = enum { path, query, userinfo, fragment };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed // Check if encoding is needed
@@ -256,8 +256,10 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
';', '=' => encode_set == .userinfo, ';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these // Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo, '/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries but not in paths or userinfo // '?' is allowed in queries only
'?' => encode_set != .query, '?' => encode_set != .query,
// '#' is allowed in fragments only
'#' => encode_set != .fragment,
// Everything else needs encoding (including space) // Everything else needs encoding (including space)
else => true, else => true,
}; };
@@ -323,14 +325,22 @@ pub fn getPassword(raw: [:0]const u8) []const u8 {
} }
pub fn getPathname(raw: [:0]const u8) []const u8 { pub fn getPathname(raw: [:0]const u8) []const u8 {
const protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0; const protocol_end = std.mem.indexOf(u8, raw, "://");
const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len;
// Handle scheme:path URLs like about:blank (no "://")
if (protocol_end == null) {
const colon_pos = std.mem.indexOfScalar(u8, raw, ':') orelse return "";
const path = raw[colon_pos + 1 ..];
const query_or_hash = std.mem.indexOfAny(u8, path, "?#") orelse path.len;
return path[0..query_or_hash];
}
const path_start = std.mem.indexOfScalarPos(u8, raw, protocol_end.? + 3, '/') orelse raw.len;
const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len; const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len;
if (path_start >= query_or_hash_start) { if (path_start >= query_or_hash_start) {
if (std.mem.indexOf(u8, raw, "://") != null) return "/"; return "/";
return "";
} }
return raw[path_start..query_or_hash_start]; return raw[path_start..query_or_hash_start];
@@ -587,11 +597,13 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato
const search = getSearch(current); const search = getSearch(current);
const hash = getHash(current); const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .path);
// Add / prefix if not present and value is not empty // Add / prefix if not present and value is not empty
const pathname = if (value.len > 0 and value[0] != '/') const pathname = if (encoded.len > 0 and encoded[0] != '/')
try std.fmt.allocPrint(allocator, "/{s}", .{value}) try std.fmt.allocPrint(allocator, "/{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -602,11 +614,13 @@ pub fn setSearch(current: [:0]const u8, value: []const u8, allocator: Allocator)
const pathname = getPathname(current); const pathname = getPathname(current);
const hash = getHash(current); const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .query);
// Add ? prefix if not present and value is not empty // Add ? prefix if not present and value is not empty
const search = if (value.len > 0 and value[0] != '?') const search = if (encoded.len > 0 and value[0] != '?')
try std.fmt.allocPrint(allocator, "?{s}", .{value}) try std.fmt.allocPrint(allocator, "?{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -617,11 +631,13 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const pathname = getPathname(current); const pathname = getPathname(current);
const search = getSearch(current); const search = getSearch(current);
const encoded = try percentEncodeSegment(allocator, value, .fragment);
// Add # prefix if not present and value is not empty // Add # prefix if not present and value is not empty
const hash = if (value.len > 0 and value[0] != '#') const hash = if (encoded.len > 0 and encoded[0] != '#')
try std.fmt.allocPrint(allocator, "#{s}", .{value}) try std.fmt.allocPrint(allocator, "#{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -1414,3 +1430,22 @@ test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page")); try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url")); try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
} }
test "URL: setPathname percent-encodes" {
// Use arena allocator to match production usage (setPathname makes intermediate allocations)
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Spaces must be encoded as %20
const result1 = try setPathname("http://a/", "c d", allocator);
try testing.expectEqualSlices(u8, "http://a/c%20d", result1);
// Already-encoded sequences must not be double-encoded
const result2 = try setPathname("https://example.com/path", "/already%20encoded", allocator);
try testing.expectEqualSlices(u8, "https://example.com/already%20encoded", result2);
// Query and hash must be preserved
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
}

View File

@@ -23,6 +23,8 @@ const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
pub fn click(node: *DOMNode, page: *Page) !void { pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -102,3 +104,34 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
}; };
} }
} }
pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode {
var timer = try std.time.Timer.start();
var runner = try session.runner(.{});
try runner.wait(.{ .ms = timeout_ms, .until = .load });
while (true) {
const page = runner.page;
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
return error.InvalidSelector;
};
if (element) |el| {
return el.asNode();
}
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
if (elapsed >= timeout_ms) {
return error.Timeout;
}
switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) {
.done => return error.Timeout,
.ok => |recommended_sleep_ms| {
if (recommended_sleep_ms > 0) {
// guanrateed to be <= 20ms
std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms);
}
},
}
}
}

View File

@@ -293,3 +293,189 @@ fn isBang(token: Tokenizer.Token) bool {
else => false, else => false,
}; };
} }
pub const Rule = struct {
selector: []const u8,
block: []const u8,
};
pub fn parseStylesheet(input: []const u8) RulesIterator {
return RulesIterator.init(input);
}
pub const RulesIterator = struct {
input: []const u8,
stream: TokenStream,
pub fn init(input: []const u8) RulesIterator {
return .{
.input = input,
.stream = TokenStream.init(input),
};
}
pub fn next(self: *RulesIterator) ?Rule {
var selector_start: ?usize = null;
var selector_end: ?usize = null;
while (true) {
const peeked = self.stream.peek() orelse return null;
if (peeked.token == .curly_bracket_block) {
if (selector_start == null) {
self.skipBlock();
continue;
}
const open_brace = self.stream.next() orelse return null;
const block_start = open_brace.end;
var block_end = block_start;
var depth: usize = 1;
while (true) {
const span = self.stream.next() orelse {
block_end = self.input.len;
break;
};
if (span.token == .curly_bracket_block) {
depth += 1;
} else if (span.token == .close_curly_bracket) {
depth -= 1;
if (depth == 0) {
block_end = span.start;
break;
}
}
}
var selector = self.input[selector_start.?..selector_end.?];
selector = std.mem.trim(u8, selector, &std.ascii.whitespace);
return .{
.selector = selector,
.block = self.input[block_start..block_end],
};
}
if (peeked.token == .at_keyword) {
self.skipAtRule();
selector_start = null;
selector_end = null;
continue;
}
if (selector_start == null and (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token))) {
_ = self.stream.next();
continue;
}
const span = self.stream.next() orelse return null;
if (!isWhitespaceOrComment(span.token)) {
if (selector_start == null) selector_start = span.start;
selector_end = span.end;
}
}
}
fn skipBlock(self: *RulesIterator) void {
const span = self.stream.next() orelse return;
if (span.token != .curly_bracket_block) return;
var depth: usize = 1;
while (true) {
const next_span = self.stream.next() orelse return;
if (next_span.token == .curly_bracket_block) {
depth += 1;
} else if (next_span.token == .close_curly_bracket) {
depth -= 1;
if (depth == 0) return;
}
}
}
fn skipAtRule(self: *RulesIterator) void {
_ = self.stream.next(); // consume @keyword
var depth: usize = 0;
var saw_block = false;
while (true) {
const peeked = self.stream.peek() orelse return;
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
if (span.token == .curly_bracket_block) {
depth += 1;
saw_block = true;
} else if (span.token == .close_curly_bracket) {
if (depth > 0) depth -= 1;
if (saw_block and depth == 0) return;
}
}
}
};
const testing = std.testing;
test "RulesIterator: single rule" {
var it = RulesIterator.init(".test { color: red; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: multiple rules" {
var it = RulesIterator.init("h1 { margin: 0; } p { padding: 10px; }");
var rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("h1", rule.selector);
try testing.expectEqualStrings(" margin: 0; ", rule.block);
rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("p", rule.selector);
try testing.expectEqualStrings(" padding: 10px; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: skips at-rules without block" {
var it = RulesIterator.init("@import url('style.css'); .test { color: red; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: skips at-rules with block" {
var it = RulesIterator.init("@media screen { .test { color: blue; } } .test2 { color: green; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test2", rule.selector);
try testing.expectEqualStrings(" color: green; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: comments and whitespace" {
var it = RulesIterator.init(" /* comment */ .test /* comment */ { /* comment */ color: red; } \n\t");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" /* comment */ color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: top-level semicolons" {
var it = RulesIterator.init("*{}; ; p{}");
var rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("*", rule.selector);
rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("p", rule.selector);
try testing.expectEqual(@as(?Rule, null), it.next());
}

View File

@@ -133,6 +133,8 @@ pub fn collectInteractiveElements(
// so classify and getListenerTypes are both O(1) per element. // so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena); const listener_targets = try buildListenerTargetMap(page, arena);
var css_cache: Element.PointerEventsCache = .empty;
var results: std.ArrayList(InteractiveElement) = .empty; var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{}); var tw = TreeWalker.Full.init(root, .{});
@@ -146,7 +148,7 @@ pub fn collectInteractiveElements(
else => {}, else => {},
} }
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue; const itype = classifyInteractivity(page, el, html_el, listener_targets, &css_cache) orelse continue;
const listener_types = getListenerTypes( const listener_types = getListenerTypes(
el.asEventTarget(), el.asEventTarget(),
@@ -210,10 +212,14 @@ pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap
} }
pub fn classifyInteractivity( pub fn classifyInteractivity(
page: *Page,
el: *Element, el: *Element,
html_el: *Element.Html, html_el: *Element.Html,
listener_targets: ListenerTargetMap, listener_targets: ListenerTargetMap,
cache: ?*Element.PointerEventsCache,
) ?InteractivityType { ) ?InteractivityType {
if (el.hasPointerEventsNone(cache, page)) return null;
// 1. Native interactive by tag // 1. Native interactive by tag
switch (el.getTag()) { switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native, .button, .summary, .details, .select, .textarea => return .native,
@@ -554,6 +560,11 @@ test "browser.interactive: disabled by fieldset" {
try testing.expect(!elements[1].disabled); try testing.expect(!elements[1].disabled);
} }
test "browser.interactive: pointer-events none" {
const elements = try testInteractive("<button style=\"pointer-events: none;\">Click me</button>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: non-interactive div" { test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>"); const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len); try testing.expectEqual(0, elements.len);

View File

@@ -18,7 +18,7 @@
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb)); testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
</script> </script>
<script id=startTime> <!-- <script id=startTime>
let a2 = document.createElement('div').animate(null, null); let a2 = document.createElement('div').animate(null, null);
// startTime defaults to null // startTime defaults to null
testing.expectEqual(null, a2.startTime); testing.expectEqual(null, a2.startTime);
@@ -67,3 +67,4 @@
}); });
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5)); testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
</script> </script>
-->

View File

@@ -419,3 +419,99 @@
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width); testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
} }
</script> </script>
<script id="CSSStyleSheet_insertRule_deleteRule">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectEqual(0, sheet.cssRules.length);
sheet.insertRule('.test { color: green; }', 0);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
testing.expectEqual('green', sheet.cssRules[0].style.color);
sheet.deleteRule(0);
testing.expectEqual(0, sheet.cssRules.length);
let caught = false;
try {
sheet.deleteRule(5);
} catch (e) {
caught = true;
testing.expectEqual('IndexSizeError', e.name);
}
testing.expectTrue(caught);
}
</script>
<script id="CSSStyleSheet_insertRule_default_index">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectEqual(0, sheet.cssRules.length);
// Call without index, should default to 0
sheet.insertRule('.test-default { color: blue; }');
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test-default', sheet.cssRules[0].selectorText);
// Insert another rule without index, should default to 0 and push the first one to index 1
sheet.insertRule('.test-at-0 { color: red; }');
testing.expectEqual(2, sheet.cssRules.length);
testing.expectEqual('.test-at-0', sheet.cssRules[0].selectorText);
testing.expectEqual('.test-default', sheet.cssRules[1].selectorText);
}
</script>
<script id="CSSStyleSheet_insertRule_semicolon">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
// Should not throw even with trailing semicolon
sheet.insertRule('*{};');
testing.expectEqual(1, sheet.cssRules.length);
}
</script>
<script id="CSSStyleSheet_replaceSync">
{
const sheet = new CSSStyleSheet();
testing.expectEqual(0, sheet.cssRules.length);
sheet.replaceSync('.test { color: blue; }');
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
testing.expectEqual('blue', sheet.cssRules[0].style.color);
let replacedAsync = false;
testing.async(async () => {
const result = await sheet.replace('.async-test { margin: 10px; }');
testing.expectTrue(result === sheet);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.async-test', sheet.cssRules[0].selectorText);
replacedAsync = true;
});
testing.eventually(() => testing.expectTrue(replacedAsync));
}
</script>
<script id="CSSStyleRule_cssText">
{
const sheet = new CSSStyleSheet();
sheet.replaceSync('.test { color: red; margin: 10px; }');
// Check serialization format
const cssText = sheet.cssRules[0].cssText;
testing.expectTrue(cssText.includes('.test { '));
testing.expectTrue(cssText.includes('color: red;'));
testing.expectTrue(cssText.includes('margin: 10px;'));
testing.expectTrue(cssText.includes('}'));
}
</script>

View File

@@ -342,3 +342,4 @@
testing.expectEqual('html', doc.lastChild.nodeName); testing.expectEqual('html', doc.lastChild.nodeName);
} }
</script> </script>

View File

@@ -0,0 +1,226 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<!-
<script id="inline_display_none">
{
const el = document.createElement("div");
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
el.style.display = "none";
testing.expectEqual(false, el.checkVisibility());
el.style.display = "block";
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="inline_visibility_hidden">
{
const el = document.createElement("div");
document.body.appendChild(el);
el.style.visibility = "hidden";
// Without visibilityProperty option, visibility:hidden is not checked
testing.expectEqual(true, el.checkVisibility());
// With visibilityProperty: true, visibility:hidden is detected
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
el.style.visibility = "collapse";
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
el.style.visibility = "visible";
testing.expectEqual(true, el.checkVisibility({ visibilityProperty: true }));
el.remove();
}
</script>
<script id="inline_opacity_zero">
{
const el = document.createElement("div");
document.body.appendChild(el);
el.style.opacity = "0";
// Without checkOpacity option, opacity:0 is not checked
testing.expectEqual(true, el.checkVisibility());
// With checkOpacity: true, opacity:0 is detected
testing.expectEqual(false, el.checkVisibility({ checkOpacity: true }));
el.style.opacity = "0.5";
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
el.style.opacity = "1";
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
el.remove();
}
</script>
<script id="parent_hidden_hides_child">
{
const parent = document.createElement("div");
const child = document.createElement("span");
parent.appendChild(child);
document.body.appendChild(parent);
testing.expectEqual(true, child.checkVisibility());
// display:none on parent hides children (no option needed)
parent.style.display = "none";
testing.expectEqual(false, child.checkVisibility());
// visibility:hidden on parent - needs visibilityProperty option
parent.style.display = "block";
parent.style.visibility = "hidden";
testing.expectEqual(true, child.checkVisibility()); // without option
testing.expectEqual(false, child.checkVisibility({ visibilityProperty: true }));
// opacity:0 on parent - needs checkOpacity option
parent.style.visibility = "visible";
parent.style.opacity = "0";
testing.expectEqual(true, child.checkVisibility()); // without option
testing.expectEqual(false, child.checkVisibility({ checkOpacity: true }));
parent.remove();
}
</script>
<style id="style-basic">
.hidden-by-class { display: none; }
.visible-by-class { display: block; }
</style>
<script id="style_tag_basic">
{
const el = document.createElement("div");
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
el.className = "hidden-by-class";
testing.expectEqual(false, el.checkVisibility());
el.className = "visible-by-class";
testing.expectEqual(true, el.checkVisibility());
el.className = "";
el.remove();
}
</script>
<style id="style-specificity">
.spec-hidden { display: none; }
#spec-visible { display: block; }
</style>
<script id="specificity_id_beats_class">
{
const el = document.createElement("div");
el.id = "spec-visible";
el.className = "spec-hidden";
document.body.appendChild(el);
// ID selector (#spec-visible: display:block) should beat class selector (.spec-hidden: display:none)
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<style id="style-order-1">
.order-test { display: none; }
</style>
<style id="style-order-2">
.order-test { display: block; }
</style>
<script id="rule_order_later_wins">
{
const el = document.createElement("div");
el.className = "order-test";
document.body.appendChild(el);
// Second style block should win (display: block)
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<style id="style-override">
.should-be-hidden { display: none; }
</style>
<script id="inline_overrides_stylesheet">
{
const el = document.createElement("div");
el.className = "should-be-hidden";
document.body.appendChild(el);
testing.expectEqual(false, el.checkVisibility());
// Inline style should override
el.style.display = "block";
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="dynamic_style_element">
{
const el = document.createElement("div");
el.className = "dynamic-style-test";
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
// Add a style element
const style = document.createElement("style");
style.textContent = ".dynamic-style-test { display: none; }";
document.head.appendChild(style);
testing.expectEqual(false, el.checkVisibility());
// Remove the style element
style.remove();
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="deep_nesting">
{
const levels = 5;
let current = document.body;
const elements = [];
for (let i = 0; i < levels; i++) {
const el = document.createElement("div");
current.appendChild(el);
elements.push(el);
current = el;
}
// All should be visible
for (let i = 0; i < levels; i++) {
testing.expectEqual(true, elements[i].checkVisibility());
}
// Hide middle element
elements[2].style.display = "none";
// Elements 0, 1 should still be visible
testing.expectEqual(true, elements[0].checkVisibility());
testing.expectEqual(true, elements[1].checkVisibility());
// Elements 2, 3, 4 should be hidden
testing.expectEqual(false, elements[2].checkVisibility());
testing.expectEqual(false, elements[3].checkVisibility());
testing.expectEqual(false, elements[4].checkVisibility());
elements[0].remove();
}
</script>

View File

@@ -29,10 +29,12 @@
testing.expectEqual('', img.src); testing.expectEqual('', img.src);
testing.expectEqual('', img.alt); testing.expectEqual('', img.alt);
testing.expectEqual('', img.currentSrc);
img.src = 'test.png'; img.src = 'test.png';
// src property returns resolved absolute URL // 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.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.currentSrc);
// getAttribute returns the raw attribute value // getAttribute returns the raw attribute value
testing.expectEqual('test.png', img.getAttribute('src')); testing.expectEqual('test.png', img.getAttribute('src'));

View File

@@ -236,9 +236,11 @@
{ {
const audio = document.createElement('audio'); const audio = document.createElement('audio');
testing.expectEqual('', audio.src); testing.expectEqual('', audio.src);
testing.expectEqual('', audio.currentSrc);
audio.src = 'test.mp3'; 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.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.currentSrc);
} }
</script> </script>

View File

@@ -131,3 +131,17 @@
testing.eventually(() => testing.expectEqual(true, result)); testing.eventually(() => testing.expectEqual(true, result));
} }
</script> </script>
<script id="style-tag-content-parsing">
{
const style = document.createElement("style");
style.textContent = '.content-test { padding: 5px; }';
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectTrue(sheet instanceof CSSStyleSheet);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.content-test', sheet.cssRules[0].selectorText);
testing.expectEqual('5px', sheet.cssRules[0].style.padding);
}
</script>

View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<head>
<title>element.replaceChildren Tests</title>
</head>
<body>
<div id="test">Original content</div>
</body>
<script id=error_replace_with_self>
{
// Test that element.replaceChildren(element) throws HierarchyRequestError
const doc = document.implementation.createHTMLDocument("title");
testing.expectError('HierarchyRequest', () => {
doc.body.replaceChildren(doc.body);
});
}
</script>
<script id=error_replace_with_ancestor>
{
// Test that replacing with an ancestor throws HierarchyRequestError
const doc = document.implementation.createHTMLDocument("title");
const child = doc.createElement('div');
doc.body.appendChild(child);
testing.expectError('HierarchyRequest', () => {
child.replaceChildren(doc.body);
});
}
</script>
<script id=replace_children_basic>
{
// Test basic element.replaceChildren
const doc = document.implementation.createHTMLDocument("title");
const child1 = doc.createElement('div');
const child2 = doc.createElement('span');
doc.body.appendChild(child1);
doc.body.replaceChildren(child2);
testing.expectEqual(1, doc.body.childNodes.length);
testing.expectEqual(child2, doc.body.firstChild);
testing.expectEqual(null, child1.parentNode);
}
</script>
<script id=replace_children_empty>
{
// Test element.replaceChildren with no arguments removes all children
const doc = document.implementation.createHTMLDocument("title");
doc.body.appendChild(doc.createElement('div'));
doc.body.appendChild(doc.createElement('span'));
doc.body.replaceChildren();
testing.expectEqual(0, doc.body.childNodes.length);
}
</script>
<script id=replace_children_fragment>
{
// Test element.replaceChildren with DocumentFragment
const doc = document.implementation.createHTMLDocument("title");
const frag = doc.createDocumentFragment();
frag.appendChild(doc.createElement('div'));
frag.appendChild(doc.createElement('span'));
doc.body.replaceChildren(frag);
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('DIV', doc.body.firstChild.tagName);
testing.expectEqual('SPAN', doc.body.lastChild.tagName);
testing.expectEqual(0, frag.childNodes.length);
}
</script>
<script id=error_fragment_replace_with_self>
{
// Test that replacing with a fragment containing self throws
const doc = document.implementation.createHTMLDocument("title");
const frag = doc.createDocumentFragment();
const child = doc.createElement('div');
frag.appendChild(child);
testing.expectError('HierarchyRequest', () => {
child.replaceChildren(frag);
});
}
</script>
<script id=replace_children_text>
{
// Test element.replaceChildren with text
const doc = document.implementation.createHTMLDocument("title");
doc.body.appendChild(doc.createElement('div'));
doc.body.replaceChildren('Hello', 'World');
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('Hello', doc.body.firstChild.textContent);
testing.expectEqual('World', doc.body.lastChild.textContent);
}
</script>
<script id=replace_children_mixed>
{
// Test element.replaceChildren with mixed nodes and text
const doc = document.implementation.createHTMLDocument("title");
const span = doc.createElement('span');
span.textContent = 'middle';
doc.body.replaceChildren('start', span, 'end');
testing.expectEqual(3, doc.body.childNodes.length);
testing.expectEqual('start', doc.body.childNodes[0].textContent);
testing.expectEqual('SPAN', doc.body.childNodes[1].tagName);
testing.expectEqual('end', doc.body.childNodes[2].textContent);
}
</script>
<script id=replace_children_reparents>
{
// Test that replaceChildren properly reparents nodes from another parent
const doc = document.implementation.createHTMLDocument("title");
const div1 = doc.createElement('div');
const div2 = doc.createElement('div');
const child = doc.createElement('span');
div1.appendChild(child);
testing.expectEqual(div1, child.parentNode);
div2.replaceChildren(child);
testing.expectEqual(div2, child.parentNode);
testing.expectEqual(0, div1.childNodes.length);
}
</script>

View File

@@ -140,8 +140,19 @@
}); });
</script> </script>
<script id=about_blank_nav>
{
let i = document.createElement('iframe');
document.documentElement.appendChild(i);
i.contentWindow.location.href = 'support/page.html';
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', i.contentDocument.documentElement.outerHTML);
});
}
</script>
<script id=count> <script id=count>
testing.eventually(() => { testing.eventually(() => {
testing.expectEqual(8, window.length); testing.expectEqual(9, window.length);
}); });
</script> </script>

View File

@@ -8,7 +8,7 @@
el.id = "delayed"; el.id = "delayed";
el.textContent = "Appeared after delay"; el.textContent = "Appeared after delay";
document.body.appendChild(el); document.body.appendChild(el);
}, 200); }, 20);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -28,3 +28,40 @@
d1.appendChild(p2); d1.appendChild(p2);
assertChildren(['p1', 'p2'], d1); assertChildren(['p1', 'p2'], d1);
</script> </script>
<div id=d3></div>
<script id=appendChild_fragment_mutation>
// Test that appendChild with DocumentFragment handles synchronous callbacks
// (like custom element connectedCallback) that modify the fragment during iteration.
// This reproduces a bug where the iterator captures "next" node pointers
// before processing, but callbacks can remove those nodes from the fragment.
const d3 = $('#d3');
const fragment = document.createDocumentFragment();
// Create custom element whose connectedCallback modifies the fragment
let bElement = null;
class ModifyingElement extends HTMLElement {
connectedCallback() {
// When this element is connected, remove 'b' from the fragment
if (bElement && bElement.parentNode === fragment) {
fragment.removeChild(bElement);
}
}
}
customElements.define('modifying-element', ModifyingElement);
const a = document.createElement('modifying-element');
a.id = 'a';
const b = document.createElement('span');
b.id = 'b';
bElement = b;
fragment.appendChild(a);
fragment.appendChild(b);
// This should not crash - appendChild should handle the modification gracefully
d3.appendChild(fragment);
// 'a' should be in d3, 'b' was removed by connectedCallback and is now detached
assertChildren(['a'], d3);
testing.expectEqual(null, b.parentNode);
</script>

View File

@@ -591,6 +591,35 @@
testing.expectEqual('/new/path', url.pathname); 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'); const url = new URL('https://example.com/path');
url.search = '?a=b'; url.search = '?a=b';
@@ -656,6 +685,20 @@
testing.expectEqual('', url.hash); 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'); const url = new URL('https://example.com/path?a=b');
url.search = ''; url.search = '';
@@ -673,6 +716,20 @@
testing.expectEqual(null, url.searchParams.get('a')); 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 url = new URL('https://example.com/path?a=b');
const sp = url.searchParams; const sp = url.searchParams;
@@ -798,3 +855,19 @@
testing.expectEqual(true, url2.startsWith('blob:')); testing.expectEqual(true, url2.startsWith('blob:'));
} }
</script> </script>
<script id="about:blank">
{
const url = new URL('about:blank');
testing.expectEqual('about:blank', url.href);
testing.expectEqual('null', url.origin);
testing.expectEqual('about:', url.protocol);
testing.expectEqual('blank', url.pathname);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
testing.expectEqual('', url.host);
testing.expectEqual('', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('', url.search);
}
</script>

View File

@@ -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 { pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
try validateDocumentNodes(self, nodes, true); try validateDocumentNodes(self, nodes, false);
return self.asNode().replaceChildren(nodes, page);
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 });
}
} }
pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element { 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) { while (stack.items.len > 0) {
const node = stack.pop() orelse break; const node = stack.pop() orelse break;
if (node.is(Element)) |element| { if (node.is(Element)) |element| {
if (element.checkVisibility(page)) { if (element.checkVisibilityCached(null, page)) {
const rect = element.getBoundingClientRectForVisible(page); const rect = element.getBoundingClientRectForVisible(page);
if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) { if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) {
topmost = element; topmost = element;
@@ -717,9 +690,16 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
} }
// Determine insertion point: // Determine insertion point:
// - If _write_insertion_point is set, continue from there (subsequent write) // - If _write_insertion_point is set and still parented correctly, continue from there
// - Otherwise, start after the script (first write) // - Otherwise, start after the script (first write, or previous insertion point was removed)
var insert_after: ?*Node = self._write_insertion_point orelse script.asNode(); 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| { for (children_to_insert.items) |child| {
// Clear parent pointer (child is currently parented to fragment/HTML wrapper) // 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) { if (has_doctype) {
return error.HierarchyError; return error.HierarchyError;
} }
if (has_element) {
// Doctype cannot be inserted if document already has an element
return error.HierarchyError;
}
has_doctype = true; has_doctype = true;
}, },
.cdata => |cd| switch (cd._type) { .cdata => |cd| switch (cd._type) {
@@ -918,6 +902,10 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti
if (has_doctype) { if (has_doctype) {
return error.HierarchyError; return error.HierarchyError;
} }
if (has_element) {
// Doctype cannot be inserted if document already has an element
return error.HierarchyError;
}
has_doctype = true; has_doctype = true;
}, },
.cdata => |cd| switch (cd._type) { .cdata => |cd| switch (cd._type) {

View File

@@ -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 { pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void {
page.domChanged(); return self.asNode().replaceChildren(nodes, page);
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 });
}
} }
pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void { pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void {

View File

@@ -24,6 +24,7 @@ const String = @import("../../string.zig").String;
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const StyleManager = @import("../StyleManager.zig");
const reflect = @import("../reflect.zig"); const reflect = @import("../reflect.zig");
const Node = @import("Node.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 { pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
page.domChanged(); return self.asNode().replaceChildren(nodes, page);
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 });
}
} }
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void { 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(); return self._proto.parentElement();
} }
pub fn checkVisibility(self: *Element, page: *Page) bool { /// Cache for visibility checks - re-exported from StyleManager for convenience.
var current: ?*Element = self; pub const VisibilityCache = StyleManager.VisibilityCache;
while (current) |el| { /// Cache for pointer-events checks - re-exported from StyleManager for convenience.
if (el.getStyle(page)) |style| { pub const PointerEventsCache = StyleManager.PointerEventsCache;
const display = style.asCSSStyleDeclaration().getPropertyValue("display", page);
if (std.mem.eql(u8, display, "none")) {
return false;
}
}
current = el.parentElement();
}
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 } { 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 { pub fn getClientWidth(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
const dims = self.getElementDimensions(page); 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 { pub fn getClientHeight(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
const dims = self.getElementDimensions(page); 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 { pub fn getBoundingClientRect(self: *Element, page: *Page) DOMRect {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return .{ return .{
._x = 0.0, ._x = 0.0,
._y = 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 { pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return &.{}; return &.{};
} }
const rects = try page.call_arena.alloc(DOMRect, 1); 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 { pub fn getOffsetHeight(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
const dims = self.getElementDimensions(page); 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 { pub fn getOffsetWidth(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
const dims = self.getElementDimensions(page); 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 { pub fn getOffsetTop(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
return calculateDocumentPosition(self.asNode()); return calculateDocumentPosition(self.asNode());
} }
pub fn getOffsetLeft(self: *Element, page: *Page) f64 { pub fn getOffsetLeft(self: *Element, page: *Page) f64 {
if (!self.checkVisibility(page)) { if (!self.checkVisibilityCached(null, page)) {
return 0.0; return 0.0;
} }
return calculateSiblingPosition(self.asNode()); return calculateSiblingPosition(self.asNode());

View File

@@ -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); 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 // Writes a JSON representation of the node and its children
pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void { pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
// stupid json api requires this to be const, // stupid json api requires this to be const,

View File

@@ -37,6 +37,14 @@ pub const resolve = @import("../URL.zig").resolve;
pub const eqlDocument = @import("../URL.zig").eqlDocument; pub const eqlDocument = @import("../URL.zig").eqlDocument;
pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL { 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 url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url);
const base = if (base_) |b| blk: { 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; return error.TypeError;
} else page.url; } else page.url;
const arena = page.arena;
const raw = try resolve(arena, base, url, .{ .always_dupe = true }); const raw = try resolve(arena, base, url, .{ .always_dupe = true });
return page._factory.create(URL{ return page._factory.create(URL{

View File

@@ -285,23 +285,23 @@ pub fn queueMicrotask(_: *Window, cb: js.Function, page: *Page) void {
} }
pub fn clearTimeout(self: *Window, id: u32) void { pub fn clearTimeout(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return; var sc = self._timers.fetchRemove(id) orelse return;
sc.removed = true; sc.value.removed = true;
} }
pub fn clearInterval(self: *Window, id: u32) void { pub fn clearInterval(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return; var sc = self._timers.fetchRemove(id) orelse return;
sc.removed = true; sc.value.removed = true;
} }
pub fn clearImmediate(self: *Window, id: u32) void { pub fn clearImmediate(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return; var sc = self._timers.fetchRemove(id) orelse return;
sc.removed = true; sc.value.removed = true;
} }
pub fn cancelAnimationFrame(self: *Window, id: u32) void { pub fn cancelAnimationFrame(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return; var sc = self._timers.fetchRemove(id) orelse return;
sc.removed = true; sc.value.removed = true;
} }
const RequestIdleCallbackOpts = struct { 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 { pub fn cancelIdleCallback(self: *Window, id: u32) void {
var sc = self._timers.get(id) orelse return; var sc = self._timers.fetchRemove(id) orelse return;
sc.removed = true; sc.value.removed = true;
} }
pub fn reportError(self: *Window, err: js.Value, page: *Page) !void { pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
@@ -704,7 +704,6 @@ const ScheduleCallback = struct {
const window = page.window; const window = page.window;
if (self.removed) { if (self.removed) {
_ = window._timers.remove(self.timer_id);
self.deinit(); self.deinit();
return null; return null;
} }

View File

@@ -62,7 +62,7 @@ const Filters = union(Mode) {
selected_options, selected_options,
links, links,
anchors, anchors,
form: *Form, form: struct { form: *Form, form_id: ?[]const u8 },
fn TypeOf(comptime mode: Mode) type { fn TypeOf(comptime mode: Mode) type {
@setEvalBranchQuota(2000); @setEvalBranchQuota(2000);
@@ -304,9 +304,13 @@ pub fn NodeLive(comptime mode: Mode) type {
return false; return false;
} }
if (el.getAttributeSafe(comptime .wrap("form"))) |form_attr| { if (self._filter.form_id) |form_id| {
const form_id = self._filter.asElement().getAttributeSafe(comptime .wrap("id")) orelse return false; if (el.getAttributeSafe(comptime .wrap("form"))) |element_form_attr| {
return std.mem.eql(u8, form_attr, form_id); 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 // 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 // This trades one O(form_size) reverse walk for N O(depth) ancestor
// checks, where N = number of controls. For forms with many nested // checks, where N = number of controls. For forms with many nested
// controls, this could be significantly faster. // controls, this could be significantly faster.
return self._filter.asNode().contains(node); return self._filter.form.asNode().contains(node);
}, },
} }
} }

View File

@@ -2,29 +2,42 @@ const std = @import("std");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const CSSStyleRule = @import("CSSStyleRule.zig");
const CSSRule = @This(); const CSSRule = @This();
pub const Type = enum(u16) { pub const Type = union(enum) {
style = 1, style: *CSSStyleRule,
charset = 2, charset: void,
import = 3, import: void,
media = 4, media: void,
font_face = 5, font_face: void,
page = 6, page: void,
keyframes = 7, keyframes: void,
keyframe = 8, keyframe: void,
margin = 9, margin: void,
namespace = 10, namespace: void,
counter_style = 11, counter_style: void,
supports = 12, supports: void,
document = 13, document: void,
font_feature_values = 14, font_feature_values: void,
viewport = 15, viewport: void,
region_style = 16, region_style: void,
}; };
_type: Type, _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 { pub fn init(rule_type: Type, page: *Page) !*CSSRule {
return page._factory.create(CSSRule{ return page._factory.create(CSSRule{
._type = rule_type, ._type = rule_type,
@@ -32,23 +45,14 @@ pub fn init(rule_type: Type, page: *Page) !*CSSRule {
} }
pub fn getType(self: *const CSSRule) u16 { 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 { pub fn getCssText(_: *const CSSRule, _: *Page) []const u8 {
_ = self;
_ = page;
return ""; return "";
} }
pub fn setCssText(self: *CSSRule, text: []const u8, page: *Page) !void { pub fn getParentRule(_: *const CSSRule) ?*CSSRule {
_ = self;
_ = text;
_ = page;
}
pub fn getParentRule(self: *const CSSRule) ?*CSSRule {
_ = self;
return null; return null;
} }
@@ -62,8 +66,8 @@ pub const JsApi = struct {
pub const Meta = struct { pub const Meta = struct {
pub const name = "CSSRule"; pub const name = "CSSRule";
pub var class_id: bridge.ClassId = undefined;
pub const prototype_chain = bridge.prototypeChain(); pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
}; };
pub const STYLE_RULE = 1; pub const STYLE_RULE = 1;
@@ -84,7 +88,7 @@ pub const JsApi = struct {
pub const REGION_STYLE_RULE = 16; pub const REGION_STYLE_RULE = 16;
pub const @"type" = bridge.accessor(CSSRule.getType, null, .{}); 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 parentRule = bridge.accessor(CSSRule.getParentRule, null, .{});
pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{}); pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{});
}; };

View File

@@ -5,21 +5,39 @@ const CSSRule = @import("CSSRule.zig");
const CSSRuleList = @This(); const CSSRuleList = @This();
_rules: []*CSSRule = &.{}, _rules: std.ArrayList(*CSSRule) = .empty,
pub fn init(page: *Page) !*CSSRuleList { pub fn init(page: *Page) !*CSSRuleList {
return page._factory.create(CSSRuleList{}); return page._factory.create(CSSRuleList{});
} }
pub fn length(self: *const CSSRuleList) u32 { 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 { pub fn item(self: *const CSSRuleList, index: usize) ?*CSSRule {
if (index >= self._rules.len) { if (index >= self._rules.items.len) {
return null; 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 { pub const JsApi = struct {

View File

@@ -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 { pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
const normalized = normalizePropertyName(property_name, &page.buf); 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 // Only return default values for computed styles
if (self._is_computed) { if (self._is_computed) {
return getDefaultPropertyValue(self, normalized); return getDefaultPropertyValue(self, wrapped);
} }
return ""; 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 { pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
const normalized = normalizePropertyName(property_name, &page.buf); 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 ""; 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); const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value);
// Find existing property // Find existing property
if (self.findProperty(normalized)) |existing| { if (self.findProperty(.wrap(normalized))) |existing| {
existing._value = try String.init(page.arena, normalized_value, .{}); existing._value = try String.init(page.arena, normalized_value, .{});
existing._important = important; existing._important = important;
return; 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 { fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 {
const normalized = normalizePropertyName(property_name, &page.buf); 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 // the value might not be on the heap (it could be inlined in the small string
// optimization), so we need to dupe it. // 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 { 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); var buf = std.Io.Writer.Allocating.init(page.call_arena);
try self.format(&buf.writer); try self.format(&buf.writer);
return buf.written(); return buf.written();
} }
pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void { pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void {
if (self._element == null) return;
// Clear existing properties // Clear existing properties
var node = self._properties.first; var node = self._properties.first;
while (node) |n| { 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; var node = self._properties.first;
while (node) |n| { while (node) |n| {
const prop = Property.fromNodeLink(n); const prop = Property.fromNodeLink(n);
if (prop._name.eqlSlice(name)) { if (prop._name.eql(name)) {
return prop; return prop;
} }
node = n.next; 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 // Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword
if (std.mem.indexOf(u8, value, "anchor-size(") != null) { if (std.mem.indexOf(u8, value, "anchor-size(")) |idx| {
return try canonicalizeAnchorSize(arena, value); 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; 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. // 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)" // 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 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) { while (i < value.len) {
// Look for "anchor-size(" // Look for "anchor-size("
@@ -279,7 +288,7 @@ fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 {
i += "anchor-size(".len; i += "anchor-size(".len;
// Parse and canonicalize the arguments // Parse and canonicalize the arguments
i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer); i = try canonicalizeAnchorFnArgs(value, i, &buf.writer, .anchor_size);
} else { } else {
try buf.writer.writeByte(value[i]); try buf.writer.writeByte(value[i]);
i += 1; i += 1;
@@ -289,21 +298,24 @@ fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 {
return buf.written(); return buf.written();
} }
// Parse anchor-size arguments and write them in canonical order const AnchorFnKind = enum { anchor, anchor_size };
fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize {
// 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 i = start;
var depth: usize = 1; var depth: usize = 1;
// Skip leading whitespace // Skip leading whitespace
while (i < value.len and value[i] == ' ') : (i += 1) {} 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 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; const args_start = i;
var in_token = false; 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 first_token = value[first_start..first_token_end];
const second_token = value[second_start..second_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 second token is a dashed ident, it should come first
if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) { // 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.writeAll(second_token);
try writer.writeByte(' '); try writer.writeByte(' ');
try writer.writeAll(first_token); try writer.writeAll(first_token);
} else { } else {
// Keep original order
try writer.writeAll(first_token); try writer.writeAll(first_token);
try writer.writeByte(' '); try writer.writeByte(' ');
try writer.writeAll(second_token); 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]); 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| { if (comma_pos) |cp| {
try writer.writeAll(", "); try writer.writeAll(", ");
i = cp + 1; i = cp + 1;
// Skip whitespace after comma // Skip whitespace after comma
while (i < value.len and value[i] == ' ') : (i += 1) {} 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) { while (i < value.len and depth > 0) {
if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { if (std.mem.startsWith(u8, value[i..], "anchor-size(")) {
try writer.writeAll("anchor-size("); try writer.writeAll("anchor-size(");
i += "anchor-size(".len; i += "anchor-size(".len;
depth += 1; 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; depth -= 1;
} else if (value[i] == '(') { } else if (value[i] == '(') {
depth += 1; depth += 1;
@@ -446,6 +467,33 @@ fn isAnchorSizeKeyword(token: []const u8) bool {
return keywords.has(token); 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" // Check if a value is "X X" (duplicate) and return just "X"
fn collapseDuplicateValue(value: []const u8) ?[]const u8 { fn collapseDuplicateValue(value: []const u8) ?[]const u8 {
const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; 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); return length_properties.has(name);
} }
fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []const u8 {
if (std.mem.eql(u8, normalized_name, "visibility")) { switch (name.len) {
return "visible"; 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 ""; return "";
} }
@@ -738,8 +796,7 @@ pub const JsApi = struct {
pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{}); 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" { test "normalizePropertyValue: unitless zero to 0px" {
const cases = .{ const cases = .{
.{ "width", "0", "0px" }, .{ "width", "0", "0px" },
@@ -760,16 +817,16 @@ test "normalizePropertyValue: unitless zero to 0px" {
}; };
inline for (cases) |case| { inline for (cases) |case| {
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); 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" { test "normalizePropertyValue: first baseline to baseline" {
const result = try normalizePropertyValue(testing.allocator, "align-items", "first 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"); 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" { test "normalizePropertyValue: collapse duplicate two-value shorthands" {
@@ -786,6 +843,27 @@ test "normalizePropertyValue: collapse duplicate two-value shorthands" {
}; };
inline for (cases) |case| { inline for (cases) |case| {
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]); 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);
} }
} }

View File

@@ -2,19 +2,20 @@ const std = @import("std");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const CSSRule = @import("CSSRule.zig"); const CSSRule = @import("CSSRule.zig");
const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig"); const CSSStyleProperties = @import("CSSStyleProperties.zig");
const CSSStyleRule = @This(); const CSSStyleRule = @This();
_proto: *CSSRule, _proto: *CSSRule,
_selector_text: []const u8 = "", _selector_text: []const u8 = "",
_style: ?*CSSStyleDeclaration = null, _style: ?*CSSStyleProperties = null,
pub fn init(page: *Page) !*CSSStyleRule { pub fn init(page: *Page) !*CSSStyleRule {
const rule = try CSSRule.init(.style, page); const style_rule = try page._factory.create(CSSStyleRule{
return page._factory.create(CSSStyleRule{ ._proto = undefined,
._proto = rule,
}); });
style_rule._proto = try CSSRule.init(.{ .style = style_rule }, page);
return style_rule;
} }
pub fn getSelectorText(self: *const CSSStyleRule) []const u8 { 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); 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| { if (self._style) |style| {
return style; return style;
} }
const style = try CSSStyleDeclaration.init(null, false, page); const style = try CSSStyleProperties.init(null, false, page);
self._style = style; self._style = style;
return 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 JsApi = struct {
pub const bridge = js.Bridge(CSSStyleRule); pub const bridge = js.Bridge(CSSStyleRule);
pub const Meta = struct { pub const Meta = struct {
pub const name = "CSSStyleRule"; 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 var class_id: bridge.ClassId = undefined;
}; };
pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{}); pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{});
pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{}); pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{});
pub const cssText = bridge.accessor(CSSStyleRule.getCssText, null, .{});
}; };

View File

@@ -4,9 +4,19 @@ const Page = @import("../../Page.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const CSSRuleList = @import("CSSRuleList.zig"); const CSSRuleList = @import("CSSRuleList.zig");
const CSSRule = @import("CSSRule.zig"); const CSSRule = @import("CSSRule.zig");
const CSSStyleRule = @import("CSSStyleRule.zig");
const Parser = @import("../../css/Parser.zig");
const CSSStyleSheet = @This(); const CSSStyleSheet = @This();
pub const CSSError = error{
OutOfMemory,
IndexSizeError,
WriteFailed,
StringTooLarge,
SyntaxError,
};
_href: ?[]const u8 = null, _href: ?[]const u8 = null,
_title: []const u8 = "", _title: []const u8 = "",
_disabled: bool = false, _disabled: bool = false,
@@ -44,8 +54,17 @@ pub fn setDisabled(self: *CSSStyleSheet, disabled: bool) void {
pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList { pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList {
if (self._css_rules) |rules| return rules; if (self._css_rules) |rules| return rules;
const rules = try CSSRuleList.init(page); const rules = try CSSRuleList.init(page);
self._css_rules = rules; 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; return rules;
} }
@@ -53,31 +72,60 @@ pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule {
return self._owner_rule; return self._owner_rule;
} }
pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, index: u32, page: *Page) !u32 { pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, page: *Page) !u32 {
_ = self; const index = maybe_index orelse 0;
_ = rule; var it = Parser.parseStylesheet(rule);
_ = index; const parsed_rule = it.next() orelse return error.SyntaxError;
_ = page;
return 0; 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 { pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void {
_ = self; const rules = try self.getCssRules(page);
_ = index; try rules.remove(index);
_ = page;
// Notify StyleManager that rules have changed
page._style_manager.sheetModified();
} }
pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise { pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) CSSError!js.Promise {
_ = self; try self.replaceSync(text, page);
_ = text; return page.js.local.?.resolvePromise(self);
// TODO: clear self.css_rules
return page.js.local.?.resolvePromise({});
} }
pub fn replaceSync(self: *CSSStyleSheet, text: []const u8) !void { pub fn replaceSync(self: *CSSStyleSheet, text: []const u8, page: *Page) CSSError!void {
_ = self; const rules = try self.getCssRules(page);
_ = text; rules.clear();
// TODO: clear self.css_rules
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 { pub const JsApi = struct {
@@ -96,13 +144,15 @@ pub const JsApi = struct {
pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{}); pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{});
pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{}); pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{});
pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{}); pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{});
pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{}); pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{ .dom_exception = true });
pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{}); pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{ .dom_exception = true });
pub const replace = bridge.function(CSSStyleSheet.replace, .{}); pub const replace = bridge.function(CSSStyleSheet.replace, .{});
pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{}); pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{});
}; };
const testing = @import("../../../testing.zig"); const testing = @import("../../../testing.zig");
test "WebApi: CSSStyleSheet" { test "WebApi: CSSStyleSheet" {
const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("css/stylesheet.html", .{}); try testing.htmlRunner("css/stylesheet.html", .{});
} }

View File

@@ -5,19 +5,32 @@ const CSSStyleSheet = @import("CSSStyleSheet.zig");
const StyleSheetList = @This(); const StyleSheetList = @This();
_sheets: []*CSSStyleSheet = &.{}, _sheets: std.ArrayList(*CSSStyleSheet) = .empty,
pub fn init(page: *Page) !*StyleSheetList { pub fn init(page: *Page) !*StyleSheetList {
return page._factory.create(StyleSheetList{}); return page._factory.create(StyleSheetList{});
} }
pub fn length(self: *const StyleSheetList) u32 { 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 { pub fn item(self: *const StyleSheetList, index: usize) ?*CSSStyleSheet {
if (index >= self._sheets.len) return null; if (index >= self._sheets.items.len) return null;
return self._sheets[index]; 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 { pub const JsApi = struct {

View File

@@ -73,18 +73,22 @@ pub fn setMethod(self: *Form, method: []const u8, page: *Page) !void {
} }
pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsCollection { 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 form_id = self.asElement().getAttributeSafe(comptime .wrap("id"));
const root = if (form_id != null) const root = if (form_id != null)
self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls
else else
self.asNode(); // No ID: walk only form subtree (no external controls possible) self.asNode(); // No ID: walk only form subtree (no external controls possible)
const node_live = collections.NodeLive(.form).init(root, self, page); return collections.NodeLive(.form).init(root, .{ .form = self, .form_id = form_id }, page);
const html_collection = try node_live.runtimeGenericWrap(page);
return page._factory.create(collections.HTMLFormControlsCollection{
._proto = html_collection,
});
} }
pub fn getAction(self: *Form, page: *Page) ![]const u8 { pub fn getAction(self: *Form, page: *Page) ![]const u8 {

View File

@@ -150,6 +150,7 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(Image.constructor, .{}); pub const constructor = bridge.constructor(Image.constructor, .{});
pub const src = bridge.accessor(Image.getSrc, Image.setSrc, .{}); 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 alt = bridge.accessor(Image.getAlt, Image.setAlt, .{});
pub const width = bridge.accessor(Image.getWidth, Image.setWidth, .{}); pub const width = bridge.accessor(Image.getWidth, Image.setWidth, .{});
pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{}); pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{});

View File

@@ -308,6 +308,7 @@ pub const JsApi = struct {
pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA), .{ .template = true }); 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 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 autoplay = bridge.accessor(Media.getAutoplay, Media.setAutoplay, .{});
pub const controls = bridge.accessor(Media.getControls, Media.setControls, .{}); pub const controls = bridge.accessor(Media.getControls, Media.setControls, .{});
pub const loop = bridge.accessor(Media.getLoop, Media.setLoop, .{}); pub const loop = bridge.accessor(Media.getLoop, Media.setLoop, .{});

View File

@@ -94,10 +94,20 @@ pub fn getSheet(self: *Style, page: *Page) !?*CSSStyleSheet {
if (self._sheet) |sheet| return sheet; if (self._sheet) |sheet| return sheet;
const sheet = try CSSStyleSheet.initWithOwner(self.asElement(), page); const sheet = try CSSStyleSheet.initWithOwner(self.asElement(), page);
self._sheet = sheet; self._sheet = sheet;
const sheets = try page.document.getStyleSheets(page);
try sheets.add(sheet, page);
return sheet; return sheet;
} }
pub fn styleAddedCallback(self: *Style, page: *Page) !void { 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 we're planning on navigating to another page, don't trigger load event.
if (page.isGoingAway()) { if (page.isGoingAway()) {
return; return;

View File

@@ -33,6 +33,7 @@ const Page = @import("../browser/Page.zig");
const Incrementing = @import("id.zig").Incrementing; const Incrementing = @import("id.zig").Incrementing;
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState; const InterceptState = @import("domains/fetch.zig").InterceptState;
const Mime = @import("../browser/Mime.zig");
pub const URL_BASE = "chrome://newtab/"; 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 // 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 // scheduled task. So we run this directly in order to process any
// timeouts (or http events) which are ready to be processed. // timeouts (or http events) which are ready to be processed.
pub fn pageWait(self: *Self, ms: u32) Session.WaitResult { pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult {
const session = &(self.browser.session orelse return .no_page); const session = &(self.browser.session orelse return error.NoPage);
return session.wait(.{ .timeout_ms = ms }); var runner = try session.runner(.{});
return runner.waitCDP(.{ .ms = ms });
} }
// Called from above, in processMessage which handles client messages // 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) // (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype, method: []const u8) !void { fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a // 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")) { if (std.mem.eql(u8, method, "Page.getFrameTree")) {
return command.sendResult(.{ // The Page.getFrameTree handles startup response gracefully.
.frameTree = .{ return dispatchCommand(command, method);
.frame = .{
.id = "TID-STARTUP",
.loaderId = "LOADERID24DD2FD56CF1EF33C965C79C",
.securityOrigin = URL_BASE,
.url = "about:blank",
.secureContextType = "Secure",
},
},
}, .{});
} }
return command.sendResult(null, .{}); return command.sendResult(null, .{});
@@ -324,6 +317,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const AXNode = @import("AXNode.zig"); const AXNode = @import("AXNode.zig");
const CapturedResponse = struct {
must_encode: bool,
data: std.ArrayList(u8),
};
return struct { return struct {
id: []const u8, id: []const u8,
cdp: *CDP_T, 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 // 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 // memory for an arbitrary amount of time, then that's where we're going
// to store the, // to store the,
captured_responses: std.AutoHashMapUnmanaged(usize, std.ArrayList(u8)), captured_responses: std.AutoHashMapUnmanaged(usize, CapturedResponse),
notification: *Notification, notification: *Notification,
@@ -637,6 +635,35 @@ pub fn BrowserContext(comptime CDP_T: type) type {
pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void { pub fn onHttpResponseHeadersDone(ctx: *anyopaque, msg: *const Notification.ResponseHeaderDone) !void {
const self: *Self = @ptrCast(@alignCast(ctx)); const self: *Self = @ptrCast(@alignCast(ctx));
defer self.resetNotificationArena(); 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); 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 arena = self.page_arena;
const id = msg.transfer.id; const id = msg.transfer.id;
const gop = try self.captured_responses.getOrPut(arena, id); const resp = self.captured_responses.getPtr(id) orelse lp.assert(false, "onHttpResponseData missinf captured response", .{});
if (!gop.found_existing) {
gop.value_ptr.* = .{}; return resp.data.appendSlice(arena, msg.data);
}
try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data));
} }
pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {

View File

@@ -36,6 +36,7 @@ pub fn processMessage(cmd: anytype) !void {
clickNode, clickNode,
fillNode, fillNode,
scrollNode, scrollNode,
waitForSelector,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
@@ -47,6 +48,7 @@ pub fn processMessage(cmd: anytype) !void {
.clickNode => return clickNode(cmd), .clickNode => return clickNode(cmd),
.fillNode => return fillNode(cmd), .fillNode => return fillNode(cmd),
.scrollNode => return scrollNode(cmd), .scrollNode => return scrollNode(cmd),
.waitForSelector => return waitForSelector(cmd),
} }
} }
@@ -257,6 +259,32 @@ fn scrollNode(cmd: anytype) !void {
return cmd.sendResult(.{}, .{}); 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"); const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" { test "cdp.lp: getMarkdown" {
var ctx = testing.context(); var ctx = testing.context();
@@ -315,7 +343,8 @@ test "cdp.lp: action tools" {
const page = try bc.session.createPage(); const page = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); 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 // Test Click
const btn = page.document.getElementById("btn", page).?.asNode(); const btn = page.document.getElementById("btn", page).?.asNode();
@@ -366,3 +395,44 @@ test "cdp.lp: action tools" {
try testing.expect(result.isTrue()); 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);
}

View File

@@ -208,11 +208,22 @@ fn getResponseBody(cmd: anytype) !void {
const request_id = try idFromRequestId(params.requestId); const request_id = try idFromRequestId(params.requestId);
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; 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(.{ if (!resp.must_encode) {
.body = buf.items, return cmd.sendResult(.{
.base64Encoded = false, .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,
}, .{}); }, .{});
} }

View File

@@ -75,8 +75,21 @@ const Frame = struct {
}; };
fn getFrameTree(cmd: anytype) !void { fn getFrameTree(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // Stagehand parses the response and error if we don't return a
const target_id = bc.target_id orelse return error.TargetNotLoaded; // 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(.{ return cmd.sendResult(.{
.frameTree = .{ .frameTree = .{
@@ -633,8 +646,18 @@ test "cdp.page: getFrameTree" {
defer ctx.deinit(); defer ctx.deinit();
{ {
try ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }); // no browser context - should return TID-STARTUP
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); 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".* }); 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 }); }, .{ .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" { test "cdp.page: captureScreenshot" {

View File

@@ -136,7 +136,8 @@ const TestContext = struct {
0, 0,
); );
try page.navigate(full_url, .{}); try page.navigate(full_url, .{});
_ = bc.session.wait(.{}); var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
} }
return bc; return bc;
} }

View File

@@ -109,7 +109,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void {
.reason = .address_bar, .reason = .address_bar,
.kind = .{ .push = null }, .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; const writer = opts.writer orelse return;
if (opts.dump_mode) |mode| { if (opts.dump_mode) |mode| {

View File

@@ -106,7 +106,8 @@ pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void {
defer try_catch.deinit(); defer try_catch.deinit();
try page.navigate(url, .{}); 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| { ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| {
const caught = try_catch.caughtOrError(allocator, err); const caught = try_catch.caughtOrError(allocator, err);

View File

@@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) {
InvalidParams = -32602, InvalidParams = -32602,
InternalError = -32603, InternalError = -32603,
PageNotLoaded = -32604, PageNotLoaded = -32604,
NotFound = -32605,
}; };
pub const Notification = struct { pub const Notification = struct {

View File

@@ -115,7 +115,7 @@ pub const tool_list = [_]protocol.Tool{
}, },
.{ .{
.name = "click", .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( .inputSchema = protocol.minify(
\\{ \\{
\\ "type": "object", \\ "type": "object",
@@ -128,7 +128,7 @@ pub const tool_list = [_]protocol.Tool{
}, },
.{ .{
.name = "fill", .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( .inputSchema = protocol.minify(
\\{ \\{
\\ "type": "object", \\ "type": "object",
@@ -142,7 +142,7 @@ pub const tool_list = [_]protocol.Tool{
}, },
.{ .{
.name = "scroll", .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( .inputSchema = protocol.minify(
\\{ \\{
\\ "type": "object", \\ "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"); 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 }); 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"); 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 }); 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"); 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 }); 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 { fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const WaitParams = struct { const WaitParams = struct {
selector: [:0]const u8, 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 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"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
}; };
const timeout_ms = args.timeout orelse 5000; const timeout_ms = args.timeout orelse 5000;
var timer = try std.time.Timer.start();
while (true) { const node = lp.actions.waitForSelector(args.selector, timeout_ms, server.session) catch |err| {
const element = Selector.querySelector(page.document.asNode(), args.selector, page) catch { if (err == error.InvalidSelector) {
return server.sendError(id, .InvalidParams, "Invalid selector"); return server.sendError(id, .InvalidParams, "Invalid selector");
}; } else if (err == error.Timeout) {
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) {
return server.sendError(id, .InternalError, "Timeout waiting for selector"); 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 { 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; 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 router = @import("router.zig");
const testing = @import("../testing.zig");
test "MCP - evaluate error reporting" { test "MCP - evaluate error reporting" {
defer testing.reset(); defer testing.reset();
const allocator = testing.allocator; var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const app = testing.test_app; const server = try testLoadPage("about:blank", &out.writer);
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);
defer server.deinit(); defer server.deinit();
_ = try server.session.createPage();
const aa = testing.arena_allocator;
// Call evaluate with a script that throws an error // Call evaluate with a script that throws an error
const msg = 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( try testing.expectJson(.{ .id = 1, .result = .{
\\{ .isError = true,
\\ "id": 1, .content = &.{.{ .type = "text" }},
\\ "result": { } }, out.written());
\\ "isError": true,
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
} }
test "MCP - Actions: click, fill, scroll" { test "MCP - Actions: click, fill, scroll" {
defer testing.reset(); defer testing.reset();
const allocator = testing.allocator; const aa = testing.arena_allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); var out: std.io.Writer.Allocating = .init(aa);
defer out_alloc.deinit(); const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit(); defer server.deinit();
const aa = testing.arena_allocator; const page = &server.session.page.?;
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(.{});
// Test Click {
const btn = page.document.getElementById("btn", page).?.asNode(); // Test Click
const btn_id = (try server.node_registry.register(btn)).id; const btn = page.document.getElementById("btn", page).?.asNode();
var btn_id_buf: [12]u8 = undefined; const btn_id = (try server.node_registry.register(btn)).id;
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; var btn_id_buf: [12]u8 = undefined;
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, "}}}" }); const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
try router.handleMessage(server, aa, click_msg); 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(); // Test Fill Input
const inp_id = (try server.node_registry.register(inp)).id; const inp = page.document.getElementById("inp", page).?.asNode();
var inp_id_buf: [12]u8 = undefined; const inp_id = (try server.node_registry.register(inp)).id;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; var inp_id_buf: [12]u8 = undefined;
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\"}}}" }); const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
try router.handleMessage(server, aa, fill_msg); 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(); // Test Fill Select
const sel_id = (try server.node_registry.register(sel)).id; const sel = page.document.getElementById("sel", page).?.asNode();
var sel_id_buf: [12]u8 = undefined; const sel_id = (try server.node_registry.register(sel)).id;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; var sel_id_buf: [12]u8 = undefined;
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\"}}}" }); const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
try router.handleMessage(server, aa, fill_sel_msg); 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(); // Test Scroll
const scrollbox_id = (try server.node_registry.register(scrollbox)).id; const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
var scroll_id_buf: [12]u8 = undefined; const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; var scroll_id_buf: [12]u8 = undefined;
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}}}" }); const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
try router.handleMessage(server, aa, scroll_msg); 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 // Evaluate assertions
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
@@ -773,108 +785,79 @@ test "MCP - Actions: click, fill, scroll" {
try_catch.init(&ls.local); try_catch.init(&ls.local);
defer try_catch.deinit(); 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()); try testing.expect(result.isTrue());
} }
test "MCP - waitForSelector: existing element" { test "MCP - waitForSelector: existing element" {
defer testing.reset(); defer testing.reset();
const allocator = testing.allocator; var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const app = testing.test_app; const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); &out.writer,
defer out_alloc.deinit(); );
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit(); 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 // waitForSelector on an element that already exists returns immediately
const msg = const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#existing","timeout":2000}}} \\{"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( try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
\\{
\\ "id": 1,
\\ "result": {
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
} }
test "MCP - waitForSelector: delayed element" { test "MCP - waitForSelector: delayed element" {
defer testing.reset(); defer testing.reset();
const allocator = testing.allocator; var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const app = testing.test_app; const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); &out.writer,
defer out_alloc.deinit(); );
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit(); 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 // waitForSelector on an element added after 200ms via setTimeout
const msg = const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#delayed","timeout":5000}}} \\{"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( try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
\\{
\\ "id": 1,
\\ "result": {
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
} }
test "MCP - waitForSelector: timeout" { test "MCP - waitForSelector: timeout" {
defer testing.reset(); defer testing.reset();
const allocator = testing.allocator; var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const app = testing.test_app; const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); &out.writer,
defer out_alloc.deinit(); );
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit(); 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 // waitForSelector with a short timeout on a non-existent element should error
const msg = const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}} \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}}
; ;
try router.handleMessage(server, aa, msg); try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(.{
try testing.expectJson( .id = 1,
\\{ .@"error" = struct {}{},
\\ "id": 1, }, out.written());
\\ "error": {} }
\\}
, out_alloc.writer.buffered()); 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;
} }

View File

@@ -73,6 +73,8 @@ const Runner = struct {
var slowest = SlowTracker.init(self.allocator, 5); var slowest = SlowTracker.init(self.allocator, 5);
defer slowest.deinit(); defer slowest.deinit();
var fail_list: std.ArrayList([]const u8) = .empty;
var pass: usize = 0; var pass: usize = 0;
var fail: usize = 0; var fail: usize = 0;
var skip: usize = 0; var skip: usize = 0;
@@ -175,6 +177,7 @@ const Runner = struct {
if (self.env.fail_first) { if (self.env.fail_first) {
break; 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.status(.fail, "{d} test{s} leaked\n", .{ leak, if (leak != 1) "s" else "" });
} }
Printer.fmt("\n", .{}); Printer.fmt("\n", .{});
try slowest.display(); try slowest.display();
Printer.fmt("\n", .{}); Printer.fmt("\n", .{});
// stats // stats
if (self.env.metrics) { if (self.env.metrics) {
var stdout = std.fs.File.stdout(); var stdout = std.fs.File.stdout();
@@ -232,6 +235,15 @@ const Runner = struct {
.alloc_size = v8_peak_memory, .alloc_size = v8_peak_memory,
} }, } },
}, .{ .whitespace = .indent_2 }, &writer.interface); }, .{ .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); std.posix.exit(if (fail == 0) 0 else 1);

View File

@@ -415,7 +415,8 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
defer try_catch.deinit(); defer try_catch.deinit();
try page.navigate(url, .{}); try page.navigate(url, .{});
_ = test_session.wait(.{}); var runner = try test_session.runner(.{});
try runner.wait(.{ .ms = 2000 });
test_browser.runMicrotasks(); test_browser.runMicrotasks();
@@ -439,7 +440,8 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
); );
try page.navigate(url, .{}); try page.navigate(url, .{});
_ = test_session.wait(.{}); var runner = try test_session.runner(.{});
try runner.wait(.{ .ms = 2000 });
return page; return page;
} }