From 78c6def2b19c63a311a6447e351c0ea3313d4f98 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:40:50 -0700 Subject: [PATCH 01/16] mcp: add detectForms tool for structured form discovery Add a detectForms MCP tool and lp.detectForms CDP command that return structured form metadata from the current page. Each form includes its action URL, HTTP method, and fields with names, types, required status, values, select options, and backendNodeIds for use with the fill tool. This lets AI agents discover and fill forms in a single step instead of calling interactiveElements, filtering for form fields, and guessing which fields belong to which form. New files: - src/browser/forms.zig: FormInfo/FormField structs, collectForms() Co-Authored-By: Claude Opus 4.6 --- src/browser/forms.zig | 339 +++++++++++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 28 ++++ src/lightpanda.zig | 1 + src/mcp/tools.zig | 146 ++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 src/browser/forms.zig diff --git a/src/browser/forms.zig b/src/browser/forms.zig new file mode 100644 index 00000000..0b2334d3 --- /dev/null +++ b/src/browser/forms.zig @@ -0,0 +1,339 @@ +// 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 Page = @import("Page.zig"); +const TreeWalker = @import("webapi/TreeWalker.zig"); +const Element = @import("webapi/Element.zig"); +const Node = @import("webapi/Node.zig"); + +const Allocator = std.mem.Allocator; + +pub const SelectOption = struct { + value: []const u8, + text: []const u8, + + pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("value"); + try jw.write(self.value); + try jw.objectField("text"); + try jw.write(self.text); + try jw.endObject(); + } +}; + +pub const FormField = struct { + node: *Node, + tag_name: []const u8, + name: ?[]const u8, + input_type: ?[]const u8, + required: bool, + value: ?[]const u8, + placeholder: ?[]const u8, + options: []const SelectOption, + + pub fn jsonStringify(self: *const FormField, jw: anytype) !void { + try jw.beginObject(); + + try jw.objectField("tagName"); + try jw.write(self.tag_name); + + if (self.name) |v| { + try jw.objectField("name"); + try jw.write(v); + } + + if (self.input_type) |v| { + try jw.objectField("inputType"); + try jw.write(v); + } + + if (self.required) { + try jw.objectField("required"); + try jw.write(true); + } + + if (self.value) |v| { + try jw.objectField("value"); + try jw.write(v); + } + + if (self.placeholder) |v| { + try jw.objectField("placeholder"); + try jw.write(v); + } + + if (self.options.len > 0) { + try jw.objectField("options"); + try jw.beginArray(); + for (self.options) |opt| { + try opt.jsonStringify(jw); + } + try jw.endArray(); + } + + try jw.endObject(); + } +}; + +pub const FormInfo = struct { + node: *Node, + action: ?[]const u8, + method: ?[]const u8, + fields: []const FormField, + + pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void { + try jw.beginObject(); + + if (self.action) |v| { + try jw.objectField("action"); + try jw.write(v); + } + + if (self.method) |v| { + try jw.objectField("method"); + try jw.write(v); + } + + try jw.objectField("fields"); + try jw.beginArray(); + for (self.fields) |field| { + try field.jsonStringify(jw); + } + try jw.endArray(); + + try jw.endObject(); + } +}; + +/// Collect all forms and their fields under `root`. +pub fn collectForms( + root: *Node, + arena: Allocator, + page: *Page, +) ![]FormInfo { + var forms: std.ArrayList(FormInfo) = .empty; + + var tw = TreeWalker.Full.init(root, .{}); + while (tw.next()) |node| { + const el = node.is(Element) orelse continue; + if (el.getTag() != .form) continue; + + const form_el = el.is(Element.Html.Form) orelse continue; + + const fields = try collectFormFields(node, arena, page); + if (fields.len == 0) continue; + + const action = form_el.getAction(page) catch null; + const method_str = form_el.getMethod(); + + try forms.append(arena, .{ + .node = node, + .action = if (action) |a| if (a.len > 0) a else null else null, + .method = if (method_str.len > 0) method_str else null, + .fields = fields, + }); + } + + return forms.items; +} + +fn collectFormFields( + form_node: *Node, + arena: Allocator, + page: *Page, +) ![]FormField { + var fields: std.ArrayList(FormField) = .empty; + + var tw = TreeWalker.Full.init(form_node, .{}); + while (tw.next()) |node| { + const el = node.is(Element) orelse continue; + + switch (el.getTag()) { + .input => { + const input = el.is(Element.Html.Input) orelse continue; + if (input._input_type == .hidden) continue; + if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; + + try fields.append(arena, .{ + .node = node, + .tag_name = "input", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = input._input_type.toString(), + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .value = input.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + }, + .textarea => { + const textarea = el.is(Element.Html.TextArea) orelse continue; + + try fields.append(arena, .{ + .node = node, + .tag_name = "textarea", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .value = textarea.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + }, + .select => { + const select = el.is(Element.Html.Select) orelse continue; + + const options = try collectSelectOptions(node, arena, page); + + try fields.append(arena, .{ + .node = node, + .tag_name = "select", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .value = select.getValue(page), + .placeholder = null, + .options = options, + }); + }, + else => {}, + } + } + + return fields.items; +} + +fn collectSelectOptions( + select_node: *Node, + arena: Allocator, + page: *Page, +) ![]SelectOption { + var options: std.ArrayList(SelectOption) = .empty; + const Option = Element.Html.Option; + + var tw = TreeWalker.Full.init(select_node, .{}); + while (tw.next()) |node| { + const el = node.is(Element) orelse continue; + const option = el.is(Option) orelse continue; + + try options.append(arena, .{ + .value = option.getValue(page), + .text = option.getText(page), + }); + } + + return options.items; +} + +const testing = @import("../testing.zig"); + +fn testForms(html: []const u8) ![]FormInfo { + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const doc = page.window._document; + const div = try doc.createElement("div", null, page); + try page.parseHtmlAsChildren(div.asNode(), html); + + return collectForms(div.asNode(), page.call_arena, page); +} + +test "browser.forms: login form" { + const forms = try testForms( + \\
+ \\ + \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual("/login", forms[0].action.?); + try testing.expectEqual("POST", forms[0].method.?); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expectEqual("email", forms[0].fields[0].name.?); + try testing.expectEqual("email", forms[0].fields[0].input_type.?); + try testing.expect(forms[0].fields[0].required); + try testing.expectEqual("password", forms[0].fields[1].name.?); +} + +test "browser.forms: form with select" { + const forms = try testForms( + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("select", forms[0].fields[0].tag_name); + try testing.expectEqual(2, forms[0].fields[0].options.len); + try testing.expectEqual("red", forms[0].fields[0].options[0].value); + try testing.expectEqual("Red", forms[0].fields[0].options[0].text); +} + +test "browser.forms: form with textarea" { + const forms = try testForms( + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("textarea", forms[0].fields[0].tag_name); + try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?); +} + +test "browser.forms: empty form skipped" { + const forms = try testForms( + \\
+ \\

No fields here

+ \\
+ ); + try testing.expectEqual(0, forms.len); +} + +test "browser.forms: hidden inputs excluded" { + const forms = try testForms( + \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("username", forms[0].fields[0].name.?); +} + +test "browser.forms: multiple forms" { + const forms = try testForms( + \\
+ \\ + \\
+ \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(2, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual(2, forms[1].fields.len); +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index f112055d..a17e6dda 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -32,6 +32,7 @@ pub fn processMessage(cmd: anytype) !void { getSemanticTree, getInteractiveElements, getStructuredData, + detectForms, clickNode, fillNode, scrollNode, @@ -42,6 +43,7 @@ pub fn processMessage(cmd: anytype) !void { .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), .getStructuredData => return getStructuredData(cmd), + .detectForms => return detectForms(cmd), .clickNode => return clickNode(cmd), .fillNode => return fillNode(cmd), .scrollNode => return scrollNode(cmd), @@ -160,6 +162,32 @@ fn getStructuredData(cmd: anytype) !void { }, .{}); } +fn detectForms(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const forms_data = try lp.forms.collectForms( + page.document.asNode(), + cmd.arena, + page, + ); + + // Register form and field nodes for backendNodeId references + var form_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, forms_data.len); + for (forms_data) |form| { + const registered = try bc.node_registry.register(form.node); + form_ids.appendAssumeCapacity(registered.id); + for (form.fields) |field| { + _ = try bc.node_registry.register(field.node); + } + } + + return cmd.sendResult(.{ + .forms = forms_data, + .formNodeIds = form_ids.items, + }, .{}); +} + fn clickNode(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 1add3dc4..fa3365a0 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.zig"); +pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 1e286209..093a772a 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -101,6 +101,18 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "detectForms", + .description = "Detect all forms on the page and return their structure including fields, types, and required status. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before detecting forms." } + \\ } + \\} + ), + }, .{ .name = "click", .description = "Click on an interactive element.", @@ -252,6 +264,7 @@ const ToolAction = enum { links, interactiveElements, structuredData, + detectForms, evaluate, semantic_tree, click, @@ -267,6 +280,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "links", .links }, .{ "interactiveElements", .interactiveElements }, .{ "structuredData", .structuredData }, + .{ "detectForms", .detectForms }, .{ "evaluate", .evaluate }, .{ "semantic_tree", .semantic_tree }, .{ "click", .click }, @@ -299,6 +313,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .links => try handleLinks(server, arena, req.id.?, call_params.arguments), .interactiveElements => try handleInteractiveElements(server, arena, req.id.?, call_params.arguments), .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), + .detectForms => try handleDetectForms(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), .click => try handleClick(server, arena, req.id.?, call_params.arguments), @@ -435,6 +450,137 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json. try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } +const FormWithId = struct { + backendNodeId: CDPNode.Id, + action: ?[]const u8, + method: ?[]const u8, + fields: []const FormFieldWithId, + + pub fn jsonStringify(self: *const FormWithId, jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("backendNodeId"); + try jw.write(self.backendNodeId); + if (self.action) |a| { + try jw.objectField("action"); + try jw.write(a); + } + if (self.method) |m| { + try jw.objectField("method"); + try jw.write(m); + } + try jw.objectField("fields"); + try jw.beginArray(); + for (self.fields) |field| { + try field.jsonStringify(jw); + } + try jw.endArray(); + try jw.endObject(); + } +}; + +const FormFieldWithId = struct { + backendNodeId: CDPNode.Id, + tag_name: []const u8, + name: ?[]const u8, + input_type: ?[]const u8, + required: bool, + value: ?[]const u8, + placeholder: ?[]const u8, + options: []const lp.forms.SelectOption, + + pub fn jsonStringify(self: *const FormFieldWithId, jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("backendNodeId"); + try jw.write(self.backendNodeId); + try jw.objectField("tagName"); + try jw.write(self.tag_name); + if (self.name) |v| { + try jw.objectField("name"); + try jw.write(v); + } + if (self.input_type) |v| { + try jw.objectField("inputType"); + try jw.write(v); + } + if (self.required) { + try jw.objectField("required"); + try jw.write(true); + } + if (self.value) |v| { + try jw.objectField("value"); + try jw.write(v); + } + if (self.placeholder) |v| { + try jw.objectField("placeholder"); + try jw.write(v); + } + if (self.options.len > 0) { + try jw.objectField("options"); + try jw.beginArray(); + for (self.options) |opt| { + try opt.jsonStringify(jw); + } + try jw.endArray(); + } + try jw.endObject(); + } +}; + +fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + url: ?[:0]const u8 = null, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const forms_data = lp.forms.collectForms(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "form collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect forms"); + }; + + // Build output with backendNodeIds + var results: std.ArrayList(FormWithId) = .empty; + for (forms_data) |form| { + const form_registered = try server.node_registry.register(form.node); + + var fields_with_ids: std.ArrayList(FormFieldWithId) = .empty; + for (form.fields) |field| { + const field_registered = try server.node_registry.register(field.node); + try fields_with_ids.append(arena, .{ + .backendNodeId = field_registered.id, + .tag_name = field.tag_name, + .name = field.name, + .input_type = field.input_type, + .required = field.required, + .value = field.value, + .placeholder = field.placeholder, + .options = field.options, + }); + } + + try results.append(arena, .{ + .backendNodeId = form_registered.id, + .action = form.action, + .method = form.method, + .fields = fields_with_ids.items, + }); + } + + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(results.items, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate"); From 4d2826583997794606b07f3577c7a2dedd9d9502 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:54:33 -0700 Subject: [PATCH 02/16] fix: use raw action attribute instead of resolved URL in forms Form.getAction() resolves relative URLs against the page base, which causes test failures when the page URL is a test server address. Use the raw action attribute value instead, which matches what agents need to understand the form's target path. Co-Authored-By: Claude Opus 4.6 --- src/browser/forms.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 0b2334d3..fd92f722 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -141,12 +141,12 @@ pub fn collectForms( const fields = try collectFormFields(node, arena, page); if (fields.len == 0) continue; - const action = form_el.getAction(page) catch null; + const action_attr = el.getAttributeSafe(comptime .wrap("action")); const method_str = form_el.getMethod(); try forms.append(arena, .{ .node = node, - .action = if (action) |a| if (a.len > 0) a else null else null, + .action = if (action_attr) |a| if (a.len > 0) a else null else null, .method = if (method_str.len > 0) method_str else null, .fields = fields, }); From ad83c6e70bc6e7db58bdbaab0a420836e5be3b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 22 Mar 2026 21:14:26 +0900 Subject: [PATCH 03/16] test: fix forms unit test method casing to match normalization --- src/browser/forms.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index fd92f722..67f7573e 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -265,7 +265,7 @@ test "browser.forms: login form" { ); try testing.expectEqual(1, forms.len); try testing.expectEqual("/login", forms[0].action.?); - try testing.expectEqual("POST", forms[0].method.?); + try testing.expectEqual("post", forms[0].method.?); try testing.expectEqual(2, forms[0].fields.len); try testing.expectEqual("email", forms[0].fields[0].name.?); try testing.expectEqual("email", forms[0].fields[0].input_type.?); From a6d2ec7610fe1a4423d1fac9ba949a2137fb7f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 23 Mar 2026 10:18:24 +0900 Subject: [PATCH 04/16] refactor: share form node ID serialization between MCP and CDP --- src/browser/forms.zig | 12 +++++ src/cdp/domains/lp.zig | 11 ++--- src/mcp/tools.zig | 105 +++-------------------------------------- 3 files changed, 23 insertions(+), 105 deletions(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 67f7573e..67cbd5b5 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -40,6 +40,7 @@ pub const SelectOption = struct { }; pub const FormField = struct { + backendNodeId: ?u32 = null, node: *Node, tag_name: []const u8, name: ?[]const u8, @@ -52,6 +53,11 @@ pub const FormField = struct { pub fn jsonStringify(self: *const FormField, jw: anytype) !void { try jw.beginObject(); + if (self.backendNodeId) |id| { + try jw.objectField("backendNodeId"); + try jw.write(id); + } + try jw.objectField("tagName"); try jw.write(self.tag_name); @@ -94,6 +100,7 @@ pub const FormField = struct { }; pub const FormInfo = struct { + backendNodeId: ?u32 = null, node: *Node, action: ?[]const u8, method: ?[]const u8, @@ -102,6 +109,11 @@ pub const FormInfo = struct { pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void { try jw.beginObject(); + if (self.backendNodeId) |id| { + try jw.objectField("backendNodeId"); + try jw.write(id); + } + if (self.action) |v| { try jw.objectField("action"); try jw.write(v); diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index a17e6dda..031e39fd 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -173,18 +173,17 @@ fn detectForms(cmd: anytype) !void { ); // Register form and field nodes for backendNodeId references - var form_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, forms_data.len); - for (forms_data) |form| { + for (forms_data) |*form| { const registered = try bc.node_registry.register(form.node); - form_ids.appendAssumeCapacity(registered.id); - for (form.fields) |field| { - _ = try bc.node_registry.register(field.node); + form.backendNodeId = registered.id; + for (@constCast(form.fields)) |*field| { + const field_registered = try bc.node_registry.register(field.node); + field.backendNodeId = field_registered.id; } } return cmd.sendResult(.{ .forms = forms_data, - .formNodeIds = form_ids.items, }, .{}); } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 093a772a..b7d3f942 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -450,82 +450,6 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json. try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } -const FormWithId = struct { - backendNodeId: CDPNode.Id, - action: ?[]const u8, - method: ?[]const u8, - fields: []const FormFieldWithId, - - pub fn jsonStringify(self: *const FormWithId, jw: anytype) !void { - try jw.beginObject(); - try jw.objectField("backendNodeId"); - try jw.write(self.backendNodeId); - if (self.action) |a| { - try jw.objectField("action"); - try jw.write(a); - } - if (self.method) |m| { - try jw.objectField("method"); - try jw.write(m); - } - try jw.objectField("fields"); - try jw.beginArray(); - for (self.fields) |field| { - try field.jsonStringify(jw); - } - try jw.endArray(); - try jw.endObject(); - } -}; - -const FormFieldWithId = struct { - backendNodeId: CDPNode.Id, - tag_name: []const u8, - name: ?[]const u8, - input_type: ?[]const u8, - required: bool, - value: ?[]const u8, - placeholder: ?[]const u8, - options: []const lp.forms.SelectOption, - - pub fn jsonStringify(self: *const FormFieldWithId, jw: anytype) !void { - try jw.beginObject(); - try jw.objectField("backendNodeId"); - try jw.write(self.backendNodeId); - try jw.objectField("tagName"); - try jw.write(self.tag_name); - if (self.name) |v| { - try jw.objectField("name"); - try jw.write(v); - } - if (self.input_type) |v| { - try jw.objectField("inputType"); - try jw.write(v); - } - if (self.required) { - try jw.objectField("required"); - try jw.write(true); - } - if (self.value) |v| { - try jw.objectField("value"); - try jw.write(v); - } - if (self.placeholder) |v| { - try jw.objectField("placeholder"); - try jw.write(v); - } - if (self.options.len > 0) { - try jw.objectField("options"); - try jw.beginArray(); - for (self.options) |opt| { - try opt.jsonStringify(jw); - } - try jw.endArray(); - } - try jw.endObject(); - } -}; - fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const Params = struct { url: ?[:0]const u8 = null, @@ -546,36 +470,19 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val return server.sendError(id, .InternalError, "Failed to collect forms"); }; - // Build output with backendNodeIds - var results: std.ArrayList(FormWithId) = .empty; - for (forms_data) |form| { + // Register form and field nodes for backendNodeId references + for (forms_data) |*form| { const form_registered = try server.node_registry.register(form.node); + form.backendNodeId = form_registered.id; - var fields_with_ids: std.ArrayList(FormFieldWithId) = .empty; - for (form.fields) |field| { + for (@constCast(form.fields)) |*field| { const field_registered = try server.node_registry.register(field.node); - try fields_with_ids.append(arena, .{ - .backendNodeId = field_registered.id, - .tag_name = field.tag_name, - .name = field.name, - .input_type = field.input_type, - .required = field.required, - .value = field.value, - .placeholder = field.placeholder, - .options = field.options, - }); + field.backendNodeId = field_registered.id; } - - try results.append(arena, .{ - .backendNodeId = form_registered.id, - .action = form.action, - .method = form.method, - .fields = fields_with_ids.items, - }); } var aw: std.Io.Writer.Allocating = .init(arena); - try std.json.Stringify.value(results.items, .{}, &aw.writer); + try std.json.Stringify.value(forms_data, .{}, &aw.writer); const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); From 4b29823a5bb48a220f8cf9531b70d6f2ef953c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 23 Mar 2026 13:24:21 +0900 Subject: [PATCH 05/16] refactor: simplify form extraction and remove const casts --- src/browser/forms.zig | 6 +++--- src/cdp/domains/lp.zig | 2 +- src/mcp/tools.zig | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 67cbd5b5..2619763f 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -48,7 +48,7 @@ pub const FormField = struct { required: bool, value: ?[]const u8, placeholder: ?[]const u8, - options: []const SelectOption, + options: []SelectOption, pub fn jsonStringify(self: *const FormField, jw: anytype) !void { try jw.beginObject(); @@ -104,7 +104,7 @@ pub const FormInfo = struct { node: *Node, action: ?[]const u8, method: ?[]const u8, - fields: []const FormField, + fields: []FormField, pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void { try jw.beginObject(); @@ -159,7 +159,7 @@ pub fn collectForms( try forms.append(arena, .{ .node = node, .action = if (action_attr) |a| if (a.len > 0) a else null else null, - .method = if (method_str.len > 0) method_str else null, + .method = method_str, .fields = fields, }); } diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 031e39fd..3944ea40 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -176,7 +176,7 @@ fn detectForms(cmd: anytype) !void { for (forms_data) |*form| { const registered = try bc.node_registry.register(form.node); form.backendNodeId = registered.id; - for (@constCast(form.fields)) |*field| { + for (form.fields) |*field| { const field_registered = try bc.node_registry.register(field.node); field.backendNodeId = field_registered.id; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index b7d3f942..c3d8bb71 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -475,7 +475,7 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val const form_registered = try server.node_registry.register(form.node); form.backendNodeId = form_registered.id; - for (@constCast(form.fields)) |*field| { + for (form.fields) |*field| { const field_registered = try server.node_registry.register(field.node); field.backendNodeId = field_registered.id; } From c37286f8451b27e4c0d6ade31776fe5268628226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:11:47 +0900 Subject: [PATCH 06/16] forms: casting Co-authored-by: Karl Seguin --- src/browser/forms.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 2619763f..9f380f8c 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -196,7 +196,7 @@ fn collectFormFields( }); }, .textarea => { - const textarea = el.is(Element.Html.TextArea) orelse continue; + const textarea = el.as(Element.Html.TextArea); try fields.append(arena, .{ .node = node, From 0bbe25ab5e014b99e40b3e008c4ff56f9168c305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:12:02 +0900 Subject: [PATCH 07/16] forms: casting Co-authored-by: Karl Seguin --- src/browser/forms.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 9f380f8c..c4f661e5 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -180,7 +180,7 @@ fn collectFormFields( switch (el.getTag()) { .input => { - const input = el.is(Element.Html.Input) orelse continue; + const input = el.as(Element.Html.Input); if (input._input_type == .hidden) continue; if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; From a6e801be59e1e7d47930639b43d6f9eac2d22bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:12:16 +0900 Subject: [PATCH 08/16] forms: casting Co-authored-by: Karl Seguin --- src/browser/forms.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index c4f661e5..1cfa69b8 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -210,7 +210,7 @@ fn collectFormFields( }); }, .select => { - const select = el.is(Element.Html.Select) orelse continue; + const select = el.as(Element.Html.Select); const options = try collectSelectOptions(node, arena, page); From c3a2318ecafa9c870c6ba224ffe7cd6b9fba1e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 23 Mar 2026 15:27:49 +0900 Subject: [PATCH 09/16] fix: pass allocator as first parameter in forms.zig --- src/browser/forms.zig | 31 +++++++++++++++++++++---------- src/cdp/domains/lp.zig | 2 +- src/mcp/tools.zig | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 1cfa69b8..b895da28 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -137,8 +137,8 @@ pub const FormInfo = struct { /// Collect all forms and their fields under `root`. pub fn collectForms( - root: *Node, arena: Allocator, + root: *Node, page: *Page, ) ![]FormInfo { var forms: std.ArrayList(FormInfo) = .empty; @@ -150,7 +150,7 @@ pub fn collectForms( const form_el = el.is(Element.Html.Form) orelse continue; - const fields = try collectFormFields(node, arena, page); + const fields = try collectFormFields(arena, node, page); if (fields.len == 0) continue; const action_attr = el.getAttributeSafe(comptime .wrap("action")); @@ -168,8 +168,8 @@ pub fn collectForms( } fn collectFormFields( - form_node: *Node, arena: Allocator, + form_node: *Node, page: *Page, ) ![]FormField { var fields: std.ArrayList(FormField) = .empty; @@ -180,7 +180,7 @@ fn collectFormFields( switch (el.getTag()) { .input => { - const input = el.as(Element.Html.Input); + const input = el.is(Element.Html.Input) orelse continue; if (input._input_type == .hidden) continue; if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; @@ -196,7 +196,7 @@ fn collectFormFields( }); }, .textarea => { - const textarea = el.as(Element.Html.TextArea); + const textarea = el.is(Element.Html.TextArea) orelse continue; try fields.append(arena, .{ .node = node, @@ -210,9 +210,9 @@ fn collectFormFields( }); }, .select => { - const select = el.as(Element.Html.Select); + const select = el.is(Element.Html.Select) orelse continue; - const options = try collectSelectOptions(node, arena, page); + const options = try collectSelectOptions(arena, node, page); try fields.append(arena, .{ .node = node, @@ -233,8 +233,8 @@ fn collectFormFields( } fn collectSelectOptions( - select_node: *Node, arena: Allocator, + select_node: *Node, page: *Page, ) ![]SelectOption { var options: std.ArrayList(SelectOption) = .empty; @@ -258,16 +258,17 @@ const testing = @import("../testing.zig"); fn testForms(html: []const u8) ![]FormInfo { const page = try testing.test_session.createPage(); - defer testing.test_session.removePage(); const doc = page.window._document; const div = try doc.createElement("div", null, page); try page.parseHtmlAsChildren(div.asNode(), html); - return collectForms(div.asNode(), page.call_arena, page); + return collectForms(page.call_arena, div.asNode(), page); } test "browser.forms: login form" { + defer testing.reset(); + defer testing.test_session.removePage(); const forms = try testForms( \\
\\ @@ -286,6 +287,8 @@ test "browser.forms: login form" { } test "browser.forms: form with select" { + defer testing.reset(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -315,6 +320,8 @@ test "browser.forms: form with textarea" { } test "browser.forms: empty form skipped" { + defer testing.reset(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\

No fields here

@@ -324,6 +331,8 @@ test "browser.forms: empty form skipped" { } test "browser.forms: hidden inputs excluded" { + defer testing.reset(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -336,6 +345,8 @@ test "browser.forms: hidden inputs excluded" { } test "browser.forms: multiple forms" { + defer testing.reset(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 3944ea40..509500bd 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -167,8 +167,8 @@ fn detectForms(cmd: anytype) !void { const page = bc.session.currentPage() orelse return error.PageNotLoaded; const forms_data = try lp.forms.collectForms( - page.document.asNode(), cmd.arena, + page.document.asNode(), page, ); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index c3d8bb71..57c339f0 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -465,7 +465,7 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; - const forms_data = lp.forms.collectForms(page.document.asNode(), arena, page) catch |err| { + const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| { log.err(.mcp, "form collection failed", .{ .err = err }); return server.sendError(id, .InternalError, "Failed to collect forms"); }; From 35551ac84e6550915518bb87952d56f6d9f9da21 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:30:52 -0700 Subject: [PATCH 10/16] fix: add disabled flag, external form fields, and param ordering Address review feedback from @karlseguin: 1. Use Form.getElements() instead of manual TreeWalker for field collection. This reuses NodeLive(.form) which handles fields outside the via the form="id" attribute per spec. 2. Add disabled detection: checks both the element's disabled attribute and ancestor
(with first-legend exemption per spec). Fields are flagged rather than excluded - agents need visibility into disabled state. 3. allocator is now the first parameter in collectForms/helpers. 4. handleDetectForms returns InvalidParams on bad input instead of silently swallowing parse errors. 5. Added tests for disabled fields, disabled fieldsets, and external form fields via form="id". Co-Authored-By: Claude Opus 4.6 (1M context) --- src/browser/forms.zig | 203 ++++++++++++++++++++++++++++++------------ src/mcp/tools.zig | 11 +-- 2 files changed, 153 insertions(+), 61 deletions(-) diff --git a/src/browser/forms.zig b/src/browser/forms.zig index b895da28..5277c614 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -46,6 +46,7 @@ pub const FormField = struct { name: ?[]const u8, input_type: ?[]const u8, required: bool, + disabled: bool, value: ?[]const u8, placeholder: ?[]const u8, options: []SelectOption, @@ -76,6 +77,11 @@ pub const FormField = struct { try jw.write(true); } + if (self.disabled) { + try jw.objectField("disabled"); + try jw.write(true); + } + if (self.value) |v| { try jw.objectField("value"); try jw.write(v); @@ -136,6 +142,8 @@ pub const FormInfo = struct { }; /// Collect all forms and their fields under `root`. +/// Uses Form.getElements() to include fields outside the that +/// reference it via the form="id" attribute, matching browser behavior. pub fn collectForms( arena: Allocator, root: *Node, @@ -145,16 +153,14 @@ pub fn collectForms( var tw = TreeWalker.Full.init(root, .{}); while (tw.next()) |node| { - const el = node.is(Element) orelse continue; - if (el.getTag() != .form) continue; + const form = node.is(Element.Html.Form) orelse continue; + const el = form.asElement(); - const form_el = el.is(Element.Html.Form) orelse continue; - - const fields = try collectFormFields(arena, node, page); + const fields = try collectFormFields(arena, form, page); if (fields.len == 0) continue; const action_attr = el.getAttributeSafe(comptime .wrap("action")); - const method_str = form_el.getMethod(); + const method_str = form.getMethod(); try forms.append(arena, .{ .node = node, @@ -169,64 +175,71 @@ pub fn collectForms( fn collectFormFields( arena: Allocator, - form_node: *Node, + form: *Element.Html.Form, page: *Page, ) ![]FormField { var fields: std.ArrayList(FormField) = .empty; + const form_node = form.asNode(); - var tw = TreeWalker.Full.init(form_node, .{}); - while (tw.next()) |node| { - const el = node.is(Element) orelse continue; + var elements = try form.getElements(page); + var it = try elements.iterator(); + while (it.next()) |el| { + const node = el.asNode(); - switch (el.getTag()) { - .input => { - const input = el.is(Element.Html.Input) orelse continue; - if (input._input_type == .hidden) continue; - if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; + const is_disabled = el.getAttributeSafe(comptime .wrap("disabled")) != null or + isDisabledByFieldset(el, form_node); - try fields.append(arena, .{ - .node = node, - .tag_name = "input", - .name = el.getAttributeSafe(comptime .wrap("name")), - .input_type = input._input_type.toString(), - .required = el.getAttributeSafe(comptime .wrap("required")) != null, - .value = input.getValue(), - .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), - .options = &.{}, - }); - }, - .textarea => { - const textarea = el.is(Element.Html.TextArea) orelse continue; + if (el.is(Element.Html.Input)) |input| { + if (input._input_type == .hidden) continue; + if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; - try fields.append(arena, .{ - .node = node, - .tag_name = "textarea", - .name = el.getAttributeSafe(comptime .wrap("name")), - .input_type = null, - .required = el.getAttributeSafe(comptime .wrap("required")) != null, - .value = textarea.getValue(), - .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), - .options = &.{}, - }); - }, - .select => { - const select = el.is(Element.Html.Select) orelse continue; - - const options = try collectSelectOptions(arena, node, page); - - try fields.append(arena, .{ - .node = node, - .tag_name = "select", - .name = el.getAttributeSafe(comptime .wrap("name")), - .input_type = null, - .required = el.getAttributeSafe(comptime .wrap("required")) != null, - .value = select.getValue(page), - .placeholder = null, - .options = options, - }); - }, - else => {}, + try fields.append(arena, .{ + .node = node, + .tag_name = "input", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = input._input_type.toString(), + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = input.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + continue; } + + if (el.is(Element.Html.TextArea)) |textarea| { + try fields.append(arena, .{ + .node = node, + .tag_name = "textarea", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = textarea.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + continue; + } + + if (el.is(Element.Html.Select)) |select| { + const options = try collectSelectOptions(arena, node, page); + + try fields.append(arena, .{ + .node = node, + .tag_name = "select", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = select.getValue(page), + .placeholder = null, + .options = options, + }); + continue; + } + + // Button elements from getElements() - skip (not fillable) } return fields.items; @@ -254,6 +267,38 @@ fn collectSelectOptions( return options.items; } +/// Returns true if `element` is disabled by an ancestor
, +/// stopping at the form boundary. +/// Per spec, elements inside the first child of a disabled fieldset +/// are NOT disabled by that fieldset. +fn isDisabledByFieldset(element: *Element, form_node: *Node) bool { + const element_node = element.asNode(); + var current: ?*Node = element_node._parent; + while (current) |node| { + if (node == form_node) { + return false; + } + + current = node._parent; + const el = node.is(Element) orelse continue; + + if (el.getTag() == .fieldset and el.getAttributeSafe(comptime .wrap("disabled")) != null) { + var child = el.firstElementChild(); + while (child) |c| { + if (c.getTag() == .legend) { + if (c.asNode().contains(element_node)) { + return false; + } + break; + } + child = c.nextElementSibling(); + } + return true; + } + } + return false; +} + const testing = @import("../testing.zig"); fn testForms(html: []const u8) ![]FormInfo { @@ -283,6 +328,7 @@ test "browser.forms: login form" { try testing.expectEqual("email", forms[0].fields[0].name.?); try testing.expectEqual("email", forms[0].fields[0].input_type.?); try testing.expect(forms[0].fields[0].required); + try testing.expect(!forms[0].fields[0].disabled); try testing.expectEqual("password", forms[0].fields[1].name.?); } @@ -360,3 +406,48 @@ test "browser.forms: multiple forms" { try testing.expectEqual(1, forms[0].fields.len); try testing.expectEqual(2, forms[1].fields.len); } + +test "browser.forms: disabled fields flagged" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\ + \\ + \\ + \\ + ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expect(!forms[0].fields[0].disabled); + try testing.expect(forms[0].fields[1].disabled); +} + +test "browser.forms: disabled fieldset" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\
+ \\ + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expect(forms[0].fields[0].disabled); + try testing.expect(!forms[0].fields[1].disabled); +} + +test "browser.forms: external field via form attribute" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\ + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 57c339f0..9701ea5a 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -455,11 +455,12 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val url: ?[:0]const u8 = null, }; if (arguments) |args_raw| { - if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { - if (args.url) |u| { - try performGoto(server, u, id); - } - } else |_| {} + const args = std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true }) catch { + return server.sendError(id, .InvalidParams, "Invalid arguments for detectForms"); + }; + if (args.url) |u| { + try performGoto(server, u, id); + } } const page = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); From 80235e2ddd7581083501f8035e8c645096d3be2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 23 Mar 2026 16:04:21 +0900 Subject: [PATCH 11/16] test: fix scoping bug in frames test causing spurious failures --- src/browser/tests/frames/frames.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 90cb038e..f31e0d31 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -120,9 +120,10 @@