From 8ff4711c214c3d0a01f3c35658701f8e9b30f1c6 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 22:39:52 +0200 Subject: [PATCH 1/6] webapi: wheel/touch testdriver actions; passive-listener cancelability Fixes all 29 failing files under /dom/events/non-cancelable-when-passive/ (13/42 -> 42/42 files passing): - WebDriver wheel actions only worked with an element origin; testdriver's Actions().scroll() uses the viewport origin, so nothing was dispatched. Viewport origins now resolve their target through the faux layout (a new vertical-axis-only elementFromPoint variant, since the faux layout gives elements no useful horizontal extent), falling back to the document element. The faux dimensions also learn vh/vw units (resolved against the page viewport), which the tests' 200vh scroll containers rely on. - Wheel dispatch also fires the legacy mousewheel event, like Blink. - Touch pointer sources (Actions().addPointer("touch")) now dispatch touchstart/touchmove/touchend (with pointer events, without mouse events) instead of being treated as a mouse. - Per the cancelability rules for scroll-blocking events, wheel, mousewheel and touch events are dispatched cancelable only when some listener on the propagation path (target chain plus window) is non-passive: the UA knows preventDefault can't be called otherwise. Coverage: /dom/events/non-cancelable-when-passive/: all 42 files pass (29 newly passing, 1 subtest each); no regressions across /dom/events/. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/CSS.zig | 14 +++ src/browser/webapi/Document.zig | 14 ++- src/browser/webapi/Element.zig | 4 +- src/browser/webapi/WebDriver.zig | 116 +++++++++++++++++++++--- src/browser/webapi/event/TouchEvent.zig | 10 +- 5 files changed, 141 insertions(+), 17 deletions(-) diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig index 00bd1a952..85de30ead 100644 --- a/src/browser/webapi/CSS.zig +++ b/src/browser/webapi/CSS.zig @@ -38,6 +38,20 @@ pub fn parseDimension(value: []const u8) ?f64 { return std.fmt.parseFloat(f64, num_str) catch null; } +// parseDimension plus viewport-relative units, which the faux layout +// resolves against the page viewport. +pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 { + if (std.mem.endsWith(u8, value, "vh")) { + const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; + return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0; + } + if (std.mem.endsWith(u8, value, "vw")) { + const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; + return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0; + } + return parseDimension(value); +} + /// Escapes a CSS identifier string /// https://drafts.csswg.org/cssom/#the-css.escape()-method pub fn escape(value: []const u8, frame: *Frame) ![]const u8 { diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index b2a621c01..c09e01377 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -817,6 +817,17 @@ pub fn moveBefore(self: *Document, node: js.Value, child: js.Value, frame: *Fram } pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element { + return self.elementFromPointImpl(x, y, false, frame); +} + +// The faux layout gives most elements no useful horizontal extent, so +// viewport-relative hit-testing (WebDriver scroll actions) matches on the +// vertical axis only. +pub fn elementFromVerticalPoint(self: *Document, y: f64, frame: *Frame) !?*Element { + return self.elementFromPointImpl(0, y, true, frame); +} + +fn elementFromPointImpl(self: *Document, x: f64, y: f64, ignore_x: bool, frame: *Frame) !?*Element { // DFS in document order; topmost = last visited element whose rect contains (x, y). // // Faux-layout shortcut: rect.top is calculateDocumentPosition × 5, which is @@ -854,7 +865,8 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Eleme const top = pos; const right = pos + dims.width; const bottom = pos + dims.height; - if (x >= left and x <= right and y >= top and y <= bottom) { + const x_contained = ignore_x or (x >= left and x <= right); + if (x_contained and y >= top and y <= bottom) { topmost = element; } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index b1193459f..26ecce962 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1252,8 +1252,8 @@ pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, if (self.getStyle(frame)) |style| { const decl = style.asCSSStyleDeclaration(); - width = CSS.parseDimension(decl.getPropertyValue("width", frame)) orelse 5.0; - height = CSS.parseDimension(decl.getPropertyValue("height", frame)) orelse 5.0; + width = CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame) orelse 5.0; + height = CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame) orelse 5.0; } if (width == 5.0 or height == 5.0) { diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 140df74ea..bcbe189bf 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -30,6 +30,8 @@ const MouseEvent = @import("event/MouseEvent.zig"); const PointerEvent = @import("event/PointerEvent.zig"); const KeyboardEvent = @import("event/KeyboardEvent.zig"); const WheelEvent = @import("event/WheelEvent.zig"); +const TouchEvent = @import("event/TouchEvent.zig"); +const EventManagerBase = @import("../EventManagerBase.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -159,9 +161,19 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void { } const actions = actions_val.toArray(); + // A touch pointer dispatches touch events instead of mouse events. + var is_touch = false; + const params = try source.get("parameters"); + if (params.isObject()) { + if ((try params.toObject().get("pointerType")).toSSO(false)) |pointer_type| { + is_touch = pointer_type.eql(comptime .wrap("touch")); + } else |_| {} + } + // The element the pointer is currently over, set by the last pointerMove // whose origin resolved to an element. var target: ?*Element = null; + var pressed = false; for (0..actions.len()) |i| { const action_val = try actions.get(@intCast(i)); @@ -178,21 +190,37 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void { } const el = target orelse continue; dispatchPointer(el, "pointermove", 0, 0, frame); - dispatchMouse(el, "mousemove", 0, 0, frame); + if (is_touch) { + if (pressed) { + dispatchTouch(el, "touchmove", frame); + } + } else { + dispatchMouse(el, "mousemove", 0, 0, frame); + } } else if (action_type.eql(comptime .wrap("pointerDown"))) { const el = target orelse continue; const button = readI32(action, "button", 0); + pressed = true; dispatchPointer(el, "pointerdown", button, 1, frame); - dispatchMouse(el, "mousedown", button, 1, frame); - Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| { - log.warn(.app, "webdriver editable focus", .{ .err = err }); - }; + if (is_touch) { + dispatchTouch(el, "touchstart", frame); + } else { + dispatchMouse(el, "mousedown", button, 1, frame); + Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| { + log.warn(.app, "webdriver editable focus", .{ .err = err }); + }; + } } else if (action_type.eql(comptime .wrap("pointerUp"))) { const el = target orelse continue; const button = readI32(action, "button", 0); + pressed = false; dispatchPointer(el, "pointerup", button, 0, frame); - dispatchMouse(el, "mouseup", button, 0, frame); - dispatchMouse(el, "click", button, 0, frame); + if (is_touch) { + dispatchTouch(el, "touchend", frame); + } else { + dispatchMouse(el, "mouseup", button, 0, frame); + dispatchMouse(el, "click", button, 0, frame); + } } // "pause" carries timing only and is ignored. ("pointerCancel" is not // emitted by the testdriver Actions builder.) @@ -219,14 +247,23 @@ fn performWheelSource(source: js.Object, frame: *Frame) !void { } const origin = try action.get("origin"); - if (!origin.isObject()) { - continue; + var el: ?*Element = null; + if (origin.isObject()) { + el = origin.local.jsValueToZig(*Element, origin) catch null; + } else { + // "viewport"/"pointer" origins: approximate hit-testing with + // the faux layout's vertical axis, falling back to the root. + const y = readI32(action, "y", 0); + el = frame.document.elementFromVerticalPoint(@floatFromInt(y), frame) catch null; + if (el == null) { + el = frame.document.getDocumentElement(); + } } - const el = origin.local.jsValueToZig(*Element, origin) catch continue; + const target = el orelse continue; const delta_x = readI32(action, "deltaX", 0); const delta_y = readI32(action, "deltaY", 0); - dispatchWheel(el, delta_x, delta_y, frame); + dispatchWheel(target, delta_x, delta_y, frame); } } @@ -310,9 +347,12 @@ fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u } fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void { + // The UA dispatches scroll-blocking events as non-cancelable when every + // listener on the propagation path is passive: it already knows + // preventDefault can't be called. const event = WheelEvent.initTrusted("wheel", .{ .bubbles = true, - .cancelable = true, + .cancelable = hasNonPassiveListener(el, "wheel", frame), .composed = true, .deltaX = @floatFromInt(delta_x), .deltaY = @floatFromInt(delta_y), @@ -326,7 +366,22 @@ fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void { defer _ = event.asEvent().releaseRef(frame._page); dispatch(el.asEventTarget(), event.asEvent(), frame, "wheel"); - if (event.asEvent()._prevent_default) { + // Blink also fires the legacy mousewheel event. + const legacy = WheelEvent.initTrusted("mousewheel", .{ + .bubbles = true, + .cancelable = hasNonPassiveListener(el, "mousewheel", frame), + .composed = true, + .deltaX = @floatFromInt(delta_x), + .deltaY = @floatFromInt(delta_y), + }, frame) catch |err| { + log.warn(.app, "webdriver mousewheel event", .{ .err = err }); + return; + }; + legacy.asEvent().acquireRef(); + defer _ = legacy.asEvent().releaseRef(frame._page); + dispatch(el.asEventTarget(), legacy.asEvent(), frame, "mousewheel"); + + if (event.asEvent()._prevent_default or legacy.asEvent()._prevent_default) { return; } @@ -349,6 +404,41 @@ fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) }; } +fn hasNonPassiveListener(el: *Element, typ: []const u8, frame: *Frame) bool { + const base = &frame._event_manager.base; + var current: ?*@import("Node.zig") = el.asNode(); + while (current) |node| : (current = node.parentNode()) { + if (anyNonPassive(base.getListeners(node.asEventTarget(), .wrap(typ)))) { + return true; + } + } + return anyNonPassive(base.getListeners(frame.window.asEventTarget(), .wrap(typ))); +} + +fn anyNonPassive(list_: ?*std.DoublyLinkedList) bool { + const list = list_ orelse return false; + var link = list.first; + while (link) |l| : (link = l.next) { + const listener: *align(8) EventManagerBase.Listener = @fieldParentPtr("node", l); + if (!listener.passive) { + return true; + } + } + return false; +} + +fn dispatchTouch(el: *Element, comptime typ: []const u8, frame: *Frame) void { + const event = TouchEvent.initTrusted(typ, .{ + .bubbles = true, + .cancelable = hasNonPassiveListener(el, typ, frame), + .composed = true, + }, frame) catch |err| { + log.warn(.app, "webdriver touch event", .{ .err = err }); + return; + }; + dispatch(el.asEventTarget(), event.asEvent(), frame, typ); +} + pub const JsApi = struct { pub const bridge = js.Bridge(WebDriver); diff --git a/src/browser/webapi/event/TouchEvent.zig b/src/browser/webapi/event/TouchEvent.zig index 4956ed0ad..f15da08b7 100644 --- a/src/browser/webapi/event/TouchEvent.zig +++ b/src/browser/webapi/event/TouchEvent.zig @@ -51,6 +51,14 @@ pub const Options = Event.inheritOptions( ); pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent { + return initWithTrusted(typ, _opts, false, frame); +} + +pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent { + return initWithTrusted(typ, _opts, true, frame); +} + +fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*TouchEvent { const arena = try frame.getArena(.tiny, "TouchEvent"); errdefer frame.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); @@ -68,7 +76,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TouchEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; } From 1187621f1bc7fb18ba9818912b67b5d7b911ec1d Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 22:45:49 +0200 Subject: [PATCH 2/6] webapi: clicking a javascript: link runs the script Fixes the last failing subtest of WPT /dom/events/Event-dispatch-click.html ("pick the first with activation behavior ", 32/33 -> 33/33): following a link whose href is a javascript: URL must evaluate the URL body as script in the link's frame. We explicitly ignored such hrefs, so the test's completion callback never ran. Anchor activation now schedules the script on the frame's JS scheduler (navigation is a queued task) and compiles/runs it in the frame's context, ignoring the completion value (a string result would replace the document, which nothing relies on here). The URL body should be percent-decoded per spec; markup hrefs rarely are, so that's left as a TODO. Coverage: /dom/events/Event-dispatch-click.html 32/33 -> 33/33 (fully green). Co-Authored-By: Claude Fable 5 --- src/browser/frame/user_input.zig | 58 +++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 3248974eb..a1734bc82 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -27,6 +27,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const Frame = @import("../Frame.zig"); +const js = @import("../js/js.zig"); const Node = @import("../webapi/Node.zig"); const Event = @import("../webapi/Event.zig"); @@ -271,6 +272,58 @@ pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node { return null; } +fn runJavascriptUrl(frame: *Frame, source: []const u8) !void { + const arena = try frame.getArena(.tiny, "javascript-url"); + errdefer frame.releaseArena(arena); + + const task = try arena.create(JavascriptUrlTask); + task.* = .{ + .frame = frame, + .arena = arena, + // TODO: the URL body should be percent-decoded; hrefs written in + // markup rarely are. + .source = try arena.dupe(u8, source), + }; + try frame.js.scheduler.add(task, JavascriptUrlTask.run, 0, .{ + .name = "javascript-url", + .finalizer = JavascriptUrlTask.finalize, + }); +} + +const JavascriptUrlTask = struct { + frame: *Frame, + arena: std.mem.Allocator, + source: []const u8, + + fn run(ptr: *anyopaque) !?u32 { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + const frame = self.frame; + defer self.deinit(); + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const script = ls.local.compile(self.source, "javascript:") catch |err| { + log.warn(.browser, "javascript-url compile", .{ .err = err, .type = frame._type, .url = frame.url }); + return null; + }; + _ = script.run() catch |err| { + log.warn(.browser, "javascript-url run", .{ .err = err, .type = frame._type, .url = frame.url }); + }; + return null; + } + + fn finalize(ptr: *anyopaque) void { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + fn deinit(self: *JavascriptUrlTask) void { + self.frame.releaseArena(self.arena); + } +}; + pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; @@ -284,7 +337,10 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { } if (std.mem.startsWith(u8, href, "javascript:")) { - return; + // Navigating to a javascript: URL evaluates the script in the + // node's frame as a queued task. (A string completion value + // would replace the document; we ignore results.) + return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]); } if (try element.hasAttribute(comptime .wrap("download"), frame)) { From 18df15959b95429bc02a6a4b616a1fcb1228f09c Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 22:48:56 +0200 Subject: [PATCH 3/6] webapi: add the GamepadEvent interface Fixes WPT /dom/events/Event-timestamp-high-resolution.https.html ("window[eventType] is not a constructor"): the test constructs a GamepadEvent and checks its timeStamp against performance.now(). GamepadEvent is a plain Event subclass with a gamepad member that is always null - there are no gamepads in a headless browser - following the DeviceMotionEvent pattern. Coverage: /dom/events/Event-timestamp-high-resolution.https.html 0/1 -> 1/1 (fully green). Co-Authored-By: Claude Fable 5 --- src/browser/js/bridge.zig | 1 + src/browser/webapi/Event.zig | 2 + src/browser/webapi/event/GamepadEvent.zig | 77 +++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/browser/webapi/event/GamepadEvent.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7bbd62a48..1e8f0d464 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1094,6 +1094,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/BeforeUnloadEvent.zig"), @import("../webapi/event/StorageEvent.zig"), @import("../webapi/event/DeviceMotionEvent.zig"), + @import("../webapi/event/GamepadEvent.zig"), @import("../webapi/event/DeviceOrientationEvent.zig"), @import("../webapi/event/TouchEvent.zig"), @import("../webapi/event/UIEvent.zig"), diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index c344a761f..0c0bad24e 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -87,6 +87,7 @@ pub const Type = union(enum) { before_unload_event: *@import("event/BeforeUnloadEvent.zig"), storage_event: *@import("event/StorageEvent.zig"), device_motion_event: *@import("event/DeviceMotionEvent.zig"), + gamepad_event: *@import("event/GamepadEvent.zig"), device_orientation_event: *@import("event/DeviceOrientationEvent.zig"), ui_event: *@import("event/UIEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), @@ -200,6 +201,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .before_unload_event => |e| return if (T == @import("event/BeforeUnloadEvent.zig")) e else null, .storage_event => |e| return if (T == @import("event/StorageEvent.zig")) e else null, .device_motion_event => |e| return if (T == @import("event/DeviceMotionEvent.zig")) e else null, + .gamepad_event => |e| return if (T == @import("event/GamepadEvent.zig")) e else null, .device_orientation_event => |e| return if (T == @import("event/DeviceOrientationEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, diff --git a/src/browser/webapi/event/GamepadEvent.zig b/src/browser/webapi/event/GamepadEvent.zig new file mode 100644 index 000000000..f0423fec8 --- /dev/null +++ b/src/browser/webapi/event/GamepadEvent.zig @@ -0,0 +1,77 @@ +// 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://w3c.github.io/gamepad/#gamepadevent-interface +const GamepadEvent = @This(); + +_proto: *Event, + +const GamepadEventOptions = struct {}; + +const Options = Event.inheritOptions(GamepadEvent, GamepadEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*GamepadEvent { + const arena = try frame.getArena(.tiny, "GamepadEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + const event = try frame._factory.event( + arena, + type_string, + GamepadEvent{ + ._proto = undefined, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *GamepadEvent) *Event { + return self._proto; +} + +// There are no gamepads in a headless browser. +pub fn getGamepad(_: *const GamepadEvent) ?bool { + return null; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(GamepadEvent); + + pub const Meta = struct { + pub const name = "GamepadEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(GamepadEvent.init, .{}); + pub const gamepad = bridge.accessor(GamepadEvent.getGamepad, null, .{ .null_as_undefined = false }); +}; From 65e4b3c612304398c206de23b295a4eca0739dfc Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 22:59:17 +0200 Subject: [PATCH 4/6] webapi: WebDriver.actionSequence resolves when the actions have run Fixes WPT /dom/events/focus-event-document-move.html: testdriver's Actions().send() promise resolved immediately (the vendor glue returned Promise.resolve()) while the action sequence runs on the next scheduler tick, so tests asserting right after `await ...send()` observed the pre-action state. actionSequence now returns a promise that a persisted resolver settles once the input sources have been performed; the testdriver vendor glue returns it. The persisted resolver handle is page-managed (persist tracks it on the context), so the task does not reset it itself - doing so double-freed the v8 global at page teardown. Coverage: /dom/events/focus-event-document-move.html 0/1 -> 1/1 (fully green); /dom/events regression clean (201 files). Co-Authored-By: Claude Fable 5 --- src/browser/webapi/WebDriver.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index bcbe189bf..ef94f2eec 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -83,7 +83,7 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { // { type: "pointer", actions: [{type: "pointerMove", x, y, origin}, ...] } // { type: "key", actions: [{type: "keyDown", value}, ...] } // { type: "wheel", actions: [{type: "scroll", deltaX, deltaY, origin}, ...] } -pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !void { +pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !js.Promise { if (sources.isArray() == false) { return error.InvalidArgument; } @@ -94,11 +94,16 @@ pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !vo const persisted = try sources.persist(); errdefer persisted.release(); + // Resolved once the actions have been performed, so testdriver's + // Actions().send() promise doesn't settle before the events fired. + const resolver = frame.js.local.?.createPromiseResolver(); + const action_sequence = try arena.create(ActionSequence); action_sequence.* = .{ .frame = frame, .arena = arena, .sources = persisted, + .resolver = try resolver.persist(), }; errdefer action_sequence.sources.release(); @@ -107,12 +112,15 @@ pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !vo .name = "WebDriver.actionSequence", .finalizer = ActionSequence.finalize, }); + + return resolver.promise(); } const ActionSequence = struct { frame: *Frame, arena: Allocator, sources: js.Value.Global, + resolver: js.PromiseResolver.Global, fn run(ptr: *anyopaque) !?u32 { const self: *ActionSequence = @ptrCast(@alignCast(ptr)); @@ -140,6 +148,8 @@ const ActionSequence = struct { } // "none" sources only carry pauses, which have no observable effect here. } + + ls.toLocal(self.resolver).resolve("WebDriver.actionSequence", {}); return null; } @@ -150,6 +160,8 @@ const ActionSequence = struct { fn deinit(self: *ActionSequence) void { self.sources.release(); + // The persisted resolver handle is page-managed; resetting it here + // too would double-free the v8 global at page teardown. self.frame.releaseArena(self.arena); } }; From 2d52bd4f8e85d97da7f5230cf42b15d12982c11a Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Tue, 14 Jul 2026 15:35:41 +0200 Subject: [PATCH 5/6] webapi: resolve the element's frame in hasNonPassiveListener Review pattern from #2944: an injected `frame` parameter is the frame of the calling realm, not the frame owning the element. The wheel/touch cancelability check consulted the caller's event manager and window; for an element living in another frame (e.g. inside an iframe) its listeners are registered in its own frame's event manager and its propagation path ends at that frame's window. Resolve the frame through Element.ownerFrame like the other cross-realm-safe paths. Co-Authored-By: Karl Seguin --- src/browser/webapi/WebDriver.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index ef94f2eec..0aa6e3ae3 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -417,14 +417,18 @@ fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) } fn hasNonPassiveListener(el: *Element, typ: []const u8, frame: *Frame) bool { - const base = &frame._event_manager.base; + // Listeners live in the event manager of the element's own frame (and the + // propagation path ends at that frame's window), which is not the caller's + // frame when the element belongs to e.g. an iframe's document. + const owner = el.ownerFrame(frame); + const base = &owner._event_manager.base; var current: ?*@import("Node.zig") = el.asNode(); while (current) |node| : (current = node.parentNode()) { if (anyNonPassive(base.getListeners(node.asEventTarget(), .wrap(typ)))) { return true; } } - return anyNonPassive(base.getListeners(frame.window.asEventTarget(), .wrap(typ))); + return anyNonPassive(base.getListeners(owner.window.asEventTarget(), .wrap(typ))); } fn anyNonPassive(list_: ?*std.DoublyLinkedList) bool { From 888d6fd52f41f394c8e87b7f9732a125955b9105 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Jul 2026 15:17:17 +0800 Subject: [PATCH 6/6] free WebDriver actionSequence resolver --- src/browser/webapi/CSS.zig | 37 ++++++++++++++--------- src/browser/webapi/WebDriver.zig | 9 ++++-- src/browser/webapi/event/GamepadEvent.zig | 2 +- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig index 85de30ead..0df73dcad 100644 --- a/src/browser/webapi/CSS.zig +++ b/src/browser/webapi/CSS.zig @@ -29,7 +29,30 @@ pub fn parseDimension(value: []const u8) ?f64 { if (value.len == 0) { return null; } + return parseNonEmptyDimension(value); +} +// parseDimension plus viewport-relative units, which the faux layout +// resolves against the page viewport. +pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 { + if (value.len == 0) { + return null; + } + + if (std.mem.endsWith(u8, value, "vh")) { + const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; + return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0; + } + + if (std.mem.endsWith(u8, value, "vw")) { + const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; + return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0; + } + + return parseNonEmptyDimension(value); +} + +fn parseNonEmptyDimension(value: []const u8) ?f64 { var num_str = value; if (std.mem.endsWith(u8, value, "px")) { num_str = value[0 .. value.len - 2]; @@ -38,20 +61,6 @@ pub fn parseDimension(value: []const u8) ?f64 { return std.fmt.parseFloat(f64, num_str) catch null; } -// parseDimension plus viewport-relative units, which the faux layout -// resolves against the page viewport. -pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 { - if (std.mem.endsWith(u8, value, "vh")) { - const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; - return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0; - } - if (std.mem.endsWith(u8, value, "vw")) { - const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null; - return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0; - } - return parseDimension(value); -} - /// Escapes a CSS identifier string /// https://drafts.csswg.org/cssom/#the-css.escape()-method pub fn escape(value: []const u8, frame: *Frame) ![]const u8 { diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 0aa6e3ae3..4ebe5e52e 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -105,7 +105,7 @@ pub fn actionSequence(_: *const WebDriver, sources: js.Value, frame: *Frame) !js .sources = persisted, .resolver = try resolver.persist(), }; - errdefer action_sequence.sources.release(); + errdefer action_sequence.resolver.release(); // cannot be run synchronously, has to be run on the next tick try frame.js.scheduler.add(action_sequence, ActionSequence.run, 0, .{ @@ -131,6 +131,10 @@ const ActionSequence = struct { frame.js.localScope(&ls); defer ls.deinit(); + errdefer |err| { + ls.toLocal(self.resolver).reject("WebDriver.actionSequence", ls.local.newString(@errorName(err))); + } + const sources = self.sources.local(&ls.local).toArray(); for (0..sources.len()) |i| { const source_val = try sources.get(@intCast(i)); @@ -160,8 +164,7 @@ const ActionSequence = struct { fn deinit(self: *ActionSequence) void { self.sources.release(); - // The persisted resolver handle is page-managed; resetting it here - // too would double-free the v8 global at page teardown. + self.resolver.release(); self.frame.releaseArena(self.arena); } }; diff --git a/src/browser/webapi/event/GamepadEvent.zig b/src/browser/webapi/event/GamepadEvent.zig index f0423fec8..62fe4f75c 100644 --- a/src/browser/webapi/event/GamepadEvent.zig +++ b/src/browser/webapi/event/GamepadEvent.zig @@ -73,5 +73,5 @@ pub const JsApi = struct { }; pub const constructor = bridge.constructor(GamepadEvent.init, .{}); - pub const gamepad = bridge.accessor(GamepadEvent.getGamepad, null, .{ .null_as_undefined = false }); + pub const gamepad = bridge.accessor(GamepadEvent.getGamepad, null, .{}); };