From e10ccd846d8073690ef8e788dbdabe628d26fd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 22 Mar 2026 00:08:56 +0900 Subject: [PATCH] CDP: add waitForSelector to lp.actions It refactors the implementation from MCP to be reused. --- src/browser/actions.zig | 22 +++++++++++++ src/cdp/domains/lp.zig | 68 +++++++++++++++++++++++++++++++++++++++++ src/mcp/tools.zig | 27 ++++++---------- 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 951f2b1e..f62cfdbc 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -23,6 +23,7 @@ const Element = @import("webapi/Element.zig"); const Event = @import("webapi/Event.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig"); const Page = @import("Page.zig"); +const Selector = @import("webapi/selector/Selector.zig"); pub fn click(node: *DOMNode, page: *Page) !void { const el = node.is(Element) orelse return error.InvalidNodeType; @@ -102,3 +103,24 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { }; } } + +pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, page: *Page) !*DOMNode { + var timer = try std.time.Timer.start(); + + while (true) { + const element = Selector.querySelector(page.document.asNode(), selector, page) catch { + return error.InvalidSelector; + }; + + if (element) |el| { + return el.asNode(); + } + + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + if (elapsed >= timeout_ms) { + return error.Timeout; + } + + _ = page._session.wait(.{ .timeout_ms = @min(100, timeout_ms - elapsed) }); + } +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index f112055d..2f8114f3 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -35,6 +35,7 @@ pub fn processMessage(cmd: anytype) !void { clickNode, fillNode, scrollNode, + waitForSelector, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -45,6 +46,7 @@ pub fn processMessage(cmd: anytype) !void { .clickNode => return clickNode(cmd), .fillNode => return fillNode(cmd), .scrollNode => return scrollNode(cmd), + .waitForSelector => return waitForSelector(cmd), } } @@ -230,6 +232,32 @@ fn scrollNode(cmd: anytype) !void { return cmd.sendResult(.{}, .{}); } + +fn waitForSelector(cmd: anytype) !void { + const Params = struct { + selector: []const u8, + timeout: ?u32 = null, + }; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const timeout_ms = params.timeout orelse 5000; + const selector_z = try cmd.arena.dupeZ(u8, params.selector); + + const node = lp.actions.waitForSelector(selector_z, timeout_ms, page) catch |err| { + if (err == error.InvalidSelector) return error.InvalidParam; + if (err == error.Timeout) return error.InternalError; + return error.InternalError; + }; + + const registered = try bc.node_registry.register(node); + return cmd.sendResult(.{ + .backendNodeId = registered.id, + }, .{}); +} + const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); @@ -339,3 +367,43 @@ test "cdp.lp: action tools" { try testing.expect(result.isTrue()); } + +test "cdp.lp: waitForSelector" { + var ctx = testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + _ = bc.session.wait(.{}); + + // 1. Existing element + try ctx.processMessage(.{ + .id = 1, + .method = "LP.waitForSelector", + .params = .{ .selector = "#existing", .timeout = 2000 }, + }); + var result = ctx.client.?.sent.items[0].object.get("result").?.object; + try testing.expect(result.get("backendNodeId") != null); + ctx.client.?.sent.clearRetainingCapacity(); + + // 2. Delayed element + try ctx.processMessage(.{ + .id = 2, + .method = "LP.waitForSelector", + .params = .{ .selector = "#delayed", .timeout = 5000 }, + }); + result = ctx.client.?.sent.items[0].object.get("result").?.object; + try testing.expect(result.get("backendNodeId") != null); + ctx.client.?.sent.clearRetainingCapacity(); + + // 3. Timeout error + try ctx.processMessage(.{ + .id = 3, + .method = "LP.waitForSelector", + .params = .{ .selector = "#nonexistent", .timeout = 100 }, + }); + const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object; + try testing.expect(err_obj.get("code") != null); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 1e286209..a7dd502d 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -561,28 +561,21 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json }; const timeout_ms = args.timeout orelse 5000; - var timer = try std.time.Timer.start(); - while (true) { - const element = Selector.querySelector(page.document.asNode(), args.selector, page) catch { + const node = lp.actions.waitForSelector(args.selector, timeout_ms, page) catch |err| { + if (err == error.InvalidSelector) { return server.sendError(id, .InvalidParams, "Invalid selector"); - }; - - if (element) |el| { - const registered = try server.node_registry.register(el.asNode()); - const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; - - const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; - return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); - } - - const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); - if (elapsed >= timeout_ms) { + } else if (err == error.Timeout) { return server.sendError(id, .InternalError, "Timeout waiting for selector"); } + return server.sendError(id, .InternalError, "Failed waiting for selector"); + }; - _ = server.session.wait(.{ .timeout_ms = @min(100, timeout_ms - elapsed) }); - } + const registered = try server.node_registry.register(node); + const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found."; + + const content = [_]protocol.TextContent([]const u8){.{ .text = msg }}; + return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {