From a3e2b5246ec6ffe4c42709e173543e5270338f32 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 26 Feb 2025 09:33:50 +0800 Subject: [PATCH 1/4] Make CDP server more authoritative with respect to IDs The TL;DR is that this commit enforces the use of correct IDs, introduces a BrowserContext, and adds some CDP tests. These are the ids we need to be aware of when talking about CDP: - id - browserContextId - targetId - sessionId - loaderId - frameId The `id` is the only one that _should_ originate from the driver. It's attached to most messages and it's how we maintain a request -> response flow: when the server responds to a specific message, it echo's back the id from the requested message. (As opposed to out-of-band events sent from the server which won't have an `id`). When I say "id" from this point forward, I mean every id except for this req->res id. Every other id is created by the browser. Prior to this commit, we didn't really check incoming ids from the driver. If the driver said "attachToTarget" and included a targetId, we just assumed that this was the current targetId. This was aided by the fact that we only used hard-coded IDS. If _we_ only "create" a frameId of "FRAME-1", then it's tempting to think the driver will only ever send a frameId of "FRAME-1". The issue with this approach is that _if_ the browser and driver fall out of sync and there's only ever 1 browserContextId, 1 sessionId and 1 frameId, it's not impossible to imagine cases where we behave on the thing. Imagine this flow: - Driver asks for a new BrowserContext - Browser says OK, your browserContextId is 1 - Driver, for whatever reason, says close browserContextId 2 - Browser says, OK, but it doesn't check the id and just closes the only BrowserContext it knows about (which is 1) By both re-using the same hard-coded ids, and not verifying that the ids sent from the client correspond to the correct ids, any issues are going to be hard to debug. Currently LOADER_ID and FRAEM_ID are still hard-coded. Baby steps. --- src/browser/browser.zig | 6 - src/cdp/browser.zig | 8 +- src/cdp/cdp.zig | 458 +++++++++++++++++-------- src/cdp/css.zig | 2 +- src/cdp/dom.zig | 36 +- src/cdp/emulation.zig | 2 +- src/cdp/fetch.zig | 2 +- src/cdp/inspector.zig | 2 +- src/cdp/log.zig | 2 +- src/cdp/network.zig | 2 +- src/cdp/page.zig | 154 +++------ src/cdp/performance.zig | 2 +- src/cdp/runtime.zig | 16 +- src/cdp/security.zig | 2 +- src/cdp/target.zig | 715 +++++++++++++++++++++++++--------------- src/cdp/testing.zig | 298 +++++++++++++++-- src/id.zig | 12 +- 17 files changed, 1128 insertions(+), 591 deletions(-) diff --git a/src/browser/browser.zig b/src/browser/browser.zig index ec0f3916..7b875657 100644 --- a/src/browser/browser.zig +++ b/src/browser/browser.zig @@ -98,12 +98,6 @@ pub const Browser = struct { self.session = null; } } - - pub fn currentPage(self: *Browser) ?*Page { - if (self.session.page == null) return null; - - return &self.session.page.?; - } }; // Session is like a browser's tab. diff --git a/src/cdp/browser.zig b/src/cdp/browser.zig index 635de3bd..da972f89 100644 --- a/src/cdp/browser.zig +++ b/src/cdp/browser.zig @@ -33,7 +33,7 @@ pub fn processMessage(cmd: anytype) !void { setDownloadBehavior, getWindowForTarget, setWindowBounds, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getVersion => return getVersion(cmd), @@ -88,7 +88,6 @@ test "cdp.browser: getVersion" { try ctx.processMessage(.{ .id = 32, - .sessionID = "leto", .method = "Browser.getVersion", }); @@ -99,7 +98,7 @@ test "cdp.browser: getVersion" { .revision = REVISION, .userAgent = USER_AGENT, .jsVersion = JS_VERSION, - }, .{ .id = 32, .index = 0 }); + }, .{ .id = 32, .index = 0, .session_id = null }); } test "cdp.browser: getWindowForTarget" { @@ -108,7 +107,6 @@ test "cdp.browser: getWindowForTarget" { try ctx.processMessage(.{ .id = 33, - .sessionId = "leto", .method = "Browser.getWindowForTarget", }); @@ -116,5 +114,5 @@ test "cdp.browser: getWindowForTarget" { try ctx.expectSentResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{ .windowState = "normal" }, - }, .{ .id = 33, .index = 0, .session_id = "leto" }); + }, .{ .id = 33, .index = 0, .session_id = null }); } diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index dfa7aaa0..466ff765 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -20,115 +20,78 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const json = std.json; -const dom = @import("dom.zig"); const Loop = @import("jsruntime").Loop; -// const Client = @import("../server.zig").Client; const asUint = @import("../str/parser.zig").asUint; +const Incrementing = @import("../id.zig").Incrementing; const log = std.log.scoped(.cdp); pub const URL_BASE = "chrome://newtab/"; pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C"; pub const FRAME_ID = "FRAMEIDD8AED408A0467AC93100BCDBE"; -pub const BROWSER_SESSION_ID = @tagName(SessionID.BROWSERSESSIONID597D9875C664CAC0); -pub const CONTEXT_SESSION_ID = @tagName(SessionID.CONTEXTSESSIONID0497A05C95417CF4); pub const TimestampEvent = struct { timestamp: f64, }; pub const CDP = CDPT(struct { - const Client = @import("../server.zig").Client; + const Loop = *@import("jsruntime").Loop; + const Client = *@import("../server.zig").Client; const Browser = @import("../browser/browser.zig").Browser; const Session = @import("../browser/browser.zig").Session; }); +const SessionIdGen = Incrementing(u32, "SID"); +const TargetIdGen = Incrementing(u32, "TID"); +const BrowserContextIdGen = Incrementing(u32, "BID"); + // Generic so that we can inject mocks into it. pub fn CDPT(comptime TypeProvider: type) type { return struct { + loop: TypeProvider.Loop, + // Used for sending message to the client and closing on error - client: *TypeProvider.Client, - - // The active browser - browser: Browser, - - // The active browser session - session: ?*Session, + client: TypeProvider.Client, allocator: Allocator, + // The active browser + browser: ?Browser = null, + + target_id_gen: TargetIdGen = .{}, + session_id_gen: SessionIdGen = .{}, + browser_context_id_gen: BrowserContextIdGen = .{}, + + browser_context: ?BrowserContext(Self), + // Re-used arena for processing a message. We're assuming that we're getting // 1 message at a time. message_arena: std.heap.ArenaAllocator, - // State - url: []const u8, - frame_id: []const u8, - loader_id: []const u8, - session_id: SessionID, - context_id: ?[]const u8, - execution_context_id: u32, - security_origin: []const u8, - page_life_cycle_events: bool, - secure_context_type: []const u8, - node_list: dom.NodeList, - node_search_list: dom.NodeSearchList, - const Self = @This(); pub const Browser = TypeProvider.Browser; pub const Session = TypeProvider.Session; - pub fn init(allocator: Allocator, client: *TypeProvider.Client, loop: anytype) Self { + pub fn init(allocator: Allocator, client: TypeProvider.Client, loop: TypeProvider.Loop) Self { return .{ + .loop = loop, .client = client, - .browser = Browser.init(allocator, loop), - .session = null, .allocator = allocator, - .url = URL_BASE, - .execution_context_id = 0, - .context_id = null, - .frame_id = FRAME_ID, - .session_id = .CONTEXTSESSIONID0497A05C95417CF4, - .security_origin = URL_BASE, - .secure_context_type = "Secure", // TODO = enum - .loader_id = LOADER_ID, + .browser_context = null, .message_arena = std.heap.ArenaAllocator.init(allocator), - .page_life_cycle_events = false, // TODO; Target based value - .node_list = dom.NodeList.init(allocator), - .node_search_list = dom.NodeSearchList.init(allocator), }; } pub fn deinit(self: *Self) void { - self.node_list.deinit(); - for (self.node_search_list.items) |*s| { - s.deinit(); + if (self.browser_context) |*bc| { + bc.deinit(); } - self.node_search_list.deinit(); - - self.browser.deinit(); self.message_arena.deinit(); } - pub fn reset(self: *Self) void { - self.node_list.reset(); - - // deinit all node searches. - for (self.node_search_list.items) |*s| { - s.deinit(); - } - self.node_search_list.clearAndFree(); - } - - pub fn newSession(self: *Self) !void { - self.session = try self.browser.newSession(self); - } - pub fn handleMessage(self: *Self, msg: []const u8) bool { - self.processMessage(msg) catch |err| { - log.err("failed to process message: {}\n{s}", .{ err, msg }); - return false; - }; + // if there's an error, it's already been logged + self.processMessage(msg) catch return false; return true; } @@ -140,83 +103,236 @@ pub fn CDPT(comptime TypeProvider: type) type { // Called from above, in processMessage which handles client messages // but can also be called internally. For example, Target.sendMessageToTarget - // calls back into dispatch to capture the response + // calls back into dispatch to capture the response. pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void { const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{ .ignore_unknown_fields = true, }) catch return error.InvalidJSON; - const domain, const action = blk: { - const method = input.method; + var command = Command(Self, @TypeOf(sender)){ + .input = .{ + .json = str, + .id = input.id, + .action = "", + .params = input.params, + .session_id = input.sessionId, + }, + .cdp = self, + .arena = arena, + .sender = sender, + .browser_context = if (self.browser_context) |*bc| bc else null, + }; + + // See dispatchStartupCommand for more info on this. + var is_startup = false; + if (input.sessionId) |input_session_id| { + if (std.mem.eql(u8, input_session_id, "STARTUP")) { + is_startup = true; + } else if (self.isValidSessionId(input_session_id) == false) { + return command.sendError(-32001, "Unknown sessionId"); + } + } + + if (is_startup) { + dispatchStartupCommand(&command) catch |err| { + command.sendError(-31999, @errorName(err)) catch {}; + return err; + }; + } else { + dispatchCommand(&command, input.method) catch |err| { + command.sendError(-31998, @errorName(err)) catch {}; + return err; + }; + } + } + + // A CDP session isn't 100% fully driven by the driver. There's are + // independent actions that the browser is expected to take. For example + // Puppeteer expects the browser to startup a tab and thus have existing + // targets. + // To this end, we create a [very] dummy BrowserContext, Target and + // Session. There isn't actually a BrowserContext, just a special id. + // When messages are received with the "STARTUP" sessionId, we do + // "special" handling - the bare minimum we need to do until the driver + // switches to a real BrowserContext. + // (I can imagine this logic will become driver-specific) + fn dispatchStartupCommand(command: anytype) !void { + return command.sendResult(null, .{}); + } + + fn dispatchCommand(command: anytype, method: []const u8) !void { + const domain = blk: { const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse { return error.InvalidMethod; }; - break :blk .{ method[0..i], method[i + 1 ..] }; - }; - - var command = Command(Self, @TypeOf(sender)){ - .json = str, - .cdp = self, - .id = input.id, - .arena = arena, - .action = action, - ._params = input.params, - .session_id = input.sessionId, - .sender = sender, - .session = self.session orelse blk: { - try self.newSession(); - break :blk self.session.?; - }, + command.input.action = method[i + 1 ..]; + break :blk method[0..i]; }; switch (domain.len) { 3 => switch (@as(u24, @bitCast(domain[0..3].*))) { - asUint("DOM") => return @import("dom.zig").processMessage(&command), - asUint("Log") => return @import("log.zig").processMessage(&command), - asUint("CSS") => return @import("css.zig").processMessage(&command), + asUint("DOM") => return @import("dom.zig").processMessage(command), + asUint("Log") => return @import("log.zig").processMessage(command), + asUint("CSS") => return @import("css.zig").processMessage(command), else => {}, }, 4 => switch (@as(u32, @bitCast(domain[0..4].*))) { - asUint("Page") => return @import("page.zig").processMessage(&command), + asUint("Page") => return @import("page.zig").processMessage(command), else => {}, }, 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { - asUint("Fetch") => return @import("fetch.zig").processMessage(&command), + asUint("Fetch") => return @import("fetch.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { - asUint("Target") => return @import("target.zig").processMessage(&command), + asUint("Target") => return @import("target.zig").processMessage(command), else => {}, }, 7 => switch (@as(u56, @bitCast(domain[0..7].*))) { - asUint("Browser") => return @import("browser.zig").processMessage(&command), - asUint("Runtime") => return @import("runtime.zig").processMessage(&command), - asUint("Network") => return @import("network.zig").processMessage(&command), + asUint("Browser") => return @import("browser.zig").processMessage(command), + asUint("Runtime") => return @import("runtime.zig").processMessage(command), + asUint("Network") => return @import("network.zig").processMessage(command), else => {}, }, 8 => switch (@as(u64, @bitCast(domain[0..8].*))) { - asUint("Security") => return @import("security.zig").processMessage(&command), + asUint("Security") => return @import("security.zig").processMessage(command), else => {}, }, 9 => switch (@as(u72, @bitCast(domain[0..9].*))) { - asUint("Emulation") => return @import("emulation.zig").processMessage(&command), - asUint("Inspector") => return @import("inspector.zig").processMessage(&command), + asUint("Emulation") => return @import("emulation.zig").processMessage(command), + asUint("Inspector") => return @import("inspector.zig").processMessage(command), else => {}, }, 11 => switch (@as(u88, @bitCast(domain[0..11].*))) { - asUint("Performance") => return @import("performance.zig").processMessage(&command), + asUint("Performance") => return @import("performance.zig").processMessage(command), else => {}, }, else => {}, } + return error.UnknownDomain; } + fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool { + const browser_context = &(self.browser_context orelse return false); + const session_id = browser_context.session_id orelse return false; + return std.mem.eql(u8, session_id, input_session_id); + } + + pub fn createBrowserContext(self: *Self) ![]const u8 { + if (self.browser_context != null) { + return error.AlreadyExists; + } + const browser_context_id = self.browser_context_id_gen.next(); + + // is this safe? + self.browser_context = undefined; + errdefer self.browser_context = null; + try BrowserContext(Self).init(&self.browser_context.?, browser_context_id, self); + + return browser_context_id; + } + + pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool { + const bc = &(self.browser_context orelse return false); + if (std.mem.eql(u8, bc.id, browser_context_id) == false) { + return false; + } + bc.deinit(); + self.browser_context = null; + return true; + } + fn sendJSON(self: *Self, message: anytype) !void { return self.client.sendJSON(message, .{ .emit_null_optional_fields = false, }); } + }; +} + +pub fn BrowserContext(comptime CDP_T: type) type { + const dom = @import("dom.zig"); + + return struct { + id: []const u8, + cdp: *CDP_T, + + browser: CDP_T.Browser, + // Represents the browser session. There is no equivalent in CDP. For + // all intents and purpose, from CDP's point of view our Browser and + // our Session more or less maps to a BrowserContext. THIS HAS ZERO + // RELATION TO SESSION_ID + session: *CDP_T.Session, + + // Maps to our Page. (There are other types of targets, but we only + // deal with "pages" for now). Since we only allow 1 open page at a + // time, we only have 1 target_id. + target_id: ?[]const u8, + + // 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. + session_id: ?[]const u8, + + // State + url: []const u8, + frame_id: []const u8, + loader_id: []const u8, + security_origin: []const u8, + page_life_cycle_events: bool, + secure_context_type: []const u8, + node_list: dom.NodeList, + node_search_list: dom.NodeSearchList, + + const Self = @This(); + + fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void { + self.* = .{ + .id = id, + .cdp = cdp, + .browser = undefined, + .session = undefined, + .target_id = null, + .session_id = null, + .url = URL_BASE, + .frame_id = FRAME_ID, + .security_origin = URL_BASE, + .secure_context_type = "Secure", // TODO = enum + .loader_id = LOADER_ID, + .page_life_cycle_events = false, // TODO; Target based value + .node_list = dom.NodeList.init(cdp.allocator), + .node_search_list = dom.NodeSearchList.init(cdp.allocator), + }; + + self.browser = CDP_T.Browser.init(cdp.allocator, cdp.loop); + errdefer self.browser.deinit(); + self.session = try self.browser.newSession(self); + } + + pub fn deinit(self: *Self) void { + self.node_list.deinit(); + for (self.node_search_list.items) |*s| { + s.deinit(); + } + self.node_search_list.deinit(); + self.browser.deinit(); + } + + pub fn reset(self: *Self) void { + self.node_list.reset(); + + // deinit all node searches. + for (self.node_search_list.items) |*s| { + s.deinit(); + } + self.node_search_list.clearAndFree(); + } pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { if (std.log.defaultLogEnabled(.debug)) { @@ -252,19 +368,24 @@ pub fn CDPT(comptime TypeProvider: type) type { }; } - // This is hacky * 2. First, we have the JSON payload by gluing our + // This is hacky x 2. First, we create the JSON payload by gluing our // session_id onto it. Second, we're much more client/websocket aware than // we should be. fn sendInspectorMessage(self: *Self, msg: []const u8) !void { - var arena = std.heap.ArenaAllocator.init(self.allocator); + const session_id = self.session_id orelse { + // We no longer have an active session. What should we do + // in this case? + return; + }; + + const cdp = self.cdp; + var arena = std.heap.ArenaAllocator.init(cdp.allocator); errdefer arena.deinit(); const field = ",\"sessionId\":\""; - const session_id = @tagName(self.session_id); // + 1 for the closing quote after the session id // + 10 for the max websocket header - const message_len = msg.len + session_id.len + 1 + field.len + 10; var buf: std.ArrayListUnmanaged(u8) = .{}; @@ -283,7 +404,7 @@ pub fn CDPT(comptime TypeProvider: type) type { buf.appendSliceAssumeCapacity("\"}"); std.debug.assert(buf.items.len == message_len); - try self.client.sendJSONRaw(arena, buf); + try cdp.client.sendJSONRaw(arena, buf); } }; } @@ -294,38 +415,29 @@ pub fn CDPT(comptime TypeProvider: type) type { // generic. pub fn Command(comptime CDP_T: type, comptime Sender: type) type { return struct { - // reference to our CDP instance - cdp: *CDP_T, - - // Comes directly from the input.id field - id: ?i64, - // A misc arena that can be used for any allocation for processing // the message arena: Allocator, - // the browser session - session: *CDP_T.Session, + // reference to our CDP instance + cdp: *CDP_T, - // The "action" of the message.Given a method of "LOG.enable", the - // action is "enable" - action: []const u8, + // The browser context this command targets + browser_context: ?*BrowserContext(CDP_T), - // Comes directly from the input.sessionId field - session_id: ?[]const u8, - - // Unparsed / untyped input.params. - _params: ?InputParams, - - // The full raw json input - json: []const u8, + // The command input (the id, optional session_id, params, ...) + input: Input, + // In most cases, Sender is going to be cdp itself. We'll call + // sender.sendJSON() and CDP will send it to the client. But some + // comamnds are dispatched internally, in which cases the Sender will + // be code to capture the data that we were "sending". sender: Sender, const Self = @This(); pub fn params(self: *const Self, comptime T: type) !?T { - if (self._params) |p| { + if (self.input.params) |p| { return try json.parseFromSliceLeaky( T, self.arena, @@ -336,20 +448,26 @@ pub fn Command(comptime CDP_T: type, comptime Sender: type) type { return null; } + pub fn createBrowserContext(self: *Self) !*BrowserContext(CDP_T) { + _ = try self.cdp.createBrowserContext(); + self.browser_context = &self.cdp.browser_context.?; + return self.browser_context.?; + } + const SendResultOpts = struct { include_session_id: bool = true, }; pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void { return self.sender.sendJSON(.{ - .id = self.id, + .id = self.input.id, .result = if (comptime @typeInfo(@TypeOf(result)) == .Null) struct {}{} else result, - .sessionId = if (opts.include_session_id) self.session_id else null, + .sessionId = if (opts.include_session_id) self.input.session_id else null, }); } + const SendEventOpts = struct { session_id: ?[]const u8 = null, }; - pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void { // Events ALWAYS go to the client. self.sender should not be used return self.cdp.sendJSON(.{ @@ -358,6 +476,32 @@ pub fn Command(comptime CDP_T: type, comptime Sender: type) type { .sessionId = opts.session_id, }); } + + pub fn sendError(self: *Self, code: i32, message: []const u8) !void { + return self.sender.sendJSON(.{ + .id = self.input.id, + .code = code, + .message = message, + }); + } + + const Input = struct { + // When we reply to a message, we echo back the message id + id: ?i64, + + // The "action" of the message.Given a method of "LOG.enable", the + // action is "enable" + action: []const u8, + + // See notes in BrowserContext about session_id + session_id: ?[]const u8, + + // Unparsed / untyped input.params. + params: ?InputParams, + + // The full raw json input + json: []const u8, + }; }; } @@ -395,24 +539,7 @@ const InputParams = struct { } }; -// Common -// ------ - -// TODO: hard coded IDs -pub const SessionID = enum { - BROWSERSESSIONID597D9875C664CAC0, - CONTEXTSESSIONID0497A05C95417CF4, - - pub fn parse(str: []const u8) !SessionID { - return std.meta.stringToEnum(SessionID, str) orelse { - log.err("parse sessionID: {s}", .{str}); - return error.InvalidSessionID; - }; - } -}; - const testing = @import("testing.zig"); - test "cdp: invalid json" { var ctx = testing.context(); defer ctx.deinit(); @@ -425,6 +552,7 @@ test "cdp: invalid json" { try testing.expectError(error.InvalidMethod, ctx.processMessage(.{ .method = "Target", })); + try ctx.expectSentError(-31998, "InvalidMethod", .{}); try testing.expectError(error.UnknownDomain, ctx.processMessage(.{ .method = "Unknown.domain", @@ -434,3 +562,53 @@ test "cdp: invalid json" { .method = "Target.over9000", })); } + +test "cdp: invalid sessionId" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + // we have no browser context + try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" }); + try ctx.expectSentError(-32001, "Unknown sessionId", .{}); + } + + { + // we have a brower context but no session_id + _ = try ctx.loadBrowserContext(.{}); + try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" }); + try ctx.expectSentError(-32001, "Unknown sessionId", .{}); + } + + { + // we have a brower context with a different session_id + _ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" }); + try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" }); + try ctx.expectSentError(-32001, "Unknown sessionId", .{}); + } +} + +test "cdp: STARTUP sessionId" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + // we have no browser context + try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" }); + try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" }); + } + + { + // we have a brower context but no session_id + _ = try ctx.loadBrowserContext(.{}); + try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" }); + try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" }); + } + + { + // we have a brower context with a different session_id + _ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" }); + try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" }); + try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" }); + } +} diff --git a/src/cdp/css.zig b/src/cdp/css.zig index 21834d83..4dc4c001 100644 --- a/src/cdp/css.zig +++ b/src/cdp/css.zig @@ -22,7 +22,7 @@ const cdp = @import("cdp.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/dom.zig b/src/cdp/dom.zig index 2bcacd41..ce4d2610 100644 --- a/src/cdp/dom.zig +++ b/src/cdp/dom.zig @@ -29,7 +29,7 @@ pub fn processMessage(cmd: anytype) !void { performSearch, getSearchResults, discardSearchResults, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), @@ -133,14 +133,13 @@ fn getDocument(cmd: anytype) !void { // pierce: ?bool = null, // })) orelse return error.InvalidParams; - // retrieve the root node - const page = cmd.session.page orelse return error.NoPage; - const doc = page.doc orelse return error.NoDocument; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + const doc = page.doc orelse return error.DocumentNotLoaded; - const state = cmd.cdp; const node = parser.documentToNode(doc); - var n = try Node.init(node, &state.node_list); - _ = try n.initChildren(cmd.arena, node, &state.node_list); + var n = try Node.init(node, &bc.node_list); + _ = try n.initChildren(cmd.arena, node, &bc.node_list); return cmd.sendResult(.{ .root = n, @@ -184,21 +183,20 @@ fn performSearch(cmd: anytype) !void { includeUserAgentShadowDOM: ?bool = null, })) orelse return error.InvalidParams; - // retrieve the root node - const page = cmd.session.page orelse return error.NoPage; - const doc = page.doc orelse return error.NoDocument; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + const doc = page.doc orelse return error.DocumentNotLoaded; const list = try css.querySelectorAll(cmd.cdp.allocator, parser.documentToNode(doc), params.query); const ln = list.nodes.items.len; var ns = try NodeSearch.initCapacity(cmd.cdp.allocator, ln); - var state = cmd.cdp; for (list.nodes.items) |n| { - const id = try state.node_list.set(n); + const id = try bc.node_list.set(n); try ns.append(id); } - try state.node_search_list.append(ns); + try bc.node_search_list.append(ns); return cmd.sendResult(.{ .searchId = ns.name, @@ -212,13 +210,14 @@ fn discardSearchResults(cmd: anytype) !void { searchId: []const u8, })) orelse return error.InvalidParams; - var state = cmd.cdp; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + // retrieve the search from context - for (state.node_search_list.items, 0..) |*s, i| { + for (bc.node_search_list.items, 0..) |*s, i| { if (!std.mem.eql(u8, s.name, params.searchId)) continue; s.deinit(); - _ = state.node_search_list.swapRemove(i); + _ = bc.node_search_list.swapRemove(i); break; } @@ -237,10 +236,11 @@ fn getSearchResults(cmd: anytype) !void { return error.BadIndices; } - const state = cmd.cdp; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + // retrieve the search from context var ns: ?*const NodeSearch = undefined; - for (state.node_search_list.items) |s| { + for (bc.node_search_list.items) |s| { if (!std.mem.eql(u8, s.name, params.searchId)) continue; ns = &s; break; diff --git a/src/cdp/emulation.zig b/src/cdp/emulation.zig index 88c5ddf7..9edc0d6f 100644 --- a/src/cdp/emulation.zig +++ b/src/cdp/emulation.zig @@ -26,7 +26,7 @@ pub fn processMessage(cmd: anytype) !void { setFocusEmulationEnabled, setDeviceMetricsOverride, setTouchEmulationEnabled, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .setEmulatedMedia => return setEmulatedMedia(cmd), diff --git a/src/cdp/fetch.zig b/src/cdp/fetch.zig index 0a9a8cae..00a2f948 100644 --- a/src/cdp/fetch.zig +++ b/src/cdp/fetch.zig @@ -22,7 +22,7 @@ const cdp = @import("cdp.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { disable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .disable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/inspector.zig b/src/cdp/inspector.zig index 21834d83..4dc4c001 100644 --- a/src/cdp/inspector.zig +++ b/src/cdp/inspector.zig @@ -22,7 +22,7 @@ const cdp = @import("cdp.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/log.zig b/src/cdp/log.zig index 21834d83..4dc4c001 100644 --- a/src/cdp/log.zig +++ b/src/cdp/log.zig @@ -22,7 +22,7 @@ const cdp = @import("cdp.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/network.zig b/src/cdp/network.zig index 60d9cbbb..ac520016 100644 --- a/src/cdp/network.zig +++ b/src/cdp/network.zig @@ -23,7 +23,7 @@ pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, setCacheDisabled, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/page.zig b/src/cdp/page.zig index 6ca9655d..3dfb2c44 100644 --- a/src/cdp/page.zig +++ b/src/cdp/page.zig @@ -28,7 +28,7 @@ pub fn processMessage(cmd: anytype) !void { addScriptToEvaluateOnNewDocument, createIsolatedWorld, navigate, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), @@ -56,43 +56,16 @@ const Frame = struct { }; fn getFrameTree(cmd: anytype) !void { - // output - const FrameTree = struct { - frameTree: struct { - frame: Frame, - }, - childFrames: ?[]@This() = null, + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - pub fn format( - self: @This(), - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try writer.writeAll("cdp.page.getFrameTree { "); - try writer.writeAll(".frameTree = { "); - try writer.writeAll(".frame = { "); - - const frame = self.frameTree.frame; - try writer.writeAll(".id = "); - try std.fmt.formatText(frame.id, "s", options, writer); - try writer.writeAll(", .loaderId = "); - try std.fmt.formatText(frame.loaderId, "s", options, writer); - try writer.writeAll(", .url = "); - try std.fmt.formatText(frame.url, "s", options, writer); - try writer.writeAll(" } } }"); - } - }; - - const state = cmd.cdp; - return cmd.sendResult(FrameTree{ + return cmd.sendResult(.{ .frameTree = .{ - .frame = .{ - .id = state.frame_id, - .url = state.url, - .securityOrigin = state.security_origin, - .secureContextType = state.secure_context_type, - .loaderId = state.loader_id, + .frame = Frame{ + .url = bc.url, + .id = bc.frame_id, + .loaderId = bc.loader_id, + .securityOrigin = bc.security_origin, + .secureContextType = bc.secure_context_type, }, }, }, .{}); @@ -103,7 +76,8 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void { // enabled: bool, // })) orelse return error.InvalidParams; - cmd.cdp.page_life_cycle_events = true; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + bc.page_life_cycle_events = true; return cmd.sendResult(null, .{}); } @@ -116,27 +90,16 @@ fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void { // runImmediately: bool = false, // })) orelse return error.InvalidParams; - const Response = struct { - identifier: []const u8 = "1", - - pub fn format( - self: @This(), - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try writer.writeAll("cdp.page.addScriptToEvaluateOnNewDocument { "); - try writer.writeAll(".identifier = "); - try std.fmt.formatText(self.identifier, "s", options, writer); - try writer.writeAll(" }"); - } - }; - return cmd.sendResult(Response{}, .{}); + return cmd.sendResult(.{ + .identifier = "1", + }, .{}); } // TODO: hard coded method fn createIsolatedWorld(cmd: anytype) !void { - const session_id = cmd.session_id orelse return error.SessionIdRequired; + _ = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + const session_id = cmd.input.session_id orelse return error.SessionIdRequired; const params = (try cmd.params(struct { frameId: []const u8, @@ -166,7 +129,16 @@ fn createIsolatedWorld(cmd: anytype) !void { } fn navigate(cmd: anytype) !void { - const session_id = cmd.session_id orelse return error.SessionIdRequired; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + // didn't create? + _ = bc.target_id orelse return error.TargetIdNotLoaded; + + // didn't attach? + const session_id = bc.session_id orelse return error.SessionIdNotLoaded; + + // if we have a target_id we have to have a page; + std.debug.assert(bc.session.page != null); const params = (try cmd.params(struct { url: []const u8, @@ -177,12 +149,11 @@ fn navigate(cmd: anytype) !void { })) orelse return error.InvalidParams; // change state - var state = cmd.cdp; - state.reset(); - state.url = params.url; + bc.reset(); + bc.url = params.url; // TODO: hard coded ID - state.loader_id = "AF8667A203C5392DBE9AC290044AA4C2"; + bc.loader_id = "AF8667A203C5392DBE9AC290044AA4C2"; const LifecycleEvent = struct { frameId: []const u8, @@ -192,8 +163,8 @@ fn navigate(cmd: anytype) !void { }; var life_event = LifecycleEvent{ - .frameId = state.frame_id, - .loaderId = state.loader_id, + .frameId = bc.frame_id, + .loaderId = bc.loader_id, .name = "init", .timestamp = 343721.796037, }; @@ -201,39 +172,17 @@ fn navigate(cmd: anytype) !void { // frameStartedLoading event // TODO: event partially hard coded try cmd.sendEvent("Page.frameStartedLoading", .{ - .frameId = state.frame_id, + .frameId = bc.frame_id, }, .{ .session_id = session_id }); - if (state.page_life_cycle_events) { + if (bc.page_life_cycle_events) { try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); } // output - const Response = struct { - frameId: []const u8, - loaderId: ?[]const u8, - errorText: ?[]const u8 = null, - - pub fn format( - self: @This(), - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try writer.writeAll("cdp.page.navigate.Resp { "); - try writer.writeAll(".frameId = "); - try std.fmt.formatText(self.frameId, "s", options, writer); - if (self.loaderId) |loaderId| { - try writer.writeAll(", .loaderId = '"); - try std.fmt.formatText(loaderId, "s", options, writer); - } - try writer.writeAll(" }"); - } - }; - - try cmd.sendResult(Response{ - .frameId = state.frame_id, - .loaderId = state.loader_id, + try cmd.sendResult(.{ + .frameId = bc.frame_id, + .loaderId = bc.loader_id, }, .{}); // TODO: at this point do we need async the following actions to be async? @@ -242,24 +191,21 @@ fn navigate(cmd: anytype) !void { // TODO: noop event, we have no env context at this point, is it necesarry? try cmd.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); - // Launch navigate, the page must have been created by a - // target.createTarget. - var p = cmd.session.currentPage() orelse return error.NoPage; - state.execution_context_id += 1; - const aux_data = try std.fmt.allocPrint( cmd.arena, // NOTE: we assume this is the default web page "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", - .{state.frame_id}, + .{bc.frame_id}, ); - try p.navigate(params.url, aux_data); + + var page = bc.session.currentPage().?; + try page.navigate(params.url, aux_data); // Events // lifecycle init event // TODO: partially hard coded - if (state.page_life_cycle_events) { + if (bc.page_life_cycle_events) { life_event.name = "init"; life_event.timestamp = 343721.796037; try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); @@ -271,11 +217,11 @@ fn navigate(cmd: anytype) !void { try cmd.sendEvent("Page.frameNavigated", .{ .type = "Navigation", .frame = Frame{ - .id = state.frame_id, - .url = state.url, - .securityOrigin = state.security_origin, - .secureContextType = state.secure_context_type, - .loaderId = state.loader_id, + .id = bc.frame_id, + .url = bc.url, + .securityOrigin = bc.security_origin, + .secureContextType = bc.secure_context_type, + .loaderId = bc.loader_id, }, }, .{ .session_id = session_id }); @@ -289,7 +235,7 @@ fn navigate(cmd: anytype) !void { // lifecycle DOMContentLoaded event // TODO: partially hard coded - if (state.page_life_cycle_events) { + if (bc.page_life_cycle_events) { life_event.name = "DOMContentLoaded"; life_event.timestamp = 343721.803338; try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); @@ -305,7 +251,7 @@ fn navigate(cmd: anytype) !void { // lifecycle DOMContentLoaded event // TODO: partially hard coded - if (state.page_life_cycle_events) { + if (bc.page_life_cycle_events) { life_event.name = "load"; life_event.timestamp = 343721.824655; try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); @@ -313,6 +259,6 @@ fn navigate(cmd: anytype) !void { // frameStoppedLoading return cmd.sendEvent("Page.frameStoppedLoading", .{ - .frameId = state.frame_id, + .frameId = bc.frame_id, }, .{ .session_id = session_id }); } diff --git a/src/cdp/performance.zig b/src/cdp/performance.zig index 8db70ed4..d06bebfa 100644 --- a/src/cdp/performance.zig +++ b/src/cdp/performance.zig @@ -23,7 +23,7 @@ const asUint = @import("../str/parser.zig").asUint; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/runtime.zig b/src/cdp/runtime.zig index 3da66105..d34920ea 100644 --- a/src/cdp/runtime.zig +++ b/src/cdp/runtime.zig @@ -27,7 +27,7 @@ pub fn processMessage(cmd: anytype) !void { addBinding, callFunctionOn, releaseObject, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .runIfWaitingForDebugger => return cmd.sendResult(null, .{}), @@ -41,26 +41,24 @@ fn sendInspector(cmd: anytype, action: anytype) !void { try logInspector(cmd, action); } - if (cmd.session_id) |s| { - cmd.cdp.session_id = try cdp.SessionID.parse(s); - } + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; // remove awaitPromise true params // TODO: delete when Promise are correctly handled by zig-js-runtime if (action == .callFunctionOn or action == .evaluate) { - const json = cmd.json; + const json = cmd.input.json; if (std.mem.indexOf(u8, json, "\"awaitPromise\":true")) |_| { // +1 because we'll be turning a true -> false const buf = try cmd.arena.alloc(u8, json.len + 1); _ = std.mem.replace(u8, json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); - cmd.session.callInspector(buf); + bc.session.callInspector(buf); return; } } - cmd.session.callInspector(cmd.json); + bc.session.callInspector(cmd.input.json); - if (cmd.id != null) { + if (cmd.input.id != null) { return cmd.sendResult(null, .{}); } } @@ -110,7 +108,7 @@ fn logInspector(cmd: anytype, action: anytype) !void { }, else => return, }; - const id = cmd.id orelse return error.RequiredId; + const id = cmd.input.id orelse return error.RequiredId; const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id}); var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); diff --git a/src/cdp/security.zig b/src/cdp/security.zig index 21834d83..4dc4c001 100644 --- a/src/cdp/security.zig +++ b/src/cdp/security.zig @@ -22,7 +22,7 @@ const cdp = @import("cdp.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { enable, - }, cmd.action) orelse return error.UnknownMethod; + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .enable => return cmd.sendResult(null, .{}), diff --git a/src/cdp/target.zig b/src/cdp/target.zig index 815741d9..9e0087a7 100644 --- a/src/cdp/target.zig +++ b/src/cdp/target.zig @@ -17,136 +17,229 @@ // along with this program. If not, see . const std = @import("std"); -const cdp = @import("cdp.zig"); const log = std.log.scoped(.cdp); // TODO: hard coded IDs -const CONTEXT_ID = "CONTEXTIDDCCDD11109E2D4FEFBE4F89"; -const PAGE_TARGET_ID = "PAGETARGETIDB638E9DC0F52DDC"; -const BROWSER_TARGET_ID = "browser9-targ-et6f-id0e-83f3ab73a30c"; -const BROWER_CONTEXT_ID = "BROWSERCONTEXTIDA95049E9DFE95EA9"; -const TARGET_ID = "TARGETID460A8F29706A2ADF14316298"; const LOADER_ID = "LOADERID42AA389647D702B4D805F49A"; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { - setDiscoverTargets, - setAutoAttach, attachToTarget, - getTargetInfo, - getBrowserContexts, - createBrowserContext, - disposeBrowserContext, - createTarget, closeTarget, - sendMessageToTarget, + createBrowserContext, + createTarget, detachFromTarget, - }, cmd.action) orelse return error.UnknownMethod; + disposeBrowserContext, + getBrowserContexts, + getTargetInfo, + sendMessageToTarget, + setAutoAttach, + setDiscoverTargets, + }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { - .setDiscoverTargets => return setDiscoverTargets(cmd), - .setAutoAttach => return setAutoAttach(cmd), .attachToTarget => return attachToTarget(cmd), - .getTargetInfo => return getTargetInfo(cmd), - .getBrowserContexts => return getBrowserContexts(cmd), - .createBrowserContext => return createBrowserContext(cmd), - .disposeBrowserContext => return disposeBrowserContext(cmd), - .createTarget => return createTarget(cmd), .closeTarget => return closeTarget(cmd), - .sendMessageToTarget => return sendMessageToTarget(cmd), + .createBrowserContext => return createBrowserContext(cmd), + .createTarget => return createTarget(cmd), .detachFromTarget => return detachFromTarget(cmd), + .disposeBrowserContext => return disposeBrowserContext(cmd), + .getBrowserContexts => return getBrowserContexts(cmd), + .getTargetInfo => return getTargetInfo(cmd), + .sendMessageToTarget => return sendMessageToTarget(cmd), + .setAutoAttach => return setAutoAttach(cmd), + .setDiscoverTargets => return setDiscoverTargets(cmd), } } -// TODO: noop method -fn setDiscoverTargets(cmd: anytype) !void { - return cmd.sendResult(null, .{}); -} -const AttachToTarget = struct { - sessionId: []const u8, - targetInfo: TargetInfo, - waitingForDebugger: bool = false, -}; - -const TargetCreated = struct { - sessionId: []const u8, - targetInfo: TargetInfo, -}; - -const TargetInfo = struct { - targetId: []const u8, - type: []const u8 = "page", - title: []const u8, - url: []const u8, - attached: bool = true, - canAccessOpener: bool = false, - browserContextId: []const u8, -}; - -// TODO: noop method -fn setAutoAttach(cmd: anytype) !void { - // const TargetFilter = struct { - // type: ?[]const u8 = null, - // exclude: ?bool = null, - // }; - - // const params = (try cmd.params(struct { - // autoAttach: bool, - // waitForDebuggerOnStart: bool, - // flatten: bool = true, - // filter: ?[]TargetFilter = null, - // })) orelse return error.InvalidParams; - - // attachedToTarget event - if (cmd.session_id == null) { - try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ - .sessionId = cdp.BROWSER_SESSION_ID, - .targetInfo = .{ - .targetId = PAGE_TARGET_ID, - .title = "about:blank", - .url = cdp.URL_BASE, - .browserContextId = BROWER_CONTEXT_ID, - }, - }, .{}); +fn getBrowserContexts(cmd: anytype) !void { + var browser_context_ids: []const []const u8 = undefined; + if (cmd.browser_context) |bc| { + browser_context_ids = &.{bc.id}; + } else { + browser_context_ids = &.{}; } - return cmd.sendResult(null, .{}); + return cmd.sendResult(.{ + .browserContextIds = browser_context_ids, + }, .{ .include_session_id = false }); +} + +fn createBrowserContext(cmd: anytype) !void { + const bc = cmd.createBrowserContext() catch |err| switch (err) { + error.AlreadyExists => return cmd.sendError(-32000, "Cannot have more than one browser context at a time"), + else => return err, + }; + + return cmd.sendResult(.{ + .browserContextId = bc.id, + }, .{}); +} + +fn disposeBrowserContext(cmd: anytype) !void { + const params = (try cmd.params(struct { + browserContextId: []const u8, + })) orelse return error.InvalidParams; + + if (cmd.cdp.disposeBrowserContext(params.browserContextId) == false) { + return cmd.sendError(-32602, "No browser context with the given id found"); + } + try cmd.sendResult(null, .{}); +} + +fn createTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { + // url: []const u8, + // width: ?u64 = null, + // height: ?u64 = null, + browserContextId: ?[]const u8 = null, + // enableBeginFrameControl: bool = false, + // newWindow: bool = false, + // background: bool = false, + // forTab: ?bool = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + if (bc.target_id != null) { + return error.TargetAlreadyLoaded; + } + if (params.browserContextId) |param_browser_context_id| { + if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + + // if target_id is null, we should never have a page + std.debug.assert(bc.session.page == null); + + // if target_id is null, we should never have a session_id + std.debug.assert(bc.session_id == null); + + const page = try bc.session.createPage(); + const target_id = cmd.cdp.target_id_gen.next(); + + // change CDP state + bc.url = "about:blank"; + bc.security_origin = "://"; + bc.secure_context_type = "InsecureScheme"; + bc.loader_id = LOADER_ID; + + // start the js env + const aux_data = try std.fmt.allocPrint( + cmd.arena, + // NOTE: we assume this is the default web page + "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", + .{target_id}, + ); + try page.start(aux_data); + + try cmd.sendResult(.{ + .targetId = target_id, + }, .{}); + + // send targetCreated event + // TODO: should this only be sent when Target.setDiscoverTargets + // has been enabled? + try cmd.sendEvent("Target.targetCreated", .{ + .targetInfo = TargetInfo{ + .url = bc.url, + .targetId = target_id, + .title = "about:blank", + .browserContextId = bc.id, + .attached = false, + }, + }, .{}); + + // only if setAutoAttach is true? + try doAttachtoTarget(cmd, target_id); + bc.target_id = target_id; } -// TODO: noop method fn attachToTarget(cmd: anytype) !void { const params = (try cmd.params(struct { targetId: []const u8, flatten: bool = true, })) orelse return error.InvalidParams; - // attachedToTarget event - if (cmd.session_id == null) { - try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ - .sessionId = cdp.BROWSER_SESSION_ID, - .targetInfo = .{ - .targetId = params.targetId, - .title = "about:blank", - .url = cdp.URL_BASE, - .browserContextId = BROWER_CONTEXT_ID, - }, - }, .{}); + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const target_id = bc.target_id orelse return error.TargetNotLoaded; + if (std.mem.eql(u8, target_id, params.targetId) == false) { + return error.UnknownTargetId; } + if (bc.session_id != null) { + return error.SessionAlreadyLoaded; + } + + try doAttachtoTarget(cmd, target_id); + return cmd.sendResult( - .{ .sessionId = cmd.session_id orelse cdp.BROWSER_SESSION_ID }, + .{ .sessionId = bc.session_id }, .{ .include_session_id = false }, ); } +fn closeTarget(cmd: anytype) !void { + const params = (try cmd.params(struct { + targetId: []const u8, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const target_id = bc.target_id orelse return error.TargetNotLoaded; + if (std.mem.eql(u8, target_id, params.targetId) == false) { + return error.UnknownTargetId; + } + + // can't be null if we have a target_id + std.debug.assert(bc.session.page != null); + + try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); + + // could be null, created but never attached + if (bc.session_id) |session_id| { + // Inspector.detached event + try cmd.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session_id }); + + // detachedFromTarget event + try cmd.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session_id, + .reason = "Render process gone.", + }, .{}); + + bc.session_id = null; + } + + bc.session.currentPage().?.end(); + bc.target_id = null; +} + fn getTargetInfo(cmd: anytype) !void { - // const params = (try cmd.params(struct { - // targetId: ?[]const u8 = null, - // })) orelse return error.InvalidParams; + const params = (try cmd.params(struct { + targetId: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + if (params.targetId) |param_target_id| { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const target_id = bc.target_id orelse return error.TargetNotLoaded; + if (std.mem.eql(u8, target_id, param_target_id) == false) { + return error.UnknownTargetId; + } + + return cmd.sendResult(.{ + .targetId = target_id, + .type = "page", + .title = "", + .url = "", + .attached = true, + .canAccessOpener = false, + }, .{ .include_session_id = false }); + } return cmd.sendResult(.{ - .targetId = BROWSER_TARGET_ID, .type = "browser", .title = "", .url = "", @@ -155,188 +248,24 @@ fn getTargetInfo(cmd: anytype) !void { }, .{ .include_session_id = false }); } -// Browser context are not handled and not in the roadmap for now -// The following methods are "fake" - -// TODO: noop method -fn getBrowserContexts(cmd: anytype) !void { - var context_ids: []const []const u8 = undefined; - if (cmd.cdp.context_id) |context_id| { - context_ids = &.{context_id}; - } else { - context_ids = &.{}; - } - - return cmd.sendResult(.{ - .browserContextIds = context_ids, - }, .{ .include_session_id = false }); -} - -// TODO: noop method -fn createBrowserContext(cmd: anytype) !void { - // const params = (try cmd.params(struct { - // disposeOnDetach: bool = false, - // proxyServer: ?[]const u8 = null, - // proxyBypassList: ?[]const u8 = null, - // originsWithUniversalNetworkAccess: ?[][]const u8 = null, - // })) orelse return error.InvalidParams; - - cmd.cdp.context_id = CONTEXT_ID; - - const Response = struct { - browserContextId: []const u8, - - pub fn format( - self: @This(), - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try writer.writeAll("cdp.target.createBrowserContext { "); - try writer.writeAll(".browserContextId = "); - try std.fmt.formatText(self.browserContextId, "s", options, writer); - try writer.writeAll(" }"); - } - }; - - return cmd.sendResult(Response{ - .browserContextId = CONTEXT_ID, - }, .{}); -} - -fn disposeBrowserContext(cmd: anytype) !void { - // const params = (try cmd.params(struct { - // browserContextId: []const u8, - // proxyServer: ?[]const u8 = null, - // proxyBypassList: ?[]const u8 = null, - // originsWithUniversalNetworkAccess: ?[][]const u8 = null, - // })) orelse return error.InvalidParams; - - try cmd.cdp.newSession(); - try cmd.sendResult(null, .{}); -} - -fn createTarget(cmd: anytype) !void { - const params = (try cmd.params(struct { - url: []const u8, - width: ?u64 = null, - height: ?u64 = null, - browserContextId: ?[]const u8 = null, - enableBeginFrameControl: bool = false, - newWindow: bool = false, - background: bool = false, - forTab: ?bool = null, - })) orelse return error.InvalidParams; - - // change CDP state - var state = cmd.cdp; - state.frame_id = TARGET_ID; - state.url = "about:blank"; - state.security_origin = "://"; - state.secure_context_type = "InsecureScheme"; - state.loader_id = LOADER_ID; - - if (cmd.session_id) |s| { - state.session_id = try cdp.SessionID.parse(s); - } - - // TODO stop the previous page instead? - if (cmd.session.page != null) { - return error.pageAlreadyExists; - } - - // create the page - const p = try cmd.session.createPage(); - state.execution_context_id += 1; - - // start the js env - const aux_data = try std.fmt.allocPrint( - cmd.arena, - // NOTE: we assume this is the default web page - "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", - .{state.frame_id}, - ); - try p.start(aux_data); - - const browser_context_id = params.browserContextId orelse CONTEXT_ID; - - // send targetCreated event - try cmd.sendEvent("Target.targetCreated", TargetCreated{ - .sessionId = cdp.CONTEXT_SESSION_ID, - .targetInfo = .{ - .targetId = state.frame_id, - .title = "about:blank", - .url = state.url, - .browserContextId = browser_context_id, - .attached = true, - }, - }, .{ .session_id = cmd.session_id }); - - // send attachToTarget event - try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ - .sessionId = cdp.CONTEXT_SESSION_ID, - .waitingForDebugger = true, - .targetInfo = .{ - .targetId = state.frame_id, - .title = "about:blank", - .url = state.url, - .browserContextId = browser_context_id, - .attached = true, - }, - }, .{ .session_id = cmd.session_id }); - - const Response = struct { - targetId: []const u8 = TARGET_ID, - - pub fn format( - self: @This(), - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try writer.writeAll("cdp.target.createTarget { "); - try writer.writeAll(".targetId = "); - try std.fmt.formatText(self.targetId, "s", options, writer); - try writer.writeAll(" }"); - } - }; - return cmd.sendResult(Response{}, .{}); -} - -fn closeTarget(cmd: anytype) !void { - const params = (try cmd.params(struct { - targetId: []const u8, - })) orelse return error.InvalidParams; - - try cmd.sendResult(.{ - .success = true, - }, .{ .include_session_id = false }); - - const session_id = cmd.session_id orelse cdp.CONTEXT_SESSION_ID; - - // Inspector.detached event - try cmd.sendEvent("Inspector.detached", .{ - .reason = "Render process gone.", - }, .{ .session_id = session_id }); - - // detachedFromTarget event - try cmd.sendEvent("Target.detachedFromTarget", .{ - .sessionId = session_id, - .targetId = params.targetId, - .reason = "Render process gone.", - }, .{}); - - if (cmd.session.page) |*page| { - page.end(); - } -} - fn sendMessageToTarget(cmd: anytype) !void { const params = (try cmd.params(struct { message: []const u8, sessionId: []const u8, })) orelse return error.InvalidParams; + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + if (bc.target_id == null) { + return error.TargetNotLoaded; + } + + std.debug.assert(bc.session_id != null); + if (std.mem.eql(u8, bc.session_id.?, params.sessionId) == false) { + // Is this right? Is the params.sessionId meant to be the active + // sessionId? What else could it be? We have no other session_id. + return error.UnknownSessionId; + } + const Capture = struct { allocator: std.mem.Allocator, buf: std.ArrayListUnmanaged(u8), @@ -354,7 +283,7 @@ fn sendMessageToTarget(cmd: anytype) !void { }; cmd.cdp.dispatch(cmd.arena, &capture, params.message) catch |err| { - log.err("send message {d} ({s}): {any}", .{ cmd.id orelse -1, params.message, err }); + log.err("send message {d} ({s}): {any}", .{ cmd.input.id orelse -1, params.message, err }); return err; }; @@ -368,3 +297,253 @@ fn sendMessageToTarget(cmd: anytype) !void { fn detachFromTarget(cmd: anytype) !void { return cmd.sendResult(null, .{}); } + +// TODO: noop method +fn setDiscoverTargets(cmd: anytype) !void { + return cmd.sendResult(null, .{}); +} + +fn setAutoAttach(cmd: anytype) !void { + // const params = (try cmd.params(struct { + // autoAttach: bool, + // waitForDebuggerOnStart: bool, + // flatten: bool = true, + // filter: ?[]TargetFilter = null, + // })) orelse return error.InvalidParams; + + // TODO: should set a flag to send Target.attachedToTarget events + + try cmd.sendResult(null, .{}); + + if (cmd.browser_context) |bc| { + if (bc.target_id == null) { + // hasn't attached yet + const target_id = cmd.cdp.target_id_gen.next(); + try doAttachtoTarget(cmd, target_id); + bc.target_id = target_id; + } + // should we send something here? + return; + } + + // This is a hack. Puppeteer, and probably others, expect the Browser to + // automatically started creating targets. Things like an empty tab, or + // a blank page. And they block until this happens. So we send an event + // telling them that they've been attached to our Broswer. Hopefully, the + // first thing they'll do is create a real BrowserContext and progress from + // there. + // This hack requires the main cdp dispatch handler to special case + // messages from this "STARTUP" session. + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = "STARTUP", + .targetInfo = TargetInfo{ + .type = "browser", + .targetId = "TID-STARTUP", + .title = "about:blank", + .url = "chrome://newtab/", + .browserContextId = "BID-STARTUP", + }, + }, .{}); +} + +fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void { + const bc = cmd.browser_context.?; + std.debug.assert(bc.session_id == null); + const session_id = cmd.cdp.session_id_gen.next(); + + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = session_id, + .targetInfo = TargetInfo{ + .targetId = target_id, + .title = "about:blank", + .url = "chrome://newtab/", + .browserContextId = bc.id, + }, + }, .{}); + + bc.session_id = session_id; +} + +const AttachToTarget = struct { + sessionId: []const u8, + targetInfo: TargetInfo, + waitingForDebugger: bool = false, +}; + +const TargetInfo = struct { + url: []const u8, + title: []const u8, + targetId: []const u8, + attached: bool = true, + type: []const u8 = "page", + canAccessOpener: bool = false, + browserContextId: []const u8, +}; + +const testing = @import("testing.zig"); +test "cdp.target: getBrowserContexts" { + var ctx = testing.context(); + defer ctx.deinit(); + + // { + // // no browser context + // try ctx.processMessage(.{.id = 4, .method = "Target.getBrowserContexts"}); + + // try ctx.expectSentResult(.{ + // .browserContextIds = &.{}, + // }, .{ .id = 4, .session_id = null }); + // } + + { + // with a browser context + _ = try ctx.loadBrowserContext(.{ .id = "BID-X" }); + try ctx.processMessage(.{ .id = 5, .method = "Target.getBrowserContexts" }); + + try ctx.expectSentResult(.{ + .browserContextIds = &.{"BID-X"}, + }, .{ .id = 5, .session_id = null }); + } +} + +test "cdp.target: createBrowserContext" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + try ctx.processMessage(.{ .id = 4, .method = "Target.createBrowserContext" }); + try ctx.expectSentResult(.{ + .browserContextId = ctx.cdp().browser_context.?.id, + }, .{ .id = 4, .session_id = null }); + } + + { + // we already have one now + try ctx.processMessage(.{ .id = 5, .method = "Target.createBrowserContext" }); + try ctx.expectSentError(-32000, "Cannot have more than one browser context at a time", .{ .id = 5 }); + } +} + +test "cdp.target: disposeBrowserContext" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + try testing.expectError(error.InvalidParams, ctx.processMessage(.{ .id = 7, .method = "Target.disposeBrowserContext" })); + try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 7 }); + } + + { + try ctx.processMessage(.{ + .id = 8, + .method = "Target.disposeBrowserContext", + .params = .{ .browserContextId = "BID-10" }, + }); + try ctx.expectSentError(-32602, "No browser context with the given id found", .{ .id = 8 }); + } + + { + _ = try ctx.loadBrowserContext(.{ .id = "BID-20" }); + try ctx.processMessage(.{ + .id = 9, + .method = "Target.disposeBrowserContext", + .params = .{ .browserContextId = "BID-20" }, + }); + try ctx.expectSentResult(null, .{ .id = 9 }); + try testing.expectEqual(null, ctx.cdp().browser_context); + } +} + +test "cdp.target: createTarget" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ + .id = 10, + .method = "Target.createTarget", + .params = struct {}{}, + })); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); + } + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); + { + try testing.expectError(error.UnknownBrowserContextId, ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-8" } })); + try ctx.expectSentError(-31998, "UnknownBrowserContextId", .{ .id = 10 }); + } + + { + try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); + try testing.expectEqual(true, bc.target_id != null); + try testing.expectString( + \\{"isDefault":true,"type":"default","frameId":"TID-1"} + , bc.session.page.?.aux_data); + + try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); + try ctx.expectSentEvent("Target.targetCreated", .{ .targetInfo = .{ .url = "about:blank", .title = "about:blank", .attached = false, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); + + try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "chrome://newtab/", .title = "about:blank", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); + } +} + +test "cdp.target: closeTarget" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "X" } })); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); + } + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); + { + try testing.expectError(error.TargetNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } })); + try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 }); + } + + // pretend we createdTarget first + _ = try bc.session.createPage(); + bc.target_id = "TID-A"; + { + try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } })); + try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 }); + } + + { + try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-A" } }); + try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 }); + try testing.expectEqual(null, bc.session.page); + try testing.expectEqual(null, bc.target_id); + } +} + +test "cdp.target: attachToTarget" { + var ctx = testing.context(); + defer ctx.deinit(); + + { + try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "X" } })); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 }); + } + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); + { + try testing.expectError(error.TargetNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } })); + try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 }); + } + + // pretend we createdTarget first + _ = try bc.session.createPage(); + bc.target_id = "TID-B"; + { + try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } })); + try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 }); + } + + { + try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-B" } }); + const session_id = bc.session_id.?; + try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); + try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "chrome://newtab/", .title = "about:blank", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); + } +} diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 889b8c1f..1e7bc25c 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -5,7 +5,7 @@ const Allocator = std.mem.Allocator; const Testing = @This(); -const cdp = @import("cdp.zig"); +const main = @import("cdp.zig"); const parser = @import("netsurf"); pub const expectEqual = std.testing.expectEqual; @@ -13,32 +13,57 @@ pub const expectError = std.testing.expectError; pub const expectString = std.testing.expectEqualStrings; const Browser = struct { - session: ?Session = null, + session: ?*Session = null, + arena: std.heap.ArenaAllocator, - pub fn init(_: Allocator, loop: anytype) Browser { + pub fn init(allocator: Allocator, loop: anytype) Browser { _ = loop; - return .{}; + return .{ + .arena = std.heap.ArenaAllocator.init(allocator), + }; } - pub fn deinit(_: *const Browser) void {} + pub fn deinit(self: *Browser) void { + self.arena.deinit(); + } pub fn newSession(self: *Browser, ctx: anytype) !*Session { _ = ctx; + if (self.session != null) { + return error.MockBrowserSessionAlreadyExists; + } - self.session = .{}; - return &self.session.?; + const allocator = self.arena.allocator(); + self.session = try allocator.create(Session); + self.session.?.* = .{ + .page = null, + .allocator = allocator, + }; + return self.session.?; + } + + pub fn hasSession(self: *const Browser, session_id: []const u8) bool { + const session = self.session orelse return false; + return std.mem.eql(u8, session.id, session_id); } }; const Session = struct { page: ?Page = null, + allocator: Allocator, pub fn currentPage(self: *Session) ?*Page { return &(self.page orelse return null); } pub fn createPage(self: *Session) !*Page { - self.page = .{}; + if (self.page != null) { + return error.MockBrowserPageAlreadyExists; + } + self.page = .{ + .session = self, + .allocator = self.allocator, + }; return &self.page.?; } @@ -49,6 +74,9 @@ const Session = struct { }; const Page = struct { + session: *Session, + allocator: Allocator, + aux_data: []const u8 = "", doc: ?*parser.Document = null, pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void { @@ -58,18 +86,18 @@ const Page = struct { } pub fn start(self: *Page, aux_data: []const u8) !void { - _ = self; - _ = aux_data; + self.aux_data = try self.allocator.dupe(u8, aux_data); } pub fn end(self: *Page) void { - _ = self; + self.session.page = null; } }; const Client = struct { allocator: Allocator, - sent: std.ArrayListUnmanaged([]const u8) = .{}, + sent: std.ArrayListUnmanaged(json.Value) = .{}, + serialized: std.ArrayListUnmanaged([]const u8) = .{}, fn init(allocator: Allocator) Client { return .{ @@ -78,15 +106,21 @@ const Client = struct { } pub fn sendJSON(self: *Client, message: anytype, opts: json.StringifyOptions) !void { - const serialized = try json.stringifyAlloc(self.allocator, message, opts); - try self.sent.append(self.allocator, serialized); + var opts_copy = opts; + opts_copy.whitespace = .indent_2; + const serialized = try json.stringifyAlloc(self.allocator, message, opts_copy); + try self.serialized.append(self.allocator, serialized); + + const value = try json.parseFromSliceLeaky(json.Value, self.allocator, serialized, .{}); + try self.sent.append(self.allocator, value); } }; -const TestCDP = cdp.CDPT(struct { +const TestCDP = main.CDPT(struct { + pub const Loop = void; pub const Browser = Testing.Browser; pub const Session = Testing.Session; - pub const Client = Testing.Client; + pub const Client = *Testing.Client; }); const TestContext = struct { @@ -106,15 +140,39 @@ const TestContext = struct { self.client = Client.init(self.arena.allocator()); // Don't use the arena here. We want to detect leaks in CDP. // The arena is only for test-specific stuff - self.cdp_ = TestCDP.init(std.testing.allocator, &self.client.?, "dummy-loop"); + self.cdp_ = TestCDP.init(std.testing.allocator, &self.client.?, {}); } return &self.cdp_.?; } + const BrowserContextOpts = struct { + id: ?[]const u8 = null, + session_id: ?[]const u8 = null, + }; + pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) { + var c = self.cdp(); + if (c.browser_context) |*bc| { + bc.deinit(); + c.browser_context = null; + } + + _ = try c.createBrowserContext(); + var bc = &c.browser_context.?; + + if (opts.id) |id| { + bc.id = id; + } + + if (opts.session_id) |sid| { + bc.session_id = sid; + } + return bc; + } + pub fn processMessage(self: *TestContext, msg: anytype) !void { var json_message: []const u8 = undefined; if (@typeInfo(@TypeOf(msg)) != .Pointer) { - json_message = try std.json.stringifyAlloc(self.arena.allocator(), msg, .{}); + json_message = try json.stringifyAlloc(self.arena.allocator(), msg, .{}); } else { // assume this is a string we want to send as-is, if it isn't, we'll // get a compile error, so no big deal. @@ -132,34 +190,71 @@ const TestContext = struct { index: ?usize = null, session_id: ?[]const u8 = null, }; - pub fn expectSentResult(self: *TestContext, expected: anytype, opts: ExpectResultOpts) !void { const expected_result = .{ .id = opts.id, - .result = expected, + .result = if (comptime @typeInfo(@TypeOf(expected)) == .Null) struct {}{} else expected, .sessionId = opts.session_id, }; - const serialized = try json.stringifyAlloc(self.arena.allocator(), expected_result, .{ + try self.expectSent(expected_result, .{ .index = opts.index }); + } + + const ExpectEventOpts = struct { + index: ?usize = null, + session_id: ?[]const u8 = null, + }; + pub fn expectSentEvent(self: *TestContext, method: []const u8, params: anytype, opts: ExpectEventOpts) !void { + const expected_event = .{ + .method = method, + .params = if (comptime @typeInfo(@TypeOf(params)) == .Null) struct {}{} else params, + .sessionId = opts.session_id, + }; + + try self.expectSent(expected_event, .{ .index = opts.index }); + } + + const ExpectErrorOpts = struct { + id: ?usize = null, + index: ?usize = null, + }; + pub fn expectSentError(self: *TestContext, code: i32, message: []const u8, opts: ExpectErrorOpts) !void { + const expected_message = .{ + .id = opts.id, + .code = code, + .message = message, + }; + try self.expectSent(expected_message, .{ .index = opts.index }); + } + + const SentOpts = struct { + index: ?usize = null, + }; + pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void { + const serialized = try json.stringifyAlloc(self.arena.allocator(), expected, .{ + .whitespace = .indent_2, .emit_null_optional_fields = false, }); for (self.client.?.sent.items, 0..) |sent, i| { - if (std.mem.eql(u8, sent, serialized) == false) { + if (try compareExpectedToSent(serialized, sent) == false) { continue; } + if (opts.index) |expected_index| { if (expected_index != i) { - return error.MessageAtWrongIndex; + return error.ErrorAtWrongIndex; } - return; } + _ = self.client.?.sent.orderedRemove(i); + _ = self.client.?.serialized.orderedRemove(i); + return; } - std.debug.print("Message not found. Expecting:\n{s}\n\nGot:\n", .{serialized}); - for (self.client.?.sent.items, 0..) |sent, i| { + std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized}); + for (self.client.?.serialized.items, 0..) |sent, i| { std.debug.print("#{d}\n{s}\n\n", .{ i, sent }); } - return error.MessageNotFound; + return error.ErrorNotFound; } }; @@ -168,3 +263,152 @@ pub fn context() TestContext { .arena = std.heap.ArenaAllocator.init(std.testing.allocator), }; } + +// Zig makes this hard. When sendJSON is called, we're sending an anytype. +// We can't record that in an ArrayList(???), so we serialize it to JSON. +// Now, ideally, we could just take our expected structure, serialize it to +// json and check if the two are equal. +// Except serializing to JSON isn't deterministic. +// So we serialize the JSON then we deserialize to json.Value. And then we can +// compare our anytype expection with the json.Value that we captured + +fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool { + const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{}); + defer expected_value.deinit(); + return compareJsonValues(expected_value.value, actual); +} + +fn compareJsonValues(a: std.json.Value, b: std.json.Value) bool { + if (!std.mem.eql(u8, @tagName(a), @tagName(b))) { + return false; + } + + switch (a) { + .null => return true, + .bool => return a.bool == b.bool, + .integer => return a.integer == b.integer, + .float => return a.float == b.float, + .number_string => return std.mem.eql(u8, a.number_string, b.number_string), + .string => return std.mem.eql(u8, a.string, b.string), + .array => { + const a_len = a.array.items.len; + const b_len = b.array.items.len; + if (a_len != b_len) { + return false; + } + for (a.array.items, b.array.items) |a_item, b_item| { + if (compareJsonValues(a_item, b_item) == false) { + return false; + } + } + return true; + }, + .object => { + var it = a.object.iterator(); + while (it.next()) |entry| { + const key = entry.key_ptr.*; + if (b.object.get(key)) |b_item| { + if (compareJsonValues(entry.value_ptr.*, b_item) == false) { + return false; + } + } else { + return false; + } + } + return true; + }, + } +} + +// fn compareAnyToJsonValue(expected: anytype, actual: json.Value) bool { +// switch (@typeInfo(@TypeOf(expected))) { +// .Optional => { +// if (expected) |e| { +// return compareAnyToJsonValue(e, actual); +// } +// return actual == .null; +// }, +// .Int, .ComptimeInt => { +// if (actual != .integer) { +// return false; +// } +// return expected == actual.integer; +// }, +// .Float, .ComptimeFloat => { +// if (actual != .float) { +// return false; +// } +// return expected == actual.float; +// }, +// .Bool => { +// if (actual != .bool) { +// return false; +// } +// return expected == actual.bool; +// }, +// .Pointer => |ptr| switch (ptr.size) { +// .One => switch (@typeInfo(ptr.child)) { +// .Struct => return compareAnyToJsonValue(expected.*, actual), +// .Array => |arr| if (arr.child == u8) { +// if (actual != .string) { +// return false; +// } +// return std.mem.eql(u8, expected, actual.string); +// }, +// else => {}, +// }, +// .Slice => switch (ptr.child) { +// u8 => { +// if (actual != .string) { +// return false; +// } +// return std.mem.eql(u8, expected, actual.string); +// }, +// else => {}, +// }, +// else => {}, +// }, +// .Struct => |s| { +// if (s.is_tuple) { +// // how an array might look in an anytype +// if (actual != .array) { +// return false; +// } +// if (s.fields.len != actual.array.items.len) { +// return false; +// } + +// inline for (s.fields, 0..) |f, i| { +// const e = @field(expected, f.name); +// if (compareAnyToJsonValue(e, actual.array.items[i]) == false) { +// return false; +// } +// } +// return true; +// } + +// if (s.fields.len == 0) { +// return (actual == .array and actual.array.items.len == 0); +// } + +// if (actual != .object) { +// return false; +// } +// inline for (s.fields) |f| { +// const e = @field(expected, f.name); +// if (actual.object.get(f.name)) |a| { +// if (compareAnyToJsonValue(e, a) == false) { +// return false; +// } +// } else if (@typeInfo(f.type) != .Optional or e != null) { +// // We don't JSON serialize nulls. So if we're expecting +// // a null, that should show up as a missing field. +// return false; +// } +// } +// return true; +// }, +// else => {}, +// } +// @compileError("Can't compare " ++ @typeName(@TypeOf(expected))); +// } diff --git a/src/id.zig b/src/id.zig index 04f16018..f21af778 100644 --- a/src/id.zig +++ b/src/id.zig @@ -9,7 +9,7 @@ const std = @import("std"); // - while incrementor is valid // - until the next call to next() // On the positive, it's zero allocation -fn Incrementing(comptime T: type, comptime prefix: []const u8) type { +pub fn Incrementing(comptime T: type, comptime prefix: []const u8) type { // +1 for the '-' separator const NUMERIC_START = prefix.len + 1; const MAX_BYTES = NUMERIC_START + switch (T) { @@ -35,15 +35,15 @@ fn Incrementing(comptime T: type, comptime prefix: []const u8) type { const PREFIX_INT_CODE: PrefixIntType = @bitCast(buffer[0..NUMERIC_START].*); return struct { - current: T = 0, + counter: T = 0, buffer: [MAX_BYTES]u8 = buffer, const Self = @This(); pub fn next(self: *Self) []const u8 { - const current = self.current; - const n = current +% 1; - defer self.current = n; + const counter = self.counter; + const n = counter +% 1; + defer self.counter = n; const size = std.fmt.formatIntBuf(self.buffer[NUMERIC_START..], n, 10, .lower, .{}); return self.buffer[0 .. NUMERIC_START + size]; @@ -106,7 +106,7 @@ test "id: Incrementing.next" { try testing.expectEqualStrings("IDX-3", id.next()); // force a wrap - id.current = 65533; + id.counter = 65533; try testing.expectEqualStrings("IDX-65534", id.next()); try testing.expectEqualStrings("IDX-65535", id.next()); try testing.expectEqualStrings("IDX-0", id.next()); From fbb0e675f5ee109e630359c513e1501b1a2d2a3b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Mar 2025 12:57:25 +0800 Subject: [PATCH 2/4] send attach events before result --- src/cdp/target.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cdp/target.zig b/src/cdp/target.zig index 9e0087a7..dc0cd1fa 100644 --- a/src/cdp/target.zig +++ b/src/cdp/target.zig @@ -134,10 +134,6 @@ fn createTarget(cmd: anytype) !void { ); try page.start(aux_data); - try cmd.sendResult(.{ - .targetId = target_id, - }, .{}); - // send targetCreated event // TODO: should this only be sent when Target.setDiscoverTargets // has been enabled? @@ -154,6 +150,10 @@ fn createTarget(cmd: anytype) !void { // only if setAutoAttach is true? try doAttachtoTarget(cmd, target_id); bc.target_id = target_id; + + try cmd.sendResult(.{ + .targetId = target_id, + }, .{}); } fn attachToTarget(cmd: anytype) !void { From adb8779d00dba15f1c3d584d6b3161e7dfeb538f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Mar 2025 13:19:15 +0800 Subject: [PATCH 3/4] allow Target.getTargetInfo to be called without parameters --- src/cdp/target.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cdp/target.zig b/src/cdp/target.zig index dc0cd1fa..1c278f3a 100644 --- a/src/cdp/target.zig +++ b/src/cdp/target.zig @@ -218,9 +218,10 @@ fn closeTarget(cmd: anytype) !void { } fn getTargetInfo(cmd: anytype) !void { - const params = (try cmd.params(struct { + const Params = struct { targetId: ?[]const u8 = null, - })) orelse return error.InvalidParams; + }; + const params = (try cmd.params(Params)) orelse Params{}; if (params.targetId) |param_target_id| { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; From 9de84aee2eacbcb48dc382237581b7077def87a7 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 4 Mar 2025 14:11:03 +0800 Subject: [PATCH 4/4] Don't send CDP result when message is forward to inspector. Rely on inspector to send the result, otherwise we'll send 2 responses to the same message (one ourselves and one from the inspector), which Playwright does not like. --- src/cdp/runtime.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/cdp/runtime.zig b/src/cdp/runtime.zig index d34920ea..c054521e 100644 --- a/src/cdp/runtime.zig +++ b/src/cdp/runtime.zig @@ -57,10 +57,6 @@ fn sendInspector(cmd: anytype, action: anytype) !void { } bc.session.callInspector(cmd.input.json); - - if (cmd.input.id != null) { - return cmd.sendResult(null, .{}); - } } pub const ExecutionContextCreated = struct {