Skip to content

Commit 75dc4d5

Browse files
authored
Merge pull request #2031 from lightpanda-io/cdp-add-script-to-evaluate-on-new-document
Cdp add script to evaluate on new document
2 parents 03ed456 + 0d40aed commit 75dc4d5

3 files changed

Lines changed: 155 additions & 29 deletions

File tree

src/cdp/CDP.zig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
363363
inspector_session: *js.Inspector.Session,
364364
isolated_worlds: std.ArrayList(*IsolatedWorld),
365365

366+
// Scripts registered via Page.addScriptToEvaluateOnNewDocument.
367+
// Evaluated in each new document after navigation completes.
368+
scripts_on_new_document: std.ArrayList(ScriptOnNewDocument) = .empty,
369+
next_script_id: u32 = 1,
370+
366371
http_proxy_changed: bool = false,
367372

368373
// Extra headers to add to all requests.
@@ -762,6 +767,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
762767
/// Clients create this to be able to create variables and run code without interfering with the
763768
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
764769
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
770+
const ScriptOnNewDocument = struct {
771+
identifier: u32,
772+
source: []const u8,
773+
};
774+
765775
/// in the isolated world by using its Context ID or the worldName.
766776
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
767777
/// An isolated world has it's own instance of globals like Window.

src/cdp/domains/page.zig

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub fn processMessage(cmd: anytype) !void {
3737
getFrameTree,
3838
setLifecycleEventsEnabled,
3939
addScriptToEvaluateOnNewDocument,
40+
removeScriptToEvaluateOnNewDocument,
4041
createIsolatedWorld,
4142
navigate,
4243
reload,
@@ -51,6 +52,7 @@ pub fn processMessage(cmd: anytype) !void {
5152
.getFrameTree => return getFrameTree(cmd),
5253
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
5354
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
55+
.removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
5456
.createIsolatedWorld => return createIsolatedWorld(cmd),
5557
.navigate => return navigate(cmd),
5658
.reload => return doReload(cmd),
@@ -147,22 +149,55 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
147149
return cmd.sendResult(null, .{});
148150
}
149151

150-
// TODO: hard coded method
151-
// With the command we receive a script we need to store and run for each new document.
152-
// Note that the worldName refers to the name given to the isolated world.
153152
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
154-
// const params = (try cmd.params(struct {
155-
// source: []const u8,
156-
// worldName: ?[]const u8 = null,
157-
// includeCommandLineAPI: bool = false,
158-
// runImmediately: bool = false,
159-
// })) orelse return error.InvalidParams;
153+
const params = (try cmd.params(struct {
154+
source: []const u8,
155+
worldName: ?[]const u8 = null,
156+
includeCommandLineAPI: bool = false,
157+
runImmediately: bool = false,
158+
})) orelse return error.InvalidParams;
159+
160+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
160161

162+
if (params.runImmediately) {
163+
log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
164+
}
165+
166+
const script_id = bc.next_script_id;
167+
bc.next_script_id += 1;
168+
169+
const source_dupe = try bc.arena.dupe(u8, params.source);
170+
try bc.scripts_on_new_document.append(bc.arena, .{
171+
.identifier = script_id,
172+
.source = source_dupe,
173+
});
174+
175+
var id_buf: [16]u8 = undefined;
176+
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{script_id}) catch "1";
161177
return cmd.sendResult(.{
162-
.identifier = "1",
178+
.identifier = id_str,
163179
}, .{});
164180
}
165181

182+
fn removeScriptToEvaluateOnNewDocument(cmd: anytype) !void {
183+
const params = (try cmd.params(struct {
184+
identifier: []const u8,
185+
})) orelse return error.InvalidParams;
186+
187+
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
188+
189+
const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch
190+
return cmd.sendResult(null, .{});
191+
192+
for (bc.scripts_on_new_document.items, 0..) |script, i| {
193+
if (script.identifier == target_id) {
194+
_ = bc.scripts_on_new_document.orderedRemove(i);
195+
break;
196+
}
197+
}
198+
return cmd.sendResult(null, .{});
199+
}
200+
166201
fn close(cmd: anytype) !void {
167202
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
168203

@@ -482,6 +517,27 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
482517
);
483518
}
484519

