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 {
|
||||
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" });
|
||||
}
|
||||
|
||||
131
src/cdp/cdp.zig
131
src/cdp/cdp.zig
@@ -22,10 +22,8 @@ const json = std.json;
|
||||
|
||||
const dom = @import("dom.zig");
|
||||
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 Browser = @import("../browser/browser.zig").Browser;
|
||||
const Session = @import("../browser/browser.zig").Session;
|
||||
|
||||
const log = std.log.scoped(.cdp);
|
||||
|
||||
@@ -39,9 +37,17 @@ pub const TimestampEvent = struct {
|
||||
timestamp: f64,
|
||||
};
|
||||
|
||||
pub const CDP = struct {
|
||||
pub const CDP = CDPT(struct {
|
||||
const Client = @import("../server.zig").Client;
|
||||
const Browser = @import("../browser/browser.zig").Browser;
|
||||
const Session = @import("../browser/browser.zig").Session;
|
||||
});
|
||||
|
||||
// Generic so that we can inject mocks into it.
|
||||
pub fn CDPT(comptime TypeProvider: type) type {
|
||||
return struct {
|
||||
// Used for sending message to the client and closing on error
|
||||
client: *Client,
|
||||
client: *TypeProvider.Client,
|
||||
|
||||
// The active browser
|
||||
browser: Browser,
|
||||
@@ -68,7 +74,11 @@ pub const CDP = struct {
|
||||
node_list: dom.NodeList,
|
||||
node_search_list: dom.NodeSearchList,
|
||||
|
||||
pub fn init(allocator: Allocator, client: *Client, loop: *Loop) CDP {
|
||||
const Self = @This();
|
||||
pub const Browser = TypeProvider.Browser;
|
||||
pub const Session = TypeProvider.Session;
|
||||
|
||||
pub fn init(allocator: Allocator, client: *TypeProvider.Client, loop: anytype) Self {
|
||||
return .{
|
||||
.client = client,
|
||||
.browser = Browser.init(allocator, loop),
|
||||
@@ -89,7 +99,7 @@ pub const CDP = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CDP) void {
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.node_list.deinit();
|
||||
for (self.node_search_list.items) |*s| {
|
||||
s.deinit();
|
||||
@@ -100,7 +110,7 @@ pub const CDP = struct {
|
||||
self.message_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn reset(self: *CDP) void {
|
||||
pub fn reset(self: *Self) void {
|
||||
self.node_list.reset();
|
||||
|
||||
// deinit all node searches.
|
||||
@@ -110,33 +120,31 @@ pub const CDP = struct {
|
||||
self.node_search_list.clearAndFree();
|
||||
}
|
||||
|
||||
pub fn newSession(self: *CDP) !void {
|
||||
pub fn newSession(self: *Self) !void {
|
||||
self.session = try self.browser.newSession(self);
|
||||
}
|
||||
|
||||
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| {
|
||||
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.
|
||||
pub fn dispatch(
|
||||
self: *CDP,
|
||||
arena: Allocator,
|
||||
sender: anytype,
|
||||
str: []const u8,
|
||||
) anyerror!void {
|
||||
const input = try json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
||||
// 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;
|
||||
@@ -146,7 +154,7 @@ pub const CDP = struct {
|
||||
break :blk .{ method[0..i], method[i + 1 ..] };
|
||||
};
|
||||
|
||||
var command = Command(@TypeOf(sender)){
|
||||
var command = Command(Self, @TypeOf(sender)){
|
||||
.json = str,
|
||||
.cdp = self,
|
||||
.id = input.id,
|
||||
@@ -204,7 +212,7 @@ pub const CDP = struct {
|
||||
return error.UnknownDomain;
|
||||
}
|
||||
|
||||
fn sendJSON(self: *CDP, message: anytype) !void {
|
||||
fn sendJSON(self: *Self, message: anytype) !void {
|
||||
return self.client.sendJSON(message, .{
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
@@ -247,7 +255,7 @@ pub const CDP = struct {
|
||||
// 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 {
|
||||
fn sendInspectorMessage(self: *Self, msg: []const u8) !void {
|
||||
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
||||
errdefer arena.deinit();
|
||||
|
||||
@@ -277,16 +285,17 @@ pub const CDP = struct {
|
||||
|
||||
try self.client.sendJSONRaw(arena, buf);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
// we want to capture the result. So we want the command.sendResult to be
|
||||
// generic.
|
||||
pub fn Command(comptime Sender: type) type {
|
||||
pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
|
||||
return struct {
|
||||
// refernece to our CDP instance
|
||||
cdp: *CDP,
|
||||
// reference to our CDP instance
|
||||
cdp: *CDP_T,
|
||||
|
||||
// Comes directly from the input.id field
|
||||
id: ?i64,
|
||||
@@ -296,7 +305,7 @@ pub fn Command(comptime Sender: type) type {
|
||||
arena: Allocator,
|
||||
|
||||
// the browser session
|
||||
session: *Session,
|
||||
session: *CDP_T.Session,
|
||||
|
||||
// The "action" of the message.Given a method of "LOG.enable", the
|
||||
// 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
|
||||
// we always expect
|
||||
const InputMessage = struct {
|
||||
id: ?i64,
|
||||
id: ?i64 = null,
|
||||
method: []const u8,
|
||||
params: ?InputParams = 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
|
||||
// ------
|
||||
|
||||
@@ -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);
|
||||
return false;
|
||||
},
|
||||
.text, .binary => if (self.cdp.?.processMessage(msg.data) == false) {
|
||||
.text, .binary => if (self.cdp.?.handleMessage(msg.data) == false) {
|
||||
self.close(null);
|
||||
return false;
|
||||
},
|
||||
@@ -1747,7 +1747,7 @@ const MockCDP = struct {
|
||||
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;
|
||||
self.messages.append(self.allocator, owned) catch unreachable;
|
||||
return true;
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn main() !void {
|
||||
const result = t.func();
|
||||
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) {
|
||||
leak += 1;
|
||||
@@ -227,9 +227,12 @@ const SlowTracker = struct {
|
||||
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;
|
||||
const ns = timer.lap();
|
||||
if (is_unnamed_test) {
|
||||
return ns;
|
||||
}
|
||||
|
||||
var slowest = &self.slowest;
|
||||
|
||||
@@ -377,4 +380,5 @@ test {
|
||||
std.testing.refAllDecls(@import("storage/storage.zig"));
|
||||
std.testing.refAllDecls(@import("iterator/iterator.zig"));
|
||||
std.testing.refAllDecls(@import("server.zig"));
|
||||
std.testing.refAllDecls(@import("cdp/cdp.zig"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user