Merge branch 'main' into osc/feat-mcp-detect-forms

This commit is contained in:
Adrià Arrufat
2026-03-24 09:25:47 +09:00
54 changed files with 3006 additions and 731 deletions

View File

@@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) {
InvalidParams = -32602,
InternalError = -32603,
PageNotLoaded = -32604,
NotFound = -32605,
};
pub const Notification = struct {

View File

@@ -115,7 +115,7 @@ pub const tool_list = [_]protocol.Tool{
},
.{
.name = "click",
.description = "Click on an interactive element.",
.description = "Click on an interactive element. Returns the current page URL and title after the click.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
@@ -128,7 +128,7 @@ pub const tool_list = [_]protocol.Tool{
},
.{
.name = "fill",
.description = "Fill text into an input element.",
.description = "Fill text into an input element. Returns the filled value and current page URL and title.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
@@ -142,7 +142,7 @@ pub const tool_list = [_]protocol.Tool{
},
.{
.name = "scroll",
.description = "Scroll the page or a specific element.",
.description = "Scroll the page or a specific element. Returns the scroll position and current page URL and title.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
@@ -543,7 +543,13 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
return server.sendError(id, .InternalError, "Failed to click element");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
@@ -569,7 +575,14 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
return server.sendError(id, .InternalError, "Failed to fill element");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
@@ -600,9 +613,17 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a
return server.sendError(id, .InternalError, "Failed to scroll");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{
args.x orelse 0,
args.y orelse 0,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const WaitParams = struct {
selector: [:0]const u8,
@@ -610,33 +631,26 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
};
const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector");
const page = server.session.currentPage() orelse {
_ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const timeout_ms = args.timeout orelse 5000;
var timer = try std.time.Timer.start();
while (true) {
const element = Selector.querySelector(page.document.asNode(), args.selector, page) catch {
const node = lp.actions.waitForSelector(args.selector, timeout_ms, server.session) catch |err| {
if (err == error.InvalidSelector) {
return server.sendError(id, .InvalidParams, "Invalid selector");
};
if (element) |el| {
const registered = try server.node_registry.register(el.asNode());
const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found.";
const content = [_]protocol.TextContent([]const u8){.{ .text = msg }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
if (elapsed >= timeout_ms) {
} else if (err == error.Timeout) {
return server.sendError(id, .InternalError, "Timeout waiting for selector");
}
return server.sendError(id, .InternalError, "Failed waiting for selector");
};
_ = server.session.wait(.{ .timeout_ms = @min(100, timeout_ms - elapsed) });
}
const registered = try server.node_registry.register(node);
const msg = std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found.";
const content = [_]protocol.TextContent([]const u8){.{ .text = msg }};
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
@@ -665,25 +679,18 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
return error.NavigationFailed;
};
_ = server.session.wait(.{});
var runner = try session.runner(.{});
try runner.wait(.{ .ms = 2000 });
}
const testing = @import("../testing.zig");
const router = @import("router.zig");
const testing = @import("../testing.zig");
test "MCP - evaluate error reporting" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage("about:blank", &out.writer);
defer server.deinit();
_ = try server.session.createPage();
const aa = testing.arena_allocator;
// Call evaluate with a script that throws an error
const msg =
@@ -700,69 +707,74 @@ test "MCP - evaluate error reporting" {
\\}
;
try router.handleMessage(server, aa, msg);
try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(
\\{
\\ "id": 1,
\\ "result": {
\\ "isError": true,
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
try testing.expectJson(.{ .id = 1, .result = .{
.isError = true,
.content = &.{.{ .type = "text" }},
} }, out.written());
}
test "MCP - Actions: click, fill, scroll" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
const aa = testing.arena_allocator;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
var out: std.io.Writer.Allocating = .init(aa);
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
const aa = testing.arena_allocator;
const page = try server.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = server.session.wait(.{});
const page = &server.session.page.?;
// Test Click
const btn = page.document.getElementById("btn", page).?.asNode();
const btn_id = (try server.node_registry.register(btn)).id;
var btn_id_buf: [12]u8 = undefined;
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
try router.handleMessage(server, aa, click_msg);
{
// Test Click
const btn = page.document.getElementById("btn", page).?.asNode();
const btn_id = (try server.node_registry.register(btn)).id;
var btn_id_buf: [12]u8 = undefined;
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
try router.handleMessage(server, aa, click_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null);
out.clearRetainingCapacity();
}
// Test Fill Input
const inp = page.document.getElementById("inp", page).?.asNode();
const inp_id = (try server.node_registry.register(inp)).id;
var inp_id_buf: [12]u8 = undefined;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" });
try router.handleMessage(server, aa, fill_msg);
{
// Test Fill Input
const inp = page.document.getElementById("inp", page).?.asNode();
const inp_id = (try server.node_registry.register(inp)).id;
var inp_id_buf: [12]u8 = undefined;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" });
try router.handleMessage(server, aa, fill_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"hello\\\"") != null);
out.clearRetainingCapacity();
}
// Test Fill Select
const sel = page.document.getElementById("sel", page).?.asNode();
const sel_id = (try server.node_registry.register(sel)).id;
var sel_id_buf: [12]u8 = undefined;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" });
try router.handleMessage(server, aa, fill_sel_msg);
{
// Test Fill Select
const sel = page.document.getElementById("sel", page).?.asNode();
const sel_id = (try server.node_registry.register(sel)).id;
var sel_id_buf: [12]u8 = undefined;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" });
try router.handleMessage(server, aa, fill_sel_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"opt2\\\"") != null);
out.clearRetainingCapacity();
}
// Test Scroll
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
var scroll_id_buf: [12]u8 = undefined;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
try router.handleMessage(server, aa, scroll_msg);
{
// Test Scroll
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
var scroll_id_buf: [12]u8 = undefined;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
try router.handleMessage(server, aa, scroll_msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled to x: 0, y: 50") != null);
out.clearRetainingCapacity();
}
// Evaluate assertions
var ls: js.Local.Scope = undefined;
@@ -773,108 +785,79 @@ test "MCP - Actions: click, fill, scroll" {
try_catch.init(&ls.local);
defer try_catch.deinit();
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
const result = try ls.local.exec(
\\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true
, null);
try testing.expect(result.isTrue());
}
test "MCP - waitForSelector: existing element" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
&out.writer,
);
defer server.deinit();
const aa = testing.arena_allocator;
const page = try server.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = server.session.wait(.{});
// waitForSelector on an element that already exists returns immediately
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#existing","timeout":2000}}}
;
try router.handleMessage(server, aa, msg);
try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(
\\{
\\ "id": 1,
\\ "result": {
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
}
test "MCP - waitForSelector: delayed element" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
&out.writer,
);
defer server.deinit();
const aa = testing.arena_allocator;
const page = try server.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = server.session.wait(.{});
// waitForSelector on an element added after 200ms via setTimeout
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#delayed","timeout":5000}}}
;
try router.handleMessage(server, aa, msg);
try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(
\\{
\\ "id": 1,
\\ "result": {
\\ "content": [
\\ { "type": "text" }
\\ ]
\\ }
\\}
, out_alloc.writer.buffered());
try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
}
test "MCP - waitForSelector: timeout" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
const server = try testLoadPage(
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
&out.writer,
);
defer server.deinit();
const aa = testing.arena_allocator;
const page = try server.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = server.session.wait(.{});
// waitForSelector with a short timeout on a non-existent element should error
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}}
;
try router.handleMessage(server, aa, msg);
try testing.expectJson(
\\{
\\ "id": 1,
\\ "error": {}
\\}
, out_alloc.writer.buffered());
try router.handleMessage(server, testing.arena_allocator, msg);
try testing.expectJson(.{
.id = 1,
.@"error" = struct {}{},
}, out.written());
}
fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server {
var server = try Server.init(testing.allocator, testing.test_app, writer);
errdefer server.deinit();
const page = try server.session.createPage();
try page.navigate(url, .{});
var runner = try server.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
return server;
}