520+
// Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
521+
// Must run after the execution context is created but before the client
522+
// receives frameNavigated/loadEventFired so polyfills are available for
523+
// subsequent CDP commands.
524+
if (bc.scripts_on_new_document.items.len > 0) {
525+
var ls: js.Local.Scope = undefined;
526+
page.js.localScope(&ls);
527+
defer ls.deinit();
528+
529+
for (bc.scripts_on_new_document.items) |script| {
530+
var try_catch: lp.js.TryCatch = undefined;
531+
try_catch.init(&ls.local);
532+
defer try_catch.deinit();
533+
534+
ls.local.eval(script.source, null) catch |err| {
535+
const caught = try_catch.caughtOrError(arena, err);
536+
log.warn(.cdp, "script on new doc", .{ .caught = caught });
537+
};
538+
}
539+
}
540+
485541
// frameNavigated event
486542
try cdp.sendEvent("Page.frameNavigated", .{
487543
.type = "Navigation",
@@ -840,3 +896,55 @@ test "cdp.page: reload" {
840896
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
841897
}
842898
}
899+
900+
test "cdp.page: addScriptToEvaluateOnNewDocument" {
901+
var ctx = try testing.context();
902+
defer ctx.deinit();
903+
904+
var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
905+
906+
{
907+
// Register a script — should return unique identifier "1"
908+
try ctx.processMessage(.{ .id = 20, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test = 1" } });
909+
try ctx.expectSentResult(.{
910+
.identifier = "1",
911+
}, .{ .id = 20 });
912+
}
913+
914+
{
915+
// Register another script — should return identifier "2"
916+
try ctx.processMessage(.{ .id = 21, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test2 = 2" } });
917+
try ctx.expectSentResult(.{
918+
.identifier = "2",
919+
}, .{ .id = 21 });
920+
}
921+
922+
{
923+
// Remove the first script — should succeed
924+
try ctx.processMessage(.{ .id = 22, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "1" } });
925+
try ctx.expectSentResult(null, .{ .id = 22 });
926+
}
927+
928+
{
929+
// Remove a non-existent identifier — should succeed silently
930+
try ctx.processMessage(.{ .id = 23, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "999" } });
931+
try ctx.expectSentResult(null, .{ .id = 23 });
932+
}
933+
934+
{
935+
try ctx.processMessage(.{ .id = 34, .method = "Page.reload" });
936+
// wait for this event, which is sent after we've run the registered scripts
937+
try ctx.expectSentEvent("Page.frameNavigated", .{
938+
.frame = .{ .loaderId = "LID-0000000002" },
939+
}, .{});
940+
941+
const page = bc.session.currentPage() orelse unreachable;
942+
943+
var ls: js.Local.Scope = undefined;
944+
page.js.localScope(&ls);
945+
defer ls.deinit();
946+
947+
const test_val = try ls.local.exec("window.__test2", null);
948+
try testing.expectEqual(2, try test_val.toI32());
949+
}
950+
}

src/cdp/testing.zig

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,26 @@ const TestContext = struct {
168168
index: ?usize = null,
169169
};
170170
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
171-
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
172-
.whitespace = .indent_2,
173-
.emit_null_optional_fields = false,
174-
});
171+
const expected_json = blk: {
172+
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
173+
// We can't record that in an ArrayList(???), so we serialize it to JSON.
174+
// Now, ideally, we could just take our expected structure, serialize it to
175+
// json and check if the two are equal.
176+
// Except serializing to JSON isn't deterministic.
177+
// So we serialize the JSON then we deserialize to json.Value. And then we can
178+
// compare our anytype expectation with the json.Value that we captured
179+
180+
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
181+
.whitespace = .indent_2,
182+
.emit_null_optional_fields = false,
183+
});
184+
185+
break :blk try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, serialized, .{});
186+
};
187+
175188
for (0..5) |_| {
176189
for (self.received.items, 0..) |received, i| {
177-
if (try compareExpectedToSent(serialized, received) == false) {
190+
if (try base.isEqualJson(expected_json, received) == false) {
178191
continue;
179192
}
180193

@@ -187,6 +200,15 @@ const TestContext = struct {
187200
}
188201
return;
189202
}
203+
204+
if (self.cdp_) |*cdp__| {
205+
if (cdp__.browser_context) |*bc| {
206+
if (bc.session.page != null) {
207+
var runner = try bc.session.runner(.{});
208+
_ = try runner.tick(.{ .ms = 1000 });
209+
}
210+
}
211+
}
190212
std.Thread.sleep(5 * std.time.ns_per_ms);
191213
try self.read();
192214
}
@@ -299,17 +321,3 @@ pub fn context() !TestContext {
299321
.socket = pair[0],
300322
};
301323
}
302-
303-
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
304-
// We can't record that in an ArrayList(???), so we serialize it to JSON.
305-
// Now, ideally, we could just take our expected structure, serialize it to
306-
// json and check if the two are equal.
307-
// Except serializing to JSON isn't deterministic.
308-
// So we serialize the JSON then we deserialize to json.Value. And then we can
309-
// compare our anytype expectation with the json.Value that we captured
310-
311-
fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool {
312-
const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{});
313-
defer expected_value.deinit();
314-
return base.isEqualJson(expected_value.value, actual);
315-
}

0 commit comments

Comments
 (0)