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)) {
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/CSS.zig b/src/browser/webapi/CSS.zig
index 00bd1a952..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];
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/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/WebDriver.zig b/src/browser/webapi/WebDriver.zig
index 140df74ea..4ebe5e52e 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;
@@ -81,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;
}
@@ -92,25 +94,33 @@ 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();
+ 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, .{
.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));
@@ -121,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));
@@ -138,6 +152,8 @@ const ActionSequence = struct {
}
// "none" sources only carry pauses, which have no observable effect here.
}
+
+ ls.toLocal(self.resolver).resolve("WebDriver.actionSequence", {});
return null;
}
@@ -148,6 +164,7 @@ const ActionSequence = struct {
fn deinit(self: *ActionSequence) void {
self.sources.release();
+ self.resolver.release();
self.frame.releaseArena(self.arena);
}
};
@@ -159,9 +176,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 +205,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 +262,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 +362,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 +381,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 +419,45 @@ fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8)
};
}
+fn hasNonPassiveListener(el: *Element, typ: []const u8, frame: *Frame) bool {
+ // 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(owner.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/GamepadEvent.zig b/src/browser/webapi/event/GamepadEvent.zig
new file mode 100644
index 000000000..62fe4f75c
--- /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, .{});
+};
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;
}