Files
browser/src/main_wpt.zig
Karl Seguin 687f09d952 Make the App own the Platform
Removes optional platform, which only existed for tests.

There is now a global `@import("testing.zig").test_app` available. This is setup
when the test runner starts, and cleaned up at the end of tests. Individual
tests don't have to worry about creating app, which I assume was the reason I
Platform optional, since that woul dhave been something else that needed to be
setup.
2025-08-29 10:14:06 +08:00

487 lines
16 KiB
Zig

// Copyright (C) 2023-2024 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 log = @import("log.zig");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Env = @import("browser/env.zig").Env;
const Platform = @import("runtime/js.zig").Platform;
const parser = @import("browser/netsurf.zig");
const polyfill = @import("browser/polyfill/polyfill.zig");
const WPT_DIR = "tests/wpt";
// TODO For now the WPT tests run is specific to WPT.
// It manually load js framwork libs, and run the first script w/ js content in
// the HTML page.
// Once lightpanda will have the html loader, it would be useful to refactor
// this test to use it.
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
log.opts.level = .warn;
// An arena for the runner itself, lives for the duration of the the process
var ra = ArenaAllocator.init(allocator);
defer ra.deinit();
const runner_arena = ra.allocator();
const cmd = try parseArgs(runner_arena);
try @import("testing.zig").setup();
defer @import("testing.zig").shutdown();
// prepare libraries to load on each test case.
var loader = FileLoader.init(runner_arena, WPT_DIR);
var it = try TestIterator.init(runner_arena, WPT_DIR, cmd.filters);
defer it.deinit();
var writer = try Writer.init(runner_arena, cmd.format);
// An arena for running each tests. Is reset after every test.
var test_arena = ArenaAllocator.init(allocator);
defer test_arena.deinit();
while (try it.next()) |test_file| {
defer _ = test_arena.reset(.{ .retain_capacity = {} });
var err_out: ?[]const u8 = null;
const result = run(
test_arena.allocator(),
test_file,
&loader,
&err_out,
) catch |err| blk: {
if (err_out == null) {
err_out = @errorName(err);
}
break :blk null;
};
if (result == null and err_out == null) {
// We sometimes pass a non-test to `run` (we don't know it's a non
// test, we need to open the contents of the test file to find out
// and that's in run).
continue;
}
try writer.process(test_file, result, err_out);
}
try writer.finalize();
}
fn run(
arena: Allocator,
test_file: []const u8,
loader: *FileLoader,
err_out: *?[]const u8,
) !?[]const u8 {
// document
const html = blk: {
const full_path = try std.fs.path.join(arena, &.{ WPT_DIR, test_file });
const file = try std.fs.cwd().openFile(full_path, .{});
defer file.close();
break :blk try file.readToEndAlloc(arena, 128 * 1024);
};
if (std.mem.indexOf(u8, html, "testharness.js") == null) {
// This isn't a test. A lot of files are helpers/content for tests to
// make use of.
return null;
}
// this returns null for the success.html test in the root of tests/wpt
const dirname = std.fs.path.dirname(test_file) orelse "";
var runner = try @import("testing.zig").jsRunner(arena, .{
.url = "http://127.0.0.1",
.html = html,
});
defer runner.deinit();
defer if (err_out.*) |eo| {
// the error might be owned by the runner, we'll dupe it with our
// own arena so that it can be returned out of this function.
err_out.* = arena.dupe(u8, eo) catch "failed to dupe error";
};
try polyfill.preload(arena, runner.page.main_context);
// loop over the scripts.
const doc = parser.documentHTMLToDocument(runner.page.window.document);
const scripts = try parser.documentGetElementsByTagName(doc, "script");
const script_count = try parser.nodeListLength(scripts);
for (0..script_count) |i| {
const s = (try parser.nodeListItem(scripts, @intCast(i))).?;
// If the script contains an src attribute, load it.
if (try parser.elementGetAttribute(@as(*parser.Element, @ptrCast(s)), "src")) |src| {
var path = src;
if (!std.mem.startsWith(u8, src, "/")) {
path = try std.fs.path.join(arena, &.{ "/", dirname, path });
}
const script_source = loader.get(path) catch |err| {
err_out.* = std.fmt.allocPrint(arena, "{s} - {s}", .{ @errorName(err), path }) catch null;
return err;
};
try runner.exec(script_source, src, err_out);
}
// If the script as a source text, execute it.
const src = try parser.nodeTextContent(s) orelse continue;
try runner.exec(src, null, err_out);
}
{
// Mark tests as ready to run.
const loadevt = try parser.eventCreate();
defer parser.eventDestroy(loadevt);
try parser.eventInit(loadevt, "load", .{});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(@TypeOf(runner.page.window), &runner.page.window),
loadevt,
);
}
{
// wait for all async executions
var try_catch: Env.TryCatch = undefined;
try_catch.init(runner.page.main_context);
defer try_catch.deinit();
runner.page.wait(2);
if (try_catch.hasCaught()) {
err_out.* = (try try_catch.err(arena)) orelse "unknwon error";
}
}
// Check the final test status.
try runner.exec("report.status", "teststatus", err_out);
// return the detailed result.
const res = try runner.eval("report.log", "report", err_out);
return try res.toString(arena);
}
const Writer = struct {
format: Format,
arena: Allocator,
pass_count: usize = 0,
fail_count: usize = 0,
case_pass_count: usize = 0,
case_fail_count: usize = 0,
out: std.fs.File.Writer,
cases: std.ArrayListUnmanaged(Case) = .{},
const Format = enum {
json,
text,
summary,
};
fn init(arena: Allocator, format: Format) !Writer {
const out = std.io.getStdOut().writer();
if (format == .json) {
try out.writeByte('[');
}
return .{
.out = out,
.arena = arena,
.format = format,
};
}
fn finalize(self: *Writer) !void {
if (self.format == .json) {
// When we write a test output, we add a trailing comma to act as
// a separator for the next test. We need to add this dummy entry
// to make it valid json.
// Better option could be to change the formatter to work on JSONL:
// https://github.com/lightpanda-io/perf-fmt/blob/main/wpt/wpt.go
try self.out.writeAll("{\"name\":\"empty\",\"pass\": true, \"cases\": []}]");
} else {
try self.out.print("\n==Summary==\nTests: {d}/{d}\nCases: {d}/{d}\n", .{
self.pass_count,
self.pass_count + self.fail_count,
self.case_pass_count,
self.case_pass_count + self.case_fail_count,
});
}
}
fn process(self: *Writer, test_file: []const u8, result_: ?[]const u8, err_: ?[]const u8) !void {
if (err_) |err| {
self.fail_count += 1;
switch (self.format) {
.text => return self.out.print("Fail\t{s}\n\t{s}\n", .{ test_file, err }),
.summary => return self.out.print("Fail 0/0\t{s}\n", .{test_file}),
.json => {
try std.json.stringify(Test{
.pass = false,
.name = test_file,
.cases = &.{},
}, .{ .whitespace = .indent_2 }, self.out);
return self.out.writeByte(',');
},
}
// just make sure we didn't fall through by mistake
unreachable;
}
// if we don't have an error, we must have a result
const result = result_ orelse return error.InvalidResult;
var cases = &self.cases;
cases.clearRetainingCapacity(); // from previous run
var pass = true;
var case_pass_count: usize = 0;
var case_fail_count: usize = 0;
var lines = std.mem.splitScalar(u8, result, '\n');
while (lines.next()) |line| {
if (line.len == 0) {
break;
}
// case names can have | in them, so we can't simply split on |
var case_name = line;
var case_pass = false; // so pessimistic!
var case_message: []const u8 = "";
if (std.mem.endsWith(u8, line, "|Pass")) {
case_name = line[0 .. line.len - 5];
case_pass = true;
case_pass_count += 1;
} else {
// both cases names and messages can have | in them. Our only
// chance to "parse" this is to anchor off the |$Status.
const statuses = [_][]const u8{ "|Fail", "|Timeout", "|Not Run", "|Optional Feature Unsupported" };
var pos_: ?usize = null;
var message_start: usize = 0;
for (statuses) |status| {
if (std.mem.indexOf(u8, line, status)) |idx| {
pos_ = idx;
message_start = idx + status.len;
break;
}
}
const pos = pos_ orelse {
std.debug.print("invalid result line: {s}\n", .{line});
return error.InvalidResult;
};
case_name = line[0..pos];
case_message = line[message_start..];
pass = false;
case_fail_count += 1;
}
try cases.append(self.arena, .{
.name = case_name,
.pass = case_pass,
.message = case_message,
});
}
// our global counters
if (pass) {
self.pass_count += 1;
} else {
self.fail_count += 1;
}
self.case_pass_count += case_pass_count;
self.case_fail_count += case_fail_count;
switch (self.format) {
.summary => try self.out.print("{s} {d}/{d}\t{s}\n", .{ statusText(pass), case_pass_count, case_pass_count + case_fail_count, test_file }),
.text => {
try self.out.print("{s}\t{s}\n", .{ statusText(pass), test_file });
for (cases.items) |c| {
try self.out.print("\t{s}\t{s}\n", .{ statusText(c.pass), c.name });
if (c.message) |msg| {
try self.out.print("\t\t{s}\n", .{msg});
}
}
},
.json => {
try std.json.stringify(Test{
.pass = pass,
.name = test_file,
.cases = cases.items,
}, .{ .whitespace = .indent_2 }, self.out);
// separator, see `finalize` for the hack we use to terminate this
try self.out.writeByte(',');
},
}
}
fn statusText(pass: bool) []const u8 {
return if (pass) "Pass" else "Fail";
}
};
const Command = struct {
format: Writer.Format,
filters: [][]const u8,
};
fn parseArgs(arena: Allocator) !Command {
const usage =
\\usage: {s} [options] [test filter]
\\ Run the Web Test Platform.
\\
\\ -h, --help Print this help message and exit.
\\ --json result is formatted in JSON.
\\ --summary print a summary result. Incompatible w/ --json
\\
;
var args = try std.process.argsWithAllocator(arena);
// get the exec name.
const execname = args.next().?;
var format = Writer.Format.text;
var filters: std.ArrayListUnmanaged([]const u8) = .{};
while (args.next()) |arg| {
if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) {
try std.io.getStdErr().writer().print(usage, .{execname});
std.posix.exit(0);
}
if (std.mem.eql(u8, "--json", arg)) {
format = .json;
} else if (std.mem.eql(u8, "--summary", arg)) {
format = .summary;
} else {
try filters.append(arena, arg);
}
}
return .{
.format = format,
.filters = filters.items,
};
}
const TestIterator = struct {
dir: Dir,
walker: Dir.Walker,
filters: [][]const u8,
const Dir = std.fs.Dir;
fn init(arena: Allocator, root: []const u8, filters: [][]const u8) !TestIterator {
var dir = try std.fs.cwd().openDir(root, .{ .iterate = true, .no_follow = true });
errdefer dir.close();
return .{
.dir = dir,
.filters = filters,
.walker = try dir.walk(arena),
};
}
fn deinit(self: *TestIterator) void {
self.dir.close();
}
fn next(self: *TestIterator) !?[]const u8 {
NEXT: while (try self.walker.next()) |entry| {
if (entry.kind != .file) {
continue;
}
if (std.mem.startsWith(u8, entry.path, "resources/")) {
// resources for running the tests themselves, not actual tests
continue;
}
if (!std.mem.endsWith(u8, entry.basename, ".html") and !std.mem.endsWith(u8, entry.basename, ".htm")) {
continue;
}
const path = entry.path;
for (self.filters) |filter| {
if (std.mem.indexOf(u8, path, filter) == null) {
continue :NEXT;
}
}
return path;
}
return null;
}
};
const Case = struct {
pass: bool,
name: []const u8,
message: ?[]const u8,
};
const Test = struct {
pass: bool,
crash: bool = false,
name: []const u8,
cases: []Case,
};
pub const FileLoader = struct {
path: []const u8,
arena: Allocator,
files: std.StringHashMapUnmanaged([]const u8),
pub fn init(arena: Allocator, path: []const u8) FileLoader {
return .{
.path = path,
.files = .{},
.arena = arena,
};
}
pub fn get(self: *FileLoader, name: []const u8) ![]const u8 {
const gop = try self.files.getOrPut(self.arena, name);
if (gop.found_existing == false) {
gop.key_ptr.* = try self.arena.dupe(u8, name);
gop.value_ptr.* = self.load(name) catch |err| {
_ = self.files.remove(name);
return err;
};
}
return gop.value_ptr.*;
}
fn load(self: *FileLoader, name: []const u8) ![]const u8 {
const filename = try std.fs.path.join(self.arena, &.{ self.path, name });
var file = try std.fs.cwd().openFile(filename, .{});
defer file.close();
return file.readToEndAlloc(self.arena, 4 * 1024 * 1024);
}
};