Files
browser/src/testing.zig
T
Karl Seguin 766c0d05d6 bidi: protocol selection CLI, start of [classic] WebDriver
In order to support Selenium the way people are used to, it looks  like we need
to support both WebDriver classic (WebDriver) and WebDriver BiDi (BiDi). Typical
scripts look like a mix of the two, e.g. using WebDriver to control the browser
and using BiDi  to receive notifications. This commit:

1 - adds a --protocol (cdp|webdriver) CLI argument to the `serve` command to
    enable one or the other protocol (defaulting to CDP)

2 - adds basic WebDriver endpoint to let a Selenium client connect. This
    implementation is hackish and sits on top of our simple Handshake handler.

The handshake handler is well past its original design. Serving /json/version
and /metrics from it was one thing. But Driving the entire browser session? This
will get a follow up PR.
2026-08-31 22:45:15 +08:00

1259 lines
43 KiB
Zig

// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const Allocator = std.mem.Allocator;
pub const io = std.testing.io;
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 Config = @import("Config.zig");
const Frame = @import("browser/Frame.zig");
const Browser = @import("browser/Browser.zig");
const Session = @import("browser/Session.zig");
const Notification = @import("Notification.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;
io.random(std.mem.asBytes(&seed));
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;
},
}
}
pub var test_app: *App = undefined;
pub var test_browser: Browser = undefined;
pub var test_notification: *Notification = undefined;
pub var test_session: *Session = undefined;
const WEB_API_TEST_ROOT = "src/browser/tests/";
const HtmlRunnerOpts = struct {
timeout_ms: u32 = 2000,
inject_script: ?[]const u8 = null,
load_resources: Config.LoadResources = .{
.worker = true,
.iframe = true,
},
};
// Create a fresh page on `test_session` and return its root frame — for tests
// that just need a frame to build a DOM in. The page lives on the session;
// release it with `defer testing.test_session.closeAllPages()`.
pub fn createFrame() !*Frame {
return (try test_session.createPage()).frame().?;
}
pub fn waitForFrame() !void {
var runner = test_session.runner(.{});
const frame_id = test_session.pages.items[0].frame._frame_id;
return runner.waitForFrame(frame_id, 2000, .{ .until = .done });
}
pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void {
defer reset();
var inject_scripts: [1][]const u8 = undefined;
if (opts.inject_script) |script| {
inject_scripts[0] = script;
test_session.inject_scripts = inject_scripts[0..1];
}
defer test_session.inject_scripts = &.{};
test_session.load_resources = opts.load_resources;
defer test_session.load_resources = .{
// original defaults, tests expect these to be on
.worker = true,
.iframe = true,
};
const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path });
const stat = std.Io.Dir.cwd().statFile(io, 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, opts.timeout_ms);
},
.directory => {
var dir = try std.Io.Dir.cwd().openDir(io, root, .{
.iterate = true,
.follow_symlinks = false,
.access_sub_paths = false,
});
defer dir.close(io);
var it = dir.iterateAssumeFirstIteration();
while (try it.next(io)) |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, opts.timeout_ms);
}
},
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, timeout_ms: u32) !void {
const page = try test_session.createPage();
defer page.close();
const url = try std.fmt.allocPrintSentinel(
arena_allocator,
"http://127.0.0.1:9582/{s}",
.{test_file},
0,
);
const frame = page.frame().?;
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
{
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
try frame.navigate(url, .{});
}
var runner = test_session.runner(.{});
try runner.waitForFrame(page.frame_id, 2000, .{ .until = .load });
var wait_ms: u32 = timeout_ms;
var timer: std.Io.Timestamp = .now(lp.io, .boot);
while (true) {
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const js_val = ls.local.exec("testing.assertOk()", "testing.assertOk()") catch |err| {
const caught = try_catch.caughtOrError(arena_allocator, err);
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
return err;
};
if (js_val.isTrue()) {
return;
}
const sleep_ms: usize = switch (try runner.tickForFrame(page.frame_id, 20, .{ .until = .done })) {
.done => 20,
.ok => |next_ms| @min(next_ms, 20),
};
const lap: std.Io.Timestamp = .now(lp.io, .boot);
const ms_elapsed: u64 = @intCast(timer.durationTo(lap).toMilliseconds());
timer = lap;
if (ms_elapsed >= wait_ms) {
ls.local.eval("testing.printTimeoutState()", "testing.printTimeoutState()") catch {};
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);
lp.io.sleep(.fromMilliseconds(@intCast(sleep_ms)), .awake) catch {};
}
}
const PageTestOpts = struct {
wait_until_done: bool = true,
};
pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !Session.PageHandle {
const page = try test_session.createPage();
errdefer page.close();
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, .{});
if (opts.wait_until_done) {
var runner = test_session.runner(.{});
try runner.waitForFrame(page.frame_id, 2000, .{ .until = .done });
}
return page;
}
const Server = @import("server/Server.zig");
const TestWSServer = @import("TestWSServer.zig");
const TestHTTPServer = @import("TestHTTPServer.zig");
pub var test_cdp_server: ?*Server = null;
var test_cdp_server_thread: ?std.Thread = null;
var test_http_server: ?TestHTTPServer = null;
var test_http_server_thread: ?std.Thread = null;
var test_ws_server: ?TestWSServer = null;
var test_ws_server_thread: ?std.Thread = null;
// Server-side state for the /sse/* endpoints. sse_flag proves progressive
// delivery (see /sse/streaming); sse_reconnect_hits makes /sse/reconnect
// serve a different stream on the second connection.
var sse_flag = std.atomic.Value(bool).init(false);
var sse_reconnect_hits = std.atomic.Value(usize).init(0);
var test_config: Config = undefined;
test "tests:beforeAll" {
log.opts.level = .warn;
log.opts.format = .pretty;
const test_allocator = @import("root").tracking_allocator;
test_config = try Config.init(test_allocator, "test", .{ .serve = .{
.insecure_disable_tls_host_verification = true,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
.load_resources = .{ .worker = true, .iframe = true },
} });
test_app = try App.init(test_allocator, &test_config);
errdefer test_app.deinit();
try test_browser.init(test_app, .{}, null);
errdefer test_browser.deinit();
// Create notification for testing
test_notification = try Notification.init(test_app.allocator);
errdefer test_notification.deinit();
test_session = try test_browser.newSession(test_notification);
var wg: lp.WaitGroup = .{};
wg.startMany(3);
test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
test_http_server = TestHTTPServer.init(testHTTPHandler);
test_http_server_thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg });
test_ws_server = TestWSServer.init();
test_ws_server_thread = try std.Thread.spawn(.{}, TestWSServer.run, .{ &test_ws_server.?, &wg });
// 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.shutdown();
}
if (test_cdp_server_thread) |thread| {
thread.join();
}
if (test_cdp_server) |server| {
server.deinit();
}
if (test_http_server) |*server| {
server.stop();
}
if (test_http_server_thread) |thread| {
thread.join();
}
if (test_http_server) |*server| {
server.deinit();
}
if (test_ws_server) |*server| {
server.stop();
}
if (test_ws_server_thread) |thread| {
thread.join();
}
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
// Browser must be deinit'd before the notification — Session/Frame
// teardown may unregister notification listeners (e.g. CookieStore
// detach), which dereferences `notification.listeners`.
test_browser.deinit();
test_notification.deinit();
test_app.deinit();
test_config.deinit(@import("root").tracking_allocator);
}
fn serveCDP(wg: *lp.WaitGroup) !void {
const address = try std.Io.net.IpAddress.parse("127.0.0.1", 9583);
test_cdp_server = Server.init(test_app, address) catch |err| {
std.debug.print("CDP server error: {}", .{err});
return err;
};
test_cdp_server.?.protocols = .{ .cdp = true, .webdriver = true };
wg.finish();
test_cdp_server.?.run();
}
// /serve-count/ counters; only ever touched from the test HTTP server thread.
var serve_counts = [_]struct { name: []const u8, count: u32 = 0 }{
.{ .name = "defer" },
.{ .name = "async" },
.{ .name = "dynamic" },
.{ .name = "prescan_blocking" },
.{ .name = "prescan_defer" },
.{ .name = "prescan_module" },
};
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/slow")) {
// Long enough for a timer scheduled by the requester to fire first.
lp.io.sleep(.fromMilliseconds(100), .awake) catch {};
return req.respond("slow", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/xhr_empty")) {
return req.respond("", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
const xhr_xml_body = "<?xml version=\"1.0\"?><catalog><item id=\"a\"/><item id=\"b\"/><pubDate>2026</pubDate></catalog>";
if (std.mem.eql(u8, path, "/xhr/xml")) {
return req.respond(xhr_xml_body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/xml" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/xml_as_text")) {
return req.respond(xhr_xml_body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/json")) {
return req.respond("{\"over\":\"9000!!!\",\"updated_at\":1765867200000}", .{
.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, "/redirect-no-fragment")) {
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/redirect-target" },
},
});
}
if (std.mem.eql(u8, path, "/redirect-cross-origin-x-hop")) {
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "http://localhost:9582/echo-x-hop" },
},
});
}
if (std.mem.eql(u8, path, "/echo-x-hop")) {
var it = req.iterateHeaders();
var value: []const u8 = "NONE";
while (it.next()) |header| {
if (std.ascii.eqlIgnoreCase(header.name, "x-hop")) {
value = header.value;
break;
}
}
return req.respond(value, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/redirect-target")) {
return req.respond("<!DOCTYPE html><title>landed</title>", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/303-no-location")) {
// 3xx WITHOUT a Location header: not a redirect, a final response
// whose body must be delivered (RFC 9110 §15.4).
return req.respond("<!DOCTYPE html><title>landed</title><p>see other body</p>", .{
.status = .see_other,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/300-with-location")) {
// A non-redirect 3xx (fetch's redirect statuses are only 301, 302,
// 303, 307 and 308) carrying a Location header: the header is a
// preference hint, not a redirect — the body must be delivered.
return req.respond("<!DOCTYPE html><title>choices</title><p>multiple choices body</p>", .{
.status = .multiple_choice,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
.{ .name = "Location", .value = "http://127.0.0.1:9582/hi.html" },
},
});
}
if (std.mem.eql(u8, path, "/redirect-with-fragment")) {
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/redirect-target#target_fragment" },
},
});
}
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, "/src/browser/tests/401")) {
return req.respond("No", .{
.status = .unauthorized,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/404.js")) {
// Valid JS body served with a 404 status. Used to assert that
// ScriptManager does NOT execute the body of a failed script
// fetch — if it did, window.__404_body_executed would be set.
return req.respond("window.__404_body_executed = true;", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/javascript" },
},
});
}
if (std.mem.eql(u8, path, "/defer-marker.js")) {
// A deferred script body. Used by the regression test for a
// <script defer> whose completion is deferred by a later
// <link rel=stylesheet>'s synchronous fetch — it must still execute.
return req.respond("window.__deferRan = true;", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/javascript" },
},
});
}
if (std.mem.startsWith(u8, path, "/serve-count/") and std.mem.endsWith(u8, path, ".js")) {
// Serves `window.__serve_count_<name> = N;` where N counts how many
// times this URL has been served. Lets a fixture assert a preloaded
// script was fetched exactly once: the body the consumer executes
// carries N == 1, while a duplicate fetch would execute N == 2.
// no-store so the second fetch can't be satisfied by the HTTP cache.
const name = path["/serve-count/".len .. path.len - ".js".len];
const slot: *u32 = blk: {
for (&serve_counts) |*sc| {
if (std.mem.eql(u8, sc.name, name)) break :blk &sc.count;
}
return req.respond("unknown counter", .{ .status = .not_found });
};
slot.* += 1;
var buf: [64]u8 = undefined;
const body = try std.fmt.bufPrint(&buf, "window.__serve_count_{s} = {d};", .{ name, slot.* });
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/javascript" },
.{ .name = "Cache-Control", .value = "no-store" },
},
});
}
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.eql(u8, path, "/xhr/no_content_type")) {
return req.respond("untyped", .{});
}
if (std.mem.eql(u8, path, "/xhr/binary")) {
return req.respond(&.{ 0, 0, 1, 2, 0, 0, 9 }, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/octet-stream" },
},
});
}
if (std.mem.eql(u8, path, "/sse/simple")) {
// A complete stream: default + custom event types, multi-line data,
// an id, and a comment. The connection closes right after; the test
// close()s before the (1s) reconnect fires.
return req.respond("retry: 1000\ndata: first\n\n: comment\nid: 42\nevent: custom\ndata: a\ndata: b\n\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
});
}
if (std.mem.eql(u8, path, "/sse/streaming")) {
sse_flag.store(false, .release);
var send_buffer: [1024]u8 = undefined;
var res = try req.respondStreaming(&send_buffer, .{
.respond_options = .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
},
});
try res.writer.writeAll("data: first\n\n");
try res.writer.flush();
try res.flush();
// Proof of progressive delivery: "second" is only sent once the
// client has reacted to "first" (by fetching /sse/flag), which it
// can only do if "first" was delivered while this response was
// still streaming.
var waited: usize = 0;
while (!sse_flag.load(.acquire) and waited < 5000) : (waited += 10) {
lp.io.sleep(.fromMilliseconds(10), .awake) catch {};
}
if (sse_flag.load(.acquire)) {
// split mid-line to exercise buffering across chunks
try res.writer.writeAll("data: sec");
try res.writer.flush();
try res.flush();
lp.io.sleep(.fromMilliseconds(20), .awake) catch {};
try res.writer.writeAll("ond\n\n");
try res.writer.flush();
try res.flush();
}
return res.end();
}
if (std.mem.eql(u8, path, "/sse/flag")) {
sse_flag.store(true, .release);
return req.respond("", .{});
}
if (std.mem.eql(u8, path, "/sse/reconnect")) {
if (sse_reconnect_hits.fetchAdd(1, .monotonic) == 0) {
return req.respond("retry: 10\ndata: one\n\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
});
}
return req.respond("data: two\n\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
});
}
if (std.mem.eql(u8, path, "/sse/long_retry")) {
return req.respond("retry: 60000\ndata: x\n\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
});
}
if (std.mem.eql(u8, path, "/sse/404")) {
return req.respond("data: x\n\n", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/event-stream" },
},
});
}
if (std.mem.eql(u8, path, "/sse/badmime")) {
return req.respond("data: x\n\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/styles/visibility.css")) {
// Used by css/external_stylesheet.html — drives the visibility
// cascade through StyleManager via Frame.loadExternalStylesheet
// so a `.ext-hide` element is observable to checkVisibility().
return req.respond(".ext-hide { display: none; }", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/css" },
},
});
}
if (std.mem.eql(u8, path, "/styles/visibility2.css")) {
// Second visibility sheet used by the href-change regression test:
// mutating link.href must replace the cached sheet's rules in place,
// not append a new entry to document.styleSheets.
return req.respond(".ext-hide-2 { display: none; }", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/css" },
},
});
}
if (std.mem.eql(u8, path, "/styles/404.css")) {
return req.respond("/* unused */", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/css" },
},
});
}
if (std.mem.eql(u8, path, "/styles/oversize.css")) {
// Body that exceeds Frame.MAX_STYLESHEET_BYTES (2 MiB) — written as a
// long sequence of valid declarations so the response itself parses
// fine and the error path is exercised by the size cap, not by a
// CSS parse failure.
const chunk = ".pad { color: #abcdef; } "; // 25 bytes
const repeats = (2 * 1024 * 1024 / chunk.len) + 1024;
var body = try std.ArrayList(u8).initCapacity(arena_allocator, chunk.len * repeats);
for (0..repeats) |_| body.appendSliceAssumeCapacity(chunk);
return req.respond(body.items, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/css" },
},
});
}
// Bodies are non-empty so that libcurl always reaches the write callback,
// which is where a headers_only request decides whether to drain or abort.
// ok.png takes the abort branch, small.png the drain branch; both must
// behave identically as far as the DOM is concerned.
if (std.mem.eql(u8, path, "/images/ok.png")) {
// > HttpClient.Request.HEADERS_ONLY_DRAIN_MAX
const body = try arena_allocator.alloc(u8, 16 * 1024 + 1);
@memset(body, 'x');
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "image/png" },
},
});
}
// startsWith, not eql: a caller can append a query string to get distinct
// URLs (and so distinct transfers) off this one route.
if (std.mem.startsWith(u8, path, "/images/small.png")) {
const body = try arena_allocator.alloc(u8, 1024);
@memset(body, 'x');
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "image/png" },
},
});
}
// No Content-Length: whether the body is small enough to drain can only
// be decided as it arrives.
if (std.mem.eql(u8, path, "/images/chunked.png")) {
var send_buffer: [1024]u8 = undefined;
var res = try req.respondStreaming(&send_buffer, .{
.respond_options = .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "image/svg+xml" },
},
},
});
try res.writer.writeAll("<svg xmlns=\"http://www.w3.org/2000/svg\"/>");
try res.writer.flush();
return res.end();
}
if (std.mem.eql(u8, path, "/images/404.png")) {
return req.respond("not here", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/images/500.png")) {
return req.respond("boom", .{
.status = .internal_server_error,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/images/redirect.png")) {
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/images/ok.png" },
},
});
}
if (std.mem.eql(u8, path, "/echo_referer")) {
// Echo the request's Referer header back as HTML so tests can assert
// what Referer the navigation sent. Used by the cross-page Referer test.
var it = req.iterateHeaders();
var referer: []const u8 = "NONE";
while (it.next()) |h| {
if (std.ascii.eqlIgnoreCase(h.name, "Referer")) {
referer = h.value;
break;
}
}
var html_buf: [512]u8 = undefined;
const html = try std.fmt.bufPrint(&html_buf, "<html><body>referer={s}</body></html>", .{referer});
return req.respond(html, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/referer_link.html")) {
// Page with an anchor link to /echo_referer. The test clicks the link
// via JS and asserts the resulting page reports Referer = this page.
return req.respond(
"<html><body><a id=\"link\" href=\"/echo_referer\">go</a></body></html>",
.{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
},
);
}
if (std.mem.eql(u8, path, "/echo_method")) {
// Echo the request method back as HTML so tests can assert on what
// method the navigation used. Used by the Page.reload-replays-POST test.
const method_name = @tagName(req.head.method);
var html_buf: [128]u8 = undefined;
const html = try std.fmt.bufPrint(&html_buf, "<html><body>method={s}</body></html>", .{method_name});
return req.respond(html, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/echo_body")) {
// Echo the request body back verbatim, so tests can assert on the bytes
// a request actually sent rather than just on its status.
var body_buf: [4096]u8 = undefined;
const body = if (req.head.method.requestHasBody())
try req.readerExpectNone(&body_buf).allocRemaining(arena_allocator, .limited(body_buf.len))
else
"";
return req.respond(body, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/echo_headers")) {
// Echo every request header back as "name: value" lines, so tests
// can assert on the headers a request actually sent.
var buf: [8192]u8 = undefined;
var pos: usize = 0;
var it = req.iterateHeaders();
while (it.next()) |header| {
const line = try std.fmt.bufPrint(buf[pos..], "{s}: {s}\n", .{ header.name, header.value });
pos += line.len;
}
return req.respond(buf[0..pos], .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_same_echo_headers")) {
// Same-origin 302 to /echo_headers: Authorization must survive the hop.
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/echo_headers" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_cross_echo_headers")) {
// 302 to /echo_headers on the localhost alias — a cross-origin hop, so
// Authorization must be stripped before the request is re-sent.
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "http://localhost:9582/echo_headers" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_same_echo_referer")) {
// Same-origin 302 to /echo_referer: the full Referer must survive the hop.
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/echo_referer" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_cross_echo_referer")) {
// 302 to /echo_referer on the localhost alias — a cross-origin hop, so
// the Referer must be recomputed at the redirect (stripped to origin
// under the default policy) rather than re-sent in full.
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "http://localhost:9582/echo_referer" },
},
});
}
if (std.mem.eql(u8, path, "/redirect_to_echo")) {
// 302 to /echo_method. Used by the Page.reload-after-redirect test to
// confirm a POST→302→GET chain doesn't replay POST on reload.
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "/echo_method" },
},
});
}
if (std.mem.eql(u8, path, "/download/report.csv")) {
// A file download: Content-Disposition: attachment drives the
// Browser.setDownloadBehavior path (issue #2701).
return req.respond("col1,col2\nhello,world\n42,1337\n", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/csv" },
.{ .name = "Content-Disposition", .value = "attachment; filename=\"report.csv\"" },
},
});
}
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
if (std.mem.indexOf(u8, path, "delay_ms=")) |pos| {
const digits_start = pos + "delay_ms=".len;
var end = digits_start;
while (end < path.len and std.ascii.isDigit(path[end])) : (end += 1) {}
const delay_ms = std.fmt.parseInt(u64, path[digits_start..end], 10) catch 0;
lp.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .awake) catch {};
}
// 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;
}
/// Declares the log lines a test expects to emit: one entry per line, so
/// `&.{ .js, .js, .http }` covers two `js` lines and one `http` line.
pub const expectLog = log.expectLog;
/// Suppresses every line from `scopes` for the rest of the test.
pub fn silenceLog(comptime scopes: []const log.Scope) void {
inline for (scopes) |scope| {
log.opts.scope_enabled[@intFromEnum(scope)] = false;
}
}
test "tests:afterEach" {
defer reset();
const unmet = log.resetTestState();
if (@import("root").hasSubfilter()) {
// only part of the test ran, so expectations about what it logs
// don't hold.
return;
}
var failed = false;
for (unmet, 0..) |count, i| {
if (count == 0) {
continue;
}
failed = true;
const scope: log.Scope = @enumFromInt(i);
std.debug.print("expected {d} more {s} log line(s)\n", .{ count, @tagName(scope) });
}
if (failed) {
return error.UnmetLogExpectation;
}
}