Merge pull request #2031 from lightpanda-io/cdp-add-script-to-evaluate-on-new-document

Cdp add script to evaluate on new document
This commit is contained in:
Karl Seguin
2026-03-30 11:16:39 +08:00
committed by GitHub
3 changed files with 155 additions and 29 deletions

View File

@@ -363,6 +363,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
inspector_session: *js.Inspector.Session,
isolated_worlds: std.ArrayList(*IsolatedWorld),
// Scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Evaluated in each new document after navigation completes.
scripts_on_new_document: std.ArrayList(ScriptOnNewDocument) = .empty,
next_script_id: u32 = 1,
http_proxy_changed: bool = false,
// Extra headers to add to all requests.
@@ -762,6 +767,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
/// Clients create this to be able to create variables and run code without interfering with the
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
const ScriptOnNewDocument = struct {
identifier: u32,
source: []const u8,
};
/// in the isolated world by using its Context ID or the worldName.
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
/// An isolated world has it's own instance of globals like Window.

View File

@@ -37,6 +37,7 @@ pub fn processMessage(cmd: anytype) !void {
getFrameTree,
setLifecycleEventsEnabled,
addScriptToEvaluateOnNewDocument,
removeScriptToEvaluateOnNewDocument,
createIsolatedWorld,
navigate,
reload,
@@ -51,6 +52,7 @@ pub fn processMessage(cmd: anytype) !void {
.getFrameTree => return getFrameTree(cmd),
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd),
.reload => return doReload(cmd),
@@ -147,22 +149,55 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
// TODO: hard coded method
// With the command we receive a script we need to store and run for each new document.
// Note that the worldName refers to the name given to the isolated world.
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// source: []const u8,
// worldName: ?[]const u8 = null,
// includeCommandLineAPI: bool = false,
// runImmediately: bool = false,
// })) orelse return error.InvalidParams;
const params = (try cmd.params(struct {
source: []const u8,
worldName: ?[]const u8 = null,
includeCommandLineAPI: bool = false,
runImmediately: bool = false,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (params.runImmediately) {
log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
}
const script_id = bc.next_script_id;
bc.next_script_id += 1;
const source_dupe = try bc.arena.dupe(u8, params.source);
try bc.scripts_on_new_document.append(bc.arena, .{
.identifier = script_id,
.source = source_dupe,
});
var id_buf: [16]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{script_id}) catch "1";
return cmd.sendResult(.{
.identifier = "1",
.identifier = id_str,
}, .{});
}
fn removeScriptToEvaluateOnNewDocument(cmd: anytype) !void {
const params = (try cmd.params(struct {
identifier: []const u8,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch
return cmd.sendResult(null, .{});
for (bc.scripts_on_new_document.items, 0..) |script, i| {
if (script.identifier == target_id) {
_ = bc.scripts_on_new_document.orderedRemove(i);
break;
}
}
return cmd.sendResult(null, .{});
}
fn close(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
@@ -482,6 +517,27 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
);
}
// Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
// Must run after the execution context is created but before the client
// receives frameNavigated/loadEventFired so polyfills are available for
// subsequent CDP commands.
if (bc.scripts_on_new_document.items.len > 0) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
for (bc.scripts_on_new_document.items) |script| {
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
ls.local.eval(script.source, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
log.warn(.cdp, "script on new doc", .{ .caught = caught });
};
}
}
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
@@ -840,3 +896,55 @@ test "cdp.page: reload" {
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
}
}
test "cdp.page: addScriptToEvaluateOnNewDocument" {
var ctx = try testing.context();
defer ctx.deinit();
var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// Register a script — should return unique identifier "1"
try ctx.processMessage(.{ .id = 20, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test = 1" } });
try ctx.expectSentResult(.{
.identifier = "1",
}, .{ .id = 20 });
}
{
// Register another script — should return identifier "2"
try ctx.processMessage(.{ .id = 21, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test2 = 2" } });
try ctx.expectSentResult(.{
.identifier = "2",
}, .{ .id = 21 });
}
{
// Remove the first script — should succeed
try ctx.processMessage(.{ .id = 22, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "1" } });
try ctx.expectSentResult(null, .{ .id = 22 });
}
{
// Remove a non-existent identifier — should succeed silently
try ctx.processMessage(.{ .id = 23, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "999" } });
try ctx.expectSentResult(null, .{ .id = 23 });
}
{
try ctx.processMessage(.{ .id = 34, .method = "Page.reload" });
// wait for this event, which is sent after we've run the registered scripts
try ctx.expectSentEvent("Page.frameNavigated", .{
.frame = .{ .loaderId = "LID-0000000002" },
}, .{});
const page = bc.session.currentPage() orelse unreachable;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const test_val = try ls.local.exec("window.__test2", null);
try testing.expectEqual(2, try test_val.toI32());
}
}

View File

@@ -168,13 +168,26 @@ const TestContext = struct {
index: ?usize = null,
};
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
.whitespace = .indent_2,
.emit_null_optional_fields = false,
});
const expected_json = blk: {
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
// We can't record that in an ArrayList(???), so we serialize it to JSON.
// Now, ideally, we could just take our expected structure, serialize it to
// json and check if the two are equal.
// Except serializing to JSON isn't deterministic.
// So we serialize the JSON then we deserialize to json.Value. And then we can
// compare our anytype expectation with the json.Value that we captured
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
.whitespace = .indent_2,
.emit_null_optional_fields = false,
});
break :blk try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, serialized, .{});
};
for (0..5) |_| {
for (self.received.items, 0..) |received, i| {
if (try compareExpectedToSent(serialized, received) == false) {
if (try base.isEqualJson(expected_json, received) == false) {
continue;
}
@@ -187,6 +200,15 @@ const TestContext = struct {
}
return;
}
if (self.cdp_) |*cdp__| {
if (cdp__.browser_context) |*bc| {
if (bc.session.page != null) {
var runner = try bc.session.runner(.{});
_ = try runner.tick(.{ .ms = 1000 });
}
}
}
std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read();
}
@@ -299,17 +321,3 @@ pub fn context() !TestContext {
.socket = pair[0],
};
}
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
// We can't record that in an ArrayList(???), so we serialize it to JSON.
// Now, ideally, we could just take our expected structure, serialize it to
// json and check if the two are equal.
// Except serializing to JSON isn't deterministic.
// So we serialize the JSON then we deserialize to json.Value. And then we can
// compare our anytype expectation with the json.Value that we captured
fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool {
const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{});
defer expected_value.deinit();
return base.isEqualJson(expected_value.value, actual);
}