diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 655af76b6..0b1df93d4 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -20,6 +20,7 @@ const std = @import("std"); const Frame = @import("Frame.zig"); const URL = @import("URL.zig"); +const Regex = @import("../Regex.zig"); const TreeWalker = @import("webapi/TreeWalker.zig"); const Label = @import("webapi/element/html/Label.zig"); const AXNode = @import("../server/cdp/AXNode.zig"); @@ -154,6 +155,8 @@ const FindFilter = struct { role: ?[]const u8 = null, /// Accessible-name substring match (case-insensitive). When null, name is not filtered. name: ?[]const u8 = null, + /// Compiled pattern searched in the accessible name. + name_regex: ?Regex = null, /// Stop walking once this many matches accumulate. When null, walks the full subtree. max: ?usize = null, }; @@ -229,6 +232,10 @@ fn walkInteractive( const n = name orelse continue; if (std.ascii.indexOfIgnoreCase(n, nf) == null) continue; } + if (filter.name_regex) |re| { + const n = name orelse continue; + if (!re.matches(n)) continue; + } const listener_types = getListenerTypes(el.asEventTarget(), listener_targets); @@ -490,6 +497,30 @@ fn testInteractiveInBody(html: []const u8) ![]InteractiveElement { return collectInteractiveElements(div.asNode(), frame.call_arena, frame); } +test "browser.interactive: a name regex filters the walk" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + const doc = frame.window._document; + const div = try doc.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, div.asNode(), "Add item"); + + const context = testing.test_app.regex_context; + const options: Regex.Options = .{ .case_insensitive = true, .unicode = true }; + + const starts_add = try Regex.compile(context, "^add", options, null); + defer starts_add.deinit(); + const found_add = try findInteractiveElements(div.asNode(), frame.call_arena, frame, .{ .name_regex = starts_add }); + try testing.expectEqual(2, found_add.len); + try testing.expectEqual("Add to cart", found_add[0].name.?); + try testing.expectEqual("Add item", found_add[1].name.?); + + const only_cart = try Regex.compile(context, "^cart$", options, null); + defer only_cart.deinit(); + const found_cart = try findInteractiveElements(div.asNode(), frame.call_arena, frame, .{ .name_regex = only_cart }); + try testing.expectEqual(1, found_cart.len); + try testing.expectEqual("Cart", found_cart[0].name.?); +} + test "browser.interactive: names come from labels, like the tree" { const elements = try testInteractiveInBody( \\ diff --git a/src/browser/tools.zig b/src/browser/tools.zig index ab2499905..777cf1525 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -50,8 +50,8 @@ pub const driver_guidance = \\ values are already in the tree — don't re-fetch via `nodeDetails`. \\- `nodeDetails(backendNodeId)` → a ready-to-use CSS `selector` that \\ resolves to one node, plus its id/class/attrs. - \\- `findElement(role, name)` → locate a candidate by role/name without - \\ parsing the whole tree. + \\- `findElement(role, name)` → locate a candidate by role and name (a + \\ substring, or `/regex/`) without parsing the whole tree. \\- `markdown(selector | backendNodeId)` → readable text for one \\ subtree. Use after `tree` has shown you where the interesting \\ region is. @@ -683,7 +683,7 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." }, - \\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." } + \\ "name": { "type": "string", "description": "Optional accessible name to match, case-insensitive: a substring, or a JavaScript-syntax regex written as /.../ (unanchored: use ^...$ for the whole name; prefix (?-i) for case-sensitive)." } \\ } \\} ), @@ -924,7 +924,7 @@ fn dispatch( .press => .{ .text = try execPress(arena, session, registry, substituted) }, .selectOption => .{ .text = try execSelectOption(arena, session, registry, substituted) }, .setChecked => .{ .text = try execSetChecked(arena, session, registry, substituted) }, - .findElement => .{ .text = try execFindElement(arena, session, registry, substituted) }, + .findElement => execFindElement(arena, session, registry, substituted), .evaluate => execEvaluate(arena, session, registry, substituted), .extract => execExtract(arena, session, registry, substituted), .getEnv => .{ .text = try execGetEnv(arena, substituted) }, @@ -2062,7 +2062,7 @@ fn execSetChecked(arena: std.mem.Allocator, session: *lp.Session, registry: *Nod return finalizeAction(arena, session, registry, scope, body); } -fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *NodeRegistry, arguments: ?std.json.Value) ToolError![]const u8 { +fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *NodeRegistry, arguments: ?std.json.Value) ToolError!ToolResult { const Params = struct { role: ?[]const u8 = null, name: ?[]const u8 = null, @@ -2073,14 +2073,36 @@ fn execFindElement(arena: std.mem.Allocator, session: *lp.Session, registry: *No const page = try requireFrame(session); + const pattern: ?[]const u8 = if (args.name) |name| regexBody(name) else null; + var diag: lp.Regex.Diagnostic = .{}; + const name_regex: ?lp.Regex = if (pattern) |p| + lp.Regex.compile(session.browser.app.regex_context, p, .{ .case_insensitive = true, .unicode = true }, &diag) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidRegex => return .{ + .text = try std.fmt.allocPrint(arena, "findElement: invalid name regex '{s}': {s} at offset {d}", .{ p, diag.message(), diag.offset }), + .is_error = true, + }, + } + else + null; + defer if (name_regex) |re| re.deinit(); + const matched = lp.interactive.findInteractiveElements(page.document.asNode(), arena, page, .{ .role = args.role, - .name = args.name, + .name = if (pattern == null) args.name else null, + .name_regex = name_regex, }) catch return ToolError.InternalError; lp.interactive.registerNodes(matched, registry) catch return ToolError.InternalError; - return renderJson(arena, matched); + return .{ .text = try renderJson(arena, matched) }; +} + +/// The body of a `/.../` literal, the spelling adblock lists use for a regex +/// too. Unanchored, so a name that really is written that way still matches. +fn regexBody(text: []const u8) ?[]const u8 { + if (text.len > 2 and text[0] == '/' and text[text.len - 1] == '/') return text[1 .. text.len - 1]; + return null; } fn execGetEnv(arena: std.mem.Allocator, arguments: ?std.json.Value) ToolError![]const u8 { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 49561705a..0c3b59a03 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -1411,6 +1411,26 @@ test "MCP - findElement" { try testing.expect(std.mem.indexOf(u8, out.written(), "error") != null); out.clearRetainingCapacity(); } + + { + const msg = + \\{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"/^prevent.*default$/"}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Prevent Default") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") == null); + out.clearRetainingCapacity(); + } + + { + const msg = + \\{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"/(/"}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "\"isError\":true") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "missing closing parenthesis at offset 1") != null); + out.clearRetainingCapacity(); + } } test "MCP - waitForSelector: existing element" { diff --git a/src/script/skill.zig b/src/script/skill.zig index 7ad4306c9..0792a91f5 100644 --- a/src/script/skill.zig +++ b/src/script/skill.zig @@ -203,7 +203,8 @@ fn note(tool: browser_tools.Tool) []const u8 { .screenshot => "`path` is required: writes a PNG of the text layout.", .press => "Selector first! `page.press(\"Enter\")` binds \"Enter\" to `selector` and fails — use `page.press(null, \"Enter\")` or `page.press({ key: \"Enter\" })`.", .click, .fill, .scroll, .hover, .selectOption, .setChecked => "", - .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => "", + .findElement => "`name` is a case-insensitive substring, or a JS-syntax regex written as `/.../`.", + .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .consoleLogs, .getUrl, .getCookies, .getEnv => "", }; }