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

@@ -36,6 +36,7 @@ pub fn processMessage(cmd: anytype) !void {
clickNode,
fillNode,
scrollNode,
waitForSelector,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -47,6 +48,7 @@ pub fn processMessage(cmd: anytype) !void {
.clickNode => return clickNode(cmd),
.fillNode => return fillNode(cmd),
.scrollNode => return scrollNode(cmd),
.waitForSelector => return waitForSelector(cmd),
}
}
@@ -257,6 +259,32 @@ fn scrollNode(cmd: anytype) !void {
return cmd.sendResult(.{}, .{});
}
fn waitForSelector(cmd: anytype) !void {
const Params = struct {
selector: []const u8,
timeout: ?u32 = null,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
_ = bc.session.currentPage() orelse return error.PageNotLoaded;
const timeout_ms = params.timeout orelse 5000;
const selector_z = try cmd.arena.dupeZ(u8, params.selector);
const node = lp.actions.waitForSelector(selector_z, timeout_ms, bc.session) catch |err| {
if (err == error.InvalidSelector) return error.InvalidParam;
if (err == error.Timeout) return error.InternalError;
return error.InternalError;
};
const registered = try bc.node_registry.register(node);
return cmd.sendResult(.{
.backendNodeId = registered.id,
}, .{});
}
const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" {
var ctx = testing.context();
@@ -315,7 +343,8 @@ test "cdp.lp: action tools" {
const page = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = bc.session.wait(.{});
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
// Test Click
const btn = page.document.getElementById("btn", page).?.asNode();
@@ -366,3 +395,44 @@ test "cdp.lp: action tools" {
try testing.expect(result.isTrue());
}
test "cdp.lp: waitForSelector" {
var ctx = testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const page = try bc.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 } });
var runner = try bc.session.runner(.{});
try runner.wait(.{ .ms = 2000 });
// 1. Existing element
try ctx.processMessage(.{
.id = 1,
.method = "LP.waitForSelector",
.params = .{ .selector = "#existing", .timeout = 2000 },
});
var result = ctx.client.?.sent.items[0].object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 2. Delayed element
try ctx.processMessage(.{
.id = 2,
.method = "LP.waitForSelector",
.params = .{ .selector = "#delayed", .timeout = 5000 },
});
result = ctx.client.?.sent.items[0].object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 3. Timeout error
try ctx.processMessage(.{
.id = 3,
.method = "LP.waitForSelector",
.params = .{ .selector = "#nonexistent", .timeout = 100 },
});
const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object;
try testing.expect(err_obj.get("code") != null);
}

View File

@@ -208,11 +208,22 @@ fn getResponseBody(cmd: anytype) !void {
const request_id = try idFromRequestId(params.requestId);
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const buf = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound;
const resp = bc.captured_responses.getPtr(request_id) orelse return error.RequestNotFound;
try cmd.sendResult(.{
.body = buf.items,
.base64Encoded = false,
if (!resp.must_encode) {
return cmd.sendResult(.{
.body = resp.data.items,
.base64Encoded = false,
}, .{});
}
const encoded_len = std.base64.standard.Encoder.calcSize(resp.data.items.len);
const encoded = try cmd.arena.alloc(u8, encoded_len);
_ = std.base64.standard.Encoder.encode(encoded, resp.data.items);
return cmd.sendResult(.{
.body = encoded,
.base64Encoded = true,
}, .{});
}

View File

@@ -75,8 +75,21 @@ const Frame = struct {
};
fn getFrameTree(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
// Stagehand parses the response and error if we don't return a
// correct one for this call when browser context or target id are missing.
const startup = .{
.frameTree = .{
.frame = .{
.id = "TID-STARTUP",
.loaderId = "LID-STARTUP",
.securityOrigin = @import("../cdp.zig").URL_BASE,
.url = "about:blank",
.secureContextType = "Secure",
},
},
};
const bc = cmd.browser_context orelse return cmd.sendResult(startup, .{});
const target_id = bc.target_id orelse return cmd.sendResult(startup, .{});
return cmd.sendResult(.{
.frameTree = .{
@@ -633,8 +646,18 @@ test "cdp.page: getFrameTree" {
defer ctx.deinit();
{
try ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
// no browser context - should return TID-STARTUP
try ctx.processMessage(.{ .id = 1, .method = "Page.getFrameTree", .sessionId = "STARTUP" });
try ctx.expectSentResult(.{
.frameTree = .{
.frame = .{
.id = "TID-STARTUP",
.loaderId = "LID-STARTUP",
.url = "about:blank",
.secureContextType = "Secure",
},
},
}, .{ .id = 1, .session_id = "STARTUP" });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
@@ -659,6 +682,29 @@ test "cdp.page: getFrameTree" {
},
}, .{ .id = 11 });
}
{
// STARTUP sesion is handled when a broweser context and a target id exists.
try ctx.processMessage(.{ .id = 12, .method = "Page.getFrameTree", .session_id = "STARTUP" });
try ctx.expectSentResult(.{
.frameTree = .{
.frame = .{
.id = "FID-000000000X",
.loaderId = "LID-0000000001",
.url = "http://127.0.0.1:9582/src/browser/tests/hi.html",
.domainAndRegistry = "",
.securityOrigin = bc.security_origin,
.mimeType = "text/html",
.adFrameStatus = .{
.adFrameType = "none",
},
.secureContextType = bc.secure_context_type,
.crossOriginIsolatedContextType = "NotIsolated",
.gatedAPIFeatures = [_][]const u8{},
},
},
}, .{ .id = 12 });
}
}
test "cdp.page: captureScreenshot" {