diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 1e186af6..21005de4 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -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. diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 0e781fa4..108d1e3b 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -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()); + } +} diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index a327f1bb..0edba1d4 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -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); -}