// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Allocator = std.mem.Allocator; pub const allocator = std.testing.allocator; pub const expectError = std.testing.expectError; pub const expect = std.testing.expect; pub const expectString = std.testing.expectEqualStrings; pub const expectEqualSlices = std.testing.expectEqualSlices; // sometimes it's super useful to have an arena you don't really care about // in a test. Like, you need a mutable string, so you just want to dupe a // string literal. It has nothing to do with the code under test, it's just // infrastructure for the test itself. pub var arena_instance = std.heap.ArenaAllocator.init(std.heap.c_allocator); pub const arena_allocator = arena_instance.allocator(); pub fn reset() void { _ = arena_instance.reset(.retain_capacity); } const App = @import("App.zig"); const js = @import("browser/js/js.zig"); const Browser = @import("browser/Browser.zig"); const Session = @import("browser/Session.zig"); const Page = @import("browser/Page.zig"); // Merged std.testing.expectEqual and std.testing.expectString // can be useful when testing fields of an anytype an you don't know // exactly how to assert equality pub fn expectEqual(expected: anytype, actual: anytype) !void { switch (@typeInfo(@TypeOf(actual))) { .array => |arr| if (arr.child == u8) { return std.testing.expectEqualStrings(expected, &actual); }, .pointer => |ptr| { if (ptr.child == u8) { return std.testing.expectEqualStrings(expected, actual); } else if (comptime isStringArray(ptr.child)) { return std.testing.expectEqualStrings(expected, actual); } else if (ptr.child == []u8 or ptr.child == []const u8) { return expectString(expected, actual); } }, .@"struct" => |structType| { inline for (structType.fields) |field| { try expectEqual(@field(expected, field.name), @field(actual, field.name)); } return; }, .optional => { if (@typeInfo(@TypeOf(expected)) == .null) { return std.testing.expectEqual(null, actual); } if (actual) |_actual| { return expectEqual(expected, _actual); } return std.testing.expectEqual(expected, null); }, .@"union" => |union_info| { if (union_info.tag_type == null) { @compileError("Unable to compare untagged union values"); } const Tag = std.meta.Tag(@TypeOf(expected)); const expectedTag = @as(Tag, expected); const actualTag = @as(Tag, actual); try expectEqual(expectedTag, actualTag); inline for (std.meta.fields(@TypeOf(actual))) |fld| { if (std.mem.eql(u8, fld.name, @tagName(actualTag))) { try expectEqual(@field(expected, fld.name), @field(actual, fld.name)); return; } } unreachable; }, else => {}, } return std.testing.expectEqual(expected, actual); } pub fn expectDelta(expected: anytype, actual: anytype, delta: anytype) !void { if (@typeInfo(@TypeOf(expected)) == .null) { return std.testing.expectEqual(null, actual); } switch (@typeInfo(@TypeOf(actual))) { .optional => { if (actual) |value| { return expectDelta(expected, value, delta); } return std.testing.expectEqual(null, expected); }, else => {}, } switch (@typeInfo(@TypeOf(expected))) { .optional => { if (expected) |value| { return expectDelta(value, actual, delta); } return std.testing.expectEqual(null, actual); }, else => {}, } var diff = expected - actual; if (diff < 0) { diff = -diff; } if (diff <= delta) { return; } print("Expected {} to be within {} of {}. Actual diff: {}", .{ expected, delta, actual, diff }); return error.NotWithinDelta; } fn isStringArray(comptime T: type) bool { if (!is(.array)(T) and !isPtrTo(.array)(T)) { return false; } return std.meta.Elem(T) == u8; } pub const TraitFn = fn (type) bool; pub fn is(comptime id: std.builtin.TypeId) TraitFn { const Closure = struct { pub fn trait(comptime T: type) bool { return id == @typeInfo(T); } }; return Closure.trait; } pub fn isPtrTo(comptime id: std.builtin.TypeId) TraitFn { const Closure = struct { pub fn trait(comptime T: type) bool { if (!comptime isSingleItemPtr(T)) return false; return id == @typeInfo(std.meta.Child(T)); } }; return Closure.trait; } pub fn isSingleItemPtr(comptime T: type) bool { if (comptime is(.pointer)(T)) { return @typeInfo(T).pointer.size == .one; } return false; } pub fn print(comptime fmt: []const u8, args: anytype) void { if (@inComptime()) { @compileError(std.fmt.comptimePrint(fmt, args)); } else { std.debug.print(fmt, args); } } pub const Random = struct { var instance: ?std.Random.DefaultPrng = null; pub fn fill(buf: []u8) void { var r = random(); r.bytes(buf); } pub fn fillAtLeast(buf: []u8, min: usize) []u8 { var r = random(); const l = r.intRangeAtMost(usize, min, buf.len); r.bytes(buf[0..l]); return buf; } pub fn intRange(comptime T: type, min: T, max: T) T { var r = random(); return r.intRangeAtMost(T, min, max); } pub fn random() std.Random { if (instance == null) { var seed: u64 = undefined; std.posix.getrandom(std.mem.asBytes(&seed)) catch unreachable; instance = std.Random.DefaultPrng.init(seed); // instance = std.Random.DefaultPrng.init(0); } return instance.?.random(); } }; pub fn expectJson(a: anytype, b: anytype) !void { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const aa = arena.allocator(); const a_value = try convertToJson(aa, a); const b_value = try convertToJson(aa, b); errdefer { const a_json = std.json.Stringify.valueAlloc(aa, a_value, .{ .whitespace = .indent_2 }) catch unreachable; const b_json = std.json.Stringify.valueAlloc(aa, b_value, .{ .whitespace = .indent_2 }) catch unreachable; std.debug.print("== Expected ==\n{s}\n\n== Actual ==\n{s}", .{ a_json, b_json }); } try expectJsonValue(a_value, b_value); } pub fn isEqualJson(a: anytype, b: anytype) !bool { var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const aa = arena.allocator(); const a_value = try convertToJson(aa, a); const b_value = try convertToJson(aa, b); return isJsonValue(a_value, b_value); } fn convertToJson(arena: Allocator, value: anytype) !std.json.Value { const T = @TypeOf(value); if (T == std.json.Value) { return value; } var str: []const u8 = undefined; if (T == []u8 or T == []const u8 or comptime isStringArray(T)) { str = value; } else { str = try std.json.Stringify.valueAlloc(arena, value, .{}); } return std.json.parseFromSliceLeaky(std.json.Value, arena, str, .{}); } fn expectJsonValue(a: std.json.Value, b: std.json.Value) !void { try expectEqual(@tagName(a), @tagName(b)); // at this point, we know that if a is an int, b must also be an int switch (a) { .null => return, .bool => try expectEqual(a.bool, b.bool), .integer => try expectEqual(a.integer, b.integer), .float => try expectEqual(a.float, b.float), .number_string => try expectEqual(a.number_string, b.number_string), .string => try expectEqual(a.string, b.string), .array => { const a_len = a.array.items.len; const b_len = b.array.items.len; try expectEqual(a_len, b_len); for (a.array.items, b.array.items) |a_item, b_item| { try expectJsonValue(a_item, b_item); } }, .object => { var it = a.object.iterator(); while (it.next()) |entry| { const key = entry.key_ptr.*; if (b.object.get(key)) |b_item| { try expectJsonValue(entry.value_ptr.*, b_item); } else { return error.MissingKey; } } }, } } fn isJsonValue(a: std.json.Value, b: std.json.Value) bool { if (std.mem.eql(u8, @tagName(a), @tagName(b)) == false) { return false; } // at this point, we know that if a is an int, b must also be an int switch (a) { .null => return true, .bool => return a.bool == b.bool, .integer => return a.integer == b.integer, .float => return a.float == b.float, .number_string => return std.mem.eql(u8, a.number_string, b.number_string), .string => return std.mem.eql(u8, a.string, b.string), .array => { const a_len = a.array.items.len; const b_len = b.array.items.len; if (a_len != b_len) { return false; } for (a.array.items, b.array.items) |a_item, b_item| { if (isJsonValue(a_item, b_item) == false) { return false; } } return true; }, .object => { var it = a.object.iterator(); while (it.next()) |entry| { const key = entry.key_ptr.*; if (b.object.get(key)) |b_item| { if (isJsonValue(entry.value_ptr.*, b_item) == false) { return false; } } else { return false; } } return true; }, } } var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; pub var test_app: *App = undefined; pub var test_browser: Browser = undefined; pub var test_session: *Session = undefined; const WEB_API_TEST_ROOT = "src/browser/tests/"; const WEB_API_HELPER_PATH = WEB_API_TEST_ROOT ++ "testing.js"; const HtmlRunnerOpts = struct {}; pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { _ = opts; defer reset(); const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path }); const stat = std.fs.cwd().statFile(root) catch |err| { std.debug.print("Failed to stat file: '{s}'", .{root}); return err; }; switch (stat.kind) { .file => { if (@import("root").shouldRun(std.fs.path.basename(root)) == false) { return; } try @import("root").subtest(root); try runWebApiTest(root); }, .directory => { var dir = try std.fs.cwd().openDir(root, .{ .iterate = true, .no_follow = true, .access_sub_paths = false, }); defer dir.close(); var it = dir.iterateAssumeFirstIteration(); while (try it.next()) |entry| { if (entry.kind != .file) { continue; } if (!std.mem.endsWith(u8, entry.name, ".html")) { continue; } if (@import("root").shouldRun(entry.name) == false) { continue; } const full_path = try std.fs.path.joinZ(arena_allocator, &.{ root, entry.name }); try @import("root").subtest(entry.name); try runWebApiTest(full_path); } }, else => |kind| { std.debug.print("Unknown file type: {s} for {s}\n", .{ @tagName(kind), root }); return error.InvalidTestPath; }, } } fn runWebApiTest(test_file: [:0]const u8) !void { const page = try test_session.createPage(); defer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, "http://127.0.0.1:9582/{s}", .{test_file}, 0, ); const js_context = page.js; var try_catch: js.TryCatch = undefined; try_catch.init(js_context); defer try_catch.deinit(); try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); page._session.browser.runMicrotasks(); js_context.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const msg = try_catch.err(arena_allocator) catch @errorName(err) orelse "unknown"; std.debug.print("{s}: test failure\nError: {s}\n", .{ test_file, msg }); return err; }; } // Used by a few CDP tests - wouldn't be sad to see this go. pub fn pageTest(comptime test_file: []const u8) !*Page { const page = try test_session.createPage(); errdefer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, "http://127.0.0.1:9582/{s}{s}", .{ WEB_API_TEST_ROOT, test_file }, 0, ); try page.navigate(url, .{}, .{ .push = null }); test_session.fetchWait(2000); return page; } test { std.testing.refAllDecls(@This()); } const log = @import("log.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); const Server = @import("Server.zig"); var test_cdp_server: ?Server = null; var test_http_server: ?TestHTTPServer = null; test "tests:beforeAll" { log.opts.level = .warn; log.opts.format = .pretty; test_app = try App.init(gpa.allocator(), .{ .run_mode = .serve, .tls_verify_host = false, .user_agent = "User-Agent: Lightpanda/1.0 internal-tester", }); errdefer test_app.deinit(); test_browser = try Browser.init(test_app); errdefer test_browser.deinit(); test_session = try test_browser.newSession(); var wg: std.Thread.WaitGroup = .{}; wg.startMany(2); { const thread = try std.Thread.spawn(.{}, serveCDP, .{&wg}); thread.detach(); } test_http_server = TestHTTPServer.init(testHTTPHandler); { const thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg }); thread.detach(); } // need to wait for the servers to be listening, else tests will fail because // they aren't able to connect. wg.wait(); } test "tests:afterAll" { if (test_cdp_server) |*server| { server.deinit(); } if (test_http_server) |*server| { server.deinit(); } test_browser.deinit(); test_app.deinit(); } fn serveCDP(wg: *std.Thread.WaitGroup) !void { const address = try std.net.Address.parseIp("127.0.0.1", 9583); test_cdp_server = try Server.init(test_app, address); var server = try Server.init(test_app, address); defer server.deinit(); wg.finish(); test_cdp_server.?.run(address, 5) catch |err| { std.debug.print("CDP server error: {}", .{err}); return err; }; } fn testHTTPHandler(req: *std.http.Server.Request) !void { const path = req.head.target; if (std.mem.eql(u8, path, "/xhr")) { return req.respond("1234567890" ** 10, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/html; charset=utf-8" }, }, }); } if (std.mem.eql(u8, path, "/xhr/json")) { return req.respond("{\"over\":\"9000!!!\"}", .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "application/json" }, }, }); } if (std.mem.eql(u8, path, "/xhr/redirect")) { return req.respond("", .{ .status = .found, .extra_headers = &.{ .{ .name = "Location", .value = "http://127.0.0.1:9582/xhr" }, }, }); } if (std.mem.eql(u8, path, "/xhr/404")) { return req.respond("Not Found", .{ .status = .not_found, .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/plain" }, }, }); } if (std.mem.eql(u8, path, "/xhr/500")) { return req.respond("Internal Server Error", .{ .status = .internal_server_error, .extra_headers = &.{ .{ .name = "Content-Type", .value = "text/plain" }, }, }); } if (std.mem.startsWith(u8, path, "/src/browser/tests/")) { // strip off leading / so that it's relative to CWD return TestHTTPServer.sendFile(req, path[1..]); } std.debug.print("TestHTTPServer was asked to serve an unknown file: {s}\n", .{path}); unreachable; }