Build tests
prose build, run on tests/fixtures/simple and on a throwaway git repository. Every page
must match the server's, less the live parts. The build must clear only an output folder it
made.
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, posix, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { LIVE, STYLE } from "../src/assets.js";
import { build } from "../src/build.js";
import { renderRoute, type Site } from "../src/server.js";
import { projectFiles } from "../src/tree.js";
const temps: string[] = [];
function temp(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
temps.push(dir);
return dir;
}
afterAll(() => {
for (const dir of temps) rmSync(dir, { recursive: true, force: true });
});
/** Every file under `dir`, relative, with `/` separators. */
function listAll(dir: string, rel = ""): string[] {
return readdirSync(join(dir, rel), { withFileTypes: true }).flatMap((entry) => {
const path = rel ? `${rel}/${entry.name}` : entry.name;
return entry.isDirectory() ? listAll(dir, path) : [path];
});
}
function git(cwd: string, ...args: string[]): void {
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], {
cwd,
stdio: "ignore",
});
}
describe("build: tests/fixtures/simple", () => {
let out: string;
let pages: string[];
beforeAll(async () => {
out = join(temp("prose-build-out-"), "site");
await build(resolve("tests/fixtures/simple"), { out });
pages = listAll(out).filter((p) => p.endsWith(".html"));
});
it("writes a page per folder and per file, a source index.html as index.html.html", () => {
expect(pages.sort()).toEqual(
[
".gitignore.html",
"404.html",
"index.html",
"index.html.html",
"main.js.html",
"package.json.html",
"pnpm-lock.yaml.html",
"README.md.html",
"style.css.html",
].sort(),
);
});
// A folder's README.md is a redirect to its folder's page, not a page of its own.
const shown = () => pages.filter((page) => page !== "README.md.html");
it("writes a folder's README.md as a redirect to the folder's page", () => {
const html = readFileSync(join(out, "README.md.html"), "utf-8");
expect(html).toContain('<meta http-equiv="refresh" content="0; url=/">');
expect(html).toContain('<link rel="canonical" href="/">');
});
it("puts the rail on every page, and a source page's blocks", () => {
for (const page of shown())
expect(readFileSync(join(out, page), "utf-8")).toContain('<nav class="rail"');
const main = readFileSync(join(out, "main.js.html"), "utf-8");
expect(main).toContain('id="state"');
expect(main).toContain('class="code-head"');
});
it("writes the host's 404 page with the file tree, since it's served at any missing address", () => {
const missing = readFileSync(join(out, "404.html"), "utf-8");
expect(missing).toContain('<nav class="rail"');
expect(missing).toContain("Nothing at this address");
expect(missing).toContain('href="/main.js"');
});
it("leaves the live parts out and says which commit it is", () => {
let headTag = "";
try {
headTag = execFileSync("git", ["describe", "--tags", "--exact-match", "HEAD"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {}
for (const page of shown()) {
const html = readFileSync(join(out, page), "utf-8");
expect(html).not.toContain(LIVE.url);
expect(html).not.toContain("data-rendered=");
expect(html).not.toContain('href="vscode://');
// The tag is named only when HEAD is exactly that tag (a release run builds a tagged HEAD),
// so the nearest earlier tag must not stand in for one.
expect(html).toMatch(
headTag
? new RegExp(`Snapshot · (?:<a [^>]*>)?${headTag.replace(/\./g, "\\.")}(?:</a>)? · `)
: /Snapshot · (?:<a [^>]*>)?[0-9a-f]{7,}/,
);
if (!headTag) expect(html).not.toMatch(/Snapshot · (?:<a [^>]*>)?v\d/);
}
});
it("writes the shared files once, and tells the host to cache them for good", () => {
expect(readFileSync(join(out, STYLE.url), "utf-8")).toBe(STYLE.body);
expect(existsSync(join(out, LIVE.url))).toBe(false);
expect(readFileSync(join(out, "_headers"), "utf-8")).toContain("/assets/*\n Cache-Control:");
});
it("resolves every link to a page it wrote", () => {
for (const page of pages) {
const html = readFileSync(join(out, page), "utf-8");
// The page's URL: a folder's `index.html` is served at its folder.
const url = `/${page.endsWith("/index.html") || page === "index.html" ? page.slice(0, -"index.html".length) : page.slice(0, -".html".length)}`;
for (const [, href] of html.matchAll(/(?:href|src)="([^"#]+)(?:#[^"]*)?"/g)) {
if (href!.includes(":")) continue;
// A link above the project's root (the example's README points into this repo's docs)
// has no page on any host; it's a link out of the project, not a missing page.
if (posix.normalize(posix.join(posix.dirname(`${url}x`).slice(1), href!)).startsWith(".."))
continue;
const target =
decodeURIComponent(posix.resolve(posix.dirname(`${url}x`), href!)) +
(href!.endsWith("/") ? "/" : "");
// As Pages does: the file at that path, else that path plus `.html`.
const file = target.endsWith("/")
? `${target}index.html`
: existsSync(join(out, target))
? target
: `${target}.html`;
expect(existsSync(join(out, file)), `${page} → ${href}`).toBe(true);
}
}
});
});
describe("build: a repository", () => {
let root: string;
beforeAll(() => {
root = temp("prose-build-repo-");
const files: Record<string, string> = {
".gitignore": ".prose/\n",
"README.md": "# Demo\n\nSee [a](src/a.ts).\n",
"src/a.ts": "/** @prose\n * Does a.\n */\nexport const a = 1;\n",
};
for (const [path, text] of Object.entries(files)) {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), text);
}
git(root, "init", "-q");
git(root, "add", "-A");
git(root, "commit", "-q", "-m", "first");
});
it("publishes the commit: no untracked file, no uncommitted edit, and a warning about them", async () => {
writeFileSync(join(root, "scratch.ts"), "export const secret = 1;\n");
writeFileSync(join(root, "src/a.ts"), "/** @prose\n * Edited.\n */\nexport const a = 2;\n");
try {
const built = await build(root);
expect(built.out).toBe(join(root, ".prose"));
expect(existsSync(join(built.out, "scratch.ts.html"))).toBe(false);
expect(readFileSync(join(built.out, "src/a.ts.html"), "utf-8")).toContain("Does a.");
expect(built.warnings.join()).toContain("uncommitted changes");
expect(built.warnings.join()).not.toContain(".gitignore");
} finally {
rmSync(join(root, "scratch.ts"));
git(root, "checkout", "--", "src/a.ts");
}
});
it("gives the same bytes for the same commit", async () => {
const one = join(temp("prose-build-a-"), "site");
const two = join(temp("prose-build-b-"), "site");
await build(root, { out: one });
await build(root, { out: two });
const files = listAll(one).sort();
expect(listAll(two).sort()).toEqual(files);
for (const file of files) {
expect(readFileSync(join(two, file), "utf-8")).toBe(readFileSync(join(one, file), "utf-8"));
}
});
it("refuses an output folder it didn't make, or one holding the repository or tracked files", async () => {
const other = temp("prose-build-other-");
writeFileSync(join(other, "keep.txt"), "mine\n");
await expect(build(root, { out: other })).rejects.toThrow("isn't a previous build");
expect(existsSync(join(other, "keep.txt"))).toBe(true);
await expect(build(root, { out: root })).rejects.toThrow("holds the repository");
await expect(build(root, { out: join(root, "src") })).rejects.toThrow("tracked files");
});
it("clears its own previous output, and warns when the output isn't ignored", async () => {
const built = await build(root, { out: "site" });
writeFileSync(join(built.out, "stale.html"), "old\n");
const again = await build(root, { out: "site" });
expect(existsSync(join(again.out, "stale.html"))).toBe(false);
expect(again.warnings.join()).toContain("isn't in .gitignore");
rmSync(again.out, { recursive: true });
});
it("renders the same page as the server, less the live parts", async () => {
const site: Site = { root, project: "demo", files: projectFiles(root), live: true, nav: [] };
const main = (html: string) =>
html.slice(html.indexOf("</div>", html.indexOf("<main")), html.indexOf("</main>"));
const live = renderRoute(site, "src/a.ts");
const built = renderRoute(
{ ...site, live: false, snapshot: { commit: "abc1234" } },
"src/a.ts",
);
if (live.status !== 200 || built.status !== 200) throw new Error("expected pages");
expect(main(built.html)).toBe(main(live.html));
expect(live.html).toContain(LIVE.url);
expect(built.html).not.toContain(LIVE.url);
});
it("names what's ignored on a local folder page, never on a built one", async () => {
writeFileSync(join(root, ".env"), "SECRET=1\n");
writeFileSync(join(root, ".gitignore"), ".prose/\n.env\n");
try {
const site: Site = { root, project: "demo", files: projectFiles(root), live: true, nav: [] };
const live = renderRoute(site, "");
const built = renderRoute({ ...site, live: false }, "");
if (live.status !== 200 || built.status !== 200) throw new Error("expected pages");
expect(live.html).toContain('<p class="ignored">Ignored here: <code>.env</code>');
expect(built.html).not.toContain("Ignored here");
} finally {
rmSync(join(root, ".env"));
git(root, "checkout", "--", ".gitignore");
}
});
});