Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126import { describe, expect, it } from "vitest";import { recordSession, type RecordDeps, type RecordOptions } from "../../runner/record";import type { CdpEvent, CdpSession } from "../../runner/cdp";import type { AiteConfig } from "../../shared/config";import { desc } from "../helpers/descriptors";import { parseScript } from "../../runner/parser";
/** P7-1 with a fake Chrome: what record mode asks of Chrome, what it does with what comes back, and what it writes. */
const config = { profiles: { qian: { userDataDir: "/profiles/qian", chrome: "/opt/chrome" } }, allow: [] } as unknown as AiteConfig;const send = desc({ tag: "button", role: "button", name: "Send", text: "Send" });
function machine(over: { files?: Record<string, string>; script?: string; calls?: Array<(emit: (e: unknown) => void) => void>; launchFails?: boolean; chromeExitsAfterStop?: boolean; config?: AiteConfig; navigateFails?: boolean; noPage?: boolean } = {}) { const files = new Map(Object.entries(over.files ?? {})); const log: string[] = []; const handlers: Array<(e: CdpEvent) => void> = []; let stopRecording: () => void = () => undefined; const stopped = new Promise<void>((r) => { stopRecording = r; }); let closed = false; const cdp: CdpSession = { onEvent: (h) => { handlers.push(h); return () => void handlers.splice(handlers.indexOf(h), 1); }, send: async (method, params) => { log.push(`${method} ${JSON.stringify(params ?? {})}`.slice(0, 160)); if (method === "Target.getTargets") return { targetInfos: over.noPage ? [] : [{ targetId: "T1", type: "page", url: "about:blank" }] }; if (method === "Target.attachToTarget") return { sessionId: "S1" }; if (method === "Page.navigate" && over.navigateFails) return { errorText: "net::ERR_NAME_NOT_RESOLVED" }; if (method === "Page.navigate") { // the page is open: now the person acts queueMicrotask(() => { for (const call of over.calls ?? []) call((payload) => handlers.forEach((h) => h({ method: "Runtime.bindingCalled", params: { name: "__aiteRecord", payload: typeof payload === "string" ? payload : JSON.stringify(payload) }, sessionId: "S1" }))); stopRecording(); }); } return {}; }, }; const deps: RecordDeps = { loadConfig: () => over.config ?? config, launch: async (o) => { log.push(`launch ${o.chrome} ${o.userDataDir} headless=${o.headless} ext=${"extensionDir" in o ? "given" : "none"}`); if (over.launchFails) throw new Error("cannot start Chrome at /opt/chrome: ENOENT"); return { cdp, close: async () => void ((closed = true), log.push("closed")), exited: new Promise(() => undefined), stderrTail: () => "" }; }, recorderSource: () => "/*recorder*/", exists: (p) => files.has(p), writeFile: (p, t) => void files.set(p, t), waitForStop: () => stopped, log: (l) => void log.push(`say ${l}`), }; const options = (o: Partial<RecordOptions> = {}): RecordOptions => ({ url: "http://127.0.0.1:4780/form.html", out: "scripts/draft.aite", configPath: "aite.config.json", headless: false, noSandbox: false, ...o }); return { deps, files, log, options, isClosed: () => closed, handlers };}
describe("recordSession", () => { it("opens the page with the recorder in it, takes what the person did, writes a draft and closes Chrome", async () => { const m = machine({ calls: [(emit) => emit({ kind: "type", target: desc({ tag: "input", role: "textbox", name: "Amount" }), text: "1" }), (emit) => emit({ kind: "click", target: send })] }); const r = await recordSession(m.options(), m.deps); expect(r.exitCode).toBe(0); expect(m.log.some((l) => l.startsWith("launch /opt/chrome /profiles/qian headless=false ext=none"))).toBe(true); const order = m.log.filter((l) => /^(Target|Runtime|Page)\./.test(l)).map((l) => l.split(" ")[0]); expect(order).toEqual(["Target.getTargets", "Target.attachToTarget", "Runtime.enable", "Page.enable", "Runtime.addBinding", "Page.addScriptToEvaluateOnNewDocument", "Page.navigate"]); expect(m.log.find((l) => l.startsWith("Page.addScriptToEvaluateOnNewDocument"))).toContain("/*recorder*/"); expect(m.log.find((l) => l.startsWith("Page.navigate"))).toContain("http://127.0.0.1:4780/form.html"); const draft = m.files.get("scripts/draft.aite")!; expect(draft).toContain("start http://127.0.0.1:4780/form.html"); expect(draft).toContain('type role:textbox name:"Amount" "1"'); expect(draft).toContain('click role:button name:"Send"'); expect(parseScript(draft).ok).toBe(false); // the steps still say TODO name expect(r.output).toContain("1 step"); expect(r.output).toMatch(/TODO name/); expect(m.isClosed()).toBe(true); }); it("ignores bindings that are not ours, payloads that are not events, and events of the wrong shape", async () => { const m = machine({ calls: [(emit) => { emit("not json"); emit({ kind: "dance" }); emit({ kind: "click" }); emit({ kind: "press", key: "Enter" }); }] }); const r = await recordSession(m.options(), m.deps); expect(r.exitCode).toBe(0); expect(m.files.get("scripts/draft.aite")).toContain('press "Enter"'); expect(m.files.get("scripts/draft.aite")).not.toContain("dance"); }); it("a recording with nothing in it writes no file and says so", async () => { const m = machine(); const r = await recordSession(m.options(), m.deps); expect(r.exitCode).toBe(1); expect(m.files.has("scripts/draft.aite")).toBe(false); expect(r.output).toMatch(/nothing was recorded/i); expect(m.isClosed()).toBe(true); }); it("never overwrites a file that is there", async () => { const m = machine({ files: { "scripts/draft.aite": "mine" } }); const r = await recordSession(m.options(), m.deps); expect(r.exitCode).toBe(2); expect(r.output).toContain("already exists"); expect(m.files.get("scripts/draft.aite")).toBe("mine"); expect(m.log.some((l) => l.startsWith("launch"))).toBe(false); }); it("only an http or https page can be recorded, and a config with several profiles needs --profile", async () => { const m = machine(); expect((await recordSession(m.options({ url: "file:///etc/passwd" }), m.deps)).exitCode).toBe(2); expect((await recordSession(m.options({ url: "nonsense" }), m.deps)).exitCode).toBe(2); const two = { profiles: { a: { userDataDir: "/a", chrome: "/c" }, b: { userDataDir: "/b", chrome: "/c" } }, allow: [] } as unknown as AiteConfig; const r = await recordSession(m.options(), { ...m.deps, loadConfig: () => two }); expect(r.exitCode).toBe(2); expect(r.output).toContain("--profile"); const picked = machine({ config: two }); expect((await recordSession(picked.options({ profile: "zzz" }), picked.deps)).output).toContain('no profile "zzz"'); await recordSession(picked.options({ profile: "b" }), picked.deps); expect(picked.log.some((l) => l.startsWith("launch /c /b"))).toBe(true); }); it("Chrome that cannot start, a page that cannot load and no page to attach to are reported, and Chrome is closed", async () => { const bad = machine({ launchFails: true }); expect(await recordSession(bad.options(), bad.deps)).toMatchObject({ exitCode: 3 }); const nav = machine({ navigateFails: true }); const r = await recordSession(nav.options(), nav.deps); expect(r.exitCode).toBe(3); expect(r.output).toContain("ERR_NAME_NOT_RESOLVED"); expect(nav.isClosed()).toBe(true); const none = machine({ noPage: true }); expect((await recordSession(none.options(), none.deps)).exitCode).toBe(3); expect(none.isClosed()).toBe(true); }); it("a file that cannot be written is an error that still closes Chrome", async () => { const m = machine({ calls: [(emit) => emit({ kind: "press", key: "Enter" })] }); const r = await recordSession(m.options(), { ...m.deps, writeFile: () => { throw new Error("EACCES"); } }); expect(r.exitCode).toBe(2); expect(r.output).toContain("EACCES"); expect(m.isClosed()).toBe(true); });});