Merge pull request #3515 from lightpanda-io/scroll-tool-container

Scroll the nearest scroll container from the scroll tool
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-15 08:58:15 +08:00
commit baf45f03e9
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());
}