Add WPT extensions

Some WPT tests need to interact with the browser in a way that isn't possible
with web apis. Browsers need to expose a way for tests to do this and then use
the testdriver-vendor.js to hook into these special WPT actions.

This commit sets up the infrastructure for supporting this and includes
the delete_all_cookies functionality needed by various cookie tests (e.g.
/cookies/attributes/attributes-ctl.sub.html).

A new compilation flag, `-Dwpt_extensions`, can be specified. When specified
a `window.webdriver` accessor is defined and a `WebDriver` type is exposed.

Note that, while I only implemented delete_all_cookies for now, I've seen other
tests fail because of missing vendor-specific implementation.
This commit is contained in:
Karl Seguin
2026-04-17 15:59:06 +08:00
parent 6caca237fd
commit 23e98a4ce0
7 changed files with 97 additions and 3 deletions

View File

@@ -17,7 +17,7 @@ on:
jobs:
wpt-build-release:
name: zig build release
name: zig build -Dwpt_extensions release
env:
ARCH: aarch64

View File

@@ -41,6 +41,7 @@ pub fn build(b: *Build) !void {
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
const wpt_extensions = b.option(bool, "wpt_extensions", "Extend WebAPI with WPT driver behavior") orelse false;
const version = resolveVersion(b);
var stdout = std.fs.File.stdout().writer(&.{});
@@ -53,6 +54,7 @@ pub fn build(b: *Build) !void {
opts.addOption([]const u8, "version", version_string);
opts.addOption([]const u8, "version_encoded", version_encoded);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
opts.addOption(bool, "wpt_extensions", wpt_extensions);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false;

View File

@@ -548,6 +548,7 @@ pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
wpt_only: bool = false,
deletable: bool = true,
dom_exception: bool = false,
as_typed_array: bool = false,

View File

@@ -18,9 +18,11 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("js.zig");
const bridge = @import("bridge.zig");
const log = @import("../../log.zig");
const WebDriver = @import("../webapi/WebDriver.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -31,7 +33,7 @@ const WorkerJsApis = bridge.WorkerJsApis;
const Snapshot = @This();
const embedded_snapshot_blob = if (@import("build_config").snapshot_path) |path| @embedFile(path) else "";
const embedded_snapshot_blob = if (lp.build_config.snapshot_path) |path| @embedFile(path) else "";
// When creating our Snapshot, we use local function templates for every Zig type.
// You cannot, from what I can tell, create persisted FunctionTemplates at
@@ -335,6 +337,8 @@ fn countExternalReferences() comptime_int {
// +1 for unknownWindowPropertyCallback used on Window's global template
count += 1;
const wpt_extensions_enabled = lp.build_config.wpt_extensions;
inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) {
count += 1;
@@ -349,11 +353,17 @@ fn countExternalReferences() comptime_int {
const value = @field(JsApi, d.name);
const T = @TypeOf(value);
if (T == bridge.Accessor) {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
count += 1;
if (value.setter != null) {
count += 1;
}
} else if (T == bridge.Function) {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
count += 1;
} else if (T == bridge.Iterator) {
count += 1;
@@ -394,6 +404,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&bridge.unknownWindowPropertyCallback));
idx += 1;
const wpt_extensions_enabled = lp.build_config.wpt_extensions;
inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) {
references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func));
@@ -410,6 +422,10 @@ fn collectExternalReferences() [countExternalReferences()]isize {
const value = @field(JsApi, d.name);
const T = @TypeOf(value);
if (T == bridge.Accessor) {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1;
if (value.setter) |setter| {
@@ -417,6 +433,9 @@ fn collectExternalReferences() [countExternalReferences()]isize {
idx += 1;
}
} else if (T == bridge.Function) {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
} else if (T == bridge.Iterator) {
@@ -573,6 +592,8 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false;
const wpt_extensions_enabled = lp.build_config.wpt_extensions;
inline for (declarations) |d| {
const name: [:0]const u8 = d.name;
const value = @field(JsApi, name);
@@ -580,6 +601,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
switch (definition) {
bridge.Accessor => {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
@@ -614,6 +639,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
}
},
bridge.Function => {
if (value.wpt_only and wpt_extensions_enabled == false) {
continue;
}
// For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{

View File

@@ -17,6 +17,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
@@ -134,6 +136,7 @@ pub const Function = struct {
static: bool,
arity: usize,
noop: bool = false,
wpt_only: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
@@ -141,6 +144,7 @@ pub const Function = struct {
return .{
.cache = opts.cache,
.static = opts.static,
.wpt_only = opts.wpt_only,
.arity = getArity(@TypeOf(func)),
.func = if (opts.noop) noopFunction else struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
@@ -172,6 +176,7 @@ pub const Function = struct {
pub const Accessor = struct {
static: bool = false,
deletable: bool = true,
wpt_only: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
@@ -180,6 +185,7 @@ pub const Accessor = struct {
var accessor = Accessor{
.cache = opts.cache,
.static = opts.static,
.wpt_only = opts.wpt_only,
.deletable = opts.deletable,
};
@@ -928,4 +934,10 @@ pub const WorkerJsApis = flattenTypes(&.{
// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs
// to know about all possible types. Individual snapshots use their own
// subsets (PageJsApis, WorkerSnapshot.JsApis).
pub const JsApis = PageJsApis ++ [_]type{@import("../webapi/WorkerGlobalScope.zig").JsApi};
pub const JsApis = blk: {
const base = PageJsApis ++ [_]type{@import("../webapi/WorkerGlobalScope.zig").JsApi};
if (lp.build_config.wpt_extensions == false) {
break :blk base;
}
break :blk base ++ [_]type{@import("../webapi/WebDriver.zig").JsApi};
};

View File

@@ -0,0 +1,43 @@
// Copyright (C) 2023-2026 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 js = @import("../js/js.zig");
const Session = @import("../Session.zig");
// This type is only included when the binary is built with the -Dwpt_extensions flag
const WebDriver = @This();
_pad: bool = false,
pub fn deleteAllCookies(_: *const WebDriver, session: *Session) void {
session.cookie_jar.clearRetainingCapacity();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(WebDriver);
pub const Meta = struct {
pub const name = "WebDriver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{});
};

View File

@@ -571,6 +571,11 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
return self.scrollTo(.{ .x = absx }, absy, page);
}
// only exposed when the binary is built with the -Dwpt_extensions flag
pub fn getWebDriver(_: *const Window) @import("WebDriver.zig") {
return .{};
}
pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void {
if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{
@@ -916,6 +921,8 @@ pub const JsApi = struct {
return null;
}
}.prompt, .{});
pub const webdriver = bridge.accessor(Window.getWebDriver, null, .{ .wpt_only = true });
};
const CrossOriginWindow = struct {