Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147/** * `aite record URL --out FILE` (P7-1): opens the page in a real, visible Chrome on one of the config's profiles, with the recorder in it, and writes what the person * does as a draft script when they press Enter in the terminal. Chrome is driven over its private debugging pipe only; no extension is loaded, so this is * the person's own browsing, recorded. The draft has `step "TODO name"` for every step and no `verify`: the person says what each step is for and what must be true. */import { existsSync, writeFileSync } from "node:fs";import { recorderSource } from "../tools/buildRecorder";import { loadConfig } from "../shared/config";import type { CdpEvent } from "./cdp";import { launchBareChrome, type BareLaunchOptions, type ChromeProcess } from "./launcher";import type { RunOutcome } from "./run";import type { AiteConfig } from "../shared/config";import { isElementDescriptor } from "../shared/descriptor";import { draftScript, PLACEHOLDER_STEP, type RecordedEvent } from "./recorder";
export const BINDING = "__aiteRecord";
export interface RecordOptions { url: string; out: string; configPath: string; profile?: string; headless: boolean; noSandbox: boolean; display?: string;}
export interface RecordDeps { loadConfig(path: string): AiteConfig; launch(options: BareLaunchOptions): Promise<ChromeProcess>; recorderSource(): string; exists(path: string): boolean; writeFile(path: string, text: string): void; /** Resolves when the person says they are done (Enter in the terminal, Ctrl-C) or Chrome closes. */ waitForStop(chrome: ChromeProcess): Promise<void>; log(line: string): void;}
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));const isString = (v: unknown): v is string => typeof v === "string";
/** An event from the page, checked: the page is not trusted to send only what we expect. */export function parseRecordedEvent(payload: unknown): RecordedEvent | undefined { if (!isString(payload) || payload.length > 20_000) return undefined; let v: unknown; try { v = JSON.parse(payload); } catch { return undefined; } if (typeof v !== "object" || v === null) return undefined; const e = v as Record<string, unknown>; switch (e.kind) { case "click": return isElementDescriptor(e.target) ? { kind: "click", target: e.target } : undefined; case "type": return isElementDescriptor(e.target) && isString(e.text) ? { kind: "type", target: e.target, text: e.text } : undefined; case "select": return isElementDescriptor(e.target) && isString(e.option) ? { kind: "select", target: e.target, option: e.option } : undefined; case "check": return isElementDescriptor(e.target) && typeof e.checked === "boolean" ? { kind: "check", target: e.target, checked: e.checked } : undefined; case "press": return isString(e.key) && (e.key === "Enter" || e.key === "Escape") ? { kind: "press", key: e.key } : undefined; default: return undefined; }}
export async function recordSession(options: RecordOptions, deps: RecordDeps): Promise<RunOutcome> { if (!URL.canParse(options.url) || !/^https?:$/.test(new URL(options.url).protocol)) return { exitCode: 2, output: "record needs an http or https address, for example: aite record https://example.com/ --out scripts/draft.aite" }; if (deps.exists(options.out)) return { exitCode: 2, output: `${options.out} already exists: record mode does not overwrite a file (choose another --out)` }; let config: AiteConfig; try { config = deps.loadConfig(options.configPath); } catch (err) { return { exitCode: 2, output: messageOf(err) }; } const names = Object.keys(config.profiles); const name = options.profile ?? (names.length === 1 ? names[0] : undefined); if (name === undefined) return { exitCode: 2, output: `the config has several profiles (${names.join(", ")}): choose one with --profile NAME` }; const profile = config.profiles[name]; if (profile === undefined) return { exitCode: 2, output: `the config has no profile "${name}" (it has: ${names.join(", ")})` };
let chrome: ChromeProcess; try { chrome = await deps.launch({ chrome: profile.chrome, userDataDir: profile.userDataDir, headless: options.headless, noSandbox: options.noSandbox, ...(options.display !== undefined ? { display: options.display } : {}) }); } catch (err) { return { exitCode: 3, output: messageOf(err) }; } const events: RecordedEvent[] = []; try { const cdp = chrome.cdp; if (cdp === undefined || cdp.onEvent === undefined) return { exitCode: 3, output: "this Chrome connection cannot deliver events, so nothing can be recorded" }; const stopListening = cdp.onEvent((e: CdpEvent) => { if (e.method !== "Runtime.bindingCalled") return; const p = e.params as { name?: unknown; payload?: unknown }; if (p.name !== BINDING) return; const event = parseRecordedEvent(p.payload); if (event !== undefined) events.push(event); }); const targets = (await cdp.send("Target.getTargets")) as { targetInfos?: Array<{ targetId: string; type: string }> }; const page = targets.targetInfos?.find((t) => t.type === "page"); if (page === undefined) return { exitCode: 3, output: "Chrome has no page to record in" }; const { sessionId } = (await cdp.send("Target.attachToTarget", { targetId: page.targetId, flatten: true })) as { sessionId: string }; await cdp.send("Runtime.enable", {}, sessionId); await cdp.send("Page.enable", {}, sessionId); await cdp.send("Runtime.addBinding", { name: BINDING }, sessionId); await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: deps.recorderSource() }, sessionId); const nav = (await cdp.send("Page.navigate", { url: options.url }, sessionId)) as { errorText?: string }; if (nav.errorText !== undefined) return { exitCode: 3, output: `the page could not be opened: ${nav.errorText}` }; deps.log("recording: use the page as a person would; press Enter here (or close Chrome) when done"); await deps.waitForStop(chrome); stopListening(); } catch (err) { return { exitCode: 3, output: `recording failed: ${messageOf(err)}` }; } finally { await chrome.close().catch(() => undefined); } if (events.length === 0) return { exitCode: 1, output: "nothing was recorded (no click, typing or key was seen), so no file was written" }; const text = draftScript(events, { start: options.url }); try { deps.writeFile(options.out, text); } catch (err) { return { exitCode: 2, output: `could not write ${options.out}: ${messageOf(err)}` }; } const steps = text.split("\n").filter((l) => l.startsWith("step ")).length; return { exitCode: 0, output: `wrote ${options.out}: ${steps} step${steps === 1 ? "" : "s"} named "${PLACEHOLDER_STEP}".\nName each step, add the verify lines that say what must be true (a recording only says what was done), and give the test a name. It does not parse until every step has a name.` };}
export const realRecordDeps = (): RecordDeps => ({ loadConfig: (p) => loadConfig(p), launch: (o) => launchBareChrome(o), recorderSource: () => recorderSource(), exists: (p) => existsSync(p), writeFile: (p, t) => writeFileSync(p, t, { flag: "wx" }), waitForStop: (chrome) => new Promise<void>((resolve) => { const done = () => { process.off("SIGINT", done); process.off("SIGTERM", done); process.stdin.off("data", onData); process.stdin.pause(); resolve(); }; const onData = (chunk: Buffer) => { if (chunk.toString("utf8").includes("\n")) done(); }; process.once("SIGINT", done); process.once("SIGTERM", done); process.stdin.on("data", onData); void chrome.exited.then(done); }), log: (l) => console.log(l),});