mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
fix: verify MCP typing
This commit is contained in:
1 parent
3b4737feeb
commit
d8e05cd250
8 files changed
+1838
-601
No files matched your search
@@ -687,3 +687,137 @@ export function extensionCrx3({
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form page the MCP locator and typing checks run against. Each part
|
||||
* stands for a kind of field agents reported trouble with: a search input and
|
||||
* a search textarea named by id, a controlled input that keeps only what real
|
||||
* input events gave it, an autocomplete whose input is transparent and one
|
||||
* pixel wide, two fields whose names differ by a suffix, radios drawn by their
|
||||
* labels, a grid of day cells, a field the page empties after every key, and
|
||||
* a textarea far down the page beside a fixed banner that covers the bottom of
|
||||
* the window.
|
||||
*/
|
||||
export function mcpFormsPage() {
|
||||
const days = Array.from(
|
||||
{ length: 31 },
|
||||
(_, i) =>
|
||||
`<div role="option" aria-label="Choose October ${i + 1}, 2026" data-day="${i + 1}">${i + 1}</div>`,
|
||||
).join("");
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Donut E2E MCP Forms</title>
|
||||
<style>
|
||||
body { font: 14px sans-serif; margin: 0; padding: 16px 16px 160px; }
|
||||
.row { margin: 10px 0; }
|
||||
.control { position: relative; width: 320px; min-height: 36px; border: 1px solid #888; border-radius: 4px; display: flex; align-items: center; padding: 0 8px; cursor: text; }
|
||||
.control .placeholder { position: absolute; left: 8px; color: #777; pointer-events: none; }
|
||||
.control input { width: 1px; opacity: 0; border: 0; padding: 0; outline: 0; font: inherit; background: none; }
|
||||
.choice { position: relative; display: inline-block; padding-left: 24px; margin-right: 16px; }
|
||||
.choice input { position: absolute; z-index: -1; opacity: 0; left: 0; top: 2px; width: 16px; height: 16px; margin: 0; }
|
||||
.choice label::before { content: ""; position: absolute; left: 0; top: 2px; width: 14px; height: 14px; border: 1px solid #333; border-radius: 50%; }
|
||||
.choice input:checked + label::before { background: #333; }
|
||||
.days { display: grid; grid-template-columns: repeat(7, 36px); gap: 2px; }
|
||||
.days div { height: 28px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; cursor: pointer; }
|
||||
.spacer { height: 900px; }
|
||||
#fixedban { position: fixed; left: 0; right: 0; bottom: 0; height: 110px; z-index: 10; background: #333; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MCP forms</h1>
|
||||
<div class="row"><input id="searchInput" name="search" placeholder="Search Wikipedia"></div>
|
||||
<div class="row"><textarea id="sb_form_q" name="q" rows="1" placeholder="Ask me anything"></textarea></div>
|
||||
<div class="row"><label for="react-name">Full name</label> <input id="react-name" name="fullName"></div>
|
||||
<div class="row">
|
||||
<label for="first-name">First Name</label> <input id="first-name" name="firstName">
|
||||
<label for="first-name-optional">First Name (optional)</label> <input id="first-name-optional" name="firstNameOptional">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div id="subjects" class="control"><span class="placeholder">Subjects</span><input id="subjectsInput" aria-label="Subjects" autocomplete="off"></div>
|
||||
<ul id="subjects-menu"></ul>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="choice"><input type="radio" name="gender" id="gender-radio-1" value="Male"><label for="gender-radio-1">Male</label></div>
|
||||
<div class="choice"><input type="radio" name="gender" id="gender-radio-2" value="Female"><label for="gender-radio-2">Female</label></div>
|
||||
</div>
|
||||
<div class="row"><div id="days" class="days" role="listbox" aria-label="October 2026">${days}</div></div>
|
||||
<div class="row"><input id="locked" name="locked" aria-label="Locked"></div>
|
||||
<div class="spacer"></div>
|
||||
<div class="row"><label for="currentAddress">Current Address</label><br><textarea id="currentAddress" name="currentAddress" rows="5" cols="40"></textarea></div>
|
||||
<div class="spacer"></div>
|
||||
<div id="fixedban">Advertisement</div>
|
||||
<script>
|
||||
(() => {
|
||||
const banner = document.getElementById("fixedban");
|
||||
banner.addEventListener("mousedown", () => {
|
||||
banner.dataset.clicks = String(Number(banner.dataset.clicks || 0) + 1);
|
||||
});
|
||||
|
||||
// A controlled input as React keeps one: a value a script assigns moves
|
||||
// the tracker, so the input event after it changes nothing, and every
|
||||
// render puts the state back into the field.
|
||||
const named = document.getElementById("react-name");
|
||||
const native = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
||||
let state = "";
|
||||
let tracked = "";
|
||||
Object.defineProperty(named, "value", {
|
||||
configurable: true,
|
||||
get() { return native.get.call(this); },
|
||||
set(value) { tracked = String(value); native.set.call(this, value); },
|
||||
});
|
||||
named.addEventListener("input", () => {
|
||||
const now = native.get.call(named);
|
||||
if (now !== tracked) {
|
||||
tracked = now;
|
||||
state = now;
|
||||
named.dataset.state = state;
|
||||
}
|
||||
});
|
||||
setInterval(() => {
|
||||
if (native.get.call(named) !== state) {
|
||||
native.set.call(named, state);
|
||||
tracked = state;
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// An autocomplete: a click anywhere on the control focuses its input,
|
||||
// which grows and shows itself only once it holds text.
|
||||
const control = document.getElementById("subjects");
|
||||
const subject = document.getElementById("subjectsInput");
|
||||
const placeholder = control.querySelector(".placeholder");
|
||||
const menu = document.getElementById("subjects-menu");
|
||||
control.addEventListener("mousedown", (event) => {
|
||||
if (event.target !== subject) {
|
||||
event.preventDefault();
|
||||
subject.focus();
|
||||
}
|
||||
});
|
||||
subject.addEventListener("input", () => {
|
||||
const typed = subject.value;
|
||||
subject.style.width = typed ? typed.length + 1 + "ch" : "1px";
|
||||
subject.style.opacity = typed ? "1" : "0";
|
||||
placeholder.style.display = typed ? "none" : "";
|
||||
menu.innerHTML = ["Maths", "Physics", "Chemistry"]
|
||||
.filter((option) => typed && option.toLowerCase().startsWith(typed.toLowerCase()))
|
||||
.map((option) => "<li>" + option + "</li>")
|
||||
.join("");
|
||||
});
|
||||
|
||||
// A field the page empties after every key.
|
||||
const locked = document.getElementById("locked");
|
||||
locked.addEventListener("input", () => {
|
||||
locked.value = "";
|
||||
});
|
||||
|
||||
const daysGrid = document.getElementById("days");
|
||||
daysGrid.addEventListener("click", (event) => {
|
||||
const day = event.target.closest("[data-day]");
|
||||
if (day) daysGrid.dataset.picked = day.dataset.day;
|
||||
});
|
||||
window.__formsReady = true;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
+9
-1
@@ -28,7 +28,7 @@ import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ensureRclone, serveS3Args } from "../scripts/rclone.mjs";
|
||||
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
import { extensionCrx3 } from "./lib/fixtures.mjs";
|
||||
import { extensionCrx3, mcpFormsPage } from "./lib/fixtures.mjs";
|
||||
import { DRIVER_COMMAND_TIMEOUT_SECONDS } from "./lib/limits.mjs";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -408,6 +408,14 @@ function startFixtureServer(geoIpFixture, geoIpAsnFixture) {
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/mcp-forms") {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(mcpFormsPage());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
|
||||
+310
-51
@@ -150,6 +150,59 @@ const LOCATION_KEYS = new Set([
|
||||
"accuracy",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The MCP tools as an agent reaches them over the remote bridge, on one
|
||||
* initialized session. `tool` fails on an error answer; `toolError` expects one
|
||||
* and returns it.
|
||||
*/
|
||||
async function mcpClient(base, token, profileId) {
|
||||
let rpcId = 0;
|
||||
let session = null;
|
||||
const post = async (method, params) => {
|
||||
const response = await fetch(`${base}/v1/e2e/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
...(session ? { "mcp-session-id": session } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
||||
});
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
const answer = await response.json();
|
||||
session = answer.session_id ?? session;
|
||||
return answer.body;
|
||||
};
|
||||
const mcp = async (method, params) => {
|
||||
const body = await post(method, params);
|
||||
assert.equal(body.error, undefined, JSON.stringify(body));
|
||||
return body.result;
|
||||
};
|
||||
const call = (name, args) =>
|
||||
post("tools/call", {
|
||||
name,
|
||||
arguments: { profile_id: profileId, ...args },
|
||||
});
|
||||
const tool = async (name, args = {}) => {
|
||||
const body = await call(name, args);
|
||||
assert.equal(body.error, undefined, JSON.stringify(body));
|
||||
assert.notEqual(body.result.isError, true, JSON.stringify(body.result));
|
||||
return body.result.content[0];
|
||||
};
|
||||
const toolJson = async (name, args) =>
|
||||
JSON.parse((await tool(name, args)).text);
|
||||
const toolError = async (name, args = {}) => {
|
||||
const body = await call(name, args);
|
||||
assert.ok(body.error, JSON.stringify(body));
|
||||
return body.error;
|
||||
};
|
||||
await mcp("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
clientInfo: { name: "Donut E2E", version: "1" },
|
||||
});
|
||||
return { mcp, tool, toolJson, toolError };
|
||||
}
|
||||
|
||||
/** `fingerprint` is the serialised fingerprint STRING, or null for a fresh one. */
|
||||
async function createRealProfile(app, version, name, fingerprint = null) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
@@ -750,38 +803,11 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
|
||||
// The MCP browser tools, through the engine an agent reaches over the
|
||||
// remote bridge. Each action says where the page is afterwards.
|
||||
let rpcId = 0;
|
||||
let mcpSession = null;
|
||||
const mcp = async (method, params) => {
|
||||
const response = await fetch(`${base}/v1/e2e/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${saved.api_token}`,
|
||||
"content-type": "application/json",
|
||||
...(mcpSession ? { "mcp-session-id": mcpSession } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
|
||||
});
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
const answer = await response.json();
|
||||
mcpSession = answer.session_id ?? mcpSession;
|
||||
assert.equal(answer.body.error, undefined, JSON.stringify(answer.body));
|
||||
return answer.body.result;
|
||||
};
|
||||
const tool = async (name, args = {}) => {
|
||||
const result = await mcp("tools/call", {
|
||||
name,
|
||||
arguments: { profile_id: profile.id, ...args },
|
||||
});
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return result.content[0];
|
||||
};
|
||||
const toolJson = async (name, args) =>
|
||||
JSON.parse((await tool(name, args)).text);
|
||||
await mcp("initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
clientInfo: { name: "Donut E2E", version: "1" },
|
||||
});
|
||||
const { mcp, tool, toolJson, toolError } = await mcpClient(
|
||||
base,
|
||||
saved.api_token,
|
||||
profile.id,
|
||||
);
|
||||
|
||||
// The device the profile presents, through each way it is read: the
|
||||
// fingerprint form, REST and MCP. An identity-backed profile stores no
|
||||
@@ -874,25 +900,6 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
);
|
||||
|
||||
// Form tools on a native select and a hidden file input added to the page.
|
||||
const toolError = async (name, args = {}) => {
|
||||
const response = await fetch(`${base}/v1/e2e/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${saved.api_token}`,
|
||||
"content-type": "application/json",
|
||||
"mcp-session-id": mcpSession,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: ++rpcId,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: { profile_id: profile.id, ...args } },
|
||||
}),
|
||||
});
|
||||
const answer = await response.json();
|
||||
assert.ok(answer.body.error, JSON.stringify(answer.body));
|
||||
return answer.body.error;
|
||||
};
|
||||
await cdp.evaluate(`(() => {
|
||||
const form = document.createElement('form');
|
||||
form.innerHTML = '<label for="fixture-country">Country</label>'
|
||||
@@ -1492,6 +1499,258 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
}
|
||||
});
|
||||
|
||||
// What MCP agents reported on real forms, on a local page that has each kind
|
||||
// of field: a locator by id finds a search input and a search textarea, a click
|
||||
// lands on what a person sees (a radio drawn by its label, a day cell, a
|
||||
// textarea beside a fixed banner, never the banner), a name that two fields
|
||||
// share settles on the exact one, and typing either leaves the text in the
|
||||
// field, through the input events a framework listens to, or fails and says so.
|
||||
test("MCP locators and typing act on the field a person sees", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernVersion = cachedFixtureVersion(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const app = appFromEnvironment("browser-mcp-forms", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let cdp;
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
}
|
||||
const profile = await createRealProfile(app, prepared.version, "MCP Forms");
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
const port = await app.invoke("start_api_server", { port: 0 });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const launched = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/mcp-forms`, headless: true },
|
||||
});
|
||||
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||
browserPid = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
)?.process_id;
|
||||
cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||
await cdp.waitFor("window.__formsReady === true", {
|
||||
description: "the MCP forms fixture",
|
||||
});
|
||||
const { tool, toolJson, toolError } = await mcpClient(
|
||||
base,
|
||||
saved.api_token,
|
||||
profile.id,
|
||||
);
|
||||
const valueOf = (selector) =>
|
||||
cdp.evaluate(`document.querySelector(${JSON.stringify(selector)}).value`);
|
||||
const byId = (id) => ({ attributes: [{ name: "id", value: id }] });
|
||||
|
||||
// An id the browser's own index does not carry: a search input and a
|
||||
// search textarea.
|
||||
const searched = await toolJson("type_locator", {
|
||||
locator: byId("searchInput"),
|
||||
text: "Donut Browser",
|
||||
});
|
||||
assert.equal(searched.typed, true, JSON.stringify(searched));
|
||||
assert.equal(searched.verified, true, JSON.stringify(searched));
|
||||
assert.equal(await valueOf("#searchInput"), "Donut Browser");
|
||||
const asked = await toolJson("type_locator", {
|
||||
locator: byId("sb_form_q"),
|
||||
text: "weather today",
|
||||
});
|
||||
assert.equal(asked.verified, true, JSON.stringify(asked));
|
||||
assert.equal(await valueOf("#sb_form_q"), "weather today");
|
||||
|
||||
// A controlled field keeps only what real input events gave it: a value
|
||||
// assigned by a script is put back on the next render.
|
||||
const controlled = await toolJson("type_locator", {
|
||||
locator: { role: "textbox", name: "Full name" },
|
||||
text: "Jane Doe",
|
||||
});
|
||||
assert.equal(controlled.verified, true, JSON.stringify(controlled));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
assert.equal(await valueOf("#react-name"), "Jane Doe");
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#react-name').dataset.state"),
|
||||
"Jane Doe",
|
||||
"the page's own state took the text",
|
||||
);
|
||||
// Replaced, not appended, and still through input events.
|
||||
assert.match(
|
||||
(
|
||||
await tool("type_text", {
|
||||
selector: "#react-name",
|
||||
text: "John Roe",
|
||||
})
|
||||
).text,
|
||||
/the field holds the text/,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
assert.equal(await valueOf("#react-name"), "John Roe");
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#react-name').dataset.state"),
|
||||
"John Roe",
|
||||
);
|
||||
|
||||
// An autocomplete whose input is transparent and one pixel wide: the click
|
||||
// goes to its control, and the text reaches the input.
|
||||
const subject = await toolJson("type_locator", {
|
||||
locator: byId("subjectsInput"),
|
||||
text: "Mat",
|
||||
});
|
||||
assert.equal(subject.verified, true, JSON.stringify(subject));
|
||||
assert.equal(await valueOf("#subjectsInput"), "Mat");
|
||||
assert.match(
|
||||
await cdp.evaluate(
|
||||
"document.querySelector('#subjects-menu').textContent",
|
||||
),
|
||||
/Maths/,
|
||||
);
|
||||
const batch = await toolJson("run_actions", {
|
||||
actions: [
|
||||
{
|
||||
tool: "type_text",
|
||||
arguments: { selector: "#subjectsInput", text: "Phy" },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(batch.completed, 1, JSON.stringify(batch));
|
||||
assert.equal(await valueOf("#subjectsInput"), "Phy");
|
||||
|
||||
// Two names that differ by a suffix: the exact one is meant, and a
|
||||
// locator that really is ambiguous says what it matched.
|
||||
const first = await toolJson("type_locator", {
|
||||
locator: { nameContains: "First Name" },
|
||||
text: "Ada",
|
||||
});
|
||||
assert.equal(first.match.name, "First Name", JSON.stringify(first));
|
||||
assert.equal(await valueOf("#first-name"), "Ada");
|
||||
assert.equal(await valueOf("#first-name-optional"), "");
|
||||
const ambiguous = await toolError("resolve_locator", {
|
||||
locator: { role: "textbox", nameContains: "First" },
|
||||
});
|
||||
assert.equal(ambiguous.data.code, "LOCATOR_AMBIGUOUS");
|
||||
assert.deepEqual(
|
||||
ambiguous.data.candidates.map((candidate) => candidate.name).sort(),
|
||||
["First Name", "First Name (optional)"],
|
||||
);
|
||||
assert.match(ambiguous.message, /First Name \(optional\)/);
|
||||
|
||||
// Radios drawn by their labels: a text locator matches the label, a role
|
||||
// and name locator matches the radio, and both click through the label.
|
||||
const checked = () =>
|
||||
cdp.evaluate("document.querySelector('input[name=gender]:checked')?.id");
|
||||
const female = await toolJson("click_locator", {
|
||||
locator: { text: "Female" },
|
||||
});
|
||||
assert.equal(female.clicked, true, JSON.stringify(female));
|
||||
assert.equal(await checked(), "gender-radio-2");
|
||||
await toolJson("click_locator", {
|
||||
locator: { role: "radio", name: "Male" },
|
||||
});
|
||||
assert.equal(await checked(), "gender-radio-1");
|
||||
|
||||
// A day cell of a date grid.
|
||||
await toolJson("click_locator", { locator: { text: "18" } });
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#days').dataset.picked"),
|
||||
"18",
|
||||
);
|
||||
|
||||
// A field the page will not change: typing fails, and says what it holds.
|
||||
const locked = await toolError("type_locator", {
|
||||
locator: byId("locked"),
|
||||
text: "nothing changes",
|
||||
});
|
||||
assert.equal(locked.data.code, "TYPE_NOT_APPLIED", JSON.stringify(locked));
|
||||
assert.equal(locked.data.reason, "value");
|
||||
assert.match(locked.message, /holds nothing/);
|
||||
|
||||
// A textarea at the bottom of the window, half under a fixed banner: the
|
||||
// click and the typing land on the textarea, never on the banner.
|
||||
const underBanner = `(() => {
|
||||
const box = document.querySelector('#currentAddress').getBoundingClientRect();
|
||||
window.scrollBy(0, box.top - (window.innerHeight - 140));
|
||||
document.activeElement?.blur();
|
||||
return true;
|
||||
})()`;
|
||||
await cdp.evaluate(underBanner);
|
||||
await toolJson("click_locator", { locator: byId("currentAddress") });
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.activeElement?.id"),
|
||||
"currentAddress",
|
||||
);
|
||||
await cdp.evaluate(underBanner);
|
||||
const address = await toolJson("type_locator", {
|
||||
locator: { role: "textbox", name: "Current Address" },
|
||||
text: "221B Baker Street",
|
||||
});
|
||||
assert.equal(address.verified, true, JSON.stringify(address));
|
||||
assert.equal(await valueOf("#currentAddress"), "221B Baker Street");
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#fixedban').dataset.clicks"),
|
||||
undefined,
|
||||
"nothing struck the banner",
|
||||
);
|
||||
|
||||
// By index from a listing, with the same read-back.
|
||||
const listing = (await tool("get_interactive_elements")).text;
|
||||
const line = listing
|
||||
.split("\n")
|
||||
.find((entry) => entry.includes('id="sb_form_q"'));
|
||||
const elementId = /^\[\d+ (e\d+)/.exec(line ?? "")?.[1];
|
||||
assert.ok(elementId, listing);
|
||||
assert.match(
|
||||
(
|
||||
await tool("type_by_index", {
|
||||
element_id: elementId,
|
||||
text: "by index",
|
||||
})
|
||||
).text,
|
||||
/the field holds the text/,
|
||||
);
|
||||
assert.equal(await valueOf("#sb_form_q"), "by index");
|
||||
|
||||
await request(`${base}/v1/profiles/${profile.id}/kill`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
if (browserPid) await waitForProcessExit(app, browserPid);
|
||||
await app.invoke("stop_api_server");
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
cdp?.close();
|
||||
if (app.session && browserPid && processExists(browserPid)) {
|
||||
const running = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((item) => item.process_id === browserPid);
|
||||
if (running)
|
||||
await app
|
||||
.invoke("kill_browser_profile", { profile: running })
|
||||
.catch(() => {});
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
/// Read every donut-proxy worker config the app has on disk.
|
||||
async function readWorkerConfigs(app) {
|
||||
const dir = path.join(app.dataRoot, "cache", "proxy_workers");
|
||||
|
||||
@@ -786,4 +786,10 @@ export interface AgentTyping {
|
||||
duration_ms: number;
|
||||
engine: Engine;
|
||||
match: LocatorCandidate;
|
||||
/**
|
||||
* The field was read back and holds the text. False only for a field whose
|
||||
* value is not the typed text (a date or colour picker); a field that does
|
||||
* not hold the text fails with `TYPE_NOT_APPLIED`.
|
||||
*/
|
||||
verified: boolean;
|
||||
}
|
||||
@@ -913,13 +913,17 @@ class _AgentTypingRequired(TypedDict):
|
||||
duration_ms: float
|
||||
engine: str
|
||||
match: LocatorCandidate
|
||||
verified: bool
|
||||
|
||||
|
||||
class AgentTyping(_AgentTypingRequired, total=False):
|
||||
"""What a typing call did.
|
||||
|
||||
``corrections`` is absent on the fallback engine, which does not count its
|
||||
own mistypes.
|
||||
own mistypes. ``verified`` is true when the field was read back and holds
|
||||
the text; it is false only for a field whose value is not the typed text
|
||||
(a date or colour picker). A field that does not hold the text fails with
|
||||
``TYPE_NOT_APPLIED``.
|
||||
"""
|
||||
|
||||
corrections: int
|
||||
@@ -5505,6 +5505,8 @@ fn agent_error_response(error: crate::mcp_server::AgentError) -> (StatusCode, St
|
||||
AgentError::AuthorizationUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
AgentError::PickerTimedOut { .. } => StatusCode::REQUEST_TIMEOUT,
|
||||
AgentError::PickerCancelled { .. } => StatusCode::CONFLICT,
|
||||
// The page did not take the text: a conflict with its state, not a fault.
|
||||
AgentError::TypeNotApplied { .. } => StatusCode::CONFLICT,
|
||||
AgentError::Cdp(CdpError::Unauthorized(_)) => StatusCode::UNAUTHORIZED,
|
||||
// Nothing answers on the profile's socket: the browser is gone, or is not
|
||||
// drivable yet. Both read as "start it and try again".
|
||||
@@ -5628,12 +5630,12 @@ async fn agent_click_api(
|
||||
),
|
||||
request_body = crate::mcp_server::AgentTypeRequest,
|
||||
responses(
|
||||
(status = 200, description = "The text was typed", body = crate::mcp_server::AgentTyping),
|
||||
(status = 200, description = "The text was typed, and the field read back holds it (`verified`)", body = crate::mcp_server::AgentTyping),
|
||||
(status = 400, description = "Malformed request, no element matched, several did, or the text would take longer than the typing budget (`code` TYPING_TOO_LONG)"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "The profile is not running"),
|
||||
(status = 409, description = "The profile is not running, or the field did not take the focus or the text (`code` TYPE_NOT_APPLIED, `reason` not_focused or value)"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 502, description = "The browser did not answer as documented"),
|
||||
(status = 500, description = "Internal server error")
|
||||
|
||||
+1282
-541
File diff suppressed because it is too large.
Load diff
@@ -110,6 +110,8 @@ pub enum WayfernError {
|
||||
PickerTimedOut { timeout_ms: u64 },
|
||||
/// The picker ended without a pick: "escape", "stopped" or "navigated".
|
||||
PickerCancelled { reason: String },
|
||||
/// The node has no box on screen to act on.
|
||||
NotVisible(String),
|
||||
/// A reply did not have the shape the protocol documents.
|
||||
Malformed(String),
|
||||
}
|
||||
@@ -123,6 +125,7 @@ impl std::fmt::Display for WayfernError {
|
||||
write!(f, "no element was picked within {timeout_ms} ms")
|
||||
}
|
||||
Self::PickerCancelled { reason } => write!(f, "the element picker was cancelled ({reason})"),
|
||||
Self::NotVisible(m) => f.write_str(m),
|
||||
Self::Malformed(m) => write!(f, "unexpected reply from the browser: {m}"),
|
||||
}
|
||||
}
|
||||
@@ -1037,8 +1040,9 @@ pub async fn viewport_target(
|
||||
.filter_map(|quad| quad_target(quad, viewport_width, viewport_height))
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
WayfernError::Malformed(
|
||||
"the node has no visible box to strike; it may be hidden or clipped".into(),
|
||||
WayfernError::NotVisible(
|
||||
"The element has no visible box to click or type into; it may be hidden or clipped. Open, expand or scroll to the part of the page that shows it."
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1383,6 +1387,16 @@ pub(crate) mod test_support {
|
||||
NativeNoMatch,
|
||||
/// Every perception capture reports it ran past the default budget.
|
||||
SlowPerception,
|
||||
/// The page resets the field after typing: it reads back empty.
|
||||
TypingLost,
|
||||
/// The field never takes the focus.
|
||||
FieldUnfocusable,
|
||||
/// The node has no box: the page aims at nothing and the DOM has no quads.
|
||||
HiddenControl,
|
||||
/// The node is in a frame: the page leaves its position to the DOM.
|
||||
FramedNode,
|
||||
/// `resolveLocator` matches "First Name (optional)" and "First Name".
|
||||
AmbiguousByName,
|
||||
}
|
||||
|
||||
pub(crate) type Frames = Arc<Mutex<Vec<Value>>>;
|
||||
@@ -1448,10 +1462,14 @@ pub(crate) mod test_support {
|
||||
let port = listener.local_addr().expect("a bound port").port();
|
||||
let frames: Frames = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded = frames.clone();
|
||||
// What the keys put in the field, across every connection: key events
|
||||
// arrive on a socket of their own.
|
||||
let typed: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok((socket, _)) = listener.accept().await {
|
||||
let recorded = recorded.clone();
|
||||
let typed = typed.clone();
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut stream) = tokio_tungstenite::accept_async(socket).await else {
|
||||
return;
|
||||
@@ -1489,6 +1507,7 @@ pub(crate) mod test_support {
|
||||
}
|
||||
"Vellum.inscribe" => {
|
||||
let text = params.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
typed.lock().unwrap().push_str(text);
|
||||
ok(serde_json::json!({
|
||||
"characters": text.chars().count(), "corrections": 1, "durationMs": 900.0
|
||||
}))
|
||||
@@ -1515,13 +1534,30 @@ pub(crate) mod test_support {
|
||||
};
|
||||
ok(serde_json::json!({}))
|
||||
}
|
||||
"Input.insertText" => {
|
||||
let text = params.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
typed.lock().unwrap().push_str(text);
|
||||
ok(serde_json::json!({}))
|
||||
}
|
||||
"Input.dispatchKeyEvent" => {
|
||||
if params["type"] == "keyDown" {
|
||||
let mut field = typed.lock().unwrap();
|
||||
match params.get("text").and_then(Value::as_str) {
|
||||
Some(text) => field.push_str(text),
|
||||
None if params["key"] == "Backspace" => {
|
||||
field.pop();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ok(serde_json::json!({}))
|
||||
}
|
||||
"Vellum.release"
|
||||
| "Page.enable"
|
||||
| "Page.disable"
|
||||
| "Wayfern.stopElementPicker"
|
||||
| "DOM.scrollIntoViewIfNeeded"
|
||||
| "Input.dispatchMouseEvent"
|
||||
| "Input.dispatchKeyEvent" => ok(serde_json::json!({})),
|
||||
| "Input.dispatchMouseEvent" => ok(serde_json::json!({})),
|
||||
"Wayfern.capturePagePerception" => {
|
||||
perception_page += 1;
|
||||
let endless = behaviour == Fake::EndlessPerception;
|
||||
@@ -1550,6 +1586,19 @@ pub(crate) mod test_support {
|
||||
"Wayfern.resolveLocator" if behaviour == Fake::AmbiguousLocator => error(
|
||||
"Ambiguous locator: 2 nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: [{\"backendNodeId\":7,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"s7\",\"attributes\":[]},{\"backendNodeId\":8,\"role\":\"button\",\"name\":\"Save\",\"text\":\"Save\",\"signature\":\"s8\",\"attributes\":[]}]",
|
||||
),
|
||||
"Wayfern.resolveLocator" if behaviour == Fake::AmbiguousByName => {
|
||||
let named = |name: &str, signature: &str| {
|
||||
let mut candidate = canned_candidate();
|
||||
candidate["role"] = Value::from("textField");
|
||||
candidate["name"] = Value::from(name);
|
||||
candidate["signature"] = Value::from(signature);
|
||||
candidate
|
||||
};
|
||||
error(&format!(
|
||||
"Ambiguous locator: 2 nodes match. Refine it with a role, a stable attribute, or more exact text. Candidates: {}",
|
||||
Value::Array(vec![named("First Name (optional)", "s7"), named("First Name", "s8")])
|
||||
))
|
||||
}
|
||||
"Wayfern.resolveLocator" if behaviour == Fake::NativeNoMatch => {
|
||||
error("No node matches locator (id=save).")
|
||||
}
|
||||
@@ -1563,14 +1612,48 @@ pub(crate) mod test_support {
|
||||
"rowCount": 1, "pageCount": 1, "byteSize": 40, "truncated": false,
|
||||
"stopReason": "complete"
|
||||
})),
|
||||
"DOM.getContentQuads" if behaviour == Fake::HiddenControl => {
|
||||
ok(serde_json::json!({ "quads": [] }))
|
||||
}
|
||||
"DOM.getContentQuads" => ok(serde_json::json!({
|
||||
"quads": [[100.0, 200.0, 180.0, 200.0, 180.0, 230.0, 100.0, 230.0]]
|
||||
})),
|
||||
"DOM.resolveNode" => ok(serde_json::json!({
|
||||
"object": { "type": "object", "objectId": "obj-7" }
|
||||
})),
|
||||
"Runtime.callFunctionOn" if params["arguments"][0]["value"] == "field" => {
|
||||
ok(serde_json::json!({
|
||||
"result": { "type": "object", "subtype": "node", "objectId": "field-7" }
|
||||
}))
|
||||
}
|
||||
"Runtime.callFunctionOn" => {
|
||||
ok(serde_json::json!({ "result": { "type": "boolean", "value": true } }))
|
||||
let step = params["arguments"][0]["value"].as_str().unwrap_or_default();
|
||||
let value = match step {
|
||||
"point" if behaviour == Fake::HiddenControl => serde_json::json!({ "hidden": true }),
|
||||
"point" if behaviour == Fake::FramedNode => serde_json::json!({ "frame": true }),
|
||||
"point" => {
|
||||
serde_json::json!({ "x": 140.0, "y": 215.0, "width": 80.0, "height": 30.0 })
|
||||
}
|
||||
"focus" => serde_json::json!({
|
||||
"focused": behaviour != Fake::FieldUnfocusable, "active": "body"
|
||||
}),
|
||||
"prepare" => {
|
||||
serde_json::json!({ "kind": "value", "type": "text", "value": "", "secret": false })
|
||||
}
|
||||
"read" => {
|
||||
let value = if behaviour == Fake::TypingLost {
|
||||
String::new()
|
||||
} else {
|
||||
typed.lock().unwrap().clone()
|
||||
};
|
||||
serde_json::json!({
|
||||
"kind": "value", "type": "text", "value": value, "secret": false, "focusMoved": false
|
||||
})
|
||||
}
|
||||
_ => Value::Bool(true),
|
||||
};
|
||||
let kind = if value.is_boolean() { "boolean" } else { "object" };
|
||||
ok(serde_json::json!({ "result": { "type": kind, "value": value } }))
|
||||
}
|
||||
"Page.getLayoutMetrics" => ok(serde_json::json!({
|
||||
"cssLayoutViewport": { "clientWidth": 1280.0, "clientHeight": 720.0 }
|
||||
|
||||
Reference in new issue
Block a user