diff --git a/src/browser/js/Inspector.zig b/src/browser/js/Inspector.zig
index 5d176095f..94d467ec8 100644
--- a/src/browser/js/Inspector.zig
+++ b/src/browser/js/Inspector.zig
@@ -17,7 +17,6 @@
// along with this program. If not, see .
const std = @import("std");
-const lp = @import("lightpanda");
const js = @import("js.zig");
const v8 = js.v8;
@@ -42,7 +41,6 @@ isolate: *v8.Isolate,
handle: *v8.Inspector,
client: *v8.InspectorClientImpl,
default_context: ?v8.Global,
-session: ?Session,
pub fn init(allocator: Allocator, isolate: *v8.Isolate) !*Inspector {
const self = try allocator.create(Inspector);
@@ -50,7 +48,6 @@ pub fn init(allocator: Allocator, isolate: *v8.Isolate) !*Inspector {
self.* = .{
.unique_id = 1,
- .session = null,
.isolate = isolate,
.client = undefined,
.handle = undefined,
@@ -72,29 +69,11 @@ pub fn deinit(self: *const Inspector, allocator: Allocator) void {
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate);
defer v8.v8__HandleScope__DESTRUCT(&hs);
- if (self.session) |*s| {
- s.deinit();
- }
v8.v8_inspector__Client__IMPL__DELETE(self.client);
v8.v8_inspector__Inspector__DELETE(self.handle);
allocator.destroy(self);
}
-pub fn startSession(self: *Inspector, ctx: anytype) *Session {
- if (comptime lp.IS_DEBUG) {
- std.debug.assert(self.session == null);
- }
-
- self.session = @as(Session, undefined);
- Session.init(&self.session.?, self, ctx);
- return &self.session.?;
-}
-
-pub fn stopSession(self: *Inspector) void {
- self.session.?.deinit();
- self.session = null;
-}
-
// From CDP docs
// https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-ExecutionContextDescription
// ----
@@ -199,7 +178,10 @@ const RemoteObject = struct {
// Combines a v8::InspectorSession and a v8::InspectorChannelImpl. The
// InspectorSession is for zig -> v8 (sending messages to the inspector). The
// Channel is for v8 -> zig, getting events from the Inspector (that we'll pass
-// back to some opaque context, i.e the CDP BrowserContext).
+// back to some opaque context, i.e the CDP AttachedSession).
+// The channel keeps the Session's address, so the owner must not move it
+// between init and deinit. Every Session connects to the same
+// CONTEXT_GROUP_ID and so sees every context.
// The channel callbacks are defined below, as:
// pub export fn v8_inspector__Channel__IMPL__XYZ
pub const Session = struct {
@@ -212,7 +194,7 @@ pub const Session = struct {
onNotif: *const fn (ctx: *anyopaque, msg: []const u8) void,
onResp: *const fn (ctx: *anyopaque, call_id: u32, msg: []const u8) void,
- fn init(self: *Session, inspector: *Inspector, ctx: anytype) void {
+ pub fn init(self: *Session, inspector: *Inspector, ctx: anytype) void {
const Container = @typeInfo(@TypeOf(ctx)).pointer.child;
const channel = v8.v8_inspector__Channel__IMPL__CREATE(inspector.isolate);
@@ -234,11 +216,23 @@ pub const Session = struct {
};
}
- fn deinit(self: *const Session) void {
+ pub fn deinit(self: *Session) void {
+ // Deleting the V8 session fails its pending evaluations, which V8
+ // answers through the channel. The client is gone: drop them.
+ self.onResp = dropResponse;
+ self.onNotif = dropNotification;
+
+ var hs: v8.HandleScope = undefined;
+ v8.v8__HandleScope__CONSTRUCT(&hs, self.inspector.isolate);
+ defer v8.v8__HandleScope__DESTRUCT(&hs);
+
v8.v8_inspector__Session__DELETE(self.handle);
v8.v8_inspector__Channel__IMPL__DELETE(self.channel);
}
+ fn dropResponse(_: *anyopaque, _: u32, _: []const u8) void {}
+ fn dropNotification(_: *anyopaque, _: []const u8) void {}
+
pub fn send(self: *const Session, msg: []const u8) void {
const isolate = self.inspector.isolate;
var hs: v8.HandleScope = undefined;
diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig
index cf1dd2e84..0a4e74cf9 100644
--- a/src/server/cdp/CDP.zig
+++ b/src/server/cdp/CDP.zig
@@ -333,18 +333,8 @@ pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const
return browser_session_id;
}
}
- const browser_context = &(self.browser_context orelse return null);
- if (browser_context.session_id) |session_id| {
- if (std.mem.eql(u8, session_id, input_session_id)) {
- return session_id;
- }
- }
- for (browser_context.attached_sessions.items) |session| {
- if (std.mem.eql(u8, session.id, input_session_id)) {
- return session.id;
- }
- }
- return null;
+ const browser_context = if (self.browser_context) |*bc| bc else return null;
+ return browser_context.attached_sessions.getKey(input_session_id);
}
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
@@ -407,9 +397,41 @@ pub const BrowserContext = struct {
id: u32,
};
- const AttachedSession = struct {
+ // A CDP session attached to this context's page target. Each owns a V8
+ // inspector session; V8 keeps `Runtime.enable` state and remote object ids
+ // per session, so a command is answered on the session it was sent
+ // through and a session only gets the inspector events it enabled.
+ pub const AttachedSession = struct {
id: []const u8,
parent_id: ?[]const u8,
+ bc: *BrowserContext,
+ inspector_session: js.Inspector.Session,
+
+ /// V8 -> this session. Stamp OUR id on responses and events.
+ pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
+ const self: *AttachedSession = @ptrCast(@alignCast(ctx));
+ self.bc.sendInspectorMessage(msg, self.id) catch |err| {
+ log.err(.cdp, "send inspector response", .{ .err = err });
+ };
+ }
+
+ pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
+ const self: *AttachedSession = @ptrCast(@alignCast(ctx));
+ if (log.enabled(.cdp, .debug)) {
+ // msg should be {"method":,...
+ lp.assert(std.mem.startsWith(u8, msg, "{\"method\":"), "onInspectorEvent prefix", .{});
+ const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
+ log.err(.cdp, "invalid inspector event", .{ .msg = msg });
+ return;
+ };
+ const method = msg[10..method_end];
+ log.debug(.cdp, "inspector event", .{ .method = method, .session_id = self.id });
+ }
+
+ self.bc.sendInspectorMessage(msg, self.id) catch |err| {
+ log.err(.cdp, "send inspector event", .{ .err = err });
+ };
+ }
};
id: []const u8,
cdp: *CDP,
@@ -444,16 +466,18 @@ pub const BrowserContext = struct {
// it). null until the first page is created.
page_handle: ?Session.PageHandle = null,
- // The CDP session_id. After the target/page is created, the client
- // "attaches" to it (either explicitly or automatically). We return a
- // "sessionId" which identifies this link. `sessionId` is the how
- // the CDP client informs us what it's trying to manipulate. Because we
- // only support 1 BrowserContext at a time, and 1 page at a time, this
- // is all pretty straightforward, but it still needs to be enforced, i.e.
- // if we get a request with a sessionId that doesn't match the current one
- // we should reject it.
+ // The primary CDP session's id. After the target/page is created, the
+ // client "attaches" to it (either explicitly or automatically). We return
+ // a "sessionId" which identifies this link. `sessionId` is the how the
+ // CDP client informs us what it's trying to manipulate. Also included in
+ // `attached_sessions`.
session_id: ?[]const u8,
- attached_sessions: std.ArrayList(AttachedSession) = .empty,
+
+ // Every session attached to the page target, the primary included, by
+ // session id. Insertion-ordered so detach events come out in attach order.
+ //
+ // Request with a session id that isn't there is rejected.
+ attached_sessions: std.StringArrayHashMapUnmanaged(*AttachedSession) = .empty,
// A cancelled text-less keyDown drops the char message that follows it
// (chromedp's keyDown/char/keyUp split), as Chrome does.
@@ -470,7 +494,6 @@ pub const BrowserContext = struct {
// entries evicted by resetFrame can linger harmlessly until reset.
set_child_nodes_sent: std.AutoHashMapUnmanaged(NodeRegistry.Id, void) = .empty,
- inspector_session: *js.Inspector.Session,
isolated_worlds: std.ArrayList(*IsolatedWorld),
// True when Runtime.evaluate has been run in the main world. Optimization
@@ -533,10 +556,6 @@ pub const BrowserContext = struct {
lp.cookies.loadFromFile(session, cookie_path);
}
- const browser = &cdp.browser;
- const inspector_session = browser.env.inspector.?.startSession(self);
- errdefer browser.env.inspector.?.stopSession();
-
var registry = NodeRegistry.init(allocator);
errdefer registry.deinit();
@@ -552,7 +571,6 @@ pub const BrowserContext = struct {
.node_registry = registry,
.node_search_list = undefined,
.isolated_worlds = .empty,
- .inspector_session = inspector_session,
.frame_arena = cdp.frame_arena.allocator(),
.arena = cdp.browser_context_arena.allocator(),
.notification_arena = cdp.notification_arena.allocator(),
@@ -585,7 +603,7 @@ pub const BrowserContext = struct {
// It appends async tasks, so we make sure we run the message loop
// before deinit it.
env.inspector.?.resetContextGroup();
- env.inspector.?.stopSession();
+ self.detachAllSessions();
// abort all intercepted requests before closing the session/page
// since some of these might callback into the page/scriptmanager.
@@ -622,6 +640,7 @@ pub const BrowserContext = struct {
self.node_registry.deinit();
self.node_search_list.deinit();
self.set_child_nodes_sent.deinit(self.cdp.allocator);
+ self.attached_sessions.deinit(self.cdp.allocator);
// Session.deinit (called via closeSession above) already cleared this
// notification off any ownerless CorsGate/RobotsGate transfers.
@@ -842,7 +861,7 @@ pub const BrowserContext = struct {
pub fn closeTarget(self: *BrowserContext) !void {
const target_id = self.target_id orelse return;
const cdp = self.cdp;
- for (self.attached_sessions.items) |session| {
+ for (self.attached_sessions.values()) |session| {
self.fetchDisableForSession(session.id);
try cdp.sendEvent("Inspector.detached", .{
.reason = "Render process gone.",
@@ -853,21 +872,7 @@ pub const BrowserContext = struct {
.reason = "Render process gone.",
}, .{ .session_id = session.parent_id });
}
- self.attached_sessions.clearRetainingCapacity();
-
- // could be null, created but never attached
- if (self.session_id) |session_id| {
- self.fetchDisableForSession(session_id);
- try cdp.sendEvent("Inspector.detached", .{
- .reason = "Render process gone.",
- }, .{ .session_id = session_id });
- try cdp.sendEvent("Target.detachedFromTarget", .{
- .targetId = target_id,
- .sessionId = session_id,
- .reason = "Render process gone.",
- }, .{});
- self.session_id = null;
- }
+ self.detachAllSessions();
try cdp.sendEvent("Target.targetDestroyed", .{ .targetId = target_id }, .{});
@@ -1174,44 +1179,85 @@ pub const BrowserContext = struct {
}
}
- pub fn callInspector(self: *const BrowserContext, msg: []const u8) void {
- self.inspector_session.send(msg);
+ pub inline fn inspector(self: *const BrowserContext) *js.Inspector {
+ return self.cdp.browser.env.inspector.?;
+ }
+
+ /// Attaches `session_id` to the page target with its own inspector
+ /// session. The id is copied into the context's arena.
+ pub fn attachSession(self: *BrowserContext, session_id: []const u8, parent_id: ?[]const u8) !*AttachedSession {
+ if (self.attached_sessions.contains(session_id)) {
+ return error.SessionAlreadyAttached;
+ }
+ const allocator = self.cdp.allocator;
+
+ const attached = try allocator.create(AttachedSession);
+ errdefer allocator.destroy(attached);
+
+ attached.* = .{
+ .id = try self.arena.dupe(u8, session_id),
+ .parent_id = parent_id,
+ .bc = self,
+ .inspector_session = undefined,
+ };
+ attached.inspector_session.init(self.inspector(), attached);
+ errdefer attached.inspector_session.deinit();
+
+ try self.attached_sessions.put(allocator, attached.id, attached);
+ return attached;
+ }
+
+ /// The first session attached to the target.
+ pub fn attachPrimarySession(self: *BrowserContext, session_id: []const u8) !*AttachedSession {
+ lp.assert(self.session_id == null, "CDP.BrowserContext.attachPrimarySession already attached", .{});
+ const attached = try self.attachSession(session_id, null);
+ self.session_id = attached.id;
+ return attached;
+ }
+
+ /// Stops the session's inspector session and forgets it.
+ /// Returns false when no such session is attached.
+ pub fn detachSession(self: *BrowserContext, session_id: []const u8) bool {
+ const kv = self.attached_sessions.fetchOrderedRemove(session_id) orelse return false;
+ if (self.session_id) |primary| {
+ if (std.mem.eql(u8, primary, session_id)) {
+ self.session_id = null;
+ }
+ }
+ self.destroySession(kv.value);
+ return true;
+ }
+
+ pub fn detachAllSessions(self: *BrowserContext) void {
+ for (self.attached_sessions.values()) |attached| {
+ self.destroySession(attached);
+ }
+ self.attached_sessions.clearRetainingCapacity();
+ self.session_id = null;
+ }
+
+ fn destroySession(self: *BrowserContext, attached: *AttachedSession) void {
+ attached.inspector_session.deinit();
+ self.cdp.allocator.destroy(attached);
+ }
+
+ pub fn inspectorSession(self: *const BrowserContext, session_id: ?[]const u8) !*js.Inspector.Session {
+ const id = session_id orelse self.session_id orelse return error.SessionNotAttached;
+ const attached = self.attached_sessions.get(id) orelse return error.SessionNotAttached;
+ return &attached.inspector_session;
+ }
+
+ /// Forwards `cmd`'s raw JSON to the inspector session of the session it was sent through.
+ pub fn callInspector(self: *BrowserContext, cmd: *const Command) !void {
+ const inspector_session = try self.inspectorSession(cmd.input.session_id);
+ inspector_session.send(cmd.input.json);
self.session.browser.env.runMicrotasks();
}
- pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
- sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
- log.err(.cdp, "send inspector response", .{ .err = err });
- };
- }
-
- pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
- if (log.enabled(.cdp, .debug)) {
- // msg should be {"method":,...
- lp.assert(std.mem.startsWith(u8, msg, "{\"method\":"), "onInspectorEvent prefix", .{});
- const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
- log.err(.cdp, "invalid inspector event", .{ .msg = msg });
- return;
- };
- const method = msg[10..method_end];
- log.debug(.cdp, "inspector event", .{ .method = method });
- }
-
- sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
- log.err(.cdp, "send inspector event", .{ .err = err });
- };
- }
-
- // This is hacky x 2. First, we create the JSON payload by gluing our
+ // This is hacky x 2. First, we create the JSON payload by gluing the
// session_id onto it. Second, we're much more client/websocket aware than
// we should be.
- fn sendInspectorMessage(self: *BrowserContext, msg: []const u8) !void {
- const session_id = self.session_id orelse {
- // We no longer have an active session. What should we do
- // in this case?
- return;
- };
-
+ fn sendInspectorMessage(self: *BrowserContext, msg: []const u8, session_id: []const u8) !void {
const cdp = self.cdp;
const allocator = cdp.link.acquireSendArena();
defer cdp.link.releaseSendArena();
diff --git a/src/server/cdp/domains/accessibility.zig b/src/server/cdp/domains/accessibility.zig
index 9d46d796d..79346ec0a 100644
--- a/src/server/cdp/domains/accessibility.zig
+++ b/src/server/cdp/domains/accessibility.zig
@@ -89,7 +89,7 @@ fn queryAXTree(cmd: *CDP.Command) !void {
const params = (try cmd.params(Params)) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const node = try dom.getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try dom.getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const temp_arena = try frame.getArena(.medium, "AXNode");
@@ -124,7 +124,7 @@ fn getPartialAXTree(cmd: *CDP.Command) !void {
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const node = try dom.getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try dom.getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const temp_arena = try frame.getArena(.medium, "AXNode");
diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig
index 447362c59..f113c83b5 100644
--- a/src/server/cdp/domains/dom.zig
+++ b/src/server/cdp/domains/dom.zig
@@ -365,9 +365,12 @@ fn resolveNode(cmd: *CDP.Command) !void {
js_context.localScope(&ls);
defer ls.deinit();
+ // The object id is minted on the command's session; only that session can unwrap it later.
+ const inspector_session = try bc.inspectorSession(cmd.input.session_id);
+
// 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(
+ const remote_object = try inspector_session.getRemoteObject(
&ls.local,
params.objectGroup orelse "",
node.dom,
@@ -433,7 +436,7 @@ fn describeNode(cmd: *CDP.Command) !void {
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{ .depth = params.depth }) }, .{});
}
@@ -477,7 +480,7 @@ fn scrollIntoViewIfNeeded(cmd: *CDP.Command) !void {
// We retrieve the node to at least check if it exists and is valid.
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
switch (node.dom._type) {
.element => {},
@@ -489,7 +492,9 @@ fn scrollIntoViewIfNeeded(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
-pub fn getNode(arena: Allocator, bc: *CDP.BrowserContext, node_id: ?NodeRegistry.Id, backend_node_id: ?NodeRegistry.Id, object_id: ?[]const u8) !*NodeRegistry.Node {
+/// `session_id` belongs to the command; a remote object id only resolves on
+/// the inspector session that minted it.
+pub fn getNode(arena: Allocator, bc: *CDP.BrowserContext, session_id: ?[]const u8, node_id: ?NodeRegistry.Id, backend_node_id: ?NodeRegistry.Id, object_id: ?[]const u8) !*NodeRegistry.Node {
const input_node_id = node_id orelse backend_node_id;
if (input_node_id) |input_node_id_| {
return bc.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound;
@@ -501,7 +506,8 @@ pub fn getNode(arena: Allocator, bc: *CDP.BrowserContext, node_id: ?NodeRegistry
defer ls.deinit();
// Retrieve the object from which ever context it is in.
- const parser_node = try bc.inspector_session.getNodePtr(arena, object_id_, &ls.local);
+ const inspector_session = try bc.inspectorSession(session_id);
+ const parser_node = try inspector_session.getNodePtr(arena, object_id_, &ls.local);
return try bc.node_registry.register(@ptrCast(@alignCast(parser_node)));
}
return error.MissingParams;
@@ -519,7 +525,7 @@ fn getContentQuads(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
// TODO likely if the following CSS properties are set the quads should be empty
// visibility: hidden
@@ -545,7 +551,7 @@ fn getBoxModel(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
// TODO implement for document or text
const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement;
@@ -626,7 +632,7 @@ fn getOuterHTML(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
var aw = std.Io.Writer.Allocating.init(cmd.arena);
try dump.deep(node.dom, .{}, &aw.writer, frame);
@@ -640,7 +646,7 @@ fn requestNode(cmd: *CDP.Command) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
- const node = try getNode(cmd.arena, bc, null, null, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, null, null, params.objectId);
return cmd.sendResult(.{ .nodeId = node.id }, .{});
}
@@ -661,7 +667,7 @@ fn setFileInputFiles(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const root = bc.mainFrame() orelse return error.FrameNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement;
const input = element.is(Input) orelse return error.NotAnInputElement;
if (input._input_type != .file) return error.NotAFileInput;
@@ -694,7 +700,7 @@ fn focus(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
- const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
+ const node = try getNode(cmd.arena, bc, cmd.input.session_id, params.nodeId, params.backendNodeId, params.objectId);
const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement;
if (element.isFocusable(frame) == false) {
return cmd.sendError(-32000, "Element is not focusable", .{});
@@ -1400,7 +1406,49 @@ 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);
+ return bc.inspector().getContextId(&ls.local);
+}
+
+test "cdp.dom: remote object ids belong to the session that minted them" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-RO", .url = "cdp/dom1.html", .target_id = "FID-000000000R".*, .session_id = "SID-PRIMARY" });
+ _ = try bc.attachSession("SID-AUX", null);
+
+ const root = bc.mainFrame() orelse unreachable;
+ const html = root.document.getDocumentElement() orelse unreachable;
+ const html_node = try bc.node_registry.register(html.asNode());
+ const document_node = try bc.node_registry.register(root.document.asNode());
+
+ // The auxiliary session mints an id for and resolves it.
+ try ctx.processMessage(.{ .id = 20, .method = "DOM.resolveNode", .sessionId = "SID-AUX", .params = .{ .backendNodeId = html_node.id } });
+ const aux_object_id = try sentObjectId(&ctx, 20);
+ try ctx.processMessage(.{ .id = 21, .method = "DOM.requestNode", .sessionId = "SID-AUX", .params = .{ .objectId = aux_object_id } });
+ try ctx.expectSentResult(.{ .nodeId = html_node.id }, .{ .id = 21, .session_id = "SID-AUX" });
+
+ // The primary has minted nothing yet: the auxiliary's id is not its.
+ try ctx.processMessage(.{ .id = 22, .method = "Runtime.callFunctionOn", .sessionId = "SID-PRIMARY", .params = .{
+ .objectId = aux_object_id,
+ .functionDeclaration = "function() { return this.localName; }",
+ .returnByValue = true,
+ } });
+ try ctx.expectSentError(-32000, "Could not find object with given id", .{ .id = 22 });
+
+ // Each session numbers its ids by itself, so the primary's first id may
+ // well be the same string as the auxiliary's: the session a command comes
+ // through, not the id, tells the objects apart. Mint a different node on
+ // the primary and check that each id describes its own.
+ try ctx.processMessage(.{ .id = 23, .method = "DOM.resolveNode", .sessionId = "SID-PRIMARY", .params = .{ .backendNodeId = document_node.id } });
+ const primary_object_id = try sentObjectId(&ctx, 23);
+ try ctx.processMessage(.{ .id = 24, .method = "DOM.describeNode", .sessionId = "SID-PRIMARY", .params = .{ .objectId = primary_object_id } });
+ try ctx.expectSentResult(.{ .node = .{ .nodeId = document_node.id, .nodeName = "#document" } }, .{ .id = 24, .session_id = "SID-PRIMARY" });
+ try ctx.processMessage(.{ .id = 25, .method = "DOM.describeNode", .sessionId = "SID-AUX", .params = .{ .objectId = aux_object_id } });
+ try ctx.expectSentResult(.{ .node = .{ .nodeId = html_node.id, .localName = "html" } }, .{ .id = 25, .session_id = "SID-AUX" });
+ try ctx.processMessage(.{ .id = 26, .method = "DOM.requestNode", .sessionId = "SID-PRIMARY", .params = .{ .objectId = primary_object_id } });
+ try ctx.expectSentResult(.{ .nodeId = document_node.id }, .{ .id = 26, .session_id = "SID-PRIMARY" });
+ try ctx.processMessage(.{ .id = 27, .method = "DOM.requestNode", .sessionId = "SID-AUX", .params = .{ .objectId = aux_object_id } });
+ try ctx.expectSentResult(.{ .nodeId = html_node.id }, .{ .id = 27, .session_id = "SID-AUX" });
}
// The result.object.objectId of the response to command `msg_id`.
diff --git a/src/server/cdp/domains/fetch.zig b/src/server/cdp/domains/fetch.zig
index 0da0c325e..9cec0b738 100644
--- a/src/server/cdp/domains/fetch.zig
+++ b/src/server/cdp/domains/fetch.zig
@@ -595,10 +595,7 @@ test "cdp.Fetch: interception events belong to the enabling session" {
.session_id = "SID-PRIMARY",
.target_id = "TID-000000000B".*,
});
- try bc.attached_sessions.append(bc.arena, .{
- .id = "SID-AUX",
- .parent_id = null,
- });
+ _ = try bc.attachSession("SID-AUX", null);
try ctx.processMessage(.{
.id = 1,
diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig
index b431d88bd..8b9b95190 100644
--- a/src/server/cdp/domains/network.zig
+++ b/src/server/cdp/domains/network.zig
@@ -1591,7 +1591,7 @@ test "cdp.Network: worker requests emit network events" {
_ = try cdp.createBrowserContext();
var bc = &cdp.browser_context.?;
bc.id = "BID-NW";
- bc.session_id = "SID-NW";
+ _ = try bc.attachPrimarySession("SID-NW");
bc.target_id = "TID-NW-0000000".*;
try ctx.processMessage(.{ .id = 1, .method = "Network.enable" });
diff --git a/src/server/cdp/domains/page.zig b/src/server/cdp/domains/page.zig
index 71fa88d29..e2d5c66ed 100644
--- a/src/server/cdp/domains/page.zig
+++ b/src/server/cdp/domains/page.zig
@@ -284,7 +284,7 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void {
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
- const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
+ const context_id = bc.inspector().getContextId(&ls.local);
return cmd.sendResult(.{ .executionContextId = context_id }, .{});
}
@@ -315,7 +315,7 @@ fn registerIsolatedWorldContext(arena: Allocator, bc: *CDP.BrowserContext, world
js_context.localScope(&ls);
defer ls.deinit();
- bc.inspector_session.inspector.contextCreated(
+ bc.inspector().contextCreated(
&ls.local,
world.name,
frame.origin orelse "",
@@ -558,7 +558,7 @@ pub fn frameNavigate(bc: *CDP.BrowserContext, event: *const Notification.FrameNa
pub fn frameRemove(bc: *CDP.BrowserContext) void {
// Clear all remote object mappings to prevent stale objectIds from being used
// after the context is destroy
- bc.inspector_session.inspector.resetContextGroup();
+ bc.inspector().resetContextGroup();
// The main frame is going to be removed, we need to remove contexts from other worlds first.
for (bc.isolated_worlds.items) |isolated_world| {
@@ -743,7 +743,7 @@ pub fn frameNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
frame.js.localScope(&ls);
defer ls.deinit();
- bc.inspector_session.inspector.contextCreated(
+ bc.inspector().contextCreated(
&ls.local,
"",
frame.origin orelse "",
@@ -1735,7 +1735,7 @@ fn isolatedWorldContextId(bc: *CDP.BrowserContext, frame: *const Frame) !i32 {
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
- return bc.inspector_session.inspector.getContextId(&ls.local);
+ return bc.inspector().getContextId(&ls.local);
}
test "cdp.frame: child frame metadata" {
@@ -2170,7 +2170,7 @@ test "cdp.frame: reload replays POST navigation" {
_ = try cdp_inst.createBrowserContext();
var bc = &cdp_inst.browser_context.?;
bc.id = "BID-A6";
- bc.session_id = "SID-X";
+ _ = try bc.attachPrimarySession("SID-X");
bc.target_id = "TID-A6-0000000".*;
// First navigation: POST a form-style payload to /echo_method.
@@ -2222,7 +2222,7 @@ test "cdp.frame: reload after POST→redirect drops the POST" {
_ = try cdp_inst.createBrowserContext();
var bc = &cdp_inst.browser_context.?;
bc.id = "BID-A6R";
- bc.session_id = "SID-XR";
+ _ = try bc.attachPrimarySession("SID-XR");
bc.target_id = "TID-A6R-000000".*;
// First navigation: POST /redirect_to_echo → 302 → GET /echo_method.
@@ -2486,7 +2486,7 @@ test "cdp.frame: first navigation of a pristine bootstrap about:blank navigates
defer ctx.deinit();
var bc = try ctx.loadBrowserContext(.{ .id = "BID-PRS", .target_id = "TID-PRS-000000".* });
- bc.session_id = "SID-PRS";
+ _ = try bc.attachPrimarySession("SID-PRS");
_ = try bc.session.createPage();
const before = bc.mainFrame() orelse unreachable;
try testing.expectEqualSlices(u8, "about:blank", before.url);
@@ -2515,7 +2515,7 @@ test "cdp.frame: anchor click sends Referer matching the originating page" {
_ = try cdp_inst.createBrowserContext();
var bc = &cdp_inst.browser_context.?;
bc.id = "BID-A18";
- bc.session_id = "SID-A18";
+ _ = try bc.attachPrimarySession("SID-A18");
bc.target_id = "TID-A18-000000".*;
// Initial navigation to the page hosting the anchor — driven directly via
@@ -2564,7 +2564,7 @@ test "cdp.frame: address-bar Page.navigate sends no Referer" {
_ = try cdp_inst.createBrowserContext();
var bc = &cdp_inst.browser_context.?;
bc.id = "BID-A18B";
- bc.session_id = "SID-A18B";
+ _ = try bc.attachPrimarySession("SID-A18B");
bc.target_id = "TID-A18B-00000".*;
{
diff --git a/src/server/cdp/domains/runtime.zig b/src/server/cdp/domains/runtime.zig
index e36f1466e..b5e1a1be3 100644
--- a/src/server/cdp/domains/runtime.zig
+++ b/src/server/cdp/domains/runtime.zig
@@ -80,7 +80,7 @@ fn sendInspector(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// the result to return is handled directly by the inspector.
- bc.callInspector(cmd.input.json);
+ try bc.callInspector(cmd);
}
// Object arguments stay remote handles; serializing them would execute page JS.
@@ -127,18 +127,21 @@ const ConsoleMessage = struct {
};
pub fn consoleMessage(arena: Allocator, bc: *CDP.BrowserContext, event: *const Notification.ConsoleMessage) !void {
+ // The event goes to the primary session, so its argument handles are
+ // minted on the primary's inspector session.
const session_id = bc.session_id orelse return;
+ const inspector_session = try bc.inspectorSession(session_id);
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
- const context_id = bc.inspector_session.inspector.getContextId(&ls.local);
+ const context_id = bc.inspector().getContextId(&ls.local);
var args: std.ArrayList(RemoteObject) = .empty;
for (event.values) |value| {
- const remote_object = try bc.inspector_session.getRemoteObject(
+ const remote_object = try inspector_session.getRemoteObject(
&ls.local,
"",
value,
@@ -180,6 +183,77 @@ test "cdp.runtime: inspector-handled methods pass through" {
try ctx.expectSentResult(null, .{ .id = 52 });
}
+// Playwright's browserContext.newCDPSession attaches a second session to the
+// page and sends Runtime commands through it. The inspector's answer must
+// carry that session's id: the driver keys its pending callbacks by session
+// and asserts on a response it can't match (lightpanda-io/browser#1838).
+test "cdp.runtime: inspector responses go to the session that sent the command" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-RT2", .url = "hi.html", .target_id = "FID-0000000RT2".*, .session_id = "SID-PRIMARY" });
+ _ = try bc.attachSession("SID-AUX", null);
+
+ try ctx.processMessage(.{ .id = 60, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "1 + 1", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 2, .description = "2" } }, .{ .id = 60, .session_id = "SID-AUX" });
+
+ // The primary session keeps working as before.
+ try ctx.processMessage(.{ .id = 61, .method = "Runtime.evaluate", .sessionId = "SID-PRIMARY", .params = .{ .expression = "2 + 2", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 4, .description = "4" } }, .{ .id = 61, .session_id = "SID-PRIMARY" });
+
+ // A response the inspector produces after the dispatch (awaitPromise
+ // answers from a microtask) still finds its session.
+ try ctx.processMessage(.{ .id = 62, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "Promise.resolve('late')", .awaitPromise = true, .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "string", .value = "late" } }, .{ .id = 62, .session_id = "SID-AUX" });
+
+ // A command with no sessionId is answered on the primary session.
+ try ctx.processMessage(.{ .id = 63, .method = "Runtime.evaluate", .params = .{ .expression = "3 + 3", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 6, .description = "6" } }, .{ .id = 63, .session_id = "SID-PRIMARY" });
+}
+
+// Number of `method` events the client received on `session_id`.
+fn countSentEvents(ctx: *testing.TestContext, method: []const u8, session_id: []const u8) !usize {
+ var count: usize = 0;
+ var i: usize = 0;
+ while (try ctx.getSentMessage(i)) |msg| : (i += 1) {
+ const obj = switch (msg) {
+ .object => |o| o,
+ else => continue,
+ };
+ const sent_method = obj.get("method") orelse continue;
+ if (sent_method != .string or !std.mem.eql(u8, sent_method.string, method)) {
+ continue;
+ }
+ const sent_session_id = obj.get("sessionId") orelse continue;
+ if (sent_session_id == .string and std.mem.eql(u8, sent_session_id.string, session_id)) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+// V8 keeps Runtime.enable per inspector session: a session only gets the
+// executionContextCreated events it asked for, stamped with its own id.
+test "cdp.runtime: inspector events go to the session that enabled them" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{ .id = "BID-RT3", .url = "hi.html", .target_id = "FID-0000000RT3".*, .session_id = "SID-PRIMARY" });
+ _ = try bc.attachSession("SID-AUX", null);
+
+ try ctx.processMessage(.{ .id = 70, .method = "Runtime.enable", .sessionId = "SID-AUX" });
+ try ctx.expectSentResult(null, .{ .id = 70, .session_id = "SID-AUX" });
+ try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{ .auxData = .{ .isDefault = true, .type = "default" } } }, .{ .session_id = "SID-AUX" });
+ try testing.expectEqual(1, try countSentEvents(&ctx, "Runtime.executionContextCreated", "SID-AUX"));
+ try testing.expectEqual(0, try countSentEvents(&ctx, "Runtime.executionContextCreated", "SID-PRIMARY"));
+
+ try ctx.processMessage(.{ .id = 71, .method = "Runtime.enable", .sessionId = "SID-PRIMARY" });
+ try ctx.expectSentResult(null, .{ .id = 71, .session_id = "SID-PRIMARY" });
+ try ctx.expectSentEvent("Runtime.executionContextCreated", .{ .context = .{ .auxData = .{ .isDefault = true, .type = "default" } } }, .{ .session_id = "SID-PRIMARY" });
+ try testing.expectEqual(1, try countSentEvents(&ctx, "Runtime.executionContextCreated", "SID-AUX"));
+ try testing.expectEqual(1, try countSentEvents(&ctx, "Runtime.executionContextCreated", "SID-PRIMARY"));
+}
+
test "cdp.runtime: consoleAPICalled type matches the console method" {
testing.silenceLog(&.{.js});
diff --git a/src/server/cdp/domains/target.zig b/src/server/cdp/domains/target.zig
index 0457dc19f..1958b7d08 100644
--- a/src/server/cdp/domains/target.zig
+++ b/src/server/cdp/domains/target.zig
@@ -196,7 +196,7 @@ fn createTarget(cmd: *CDP.Command) !void {
defer ls.deinit();
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
- bc.inspector_session.inspector.contextCreated(
+ bc.inspector().contextCreated(
&ls.local,
"",
"", // @ZIGDOM
@@ -267,14 +267,10 @@ fn attachToTarget(cmd: *CDP.Command) !void {
cmd.cdp.resolveSessionId(session_id) orelse return error.UnknownSessionId
else
null;
- const session_id = try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next());
- try bc.attached_sessions.append(bc.arena, .{
- .id = session_id,
- .parent_id = parent_id,
- });
+ const session = try bc.attachSession(cmd.cdp.session_id_gen.next(), parent_id);
try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{
- .sessionId = session_id,
+ .sessionId = session.id,
.targetInfo = TargetInfo{
.targetId = target_id,
.title = bc.getTitle() orelse "",
@@ -283,7 +279,7 @@ fn attachToTarget(cmd: *CDP.Command) !void {
},
}, .{ .session_id = parent_id });
- return cmd.sendResult(.{ .sessionId = session_id }, .{});
+ return cmd.sendResult(.{ .sessionId = session.id }, .{});
}
fn attachToBrowserTarget(cmd: *CDP.Command) !void {
@@ -402,31 +398,18 @@ fn detachFromTarget(cmd: *CDP.Command) !void {
const params = (try cmd.params(Params)) orelse Params{};
if (cmd.browser_context) |bc| {
- if (params.sessionId) |requested_session_id| {
- for (bc.attached_sessions.items, 0..) |session, index| {
- if (!std.mem.eql(u8, session.id, requested_session_id)) continue;
+ // Without a sessionId, detach the primary session (if any).
+ if (params.sessionId orelse bc.session_id) |requested_session_id| {
+ const session = bc.attached_sessions.get(requested_session_id) orelse return error.UnknownSessionId;
+ const session_id = session.id;
+ const parent_id = session.parent_id;
- _ = bc.attached_sessions.orderedRemove(index);
- bc.fetchDisableForSession(session.id);
- try cmd.sendEvent("Target.detachedFromTarget", .{
- .sessionId = session.id,
- }, .{ .session_id = session.parent_id });
- return cmd.sendResult(null, .{});
- }
-
- const session_id = bc.session_id orelse return error.UnknownSessionId;
- if (!std.mem.eql(u8, session_id, requested_session_id)) {
- return error.UnknownSessionId;
- }
- }
-
- if (bc.session_id) |session_id| {
bc.fetchDisableForSession(session_id);
try cmd.sendEvent("Target.detachedFromTarget", .{
.sessionId = session_id,
- }, .{});
+ }, .{ .session_id = parent_id });
+ _ = bc.detachSession(session_id);
}
- bc.session_id = null;
}
return cmd.sendResult(null, .{});
@@ -456,8 +439,8 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
try cmd.sendEvent("Target.detachedFromTarget", .{
.sessionId = session_id,
}, .{});
+ _ = bc.detachSession(session_id);
}
- bc.session_id = null;
}
try cmd.sendResult(null, .{});
return;
@@ -500,27 +483,24 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void {
const bc = cmd.browser_context.?;
- const session_id = bc.session_id orelse blk: {
- break :blk try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next());
- };
+ const parent_id = bc.session_id;
if (bc.session_id == null) {
// extra_headers should not be kept on a new frame or tab,
// currently we have only 1 frame, we clear it just in case
bc.extra_headers.clearRetainingCapacity();
+ _ = try bc.attachPrimarySession(cmd.cdp.session_id_gen.next());
}
try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{
- .sessionId = session_id,
+ .sessionId = bc.session_id.?,
.targetInfo = TargetInfo{
.targetId = target_id,
.title = bc.getTitle() orelse "",
.url = bc.getURL() orelse "about:blank",
.browserContextId = bc.id,
},
- }, .{ .session_id = bc.session_id });
-
- bc.session_id = session_id;
+ }, .{ .session_id = parent_id });
}
const AttachToTarget = struct {
@@ -629,7 +609,9 @@ test "cdp.target: disposeBrowserContext detaches target sessions" {
.sessionId = "BSID-1",
.params = .{ .targetId = target_id },
});
- const auxiliary_id = try testing.arena_allocator.dupe(u8, bc.attached_sessions.items[0].id);
+ // the primary is attached first, the auxiliary session after it
+ try testing.expectEqual(2, bc.attached_sessions.count());
+ const auxiliary_id = try testing.arena_allocator.dupe(u8, bc.attached_sessions.keys()[1]);
try ctx.processMessage(.{
.id = 5,
@@ -909,7 +891,8 @@ test "cdp.target: attachToTarget" {
const session_id = bc.session_id.?;
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{});
- try testing.expectEqual(0, bc.attached_sessions.items.len);
+ try testing.expectEqual(1, bc.attached_sessions.count());
+ try testing.expect(bc.attached_sessions.contains(session_id));
}
}
@@ -930,7 +913,9 @@ test "cdp.target: auxiliary session is unique and routed through its parent" {
.params = .{ .targetId = "TID-000000000B" },
});
- const session_id = bc.attached_sessions.items[0].id;
+ // the primary is attached first, the auxiliary session after it
+ try testing.expectEqual(2, bc.attached_sessions.count());
+ const session_id = bc.attached_sessions.keys()[1];
try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?));
try ctx.expectSentEvent("Target.attachedToTarget", .{
.sessionId = session_id,
@@ -1045,16 +1030,78 @@ test "cdp.target: detachFromTarget auxiliary session" {
});
try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } });
- const session_id = bc.attached_sessions.items[0].id;
+ try testing.expectEqual(2, bc.attached_sessions.count());
+ const session_id = bc.attached_sessions.keys()[1];
try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?));
try ctx.processMessage(.{ .id = 11, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } });
try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
- try testing.expectEqual(0, bc.attached_sessions.items.len);
+ try testing.expectEqual(1, bc.attached_sessions.count());
try testing.expectEqual(true, bc.session_id != null);
try ctx.expectSentResult(null, .{ .id = 11 });
}
+// A detached session's inspector session goes with it: commands on the old
+// id are rejected up front and the primary is unaffected.
+test "cdp.target: detachFromTarget releases the auxiliary session's inspector session" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ const bc = try ctx.loadBrowserContext(.{
+ .id = "BID-9",
+ .url = "hi.html",
+ .session_id = "SID-PRIMARY",
+ .target_id = "TID-000000000B".*,
+ });
+ _ = try bc.attachSession("SID-AUX", null);
+
+ try ctx.processMessage(.{ .id = 10, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "1 + 1", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 2 } }, .{ .id = 10, .session_id = "SID-AUX" });
+
+ try ctx.processMessage(.{ .id = 11, .method = "Target.detachFromTarget", .params = .{ .sessionId = "SID-AUX" } });
+ try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = "SID-AUX" }, .{});
+ try ctx.expectSentResult(null, .{ .id = 11 });
+ try testing.expectEqual(1, bc.attached_sessions.count());
+
+ try ctx.processMessage(.{ .id = 12, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "1 + 1", .returnByValue = true } });
+ try ctx.expectSentError(-32001, "Unknown sessionId", .{ .id = 12 });
+
+ try ctx.processMessage(.{ .id = 13, .method = "Runtime.evaluate", .sessionId = "SID-PRIMARY", .params = .{ .expression = "2 + 2", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 4 } }, .{ .id = 13, .session_id = "SID-PRIMARY" });
+}
+
+// Stopping an inspector session fails its pending evaluations. The client was
+// already told the session is detached, so, like Chrome, nothing more is sent.
+test "cdp.target: detachFromTarget drops the auxiliary session's pending responses" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ const bc = try ctx.loadBrowserContext(.{
+ .id = "BID-9",
+ .url = "hi.html",
+ .session_id = "SID-PRIMARY",
+ .target_id = "TID-000000000B".*,
+ });
+ _ = try bc.attachSession("SID-AUX", null);
+
+ try ctx.processMessage(.{ .id = 10, .method = "Runtime.enable", .sessionId = "SID-AUX" });
+ try ctx.expectSentResult(null, .{ .id = 10, .session_id = "SID-AUX" });
+ try ctx.processMessage(.{ .id = 11, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "({a: 1})" } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "object" } }, .{ .id = 11, .session_id = "SID-AUX" });
+ try ctx.processMessage(.{ .id = 12, .method = "Runtime.evaluate", .sessionId = "SID-AUX", .params = .{ .expression = "new Promise(() => {})", .awaitPromise = true } });
+
+ try ctx.processMessage(.{ .id = 13, .method = "Target.detachFromTarget", .params = .{ .sessionId = "SID-AUX" } });
+ try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = "SID-AUX" }, .{});
+ try ctx.expectSentResult(null, .{ .id = 13 });
+
+ var i: usize = 0;
+ while (try ctx.getSentMessage(i)) |msg| : (i += 1) {
+ const msg_id = msg.object.get("id") orelse continue;
+ try testing.expect(msg_id != .integer or msg_id.integer != 12);
+ }
+
+ try ctx.processMessage(.{ .id = 14, .method = "Runtime.evaluate", .sessionId = "SID-PRIMARY", .params = .{ .expression = "2 + 2", .returnByValue = true } });
+ try ctx.expectSentResult(.{ .result = .{ .type = "number", .value = 4 } }, .{ .id = 14, .session_id = "SID-PRIMARY" });
+}
+
test "cdp.target: detachFromTarget without session" {
var ctx = try testing.context();
defer ctx.deinit();
diff --git a/src/server/cdp/testing.zig b/src/server/cdp/testing.zig
index fff827169..3bac1b10f 100644
--- a/src/server/cdp/testing.zig
+++ b/src/server/cdp/testing.zig
@@ -95,12 +95,12 @@ pub const TestContext = struct {
}
if (opts.session_id) |sid| {
- bc.session_id = sid;
+ _ = try bc.attachPrimarySession(sid);
}
if (opts.url) |url| {
if (bc.session_id == null) {
- bc.session_id = "SID-X";
+ _ = try bc.attachPrimarySession("SID-X");
}
if (bc.target_id == null) {
bc.target_id = "TID-000000000Z".*;