import { test, after } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
chmodSync,
utimesSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildThemeScript, HOMEPAGE_DEFAULT_BASE } from "../lib/themeConfig";
import { writeFakeStagit } from "./__fixtures__/fakeStagit";
import {
HISTORY_BACK_LINK,
HISTORY_LOCK_STALE_MS,
HISTORY_TOKENS,
HistoryProblem,
SITE_ICON_HREF,
SOURCE_HISTORY_MAX_COMMITS,
loggedCommits,
historyStylesheet,
holdHistoryCache,
homepageThemeScript,
renderHistory,
resolveStagit,
rewriteHistoryPage,
stageHistory,
stagitIdentity,
tokenBlocks,
type HistoryRun,
type RenderHistoryOpts,
} from "./sourceHistory";
// Run with:
// pnpm --filter yt-dlp-transcript-common test
//
// The history pages (sourceHistory.ts): the post-pass, the stylesheet from
// the tokens, the binary's lookup, and the render with its cache — over a
// fake stagit that writes what stagit writes, and over the real one when it
// is installed (that test SKIPS without it; a gate says whether it ran).
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"]) {
delete process.env[key];
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const TOKENS = readFileSync(path.join(HERE, "..", "styles", "tokens.css"), "utf8");
const TMP = mkdtempSync(path.join(os.tmpdir(), "source-history-"));
after(() => rmSync(TMP, { recursive: true, force: true }));
let n = 0;
function dir(name: string): string {
const d = path.join(TMP, `${name}-${n++}`);
mkdirSync(d, { recursive: true });
return d;
}
// ── the post-pass ───────────────────────────────────────────────────────────
// stagit's own markup, as it writes a commit page two levels down (relpath
// "../"): the header, the diffstat's anchors, a diff header linking both
// sides into file/, and a message that quotes an href as text.
const COMMIT_PAGE = `
a subject - archilyzer - Archilyzer
commit ${"a".repeat(40)}
a message that says href="file/x.html" as text
diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx
+added
`;
test("the post-pass: the theme script before , the back link after , file/ links to the raw tree, the logo and favicon to the site's icon — nothing else", () => {
const script = "var x=1;";
const out = rewriteHistoryPage(COMMIT_PAGE, script);
const expected = COMMIT_PAGE
.replace("", `\n`)
.replace("\n", `\n${HISTORY_BACK_LINK}\n`)
.replace('href="../favicon.png"', `href="${SITE_ICON_HREF}"`)
.replace('src="../logo.png"', `src="${SITE_ICON_HREF}"`)
.replace('href="../file/README.md.html"', 'href="../../tree/README.md"')
.replace('href="../file/LICENSE.html"', 'href="../../tree/LICENSE"')
.replaceAll('href="../file/app/%5Bslug%5D/page.tsx.html"', 'href="../../tree/app/%5Bslug%5D/page.tsx"');
assert.equal(out, expected);
// Text that quotes an href is encoded by stagit, and left alone.
assert.match(out, /says href="file\/x\.html" as text/);
// The back link is the one line added to the body, and it is ASCII.
assert.equal(HISTORY_BACK_LINK, 'Archilyzer · Source
');
assert.ok(!/[^\x00-\x7f]/.test(HISTORY_BACK_LINK));
});
test("the post-pass at the top level: files.html's links go to ../tree/, as stagit encoded them", () => {
const files = `\n\n\n\n-rw-r--r-- fonts/Archivo[wdth,wght].ttf \na.html \n\n\n`;
const out = rewriteHistoryPage(files, "s()");
assert.match(out, //);
assert.match(out, / a\.html<\/a>/, "a file that is itself .html keeps its name");
assert.ok(!out.includes('href="file/'));
});
test("the post-pass with what is published: a link to a file main no longer has, or to a commit with no page, becomes text (its id kept)", () => {
const sha = "a".repeat(40);
const gone = "b".repeat(40);
const page = `\n\n\n\n ${sha} parent ${gone} \ndiff --git a/old/name.txt b/app/[slug]/page.tsx \nbad \n\n\n`;
const published = {
tree: (p: string) => p === "app/[slug]/page.tsx",
commit: (c: string) => c === sha,
};
const out = rewriteHistoryPage(page, "t()", published);
assert.ok(out.includes(`${sha} `), "a published commit keeps its link");
assert.ok(out.includes(`parent ${gone} `), "an unpublished commit is text");
assert.ok(out.includes(`a/old/name.txt `), "a file main no longer has is text, and keeps the diffstat's target");
assert.ok(out.includes(`b/`), "a file in the tree keeps its (encoded) link");
assert.ok(out.includes(` bad `), "an encoding that does not decode is not linked");
// Without the set, nothing is dropped (the unit tests above).
assert.ok(rewriteHistoryPage(page, "t()").includes(`href="../../tree/old/name.txt"`));
});
test("the theme script is the homepage's own: its base, its string", () => {
assert.equal(HOMEPAGE_DEFAULT_BASE, "dark");
assert.equal(homepageThemeScript(), buildThemeScript({ defaultBase: HOMEPAGE_DEFAULT_BASE }));
assert.ok(!/[^\x00-\x7f]/.test(homepageThemeScript()), "ASCII, for the byte-for-byte rewrite");
// It must not be able to end its own element.
assert.throws(() => rewriteHistoryPage("", "a"));
// The homepage's layout passes the same constant to ThemeScript.
const layout = readFileSync(path.join(HERE, "..", "..", "homepage", "app", "layout.tsx"), "utf8");
assert.match(layout, / /);
});
// ── the stylesheet ──────────────────────────────────────────────────────────
test("style.css: the homepage's two grounds from tokens.css, by prefers-color-scheme and by data-base", () => {
const css = historyStylesheet(TOKENS);
const { light, dark } = tokenBlocks(TOKENS);
const block = (selector: RegExp) => {
const m = selector.exec(css);
assert.ok(m, String(selector));
return m[1];
};
const lightBlock = block(/:root,\nhtml\[data-base="light"\] \{\n([\s\S]*?)\n\}/);
const mediaBlock = block(/@media \(prefers-color-scheme: dark\) \{\n {2}:root:not\(\[data-base\]\) \{\n([\s\S]*?)\n {2}\}/);
const darkBlock = block(/\nhtml\[data-base="dark"\] \{\n([\s\S]*?)\n\}/);
for (const name of [...HISTORY_TOKENS, "color-scheme", "--swatch-signal"]) {
assert.ok(lightBlock.includes(` ${name}: ${light.get(name)};`), `light ${name}`);
assert.ok(darkBlock.includes(` ${name}: ${dark.get(name)};`), `dark ${name}`);
assert.ok(mediaBlock.includes(` ${name}: ${dark.get(name)};`), `no-JS dark ${name}`);
}
// Only what the rules read (and what that reaches through var()).
assert.ok(!lightBlock.includes("--chart-1"));
// Diff lines: insertions in --success, deletions in --destructive.
assert.match(css, /pre a\.i \{ color: var\(--success\); \}/);
assert.match(css, /pre a\.d \{ color: var\(--destructive\); \}/);
assert.match(css, /p\.archilyzer-source a \{/);
});
test("style.css follows the tokens: a changed value is the new value; a missing block or token is a HistoryProblem", () => {
const moved = TOKENS.replace(/(html\[data-base="dark"\] \{[\s\S]*?--background: )#[0-9a-f]+;/, "$1#010203;");
assert.notEqual(moved, TOKENS);
assert.match(historyStylesheet(moved), /html\[data-base="dark"\] \{[\s\S]*?--background: #010203;/);
assert.throws(() => historyStylesheet(":root { --background: #fff; }"), HistoryProblem);
const noInfo = TOKENS.replace(/--info: #[0-9a-f]+;/g, "");
assert.throws(() => historyStylesheet(noInfo), (e) => e instanceof HistoryProblem && /no --info/.test(e.message));
});
// ── the binary ──────────────────────────────────────────────────────────────
function exe(file: string, body = "#!/bin/sh\nexit 0\n"): string {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, body);
chmodSync(file, 0o755);
return file;
}
test("resolveStagit: STAGIT_BIN as a path, else stagit on PATH, else ~/.local/bin/stagit, else null", async () => {
const home = dir("home");
const bin = dir("bin");
const empty = dir("empty");
assert.equal(resolveStagit("stagit", { PATH: empty }, home), null);
const local = exe(path.join(home, ".local", "bin", "stagit"));
assert.equal(resolveStagit("stagit", { PATH: empty }, home), local);
const onPathBin = exe(path.join(bin, "stagit"));
assert.equal(resolveStagit("stagit", { PATH: `${empty}:${bin}` }, home), onPathBin, "PATH first");
const given = exe(path.join(dir("given"), "my-stagit"));
assert.equal(resolveStagit(given, { PATH: bin }, home), given);
assert.equal(resolveStagit(path.join(empty, "nope"), { PATH: bin }, home), null, "a path that is not there is nothing, not a PATH lookup");
writeFileSync(path.join(empty, "not-exec"), "x");
assert.equal(resolveStagit(path.join(empty, "not-exec"), { PATH: bin }, home), null);
assert.equal(await stagitIdentity(null), "absent");
const a = await stagitIdentity(given);
assert.match(a, /^stagit \(sha256 [0-9a-f]{12}\)$/);
exe(given, "#!/bin/sh\nexit 1\n");
assert.notEqual(await stagitIdentity(given), a, "another binary, another identity");
assert.ok(!a.includes(given), "never the path");
});
// ── the render, over a fake stagit ──────────────────────────────────────────
function gitIn(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim();
}
// A bare repository named archilyzer.git with `count` commits on main.
function bareRepo(count: number): { gitDir: string; work: string } {
const work = dir("work");
gitIn(work, "init", "-q", "-b", "main");
gitIn(work, "config", "user.name", "history test");
gitIn(work, "config", "user.email", "history@example.invalid");
gitIn(work, "config", "commit.gpgsign", "false");
for (let i = 0; i < count; i++) addCommit(work, i);
const gitDir = path.join(dir("bare"), "archilyzer.git");
gitIn(TMP, "clone", "-q", "--bare", work, gitDir);
return { gitDir, work };
}
function addCommit(work: string, i: number): void {
writeFileSync(path.join(work, "README.md"), `hello ${i}\n`);
mkdirSync(path.join(work, "app", "[slug]"), { recursive: true });
writeFileSync(path.join(work, "app", "[slug]", "page.tsx"), `export default ${i};\n`);
gitIn(work, "add", "-A");
gitIn(work, "commit", "-q", "-m", `commit ${i}`);
}
// The fake (__fixtures__/fakeStagit.ts) writes what stagit writes, with -c
// and -l as stagit.c has them; every call is logged to `log`.
function fakeStagit(log: string, o: { exit?: number; extra?: string; pad?: number } = {}): string {
return writeFakeStagit(path.join(dir("fake"), "stagit"), log, o);
}
const run: HistoryRun = async (command, args, o) => {
const p = spawnSync(command, args, { cwd: o.cwd, encoding: "utf8" });
return { code: p.status ?? 1, out: `${p.stdout}${p.stderr}`.split("\n") };
};
function renderOpts(gitDir: string, stagit: string, extra: Partial = {}): RenderHistoryOpts & { logs: string[] } {
const logs: string[] = [];
const head = gitIn(TMP, "--git-dir", gitDir, "rev-parse", "main");
const commits = Number(gitIn(TMP, "--git-dir", gitDir, "rev-list", "--count", "main"));
return {
stagit,
gitDir,
head,
commits,
maxCommits: SOURCE_HISTORY_MAX_COMMITS,
dest: path.join(dir("stage"), "source", "git"),
scratch: dir("scratch"),
cacheDir: null,
cacheKey: "k1",
fresh: false,
description: "Archilyzer",
cloneUrl: "https://archilyzer.pages.dev/source/archilyzer.git",
baseUrl: "https://archilyzer.pages.dev/source/git/",
stylesheet: "/* css */\n",
themeScript: "t()",
run,
onLog: (l) => logs.push(l),
logs,
...extra,
};
}
const calls = (log: string) => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []);
test("render: the allowlist of stagit's output, each page through the post-pass, the feeds as they are, style.css; never file/", async () => {
const { gitDir } = bareRepo(3);
const log = path.join(dir("log"), "calls");
const o = renderOpts(gitDir, fakeStagit(log));
const r = await renderHistory(o);
assert.deepEqual(calls(log), [`cache= limit= base=${o.baseUrl} repo=archilyzer.git`]);
assert.equal(r.files, 3 + 5 + 1);
assert.equal(r.rendered, 3);
assert.equal(r.shown, 3);
assert.equal(r.cached, false);
assert.deepEqual(readdirSync(o.dest).sort(), ["atom.xml", "commit", "files.html", "log.html", "refs.html", "style.css", "tags.xml"]);
assert.equal(readdirSync(path.join(o.dest, "commit")).length, 3);
const logHtml = readFileSync(path.join(o.dest, "log.html"), "utf8");
// stagit's header reads the description and the clone URL from the repo.
assert.match(logHtml, /Archilyzer<\/span> https:\/\/archilyzer\.pages\.dev\/source\/archilyzer\.git /);
assert.ok(logHtml.includes(`\n${HISTORY_BACK_LINK}\n`));
assert.ok(logHtml.includes("\n"));
assert.match(logHtml, /href="\.\.\/tree\/README\.md"/);
assert.match(logHtml, new RegExp(`src="${SITE_ICON_HREF}"`));
assert.match(readFileSync(path.join(o.dest, "files.html"), "utf8"), /href="\.\.\/tree\/app\/%5Bslug%5D\/page\.tsx"/);
// The feeds are not pages: not one byte changes.
assert.equal(readFileSync(path.join(o.dest, "atom.xml"), "utf8"), `${o.baseUrl} file/README.md.html \n`);
assert.equal(readFileSync(path.join(o.dest, "style.css"), "utf8"), "/* css */\n");
// No cache: the render ran in the publish's own scratch.
assert.ok(existsSync(path.join(o.scratch, "history", "log.html")));
assert.ok(!existsSync(path.join(o.scratch, "history", "file")), "the per-file pages are dropped where they were rendered");
});
test("render: the page bytes are kept (a diff of a file that is not UTF-8), only the ASCII injections added", async () => {
const work = dir("bytes");
mkdirSync(path.join(work, "commit"));
const odd = Buffer.concat([Buffer.from("\n\n\n\n"), Buffer.from([0xff, 0xfe, 0x80]), Buffer.from("\n\n\n")]);
writeFileSync(path.join(work, "log.html"), odd);
for (const f of ["files.html", "refs.html"]) writeFileSync(path.join(work, f), "\n\n\n\n\n\n");
for (const f of ["atom.xml", "tags.xml"]) writeFileSync(path.join(work, f), Buffer.from([0x3c, 0xff, 0x3e]));
const dest = path.join(dir("dest"), "git");
await stageHistory(work, dest, { themeScript: "t()", stylesheet: "", commits: [] });
const out = readFileSync(path.join(dest, "log.html"));
assert.ok(out.includes(Buffer.from([0xff, 0xfe, 0x80])), "the odd bytes survive");
assert.ok(out.includes(Buffer.from(HISTORY_BACK_LINK)));
assert.deepEqual(readFileSync(path.join(dest, "atom.xml")), Buffer.from([0x3c, 0xff, 0x3e]));
await assert.rejects(stageHistory(work, dest, { themeScript: "é", stylesheet: "", commits: [] }), /ASCII/);
// A commit the log lists must have its page.
await assert.rejects(
stageHistory(work, dest, { themeScript: "t()", stylesheet: "", commits: ["a".repeat(40)] }),
(e) => e instanceof HistoryProblem && /the log lists aaaaaaaaaaaa, whose page is not there/.test(e.message),
);
});
test("render: a failing stagit, or one that leaves the wrong pages, is a HistoryProblem", async () => {
const { gitDir } = bareRepo(2);
const log = path.join(dir("log"), "calls");
await assert.rejects(
renderHistory(renderOpts(gitDir, fakeStagit(log, { exit: 3 }))),
(e) => e instanceof HistoryProblem && /stagit exited 3: stagit: something broke/.test(e.message),
);
await assert.rejects(
renderHistory(renderOpts(gitDir, fakeStagit(log), { commits: 5 })),
(e) => e instanceof HistoryProblem && /stagit's log lists 2 commits, not 5 \(5 in all, at most 10000\)/.test(e.message),
);
});
test("loggedCommits: the commits the log links, in order, once each", () => {
const a = "a".repeat(40);
const b = "b".repeat(40);
const log = `x \ny \nagain says href="commit/${"c".repeat(40)}.html"`;
assert.deepEqual(loggedCommits(log), [a, b]);
});
test("the cap: past it, -l keeps the newest in the log, and only their pages are published", async () => {
const { gitDir, work } = bareRepo(5);
const newest = gitIn(TMP, "--git-dir", gitDir, "rev-list", "--max-count=3", "main").split("\n");
const log = path.join(dir("log"), "calls");
const stagit = fakeStagit(log);
// No cache: `-l 3`, no `-c`.
let o = renderOpts(gitDir, stagit, { maxCommits: 3 });
let r = await renderHistory(o);
assert.equal(calls(log).at(-1), `cache= limit=3 base=${o.baseUrl} repo=archilyzer.git`);
assert.equal(r.shown, 3);
assert.equal(r.files, 3 + 5 + 1);
assert.deepEqual(readdirSync(path.join(o.dest, "commit")).sort(), newest.map((c) => `${c}.html`).sort());
assert.equal(readdirSync(path.join(o.scratch, "history", "commit")).length, 5, "stagit wrote all five; three are published");
assert.match(readFileSync(path.join(o.dest, "log.html"), "utf8"), /2 more commits remaining, fetch the repository/);
// With the cache: still `-l 3` (stagit refuses -c with -l). A new commit:
// its page is the one rendered, and the oldest of the three drops out.
const cacheDir = path.join(dir("cache-home"), "archilyzer", "source-history");
o = renderOpts(gitDir, stagit, { maxCommits: 3, cacheDir });
r = await renderHistory(o);
assert.equal(calls(log).at(-1), `cache= limit=3 base=${o.baseUrl} repo=archilyzer.git`);
assert.equal(r.rendered, 5);
addCommit(work, 9);
gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main");
const now = gitIn(TMP, "--git-dir", gitDir, "rev-list", "--max-count=3", "main").split("\n");
o = renderOpts(gitDir, stagit, { maxCommits: 3, cacheDir });
r = await renderHistory(o);
assert.equal(r.cached, true);
assert.equal(r.rendered, 1);
assert.equal(r.shown, 3);
assert.deepEqual(readdirSync(path.join(o.dest, "commit")).sort(), now.map((c) => `${c}.html`).sort());
assert.match(readFileSync(path.join(o.dest, "log.html"), "utf8"), /3 more commits remaining/);
});
test("the cache: -c in the cache dir, incremental the next time, the key and the log's ancestry checked, busy or cut-off locks handled", async () => {
const { gitDir, work } = bareRepo(2);
const log = path.join(dir("log"), "calls");
const stagit = fakeStagit(log);
const cacheDir = path.join(dir("cache-home"), "archilyzer", "source-history");
const cacheFile = path.join(cacheDir, "stagit.cache");
const cacheArgs = (o: RenderHistoryOpts) => `cache=${cacheFile} limit= base=${o.baseUrl} repo=archilyzer.git`;
// First: everything rendered, the cache made (recursively) and kept (key,
// cache file, output), unlocked; no per-file pages, no stagit leftovers.
let o = renderOpts(gitDir, stagit, { cacheDir });
let r = await renderHistory(o);
assert.equal(r.cached, false);
assert.equal(r.rendered, 2);
assert.equal(calls(log).at(-1), cacheArgs(o));
assert.deepEqual(readdirSync(cacheDir).sort(), ["key.json", "out", "stagit.cache"]);
assert.ok(!existsSync(path.join(cacheDir, "out", "file")));
assert.ok(!existsSync(path.join(o.dest, "cache.XXXXleftover")), "only the allowlist is published");
// A new commit: the cache is used, and only the new page is rendered.
addCommit(work, 2);
gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main");
o = renderOpts(gitDir, stagit, { cacheDir });
r = await renderHistory(o);
assert.equal(r.cached, true);
assert.equal(r.rendered, 1);
assert.equal(r.files, 3 + 5 + 1);
assert.equal(loggedCommits(readFileSync(path.join(o.dest, "log.html"), "utf8")).length, 3, "the new line and the cached ones");
// Another key (other rules, another stagit…): every page rendered again.
const junk = path.join(cacheDir, "out", "commit", `${"f".repeat(40)}.html`);
writeFileSync(junk, "junk");
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" });
r = await renderHistory(o);
assert.equal(r.cached, false);
assert.equal(r.rendered, 3);
assert.ok(!existsSync(junk));
// Log lines ending at a commit that is not an ancestor of the head (a
// rewritten main): the cache file goes, the pages stay, the log is whole.
writeFileSync(cacheFile, `${"e".repeat(40)}\nx \n`);
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" });
r = await renderHistory(o);
assert.equal(r.cached, true);
assert.equal(r.rendered, 0);
assert.equal(loggedCommits(readFileSync(path.join(o.dest, "log.html"), "utf8")).length, 3);
// A page of a commit history does not have is never published.
writeFileSync(junk, "junk");
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" });
r = await renderHistory(o);
assert.equal(r.files, 3 + 5 + 1);
assert.ok(!existsSync(path.join(o.dest, "commit", `${"f".repeat(40)}.html`)));
// A listed commit's page gone from the cache: noticed, and every page is
// rendered again.
const head = gitIn(TMP, "--git-dir", gitDir, "rev-parse", "main");
const oldest = gitIn(TMP, "--git-dir", gitDir, "rev-list", "main").split("\n").at(-1)!;
assert.notEqual(oldest, head);
rmSync(path.join(cacheDir, "out", "commit", `${oldest}.html`));
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" });
r = await renderHistory(o);
assert.equal(r.cached, false);
assert.match(o.logs.join("\n"), new RegExp(`the cached render does not cover this head \\(the log lists ${oldest.slice(0, 12)}, whose page is not there\\) — stagit's -c stops at the last head it rendered, and a merge of older commits falls behind it; rendering every page again`));
assert.ok(existsSync(path.join(o.dest, "commit", `${oldest}.html`)));
// `fresh` (--force) renders every page, whatever the cache holds.
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2", fresh: true });
r = await renderHistory(o);
assert.equal(r.cached, false);
assert.equal(r.rendered, 3);
// Busy: another live publish holds it (this process's pid stands in).
writeFileSync(path.join(cacheDir, "lock"), `${process.pid}\n`);
o = renderOpts(gitDir, stagit, { cacheDir, cacheKey: "k2" });
r = await renderHistory(o);
assert.equal(r.cached, false);
assert.equal(calls(log).at(-1), `cache= limit= base=${o.baseUrl} repo=archilyzer.git`, "rendered without -c");
assert.match(o.logs.join("\n"), /the render cache is in use by another publish; rendering without it/);
assert.ok(existsSync(path.join(cacheDir, "lock")), "the other holder's lock is left alone");
// Cut off: the holder is gone, so nothing in the cache is trusted.
const dead = spawnSync("sh", ["-c", "echo $$"], { encoding: "utf8" }).stdout.trim();
writeFileSync(path.join(cacheDir, "lock"), `${dead}\n`);
writeFileSync(junk, "junk");
assert.equal(await holdHistoryCache(cacheDir), true);
assert.ok(!existsSync(junk), "emptied");
assert.ok(!existsSync(path.join(cacheDir, "key.json")));
rmSync(path.join(cacheDir, "lock"));
// A render that fails empties the cache and releases it.
o = renderOpts(gitDir, fakeStagit(log, { exit: 2 }), { cacheDir, cacheKey: "k2" });
await assert.rejects(renderHistory(o), HistoryProblem);
assert.deepEqual(readdirSync(cacheDir), []);
});
test("a stale lock: its pid not running, or over an hour old whoever runs its pid now, is replaced with one line", async () => {
const cacheDir = path.join(dir("stale"), "archilyzer", "source-history");
mkdirSync(path.join(cacheDir, "out"), { recursive: true });
const lock = path.join(cacheDir, "lock");
const logs: string[] = [];
const onLog = (l: string) => {
logs.push(l);
};
// Live and fresh: busy.
writeFileSync(lock, `${process.pid}\n`);
assert.equal(await holdHistoryCache(cacheDir, onLog), false);
assert.deepEqual(logs, []);
// Live, but two hours old: stale.
const old = new Date(Date.now() - 2 * HISTORY_LOCK_STALE_MS);
utimesSync(lock, old, old);
assert.equal(await holdHistoryCache(cacheDir, onLog), true);
assert.match(logs.join("\n"), new RegExp(`a stale lock on the render cache \\(pid ${process.pid}, 120 minutes old\\) was replaced; the cache is rendered afresh`));
assert.ok(!existsSync(path.join(cacheDir, "out")), "emptied");
assert.equal(readFileSync(lock, "utf8"), `${process.pid}\n`, "and held");
rmSync(lock);
// Not running: stale, whatever its age.
const dead = spawnSync("sh", ["-c", "echo $$"], { encoding: "utf8" }).stdout.trim();
writeFileSync(lock, `${dead}\n`);
logs.length = 0;
assert.equal(await holdHistoryCache(cacheDir, onLog), true);
assert.match(logs.join("\n"), new RegExp(`\\(pid ${dead}, not running\\) was replaced`));
});
test("a render cache that cannot be written (a read-only dir, or one that cannot be made) is one line and a render without it", async () => {
const { gitDir } = bareRepo(2);
const log = path.join(dir("log"), "calls");
const stagit = fakeStagit(log);
const root = dir("ro");
const readOnly = path.join(root, "cache");
mkdirSync(readOnly);
chmodSync(readOnly, 0o555);
const parent = path.join(root, "parent");
mkdirSync(parent);
chmodSync(parent, 0o555);
try {
for (const cacheDir of [readOnly, path.join(parent, "archilyzer", "source-history")]) {
const o = renderOpts(gitDir, stagit, { cacheDir });
const r = await renderHistory(o);
assert.equal(r.cached, false);
assert.equal(r.shown, 2);
assert.equal(calls(log).at(-1), `cache= limit= base=${o.baseUrl} repo=archilyzer.git`, "rendered without -c");
assert.match(o.logs.join("\n"), /the render cache .* is unusable \(EACCES\); rendering without it/);
assert.equal(readdirSync(path.join(o.dest, "commit")).length, 2);
}
} finally {
chmodSync(readOnly, 0o755);
chmodSync(parent, 0o755);
}
});
test("a --no-ff merge of commits older than the cached head: stagit's -c misses them, the count notices, and every page is rendered again", async () => {
const { gitDir, work } = bareRepo(2);
const log = path.join(dir("log"), "calls");
const stagit = fakeStagit(log);
const cacheDir = path.join(dir("merge-cache"), "archilyzer", "source-history");
// A branch whose commits are dated before the head the cache will name.
const base = gitIn(work, "rev-parse", "HEAD~1");
gitIn(work, "checkout", "-q", "-b", "side", base);
for (const i of [7, 8]) {
writeFileSync(path.join(work, `side-${i}.txt`), `${i}\n`);
gitIn(work, "add", "-A");
execFileSync("git", ["commit", "-q", "-m", `side ${i}`], {
cwd: work,
stdio: "pipe",
env: { ...process.env, GIT_COMMITTER_DATE: `2001-01-0${i - 6}T00:00:00Z`, GIT_AUTHOR_DATE: `2001-01-0${i - 6}T00:00:00Z` },
});
}
gitIn(work, "checkout", "-q", "main");
let o = renderOpts(gitDir, stagit, { cacheDir });
await renderHistory(o);
gitIn(work, "merge", "-q", "--no-ff", "-m", "merge side", "side");
gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main");
o = renderOpts(gitDir, stagit, { cacheDir });
const r = await renderHistory(o);
assert.equal(r.shown, 5);
assert.equal(r.cached, false);
assert.match(o.logs.join("\n"), /the cached render does not cover this head \(stagit's log lists 3 commits, not 5 .*\) — stagit's -c stops at the last head it rendered, and a merge of older commits falls behind it; rendering every page again/);
assert.equal(readdirSync(path.join(o.dest, "commit")).length, 5);
});
// ── the real stagit ─────────────────────────────────────────────────────────
test("the real stagit: a page per commit, every link into file/ now into the tree, every relative link resolves", async (t) => {
const stagit = resolveStagit(process.env.STAGIT_BIN ?? "stagit", process.env);
if (!stagit) return t.skip("stagit is not installed (STAGIT_BIN, PATH, ~/.local/bin)");
const { gitDir, work } = bareRepo(1);
// A file that main no longer has: its diffs are published, its links not.
writeFileSync(path.join(work, "gone.txt"), "soon gone\n");
gitIn(work, "add", "-A");
gitIn(work, "commit", "-q", "-m", "add gone.txt");
gitIn(work, "rm", "-q", "gone.txt");
gitIn(work, "commit", "-q", "-m", "remove gone.txt");
gitIn(TMP, "--git-dir", gitDir, "fetch", "-q", work, "main:main");
const treeFiles = new Set(["README.md", "app/[slug]/page.tsx"]);
const o = renderOpts(gitDir, stagit, { cacheDir: path.join(dir("real-root"), "archilyzer", "source-history"), treeFiles });
const r = await renderHistory(o);
assert.equal(r.rendered, 3);
assert.equal(readdirSync(path.join(o.dest, "commit")).length, 3);
assert.ok(!existsSync(path.join(o.dest, "file")));
for (const page of ["log.html", "files.html", "refs.html", ...readdirSync(path.join(o.dest, "commit")).map((f) => `commit/${f}`)]) {
const html = readFileSync(path.join(o.dest, page), "utf8");
assert.ok(html.includes(`\n${HISTORY_BACK_LINK}\n`), page);
assert.ok(html.includes("\n"), page);
assert.ok(!/href="(?:\.\.\/)*file\//.test(html), `${page} links into file/`);
assert.ok(!/(?:logo|favicon)\.png"/.test(html), `${page} names stagit's images`);
for (const m of html.matchAll(/(?:href|src)="([^"#:]+)"/g)) {
const target = m[1];
if (target.startsWith("/")) continue; // the site's own: /source/, the icon
const resolved = path.posix
.normalize(path.posix.join(path.posix.dirname(`source/git/${page}`), target))
.replace(/\/$/, "");
if (resolved.startsWith("source/tree/")) {
// Into the raw tree: only a file main has (`gone.txt` is text).
assert.ok(treeFiles.has(decodeURIComponent(resolved.slice("source/tree/".length))), `${page}: ${target}`);
} else if (resolved.startsWith("source/git/")) {
assert.ok(existsSync(path.join(path.dirname(path.dirname(o.dest)), resolved)), `${page}: ${target} → ${resolved}`);
} else {
assert.equal(resolved, "source", `${page}: ${target}`); // the logo's link, to /source/
}
}
}
const pages = readdirSync(path.join(o.dest, "commit")).map((f) => readFileSync(path.join(o.dest, "commit", f), "utf8"));
assert.ok(pages.some((h) => /gone\.txt<\/a>/.test(h)), "gone.txt's diff header is text, its #h0 target kept");
const logHtml = readFileSync(path.join(o.dest, "log.html"), "utf8");
assert.match(logHtml, /Log - archilyzer - Archilyzer<\/title>/);
assert.match(logHtml, /git clone /);
assert.match(readFileSync(path.join(o.dest, "atom.xml"), "utf8"), /href="https:\/\/archilyzer\.pages\.dev\/source\/git\/commit\/[0-9a-f]{40}\.html"/);
// The cap, by stagit itself: -l keeps the NEWEST in the log (and says how
// many more), writes a page for every commit, and only the listed are
// published.
const capped = renderOpts(gitDir, stagit, { maxCommits: 2, treeFiles });
const rc = await renderHistory(capped);
const newest = gitIn(TMP, "--git-dir", gitDir, "rev-list", "--max-count=2", "main").split("\n");
assert.equal(rc.shown, 2);
assert.deepEqual(loggedCommits(readFileSync(path.join(capped.dest, "log.html"), "utf8")), newest);
assert.deepEqual(readdirSync(path.join(capped.dest, "commit")).sort(), newest.map((c) => `${c}.html`).sort());
assert.equal(readdirSync(path.join(capped.scratch, "history", "commit")).length, 3);
assert.match(readFileSync(path.join(capped.dest, "log.html"), "utf8"), /1 more commits remaining, fetch the repository/);
// The oldest published page's parent has no page: its link is text.
const oldestShown = readFileSync(path.join(capped.dest, "commit", `${newest[1]}.html`), "utf8");
const parent = gitIn(TMP, "--git-dir", gitDir, "rev-parse", `${newest[1]}^`);
assert.ok(oldestShown.includes(`parent ${parent} `), "the parent past the cap is text");
assert.ok(!oldestShown.includes(`commit/${parent}.html`));
});