diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index dd0516aa23..d6720c39d4 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/browser/tests/cdp/isolated_world.html b/src/browser/tests/cdp/isolated_world.html
new file mode 100644
index 0000000000..03a6eeb6fb
--- /dev/null
+++ b/src/browser/tests/cdp/isolated_world.html
@@ -0,0 +1 @@
+
Parent jobs
diff --git a/src/browser/tests/cdp/isolated_world_one.html b/src/browser/tests/cdp/isolated_world_one.html
new file mode 100644
index 0000000000..71ec60c028
--- /dev/null
+++ b/src/browser/tests/cdp/isolated_world_one.html
@@ -0,0 +1 @@
+Jobs page one
diff --git a/src/browser/tests/cdp/isolated_world_two.html b/src/browser/tests/cdp/isolated_world_two.html
new file mode 100644
index 0000000000..5f9e88429f
--- /dev/null
+++ b/src/browser/tests/cdp/isolated_world_two.html
@@ -0,0 +1 @@
+Jobs page two
diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig
index b82491b531..ee9bcf9e8d 100644
--- a/src/server/cdp/CDP.zig
+++ b/src/server/cdp/CDP.zig
@@ -568,6 +568,7 @@ pub const BrowserContext = struct {
try notification.register(.frame_navigated_within_document, self, onFrameNavigatedWithinDocument);
try notification.register(.frame_navigate_failed, self, onFrameNavigateFailed);
try notification.register(.frame_child_frame_created, self, onFrameChildFrameCreated);
+ try notification.register(.frame_destroyed, self, onFrameDestroyed);
try notification.register(.frame_dom_content_loaded, self, onFrameDOMContentLoaded);
try notification.register(.frame_loaded, self, onFrameLoaded);
try notification.register(.javascript_dialog_opening, self, onJavascriptDialogOpening);
@@ -639,35 +640,35 @@ 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");
errdefer arena.release();
- const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena");
- errdefer call_arena.release();
-
- const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena");
- errdefer local_arena.release();
-
const world = try arena.create(IsolatedWorld);
world.* = .{
.arena = arena,
- .call_arena = call_arena,
- .local_arena = local_arena,
- .context = null,
.browser = browser,
.name = try arena.dupe(u8, world_name),
.grant_universal_access = grant_universal_access,
@@ -675,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 {
@@ -934,6 +947,11 @@ pub const BrowserContext = struct {
return @import("domains/page.zig").frameNavigatedWithinDocument(self, msg);
}
+ pub fn onFrameDestroyed(ctx: *anyopaque, frame: *const Frame) !void {
+ const self: *BrowserContext = @ptrCast(@alignCast(ctx));
+ @import("domains/page.zig").frameDestroyed(self, frame);
+ }
+
pub fn onFrameChildFrameCreated(ctx: *anyopaque, msg: *const Notification.FrameChildFrameCreated) !void {
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").frameChildFrameCreated(self, msg);
@@ -1168,66 +1186,132 @@ 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,
};
-/// in the isolated world by using its Context ID or the worldName.
-/// grantUniversalAccess Indicates 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.
-/// Generally the client needs to resolve a node into the isolated world to be able to work with it.
-/// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts.
-const IsolatedWorld = struct {
+/// An isolated world is identified by its name and has one V8::Context per
+/// 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.
+pub const IsolatedWorld = struct {
arena: *lp.Arena,
- call_arena: *lp.Arena,
- local_arena: *lp.Arena,
browser: *Browser,
name: []const u8,
- context: ?*js.Context = null,
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).
- // This ensures CDP inspector contexts don't share v8::Globals with 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.
identity: js.Identity = .{},
+ const FrameContext = struct {
+ frame: *const Frame,
+ context: *js.Context,
+ // Per-context, not per-world: the call_arena is reset when a context's
+ // call depth returns to 0, which would free the data of another frame's
+ // in-flight call if they shared one.
+ call_arena: *lp.Arena,
+ local_arena: *lp.Arena,
+ };
+
pub fn deinit(self: *IsolatedWorld) void {
- self.removeContext();
- self.call_arena.release();
- self.local_arena.release();
+ self.removeAllContexts();
self.arena.release();
}
- pub fn removeContext(self: *IsolatedWorld) void {
- if (self.context) |ctx| {
- self.browser.env.destroyContext(ctx);
- self.context = null;
+ pub fn seed(self: *IsolatedWorld, frame_id: u32) !void {
+ if (self.isSeeded(frame_id)) {
+ return;
}
- // I don't think it's possible to have any identity without a context,
- // but there's no harm in being safe.
- self.identity.deinit();
- self.identity = .{};
+ 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.
+ pub fn contextFor(self: *const IsolatedWorld, frame: *const Frame) ?*js.Context {
+ for (self.contexts.items) |fc| {
+ if (fc.frame == frame) {
+ return fc.context;
+ }
+ }
+ return null;
}
- // The isolate world must share at least some of the state with the related frame, specifically the DocumentHTML
- // (assuming grantUniversalAccess will be set to True!).
- // We just created the world and the frame. The frame's state lives in the session, but is update on navigation.
- // This also means this pointer becomes invalid after removePage until a new frame is created.
- // Currently we have only 1 frame and thus also only 1 state in the isolate world.
+ // Callers must register the returned context with the inspector
pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context {
- if (self.context == null) {
- const ctx = try self.browser.env.createContext(frame, .{
- .identity = &self.identity,
- .identity_arena = self.arena.allocator(),
- .call_arena = self.call_arena.allocator(),
- .local_arena = self.local_arena.allocator(),
- .debug_name = "IsolatedContext",
- });
- self.context = ctx;
- } else {
- log.warn(.cdp, "not implemented", .{
- .feature = "createContext: Not implemented second isolated context creation",
- .info = "reuse existing context",
- });
+ lp.assert(self.contextFor(frame) == null, "IsolatedWorld.createContext duplicate", .{ .frame_id = frame._frame_id });
+
+ const browser = self.browser;
+ const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena");
+ errdefer call_arena.release();
+
+ const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena");
+ errdefer local_arena.release();
+
+ const ctx = try browser.env.createContext(frame, .{
+ .identity = &self.identity,
+ .identity_arena = self.arena.allocator(),
+ .call_arena = call_arena.allocator(),
+ .local_arena = local_arena.allocator(),
+ .debug_name = "IsolatedContext",
+ });
+ errdefer browser.env.destroyContext(ctx);
+ try ctx.setOrigin(frame.origin);
+
+ try self.contexts.append(self.arena.allocator(), .{
+ .frame = frame,
+ .context = ctx,
+ .call_arena = call_arena,
+ .local_arena = local_arena,
+ });
+ return ctx;
+ }
+
+ pub fn removeContext(self: *IsolatedWorld, frame: *const Frame) void {
+ for (self.contexts.items, 0..) |fc, i| {
+ if (fc.frame == frame) {
+ self.destroyFrameContext(fc);
+ _ = self.contexts.swapRemove(i);
+ return;
+ }
}
- return self.context.?;
+ }
+
+ pub fn removeAllContexts(self: *IsolatedWorld) void {
+ for (self.contexts.items) |fc| {
+ self.destroyFrameContext(fc);
+ }
+ self.contexts.clearRetainingCapacity();
+
+ // The page's objects are going away with the root frame; wrappers
+ // keyed by their addresses must not survive to alias a new page's.
+ self.identity.deinit();
+ self.identity = .{};
+ }
+
+ fn destroyFrameContext(self: *IsolatedWorld, fc: FrameContext) void {
+ // A re-navigating child frame keeps its Window, and the identity map
+ // keeps the window's global proxy; detach it from this context so the
+ // frame's next context can reattach it (as the main world does).
+ fc.context.detachGlobal();
+ self.browser.env.destroyContext(fc.context);
+ fc.call_arena.release();
+ fc.local_arena.release();
}
};
diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig
index 238f0be56e..7ba07da02e 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,40 +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| {
- ls.deinit();
- ls_open = false;
-
- const ctx = (isolated_world.context orelse return error.ContextNotFound);
- ctx.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(
@@ -396,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,
@@ -1129,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 7ea711e4a9..9025876ce8 100644
--- a/src/server/cdp/domains/page.zig
+++ b/src/server/cdp/domains/page.zig
@@ -158,6 +158,15 @@ 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) {
+ _ = try bc.createIsolatedWorld(name, true);
+ world_name = try bc.arena.dupe(u8, name);
+ }
+ }
+
const script_id = bc.next_script_id;
bc.next_script_id += 1;
@@ -165,6 +174,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;
@@ -246,22 +256,54 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
+ const frame_id = try id.parseFrameId(params.frameId);
+ const frame = bc.session.findFrameByFrameId(frame_id) orelse {
+ return cmd.sendError(-32000, "Frame with the given id does not belong to the target.", .{});
+ };
- // An existing world already has a live, inspector-registered context for
- // the current document: return its id without re-registering.
- if (world.context) |js_context| {
- var ls: js.Local.Scope = undefined;
- js_context.localScope(&ls);
- defer ls.deinit();
- const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
- return cmd.sendResult(.{ .executionContextId = context_id }, .{});
- }
+ const gop = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
+ const world = gop.world;
- const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
+ 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);
+
+ var ls: js.Local.Scope = undefined;
+ js_context.localScope(&ls);
+ defer ls.deinit();
+ const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
+ return cmd.sendResult(.{ .executionContextId = context_id }, .{});
+}
+
+// Creates `world`'s context for `frame` and registers it with the inspector
+fn createIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world: *CDP.IsolatedWorld, frame: *Frame, loader_id: ?[]const u8) !*js.Context {
const js_context = try world.createContext(frame);
- const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId});
+ errdefer world.removeContext(frame);
+ try registerIsolatedWorldContext(arena, bc, world, js_context, frame, loader_id);
+ return js_context;
+}
+
+// Registers a world context with the inspector, which assigns the id clients
+// 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|
+ try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, lid })
+ else
+ try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{frame_id});
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
@@ -269,14 +311,11 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
bc.inspector_session.inspector.contextCreated(
&ls.local,
- params.worldName,
+ world.name,
frame.origin orelse "",
aux_data,
false,
);
-
- const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
- return cmd.sendResult(.{ .executionContextId = context_id }, .{});
}
fn navigate(cmd: *CDP.Command) !void {
@@ -517,7 +556,7 @@ pub fn frameRemove(bc: *CDP.BrowserContext) void {
// The main frame is going to be removed, we need to remove contexts from other worlds first.
for (bc.isolated_worlds.items) |isolated_world| {
- isolated_world.removeContext();
+ isolated_world.removeAllContexts();
}
// node_registry / node_search_list reference Nodes from the page being
@@ -548,10 +587,6 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void {
bc.main_world_touched = false;
}
- for (bc.isolated_worlds.items) |isolated_world| {
- _ = try isolated_world.createContext(frame);
- }
-
if (in_commit == false) {
// Only retain captured responses until a navigation event. In CDP
// terms, this is called a "renderer" and the cache-duration can be
@@ -585,6 +620,13 @@ pub fn frameNavigateFailed(bc: *CDP.BrowserContext, event: *const Notification.F
});
}
+// Fired from Frame.deinit while the frame's JS is still alive.
+pub fn frameDestroyed(bc: *CDP.BrowserContext, frame: *const Frame) void {
+ for (bc.isolated_worlds.items) |isolated_world| {
+ isolated_world.removeContext(frame);
+ }
+}
+
pub fn frameChildFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.FrameChildFrameCreated) !void {
const session_id = bc.session_id orelse return;
@@ -703,50 +745,63 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
is_root_frame,
);
}
- // Isolated worlds are session-wide (single V8 context shared across
- // navigations). Only re-register them for main frame navigations;
- // re-registering during child frame (iframe) navigations would
- // re-register the same V8 context under a new inspector id, silently
- // invalidating the id the main frame is using.
- if (is_root_frame) {
- for (bc.isolated_worlds.items) |isolated_world| {
- const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
-
- // Calling contextCreated will assign a new Id to the context and send the contextCreated event
-
- var ls: js.Local.Scope = undefined;
- (isolated_world.context orelse continue).localScope(&ls);
- defer ls.deinit();
-
- bc.inspector_session.inspector.contextCreated(
- &ls.local,
- isolated_world.name,
- "://",
- aux_json,
- false,
- );
+ // 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).
+ // A child keeps the id the client was given. The root's id was just
+ // invalidated by executionContextsCleared: the first navigation of a
+ // pristine about:blank keeps the Frame and its contexts.
+ if (!is_root_frame) {
+ continue;
+ }
+ registerIsolatedWorldContext(arena, bc, isolated_world, js_context, frame, loader_id) catch |err| {
+ log.warn(.cdp, "isolated world context", .{ .err = err, .world = isolated_world.name, .frame_id = frame._frame_id });
+ };
+ 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 });
+ };
}
// 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) {
+ 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.
@@ -1231,29 +1286,319 @@ test "cdp.frame: createIsolatedWorld is idempotent per name" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
+ const root = bc.mainFrame() orelse unreachable;
+ const root_id = id.toFrameId(root._frame_id);
try ctx.processMessage(.{ .id = 20, .method = "Page.createIsolatedWorld", .params = .{
- .frameId = "FID-000000000X",
+ .frameId = &root_id,
.worldName = "utility",
.grantUniveralAccess = true,
} });
try testing.expectEqual(1, bc.isolated_worlds.items.len);
- const world_context = bc.isolated_worlds.items[0].context.?;
+ const world_context = bc.isolated_worlds.items[0].contextFor(root).?;
try ctx.processMessage(.{ .id = 21, .method = "Page.createIsolatedWorld", .params = .{
- .frameId = "FID-000000000X",
+ .frameId = &root_id,
.worldName = "utility",
.grantUniveralAccess = true,
} });
try testing.expectEqual(1, bc.isolated_worlds.items.len);
- try testing.expectEqual(world_context, bc.isolated_worlds.items[0].context.?);
+ try testing.expectEqual(world_context, bc.isolated_worlds.items[0].contextFor(root).?);
try ctx.processMessage(.{ .id = 22, .method = "Page.createIsolatedWorld", .params = .{
- .frameId = "FID-000000000X",
+ .frameId = &root_id,
.worldName = "other",
.grantUniveralAccess = true,
} });
try testing.expectEqual(2, bc.isolated_worlds.items.len);
+
+ try ctx.processMessage(.{ .id = 23, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = "FID-4000000000",
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try ctx.expectSentError(-32000, "Frame with the given id does not belong to the target.", .{ .id = 23 });
+}
+
+// #3347: a world requested for a child frame must evaluate against that
+// frame's document, survive the root world, and follow the child across its
+// re-navigation.
+test "cdp.frame: createIsolatedWorld targets the requested frame" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW", .url = "cdp/isolated_world.html", .target_id = "FID-000000000X".* });
+ const root = bc.mainFrame() orelse unreachable;
+ try testing.expectEqual(1, root.child_frames.items.len);
+ const child = root.child_frames.items[0];
+ const root_id = id.toFrameId(root._frame_id);
+ const child_id = id.toFrameId(child._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 root_ctx = try isolatedWorldContextId(bc, root);
+ try ctx.expectSentResult(.{ .executionContextId = root_ctx }, .{ .id = 31 });
+
+ try ctx.processMessage(.{ .id = 32, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = &child_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ const child_ctx = try isolatedWorldContextId(bc, child);
+ try testing.expect(child_ctx != root_ctx);
+ try ctx.expectSentResult(.{ .executionContextId = child_ctx }, .{ .id = 32 });
+ try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{
+ .id = child_ctx,
+ .name = "utility",
+ .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &child_id },
+ } }, .{ .session_id = "SID-X" });
+
+ try ctx.processMessage(.{ .id = 33, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "document.title",
+ .contextId = child_ctx,
+ } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 33 });
+
+ // Navigate only the child. Its Frame is re-initialized in place: same
+ // frame id, new document, and a new world context announced for it.
+ try ctx.processMessage(.{ .id = 34, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "document.querySelector('iframe').src = 'isolated_world_two.html'",
+ } });
+ try testing.waitForPage(bc);
+ try testing.expectEqual(child, root.child_frames.items[0]);
+ try testing.expect(std.mem.endsWith(u8, child.url, "/cdp/isolated_world_two.html"));
+
+ try ctx.expectSentEvent("Runtime.executionContextDestroyed", .{ .executionContextId = child_ctx }, .{ .session_id = "SID-X" });
+ const child_ctx2 = try isolatedWorldContextId(bc, child);
+ try testing.expect(child_ctx2 != child_ctx);
+ try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{
+ .id = child_ctx2,
+ .name = "utility",
+ .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &child_id },
+ } }, .{ .session_id = "SID-X" });
+
+ // A driver that re-requests the world gets the announced context.
+ try ctx.processMessage(.{ .id = 35, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = &child_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try ctx.expectSentResult(.{ .executionContextId = child_ctx2 }, .{ .id = 35 });
+
+ try ctx.processMessage(.{ .id = 36, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "document.title",
+ .contextId = child_ctx2,
+ } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page two" } }, .{ .id = 36 });
+
+ // The root world was untouched by the child navigation.
+ try ctx.processMessage(.{ .id = 37, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "document.title",
+ .contextId = root_ctx,
+ } });
+ 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).
+test "cdp.frame: isolated world survives the in-place first navigation" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ try ctx.processMessage(.{ .id = 40, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } });
+ try ctx.processMessage(.{ .id = 41, .method = "Target.createTarget", .params = .{ .url = "about:blank" } });
+ const bc = &ctx.cdp().browser_context.?;
+ const session_id = bc.session_id.?;
+ const root = bc.mainFrame() orelse unreachable;
+ const root_id = id.toFrameId(root._frame_id);
+
+ try ctx.processMessage(.{ .id = 42, .method = "Runtime.enable", .sessionId = session_id });
+ try ctx.processMessage(.{ .id = 43, .method = "Page.createIsolatedWorld", .sessionId = session_id, .params = .{
+ .frameId = &root_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ const blank_ctx = try isolatedWorldContextId(bc, root);
+
+ try ctx.processMessage(.{ .id = 44, .method = "Page.navigate", .sessionId = session_id, .params = .{
+ .url = "http://127.0.0.1:9582/src/browser/tests/cdp/isolated_world_one.html",
+ } });
+ try testing.waitForPage(bc);
+ try testing.expectEqual(root, bc.mainFrame().?);
+
+ const page_ctx = try isolatedWorldContextId(bc, root);
+ try testing.expect(page_ctx != blank_ctx);
+ try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{
+ .id = page_ctx,
+ .name = "utility",
+ .auxData = .{ .isDefault = false, .type = "isolated", .frameId = &root_id },
+ } }, .{ .session_id = session_id });
+
+ try ctx.processMessage(.{ .id = 45, .method = "Runtime.evaluate", .sessionId = session_id, .params = .{
+ .expression = "document.title",
+ .contextId = page_ctx,
+ } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 45 });
+}
+
+// A committed root navigation tears the old Page down later, with the same
+// frame id as the live page. That teardown must not take the live page's
+// world context with it.
+test "cdp.frame: isolated world survives the old page's deferred teardown" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW2", .url = "cdp/isolated_world_one.html", .target_id = "FID-000000000X".* });
+ const old_root = bc.mainFrame() orelse unreachable;
+ const old_frame_id = old_root._frame_id;
+ const root_id = id.toFrameId(old_frame_id);
+
+ try ctx.processMessage(.{ .id = 50, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = &root_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try testing.expect(bc.isolated_worlds.items[0].contextFor(old_root) != null);
+
+ try ctx.processMessage(.{ .id = 51, .method = "Page.navigate", .sessionId = "SID-X", .params = .{
+ .url = "http://127.0.0.1:9582/src/browser/tests/cdp/isolated_world_two.html",
+ } });
+ try testing.waitForPage(bc);
+ bc.session.processDestroyQueues();
+
+ // old_root is freed now; only its address is compared.
+ const root = bc.mainFrame() orelse unreachable;
+ try testing.expect(root != old_root);
+ try testing.expectEqual(old_frame_id, root._frame_id);
+ try testing.expectEqual(1, bc.isolated_worlds.items[0].contexts.items.len);
+
+ const page_ctx = try isolatedWorldContextId(bc, root);
+ try ctx.processMessage(.{ .id = 52, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "document.title",
+ .contextId = page_ctx,
+ } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page two" } }, .{ .id = 52 });
+}
+
+test "cdp.frame: isolated world contexts share their frame's origin token" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-IW3", .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);
+ const child_id = id.toFrameId(child._frame_id);
+
+ try ctx.processMessage(.{ .id = 60, .method = "Runtime.enable", .sessionId = "SID-X" });
+ try ctx.processMessage(.{ .id = 61, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = &root_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+ try ctx.processMessage(.{ .id = 62, .method = "Page.createIsolatedWorld", .params = .{
+ .frameId = &child_id,
+ .worldName = "utility",
+ .grantUniveralAccess = true,
+ } });
+
+ // frames[0] is the child's window in this world; same origin as the root,
+ // so V8 must let the root's context through.
+ try ctx.processMessage(.{ .id = 63, .method = "Runtime.evaluate", .sessionId = "SID-X", .params = .{
+ .expression = "frames[0].document.title",
+ .contextId = try isolatedWorldContextId(bc, root),
+ } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "Jobs page one" } }, .{ .id = 63 });
+}
+
+fn isolatedWorldContextId(bc: *CDP.BrowserContext, frame: *const Frame) !i32 {
+ const js_context = bc.isolated_worlds.items[0].contextFor(frame) orelse return error.ContextNotFound;
+ var ls: js.Local.Scope = undefined;
+ js_context.localScope(&ls);
+ defer ls.deinit();
+ return bc.inspector_session.inspector.getContextId(&ls.local);
}
test "cdp.frame: child frame metadata" {