Merge pull request #441 from karlseguin/cdp_tests
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions

Turn CDP into a generic so that mocks can be injected for testing
This commit is contained in:
Pierre Tachoire
2025-02-21 17:49:47 +01:00
committed by GitHub
5 changed files with 480 additions and 269 deletions

View File

@@ -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" });
}

View File

@@ -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
View 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),
};
}

View File

@@ -638,7 +638,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;
}, },
@@ -1792,7 +1792,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;

View File

@@ -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"));
} }