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.
This commit is contained in:
Karl Seguin
2026-06-04 13:31:34 +08:00
parent 720e610542
commit 6efd88ced9
3 changed files with 244 additions and 2 deletions

View File

@@ -16,11 +16,21 @@
// 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 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, .{});
};

View File

@@ -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;
}

View File

@@ -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;
}