Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186import type { Settled } from "../bridge/dispatcher";import type { CdpSession } from "./cdp";import { findTarget, trustedClick, trustedDoubleClick, trustedDrag, trustedPress, trustedSelectAll, trustedType, withPage, type PageSession } from "./input";import { checkDownload, type DownloadFiles } from "./downloads";import { NetworkWatcher } from "./network";
type Body = { cmd: string; [key: string]: unknown };type Request = (body: Body, options?: { timeoutMs?: number }) => Promise<Settled>;type Answer = Record<string, unknown>;
const TRUSTED_ACTIONS = new Set(["click", "doubleClick", "check", "uncheck", "type", "press", "drag"]);const CHECK_SETTLE_MS = 100;const DEFAULT_TIMEOUT_MS = 10_000;const real = { now: () => Date.now(), sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) };
const answer = (a: Answer): Settled => ({ id: 0, ...a }) as unknown as Settled;const fail = (reason: string, detail: Record<string, unknown>): Settled => answer({ ok: false, reason, detail });const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
/** * Puts real input (docs/decisions.md D11) between the engine and the bridge. A click, double click, typing, key press, drag, check or uncheck first asks the * page where the element is and whether it can be acted on (`locate`: visible, enabled, not covered), and then sends the mouse or key events over * Chrome's private debugging pipe, so the page sees what it would see from a person. Everything else goes to the extension untouched. * `wait network-idle` is answered here too, from Chrome's own network events, and so is `verify download`, from the run's download folder. */export function withTrustedInput(options: { request: Request; cdp: CdpSession; clock?: { now(): number; sleep(ms: number): Promise<void> }; downloadDir?: string; files?: DownloadFiles }) { const clock = options.clock ?? real; const watcher = new NetworkWatcher(options.cdp, clock); const sessions = new Map<number, string>();
/** The url and title the extension reports for a tab, which is how Chrome's side finds the same page. */ async function pageOf(tab: unknown): Promise<{ url: string; title: string } | Settled> { if (typeof tab !== "number") return fail("tab-closed", { why: "there is no tab for this run" }); const tabs = (await options.request({ cmd: "tabs" }, { timeoutMs: 5000 })) as unknown as { ok?: boolean; tabs?: Array<{ id: number; url: string; title: string }>; detail?: { message?: string } }; if (tabs.ok !== true) return fail("internal", { message: tabs.detail?.message ?? "the extension could not list the tabs" }); const found = tabs.tabs?.find((t) => t.id === tab); return found ? { url: found.url, title: found.title } : fail("tab-closed", { tab }); }
/** Switches the Network domain on for the tab's page (once), so requests that start from now on are counted. */ async function watch(tab: number, page: { url: string; title: string }): Promise<string> { const known = sessions.get(tab); if (known !== undefined) return known; const targetId = await findTarget(options.cdp, page); const { sessionId } = (await options.cdp.send("Target.attachToTarget", { targetId, flatten: true })) as { sessionId?: string }; if (!sessionId) throw new Error("Chrome would not attach to the page"); await watcher.watch(sessionId); sessions.set(tab, sessionId); return sessionId; }
async function locate(body: Body, descriptor: unknown): Promise<Answer> { const { cmd: _cmd, action: _action, descriptor: _d, to: _to, text: _text, key: _key, ...rest } = body; return (await options.request({ ...rest, cmd: "locate", descriptor }, { timeoutMs: (num(body.timeoutMs) ?? DEFAULT_TIMEOUT_MS) + 5000 })) as unknown as Answer; }
async function act(body: Body): Promise<Settled> { const action = String(body.action); const page = await pageOf(body.tab); if ("ok" in page) return page; const tab = body.tab as number; const timeoutMs = num(body.timeoutMs) ?? DEFAULT_TIMEOUT_MS; let retries = 0; const located = async (descriptor: unknown): Promise<Answer> => { const a = await locate(body, descriptor); retries += num(a.retries) ?? 0; return a; }; const done = (extra: Answer = {}): Settled => answer({ ok: true, ...(retries > 0 ? { retries } : {}), ...extra }); const failed = (a: Answer): Settled => answer({ ...a, ...(retries > 0 ? { retries } : {}) });
/** A click can close the window it is in (a pay window's Approve): Chrome then drops the session before answering. That is a success only if the tab is really gone. */ const clicking = async (work: () => Promise<void>): Promise<Settled> => { try { await work(); return done(); } catch (err) { const after = await pageOf(tab); if ("ok" in after && after.ok === false && (after as unknown as { reason?: string }).reason === "tab-closed") return done({ tabClosed: true }); throw err; } };
try { await watch(tab, page).catch(() => undefined); // an action still works if the network cannot be watched; only network-idle needs it if (action === "press") { if (typeof body.key !== "string") return fail("internal", { message: "press needs a key" }); await withPage(options.cdp, page, (s) => trustedPress(s, body.key as string)); return done(); } const here = await located(body.descriptor); if (here.ok !== true) return failed(here); const x = num(here.x); const y = num(here.y); if (x === undefined || y === undefined) return fail("internal", { message: "the page did not say where the element is" });
switch (action) { case "click": return await clicking(() => withPage(options.cdp, page, (s) => trustedClick(s, x, y))); case "doubleClick": return await clicking(() => withPage(options.cdp, page, (s) => trustedDoubleClick(s, x, y))); case "type": { if (typeof body.text !== "string") return fail("internal", { message: "type needs text" }); if (here.editable !== true) return fail("not-enabled", { why: `a ${String(here.tag ?? "element")} element cannot be typed into`, descriptor: body.descriptor }); const text = body.text; await withPage(options.cdp, page, async (s: PageSession) => { await trustedClick(s, x, y); // focus, the way a person does await trustedSelectAll(s); // typing alone appends if (text === "") await trustedPress(s, "Delete"); else await trustedType(s, text); }); return done(); } case "check": case "uncheck": { const want = action === "check"; if (here.kind !== "checkbox" && here.kind !== "radio") return fail("unsupported", { action, why: "the element is not a checkbox or a radio button" }); if (here.kind === "radio" && !want) return fail("not-enabled", { why: "a radio button cannot be unchecked; check another one in its group" }); if (here.checked === want) return done({ unchanged: true }); await withPage(options.cdp, page, (s) => trustedClick(s, x, y)); // The page may take a moment to update; it must end up as wanted, or the click did not do what was asked. const start = clock.now(); let state = here.checked; for (;;) { const again = await located(body.descriptor); state = again.ok === true ? again.checked : state; if (state === want) return done(); if (clock.now() - start >= Math.min(timeoutMs, 2000)) return fail("mismatch", { expected: want ? "checked" : "not checked", found: state === true ? "checked" : "not checked", why: "the click did not change the box" }); await clock.sleep(CHECK_SETTLE_MS); } } case "drag": { if (typeof body.to !== "object" || body.to === null) return fail("internal", { message: "drag needs the element to drop on" }); const there = await located(body.to); if (there.ok !== true) return failed(there); const tx = num(there.x); const ty = num(there.y); if (tx === undefined || ty === undefined) return fail("internal", { message: "the page did not say where the drop target is" }); await withPage(options.cdp, page, (s) => trustedDrag(s, { x, y }, { x: tx, y: ty })); return done(); } default: return fail("unsupported", { action }); } } catch (err) { return fail("internal", { message: messageOf(err) }); } }
async function networkIdle(body: Body): Promise<Settled> { const timeoutMs = num(body.timeoutMs) ?? DEFAULT_TIMEOUT_MS; const page = await pageOf(body.tab); if ("ok" in page) return page; try { const session = await watch(body.tab as number, page); const start = clock.now(); const result = await watcher.whenIdle(session, timeoutMs); return result.idle ? answer({ ok: true }) : fail("timeout", { inflight: result.inflight, waitedMs: clock.now() - start }); } catch (err) { return fail("internal", { message: messageOf(err) }); } }
async function verifyDownload(body: Body): Promise<Settled> { if (options.downloadDir === undefined || options.files === undefined) return fail("unsupported", { kind: "download", why: "downloads are only checked when Chrome's debugging pipe is available" }); if (typeof body.name !== "string" || body.name === "") return fail("internal", { message: "verify download needs a name" }); const result = await checkDownload( options.downloadDir, { name: body.name, ...(body.nameMatch === "contains" ? { nameMatch: "contains" as const } : {}), ...(num(body.sizeMin) !== undefined ? { sizeMin: num(body.sizeMin) } : {}), ...(body.negate === true ? { negate: true } : {}), timeoutMs: num(body.timeoutMs) ?? DEFAULT_TIMEOUT_MS }, { files: options.files, now: clock.now, sleep: clock.sleep }, ); return result.ok ? answer({ ok: true, ...(result.found !== undefined ? { found: result.found } : {}) }) : fail(result.reason, result.detail); }
return { request: async (body: Body, opts?: { timeoutMs?: number }): Promise<Settled> => { if (body.cmd === "verify" && body.kind === "download") return verifyDownload(body); if (body.cmd === "act" && typeof body.action === "string" && TRUSTED_ACTIONS.has(body.action)) return act(body); if (body.cmd === "wait" && body.kind === "networkIdle") return networkIdle(body); return options.request(body, opts); }, stop: (): void => watcher.stop(), };}