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

archilyzer

Log | Files | Refs | README | LICENSE

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 <a href="https:\/\/archilyzer\.pages\.dev\/source\/archilyzer\.git">/); 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(`<b>parent</b> <a>${parent}</a>`), "the parent past the cap is text"); assert.ok(!oldestShown.includes(`commit/${parent}.html`)); });