fix: verify MCP typing

This commit is contained in:
zhom committed 2026-10-19 23:27:49 +04:00
1 parent 3b4737feeb
commit d8e05cd250
8 files changed
+1838 -601

No files matched your search

+134
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+6
View File
@@ -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;
}
+5 -1
View File
@@ -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
+4 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+88 -5
View File
@@ -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 }