Scroll the nearest scroll container from the scroll tool

The scroll tool (MCP, agent, LP.scrollNode) wrote scrollTop on the exact
node it was given, so a leaf inside an overflow:auto panel stored an
offset on a non-scroller, the panel's own scroll listener never ran, and
the tool reported the requested coordinates as if it had worked. It also
fired a synchronous bubbling scroll on top of the async non-bubbling
scroll/scrollend the setters already schedule.

actions.scroll now resolves the nearest ancestor-or-self scroll
container, falls back to the node itself, and returns the node that
moved plus the read-back position. The tool and LP.scrollNode report
that instead of the request.

The container query moves from user_input.zig onto Element as
scrollContainer(axes), so the wheel path, the tool and WebDriver share
one resolver. WebDriver's wheel scrolled the hit-test element directly
and fired its own bubbling scroll; it now goes through
user_input.wheelScroll like CDP and BiDi wheel.

Window and Element share one ScrollToOpts. Its offsets() helper
normalizes the positional and dictionary forms once, and an omitted axis
in the dictionary form leaves that axis untouched for the window too,
matching browsers, so scrolling the window on one axis no longer resets
the other.
This commit is contained in:
Adrià Arrufat committed 2026-09-14 17:48:25 +02:00
1 parent 72a05c7efa
commit 1e67c6fc91
10 files changed
+204 -154

No files matched your search

