From 6efd88ced918652c66bc4a97dbb82dc0c2ba2c27 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 4 Jun 2026 13:31:34 +0800 Subject: [PATCH] Support WPT action_sequence Depends on: https://github.com/lightpanda-io/wpt/pull/69 WPT can send a list of JSON message to the browser in order to simulate user interaction, e.g.: { type: "pointer", actions: [{type: "pointerMove", x, y, origin}, ...] } While some of these aren't meaningful for us, many are. A lot of these are just: 1 - scroll to an element 2 - mouse down 3 - mouse up With the main goal of generating trusted events. --- src/browser/webapi/WebDriver.zig | 226 ++++++++++++++++++++++ src/browser/webapi/event/PointerEvent.zig | 10 +- src/browser/webapi/event/WheelEvent.zig | 10 +- 3 files changed, 244 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index aa91cc70..bdc98573 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -16,11 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); + const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); const Element = @import("Element.zig"); +const Event = @import("Event.zig"); +const EventTarget = @import("EventTarget.zig"); +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 log = lp.log; // This type is only included when the binary is built with the -Dwpt_extensions flag const WebDriver = @This(); @@ -37,6 +47,221 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ! return (try axnode.getName(frame, frame.call_arena)) orelse ""; } +// Implements testdriver's `action_sequence` (the WebDriver "Perform Actions" +// command) for the renderless browser. We can't do real hit-testing, so we only +// support the subset that targets a concrete element via `origin`. Each input +// source is the serialized form produced by testdriver-actions.js: +// { 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.Object, frame: *Frame) !void { + for (sources) |source| { + const source_type = (try source.get("type")).toSSO(false) catch continue; + if (source_type.eql(comptime .wrap("pointer"))) { + try performPointerSource(source, frame); + } else if (source_type.eql(comptime .wrap("key"))) { + try performKeySource(source, frame); + } else if (source_type.eql(comptime .wrap("wheel"))) { + try performWheelSource(source, frame); + } + // "none" sources only carry pauses, which have no observable effect here. + } +} + +fn performPointerSource(source: js.Object, frame: *Frame) !void { + const actions_val = try source.get("actions"); + if (!actions_val.isArray()) { + return; + } + const actions = actions_val.toArray(); + + // The element the pointer is currently over, set by the last pointerMove + // whose origin resolved to an element. + var target: ?*Element = null; + + for (0..actions.len()) |i| { + const action_val = try actions.get(@intCast(i)); + if (!action_val.isObject()) { + continue; + } + const action = action_val.toObject(); + const action_type = (try action.get("type")).toSSO(false) catch continue; + + if (action_type.eql(comptime .wrap("pointerMove"))) { + const origin = try action.get("origin"); + if (origin.isObject()) { + target = origin.local.jsValueToZig(*Element, origin) catch null; + } + const el = target orelse continue; + dispatchPointer(el, "pointermove", 0, 0, frame); + 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); + dispatchPointer(el, "pointerdown", button, 1, frame); + dispatchMouse(el, "mousedown", button, 1, frame); + } else if (action_type.eql(comptime .wrap("pointerUp"))) { + const el = target orelse continue; + const button = readI32(action, "button", 0); + dispatchPointer(el, "pointerup", button, 0, frame); + 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.) + } +} + +fn performWheelSource(source: js.Object, frame: *Frame) !void { + const actions_val = try source.get("actions"); + if (!actions_val.isArray()) { + return; + } + const actions = actions_val.toArray(); + + for (0..actions.len()) |i| { + const action_val = try actions.get(@intCast(i)); + if (!action_val.isObject()) { + continue; + } + const action = action_val.toObject(); + const action_type = (try action.get("type")).toSSO(false) catch continue; + if (action_type.eql(comptime .wrap("scroll")) == false) { + // "pause" is the only other action and has no observable effect. + continue; + } + + const origin = try action.get("origin"); + if (!origin.isObject()) { + continue; + } + const el = origin.local.jsValueToZig(*Element, origin) catch continue; + + const delta_x = readI32(action, "deltaX", 0); + const delta_y = readI32(action, "deltaY", 0); + dispatchWheel(el, delta_x, delta_y, frame); + } +} + +fn performKeySource(source: js.Object, frame: *Frame) !void { + const actions_val = try source.get("actions"); + if (!actions_val.isArray()) return; + const actions = actions_val.toArray(); + + // Key actions have no explicit target; they go to the focused element, or + // the document if nothing is focused. + const target = if (frame.document._active_element) |el| + el.asEventTarget() + else + frame.document.asNode().asEventTarget(); + + for (0..actions.len()) |i| { + const action_val = try actions.get(@intCast(i)); + if (!action_val.isObject()) continue; + const action = action_val.toObject(); + const action_type = (try action.get("type")).toSSO(false) catch continue; + + const key = (try action.get("value")).toStringSlice() catch ""; + if (action_type.eql(comptime .wrap("keyDown"))) { + dispatchKey(target, comptime .wrap("keydown"), key, frame); + } else if (action_type.eql(comptime .wrap("keyUp"))) { + dispatchKey(target, comptime .wrap("keyup"), key, frame); + } + } +} + +fn dispatchKey(target: *EventTarget, typ: lp.String, key: []const u8, frame: *Frame) void { + const event = KeyboardEvent.initTrusted(typ, .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .key = key, + }, frame) catch |err| { + log.warn(.app, "webdriver key event", .{ .err = err }); + return; + }; + dispatch(target, event.asEvent(), frame, typ.str()); +} + +fn readI32(obj: js.Object, key: []const u8, default: i32) i32 { + const val = obj.get(key) catch return default; + if (val.isNullOrUndefined()) { + return default; + } + return val.toI32() catch default; +} + +fn dispatchPointer(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) void { + const event = PointerEvent.initTrusted(typ, .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .button = button, + .buttons = buttons, + .pointerId = 1, + .pointerType = "mouse", + .isPrimary = true, + }, frame) catch |err| { + log.warn(.app, "webdriver pointer event", .{ .err = err, .type = typ }); + return; + }; + dispatch(el.asEventTarget(), event.asEvent(), frame, typ); +} + +fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) void { + const event = MouseEvent.initTrusted(comptime .wrap(typ), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .button = button, + .buttons = buttons, + }, frame) catch |err| { + log.warn(.app, "webdriver mouse event", .{ .err = err, .type = typ }); + return; + }; + dispatch(el.asEventTarget(), event.asEvent(), frame, typ); +} + +fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void { + const event = WheelEvent.initTrusted("wheel", .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .deltaX = @floatFromInt(delta_x), + .deltaY = @floatFromInt(delta_y), + }, frame) catch |err| { + log.warn(.app, "webdriver wheel event", .{ .err = err }); + return; + }; + + // Keep the event alive past dispatch so we can read _prevent_default. + event.asEvent().acquireRef(); + defer _ = event.asEvent().releaseRef(frame._page); + dispatch(el.asEventTarget(), event.asEvent(), frame, "wheel"); + + if (event.asEvent()._prevent_default) { + return; + } + + // Apply the scroll and fire a trusted scroll event, mirroring actions.scroll. + const new_left: i32 = @as(i32, @intCast(el.getScrollLeft(frame))) + delta_x; + const new_top: i32 = @as(i32, @intCast(el.getScrollTop(frame))) + delta_y; + el.setScrollLeft(new_left, frame) catch {}; + el.setScrollTop(new_top, frame) catch {}; + + const scroll_evt = Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._page) catch |err| { + log.warn(.app, "webdriver scroll event", .{ .err = err }); + return; + }; + dispatch(el.asEventTarget(), scroll_evt, frame, "scroll"); +} + +fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) void { + frame._event_manager.dispatch(target, event) catch |err| { + log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ }); + }; +} + pub const JsApi = struct { pub const bridge = js.Bridge(WebDriver); @@ -48,4 +273,5 @@ pub const JsApi = struct { }; pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{}); pub const getComputedLabel = bridge.function(WebDriver.getComputedLabel, .{}); + pub const actionSequence = bridge.function(WebDriver.actionSequence, .{}); }; diff --git a/src/browser/webapi/event/PointerEvent.zig b/src/browser/webapi/event/PointerEvent.zig index f5038735..4d0897d7 100644 --- a/src/browser/webapi/event/PointerEvent.zig +++ b/src/browser/webapi/event/PointerEvent.zig @@ -86,6 +86,14 @@ const Options = Event.inheritOptions( ); pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent { + return initWithTrusted(typ, _opts, false, frame); +} + +pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent { + return initWithTrusted(typ, _opts, true, frame); +} + +fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*PointerEvent { const arena = try frame.getArena(.tiny, "PointerEvent"); errdefer frame.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); @@ -126,7 +134,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*PointerEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; } diff --git a/src/browser/webapi/event/WheelEvent.zig b/src/browser/webapi/event/WheelEvent.zig index d4104620..e0e6b391 100644 --- a/src/browser/webapi/event/WheelEvent.zig +++ b/src/browser/webapi/event/WheelEvent.zig @@ -52,6 +52,14 @@ pub const Options = Event.inheritOptions( ); pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent { + return initWithTrusted(typ, _opts, false, frame); +} + +pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent { + return initWithTrusted(typ, _opts, true, frame); +} + +fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*WheelEvent { const arena = try frame.getArena(.medium, "WheelEvent"); errdefer frame.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); @@ -85,7 +93,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*WheelEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; }