import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; import { checkGenerated, expectedGenerated, GENERATED_MARKER, generate, } from "./codegen.mjs"; function withTemp(fn: (root: string) => void) { const root = mkdtempSync(join(tmpdir(), "pi-ext-codegen-")); try { fn(root); } finally { rmSync(root, { recursive: true, force: true }); } } function extension(root: string, slug: string) { const dir = join(root, "extensions", slug); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "package.json"), "{}\n", "utf8"); return dir; } function targets(root: string, value: object) { const dir = join(root, "scripts", "codegen"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "targets.json"), JSON.stringify(value), "utf8"); } function template( root: string, text = "export const slug = '{{extension-slug}}';\n", ) { const path = join(root, "scripts", "codegen", "fixture.ts"); mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, text, "utf8"); return "scripts/codegen/fixture.ts"; } it("selects every package extension for all", () => { withTemp((root) => { extension(root, "alpha"); extension(root, "beta"); const source = template(root); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); generate(root); for (const slug of ["alpha", "beta"]) { const output = readFileSync( join(root, "extensions", slug, "generated.ts"), "utf8", ); expect(output).toMatch( /^\/\/ Generated by scripts\/codegen\.mjs\. DO NOT EDIT\.\n\/\/ ={10,}\n\/\/ THIS FILE IS GENERATED\. DO NOT EDIT IT DIRECTLY\./, ); expect(output).toContain(`// Instead, edit ${source}.`); expect(output).toContain("// Then run: mise run //:codegen"); expect(output).toMatch(new RegExp(`export const slug = '${slug}';\\n$`)); } }); }); it("selects listed extension slugs only", () => { withTemp((root) => { const alpha = extension(root, "alpha"); extension(root, "beta"); const source = template(root); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: ["alpha"], }, ], }); generate(root); expect(checkGenerated(alpha, root)).toEqual([]); expect(expectedGenerated(root)).toHaveLength(1); expect(() => readFileSync(join(root, "extensions", "beta", "generated.ts")), ).toThrow(); }); }); it("rejects traversal, symlinks, and output collisions", () => { withTemp((root) => { const alpha = extension(root, "alpha"); const source = template(root); targets(root, { targets: [ { name: "traversal", template: source, output: "../{{extension-slug}}.ts", extensions: "all", }, ], }); expect(checkGenerated(alpha, root)[0]).toMatch(/extensions/); targets(root, { targets: [ { name: "wrong-owner", template: source, output: "extensions/beta/generated-{{extension-slug}}.ts", extensions: "all", }, ], }); expect(checkGenerated(alpha, root)[0]).toMatch(/extensions/); targets(root, { targets: [ { name: "symlink", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); symlinkSync(join(root, "extensions", "alpha"), join(root, "linked")); writeFileSync( join(root, "scripts", "codegen", "targets.json"), JSON.stringify({ targets: [ { name: "symlink", template: "linked/fixture.ts", output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }), "utf8", ); expect(checkGenerated(alpha, root)[0]).toMatch(/symlink/); targets(root, { targets: [ { name: "one", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, { name: "two", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); expect(checkGenerated(alpha, root)[0]).toMatch(/collision/); }); }); it("rejects unsupported or incomplete placeholders", () => { withTemp((root) => { const alpha = extension(root, "alpha"); const source = template(root, 'export const value = "{{unknown}}";\n'); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); expect(checkGenerated(alpha, root)[0]).toMatch(/unsupported placeholder/); template(root, 'export const value = "{{extension-slug";\n'); expect(checkGenerated(alpha, root)[0]).toMatch(/unsupported placeholder/); template(root); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/{{unknown}}.ts", extensions: "all", }, ], }); expect(checkGenerated(alpha, root)[0]).toMatch(/unsupported placeholder/); }); }); it("reports stale output without writing", () => { withTemp((root) => { const alpha = extension(root, "alpha"); const source = template(root); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); generate(root); const output = join(root, "extensions", "alpha", "generated.ts"); writeFileSync(output, `${GENERATED_MARKER}stale\n`, "utf8"); expect(checkGenerated(alpha, root)).toEqual([ "alpha: stale generated extensions/alpha/generated.ts", ]); expect(readFileSync(output, "utf8")).toBe(`${GENERATED_MARKER}stale\n`); }); }); it("refuses to overwrite unmarked output", () => { withTemp((root) => { extension(root, "alpha"); const source = template(root); targets(root, { targets: [ { name: "fixture", template: source, output: "extensions/{{extension-slug}}/generated.ts", extensions: "all", }, ], }); const output = join(root, "extensions", "alpha", "generated.ts"); writeFileSync(output, "manual\n", "utf8"); expect(() => generate(root)).toThrow(/unmarked/); expect(readFileSync(output, "utf8")).toBe("manual\n"); }); }); it("does nothing without codegen config", () => { withTemp((root) => { const alpha = extension(root, "alpha"); expect(expectedGenerated(root)).toEqual([]); expect(checkGenerated(alpha, root)).toEqual([]); expect(generate(root)).toEqual([]); }); });