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 =
'
\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 = "";
expect(preserveReadmeDemo("# fixture\n\nPurpose.\n", oldDemo)).toContain(
oldDemo,
);
const changed = "# fixture\n\n\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`);
expect(
checkExtension("fixture", false, root).some(({ message }) =>
message.includes("does not embed"),
),
).toBe(true);
writeFileSync(path, readme + "\n\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);
});