mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 15:40:04 +00:00
Merge pull request #1951 from mvanhorn/osc/feat-mcp-detect-forms
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
mcp: add detectForms tool for structured form discovery
This commit is contained in:
460
src/browser/forms.zig
Normal file
460
src/browser/forms.zig
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||||
|
//
|
||||||
|
// Francis Bouvier <francis@lightpanda.io>
|
||||||
|
// Pierre Tachoire <pierre@lightpanda.io>
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
backendNodeId: ?u32 = null,
|
||||||
|
node: *Node,
|
||||||
|
tag_name: []const u8,
|
||||||
|
name: ?[]const u8,
|
||||||
|
input_type: ?[]const u8,
|
||||||
|
required: bool,
|
||||||
|
disabled: bool,
|
||||||
|
value: ?[]const u8,
|
||||||
|
placeholder: ?[]const u8,
|
||||||
|
options: []SelectOption,
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
try jw.objectField("required");
|
||||||
|
try jw.write(self.required);
|
||||||
|
|
||||||
|
try jw.objectField("disabled");
|
||||||
|
try jw.write(self.disabled);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
backendNodeId: ?u32 = null,
|
||||||
|
node: *Node,
|
||||||
|
action: ?[]const u8,
|
||||||
|
method: ?[]const u8,
|
||||||
|
fields: []FormField,
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Populate backendNodeId on each form and its fields by registering
|
||||||
|
/// their nodes in the given registry. Works with both CDP and MCP registries.
|
||||||
|
pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void {
|
||||||
|
for (forms_data) |*form| {
|
||||||
|
const form_registered = try registry.register(form.node);
|
||||||
|
form.backendNodeId = form_registered.id;
|
||||||
|
for (form.fields) |*field| {
|
||||||
|
const field_registered = try registry.register(field.node);
|
||||||
|
field.backendNodeId = field_registered.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// `arena` must be an arena allocator — returned slices borrow its memory.
|
||||||
|
pub fn collectForms(
|
||||||
|
arena: Allocator,
|
||||||
|
root: *Node,
|
||||||
|
page: *Page,
|
||||||
|
) ![]FormInfo {
|
||||||
|
var forms: std.ArrayList(FormInfo) = .empty;
|
||||||
|
|
||||||
|
var tw = TreeWalker.Full.init(root, .{});
|
||||||
|
while (tw.next()) |node| {
|
||||||
|
const form = node.is(Element.Html.Form) orelse continue;
|
||||||
|
const el = form.asElement();
|
||||||
|
|
||||||
|
const fields = try collectFormFields(arena, form, page);
|
||||||
|
if (fields.len == 0) continue;
|
||||||
|
|
||||||
|
const action_attr = el.getAttributeSafe(comptime .wrap("action"));
|
||||||
|
const method_str = form.getMethod();
|
||||||
|
|
||||||
|
try forms.append(arena, .{
|
||||||
|
.node = node,
|
||||||
|
.action = if (action_attr) |a| if (a.len > 0) a else null else null,
|
||||||
|
.method = method_str,
|
||||||
|
.fields = fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return forms.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectFormFields(
|
||||||
|
arena: Allocator,
|
||||||
|
form: *Element.Html.Form,
|
||||||
|
page: *Page,
|
||||||
|
) ![]FormField {
|
||||||
|
var fields: std.ArrayList(FormField) = .empty;
|
||||||
|
|
||||||
|
var elements = try form.getElements(page);
|
||||||
|
var it = try elements.iterator();
|
||||||
|
while (it.next()) |el| {
|
||||||
|
const node = el.asNode();
|
||||||
|
|
||||||
|
const is_disabled = el.isDisabled();
|
||||||
|
|
||||||
|
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 = "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collectSelectOptions(
|
||||||
|
arena: Allocator,
|
||||||
|
select_node: *Node,
|
||||||
|
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();
|
||||||
|
|
||||||
|
const doc = page.window._document;
|
||||||
|
const div = try doc.createElement("div", null, page);
|
||||||
|
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||||
|
|
||||||
|
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(
|
||||||
|
\\<form action="/login" method="POST">
|
||||||
|
\\ <input type="email" name="email" required placeholder="Email">
|
||||||
|
\\ <input type="password" name="password" required>
|
||||||
|
\\ <input type="submit" value="Log In">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
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.expect(!forms[0].fields[0].disabled);
|
||||||
|
try testing.expectEqual("password", forms[0].fields[1].name.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: form with select" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <select name="color">
|
||||||
|
\\ <option value="red">Red</option>
|
||||||
|
\\ <option value="blue">Blue</option>
|
||||||
|
\\ </select>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
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" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form method="POST">
|
||||||
|
\\ <textarea name="message" placeholder="Your message"></textarea>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
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" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form action="/empty">
|
||||||
|
\\ <p>No fields here</p>
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(0, forms.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: hidden inputs excluded" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="hidden" name="csrf" value="token123">
|
||||||
|
\\ <input type="text" name="username">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
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" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form action="/search" method="GET">
|
||||||
|
\\ <input type="text" name="q" placeholder="Search">
|
||||||
|
\\</form>
|
||||||
|
\\<form action="/login" method="POST">
|
||||||
|
\\ <input type="email" name="email">
|
||||||
|
\\ <input type="password" name="pass">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(2, forms.len);
|
||||||
|
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(
|
||||||
|
\\<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: checkbox and radio return value attribute" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="checkbox" name="agree" value="yes" checked>
|
||||||
|
\\ <input type="radio" name="color" value="red">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(2, forms[0].fields.len);
|
||||||
|
try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?);
|
||||||
|
try testing.expectEqual("yes", forms[0].fields[0].value.?);
|
||||||
|
try testing.expectEqual("radio", forms[0].fields[1].input_type.?);
|
||||||
|
try testing.expectEqual("red", forms[0].fields[1].value.?);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "browser.forms: form without action or method" {
|
||||||
|
defer testing.reset();
|
||||||
|
defer testing.test_session.removePage();
|
||||||
|
const forms = try testForms(
|
||||||
|
\\<form>
|
||||||
|
\\ <input type="text" name="q">
|
||||||
|
\\</form>
|
||||||
|
);
|
||||||
|
try testing.expectEqual(1, forms.len);
|
||||||
|
try testing.expectEqual(null, forms[0].action);
|
||||||
|
try testing.expectEqual("get", forms[0].method.?);
|
||||||
|
try testing.expectEqual(1, forms[0].fields.len);
|
||||||
|
}
|
||||||
@@ -162,7 +162,7 @@ pub fn collectInteractiveElements(
|
|||||||
.name = try getAccessibleName(el, arena),
|
.name = try getAccessibleName(el, arena),
|
||||||
.interactivity_type = itype,
|
.interactivity_type = itype,
|
||||||
.listener_types = listener_types,
|
.listener_types = listener_types,
|
||||||
.disabled = isDisabled(el),
|
.disabled = el.isDisabled(),
|
||||||
.tab_index = html_el.getTabIndex(),
|
.tab_index = html_el.getTabIndex(),
|
||||||
.id = el.getAttributeSafe(comptime .wrap("id")),
|
.id = el.getAttributeSafe(comptime .wrap("id")),
|
||||||
.class = el.getAttributeSafe(comptime .wrap("class")),
|
.class = el.getAttributeSafe(comptime .wrap("class")),
|
||||||
@@ -412,36 +412,6 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
|
|||||||
// strip out trailing space
|
// strip out trailing space
|
||||||
return arr.items[0 .. arr.items.len - 1];
|
return arr.items[0 .. arr.items.len - 1];
|
||||||
}
|
}
|
||||||
fn isDisabled(el: *Element) bool {
|
|
||||||
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
|
|
||||||
return isDisabledByFieldset(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if an element is disabled by an ancestor <fieldset disabled>.
|
|
||||||
/// Per spec, elements inside the first <legend> child of a disabled fieldset
|
|
||||||
/// are NOT disabled by that fieldset.
|
|
||||||
fn isDisabledByFieldset(el: *Element) bool {
|
|
||||||
const element_node = el.asNode();
|
|
||||||
var current: ?*Node = element_node._parent;
|
|
||||||
while (current) |node| {
|
|
||||||
current = node._parent;
|
|
||||||
const ancestor = node.is(Element) orelse continue;
|
|
||||||
|
|
||||||
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
|
||||||
// Check if element is inside the first <legend> child of this fieldset
|
|
||||||
var child = ancestor.firstElementChild();
|
|
||||||
while (child) |c| {
|
|
||||||
if (c.getTag() == .legend) {
|
|
||||||
if (c.asNode().contains(element_node)) return false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
child = c.nextElementSibling();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn getInputType(el: *Element) ?[]const u8 {
|
fn getInputType(el: *Element) ?[]const u8 {
|
||||||
if (el.is(Element.Html.Input)) |input| {
|
if (el.is(Element.Html.Input)) |input| {
|
||||||
|
|||||||
@@ -120,9 +120,10 @@
|
|||||||
|
|
||||||
<script id=link_click>
|
<script id=link_click>
|
||||||
testing.async(async (restore) => {
|
testing.async(async (restore) => {
|
||||||
|
let f6;
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let f6 = document.createElement('iframe');
|
f6 = document.createElement('iframe');
|
||||||
f6.id = 'f6';
|
f6.id = 'f6';
|
||||||
f6.addEventListener('load', () => {
|
f6.addEventListener('load', () => {
|
||||||
if (++count == 2) {
|
if (++count == 2) {
|
||||||
|
|||||||
@@ -573,6 +573,32 @@ pub fn hasAttributeSafe(self: *const Element, name: String) bool {
|
|||||||
return attributes.hasSafe(name);
|
return attributes.hasSafe(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn isDisabled(self: *Element) bool {
|
||||||
|
if (self.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element_node = self.asNode();
|
||||||
|
var current: ?*Node = element_node._parent;
|
||||||
|
while (current) |node| {
|
||||||
|
current = node._parent;
|
||||||
|
const ancestor = node.is(Element) orelse continue;
|
||||||
|
|
||||||
|
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
||||||
|
var child = ancestor.firstElementChild();
|
||||||
|
while (child) |c| {
|
||||||
|
if (c.getTag() == .legend) {
|
||||||
|
if (c.asNode().contains(element_node)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
child = c.nextElementSibling();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hasAttributes(self: *const Element) bool {
|
pub fn hasAttributes(self: *const Element) bool {
|
||||||
const attributes = self._attributes orelse return false;
|
const attributes = self._attributes orelse return false;
|
||||||
return attributes.isEmpty() == false;
|
return attributes.isEmpty() == false;
|
||||||
|
|||||||
@@ -125,15 +125,10 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
|
|||||||
var list: KeyValueList = .empty;
|
var list: KeyValueList = .empty;
|
||||||
const form = form_ orelse return list;
|
const form = form_ orelse return list;
|
||||||
|
|
||||||
const form_node = form.asNode();
|
|
||||||
|
|
||||||
var elements = try form.getElements(page);
|
var elements = try form.getElements(page);
|
||||||
var it = try elements.iterator();
|
var it = try elements.iterator();
|
||||||
while (it.next()) |element| {
|
while (it.next()) |element| {
|
||||||
if (element.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
if (element.isDisabled()) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isDisabledByFieldset(element, form_node)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,41 +197,6 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if `element` is disabled by an ancestor <fieldset disabled>,
|
|
||||||
// stopping the upward walk when the form node is reached.
|
|
||||||
// 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| {
|
|
||||||
// Stop at the form boundary (common case optimisation)
|
|
||||||
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) {
|
|
||||||
// Check if `element` is inside the first <legend> child of this fieldset
|
|
||||||
var child = el.firstElementChild();
|
|
||||||
while (child) |c| {
|
|
||||||
if (c.getTag() == .legend) {
|
|
||||||
// Found the first legend; exempt if element is a descendant
|
|
||||||
if (c.asNode().contains(element_node)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
child = c.nextElementSibling();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const JsApi = struct {
|
pub const JsApi = struct {
|
||||||
pub const bridge = js.Bridge(FormData);
|
pub const bridge = js.Bridge(FormData);
|
||||||
|
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ pub const Writer = struct {
|
|||||||
},
|
},
|
||||||
.input => {
|
.input => {
|
||||||
const input = el.as(DOMNode.Element.Html.Input);
|
const input = el.as(DOMNode.Element.Html.Input);
|
||||||
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
|
const is_disabled = el.isDisabled();
|
||||||
|
|
||||||
switch (input._input_type) {
|
switch (input._input_type) {
|
||||||
.text, .email, .tel, .url, .search, .password, .number => {
|
.text, .email, .tel, .url, .search, .password, .number => {
|
||||||
@@ -332,7 +332,7 @@ pub const Writer = struct {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
.textarea => {
|
.textarea => {
|
||||||
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
|
const is_disabled = el.isDisabled();
|
||||||
|
|
||||||
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
||||||
if (!is_disabled) {
|
if (!is_disabled) {
|
||||||
@@ -347,7 +347,7 @@ pub const Writer = struct {
|
|||||||
try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w);
|
try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w);
|
||||||
},
|
},
|
||||||
.select => {
|
.select => {
|
||||||
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
|
const is_disabled = el.isDisabled();
|
||||||
|
|
||||||
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
||||||
if (!is_disabled) {
|
if (!is_disabled) {
|
||||||
@@ -391,7 +391,7 @@ pub const Writer = struct {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
.button => {
|
.button => {
|
||||||
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled"));
|
const is_disabled = el.isDisabled();
|
||||||
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
|
||||||
if (!is_disabled) {
|
if (!is_disabled) {
|
||||||
try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
|
try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub fn processMessage(cmd: anytype) !void {
|
|||||||
getSemanticTree,
|
getSemanticTree,
|
||||||
getInteractiveElements,
|
getInteractiveElements,
|
||||||
getStructuredData,
|
getStructuredData,
|
||||||
|
detectForms,
|
||||||
clickNode,
|
clickNode,
|
||||||
fillNode,
|
fillNode,
|
||||||
scrollNode,
|
scrollNode,
|
||||||
@@ -43,6 +44,7 @@ pub fn processMessage(cmd: anytype) !void {
|
|||||||
.getSemanticTree => return getSemanticTree(cmd),
|
.getSemanticTree => return getSemanticTree(cmd),
|
||||||
.getInteractiveElements => return getInteractiveElements(cmd),
|
.getInteractiveElements => return getInteractiveElements(cmd),
|
||||||
.getStructuredData => return getStructuredData(cmd),
|
.getStructuredData => return getStructuredData(cmd),
|
||||||
|
.detectForms => return detectForms(cmd),
|
||||||
.clickNode => return clickNode(cmd),
|
.clickNode => return clickNode(cmd),
|
||||||
.fillNode => return fillNode(cmd),
|
.fillNode => return fillNode(cmd),
|
||||||
.scrollNode => return scrollNode(cmd),
|
.scrollNode => return scrollNode(cmd),
|
||||||
@@ -162,6 +164,23 @@ 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(
|
||||||
|
cmd.arena,
|
||||||
|
page.document.asNode(),
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
|
||||||
|
try lp.forms.registerNodes(forms_data, &bc.node_registry);
|
||||||
|
|
||||||
|
return cmd.sendResult(.{
|
||||||
|
.forms = forms_data,
|
||||||
|
}, .{});
|
||||||
|
}
|
||||||
|
|
||||||
fn clickNode(cmd: anytype) !void {
|
fn clickNode(cmd: anytype) !void {
|
||||||
const Params = struct {
|
const Params = struct {
|
||||||
nodeId: ?Node.Id = null,
|
nodeId: ?Node.Id = null,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
|
|||||||
pub const SemanticTree = @import("SemanticTree.zig");
|
pub const SemanticTree = @import("SemanticTree.zig");
|
||||||
pub const CDPNode = @import("cdp/Node.zig");
|
pub const CDPNode = @import("cdp/Node.zig");
|
||||||
pub const interactive = @import("browser/interactive.zig");
|
pub const interactive = @import("browser/interactive.zig");
|
||||||
|
pub const forms = @import("browser/forms.zig");
|
||||||
pub const actions = @import("browser/actions.zig");
|
pub const actions = @import("browser/actions.zig");
|
||||||
pub const structured_data = @import("browser/structured_data.zig");
|
pub const structured_data = @import("browser/structured_data.zig");
|
||||||
pub const mcp = @import("mcp.zig");
|
pub const mcp = @import("mcp.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",
|
.name = "click",
|
||||||
.description = "Click on an interactive element. Returns the current page URL and title after the click.",
|
.description = "Click on an interactive element. Returns the current page URL and title after the click.",
|
||||||
@@ -252,6 +264,7 @@ const ToolAction = enum {
|
|||||||
links,
|
links,
|
||||||
interactiveElements,
|
interactiveElements,
|
||||||
structuredData,
|
structuredData,
|
||||||
|
detectForms,
|
||||||
evaluate,
|
evaluate,
|
||||||
semantic_tree,
|
semantic_tree,
|
||||||
click,
|
click,
|
||||||
@@ -267,6 +280,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
|
|||||||
.{ "links", .links },
|
.{ "links", .links },
|
||||||
.{ "interactiveElements", .interactiveElements },
|
.{ "interactiveElements", .interactiveElements },
|
||||||
.{ "structuredData", .structuredData },
|
.{ "structuredData", .structuredData },
|
||||||
|
.{ "detectForms", .detectForms },
|
||||||
.{ "evaluate", .evaluate },
|
.{ "evaluate", .evaluate },
|
||||||
.{ "semantic_tree", .semantic_tree },
|
.{ "semantic_tree", .semantic_tree },
|
||||||
.{ "click", .click },
|
.{ "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),
|
.links => try handleLinks(server, arena, req.id.?, call_params.arguments),
|
||||||
.interactiveElements => try handleInteractiveElements(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),
|
.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),
|
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
|
||||||
.semantic_tree => try handleSemanticTree(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),
|
.click => try handleClick(server, arena, req.id.?, call_params.arguments),
|
||||||
@@ -435,6 +450,39 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
|
|||||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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| {
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
lp.forms.registerNodes(forms_data, &server.node_registry) catch |err| {
|
||||||
|
log.err(.mcp, "form node registration failed", .{ .err = err });
|
||||||
|
return server.sendError(id, .InternalError, "Failed to register form nodes");
|
||||||
|
};
|
||||||
|
|
||||||
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
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");
|
const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user