From 99fb82e244aeec21d4fe4b3559dbe206a52accc5 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 21 Feb 2025 13:17:35 +0800 Subject: [PATCH] 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. --- src/cdp/browser.zig | 38 ++++ src/cdp/cdp.zig | 501 ++++++++++++++++++++++---------------------- src/cdp/testing.zig | 170 +++++++++++++++ src/server.zig | 4 +- src/unit_tests.zig | 8 +- 5 files changed, 466 insertions(+), 255 deletions(-) create mode 100644 src/cdp/testing.zig diff --git a/src/cdp/browser.zig b/src/cdp/browser.zig index 72b0f44d..635de3bd 100644 --- a/src/cdp/browser.zig +++ b/src/cdp/browser.zig @@ -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" }); +} diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index cfd65029..dfa7aaa0 100644 --- a/src/cdp/cdp.zig +++ b/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,254 +37,265 @@ pub const TimestampEvent = struct { timestamp: f64, }; -pub const CDP = struct { - // Used for sending message to the client and closing on error - client: *Client, - - // The active browser - browser: Browser, - - // The active browser session - session: ?*Session, - - allocator: Allocator, - - // Re-used arena for processing a message. We're assuming that we're getting - // 1 message at a time. - message_arena: std.heap.ArenaAllocator, - - // State - url: []const u8, - frame_id: []const u8, - loader_id: []const u8, - session_id: SessionID, - context_id: ?[]const u8, - execution_context_id: u32, - security_origin: []const u8, - page_life_cycle_events: bool, - secure_context_type: []const u8, - node_list: dom.NodeList, - node_search_list: dom.NodeSearchList, - - pub fn init(allocator: Allocator, client: *Client, loop: *Loop) CDP { - return .{ - .client = client, - .browser = Browser.init(allocator, loop), - .session = null, - .allocator = allocator, - .url = URL_BASE, - .execution_context_id = 0, - .context_id = null, - .frame_id = FRAME_ID, - .session_id = .CONTEXTSESSIONID0497A05C95417CF4, - .security_origin = URL_BASE, - .secure_context_type = "Secure", // TODO = enum - .loader_id = LOADER_ID, - .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 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: *TypeProvider.Client, + + // The active browser + browser: Browser, + + // The active browser session + session: ?*Session, + + allocator: Allocator, + + // Re-used arena for processing a message. We're assuming that we're getting + // 1 message at a time. + message_arena: std.heap.ArenaAllocator, + + // State + url: []const u8, + frame_id: []const u8, + loader_id: []const u8, + session_id: SessionID, + context_id: ?[]const u8, + execution_context_id: u32, + security_origin: []const u8, + page_life_cycle_events: bool, + secure_context_type: []const u8, + node_list: dom.NodeList, + node_search_list: dom.NodeSearchList, - pub fn deinit(self: *CDP) void { - self.node_list.deinit(); - for (self.node_search_list.items) |*s| { - s.deinit(); + 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), + .session = null, + .allocator = allocator, + .url = URL_BASE, + .execution_context_id = 0, + .context_id = null, + .frame_id = FRAME_ID, + .session_id = .CONTEXTSESSIONID0497A05C95417CF4, + .security_origin = URL_BASE, + .secure_context_type = "Secure", // TODO = enum + .loader_id = LOADER_ID, + .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), + }; } - self.node_search_list.deinit(); - self.browser.deinit(); - self.message_arena.deinit(); - } + pub fn deinit(self: *Self) void { + self.node_list.deinit(); + for (self.node_search_list.items) |*s| { + s.deinit(); + } + self.node_search_list.deinit(); - pub fn reset(self: *CDP) void { - self.node_list.reset(); + self.browser.deinit(); + self.message_arena.deinit(); + } + + pub fn reset(self: *Self) void { + self.node_list.reset(); - // deinit all node searches. - for (self.node_search_list.items) |*s| { - s.deinit(); + // deinit all node searches. + for (self.node_search_list.items) |*s| { + s.deinit(); + } + self.node_search_list.clearAndFree(); } - self.node_search_list.clearAndFree(); - } - pub fn newSession(self: *CDP) !void { - self.session = try self.browser.newSession(self); - } + 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 }); + 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; + } - self.dispatch(arena.allocator(), self, 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, .{ - .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; + // 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 ..] }; }; - 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.?; - }, - }; + 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), + 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 => {}, - }, - 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; } - return error.UnknownDomain; - } - fn sendJSON(self: *CDP, 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":,... - std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":")); + fn sendJSON(self: *Self, message: anytype) !void { + return self.client.sendJSON(message, .{ + .emit_null_optional_fields = false, + }); + } - const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse { - log.warn("invalid inspector response message: {s}", .{msg}); - return; + pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"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}); }; - 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":,... - 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; + pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void { + if (std.log.defaultLogEnabled(.debug)) { + // msg should be {"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}); }; - 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(); + // 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); + 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 + // + 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; + 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; - }; + 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 }); + // 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); + // -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); - } -}; + 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", + })); +} diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig new file mode 100644 index 00000000..889b8c1f --- /dev/null +++ b/src/cdp/testing.zig @@ -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), + }; +} diff --git a/src/server.zig b/src/server.zig index 7ed8dc53..f5f4cf3a 100644 --- a/src/server.zig +++ b/src/server.zig @@ -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; diff --git a/src/unit_tests.zig b/src/unit_tests.zig index 94c10913..01de391f 100644 --- a/src/unit_tests.zig +++ b/src/unit_tests.zig @@ -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")); }