+24 -23
View File
@@ -287,33 +287,34 @@ pub fn fill(node: *DOMNode, text: []const u8, frame: *Frame) !void {
try dispatchInputAndChangeEvents(el, frame);
}
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !void {
if (node) |n| {
const el = n.is(Element) orelse return error.InvalidNodeType;
pub const ScrollResult = struct {
/// What moved: the given node, its nearest scroll container, or null for
/// the window.
scrolled: ?*DOMNode,
x: u32,
y: u32,
};
if (x) |val| {
el.setScrollLeft(val, frame) catch |err| {
lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
return error.ActionFailed;
};
}
if (y) |val| {
el.setScrollTop(val, frame) catch |err| {
lp.log.err(.app, "setScrollTop failed", .{ .err = err });
return error.ActionFailed;
};
}
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._page);
frame._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
};
} else {
frame.window.scrollTo(.{ .x = x orelse 0 }, y, frame) catch |err| {
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !ScrollResult {
const n = node orelse {
frame.window.scrollTo(.{ .opts = .{ .left = x, .top = y } }, null, frame) catch |err| {
lp.log.err(.app, "scroll failed", .{ .err = err });
return error.ActionFailed;
};
}
return .{ .scrolled = null, .x = frame.window.getScrollX(), .y = frame.window.getScrollY() };
};
const el = n.is(Element) orelse return error.InvalidNodeType;
const target = el.scrollContainer(.{ .x = x != null, .y = y != null }, frame) orelse el;
target.scrollTo(.{ .opts = .{ .left = x, .top = y } }, null, frame) catch |err| {
lp.log.err(.app, "scroll failed", .{ .err = err });
return error.ActionFailed;
};
return .{
.scrolled = target.asNode(),
.x = target.getScrollLeft(frame),
.y = target.getScrollTop(frame),
};
}
// Floored to 1 so timeout_ms=0 still gets one check instead of failing outright.
+12 -48
View File
@@ -302,65 +302,29 @@ pub fn triggerMouseWheel(frame: *Frame, x: f64, y: f64, delta_x: f64, delta_y: f
}
// CDP deltas are untrusted, so guard NaN and saturate the addition.
try scrollAlong(target, .x, deltaToScroll(delta_x), frame);
try scrollAlong(target, .y, deltaToScroll(delta_y), frame);
try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), frame);
}
const ScrollAxis = enum { x, y };
/// Each axis scrolls the nearest ancestor-or-self scroll container along it,
/// else the viewport. Relative deltas may land on different scrollers per
/// axis, unlike an absolute position.
pub fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void {
try scrollAlong(target, .{ .x = true }, delta_x, frame);
try scrollAlong(target, .{ .y = true }, delta_y, frame);
}
// Each axis scrolls the nearest ancestor-or-self that is a scroll container
// along it, else the viewport. Both scrollBy paths schedule the trusted
// scroll/scrollend events themselves.
fn scrollAlong(target: *Element, axis: ScrollAxis, delta: i32, frame: *Frame) !void {
fn scrollAlong(target: *Element, axes: Element.ScrollAxes, delta: i32, frame: *Frame) !void {
if (delta == 0) {
return;
}
const left: i32, const top: i32 = switch (axis) {
.x => .{ delta, 0 },
.y => .{ 0, delta },
};
if (scrollContainerOf(target, axis, frame)) |container| {
const left: i32 = if (axes.x) delta else 0;
const top: i32 = if (axes.y) delta else 0;
if (target.scrollContainer(axes, frame)) |container| {
return container.scrollBy(.{ .opts = .{ .left = left, .top = top } }, null, frame);
}
return frame.window.scrollBy(.{ .opts = .{ .left = left, .top = top } }, null, frame);
}
// html/body scroll the viewport.
fn scrollContainerOf(start: *Element, axis: ScrollAxis, frame: *Frame) ?*Element {
var current: ?*Element = start;
while (current) |el| : (current = el.parentElement()) {
switch (el.getTag()) {
.html, .body => return null,
else => {},
}
if (isScrollContainer(el, axis, frame)) {
return el;
}
}
return null;
}
// Only inline `overflow` is resolved: computed styles don't cascade stylesheet
// rules, so a sheet-declared scroll container is treated as page content.
fn isScrollContainer(el: *Element, axis: ScrollAxis, frame: *Frame) bool {
const style_manager = &frame._style_manager;
const longhand = switch (axis) {
.x => style_manager.inlineStyleValue(el, comptime .wrap("overflow-x")),
.y => style_manager.inlineStyleValue(el, comptime .wrap("overflow-y")),
};
const value = longhand orelse blk: {
// `overflow: <x> [<y>]`; a single value applies to both axes.
const shorthand = style_manager.inlineStyleValue(el, comptime .wrap("overflow")) orelse return false;
var it = std.mem.tokenizeScalar(u8, shorthand, ' ');
const x = it.next() orelse return false;
break :blk switch (axis) {
.x => x,
.y => it.next() orelse x,
};
};
return std.ascii.eqlIgnoreCase(value, "auto") or std.ascii.eqlIgnoreCase(value, "scroll");
}
fn deltaToScroll(d: f64) i32 {
if (std.math.isNan(d)) return 0;
return @trunc(std.math.clamp(d, std.math.minInt(i32), std.math.maxInt(i32)));
+3
View File
@@ -10,6 +10,9 @@
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div>
</div>
<div id="outerscroll" style="height: 100px; overflow: auto;" onscroll="window.outerScrolled = true;">
<div style="height: 500px;"><p id="innerleaf">Inner leaf</p></div>
</div>
<div id="hoverTarget" onmouseover="window.hovered = true;">Hover Me</div>
<input id="keyTarget" onkeydown="window.keyPressed = event.key;" onkeyup="window.keyReleased = event.key;">
<select id="sel2" onchange="window.sel2Changed = this.value">
+10
View File
@@ -28,6 +28,16 @@
testing.expectEqual(40, window.scrollY);
</script>
<script id=scrollTo_opts_omitted_axis_untouched>
window.scrollTo(30, 40);
window.scrollTo({ top: 60 });
testing.expectEqual(30, window.scrollX);
testing.expectEqual(60, window.scrollY);
window.scrollTo({ left: 10 });
testing.expectEqual(10, window.scrollX);
testing.expectEqual(60, window.scrollY);
</script>
<script id=scrollBy_negative_clamp>
window.scrollTo(10, 10);
window.scrollBy(-100, -100);
+11 -6
View File
@@ -566,7 +566,7 @@ pub const Tool = enum {
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted (or 0), scrolls the window." },
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If the element is not itself a scroll container, its nearest scrollable ancestor is scrolled instead. If omitted (or 0), scrolls the window." },
\\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
\\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
\\ }
@@ -1879,15 +1879,20 @@ fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *NodeReg
y: ?i32 = null,
};
const args = try parseArgsOrDefault(Params, arena, arguments);
const scope = beginAction(session);
const page = try requireFrame(session);
const target_node = try resolveOptionalNode(registry, args.backendNodeId);
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| return mapActionError(err);
const result = lp.actions.scroll(target_node, args.x, args.y, page) catch |err| return mapActionError(err);
return std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}", .{
args.x orelse 0,
args.y orelse 0,
}) catch return ToolError.InternalError;
const body = (if (result.scrolled) |scrolled| blk: {
const moved: ActionTarget = .{ .backend_node_id = (registry.register(scrolled) catch return ToolError.InternalError).id };
break :blk if (scrolled == target_node)
std.fmt.allocPrint(arena, "Scrolled element ({f}) to x: {d}, y: {d}", .{ moved, result.x, result.y })
else
std.fmt.allocPrint(arena, "Scrolled scroll container ({f}) of element ({f}) to x: {d}, y: {d}", .{ moved, ActionTarget{ .backend_node_id = args.backendNodeId.? }, result.x, result.y });
} else std.fmt.allocPrint(arena, "Scrolled window to x: {d}, y: {d}", .{ result.x, result.y })) catch return ToolError.InternalError;
return finalizeAction(arena, session, registry, scope, body);
}
/// Default timeout for the `waitFor*` tools — short, since they wait on an
+61 -20
View File
@@ -22,6 +22,7 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const dump = @import("../dump.zig");
const Frame = @import("../Frame.zig");
const StyleManager = @import("../StyleManager.zig");
const Factory = @import("../Factory.zig");
const CSS = @import("CSS.zig");
@@ -1600,6 +1601,45 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
}
}
pub const ScrollAxes = struct { x: bool = false, y: bool = false };
/// Nearest ancestor-or-self that is a scroll container along any of `axes`.
/// null once the chain reaches html/body: those scroll the viewport.
pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ?*Element {
if (!axes.x and !axes.y) return null;
const owner = self.ownerFrame(frame) orelse return null;
const style_manager = &owner._style_manager;
var current: ?*Element = self;
while (current) |el| : (current = el.parentElement()) {
const tag = el.getTag();
if (tag == .html or tag == .body) return null;
if ((axes.x and el.overflowScrolls(.x, style_manager)) or (axes.y and el.overflowScrolls(.y, style_manager))) {
return el;
}
}
return null;
}
// Only inline `overflow` is resolved: computed styles don't cascade stylesheet
// rules, so a sheet-declared scroll container is treated as page content.
fn overflowScrolls(self: *Element, axis: enum { x, y }, style_manager: *StyleManager) bool {
const longhand = switch (axis) {
.x => style_manager.inlineStyleValue(self, comptime .wrap("overflow-x")),
.y => style_manager.inlineStyleValue(self, comptime .wrap("overflow-y")),
};
const value = longhand orelse blk: {
// `overflow: <x> [<y>]`; a single value applies to both axes.
const shorthand = style_manager.inlineStyleValue(self, comptime .wrap("overflow")) orelse return false;
var it = std.mem.tokenizeAny(u8, shorthand, &std.ascii.whitespace);
const x = it.next() orelse return false;
break :blk switch (axis) {
.x => x,
.y => it.next() orelse x,
};
};
return std.ascii.eqlIgnoreCase(value, "auto") or std.ascii.eqlIgnoreCase(value, "scroll");
}
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
@@ -1948,19 +1988,32 @@ pub fn scrollIntoView(self: *Element, opts: ?ScrollIntoViewOpts, frame: *Frame)
frame.window.scrollTo(.{ .x = 0 }, @trunc(@max(0, y)), frame) catch {};
}
const ScrollToOpts = union(enum) {
// The scrollTo/scrollBy argument shape shared with Window: positional (x, y)
// or a dictionary.
pub const ScrollToOpts = union(enum) {
x: i32,
opts: Opts,
const Opts = struct {
pub const Opts = struct {
behavior: []const u8 = "",
left: ?i32 = null,
top: ?i32 = null,
};
pub const Offsets = struct { left: ?i32, top: ?i32 };
/// Per-axis values; null leaves that axis where it is. Only the dictionary
/// form can omit an axis.
pub fn offsets(self: ScrollToOpts, y: ?i32) Offsets {
return switch (self) {
.x => |x| .{ .left = x, .top = y orelse 0 },
.opts => |o| .{ .left = o.left, .top = o.top },
};
}
};
pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = opts orelse return;
const o = (opts orelse return).offsets(y);
const owner = self.ownerFrame(frame) orelse return;
const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self);
if (!gop.found_existing) {
@@ -1968,16 +2021,8 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo
}
const old_x = gop.value_ptr.x;
const old_y = gop.value_ptr.y;
switch (o) {
.x => |x| {
gop.value_ptr.x = @intCast(@max(0, x));
gop.value_ptr.y = @intCast(@max(0, y orelse 0));
},
.opts => |dict| {
if (dict.left) |left| gop.value_ptr.x = @intCast(@max(0, left));
if (dict.top) |top| gop.value_ptr.y = @intCast(@max(0, top));
},
}
if (o.left) |left| gop.value_ptr.x = @intCast(@max(0, left));
if (o.top) |top| gop.value_ptr.y = @intCast(@max(0, top));
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
try self.scheduleScrollEvents(owner);
}
@@ -1985,20 +2030,16 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo
// scrollBy(): like scrollTo() but relative to the current position.
pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = opts orelse return;
const o = (opts orelse return).offsets(y);
const owner = self.ownerFrame(frame) orelse return;
const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = .{};
}
const dx: i32, const dy: i32 = switch (o) {
.x => |x| .{ x, y orelse 0 },
.opts => |dict| .{ dict.left orelse 0, dict.top orelse 0 },
};
const old_x = gop.value_ptr.x;
const old_y = gop.value_ptr.y;
gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| dx));
gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| dy));
gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| (o.left orelse 0)));
gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| (o.top orelse 0)));
if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) {
try self.scheduleScrollEvents(owner);
}
+2 -10
View File
@@ -626,17 +626,9 @@ fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void {
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;
Frame.user_input.wheelScroll(el, delta_x, delta_y, frame) catch |err| {
log.warn(.app, "webdriver scroll", .{ .err = err });
};
dispatch(el.asEventTarget(), scroll_evt, frame, "scroll");
}
fn dispatch(target: *EventTarget, event: *Event, frame: *Frame, typ: []const u8) void {
+8 -30
View File
@@ -922,21 +922,10 @@ pub fn getInnerHeight(_: *const Window, frame: *Frame) u32 {
return frame._page.getViewport().height;
}
const ScrollToOpts = union(enum) {
x: i32,
opts: Opts,
const Opts = struct {
behavior: []const u8 = "",
left: i32,
top: i32,
};
};
pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void {
const new_x: u32, const new_y: u32 = switch (opts) {
.x => |x| .{ @intCast(@max(x, 0)), @intCast(@max(0, y orelse 0)) },
.opts => |o| .{ @intCast(@max(0, o.left)), @intCast(@max(0, o.top)) },
};
pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = opts.offsets(y);
const new_x: u32 = if (o.left) |left| @intCast(@max(0, left)) else self._scroll_pos.x;
const new_y: u32 = if (o.top) |top| @intCast(@max(0, top)) else self._scroll_pos.y;
if (new_x == self._scroll_pos.x and new_y == self._scroll_pos.y) {
return;
@@ -998,21 +987,10 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void
);
}
pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void {
// The scroll is relative to the current position. So compute to new
// absolute position.
var absx: i32 = undefined;
var absy: i32 = undefined;
switch (opts) {
.x => |x| {
absx = @as(i32, @intCast(self._scroll_pos.x)) +| x;
absy = @as(i32, @intCast(self._scroll_pos.y)) +| (y orelse 0);
},
.opts => |o| {
absx = @as(i32, @intCast(self._scroll_pos.x)) +| o.left;
absy = @as(i32, @intCast(self._scroll_pos.y)) +| o.top;
},
}
pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = opts.offsets(y);
const absx = @as(i32, @intCast(self._scroll_pos.x)) +| (o.left orelse 0);
const absy = @as(i32, @intCast(self._scroll_pos.y)) +| (o.top orelse 0);
return self.scrollTo(.{ .x = absx }, absy, frame);
}
+45 -8
View File
@@ -1169,14 +1169,44 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
out.clearRetainingCapacity();
}
// A scroll container, and a node with no scroll-container ancestor, are
// scrolled themselves.
const scrollbox = frame.document.getElementById("scrollbox", frame).?.asNode();
const scrollbox_id = (try server.active_session.registry.register(scrollbox)).id;
const plain = frame.document.getElementById("plain", frame).?.asNode();
const plain_id = (try server.active_session.registry.register(plain)).id;
for ([_]struct { id: lp.NodeRegistry.Id, y: i32 }{ .{ .id = scrollbox_id, .y = 50 }, .{ .id = plain_id, .y = 7 } }) |c| {
const msg = try std.fmt.allocPrint(aa, "{{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{{\"name\":\"scroll\",\"arguments\":{{\"backendNodeId\":{d},\"y\":{d}}}}}}}", .{ c.id, c.y });
try router.handleMessage(server, aa, msg);
const expected = try std.fmt.allocPrint(aa, "Scrolled element (backendNodeId: {d}) to x: 0, y: {d}", .{ c.id, c.y });
try testing.expect(std.mem.indexOf(u8, out.written(), expected) != null);
out.clearRetainingCapacity();
}
// A leaf inside a scroll container scrolls the container, not the leaf.
{
const scrollbox = frame.document.getElementById("scrollbox", frame).?.asNode();
const scrollbox_id = (try server.active_session.registry.register(scrollbox)).id;
var scroll_id_buf: [12]u8 = undefined;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
try router.handleMessage(server, aa, scroll_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled to x: 0, y: 50") != null);
const leaf = frame.document.getElementById("innerleaf", frame).?.asNode();
const leaf_id = (try server.active_session.registry.register(leaf)).id;
const outer = frame.document.getElementById("outerscroll", frame).?.asNode();
const outer_id = (try server.active_session.registry.register(outer)).id;
const msg = try std.fmt.allocPrint(aa, "{{\"jsonrpc\":\"2.0\",\"id\":40,\"method\":\"tools/call\",\"params\":{{\"name\":\"scroll\",\"arguments\":{{\"backendNodeId\":{d},\"y\":30}}}}}}", .{leaf_id});
try router.handleMessage(server, aa, msg);
const expected = try std.fmt.allocPrint(aa, "Scrolled scroll container (backendNodeId: {d}) of element (backendNodeId: {d}) to x: 0, y: 30", .{ outer_id, leaf_id });
try testing.expect(std.mem.indexOf(u8, out.written(), expected) != null);
out.clearRetainingCapacity();
}
// Without a node the window scrolls; an omitted axis keeps its offset.
{
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"scroll","arguments":{"y":20}}}
);
try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled window to x: 0, y: 20") != null);
out.clearRetainingCapacity();
try router.handleMessage(server, aa,
\\{"jsonrpc":"2.0","id":43,"method":"tools/call","params":{"name":"scroll","arguments":{"x":5}}}
);
try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled window to x: 5, y: 20") != null);
out.clearRetainingCapacity();
}
@@ -1243,6 +1273,10 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
try_catch.init(&ls.local);
defer try_catch.deinit();
// Scroll events are scheduled, not fired inline with the tool.
var runner = server.active_session.session.runner(.{});
try runner.waitForScript(frame._frame_id, "window.scrolled === true && window.outerScrolled === true", 1000);
const result = try ls.local.exec(
\\ JSON.stringify(window.seq) === JSON.stringify([
\\ 'pointerdown:0:1:mouse:true', 'mousedown:0:1::true',
@@ -1253,7 +1287,10 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
\\ window.focusTargetFocused === true && window.plainBlurred === true &&
\\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true &&
\\ document.getElementById('outerscroll').scrollTop === 30 &&
\\ document.getElementById('innerleaf').scrollTop === 0 &&
\\ document.getElementById('plain').scrollTop === 7 &&
\\ window.scrollX === 5 && window.scrollY === 20 &&
\\ window.hovered === true &&
\\ window.keyPressed === 'Enter' && window.keyReleased === 'Enter' &&
\\ window.sel2Changed === 'b' &&
+28 -9
View File
@@ -389,19 +389,21 @@ fn scrollNode(cmd: anytype) !void {
const frame = bc.mainFrame() orelse return error.FrameNotLoaded;
const maybe_node_id = params.nodeId orelse params.backendNodeId;
const target_node: ?*DOMNode = if (maybe_node_id) |node_id|
(bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId).dom
else
null;
var target_node: ?*DOMNode = null;
if (maybe_node_id) |node_id| {
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
target_node = node.dom;
}
lp.actions.scroll(target_node, params.x, params.y, frame) catch |err| {
const result = lp.actions.scroll(target_node, params.x, params.y, frame) catch |err| {
if (err == error.InvalidNodeType) return error.InvalidParam;
return error.InternalError;
};
return cmd.sendResult(.{}, .{});
const target_id: ?NodeRegistry.Id = if (result.scrolled) |scrolled|
(try bc.node_registry.register(scrolled)).id
else
null;
return cmd.sendResult(.{ .backendNodeId = target_id, .x = result.x, .y = result.y }, .{});
}
fn waitForSelector(cmd: anytype) !void {
@@ -712,6 +714,23 @@ test "cdp.lp: action tools" {
.method = "LP.scrollNode",
.params = .{ .backendNodeId = scrollbox_id, .y = 50 },
});
try ctx.expectSentResult(.{ .backendNodeId = scrollbox_id, .x = 0, .y = 50 }, .{ .id = 4 });
// A leaf inside a scroll container scrolls the container, not the leaf.
const leaf = frame.document.getElementById("innerleaf", frame).?.asNode();
const leaf_id = (try bc.node_registry.register(leaf)).id;
const outer = frame.document.getElementById("outerscroll", frame).?.asNode();
const outer_id = (try bc.node_registry.register(outer)).id;
try ctx.processMessage(.{
.id = 5,
.method = "LP.scrollNode",
.params = .{ .backendNodeId = leaf_id, .y = 30 },
});
try ctx.expectSentResult(.{ .backendNodeId = outer_id, .x = 0, .y = 30 }, .{ .id = 5 });
// Scroll events are scheduled, not fired inline with the command.
var runner = bc.session.runner(.{});
try runner.waitForScript(frame._frame_id, "window.scrolled === true && window.outerScrolled === true", 1000);
// Evaluate assertions
var ls: lp.js.Local.Scope = undefined;
@@ -722,7 +741,7 @@ test "cdp.lp: action tools" {
try_catch.init(&ls.local);
defer try_catch.deinit();
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && document.getElementById('outerscroll').scrollTop === 30 && document.getElementById('innerleaf').scrollTop === 0 && window.scrollY === 0", null);
try testing.expect(result.isTrue());
}