diff --git a/README.md b/README.md
index 1ec482ba..f1817bf3 100644
--- a/README.md
+++ b/README.md
@@ -18,15 +18,15 @@ Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
-[

+[

](https://github.com/lightpanda-io/demo)
-[

+[

](https://github.com/lightpanda-io/demo)
-_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
-See [benchmark details](https://github.com/lightpanda-io/demo)._
+_chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
+See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._
Lightpanda is the open-source browser made for headless usage:
diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig
index 2e3f25a4..a48e00a7 100644
--- a/src/ArenaPool.zig
+++ b/src/ArenaPool.zig
@@ -155,6 +155,11 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
_ = arena.reset(.{ .retain_with_limit = retain });
}
+pub fn resetRetain(_: *const ArenaPool, allocator: Allocator) void {
+ const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
+ _ = arena.reset(.retain_capacity);
+}
+
const testing = std.testing;
test "arena pool - basic acquire and use" {
diff --git a/src/Config.zig b/src/Config.zig
index 0bec5b7a..e95d3afc 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -163,6 +163,20 @@ pub fn cdpTimeout(self: *const Config) usize {
};
}
+pub fn port(self: *const Config) u16 {
+ return switch (self.mode) {
+ .serve => |opts| opts.port,
+ else => unreachable,
+ };
+}
+
+pub fn advertiseHost(self: *const Config) []const u8 {
+ return switch (self.mode) {
+ .serve => |opts| opts.advertise_host orelse opts.host,
+ else => unreachable,
+ };
+}
+
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
@@ -199,6 +213,7 @@ pub const Mode = union(RunMode) {
pub const Serve = struct {
host: []const u8 = "127.0.0.1",
port: u16 = 9222,
+ advertise_host: ?[]const u8 = null,
timeout: u31 = 10,
cdp_max_connections: u16 = 16,
cdp_max_pending_connections: u16 = 128,
@@ -221,7 +236,7 @@ pub const WaitUntil = enum {
load,
domcontentloaded,
networkidle,
- fixed,
+ done,
};
pub const Fetch = struct {
@@ -400,8 +415,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Defaults to 5000.
\\
\\--wait_until Wait until the specified event.
- \\ Supported events: load, domcontentloaded, networkidle, fixed.
- \\ Defaults to 'load'.
+ \\ Supported events: load, domcontentloaded, networkidle, done.
+ \\ Defaults to 'done'.
\\
++ common_options ++
\\
@@ -416,6 +431,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--port Port of the CDP server
\\ Defaults to 9222
\\
+ \\--advertise_host
+ \\ The host to advertise, e.g. in the /json/version response.
+ \\ Useful, for example, when --host is 0.0.0.0.
+ \\ Defaults to --host value
+ \\
\\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\
@@ -557,6 +577,15 @@ fn parseServeArgs(
continue;
}
+ if (std.mem.eql(u8, "--advertise_host", opt)) {
+ const str = args.next() orelse {
+ log.fatal(.app, "missing argument value", .{ .arg = "--advertise_host" });
+ return error.InvalidArgument;
+ };
+ serve.advertise_host = try allocator.dupe(u8, str);
+ continue;
+ }
+
if (std.mem.eql(u8, "--timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig
index af8720e9..6366890f 100644
--- a/src/SemanticTree.zig
+++ b/src/SemanticTree.zig
@@ -47,7 +47,15 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
- self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
+ var visibility_cache: Element.VisibilityCache = .empty;
+ var pointer_events_cache: Element.PointerEventsCache = .empty;
+ var ctx: WalkContext = .{
+ .xpath_buffer = &xpath_buffer,
+ .listener_targets = listener_targets,
+ .visibility_cache = &visibility_cache,
+ .pointer_events_cache = &pointer_events_cache,
+ };
+ self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed;
};
@@ -60,7 +68,15 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
- self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
+ var visibility_cache: Element.VisibilityCache = .empty;
+ var pointer_events_cache: Element.PointerEventsCache = .empty;
+ var ctx: WalkContext = .{
+ .xpath_buffer = &xpath_buffer,
+ .listener_targets = listener_targets,
+ .visibility_cache = &visibility_cache,
+ .pointer_events_cache = &pointer_events_cache,
+ };
+ self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed;
};
@@ -84,7 +100,22 @@ const NodeData = struct {
node_name: []const u8,
};
-fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void {
+const WalkContext = struct {
+ xpath_buffer: *std.ArrayList(u8),
+ listener_targets: interactive.ListenerTargetMap,
+ visibility_cache: *Element.VisibilityCache,
+ pointer_events_cache: *Element.PointerEventsCache,
+};
+
+fn walk(
+ self: @This(),
+ ctx: *WalkContext,
+ node: *Node,
+ parent_name: ?[]const u8,
+ visitor: anytype,
+ index: usize,
+ current_depth: u32,
+) !void {
if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes
@@ -96,7 +127,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
if (tag == .datalist or tag == .option or tag == .optgroup) return;
// Check visibility using the engine's checkVisibility which handles CSS display: none
- if (!el.checkVisibility(self.page)) {
+ if (!el.checkVisibilityCached(ctx.visibility_cache, self.page)) {
return;
}
@@ -137,7 +168,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
}
if (el.is(Element.Html)) |html_el| {
- if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) {
+ if (interactive.classifyInteractivity(self.page, el, html_el, ctx.listener_targets, ctx.pointer_events_cache) != null) {
is_interactive = true;
}
}
@@ -145,9 +176,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
node_name = "root";
}
- const initial_xpath_len = xpath_buffer.items.len;
- try appendXPathSegment(node, xpath_buffer.writer(self.arena), index);
- const xpath = xpath_buffer.items;
+ const initial_xpath_len = ctx.xpath_buffer.items.len;
+ try appendXPathSegment(node, ctx.xpath_buffer.writer(self.arena), index);
+ const xpath = ctx.xpath_buffer.items;
var name = try axn.getName(self.page, self.arena);
@@ -165,18 +196,6 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
name = null;
}
- var data = NodeData{
- .id = cdp_node.id,
- .axn = axn,
- .role = role,
- .name = name,
- .value = value,
- .options = options,
- .xpath = xpath,
- .is_interactive = is_interactive,
- .node_name = node_name,
- };
-
var should_visit = true;
if (self.interactive_only) {
var keep = false;
@@ -208,6 +227,18 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
var did_visit = false;
var should_walk_children = true;
+ var data: NodeData = .{
+ .id = cdp_node.id,
+ .axn = axn,
+ .role = role,
+ .name = name,
+ .value = value,
+ .options = options,
+ .xpath = xpath,
+ .is_interactive = is_interactive,
+ .node_name = node_name,
+ };
+
if (should_visit) {
should_walk_children = try visitor.visit(node, &data);
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
@@ -233,7 +264,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
}
gop.value_ptr.* += 1;
- try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
+ try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
}
}
@@ -241,11 +272,11 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
try visitor.leave();
}
- xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
+ ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
}
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
- var options = std.ArrayListUnmanaged(OptionData){};
+ var options: std.ArrayList(OptionData) = .empty;
var it = node.childrenIterator();
while (it.next()) |child| {
if (child.is(Element)) |el| {
diff --git a/src/Server.zig b/src/Server.zig
index d172f6dd..d9c8a455 100644
--- a/src/Server.zig
+++ b/src/Server.zig
@@ -45,7 +45,7 @@ clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator;
- const json_version_response = try buildJSONVersionResponse(allocator, address);
+ const json_version_response = try buildJSONVersionResponse(app);
errdefer allocator.free(json_version_response);
const self = try allocator.create(Server);
@@ -302,15 +302,8 @@ pub const Client = struct {
var ms_remaining = self.ws.timeout_ms;
while (true) {
- switch (cdp.pageWait(ms_remaining)) {
- .cdp_socket => {
- if (self.readSocket() == false) {
- return;
- }
- last_message = milliTimestamp(.monotonic);
- ms_remaining = self.ws.timeout_ms;
- },
- .no_page => {
+ const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
+ error.NoPage => {
const status = http.tick(ms_remaining) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
@@ -324,6 +317,18 @@ pub const Client = struct {
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
+ continue;
+ },
+ else => return wait_err,
+ };
+
+ switch (result) {
+ .cdp_socket => {
+ if (self.readSocket() == false) {
+ return;
+ }
+ last_message = milliTimestamp(.monotonic);
+ ms_remaining = self.ws.timeout_ms;
},
.done => {
const now = milliTimestamp(.monotonic);
@@ -484,11 +489,17 @@ pub const Client = struct {
// --------
fn buildJSONVersionResponse(
- allocator: Allocator,
- address: net.Address,
+ app: *const App,
) ![]const u8 {
- const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}";
- const body_len = std.fmt.count(body_format, .{address});
+ const port = app.config.port();
+ const host = app.config.advertiseHost();
+ if (std.mem.eql(u8, host, "0.0.0.0")) {
+ log.info(.cdp, "unreachable advertised host", .{
+ .message = "when --host is set to 0.0.0.0 consider setting --advertise_host to a reachable address",
+ });
+ }
+ const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"}}";
+ const body_len = std.fmt.count(body_format, .{ host, port });
// We send a Connection: Close (and actually close the connection)
// because chromedp (Go driver) sends a request to /json/version and then
@@ -502,23 +513,22 @@ fn buildJSONVersionResponse(
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format;
- return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
+ return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
}
pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
-const testing = std.testing;
+const testing = @import("testing.zig");
test "server: buildJSONVersionResponse" {
- const address = try net.Address.parseIp4("127.0.0.1", 9001);
- const res = try buildJSONVersionResponse(testing.allocator, address);
- defer testing.allocator.free(res);
+ const res = try buildJSONVersionResponse(testing.test_app);
+ defer testing.test_app.allocator.free(res);
- try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++
+ try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
- "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res);
+ "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}", res);
}
test "Client: http invalid request" {
@@ -526,7 +536,7 @@ test "Client: http invalid request" {
defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
- try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
+ try testing.expectEqual("HTTP/1.1 413 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++
"Request too large", res);
@@ -595,7 +605,7 @@ test "Client: http valid handshake" {
"Custom: Header-Value\r\n\r\n";
const res = try c.httpRequest(request);
- try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
+ try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
@@ -723,7 +733,7 @@ test "server: 404" {
defer c.deinit();
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
- try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++
+ try testing.expectEqual("HTTP/1.1 404 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 9\r\n\r\n" ++
"Not found", res);
@@ -735,7 +745,7 @@ test "server: get /json/version" {
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
- "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}";
+ "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}";
{
// twice on the same connection
@@ -743,7 +753,7 @@ test "server: get /json/version" {
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
- try testing.expectEqualStrings(expected_response, res1);
+ try testing.expectEqual(expected_response, res1);
}
{
@@ -752,7 +762,7 @@ test "server: get /json/version" {
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
- try testing.expectEqualStrings(expected_response, res1);
+ try testing.expectEqual(expected_response, res1);
}
}
@@ -770,7 +780,7 @@ fn assertHTTPError(
.{ expected_status, expected_body.len, expected_body },
);
- try testing.expectEqualStrings(expected_response, res);
+ try testing.expectEqual(expected_response, res);
}
fn assertWebSocketError(close_code: u16, input: []const u8) !void {
@@ -914,7 +924,7 @@ const TestClient = struct {
"Custom: Header-Value\r\n\r\n";
const res = try self.httpRequest(request);
- try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
+ try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig
index e23d48a2..eca97a2d 100644
--- a/src/browser/Mime.zig
+++ b/src/browser/Mime.zig
@@ -386,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html;
}
+pub fn isText(mime: *const Mime) bool {
+ return switch (mime.content_type) {
+ .text_xml, .text_html, .text_javascript, .text_plain, .text_css => true,
+ .application_json => true,
+ else => false,
+ };
+}
+
// we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index eef70bbc..bfff78ce 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -35,6 +35,7 @@ const Factory = @import("Factory.zig");
const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig");
+const StyleManager = @import("StyleManager.zig");
const Parser = @import("parser/Parser.zig");
@@ -144,6 +145,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element.Html) = .{},
+_style_manager: StyleManager,
_script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec)
@@ -269,6 +271,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._factory = factory,
._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame,
+ ._style_manager = undefined,
._script_manager = undefined,
._event_manager = EventManager.init(session.page_arena, self),
};
@@ -298,6 +301,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._visual_viewport = visual_viewport,
});
+ self._style_manager = try StyleManager.init(self);
+ errdefer self._style_manager.deinit();
+
const browser = session.browser;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit();
@@ -360,6 +366,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
}
self._script_manager.deinit();
+ self._style_manager.deinit();
session.releaseArena(self.call_arena);
}
@@ -441,6 +448,12 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
if (is_about_blank or is_blob) {
self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url);
+ // even though this might be the same _data_ as `default_location`, we
+ // have to do this to make sure window.location is at a unique _address_.
+ // If we don't do this, mulitple window._location will have the same
+ // address and thus be mapped to the same v8::Object in the identity map.
+ self.window._location = try Location.init(self.url, self);
+
if (is_blob) {
// strip out blob:
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
@@ -587,13 +600,34 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp
// page that it's acting on.
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
const resolved_url, const is_about_blank = blk: {
+ if (URL.isCompleteHTTPUrl(request_url)) {
+ break :blk .{ try arena.dupeZ(u8, request_url), false };
+ }
+
if (std.mem.eql(u8, request_url, "about:blank")) {
// navigate will handle this special case
break :blk .{ "about:blank", true };
}
+
+ // request_url isn't a "complete" URL, so it has to be resolved with the
+ // originator's base. Unless, originator's base is "about:blank", in which
+ // case we have to walk up the parents and find a real base.
+ const page_base = base_blk: {
+ var maybe_not_blank_page = originator;
+ while (true) {
+ const maybe_base = maybe_not_blank_page.base();
+ if (std.mem.eql(u8, maybe_base, "about:blank") == false) {
+ break :base_blk maybe_base;
+ }
+ // The orelse here is probably an invalid case, but there isn't
+ // anything we can do about it. It should never happen?
+ maybe_not_blank_page = maybe_not_blank_page.parent orelse break :base_blk "";
+ }
+ };
+
const u = try URL.resolve(
arena,
- originator.base(),
+ page_base,
request_url,
.{ .always_dupe = true, .encode = true },
);
@@ -2561,6 +2595,17 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
}
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
+
+ // If a
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html
index baa09918..8874bbfe 100644
--- a/src/browser/tests/element/html/image.html
+++ b/src/browser/tests/element/html/image.html
@@ -29,10 +29,12 @@
testing.expectEqual('', img.src);
testing.expectEqual('', img.alt);
+ testing.expectEqual('', img.currentSrc);
img.src = 'test.png';
// src property returns resolved absolute URL
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src);
+ testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.currentSrc);
// getAttribute returns the raw attribute value
testing.expectEqual('test.png', img.getAttribute('src'));
diff --git a/src/browser/tests/element/html/media.html b/src/browser/tests/element/html/media.html
index 15cd9b33..e97a0d67 100644
--- a/src/browser/tests/element/html/media.html
+++ b/src/browser/tests/element/html/media.html
@@ -236,9 +236,11 @@
{
const audio = document.createElement('audio');
testing.expectEqual('', audio.src);
+ testing.expectEqual('', audio.currentSrc);
audio.src = 'test.mp3';
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
+ testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.currentSrc);
}
diff --git a/src/browser/tests/element/html/style.html b/src/browser/tests/element/html/style.html
index 8abbb229..ee393846 100644
--- a/src/browser/tests/element/html/style.html
+++ b/src/browser/tests/element/html/style.html
@@ -131,3 +131,17 @@
testing.eventually(() => testing.expectEqual(true, result));
}
+
+
diff --git a/src/browser/tests/element/replace_children.html b/src/browser/tests/element/replace_children.html
new file mode 100644
index 00000000..fdadcb77
--- /dev/null
+++ b/src/browser/tests/element/replace_children.html
@@ -0,0 +1,139 @@
+
+
+
+
+ element.replaceChildren Tests
+
+
+ Original content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html
index f31e0d31..096afc78 100644
--- a/src/browser/tests/frames/frames.html
+++ b/src/browser/tests/frames/frames.html
@@ -140,8 +140,19 @@
});
+
+
diff --git a/src/browser/tests/mcp_wait_for_selector.html b/src/browser/tests/mcp_wait_for_selector.html
index 111aadaf..01706885 100644
--- a/src/browser/tests/mcp_wait_for_selector.html
+++ b/src/browser/tests/mcp_wait_for_selector.html
@@ -8,7 +8,7 @@
el.id = "delayed";
el.textContent = "Appeared after delay";
document.body.appendChild(el);
- }, 200);
+ }, 20);