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.
This commit is contained in:
Karl Seguin
2025-02-26 09:33:50 +08:00
committed by Pierre Tachoire
parent ccacac0597
commit a3e2b5246e
17 changed files with 1128 additions and 591 deletions

View File

@@ -98,12 +98,6 @@ pub const Browser = struct {
self.session = null; 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. // Session is like a browser's tab.

View File

@@ -33,7 +33,7 @@ pub fn processMessage(cmd: anytype) !void {
setDownloadBehavior, setDownloadBehavior,
getWindowForTarget, getWindowForTarget,
setWindowBounds, setWindowBounds,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.getVersion => return getVersion(cmd), .getVersion => return getVersion(cmd),
@@ -88,7 +88,6 @@ test "cdp.browser: getVersion" {
try ctx.processMessage(.{ try ctx.processMessage(.{
.id = 32, .id = 32,
.sessionID = "leto",
.method = "Browser.getVersion", .method = "Browser.getVersion",
}); });
@@ -99,7 +98,7 @@ test "cdp.browser: getVersion" {
.revision = REVISION, .revision = REVISION,
.userAgent = USER_AGENT, .userAgent = USER_AGENT,
.jsVersion = JS_VERSION, .jsVersion = JS_VERSION,
}, .{ .id = 32, .index = 0 }); }, .{ .id = 32, .index = 0, .session_id = null });
} }
test "cdp.browser: getWindowForTarget" { test "cdp.browser: getWindowForTarget" {
@@ -108,7 +107,6 @@ test "cdp.browser: getWindowForTarget" {
try ctx.processMessage(.{ try ctx.processMessage(.{
.id = 33, .id = 33,
.sessionId = "leto",
.method = "Browser.getWindowForTarget", .method = "Browser.getWindowForTarget",
}); });
@@ -116,5 +114,5 @@ test "cdp.browser: getWindowForTarget" {
try ctx.expectSentResult(.{ try ctx.expectSentResult(.{
.windowId = DEV_TOOLS_WINDOW_ID, .windowId = DEV_TOOLS_WINDOW_ID,
.bounds = .{ .windowState = "normal" }, .bounds = .{ .windowState = "normal" },
}, .{ .id = 33, .index = 0, .session_id = "leto" }); }, .{ .id = 33, .index = 0, .session_id = null });
} }

View File

@@ -20,115 +20,78 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const json = std.json; const json = std.json;
const dom = @import("dom.zig");
const Loop = @import("jsruntime").Loop; const Loop = @import("jsruntime").Loop;
// const Client = @import("../server.zig").Client;
const asUint = @import("../str/parser.zig").asUint; const asUint = @import("../str/parser.zig").asUint;
const Incrementing = @import("../id.zig").Incrementing;
const log = std.log.scoped(.cdp); const log = std.log.scoped(.cdp);
pub const URL_BASE = "chrome://newtab/"; pub const URL_BASE = "chrome://newtab/";
pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C"; pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C";
pub const FRAME_ID = "FRAMEIDD8AED408A0467AC93100BCDBE"; 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 { pub const TimestampEvent = struct {
timestamp: f64, timestamp: f64,
}; };
pub const CDP = CDPT(struct { 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 Browser = @import("../browser/browser.zig").Browser;
const Session = @import("../browser/browser.zig").Session; 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. // Generic so that we can inject mocks into it.
pub fn CDPT(comptime TypeProvider: type) type { pub fn CDPT(comptime TypeProvider: type) type {
return struct { return struct {
loop: TypeProvider.Loop,
// Used for sending message to the client and closing on error // Used for sending message to the client and closing on error
client: *TypeProvider.Client, client: TypeProvider.Client,
// The active browser
browser: Browser,
// The active browser session
session: ?*Session,
allocator: Allocator, 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 // Re-used arena for processing a message. We're assuming that we're getting
// 1 message at a time. // 1 message at a time.
message_arena: std.heap.ArenaAllocator, 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(); const Self = @This();
pub const Browser = TypeProvider.Browser; pub const Browser = TypeProvider.Browser;
pub const Session = TypeProvider.Session; 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 .{ return .{
.loop = loop,
.client = client, .client = client,
.browser = Browser.init(allocator, loop),
.session = null,
.allocator = allocator, .allocator = allocator,
.url = URL_BASE, .browser_context = null,
.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,
.message_arena = std.heap.ArenaAllocator.init(allocator), .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 { pub fn deinit(self: *Self) void {
self.node_list.deinit(); if (self.browser_context) |*bc| {
for (self.node_search_list.items) |*s| { bc.deinit();
s.deinit();
} }
self.node_search_list.deinit();
self.browser.deinit();
self.message_arena.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 { pub fn handleMessage(self: *Self, msg: []const u8) bool {
self.processMessage(msg) catch |err| { // if there's an error, it's already been logged
log.err("failed to process message: {}\n{s}", .{ err, msg }); self.processMessage(msg) catch return false;
return false;
};
return true; return true;
} }
@@ -140,83 +103,236 @@ pub fn CDPT(comptime TypeProvider: type) type {
// Called from above, in processMessage which handles client messages // Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget // 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 { pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{ const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true, .ignore_unknown_fields = true,
}) catch return error.InvalidJSON; }) catch return error.InvalidJSON;
const domain, const action = blk: { var command = Command(Self, @TypeOf(sender)){
const method = input.method; .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 { const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod; return error.InvalidMethod;
}; };
break :blk .{ method[0..i], method[i + 1 ..] }; command.input.action = method[i + 1 ..];
}; break :blk method[0..i];
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.?;
},
}; };
switch (domain.len) { switch (domain.len) {
3 => switch (@as(u24, @bitCast(domain[0..3].*))) { 3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint("DOM") => return @import("dom.zig").processMessage(&command), asUint("DOM") => return @import("dom.zig").processMessage(command),
asUint("Log") => return @import("log.zig").processMessage(&command), asUint("Log") => return @import("log.zig").processMessage(command),
asUint("CSS") => return @import("css.zig").processMessage(&command), asUint("CSS") => return @import("css.zig").processMessage(command),
else => {}, else => {},
}, },
4 => switch (@as(u32, @bitCast(domain[0..4].*))) { 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 => {}, else => {},
}, },
5 => switch (@as(u40, @bitCast(domain[0..5].*))) { 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 => {}, else => {},
}, },
6 => switch (@as(u48, @bitCast(domain[0..6].*))) { 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 => {}, else => {},
}, },
7 => switch (@as(u56, @bitCast(domain[0..7].*))) { 7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint("Browser") => return @import("browser.zig").processMessage(&command), asUint("Browser") => return @import("browser.zig").processMessage(command),
asUint("Runtime") => return @import("runtime.zig").processMessage(&command), asUint("Runtime") => return @import("runtime.zig").processMessage(command),
asUint("Network") => return @import("network.zig").processMessage(&command), asUint("Network") => return @import("network.zig").processMessage(command),
else => {}, else => {},
}, },
8 => switch (@as(u64, @bitCast(domain[0..8].*))) { 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 => {}, else => {},
}, },
9 => switch (@as(u72, @bitCast(domain[0..9].*))) { 9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint("Emulation") => return @import("emulation.zig").processMessage(&command), asUint("Emulation") => return @import("emulation.zig").processMessage(command),
asUint("Inspector") => return @import("inspector.zig").processMessage(&command), asUint("Inspector") => return @import("inspector.zig").processMessage(command),
else => {}, else => {},
}, },
11 => switch (@as(u88, @bitCast(domain[0..11].*))) { 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 => {},
}, },
else => {}, else => {},
} }
return error.UnknownDomain; 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 { fn sendJSON(self: *Self, message: anytype) !void {
return self.client.sendJSON(message, .{ return self.client.sendJSON(message, .{
.emit_null_optional_fields = false, .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 { pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
if (std.log.defaultLogEnabled(.debug)) { 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 // session_id onto it. Second, we're much more client/websocket aware than
// we should be. // we should be.
fn sendInspectorMessage(self: *Self, msg: []const u8) !void { 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(); errdefer arena.deinit();
const field = ",\"sessionId\":\""; const field = ",\"sessionId\":\"";
const session_id = @tagName(self.session_id);
// + 1 for the closing quote after the session id // + 1 for the closing quote after the session id
// + 10 for the max websocket header // + 10 for the max websocket header
const message_len = msg.len + session_id.len + 1 + field.len + 10; const message_len = msg.len + session_id.len + 1 + field.len + 10;
var buf: std.ArrayListUnmanaged(u8) = .{}; var buf: std.ArrayListUnmanaged(u8) = .{};
@@ -283,7 +404,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
buf.appendSliceAssumeCapacity("\"}"); buf.appendSliceAssumeCapacity("\"}");
std.debug.assert(buf.items.len == message_len); 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. // generic.
pub fn Command(comptime CDP_T: type, comptime Sender: type) type { pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
return struct { 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 // A misc arena that can be used for any allocation for processing
// the message // the message
arena: Allocator, arena: Allocator,
// the browser session // reference to our CDP instance
session: *CDP_T.Session, cdp: *CDP_T,
// The "action" of the message.Given a method of "LOG.enable", the // The browser context this command targets
// action is "enable" browser_context: ?*BrowserContext(CDP_T),
action: []const u8,
// Comes directly from the input.sessionId field // The command input (the id, optional session_id, params, ...)
session_id: ?[]const u8, input: Input,
// Unparsed / untyped input.params.
_params: ?InputParams,
// The full raw json input
json: []const u8,
// 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, sender: Sender,
const Self = @This(); const Self = @This();
pub fn params(self: *const Self, comptime T: type) !?T { pub fn params(self: *const Self, comptime T: type) !?T {
if (self._params) |p| { if (self.input.params) |p| {
return try json.parseFromSliceLeaky( return try json.parseFromSliceLeaky(
T, T,
self.arena, self.arena,
@@ -336,20 +448,26 @@ pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
return null; 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 { const SendResultOpts = struct {
include_session_id: bool = true, include_session_id: bool = true,
}; };
pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void { pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void {
return self.sender.sendJSON(.{ return self.sender.sendJSON(.{
.id = self.id, .id = self.input.id,
.result = if (comptime @typeInfo(@TypeOf(result)) == .Null) struct {}{} else result, .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 { const SendEventOpts = struct {
session_id: ?[]const u8 = null, session_id: ?[]const u8 = null,
}; };
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void { 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 // Events ALWAYS go to the client. self.sender should not be used
return self.cdp.sendJSON(.{ return self.cdp.sendJSON(.{
@@ -358,6 +476,32 @@ pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
.sessionId = opts.session_id, .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"); const testing = @import("testing.zig");
test "cdp: invalid json" { test "cdp: invalid json" {
var ctx = testing.context(); var ctx = testing.context();
defer ctx.deinit(); defer ctx.deinit();
@@ -425,6 +552,7 @@ test "cdp: invalid json" {
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{ try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
.method = "Target", .method = "Target",
})); }));
try ctx.expectSentError(-31998, "InvalidMethod", .{});
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{ try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
.method = "Unknown.domain", .method = "Unknown.domain",
@@ -434,3 +562,53 @@ test "cdp: invalid json" {
.method = "Target.over9000", .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" });
}
}

View File

@@ -22,7 +22,7 @@ const cdp = @import("cdp.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -29,7 +29,7 @@ pub fn processMessage(cmd: anytype) !void {
performSearch, performSearch,
getSearchResults, getSearchResults,
discardSearchResults, discardSearchResults,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),
@@ -133,14 +133,13 @@ fn getDocument(cmd: anytype) !void {
// pierce: ?bool = null, // pierce: ?bool = null,
// })) orelse return error.InvalidParams; // })) orelse return error.InvalidParams;
// retrieve the root node const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = cmd.session.page orelse return error.NoPage; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const doc = page.doc orelse return error.NoDocument; const doc = page.doc orelse return error.DocumentNotLoaded;
const state = cmd.cdp;
const node = parser.documentToNode(doc); const node = parser.documentToNode(doc);
var n = try Node.init(node, &state.node_list); var n = try Node.init(node, &bc.node_list);
_ = try n.initChildren(cmd.arena, node, &state.node_list); _ = try n.initChildren(cmd.arena, node, &bc.node_list);
return cmd.sendResult(.{ return cmd.sendResult(.{
.root = n, .root = n,
@@ -184,21 +183,20 @@ fn performSearch(cmd: anytype) !void {
includeUserAgentShadowDOM: ?bool = null, includeUserAgentShadowDOM: ?bool = null,
})) orelse return error.InvalidParams; })) orelse return error.InvalidParams;
// retrieve the root node const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = cmd.session.page orelse return error.NoPage; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const doc = page.doc orelse return error.NoDocument; const doc = page.doc orelse return error.DocumentNotLoaded;
const list = try css.querySelectorAll(cmd.cdp.allocator, parser.documentToNode(doc), params.query); const list = try css.querySelectorAll(cmd.cdp.allocator, parser.documentToNode(doc), params.query);
const ln = list.nodes.items.len; const ln = list.nodes.items.len;
var ns = try NodeSearch.initCapacity(cmd.cdp.allocator, ln); var ns = try NodeSearch.initCapacity(cmd.cdp.allocator, ln);
var state = cmd.cdp;
for (list.nodes.items) |n| { 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 ns.append(id);
} }
try state.node_search_list.append(ns); try bc.node_search_list.append(ns);
return cmd.sendResult(.{ return cmd.sendResult(.{
.searchId = ns.name, .searchId = ns.name,
@@ -212,13 +210,14 @@ fn discardSearchResults(cmd: anytype) !void {
searchId: []const u8, searchId: []const u8,
})) orelse return error.InvalidParams; })) orelse return error.InvalidParams;
var state = cmd.cdp; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// retrieve the search from context // 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; if (!std.mem.eql(u8, s.name, params.searchId)) continue;
s.deinit(); s.deinit();
_ = state.node_search_list.swapRemove(i); _ = bc.node_search_list.swapRemove(i);
break; break;
} }
@@ -237,10 +236,11 @@ fn getSearchResults(cmd: anytype) !void {
return error.BadIndices; return error.BadIndices;
} }
const state = cmd.cdp; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// retrieve the search from context // retrieve the search from context
var ns: ?*const NodeSearch = undefined; 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; if (!std.mem.eql(u8, s.name, params.searchId)) continue;
ns = &s; ns = &s;
break; break;

View File

@@ -26,7 +26,7 @@ pub fn processMessage(cmd: anytype) !void {
setFocusEmulationEnabled, setFocusEmulationEnabled,
setDeviceMetricsOverride, setDeviceMetricsOverride,
setTouchEmulationEnabled, setTouchEmulationEnabled,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.setEmulatedMedia => return setEmulatedMedia(cmd), .setEmulatedMedia => return setEmulatedMedia(cmd),

View File

@@ -22,7 +22,7 @@ const cdp = @import("cdp.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
disable, disable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.disable => return cmd.sendResult(null, .{}), .disable => return cmd.sendResult(null, .{}),

View File

@@ -22,7 +22,7 @@ const cdp = @import("cdp.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -22,7 +22,7 @@ const cdp = @import("cdp.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -23,7 +23,7 @@ pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
setCacheDisabled, setCacheDisabled,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -28,7 +28,7 @@ pub fn processMessage(cmd: anytype) !void {
addScriptToEvaluateOnNewDocument, addScriptToEvaluateOnNewDocument,
createIsolatedWorld, createIsolatedWorld,
navigate, navigate,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),
@@ -56,43 +56,16 @@ const Frame = struct {
}; };
fn getFrameTree(cmd: anytype) !void { fn getFrameTree(cmd: anytype) !void {
// output const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const FrameTree = struct {
frameTree: struct {
frame: Frame,
},
childFrames: ?[]@This() = null,
pub fn format( return cmd.sendResult(.{
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{
.frameTree = .{ .frameTree = .{
.frame = .{ .frame = Frame{
.id = state.frame_id, .url = bc.url,
.url = state.url, .id = bc.frame_id,
.securityOrigin = state.security_origin, .loaderId = bc.loader_id,
.secureContextType = state.secure_context_type, .securityOrigin = bc.security_origin,
.loaderId = state.loader_id, .secureContextType = bc.secure_context_type,
}, },
}, },
}, .{}); }, .{});
@@ -103,7 +76,8 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
// enabled: bool, // enabled: bool,
// })) orelse return error.InvalidParams; // })) 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, .{}); return cmd.sendResult(null, .{});
} }
@@ -116,27 +90,16 @@ fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
// runImmediately: bool = false, // runImmediately: bool = false,
// })) orelse return error.InvalidParams; // })) orelse return error.InvalidParams;
const Response = struct { return cmd.sendResult(.{
identifier: []const u8 = "1", .identifier = "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{}, .{});
} }
// TODO: hard coded method // TODO: hard coded method
fn createIsolatedWorld(cmd: anytype) !void { 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 { const params = (try cmd.params(struct {
frameId: []const u8, frameId: []const u8,
@@ -166,7 +129,16 @@ fn createIsolatedWorld(cmd: anytype) !void {
} }
fn navigate(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 { const params = (try cmd.params(struct {
url: []const u8, url: []const u8,
@@ -177,12 +149,11 @@ fn navigate(cmd: anytype) !void {
})) orelse return error.InvalidParams; })) orelse return error.InvalidParams;
// change state // change state
var state = cmd.cdp; bc.reset();
state.reset(); bc.url = params.url;
state.url = params.url;
// TODO: hard coded ID // TODO: hard coded ID
state.loader_id = "AF8667A203C5392DBE9AC290044AA4C2"; bc.loader_id = "AF8667A203C5392DBE9AC290044AA4C2";
const LifecycleEvent = struct { const LifecycleEvent = struct {
frameId: []const u8, frameId: []const u8,
@@ -192,8 +163,8 @@ fn navigate(cmd: anytype) !void {
}; };
var life_event = LifecycleEvent{ var life_event = LifecycleEvent{
.frameId = state.frame_id, .frameId = bc.frame_id,
.loaderId = state.loader_id, .loaderId = bc.loader_id,
.name = "init", .name = "init",
.timestamp = 343721.796037, .timestamp = 343721.796037,
}; };
@@ -201,39 +172,17 @@ fn navigate(cmd: anytype) !void {
// frameStartedLoading event // frameStartedLoading event
// TODO: event partially hard coded // TODO: event partially hard coded
try cmd.sendEvent("Page.frameStartedLoading", .{ try cmd.sendEvent("Page.frameStartedLoading", .{
.frameId = state.frame_id, .frameId = bc.frame_id,
}, .{ .session_id = session_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 }); try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id });
} }
// output // output
const Response = struct { try cmd.sendResult(.{
frameId: []const u8, .frameId = bc.frame_id,
loaderId: ?[]const u8, .loaderId = bc.loader_id,
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,
}, .{}); }, .{});
// TODO: at this point do we need async the following actions to be async? // 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? // TODO: noop event, we have no env context at this point, is it necesarry?
try cmd.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); 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( const aux_data = try std.fmt.allocPrint(
cmd.arena, cmd.arena,
// NOTE: we assume this is the default web page // NOTE: we assume this is the default web page
"{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", "{{\"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 // Events
// lifecycle init event // lifecycle init event
// TODO: partially hard coded // TODO: partially hard coded
if (state.page_life_cycle_events) { if (bc.page_life_cycle_events) {
life_event.name = "init"; life_event.name = "init";
life_event.timestamp = 343721.796037; life_event.timestamp = 343721.796037;
try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); 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", .{ try cmd.sendEvent("Page.frameNavigated", .{
.type = "Navigation", .type = "Navigation",
.frame = Frame{ .frame = Frame{
.id = state.frame_id, .id = bc.frame_id,
.url = state.url, .url = bc.url,
.securityOrigin = state.security_origin, .securityOrigin = bc.security_origin,
.secureContextType = state.secure_context_type, .secureContextType = bc.secure_context_type,
.loaderId = state.loader_id, .loaderId = bc.loader_id,
}, },
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
@@ -289,7 +235,7 @@ fn navigate(cmd: anytype) !void {
// lifecycle DOMContentLoaded event // lifecycle DOMContentLoaded event
// TODO: partially hard coded // TODO: partially hard coded
if (state.page_life_cycle_events) { if (bc.page_life_cycle_events) {
life_event.name = "DOMContentLoaded"; life_event.name = "DOMContentLoaded";
life_event.timestamp = 343721.803338; life_event.timestamp = 343721.803338;
try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id });
@@ -305,7 +251,7 @@ fn navigate(cmd: anytype) !void {
// lifecycle DOMContentLoaded event // lifecycle DOMContentLoaded event
// TODO: partially hard coded // TODO: partially hard coded
if (state.page_life_cycle_events) { if (bc.page_life_cycle_events) {
life_event.name = "load"; life_event.name = "load";
life_event.timestamp = 343721.824655; life_event.timestamp = 343721.824655;
try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id }); try cmd.sendEvent("Page.lifecycleEvent", life_event, .{ .session_id = session_id });
@@ -313,6 +259,6 @@ fn navigate(cmd: anytype) !void {
// frameStoppedLoading // frameStoppedLoading
return cmd.sendEvent("Page.frameStoppedLoading", .{ return cmd.sendEvent("Page.frameStoppedLoading", .{
.frameId = state.frame_id, .frameId = bc.frame_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }

View File

@@ -23,7 +23,7 @@ const asUint = @import("../str/parser.zig").asUint;
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -27,7 +27,7 @@ pub fn processMessage(cmd: anytype) !void {
addBinding, addBinding,
callFunctionOn, callFunctionOn,
releaseObject, releaseObject,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.runIfWaitingForDebugger => return cmd.sendResult(null, .{}), .runIfWaitingForDebugger => return cmd.sendResult(null, .{}),
@@ -41,26 +41,24 @@ fn sendInspector(cmd: anytype, action: anytype) !void {
try logInspector(cmd, action); try logInspector(cmd, action);
} }
if (cmd.session_id) |s| { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
cmd.cdp.session_id = try cdp.SessionID.parse(s);
}
// remove awaitPromise true params // remove awaitPromise true params
// TODO: delete when Promise are correctly handled by zig-js-runtime // TODO: delete when Promise are correctly handled by zig-js-runtime
if (action == .callFunctionOn or action == .evaluate) { if (action == .callFunctionOn or action == .evaluate) {
const json = cmd.json; const json = cmd.input.json;
if (std.mem.indexOf(u8, json, "\"awaitPromise\":true")) |_| { if (std.mem.indexOf(u8, json, "\"awaitPromise\":true")) |_| {
// +1 because we'll be turning a true -> false // +1 because we'll be turning a true -> false
const buf = try cmd.arena.alloc(u8, json.len + 1); const buf = try cmd.arena.alloc(u8, json.len + 1);
_ = std.mem.replace(u8, json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf); _ = std.mem.replace(u8, json, "\"awaitPromise\":true", "\"awaitPromise\":false", buf);
cmd.session.callInspector(buf); bc.session.callInspector(buf);
return; 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, .{}); return cmd.sendResult(null, .{});
} }
} }
@@ -110,7 +108,7 @@ fn logInspector(cmd: anytype, action: anytype) !void {
}, },
else => return, 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}); const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id});
var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{}); var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{});

View File

@@ -22,7 +22,7 @@ const cdp = @import("cdp.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
}, cmd.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),

View File

@@ -17,136 +17,229 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const cdp = @import("cdp.zig");
const log = std.log.scoped(.cdp); const log = std.log.scoped(.cdp);
// TODO: hard coded IDs // 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"; const LOADER_ID = "LOADERID42AA389647D702B4D805F49A";
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
setDiscoverTargets,
setAutoAttach,
attachToTarget, attachToTarget,
getTargetInfo,
getBrowserContexts,
createBrowserContext,
disposeBrowserContext,
createTarget,
closeTarget, closeTarget,
sendMessageToTarget, createBrowserContext,
createTarget,
detachFromTarget, detachFromTarget,
}, cmd.action) orelse return error.UnknownMethod; disposeBrowserContext,
getBrowserContexts,
getTargetInfo,
sendMessageToTarget,
setAutoAttach,
setDiscoverTargets,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.setDiscoverTargets => return setDiscoverTargets(cmd),
.setAutoAttach => return setAutoAttach(cmd),
.attachToTarget => return attachToTarget(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), .closeTarget => return closeTarget(cmd),
.sendMessageToTarget => return sendMessageToTarget(cmd), .createBrowserContext => return createBrowserContext(cmd),
.createTarget => return createTarget(cmd),
.detachFromTarget => return detachFromTarget(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 { fn getBrowserContexts(cmd: anytype) !void {
sessionId: []const u8, var browser_context_ids: []const []const u8 = undefined;
targetInfo: TargetInfo, if (cmd.browser_context) |bc| {
waitingForDebugger: bool = false, browser_context_ids = &.{bc.id};
}; } else {
browser_context_ids = &.{};
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,
},
}, .{});
} }
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 { fn attachToTarget(cmd: anytype) !void {
const params = (try cmd.params(struct { const params = (try cmd.params(struct {
targetId: []const u8, targetId: []const u8,
flatten: bool = true, flatten: bool = true,
})) orelse return error.InvalidParams; })) orelse return error.InvalidParams;
// attachedToTarget event const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (cmd.session_id == null) { const target_id = bc.target_id orelse return error.TargetNotLoaded;
try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ if (std.mem.eql(u8, target_id, params.targetId) == false) {
.sessionId = cdp.BROWSER_SESSION_ID, return error.UnknownTargetId;
.targetInfo = .{
.targetId = params.targetId,
.title = "about:blank",
.url = cdp.URL_BASE,
.browserContextId = BROWER_CONTEXT_ID,
},
}, .{});
} }
if (bc.session_id != null) {
return error.SessionAlreadyLoaded;
}
try doAttachtoTarget(cmd, target_id);
return cmd.sendResult( return cmd.sendResult(
.{ .sessionId = cmd.session_id orelse cdp.BROWSER_SESSION_ID }, .{ .sessionId = bc.session_id },
.{ .include_session_id = false }, .{ .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 { fn getTargetInfo(cmd: anytype) !void {
// const params = (try cmd.params(struct { const params = (try cmd.params(struct {
// targetId: ?[]const u8 = null, targetId: ?[]const u8 = null,
// })) orelse return error.InvalidParams; })) 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(.{ return cmd.sendResult(.{
.targetId = BROWSER_TARGET_ID,
.type = "browser", .type = "browser",
.title = "", .title = "",
.url = "", .url = "",
@@ -155,188 +248,24 @@ fn getTargetInfo(cmd: anytype) !void {
}, .{ .include_session_id = false }); }, .{ .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 { fn sendMessageToTarget(cmd: anytype) !void {
const params = (try cmd.params(struct { const params = (try cmd.params(struct {
message: []const u8, message: []const u8,
sessionId: []const u8, sessionId: []const u8,
})) orelse return error.InvalidParams; })) 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 { const Capture = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
buf: std.ArrayListUnmanaged(u8), buf: std.ArrayListUnmanaged(u8),
@@ -354,7 +283,7 @@ fn sendMessageToTarget(cmd: anytype) !void {
}; };
cmd.cdp.dispatch(cmd.arena, &capture, params.message) catch |err| { 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; return err;
}; };
@@ -368,3 +297,253 @@ fn sendMessageToTarget(cmd: anytype) !void {
fn detachFromTarget(cmd: anytype) !void { fn detachFromTarget(cmd: anytype) !void {
return cmd.sendResult(null, .{}); 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.? } }, .{});
}
}

View File

@@ -5,7 +5,7 @@ const Allocator = std.mem.Allocator;
const Testing = @This(); const Testing = @This();
const cdp = @import("cdp.zig"); const main = @import("cdp.zig");
const parser = @import("netsurf"); const parser = @import("netsurf");
pub const expectEqual = std.testing.expectEqual; pub const expectEqual = std.testing.expectEqual;
@@ -13,32 +13,57 @@ pub const expectError = std.testing.expectError;
pub const expectString = std.testing.expectEqualStrings; pub const expectString = std.testing.expectEqualStrings;
const Browser = struct { 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; _ = 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 { pub fn newSession(self: *Browser, ctx: anytype) !*Session {
_ = ctx; _ = ctx;
if (self.session != null) {
return error.MockBrowserSessionAlreadyExists;
}
self.session = .{}; const allocator = self.arena.allocator();
return &self.session.?; 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 { const Session = struct {
page: ?Page = null, page: ?Page = null,
allocator: Allocator,
pub fn currentPage(self: *Session) ?*Page { pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null); return &(self.page orelse return null);
} }
pub fn createPage(self: *Session) !*Page { 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.?; return &self.page.?;
} }
@@ -49,6 +74,9 @@ const Session = struct {
}; };
const Page = struct { const Page = struct {
session: *Session,
allocator: Allocator,
aux_data: []const u8 = "",
doc: ?*parser.Document = null, doc: ?*parser.Document = null,
pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void { 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 { pub fn start(self: *Page, aux_data: []const u8) !void {
_ = self; self.aux_data = try self.allocator.dupe(u8, aux_data);
_ = aux_data;
} }
pub fn end(self: *Page) void { pub fn end(self: *Page) void {
_ = self; self.session.page = null;
} }
}; };
const Client = struct { const Client = struct {
allocator: Allocator, allocator: Allocator,
sent: std.ArrayListUnmanaged([]const u8) = .{}, sent: std.ArrayListUnmanaged(json.Value) = .{},
serialized: std.ArrayListUnmanaged([]const u8) = .{},
fn init(allocator: Allocator) Client { fn init(allocator: Allocator) Client {
return .{ return .{
@@ -78,15 +106,21 @@ const Client = struct {
} }
pub fn sendJSON(self: *Client, message: anytype, opts: json.StringifyOptions) !void { pub fn sendJSON(self: *Client, message: anytype, opts: json.StringifyOptions) !void {
const serialized = try json.stringifyAlloc(self.allocator, message, opts); var opts_copy = opts;
try self.sent.append(self.allocator, serialized); 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 Browser = Testing.Browser;
pub const Session = Testing.Session; pub const Session = Testing.Session;
pub const Client = Testing.Client; pub const Client = *Testing.Client;
}); });
const TestContext = struct { const TestContext = struct {
@@ -106,15 +140,39 @@ const TestContext = struct {
self.client = Client.init(self.arena.allocator()); self.client = Client.init(self.arena.allocator());
// Don't use the arena here. We want to detect leaks in CDP. // Don't use the arena here. We want to detect leaks in CDP.
// The arena is only for test-specific stuff // 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_.?; 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 { pub fn processMessage(self: *TestContext, msg: anytype) !void {
var json_message: []const u8 = undefined; var json_message: []const u8 = undefined;
if (@typeInfo(@TypeOf(msg)) != .Pointer) { 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 { } else {
// assume this is a string we want to send as-is, if it isn't, we'll // 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. // get a compile error, so no big deal.
@@ -132,34 +190,71 @@ const TestContext = struct {
index: ?usize = null, index: ?usize = null,
session_id: ?[]const u8 = null, session_id: ?[]const u8 = null,
}; };
pub fn expectSentResult(self: *TestContext, expected: anytype, opts: ExpectResultOpts) !void { pub fn expectSentResult(self: *TestContext, expected: anytype, opts: ExpectResultOpts) !void {
const expected_result = .{ const expected_result = .{
.id = opts.id, .id = opts.id,
.result = expected, .result = if (comptime @typeInfo(@TypeOf(expected)) == .Null) struct {}{} else expected,
.sessionId = opts.session_id, .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, .emit_null_optional_fields = false,
}); });
for (self.client.?.sent.items, 0..) |sent, i| { for (self.client.?.sent.items, 0..) |sent, i| {
if (std.mem.eql(u8, sent, serialized) == false) { if (try compareExpectedToSent(serialized, sent) == false) {
continue; continue;
} }
if (opts.index) |expected_index| { if (opts.index) |expected_index| {
if (expected_index != i) { 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}); std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized});
for (self.client.?.sent.items, 0..) |sent, i| { for (self.client.?.serialized.items, 0..) |sent, i| {
std.debug.print("#{d}\n{s}\n\n", .{ i, sent }); 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), .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)));
// }

View File

@@ -9,7 +9,7 @@ const std = @import("std");
// - while incrementor is valid // - while incrementor is valid
// - until the next call to next() // - until the next call to next()
// On the positive, it's zero allocation // 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 // +1 for the '-' separator
const NUMERIC_START = prefix.len + 1; const NUMERIC_START = prefix.len + 1;
const MAX_BYTES = NUMERIC_START + switch (T) { 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].*); const PREFIX_INT_CODE: PrefixIntType = @bitCast(buffer[0..NUMERIC_START].*);
return struct { return struct {
current: T = 0, counter: T = 0,
buffer: [MAX_BYTES]u8 = buffer, buffer: [MAX_BYTES]u8 = buffer,
const Self = @This(); const Self = @This();
pub fn next(self: *Self) []const u8 { pub fn next(self: *Self) []const u8 {
const current = self.current; const counter = self.counter;
const n = current +% 1; const n = counter +% 1;
defer self.current = n; defer self.counter = n;
const size = std.fmt.formatIntBuf(self.buffer[NUMERIC_START..], n, 10, .lower, .{}); const size = std.fmt.formatIntBuf(self.buffer[NUMERIC_START..], n, 10, .lower, .{});
return self.buffer[0 .. NUMERIC_START + size]; return self.buffer[0 .. NUMERIC_START + size];
@@ -106,7 +106,7 @@ test "id: Incrementing.next" {
try testing.expectEqualStrings("IDX-3", id.next()); try testing.expectEqualStrings("IDX-3", id.next());
// force a wrap // force a wrap
id.current = 65533; id.counter = 65533;
try testing.expectEqualStrings("IDX-65534", id.next()); try testing.expectEqualStrings("IDX-65534", id.next());
try testing.expectEqualStrings("IDX-65535", id.next()); try testing.expectEqualStrings("IDX-65535", id.next());
try testing.expectEqualStrings("IDX-0", id.next()); try testing.expectEqualStrings("IDX-0", id.next());