mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-02-04 14:33:47 +00:00
When we create a js.Context, we create the underlying v8.Context and store it for the duration of the page lifetime. This works because we have a global HandleScope - the v8.Context (which is really a v8::Local<v8::Context>) is that to the global HandleScope, effectively making it a global. If we want to remove our global HandleScope, then we can no longer pin the v8.Context in our js.Context. Our js.Context now only holds a v8.Global of the v8.Context (v8::Global<v8::Context). This PR introduces a new type, js.Local, which takes over a lot of the functionality previously found in either js.Caller or js.Context. The simplest way to think about it is: 1 - For v8 -> zig calls, we create a js.Caller (as always) 2 - For zig -> v8 calls, we go through the js.Context (as always) 3 - The shared functionality, which works on a v8.Context, now belongs to js.Local For #1 (v8 -> zig), creating a js.Local for a js.Caller is really simple and centralized. v8 largely gives us everything we need from the FunctionCallbackInfo or PropertyCallbackInfo. For #2, it's messier, because we can only create a local v8::Context if we have a HandleScope, which we may or may not. Unfortunately, in many cases, what to do becomes the responsibility of the caller and much of the code has to become aware of this local-ness. What does it means for our code? The impact is on WebAPIs that store .Global. Because the global can't do anything. You always need to convert that .Global to a local (e.g. js.Function.Global -> js.Function). If you're 100% sure the WebAPI is only being invoked by a v8 callback, you can use `page.js.local.?.toLocal(some_global).call(...)` to get the local value. If you're 100% sure the WebAPI is only being invoked by Zig, you need to create `js.Local.Scope` to get access to a local: ```zig var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); ls.toLocal(some_global).call(...) // can also access `&ls.local` for APIs that require a *const js.Local ``` For functions that can be invoked by either V8 or Zig, you should generally push the responsibility to the caller by accepting a `local: *const js.Local`. If the caller is a v8 callback, it can pass `page.js.local.?`. If the caller is a Zig callback, it can create a `Local.Scope`. As an alternative, it is possible to simply pass the *Page, and check `if page.js.local == null` and, if so, create a Local.Scope. But this should only be done for performance reasons. We currently only do this in 1 place, and it's because the Zig caller doesn't know whether a Local will actually be needed and it's potentially called on every element creating from the parser.
565 lines
17 KiB
Zig
565 lines
17 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 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;
|
|
},
|
|
}
|
|
}
|
|
|
|
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 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,
|
|
);
|
|
|
|
var ls: js.Local.Scope = undefined;
|
|
page.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
|
|
var try_catch: js.TryCatch = undefined;
|
|
try_catch.init(&ls.local);
|
|
defer try_catch.deinit();
|
|
|
|
try page.navigate(url, .{});
|
|
_ = test_session.wait(2000);
|
|
|
|
test_browser.runMicrotasks();
|
|
|
|
ls.local.eval("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;
|
|
};
|
|
}
|
|
|
|
// 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, .{});
|
|
_ = test_session.wait(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(@import("root").tracking_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();
|
|
}
|
|
|
|
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
|
|
|
|
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!!!\",\"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, "/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;
|
|
}
|