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 <form> via the form="id" attribute per spec.

2. Add disabled detection: checks both the element's disabled
   attribute and ancestor <fieldset disabled> (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) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-22 23:30:52 -07:00
parent c3a2318eca
commit 35551ac84e
2 changed files with 153 additions and 61 deletions

View File

@@ -46,6 +46,7 @@ pub const FormField = struct {
name: ?[]const u8, name: ?[]const u8,
input_type: ?[]const u8, input_type: ?[]const u8,
required: bool, required: bool,
disabled: bool,
value: ?[]const u8, value: ?[]const u8,
placeholder: ?[]const u8, placeholder: ?[]const u8,
options: []SelectOption, options: []SelectOption,
@@ -76,6 +77,11 @@ pub const FormField = struct {
try jw.write(true); try jw.write(true);
} }
if (self.disabled) {
try jw.objectField("disabled");
try jw.write(true);
}
if (self.value) |v| { if (self.value) |v| {
try jw.objectField("value"); try jw.objectField("value");
try jw.write(v); try jw.write(v);
@@ -136,6 +142,8 @@ pub const FormInfo = struct {
}; };
/// Collect all forms and their fields under `root`. /// Collect all forms and their fields under `root`.
/// Uses Form.getElements() to include fields outside the <form> that
/// reference it via the form="id" attribute, matching browser behavior.
pub fn collectForms( pub fn collectForms(
arena: Allocator, arena: Allocator,
root: *Node, root: *Node,
@@ -145,16 +153,14 @@ pub fn collectForms(
var tw = TreeWalker.Full.init(root, .{}); var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| { while (tw.next()) |node| {
const el = node.is(Element) orelse continue; const form = node.is(Element.Html.Form) orelse continue;
if (el.getTag() != .form) continue; const el = form.asElement();
const form_el = el.is(Element.Html.Form) orelse continue; const fields = try collectFormFields(arena, form, page);
const fields = try collectFormFields(arena, node, page);
if (fields.len == 0) continue; if (fields.len == 0) continue;
const action_attr = el.getAttributeSafe(comptime .wrap("action")); const action_attr = el.getAttributeSafe(comptime .wrap("action"));
const method_str = form_el.getMethod(); const method_str = form.getMethod();
try forms.append(arena, .{ try forms.append(arena, .{
.node = node, .node = node,
@@ -169,18 +175,21 @@ pub fn collectForms(
fn collectFormFields( fn collectFormFields(
arena: Allocator, arena: Allocator,
form_node: *Node, form: *Element.Html.Form,
page: *Page, page: *Page,
) ![]FormField { ) ![]FormField {
var fields: std.ArrayList(FormField) = .empty; var fields: std.ArrayList(FormField) = .empty;
const form_node = form.asNode();
var tw = TreeWalker.Full.init(form_node, .{}); var elements = try form.getElements(page);
while (tw.next()) |node| { var it = try elements.iterator();
const el = node.is(Element) orelse continue; while (it.next()) |el| {
const node = el.asNode();
switch (el.getTag()) { const is_disabled = el.getAttributeSafe(comptime .wrap("disabled")) != null or
.input => { isDisabledByFieldset(el, form_node);
const input = el.is(Element.Html.Input) orelse continue;
if (el.is(Element.Html.Input)) |input| {
if (input._input_type == .hidden) continue; if (input._input_type == .hidden) continue;
if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue;
@@ -190,28 +199,30 @@ fn collectFormFields(
.name = el.getAttributeSafe(comptime .wrap("name")), .name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = input._input_type.toString(), .input_type = input._input_type.toString(),
.required = el.getAttributeSafe(comptime .wrap("required")) != null, .required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = input.getValue(), .value = input.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{}, .options = &.{},
}); });
}, continue;
.textarea => { }
const textarea = el.is(Element.Html.TextArea) orelse continue;
if (el.is(Element.Html.TextArea)) |textarea| {
try fields.append(arena, .{ try fields.append(arena, .{
.node = node, .node = node,
.tag_name = "textarea", .tag_name = "textarea",
.name = el.getAttributeSafe(comptime .wrap("name")), .name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null, .input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null, .required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = textarea.getValue(), .value = textarea.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{}, .options = &.{},
}); });
}, continue;
.select => { }
const select = el.is(Element.Html.Select) orelse continue;
if (el.is(Element.Html.Select)) |select| {
const options = try collectSelectOptions(arena, node, page); const options = try collectSelectOptions(arena, node, page);
try fields.append(arena, .{ try fields.append(arena, .{
@@ -220,13 +231,15 @@ fn collectFormFields(
.name = el.getAttributeSafe(comptime .wrap("name")), .name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null, .input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null, .required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = select.getValue(page), .value = select.getValue(page),
.placeholder = null, .placeholder = null,
.options = options, .options = options,
}); });
}, continue;
else => {},
} }
// Button elements from getElements() - skip (not fillable)
} }
return fields.items; return fields.items;
@@ -254,6 +267,38 @@ fn collectSelectOptions(
return options.items; return options.items;
} }
/// Returns true if `element` is disabled by an ancestor <fieldset disabled>,
/// stopping at the form boundary.
/// Per spec, elements inside the first <legend> 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"); const testing = @import("../testing.zig");
fn testForms(html: []const u8) ![]FormInfo { 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].name.?);
try testing.expectEqual("email", forms[0].fields[0].input_type.?); 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].required);
try testing.expect(!forms[0].fields[0].disabled);
try testing.expectEqual("password", forms[0].fields[1].name.?); 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(1, forms[0].fields.len);
try testing.expectEqual(2, forms[1].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(
\\<form>
\\ <input type="text" name="enabled_field">
\\ <input type="text" name="disabled_field" disabled>
\\</form>
);
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(
\\<form>
\\ <fieldset disabled>
\\ <input type="text" name="in_disabled_fieldset">
\\ </fieldset>
\\ <input type="text" name="outside_fieldset">
\\</form>
);
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(
\\<input type="text" name="external" form="myform">
\\<form id="myform" action="/submit">
\\ <input type="text" name="internal">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
}

View File

@@ -455,11 +455,12 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
}; };
if (arguments) |args_raw| { if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { 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| { if (args.url) |u| {
try performGoto(server, u, id); try performGoto(server, u, id);
} }
} else |_| {}
} }
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");