mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 23:23:28 +00:00
Turn CDP into a generic so that mocks can be injected for testing
ADD CDP testing helpers (mock Browser, Session, Page and Client). These are placeholders until tests are added which use them. Added a couple CDP tests.
This commit is contained in:
@@ -80,3 +80,41 @@ fn getWindowForTarget(cmd: anytype) !void {
|
|||||||
fn setWindowBounds(cmd: anytype) !void {
|
fn setWindowBounds(cmd: anytype) !void {
|
||||||
return cmd.sendResult(null, .{});
|
return cmd.sendResult(null, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testing = @import("testing.zig");
|
||||||
|
test "cdp.browser: getVersion" {
|
||||||
|
var ctx = testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
try ctx.processMessage(.{
|
||||||
|
.id = 32,
|
||||||
|
.sessionID = "leto",
|
||||||
|
.method = "Browser.getVersion",
|
||||||
|
});
|
||||||
|
|
||||||
|
try ctx.expectSentCount(1);
|
||||||
|
try ctx.expectSentResult(.{
|
||||||
|
.protocolVersion = PROTOCOL_VERSION,
|
||||||
|
.product = PRODUCT,
|
||||||
|
.revision = REVISION,
|
||||||
|
.userAgent = USER_AGENT,
|
||||||
|
.jsVersion = JS_VERSION,
|
||||||
|
}, .{ .id = 32, .index = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test "cdp.browser: getWindowForTarget" {
|
||||||
|
var ctx = testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
try ctx.processMessage(.{
|
||||||
|
.id = 33,
|
||||||
|
.sessionId = "leto",
|
||||||
|
.method = "Browser.getWindowForTarget",
|
||||||
|
});
|
||||||
|
|
||||||
|
try ctx.expectSentCount(1);
|
||||||
|
try ctx.expectSentResult(.{
|
||||||
|
.windowId = DEV_TOOLS_WINDOW_ID,
|
||||||
|
.bounds = .{ .windowState = "normal" },
|
||||||
|
}, .{ .id = 33, .index = 0, .session_id = "leto" });
|
||||||
|
}
|
||||||
|
|||||||
529
src/cdp/cdp.zig
529
src/cdp/cdp.zig
@@ -22,10 +22,8 @@ const json = std.json;
|
|||||||
|
|
||||||
const dom = @import("dom.zig");
|
const dom = @import("dom.zig");
|
||||||
const Loop = @import("jsruntime").Loop;
|
const Loop = @import("jsruntime").Loop;
|
||||||
const Client = @import("../server.zig").Client;
|
// const Client = @import("../server.zig").Client;
|
||||||
const asUint = @import("../str/parser.zig").asUint;
|
const asUint = @import("../str/parser.zig").asUint;
|
||||||
const Browser = @import("../browser/browser.zig").Browser;
|
|
||||||
const Session = @import("../browser/browser.zig").Session;
|
|
||||||
|
|
||||||
const log = std.log.scoped(.cdp);
|
const log = std.log.scoped(.cdp);
|
||||||
|
|
||||||
@@ -39,254 +37,265 @@ pub const TimestampEvent = struct {
|
|||||||
timestamp: f64,
|
timestamp: f64,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const CDP = struct {
|
pub const CDP = CDPT(struct {
|
||||||
// Used for sending message to the client and closing on error
|
const Client = @import("../server.zig").Client;
|
||||||
client: *Client,
|
const Browser = @import("../browser/browser.zig").Browser;
|
||||||
|
const Session = @import("../browser/browser.zig").Session;
|
||||||
|
});
|
||||||
|
|
||||||
// The active browser
|
// Generic so that we can inject mocks into it.
|
||||||
browser: Browser,
|
pub fn CDPT(comptime TypeProvider: type) type {
|
||||||
|
return struct {
|
||||||
|
// Used for sending message to the client and closing on error
|
||||||
|
client: *TypeProvider.Client,
|
||||||
|
|
||||||
// The active browser session
|
// The active browser
|
||||||
session: ?*Session,
|
browser: Browser,
|
||||||
|
|
||||||
allocator: Allocator,
|
// The active browser session
|
||||||
|
session: ?*Session,
|
||||||
|
|
||||||
// Re-used arena for processing a message. We're assuming that we're getting
|
allocator: Allocator,
|
||||||
// 1 message at a time.
|
|
||||||
message_arena: std.heap.ArenaAllocator,
|
|
||||||
|
|
||||||
// State
|
// Re-used arena for processing a message. We're assuming that we're getting
|
||||||
url: []const u8,
|
// 1 message at a time.
|
||||||
frame_id: []const u8,
|
message_arena: std.heap.ArenaAllocator,
|
||||||
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,
|
|
||||||
|
|
||||||
pub fn init(allocator: Allocator, client: *Client, loop: *Loop) CDP {
|
// State
|
||||||
return .{
|
url: []const u8,
|
||||||
.client = client,
|
frame_id: []const u8,
|
||||||
.browser = Browser.init(allocator, loop),
|
loader_id: []const u8,
|
||||||
.session = null,
|
session_id: SessionID,
|
||||||
.allocator = allocator,
|
context_id: ?[]const u8,
|
||||||
.url = URL_BASE,
|
execution_context_id: u32,
|
||||||
.execution_context_id = 0,
|
security_origin: []const u8,
|
||||||
.context_id = null,
|
page_life_cycle_events: bool,
|
||||||
.frame_id = FRAME_ID,
|
secure_context_type: []const u8,
|
||||||
.session_id = .CONTEXTSESSIONID0497A05C95417CF4,
|
node_list: dom.NodeList,
|
||||||
.security_origin = URL_BASE,
|
node_search_list: dom.NodeSearchList,
|
||||||
.secure_context_type = "Secure", // TODO = enum
|
|
||||||
.loader_id = LOADER_ID,
|
|
||||||
.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: *CDP) void {
|
const Self = @This();
|
||||||
self.node_list.deinit();
|
pub const Browser = TypeProvider.Browser;
|
||||||
for (self.node_search_list.items) |*s| {
|
pub const Session = TypeProvider.Session;
|
||||||
s.deinit();
|
|
||||||
}
|
|
||||||
self.node_search_list.deinit();
|
|
||||||
|
|
||||||
self.browser.deinit();
|
pub fn init(allocator: Allocator, client: *TypeProvider.Client, loop: anytype) Self {
|
||||||
self.message_arena.deinit();
|
return .{
|
||||||
}
|
.client = client,
|
||||||
|
.browser = Browser.init(allocator, loop),
|
||||||
pub fn reset(self: *CDP) void {
|
.session = null,
|
||||||
self.node_list.reset();
|
.allocator = allocator,
|
||||||
|
.url = URL_BASE,
|
||||||
// deinit all node searches.
|
.execution_context_id = 0,
|
||||||
for (self.node_search_list.items) |*s| {
|
.context_id = null,
|
||||||
s.deinit();
|
.frame_id = FRAME_ID,
|
||||||
}
|
.session_id = .CONTEXTSESSIONID0497A05C95417CF4,
|
||||||
self.node_search_list.clearAndFree();
|
.security_origin = URL_BASE,
|
||||||
}
|
.secure_context_type = "Secure", // TODO = enum
|
||||||
|
.loader_id = LOADER_ID,
|
||||||
pub fn newSession(self: *CDP) !void {
|
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||||
self.session = try self.browser.newSession(self);
|
.page_life_cycle_events = false, // TODO; Target based value
|
||||||
}
|
.node_list = dom.NodeList.init(allocator),
|
||||||
|
.node_search_list = dom.NodeSearchList.init(allocator),
|
||||||
pub fn processMessage(self: *CDP, msg: []const u8) bool {
|
|
||||||
const arena = &self.message_arena;
|
|
||||||
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
|
||||||
|
|
||||||
self.dispatch(arena.allocator(), self, msg) catch |err| {
|
|
||||||
log.err("failed to process message: {}\n{s}", .{ err, msg });
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called from above, in processMessage which handles client messages
|
|
||||||
// but can also be called internally. For example, Target.sendMessageToTarget
|
|
||||||
// calls back into dispatch.
|
|
||||||
pub fn dispatch(
|
|
||||||
self: *CDP,
|
|
||||||
arena: Allocator,
|
|
||||||
sender: anytype,
|
|
||||||
str: []const u8,
|
|
||||||
) anyerror!void {
|
|
||||||
const input = try json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
|
||||||
.ignore_unknown_fields = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const domain, const action = blk: {
|
|
||||||
const method = input.method;
|
|
||||||
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
|
|
||||||
return error.InvalidMethod;
|
|
||||||
};
|
};
|
||||||
break :blk .{ method[0..i], method[i + 1 ..] };
|
|
||||||
};
|
|
||||||
|
|
||||||
var command = Command(@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) {
|
|
||||||
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),
|
|
||||||
else => {},
|
|
||||||
},
|
|
||||||
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
|
||||||
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),
|
|
||||||
else => {},
|
|
||||||
},
|
|
||||||
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
|
||||||
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),
|
|
||||||
else => {},
|
|
||||||
},
|
|
||||||
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
|
||||||
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),
|
|
||||||
else => {},
|
|
||||||
},
|
|
||||||
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
|
||||||
asUint("Performance") => return @import("performance.zig").processMessage(&command),
|
|
||||||
else => {},
|
|
||||||
},
|
|
||||||
else => {},
|
|
||||||
}
|
}
|
||||||
return error.UnknownDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sendJSON(self: *CDP, message: anytype) !void {
|
pub fn deinit(self: *Self) void {
|
||||||
return self.client.sendJSON(message, .{
|
self.node_list.deinit();
|
||||||
.emit_null_optional_fields = false,
|
for (self.node_search_list.items) |*s| {
|
||||||
});
|
s.deinit();
|
||||||
}
|
}
|
||||||
|
self.node_search_list.deinit();
|
||||||
|
|
||||||
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
|
self.browser.deinit();
|
||||||
if (std.log.defaultLogEnabled(.debug)) {
|
self.message_arena.deinit();
|
||||||
// msg should be {"id":<id>,...
|
}
|
||||||
std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":"));
|
|
||||||
|
|
||||||
const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
pub fn reset(self: *Self) void {
|
||||||
log.warn("invalid inspector response message: {s}", .{msg});
|
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;
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn processMessage(self: *Self, msg: []const u8) !void {
|
||||||
|
const arena = &self.message_arena;
|
||||||
|
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
||||||
|
return self.dispatch(arena.allocator(), self, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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;
|
||||||
|
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.?;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
||||||
|
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),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
||||||
|
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),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
||||||
|
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),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
||||||
|
asUint("Performance") => return @import("performance.zig").processMessage(&command),
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
return error.UnknownDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sendJSON(self: *Self, message: anytype) !void {
|
||||||
|
return self.client.sendJSON(message, .{
|
||||||
|
.emit_null_optional_fields = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
|
||||||
|
if (std.log.defaultLogEnabled(.debug)) {
|
||||||
|
// msg should be {"id":<id>,...
|
||||||
|
std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":"));
|
||||||
|
|
||||||
|
const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
||||||
|
log.warn("invalid inspector response message: {s}", .{msg});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const id = msg[6..id_end];
|
||||||
|
log.debug("Res (inspector) > id {s}", .{id});
|
||||||
|
}
|
||||||
|
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
||||||
|
log.err("Failed to send inspector response: {any}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
|
||||||
|
if (std.log.defaultLogEnabled(.debug)) {
|
||||||
|
// msg should be {"method":<method>,...
|
||||||
|
std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":"));
|
||||||
|
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
||||||
|
log.warn("invalid inspector event message: {s}", .{msg});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
const method = msg[10..method_end];
|
||||||
|
log.debug("Event (inspector) > method {s}", .{method});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
||||||
|
log.err("Failed to send inspector event: {any}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is hacky * 2. First, we have 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);
|
||||||
|
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) = .{};
|
||||||
|
buf.ensureTotalCapacity(arena.allocator(), message_len) catch |err| {
|
||||||
|
log.err("Failed to expand inspector buffer: {any}", .{err});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
const id = msg[6..id_end];
|
|
||||||
log.debug("Res (inspector) > id {s}", .{id});
|
// reserve 10 bytes for websocket header
|
||||||
|
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
||||||
|
|
||||||
|
// -1 because we dont' want the closing brace '}'
|
||||||
|
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
|
||||||
|
buf.appendSliceAssumeCapacity(field);
|
||||||
|
buf.appendSliceAssumeCapacity(session_id);
|
||||||
|
buf.appendSliceAssumeCapacity("\"}");
|
||||||
|
std.debug.assert(buf.items.len == message_len);
|
||||||
|
|
||||||
|
try self.client.sendJSONRaw(arena, buf);
|
||||||
}
|
}
|
||||||
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
};
|
||||||
log.err("Failed to send inspector response: {any}", .{err});
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
|
|
||||||
if (std.log.defaultLogEnabled(.debug)) {
|
|
||||||
// msg should be {"method":<method>,...
|
|
||||||
std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":"));
|
|
||||||
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
|
||||||
log.warn("invalid inspector event message: {s}", .{msg});
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
const method = msg[10..method_end];
|
|
||||||
log.debug("Event (inspector) > method {s}", .{method});
|
|
||||||
}
|
|
||||||
|
|
||||||
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
|
||||||
log.err("Failed to send inspector event: {any}", .{err});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is hacky * 2. First, we have 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: *CDP, msg: []const u8) !void {
|
|
||||||
var arena = std.heap.ArenaAllocator.init(self.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) = .{};
|
|
||||||
buf.ensureTotalCapacity(arena.allocator(), message_len) catch |err| {
|
|
||||||
log.err("Failed to expand inspector buffer: {any}", .{err});
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// reserve 10 bytes for websocket header
|
|
||||||
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
|
||||||
|
|
||||||
// -1 because we dont' want the closing brace '}'
|
|
||||||
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
|
|
||||||
buf.appendSliceAssumeCapacity(field);
|
|
||||||
buf.appendSliceAssumeCapacity(session_id);
|
|
||||||
buf.appendSliceAssumeCapacity("\"}");
|
|
||||||
std.debug.assert(buf.items.len == message_len);
|
|
||||||
|
|
||||||
try self.client.sendJSONRaw(arena, buf);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is a generic because when we send a result we have two different
|
// This is a generic because when we send a result we have two different
|
||||||
// behaviors. Normally, we're sending the result to the client. But in some cases
|
// behaviors. Normally, we're sending the result to the client. But in some cases
|
||||||
// we want to capture the result. So we want the command.sendResult to be
|
// we want to capture the result. So we want the command.sendResult to be
|
||||||
// generic.
|
// generic.
|
||||||
pub fn Command(comptime Sender: type) type {
|
pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
|
||||||
return struct {
|
return struct {
|
||||||
// refernece to our CDP instance
|
// reference to our CDP instance
|
||||||
cdp: *CDP,
|
cdp: *CDP_T,
|
||||||
|
|
||||||
// Comes directly from the input.id field
|
// Comes directly from the input.id field
|
||||||
id: ?i64,
|
id: ?i64,
|
||||||
@@ -296,7 +305,7 @@ pub fn Command(comptime Sender: type) type {
|
|||||||
arena: Allocator,
|
arena: Allocator,
|
||||||
|
|
||||||
// the browser session
|
// the browser session
|
||||||
session: *Session,
|
session: *CDP_T.Session,
|
||||||
|
|
||||||
// The "action" of the message.Given a method of "LOG.enable", the
|
// The "action" of the message.Given a method of "LOG.enable", the
|
||||||
// action is "enable"
|
// action is "enable"
|
||||||
@@ -355,7 +364,7 @@ pub fn Command(comptime Sender: type) type {
|
|||||||
// When we parse a JSON message from the client, this is the structure
|
// When we parse a JSON message from the client, this is the structure
|
||||||
// we always expect
|
// we always expect
|
||||||
const InputMessage = struct {
|
const InputMessage = struct {
|
||||||
id: ?i64,
|
id: ?i64 = null,
|
||||||
method: []const u8,
|
method: []const u8,
|
||||||
params: ?InputParams = null,
|
params: ?InputParams = null,
|
||||||
sessionId: ?[]const u8 = null,
|
sessionId: ?[]const u8 = null,
|
||||||
@@ -386,40 +395,6 @@ const InputParams = struct {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utils
|
|
||||||
// -----
|
|
||||||
|
|
||||||
// pub fn dumpFile(
|
|
||||||
// alloc: std.mem.Allocator,
|
|
||||||
// id: u16,
|
|
||||||
// script: []const u8,
|
|
||||||
// ) !void {
|
|
||||||
// const name = try std.fmt.allocPrint(alloc, "id_{d}.js", .{id});
|
|
||||||
// defer alloc.free(name);
|
|
||||||
// var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{});
|
|
||||||
// defer dir.close();
|
|
||||||
// const f = try dir.createFile(name, .{});
|
|
||||||
// defer f.close();
|
|
||||||
// const nb = try f.write(script);
|
|
||||||
// std.debug.assert(nb == script.len);
|
|
||||||
// const p = try dir.realpathAlloc(alloc, name);
|
|
||||||
// defer alloc.free(p);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // caller owns the slice returned
|
|
||||||
// pub fn stringify(alloc: std.mem.Allocator, res: anytype) ![]const u8 {
|
|
||||||
// var out = std.ArrayList(u8).init(alloc);
|
|
||||||
// defer out.deinit();
|
|
||||||
|
|
||||||
// // Do not emit optional null fields
|
|
||||||
// const options: std.json.StringifyOptions = .{ .emit_null_optional_fields = false };
|
|
||||||
|
|
||||||
// try std.json.stringify(res, options, out.writer());
|
|
||||||
// const ret = try alloc.alloc(u8, out.items.len);
|
|
||||||
// @memcpy(ret, out.items);
|
|
||||||
// return ret;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
// ------
|
// ------
|
||||||
|
|
||||||
@@ -435,3 +410,27 @@ pub const SessionID = enum {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testing = @import("testing.zig");
|
||||||
|
|
||||||
|
test "cdp: invalid json" {
|
||||||
|
var ctx = testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
|
||||||
|
|
||||||
|
// method is required
|
||||||
|
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
|
||||||
|
|
||||||
|
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
|
||||||
|
.method = "Target",
|
||||||
|
}));
|
||||||
|
|
||||||
|
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
|
||||||
|
.method = "Unknown.domain",
|
||||||
|
}));
|
||||||
|
|
||||||
|
try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
|
||||||
|
.method = "Target.over9000",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
170
src/cdp/testing.zig
Normal file
170
src/cdp/testing.zig
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const json = std.json;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const Testing = @This();
|
||||||
|
|
||||||
|
const cdp = @import("cdp.zig");
|
||||||
|
const parser = @import("netsurf");
|
||||||
|
|
||||||
|
pub const expectEqual = std.testing.expectEqual;
|
||||||
|
pub const expectError = std.testing.expectError;
|
||||||
|
pub const expectString = std.testing.expectEqualStrings;
|
||||||
|
|
||||||
|
const Browser = struct {
|
||||||
|
session: ?Session = null,
|
||||||
|
|
||||||
|
pub fn init(_: Allocator, loop: anytype) Browser {
|
||||||
|
_ = loop;
|
||||||
|
return .{};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(_: *const Browser) void {}
|
||||||
|
|
||||||
|
pub fn newSession(self: *Browser, ctx: anytype) !*Session {
|
||||||
|
_ = ctx;
|
||||||
|
|
||||||
|
self.session = .{};
|
||||||
|
return &self.session.?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Session = struct {
|
||||||
|
page: ?Page = null,
|
||||||
|
|
||||||
|
pub fn currentPage(self: *Session) ?*Page {
|
||||||
|
return &(self.page orelse return null);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn createPage(self: *Session) !*Page {
|
||||||
|
self.page = .{};
|
||||||
|
return &self.page.?;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn callInspector(self: *Session, msg: []const u8) void {
|
||||||
|
_ = self;
|
||||||
|
_ = msg;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = struct {
|
||||||
|
doc: ?*parser.Document = null,
|
||||||
|
|
||||||
|
pub fn navigate(self: *Page, url: []const u8, aux_data: []const u8) !void {
|
||||||
|
_ = self;
|
||||||
|
_ = url;
|
||||||
|
_ = aux_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(self: *Page, aux_data: []const u8) !void {
|
||||||
|
_ = self;
|
||||||
|
_ = aux_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(self: *Page) void {
|
||||||
|
_ = self;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Client = struct {
|
||||||
|
allocator: Allocator,
|
||||||
|
sent: std.ArrayListUnmanaged([]const u8) = .{},
|
||||||
|
|
||||||
|
fn init(allocator: Allocator) Client {
|
||||||
|
return .{
|
||||||
|
.allocator = allocator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TestCDP = cdp.CDPT(struct {
|
||||||
|
pub const Browser = Testing.Browser;
|
||||||
|
pub const Session = Testing.Session;
|
||||||
|
pub const Client = Testing.Client;
|
||||||
|
});
|
||||||
|
|
||||||
|
const TestContext = struct {
|
||||||
|
client: ?Client = null,
|
||||||
|
cdp_: ?TestCDP = null,
|
||||||
|
arena: std.heap.ArenaAllocator,
|
||||||
|
|
||||||
|
pub fn deinit(self: *TestContext) void {
|
||||||
|
if (self.cdp_) |*c| {
|
||||||
|
c.deinit();
|
||||||
|
}
|
||||||
|
self.arena.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cdp(self: *TestContext) *TestCDP {
|
||||||
|
if (self.cdp_ == null) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
return &self.cdp_.?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, .{});
|
||||||
|
} 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.
|
||||||
|
json_message = msg;
|
||||||
|
}
|
||||||
|
return self.cdp().processMessage(json_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expectSentCount(self: *TestContext, expected: usize) !void {
|
||||||
|
try expectEqual(expected, self.client.?.sent.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpectResultOpts = struct {
|
||||||
|
id: ?usize = null,
|
||||||
|
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,
|
||||||
|
.sessionId = opts.session_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serialized = try json.stringifyAlloc(self.arena.allocator(), expected_result, .{
|
||||||
|
.emit_null_optional_fields = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (self.client.?.sent.items, 0..) |sent, i| {
|
||||||
|
if (std.mem.eql(u8, sent, serialized) == false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (opts.index) |expected_index| {
|
||||||
|
if (expected_index != i) {
|
||||||
|
return error.MessageAtWrongIndex;
|
||||||
|
}
|
||||||
|
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("#{d}\n{s}\n\n", .{ i, sent });
|
||||||
|
}
|
||||||
|
return error.MessageNotFound;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn context() TestContext {
|
||||||
|
return .{
|
||||||
|
.arena = std.heap.ArenaAllocator.init(std.testing.allocator),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -592,7 +592,7 @@ fn ClientT(comptime S: type, comptime C: type) type {
|
|||||||
self.server.queueClose(self.socket);
|
self.server.queueClose(self.socket);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
.text, .binary => if (self.cdp.?.processMessage(msg.data) == false) {
|
.text, .binary => if (self.cdp.?.handleMessage(msg.data) == false) {
|
||||||
self.close(null);
|
self.close(null);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -1747,7 +1747,7 @@ const MockCDP = struct {
|
|||||||
self.messages.deinit(allocator);
|
self.messages.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn processMessage(self: *MockCDP, message: []const u8) bool {
|
fn handleMessage(self: *MockCDP, message: []const u8) bool {
|
||||||
const owned = self.allocator.dupe(u8, message) catch unreachable;
|
const owned = self.allocator.dupe(u8, message) catch unreachable;
|
||||||
self.messages.append(self.allocator, owned) catch unreachable;
|
self.messages.append(self.allocator, owned) catch unreachable;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ pub fn main() !void {
|
|||||||
const result = t.func();
|
const result = t.func();
|
||||||
current_test = null;
|
current_test = null;
|
||||||
|
|
||||||
const ns_taken = slowest.endTiming(friendly_name);
|
const ns_taken = slowest.endTiming(friendly_name, is_unnamed_test);
|
||||||
|
|
||||||
if (std.testing.allocator_instance.deinit() == .leak) {
|
if (std.testing.allocator_instance.deinit() == .leak) {
|
||||||
leak += 1;
|
leak += 1;
|
||||||
@@ -227,9 +227,12 @@ const SlowTracker = struct {
|
|||||||
self.timer.reset();
|
self.timer.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn endTiming(self: *SlowTracker, test_name: []const u8) u64 {
|
fn endTiming(self: *SlowTracker, test_name: []const u8, is_unnamed_test: bool) u64 {
|
||||||
var timer = self.timer;
|
var timer = self.timer;
|
||||||
const ns = timer.lap();
|
const ns = timer.lap();
|
||||||
|
if (is_unnamed_test) {
|
||||||
|
return ns;
|
||||||
|
}
|
||||||
|
|
||||||
var slowest = &self.slowest;
|
var slowest = &self.slowest;
|
||||||
|
|
||||||
@@ -377,4 +380,5 @@ test {
|
|||||||
std.testing.refAllDecls(@import("storage/storage.zig"));
|
std.testing.refAllDecls(@import("storage/storage.zig"));
|
||||||
std.testing.refAllDecls(@import("iterator/iterator.zig"));
|
std.testing.refAllDecls(@import("iterator/iterator.zig"));
|
||||||
std.testing.refAllDecls(@import("server.zig"));
|
std.testing.refAllDecls(@import("server.zig"));
|
||||||
|
std.testing.refAllDecls(@import("cdp/cdp.zig"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user