import { afterEach, describe, expect, it } from "vitest"; import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runCli } from "../../runner/cli"; import { realRunDeps, runScriptFile, type RunDeps, type RunOptions } from "../../runner/run"; import { createJsdomBrowser } from "../helpers/jsdomBrowser"; import { startExtension } from "../helpers/extensionRuntime"; import { HELLO, makeProject, page, type Project } from "../helpers/project"; import { makeTempRepo } from "../helpers/tempRepo"; /** * The whole of `aite run` in this process: real script and config files, the real fixture server over real HTTP, the real bridge * over a real WebSocket, the extension's real code, and the runner's engine. Only Chrome itself is replaced, by jsdom running the * pages. (The same scripts run in a real Chrome in run.e2e.test.ts.) */ const root = resolve(__dirname, "../.."); const projects: Project[] = []; const browsers: Array> = []; afterEach(() => { for (const p of projects.splice(0)) p.repo.cleanup(); browsers.splice(0); }); /** Real files, real fixture server and bridge; nothing here gets as far as a page, so there is no browser at all. */ function deps(over: Partial = {}): RunDeps { return { ...realRunDeps(), launch: async () => ({ close: async () => undefined, exited: new Promise(() => undefined), stderrTail: () => "" }), ...over }; } const options = (p: Pick, over: Partial = {}): RunOptions => ({ scriptPath: p.scriptPath, configPath: p.configPath, headless: true, noSandbox: true, connectTimeoutMs: 5000, ...over }); async function project(pages: Record, script: (origin: string) => string) { const p = await makeProject(pages, script); projects.push(p); return p; } const run = (p: Project, over: Partial = {}, d: RunDeps = deps()) => runScriptFile(options(p, over), d); const clickHello = (o: string, verify: string, timeout = "1s") => `test "t"\n start ${o}/hello.html\n timeout ${timeout}\n step "click"\n click role:button name:"Say hello"\n step "greeting"\n verify text:${JSON.stringify(verify)}\n`; describe("cannot run (exit 2) with real files", () => { it("a script file that does not exist", async () => { const p = await project({}, () => ""); const r = await run(p, { scriptPath: join(p.dir, "nope.aite") }); expect(r.exitCode).toBe(2); expect(r.output).toContain("cannot read"); }); it("a script with mistakes lists them with their lines", async () => { const p = await project({ "hello.html": HELLO }, (o) => `test "t"\n start ${o}/hello.html\n step\n clik text:"x"\n click\n`); const r = await run(p); expect(r.exitCode).toBe(2); expect(r.output).toMatch(/t\.aite:3: a step needs a name/); expect(r.output).toMatch(/t\.aite:4: unknown command "clik"/); expect(r.output).toMatch(/t\.aite:5: click needs a target/); expect(r.output).toContain("3 errors in"); }); it("a config file that does not exist, or is not valid", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); expect((await run(p, { configPath: join(p.dir, "nope.json") })).output).toContain("cannot read"); writeFileSync(p.configPath, "{ nope"); const r = await run(p); expect(r.exitCode).toBe(2); expect(r.output).toContain("invalid aite config"); }); it("a start page not in the allow list, and a profile that is not configured", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); writeFileSync(p.configPath, JSON.stringify({ profiles: { p: { userDataDir: join(p.dir, "profile"), chrome: "/unused" } }, allow: ["https://elsewhere.example"] })); expect((await run(p)).output).toContain("is not in the allow list"); expect((await run(p, { profile: "ghost" })).output).toContain('profile "ghost" is not in aite.config.json'); }); it("a fixture port that is already taken", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); const blocker = await new Promise>((ok) => { const s = createServer().listen(p.port, "127.0.0.1", () => ok(s)); }); try { const r = await run(p); expect(r.exitCode).toBe(2); expect(r.output).toContain(`port ${p.port} is already in use`); } finally { blocker.close(); } }); }); describe("environment failures (exit 3)", () => { it("Chrome that cannot be started", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); writeFileSync(p.configPath, JSON.stringify({ profiles: { p: { userDataDir: join(p.dir, "profile"), chrome: "/nonexistent/chrome" } }, allow: [p.origin] })); const r = await runScriptFile(options(p), realRunDeps()); expect(r.exitCode).toBe(3); expect(r.output).toContain("cannot start Chrome at /nonexistent/chrome"); }); it("an extension that never connects: reports it after the wait, and tidies up", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); const r = await run(p, { connectTimeoutMs: 300 }, deps({ launch: async () => ({ close: async () => undefined, exited: new Promise(() => undefined), stderrTail: () => "" }) })); expect(r.exitCode).toBe(3); expect(r.output).toContain("did not connect within 300 ms"); }); it("Chrome dying at startup is reported at once with its own message", async () => { const p = await project({ "hello.html": HELLO }, (o) => clickHello(o, "Waiting")); const t0 = Date.now(); const r = await run(p, { connectTimeoutMs: 20_000 }, deps({ launch: async () => ({ close: async () => undefined, exited: Promise.resolve({ code: null, signal: "SIGABRT" }), stderrTail: () => "[1:1:FATAL:zygote] No usable sandbox!" }) })); expect(Date.now() - t0).toBeLessThan(5000); expect(r.exitCode).toBe(3); expect(r.output).toContain("No usable sandbox!"); }); }); describe("the folders a run creates", () => { it("are private to the user (mode 700): a Chrome profile holds signed-in sessions", async () => { const { mkdtempSync, statSync, rmSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); const base = mkdtempSync(join(tmpdir(), "aite-mode-")); try { const dir = join(base, "a", "profile"); realRunDeps().makeDir(dir); expect(statSync(dir).mode & 0o777).toBe(0o700); } finally { rmSync(base, { recursive: true, force: true }); } }); });