import { describe, expect, it } from "vitest"; import { parseScript } from "../../runner/parser"; import { expandIncludes } from "../../runner/include"; const files: Record = { "/proj/scripts/main.aite": 'test "main"\nstep "s"\n run shared/login.aite\n click text:"Go"\n', "/proj/scripts/shared/login.aite": 'test "login"\n start http://ignored.test/\n covers J-99\nstep "sign in"\n type label:"User" "alex"\n click text:"Sign in"\nstep "check"\n verify text:"Welcome"\n', }; const read = (extra: Record = {}) => (p: string): string => { const all = { ...files, ...extra }; if (!(p in all)) throw new Error(`ENOENT: no such file ${p}`); return all[p]!; }; const parse = (text: string, file = "scripts/main.aite") => { const r = parseScript(text, { file }); if (!r.ok) throw new Error(JSON.stringify(r.errors)); return r.script; }; const expand = (text: string, extra: Record = {}, over: object = {}) => expandIncludes(parse(text), { file: "/proj/scripts/main.aite", display: "scripts/main.aite", root: "/proj", readFile: read(extra), ...over }); describe("expandIncludes", () => { it("replaces a run command with the commands of the included script's steps, in order, each stamped with its own file and line", () => { const r = expand(files["/proj/scripts/main.aite"]!); expect(r.ok).toBe(true); if (!r.ok) return; const cmds = r.script.steps[0]!.commands; expect(cmds.map((c) => c.verb)).toEqual(["type", "click", "verify", "click"]); expect(cmds.map((c) => c.file)).toEqual(["scripts/shared/login.aite", "scripts/shared/login.aite", "scripts/shared/login.aite", "scripts/main.aite"]); expect(cmds.map((c) => c.line)).toEqual([5, 6, 8, 4]); }); it("ignores the included script's own start, covers, needs and timeout: the including script owns the run", () => { const r = expand(files["/proj/scripts/main.aite"]!); expect(r.ok && r.script.start).toBeUndefined(); expect(r.ok && r.script.covers).toEqual([]); }); it("a script with no run is returned as it is", () => { const script = parse('test "t"\nstep "s"\n press Enter'); expect(expandIncludes(script, { file: "/proj/scripts/main.aite", display: "scripts/main.aite", root: "/proj", readFile: read() })).toEqual({ ok: true, script }); }); it("includes can include, and the files are reported relative to the project", () => { const r = expand('test "t"\nstep "s"\n run a.aite', { "/proj/scripts/a.aite": 'test "a"\nstep "x"\n run shared/login.aite\n press Tab' }); expect(r.ok && r.script.steps[0]!.commands.map((c) => c.file)).toEqual(["scripts/shared/login.aite", "scripts/shared/login.aite", "scripts/shared/login.aite", "scripts/a.aite"]); }); it("variables set before the run are visible in the included script; ones set after are not", () => { const inc = { "/proj/scripts/uses.aite": 'test "u"\nstep "x"\n verify text:"${who}"' }; expect(expand('test "t"\nstep "s"\n set who "alex"\n run uses.aite', inc).ok).toBe(true); const r = expand('test "t"\nstep "s"\n run uses.aite\n set who "alex"', inc); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors[0]).toMatchObject({ file: "scripts/uses.aite", line: 3, message: expect.stringContaining("${who} is used before it is set") }); }); it("a missing file is an error at the run line of the including script", () => { const r = expand('test "t"\nstep "s"\n run nope.aite'); expect(r).toEqual({ ok: false, errors: [{ file: "scripts/main.aite", line: 3, message: expect.stringContaining("cannot read scripts/nope.aite") }] }); }); it("a syntax error in the included script is reported in that file, with its line", () => { const r = expand('test "t"\nstep "s"\n run bad.aite', { "/proj/scripts/bad.aite": 'test "b"\nstep "x"\n clik text:"y"' }); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors).toEqual([{ file: "scripts/bad.aite", line: 3, message: 'unknown command "clik" (did you mean "click"?)' }]); }); it("a script that includes itself, directly or through another, is refused with the chain", () => { const direct = expand('test "t"\nstep "s"\n run main.aite'); expect(direct.ok).toBe(false); if (!direct.ok) expect(direct.errors[0]!.message).toContain("scripts/main.aite -> scripts/main.aite"); const loop = expand('test "t"\nstep "s"\n run a.aite', { "/proj/scripts/a.aite": 'test "a"\nstep "x"\n run b.aite', "/proj/scripts/b.aite": 'test "b"\nstep "x"\n run a.aite' }); expect(loop.ok).toBe(false); if (!loop.ok) expect(loop.errors[0]!.message).toMatch(/run loop: .*a\.aite -> .*b\.aite -> .*a\.aite/); }); it("includes nested too deep are refused", () => { const chain: Record = {}; for (let i = 0; i < 12; i++) chain[`/proj/scripts/n${i}.aite`] = `test "n"\nstep "x"\n run n${i + 1}.aite`; chain["/proj/scripts/n12.aite"] = 'test "n"\nstep "x"\n press Tab'; const r = expand('test "t"\nstep "s"\n run n0.aite', chain); expect(r.ok).toBe(false); if (!r.ok) expect(r.errors[0]!.message).toContain("too deep"); }); it.each([ ["an absolute path", "/etc/passwd.aite"], ["a path that climbs out of the project", "../../../outside.aite"], ["a file that is not .aite", "secrets.txt"], ["a path with a NUL", "a\u0000.aite"], ])("%s is refused before any file is read", (_n, path) => { const reads: string[] = []; const r = expand(`test "t"\nstep "s"\n run "${path}"`, {}, { readFile: (p: string) => (reads.push(p), "") }); expect(r.ok).toBe(false); expect(reads).toEqual([]); if (!r.ok) expect(r.errors[0]!.message).toContain("must be a .aite file inside the project"); }); it("reports every problem, one per run command", () => { const r = expand('test "t"\nstep "s"\n run nope.aite\n run also-nope.aite'); expect(r.ok === false && r.errors.map((e) => e.line)).toEqual([3, 4]); }); it("the same file included twice in one script is fine (not a loop)", () => { const r = expand('test "t"\nstep "s"\n run shared/login.aite\n run shared/login.aite'); expect(r.ok && r.script.steps[0]!.commands).toHaveLength(6); }); }); describe("expandIncludes keeps what an included script says it does (#69)", () => { const inc = (head: string) => ({ "/proj/scripts/shared/inc.aite": `test "inc"\n${head}step "s"\n sleep 1ms\n` }); it("a script that posts or spends only through an include is a script that posts or spends, however deep the include is", () => { const main = 'test "main"\nstep "s"\n run shared/inc.aite\n'; const none = expand(main, inc("")); expect(none.ok && none.script.posts).toBeUndefined(); expect(none.ok && none.script.spends).toBeUndefined(); const posting = expand(main, inc(" posts\n")); expect(posting.ok && posting.script).toMatchObject({ posts: true }); expect(posting.ok && posting.script.spends).toBeUndefined(); const spending = expand(main, inc(" spends\n")); expect(spending.ok && spending.script).toMatchObject({ spends: true }); expect(spending.ok && spending.script.posts).toBeUndefined(); const deep = expand(main, { "/proj/scripts/shared/inc.aite": 'test "a"\nstep "s"\n run deeper.aite\n', "/proj/scripts/shared/deeper.aite": 'test "b"\n spends\n posts\nstep "s"\n sleep 1ms\n' }); expect(deep.ok && deep.script).toMatchObject({ posts: true, spends: true }); }); it("the including script's own flags stay when the include has none", () => { const r = expand('test "main"\n spends\nstep "s"\n run shared/inc.aite\n', inc("")); expect(r.ok && r.script).toMatchObject({ spends: true }); }); });