From 23e98a4ce02e89ae8cbe0e52e12e12f62e13d740 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 17 Apr 2026 15:59:06 +0800 Subject: [PATCH] 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. --- .github/workflows/wpt.yml | 2 +- build.zig | 2 ++ src/browser/js/Caller.zig | 1 + src/browser/js/Snapshot.zig | 31 ++++++++++++++++++++++- src/browser/js/bridge.zig | 14 ++++++++++- src/browser/webapi/WebDriver.zig | 43 ++++++++++++++++++++++++++++++++ src/browser/webapi/Window.zig | 7 ++++++ 7 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/browser/webapi/WebDriver.zig diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 8a3b8516..89d640ab 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -17,7 +17,7 @@ on: jobs: wpt-build-release: - name: zig build release + name: zig build -Dwpt_extensions release env: ARCH: aarch64 diff --git a/build.zig b/build.zig index 83f287e5..781ac02b 100644 --- a/build.zig +++ b/build.zig @@ -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; diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 05419752..61ce53a2 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -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, diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index b63e26cb..b95b5c29 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -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, &.{ diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 22cbed30..3e5ab70c 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -17,6 +17,8 @@ // along with this program. If not, see . 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}; +}; diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig new file mode 100644 index 00000000..e432b5d8 --- /dev/null +++ b/src/browser/webapi/WebDriver.zig @@ -0,0 +1,43 @@ +// Copyright (C) 2023-2026 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 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, .{}); +}; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 9e716e91..01a77495 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -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 {