From 8937a7b4e0a6906321dd5ef2b7fb64794a14db5e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 4 Sep 2026 11:01:20 +0800 Subject: [PATCH] cdp: Give isolated worlds a context per frame Previously, we had a single root-bound context which we'd re-announce for every frame. Now, Page.createIsolatedWorld and addScriptToEvaluateOnNewDocument seed the context per frame(s). --- src/browser/Session.zig | 22 ++-- src/server/cdp/CDP.zig | 61 +++++++++-- src/server/cdp/domains/dom.zig | 180 +++++++++++++++++++++++++------- src/server/cdp/domains/page.zig | 150 +++++++++++++++++++++++--- 4 files changed, 341 insertions(+), 72 deletions(-) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index dd0516aa2..d6720c39d 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -335,8 +335,8 @@ fn tearDownPage(self: *Session, page: *Page) void { } // Allocate a Page in a free slot, publish it as the active page, and -// dispatch `frame_created` so CDP creates fresh isolated-world V8 -// contexts. Used by createPage and by the synthetic-nav path. Does NOT +// dispatch `frame_created` so CDP can bind its page handle to the new +// frame. Used by createPage and by the synthetic-nav path. Does NOT // dispatch `frame_navigate` — the caller does that (or doesn't, for a // blank initial page). // @@ -351,8 +351,8 @@ fn installNewActivePage(self: *Session, frame_id: u32) !*Frame { errdefer _ = self.pages.pop(); const frame = &page.frame; - // Inform CDP the main frame has been created such that additional - // context for other Worlds can be created as well. + // Inform CDP the main frame has been created so it can point its page + // handle at the new frame. self.notification.dispatch(.frame_created, frame); return frame; } @@ -890,12 +890,14 @@ pub fn initiateRootNavigation(self: *Session, frame_id: u32, url: [:0]const u8, // isolated world contexts plus the node_registry. OLD is still the live // page and its memory is alive (intentional: CDP teardown can walk // old-page state without UAF). -// 2. frame_created dispatch — CDP creates fresh isolated world contexts -// against the new frame. `replacement.replaces` is still set, so the -// session still reports an in-flight nav and CDP's frameCreated skips -// its frame_arena reset and captured_responses zeroing (the captured -// response for the request we are committing was just inserted by -// onHttpResponseHeadersDone moments earlier and must survive). +// 2. frame_created dispatch — CDP rebinds its page handle to the new +// frame. `replacement.replaces` is still set, so the session still +// reports an in-flight nav and CDP's frameCreated skips its frame_arena +// reset and captured_responses zeroing (the captured response for the +// request we are committing was just inserted by +// onHttpResponseHeadersDone moments earlier and must survive). The +// isolated worlds emptied in step 1 are NOT refilled here — CDP rebuilds +// their contexts on the frame_navigate the caller dispatches afterwards. // 3. Promote: clear `replaces` and unlink OLD from `pages`, so // `currentFrame()` / `livePage()` now resolve to `replacement`. Done AFTER // step 2 so the in-commit signal (replaces != null) survives the dispatch diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 1d45cb414..1a9495c3c 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -640,18 +640,27 @@ pub const BrowserContext = struct { self.set_child_nodes_sent.clearRetainingCapacity(); } - pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { - // The name is the world's identity (matching Chrome). Clients re-issue - // this call after every navigation; appending a duplicate each time - // would grow the per-page context count without bound. + pub const GetOrPutIsolatedWorld = struct { + world: *IsolatedWorld, + found_existing: bool, + }; + + pub fn findIsolatedWorld(self: *const BrowserContext, world_name: []const u8) ?*IsolatedWorld { for (self.isolated_worlds.items) |world| { if (std.mem.eql(u8, world.name, world_name)) { - if (world.grant_universal_access != grant_universal_access) { - log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access }); - } return world; } } + return null; + } + + pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !GetOrPutIsolatedWorld { + if (self.findIsolatedWorld(world_name)) |world| { + if (world.grant_universal_access != grant_universal_access) { + log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access }); + } + return .{ .world = world, .found_existing = true }; + } const browser = &self.cdp.browser; const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld"); @@ -667,7 +676,19 @@ pub const BrowserContext = struct { try self.isolated_worlds.append(self.arena, world); - return world; + return .{ .world = world, .found_existing = false }; + } + + // only called when we fail to fully create a world (e.g. errdefer in + // Page.createIsolatedWorld). + pub fn removeIsolatedWorld(self: *BrowserContext, world: *IsolatedWorld) void { + for (self.isolated_worlds.items, 0..) |w, i| { + if (w == world) { + _ = self.isolated_worlds.swapRemove(i); + world.deinit(); + return; + } + } } pub fn nodeWriter(self: *BrowserContext, root: *const NodeRegistry.Node, opts: Node.Writer.Opts) Node.Writer { @@ -1165,12 +1186,16 @@ pub const BrowserContext = struct { const ScriptOnNewDocument = struct { identifier: u32, source: []const u8, + // Page.addScriptToEvaluateOnNewDocument's worldName. null means the main + // world. A named world is seeded into every frame (see IsolatedWorld). + world_name: ?[]const u8, }; /// An isolated world is identified by its name and has one V8::Context per -// it was requested it. Generally the client needs to resolve a node into the -// isolated world to be able to work with it. -/// +/// frame it has been seeded into. A world enters a frame on an explicit +/// trigger: Page.createIsolatedWorld or, or a preload script which is seeded +/// into every frame. Once seeded, the frame's context is rebuilt on every +/// navigation with no further client involvement. /// Frame ids are stable across a child frame's re-navigation (the Frame is /// torn down and re-initialized in place), so a per-frame context is removed /// on frame_destroyed and created again on the frame's next frame_navigated. @@ -1181,6 +1206,9 @@ pub const IsolatedWorld = struct { grant_universal_access: bool, contexts: std.ArrayList(FrameContext) = .empty, + // Frames this world has been seeded into, by frame id. + seeded_frames: std.ArrayList(u32) = .empty, + // Identity tracking for this isolated world (separate from main world). // Shared by all of the world's frame contexts, like the main world shares // Page.identity across frames, and reset with them on root teardown. @@ -1201,6 +1229,17 @@ pub const IsolatedWorld = struct { self.arena.release(); } + pub fn seed(self: *IsolatedWorld, frame_id: u32) !void { + if (self.isSeeded(frame_id)) { + return; + } + return self.seeded_frames.append(self.arena.allocator(), frame_id); + } + + pub fn isSeeded(self: *const IsolatedWorld, frame_id: u32) bool { + return std.mem.indexOfScalar(u32, self.seeded_frames.items, frame_id) != null; + } + // Keyed by Frame, not frame id: a retired root Page keeps its frame id // while its deferred teardown is pending, and that teardown must not // touch the live page's context. diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig index 776b92af9..7ba07da02 100644 --- a/src/server/cdp/domains/dom.zig +++ b/src/server/cdp/domains/dom.zig @@ -25,14 +25,15 @@ const NodeRegistry = @import("../../../NodeRegistry.zig"); const dump = @import("../../../browser/dump.zig"); const js = @import("../../../browser/js/js.zig"); -const DOMNode = @import("../../../browser/webapi/Node.zig"); -const Selector = @import("../../../browser/webapi/selector/Selector.zig"); -const xpath = @import("../../../browser/xpath/Evaluator.zig"); -const Input = @import("../../../browser/webapi/element/html/Input.zig"); +const Page = @import("../../../browser/Page.zig"); +const Frame = @import("../../../browser/Frame.zig"); const File = @import("../../../browser/webapi/File.zig"); const Blob = @import("../../../browser/webapi/Blob.zig"); const Factory = @import("../../../browser/Factory.zig"); -const Page = @import("../../../browser/Page.zig"); +const xpath = @import("../../../browser/xpath/Evaluator.zig"); +const DOMNode = @import("../../../browser/webapi/Node.zig"); +const Input = @import("../../../browser/webapi/element/html/Input.zig"); +const Selector = @import("../../../browser/webapi/selector/Selector.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -343,41 +344,24 @@ fn resolveNode(cmd: *CDP.Command) !void { })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - - var ls: js.Local.Scope = undefined; - var ls_open = false; - defer if (ls_open) { - ls.deinit(); - }; - - if (params.executionContextId) |context_id| blk: { - frame.js.localScope(&ls); - ls_open = true; - if (ls.local.debugContextId() == context_id) { - break :blk; - } - // not the default scope, check the other ones - for (bc.isolated_worlds.items) |isolated_world| { - for (isolated_world.contexts.items) |fc| { - ls.deinit(); - ls_open = false; - - fc.context.localScope(&ls); - ls_open = true; - if (ls.local.debugContextId() == context_id) { - break :blk; - } - } - } else return error.ContextNotFound; - } else { - frame.js.localScope(&ls); - ls_open = true; - } + const root = bc.mainFrame() orelse return error.FrameNotLoaded; const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode; + // Chrome resolves into the named context, else into the main world of the + // node's own document's frame. Drivers adopt a handle found in a child's + // utility world into that child's main world this way, so the root's + // contexts are not enough. + const js_context = if (params.executionContextId) |context_id| + findContext(bc, root, context_id) orelse return error.ContextNotFound + else + nodeFrame(node.dom, root).js; + + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + // node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement // So we use the Node.Union when retrieve the value from the environment const remote_object = try bc.inspector_session.getRemoteObject( @@ -397,6 +381,51 @@ fn resolveNode(cmd: *CDP.Command) !void { } }, .{}); } +// The frame owning the node's document. Synthetic documents (DOMParser, +// DOMImplementation) have no frame and fall back to the root. +fn nodeFrame(dom_node: *DOMNode, root: *Frame) *Frame { + const document = if (dom_node._type == .document) + dom_node.subtype(DOMNode.Document) + else + dom_node.ownerDocument(root) orelse return root; + return document._frame orelse root; +} + +// The context the inspector announced under `context_id`: any frame's main +// world, then any isolated world's per-frame contexts. +fn findContext(bc: *CDP.BrowserContext, root: *Frame, context_id: u32) ?*js.Context { + if (findMainWorldContext(root, context_id)) |js_context| { + return js_context; + } + for (bc.isolated_worlds.items) |isolated_world| { + for (isolated_world.contexts.items) |fc| { + if (contextIdOf(fc.context) == context_id) { + return fc.context; + } + } + } + return null; +} + +fn findMainWorldContext(frame: *Frame, context_id: u32) ?*js.Context { + if (contextIdOf(frame.js) == context_id) { + return frame.js; + } + for (frame.child_frames.items) |child| { + if (findMainWorldContext(child, context_id)) |js_context| { + return js_context; + } + } + return null; +} + +fn contextIdOf(js_context: *js.Context) i32 { + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + return ls.local.debugContextId(); +} + fn describeNode(cmd: *CDP.Command) !void { const params = (try cmd.params(struct { nodeId: ?NodeRegistry.Id = null, @@ -1130,6 +1159,85 @@ test "cdp.dom: querySelector Nodes found" { try ctx.expectSentResult(.{ .nodeIds = &.{7} }, .{ .id = 5 }); } +// Drivers find an element in a child frame's utility world, then adopt the +// handle into that child's main world with DOM.resolveNode. Both the named +// context and the default (the node's own frame) must be the child's, not the +// root's. +test "cdp.dom: resolveNode into a child frame's context" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-RN", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* }); + const root = bc.mainFrame() orelse unreachable; + const child = root.child_frames.items[0]; + const child_main = try mainWorldContextId(bc, child); + try testing.expect(child_main != try mainWorldContextId(bc, root)); + + // Register the child's the way DOM.describeNode(objectId) would. + const html = child.document.getDocumentElement() orelse unreachable; + const node = try bc.node_registry.register(html.asNode()); + + try ctx.processMessage(.{ .id = 10, .method = "Runtime.enable", .sessionId = "SID-X" }); + + // Into the context the client names. + try ctx.processMessage(.{ .id = 11, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{ + .backendNodeId = node.id, + .executionContextId = child_main, + } }); + const named = try sentObjectId(&ctx, 11); + try ctx.processMessage(.{ .id = 12, .method = "Runtime.callFunctionOn", .sessionId = "SID-X", .params = .{ + .objectId = named, + .functionDeclaration = "function() { return globalThis.document.title + '|' + (this.ownerDocument === globalThis.document); }", + .returnByValue = true, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one|true" } }, .{ .id = 12 }); + + // Into the node's own frame when no context is named. + try ctx.processMessage(.{ .id = 13, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{ + .backendNodeId = node.id, + } }); + const default = try sentObjectId(&ctx, 13); + try ctx.processMessage(.{ .id = 14, .method = "Runtime.callFunctionOn", .sessionId = "SID-X", .params = .{ + .objectId = default, + .functionDeclaration = "function() { return globalThis.document.title; }", + .returnByValue = true, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 14 }); + + try ctx.processMessage(.{ .id = 15, .method = "DOM.resolveNode", .sessionId = "SID-X", .params = .{ + .backendNodeId = node.id, + .executionContextId = 9999, + } }); + try ctx.expectSentError(-31998, "ContextNotFound", .{ .id = 15 }); +} + +fn mainWorldContextId(bc: *CDP.BrowserContext, frame: *const Frame) !i32 { + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + return bc.inspector_session.inspector.getContextId(&ls.local); +} + +// The result.object.objectId of the response to command `msg_id`. +fn sentObjectId(ctx: *testing.TestContext, msg_id: i64) ![]const u8 { + var i: usize = 0; + while (try ctx.getSentMessage(i)) |msg| : (i += 1) { + const obj = switch (msg) { + .object => |o| o, + else => continue, + }; + const id_value = obj.get("id") orelse continue; + if (id_value != .integer or id_value.integer != msg_id) { + continue; + } + const result = obj.get("result") orelse return error.NoResult; + const object = result.object.get("object") orelse return error.NoObject; + const object_id = object.object.get("objectId") orelse return error.NoObjectId; + return object_id.string; + } + return error.MessageNotFound; +} + test "cdp.dom: getBoxModel" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig index 2d2fcbd7a..d45473327 100644 --- a/src/server/cdp/domains/page.zig +++ b/src/server/cdp/domains/page.zig @@ -158,6 +158,18 @@ fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" }); } + // A worldName registers the world itself: + var world_name: ?[]const u8 = null; + if (params.worldName) |name| { + if (name.len > 0) { + const gop = try bc.createIsolatedWorld(name, true); + // Borrowed from the world's arena, which outlives the script: a + // world is only ever removed on the createIsolatedWorld command's + // own error path, before any script can name it. + world_name = gop.world.name; + } + } + const script_id = bc.next_script_id; bc.next_script_id += 1; @@ -165,6 +177,7 @@ fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void { try bc.scripts_on_new_document.append(bc.arena, .{ .identifier = script_id, .source = source_dupe, + .world_name = world_name, }); var id_buf: [16]u8 = undefined; @@ -251,7 +264,16 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{}); }; - const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + const gop = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + const world = gop.world; + + errdefer if (gop.found_existing == false) { + bc.removeIsolatedWorld(world); + }; + + // Seed before creating: frameNavigated only rebuilds contexts for frames + // the world was seeded into. + try world.seed(frame._frame_id); // use the existing world context for a frame if we have it, else create one const js_context = world.contextFor(frame) orelse try createIsolatedWorldContext(cmd.arena, bc, world, frame, null); @@ -272,8 +294,13 @@ fn createIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world: } // Registers a world context with the inspector, which assigns the id clients -// use and sends Runtime.executionContextCreated. Registering a context again -// assigns it a new id, so this only happens when the previous id is gone. +// use and sends Runtime.executionContextCreated. We may re-register a living +// context, the client will get a new id, but both ids are still valid. This +// happens when we fast-path via `canNavigateInPlace`, `frameNavigated` still +// fires so registerIsolatedWorldContext gets re-called for the same context. +// Not the end of the world since it's bound to a single about:blank -> navigate +// and necessary since we call executionContextsCleared which tells the client +// the old id is invalid fn registerIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world: *CDP.IsolatedWorld, js_context: *js.Context, frame: *const Frame, loader_id: ?[]const u8) !void { const frame_id = &id.toFrameId(frame._frame_id); const aux_data = if (loader_id) |lid| @@ -721,7 +748,17 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N is_root_frame, ); } - // Each known world must get a context per frame + // A worldName preload script seeds its world into every frame. This is the + // only way for a world to reach a frame besides the explicit Page.createIsolatedWorld. + for (bc.scripts_on_new_document.items) |script| { + const world = bc.findIsolatedWorld(script.world_name orelse continue) orelse continue; + world.seed(frame._frame_id) catch |err| { + log.warn(.cdp, "isolated world seed", .{ .err = err, .world = world.name, .frame_id = frame._frame_id }); + }; + } + + // Every world seeded into this frame gets a context, rebuilt on each + // navigation as blink rebuilds a detached isolated-world window proxy. for (bc.isolated_worlds.items) |isolated_world| { if (isolated_world.contextFor(frame)) |js_context| { // The context was already created ahead of time (createIsolatedWorld). @@ -737,6 +774,10 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N continue; } + if (!isolated_world.isSeeded(frame._frame_id)) { + continue; + } + _ = createIsolatedWorldContext(arena, bc, isolated_world, frame, loader_id) catch |err| { log.warn(.cdp, "isolated world context", .{ .err = err, .world = isolated_world.name, .frame_id = frame._frame_id }); }; @@ -746,21 +787,24 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N // 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) { + for (bc.scripts_on_new_document.items) |script| { + const js_context = if (script.world_name) |name| blk: { + const world = bc.findIsolatedWorld(name) orelse continue; + break :blk world.contextFor(frame) orelse continue; + } else frame.js; + var ls: js.Local.Scope = undefined; - frame.js.localScope(&ls); + js_context.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(); + 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 }); - }; - } + ls.local.eval(script.source, null) catch |err| { + const caught = try_catch.caughtOrError(arena, err); + log.warn(.cdp, "script on new doc", .{ .caught = caught }); + }; } // The DOM.documentUpdated event must be send after the frameNavigated one. @@ -1363,6 +1407,82 @@ test "cdp.frame: createIsolatedWorld targets the requested frame" { try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Parent jobs" } }, .{ .id = 37 }); } +// Chrome only puts a world in a frame on an explicit trigger. A world the +// client asked for on the root must not appear in a sibling frame just +// because that frame navigated afterwards. +test "cdp.frame: an unseeded frame gets no isolated world context" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-IWS", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* }); + const root = bc.mainFrame() orelse unreachable; + const child = root.child_frames.items[0]; + const root_id = id.toFrameId(root._frame_id); + + try ctx.processMessage(.{ .id = 30, .method = "Runtime.enable", .sessionId = "SID-X" }); + try ctx.processMessage(.{ .id = 31, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = &root_id, + .worldName = "utility", + .grantUniveralAccess = true, + } }); + + const world = bc.findIsolatedWorld("utility") orelse unreachable; + try testing.expect(world.contextFor(root) != null); + + // Navigating the child is what used to create a context for it. + try ctx.processMessage(.{ .id = 32, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.querySelector('iframe').src = 'isolated_world_two.html'", + } }); + try testing.waitForPage(bc); + + try testing.expect(world.isSeeded(root._frame_id)); + try testing.expect(world.isSeeded(child._frame_id) == false); + try testing.expect(world.contextFor(child) == null); +} + +// A worldName preload script is the one trigger that reaches frames the client +// never named, matching blink's InjectScripts. Puppeteer relies on it to give +// dynamically-added iframes a utility world. +test "cdp.frame: a worldName preload script seeds every frame" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-IWP", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* }); + const root = bc.mainFrame() orelse unreachable; + const child = root.child_frames.items[0]; + + try ctx.processMessage(.{ .id = 30, .method = "Runtime.enable", .sessionId = "SID-X" }); + try ctx.processMessage(.{ .id = 31, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ + .source = "globalThis.__seeded = 'yes';", + .worldName = "utility", + } }); + + // Registering the script registers the world, but seeds nothing yet. + const world = bc.findIsolatedWorld("utility") orelse unreachable; + try testing.expect(world.contextFor(child) == null); + + try ctx.processMessage(.{ .id = 32, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "document.querySelector('iframe').src = 'isolated_world_two.html'", + } }); + try testing.waitForPage(bc); + + // The child was never named in a Page.createIsolatedWorld, but the script + // seeded it on navigation. + try testing.expect(world.isSeeded(child._frame_id)); + const child_ctx = try isolatedWorldContextId(bc, child); + try ctx.processMessage(.{ .id = 33, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "__seeded", + .contextId = child_ctx, + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "yes" } }, .{ .id = 33 }); + + // ...and ran there, not in the main world. + try ctx.processMessage(.{ .id = 34, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{ + .expression = "typeof globalThis.__seeded", + } }); + try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "undefined" } }, .{ .id = 34 }); +} + // puppeteer: the utility world is created on the bootstrap about:blank and // must be announced again for the first document, which navigates the // pristine Frame in place (no teardown, no frame_destroyed).