diff --git a/README.md b/README.md index d2e55a4..bca95e3 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,17 @@ red PR job against. parentheses, escaped string literals, and that every unsupported context, function, operator or malformed expression returns `undefined` so the caller treats the job as gated rather than guessing. Zero dependencies. +- `node --test scripts/html-pages.test.mjs` — direct unit test of `scripts/html-pages.mjs`, + the page-discovery helper every static page gate imports to decide which pages it + inspects: `htmlFiles()` recurses, sorts, keeps only `.html`, and skips exactly the + `SKIP_DIRS` tooling directories (at any depth, directories only); `SKIP_DIRS` never + names a content directory; `isRedirectPage()` matches the generator's + `http-equiv="refresh"` in every quote/case/attribute-order spelling and nothing else; + `lineOf()` is 1-based. Then pins the live tree: `index.html`, `404.html`, + `stories/index.html` and every `make-redirects.sh` MAP page are discovered, nothing + under a skipped directory is, and the redirect pages are exactly the MAP paths — so + an over-broad `SKIP_DIRS` or a broken walker cannot make the gates pass vacuously. + Zero dependencies. - `node --test scripts/workflow-docs.test.mjs` — static gate keeping the prose about `links.yml` in step with it: in this README and `runbooks/*.md`, the workflow name must match the workflow's `name:`; every job the docs name must exist under `jobs:`; jobs the diff --git a/scripts/html-pages.test.mjs b/scripts/html-pages.test.mjs new file mode 100644 index 0000000..f2d4b3b --- /dev/null +++ b/scripts/html-pages.test.mjs @@ -0,0 +1,169 @@ +// Direct unit test for scripts/html-pages.mjs, the page-discovery helper every static +// gate (page-meta, page-csp, page-images, page-markup, page-structure, page-404, +// page-scripts, jsonld-contract, redirect-targets, ...) imports to decide WHICH pages +// it inspects. None of those gates checks the helper itself: a SKIP_DIRS entry that +// swallowed a content directory, a walker that stopped recursing, or an +// isRedirectPage() that stopped matching the generator's output would not fail a +// single gate — each would just run over fewer pages and pass vacuously. This file +// pins the walker against a throw-away tree and then pins the live tree so the +// helper can never report a page set that omits what the site ships. +// Zero dependencies. Usage: node --test scripts/html-pages.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, sep } from "node:path"; +import { ROOT, SKIP_DIRS, htmlFiles, isRedirectPage, lineOf } from "./html-pages.mjs"; + +function tree(files) { + const dir = mkdtempSync(join(tmpdir(), "html-pages-")); + for (const [rel, body] of Object.entries(files)) { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, body); + } + return dir; +} + +const rel = (dir, files) => files.map((f) => relative(dir, f).split(sep).join("/")); + +// Paths of the shortcut redirects make-redirects.sh generates (its MAP keys). +// redirect-targets.test.mjs owns the full MAP contract; only the keys matter here. +function mapPaths() { + const script = readFileSync(join(ROOT, "make-redirects.sh"), "utf8"); + const block = /declare -A MAP=\(([\s\S]*?)^\)/m.exec(script); + assert.ok(block, "make-redirects.sh has no `declare -A MAP=( ... )` block"); + const paths = [...block[1].matchAll(/^\s*\[([^\]]+)\]=/gm)].map((m) => m[1]); + assert.ok(paths.length > 0, "MAP block has no entries"); + return paths; +} + +// --- htmlFiles ----------------------------------------------------------------- + +test("htmlFiles recurses into nested directories and returns a sorted list", () => { + const dir = tree({ + "z/index.html": "", + "a/b/c/deep.html": "", + "index.html": "", + "m/page.html": "", + }); + try { + assert.deepEqual(rel(dir, htmlFiles(dir)), ["a/b/c/deep.html", "index.html", "m/page.html", "z/index.html"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("htmlFiles ignores files that are not .html, including .htm and .html.bak", () => { + const dir = tree({ + "index.html": "", + "style.css": "", + "README.md": "", + "old.htm": "", + "index.html.bak": "", + "sitemap.xml": "", + "notes.HTML": "", + }); + try { + assert.deepEqual(rel(dir, htmlFiles(dir)), ["index.html"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("htmlFiles skips exactly the SKIP_DIRS names, at any depth, and only as directories", () => { + const files = { "index.html": "", "keep/index.html": "", "keep/scripts/nested-skip.html": "" }; + for (const name of SKIP_DIRS) files[`${name}/hidden.html`] = ""; + // A regular file whose name collides with a skip entry must still be a candidate + // (it is not .html so it is ignored for that reason only — see next assertion). + files["scripts.html"] = ""; + const dir = tree(files); + try { + assert.deepEqual(rel(dir, htmlFiles(dir)), ["index.html", "keep/index.html", "scripts.html"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("htmlFiles on an empty tree returns [] rather than throwing", () => { + const dir = tree({}); + try { + assert.deepEqual(htmlFiles(dir), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("SKIP_DIRS names only tooling directories, never a content directory", () => { + const expected = new Set([".git", ".github", "node_modules", "scripts", ".linkcheck-work"]); + assert.deepEqual(SKIP_DIRS, expected, "a new SKIP_DIRS entry hides pages from every gate — add it here deliberately"); + for (const name of SKIP_DIRS) { + assert.ok(name.startsWith(".") || name === "node_modules" || name === "scripts", `SKIP_DIRS entry ${JSON.stringify(name)} looks like a content directory`); + } +}); + +// --- isRedirectPage -------------------------------------------------------------- + +test("isRedirectPage matches the generator's meta refresh in every attribute spelling", () => { + const variants = [ + '', + "", + '', + '', + '', + '', + ]; + for (const v of variants) assert.equal(isRedirectPage(`
${v}`), true, v); +}); + +test("isRedirectPage does not match other http-equiv values or refresh mentioned elsewhere", () => { + const negatives = [ + '', + '', + '', + "Click refresh to reload.
", + 'refresh', + "", + ]; + for (const n of negatives) assert.equal(isRedirectPage(n), false, n || "(empty)"); +}); + +// --- lineOf -------------------------------------------------------------------- + +test("lineOf is 1-based and counts newlines before the index", () => { + const text = "first\nsecond\nthird"; + assert.equal(lineOf(text, 0), 1); + assert.equal(lineOf(text, 5), 1, "the newline itself still belongs to line 1"); + assert.equal(lineOf(text, 6), 2); + assert.equal(lineOf(text, text.indexOf("third")), 3); + assert.equal(lineOf(text, text.length), 3); + assert.equal(lineOf("", 0), 1); +}); + +// --- live tree ----------------------------------------------------------------- + +test("live tree: htmlFiles(ROOT) includes the canonical pages, 404.html and every make-redirects.sh MAP page", () => { + const pages = rel(ROOT, htmlFiles()); + assert.ok(pages.length > 0, "htmlFiles(ROOT) returned no pages — every gate would pass vacuously"); + for (const must of ["index.html", "404.html", "stories/index.html"]) { + assert.ok(pages.includes(must), `${must} missing from htmlFiles(ROOT)`); + } + for (const path of mapPaths()) { + assert.ok(pages.includes(`${path}/index.html`), `redirect page ${path}/index.html missing from htmlFiles(ROOT)`); + } +}); + +test("live tree: nothing under a SKIP_DIRS directory is reported, and nothing reported lives outside ROOT", () => { + for (const page of rel(ROOT, htmlFiles())) { + assert.ok(!page.startsWith("../"), `${page} escapes ROOT`); + const top = page.split("/")[0]; + assert.ok(!SKIP_DIRS.has(top), `${page} is under skipped directory ${top}`); + assert.ok(existsSync(join(ROOT, page)), `${page} does not exist`); + } +}); + +test("live tree: isRedirectPage agrees with the MAP — redirect pages are exactly the MAP paths", () => { + const mapPages = new Set(mapPaths().map((p) => `${p}/index.html`)); + const redirects = rel(ROOT, htmlFiles()).filter((p) => isRedirectPage(readFileSync(join(ROOT, p), "utf8"))); + assert.deepEqual(new Set(redirects), mapPages); +});