import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; import { preserveReadmeDemo, readmePreamble } from "../extensions.ts"; import { checkExtension } from "./check.ts"; import { extractSurface, injectSurface, renderSurface } from "./config.ts"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function fixture() { const root = mkdtempSync(join(tmpdir(), "pi-ext-readme-check-")); roots.push(root); const dir = join(root, "extensions", "fixture"); mkdirSync(join(dir, "assets"), { recursive: true }); writeFileSync(join(dir, "assets", "banner.svg"), ""); writeFileSync(join(dir, "index.ts"), "export const value = 1;"); const readme = 'fixture\n\n# fixture\n\n## Reference\n\n' + renderSurface(extractSurface(dir)) + "\n"; writeFileSync(join(dir, "README.md"), readme); return { root, dir, readme, path: join(dir, "README.md") }; } it("executes CLI entrypoint and rejects an unknown extension", () => { const result = spawnSync( process.execPath, [ join(process.cwd(), "scripts/readme/check.ts"), "__missing_readme_cli_probe__", ], { cwd: process.cwd(), encoding: "utf8", timeout: 10_000 }, ); expect(result.error).toBeUndefined(); expect(result.status).toBe(1); expect(result.stderr).toContain( "unknown extension(s): __missing_readme_cli_probe__", ); }); it("preserves banner spacing and restores a missing demo without duplicating a new caption", () => { const { readme } = fixture(); const preamble = readmePreamble(readme); expect(preamble + "# fixture").toContain('height="70">\n\n# fixture'); const oldDemo = "![Old caption](assets/demo.gif)"; expect(preserveReadmeDemo("# fixture\n\nPurpose.\n", oldDemo)).toContain( oldDemo, ); const changed = "# fixture\n\n![New caption](assets/demo.gif)\n"; expect(preserveReadmeDemo(changed, oldDemo)).toBe(changed); }); it.each([ "## Debug\n\nSafe events: start, finish.\n", "[Debug contract](../DEBUG.md)\n", ])("rejects developer debug documentation in user READMEs: %s", (debug) => { const { root, readme, path } = fixture(); writeFileSync(path, `${readme}\n${debug}`); expect( checkExtension("fixture", false, root).some(({ message }) => message.includes("not a user README"), ), ).toBe(true); }); it("accepts minimal docs without invented changelog history", () => { const { root } = fixture(); expect(checkExtension("fixture", false, root)).toEqual([]); }); it("checks drift without writes; write mode is idempotent", () => { const { root, dir, readme, path } = fixture(); writeFileSync( join(dir, "index.ts"), 'pi.registerCommand("fixture", { description: "Run fixture" });', ); expect(checkExtension("fixture", false, root)[0]?.message).toContain( "out of date", ); expect(readFileSync(path, "utf8")).toBe(readme); expect(checkExtension("fixture", true, root)).toEqual([]); const written = readFileSync(path, "utf8"); expect(checkExtension("fixture", true, root)).toEqual([]); expect(readFileSync(path, "utf8")).toBe(written); }); it("rejects duplicate or reversed markers", () => { const { root, readme, path } = fixture(); writeFileSync(path, readme + "\n"); expect(checkExtension("fixture", true, root)[0]?.message).toContain( "exactly once", ); expect( injectSurface("", "block"), ).toBeUndefined(); }); it("checks demo pairing, relative links, and existing changelogs", () => { const { root, dir, readme, path } = fixture(); mkdirSync(join(dir, "demo")); writeFileSync(join(dir, "demo", "scene.json"), "{}"); writeFileSync(join(dir, "CHANGELOG.md"), "# Changelog\n\n## Unreleased\n"); writeFileSync(path, readme + "\n[Missing](missing.md)\n"); const messages = checkExtension("fixture", false, root).map( ({ message }) => message, ); expect(messages).toContain( "demo/scene.json exists but README does not embed assets/demo.gif", ); expect(messages).toContain("README does not link CHANGELOG.md"); expect(messages).toContain("broken relative link: missing.md"); }); it("requires a rendered demo image, not merely a matching asset path", () => { const { root, dir, readme, path } = fixture(); mkdirSync(join(dir, "demo")); writeFileSync(join(dir, "demo", "scene.json"), "{}"); writeFileSync(join(dir, "assets", "demo.gif"), "GIF89a"); writeFileSync(path, readme + String.raw`\n![Demo](assets/demo.gif)`); expect( checkExtension("fixture", false, root).some(({ message }) => message.includes("does not embed"), ), ).toBe(true); writeFileSync(path, readme + "\n![Demo](assets/demo.gif)\n"); expect(checkExtension("fixture", false, root)).toEqual([]); }); it("fails closed on unresolved registrations", () => { const { root, dir } = fixture(); writeFileSync(join(dir, "index.ts"), "pi.registerTool(unknownDefinition);"); expect( checkExtension("fixture", false, root).some(({ message }) => message.includes("cannot be resolved"), ), ).toBe(true); });