diff --git a/tests/scripts/annotator_boundary.test.mjs b/tests/scripts/annotator_boundary.test.mjs index 57edcc98..c029efeb 100644 --- a/tests/scripts/annotator_boundary.test.mjs +++ b/tests/scripts/annotator_boundary.test.mjs @@ -15,6 +15,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = fileURLToPath(new URL("../../", import.meta.url)); const PACKAGE = fileURLToPath(new URL("../../frontend/annotator/", import.meta.url)); const TSC = path.join(PACKAGE, "node_modules", ".bin", "tsc"); @@ -192,9 +194,7 @@ test("the scan finds the line v1 shipped, and nothing that merely names the type }); test("no module builds a synthetic DOM event to talk to another module", () => { - const listed = spawnSync("git", ["ls-files", "-z", "frontend"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout.split("\0").filter((name) => SOURCE.test(name)); + const tracked = trackedFiles(["frontend"]).filter((name) => SOURCE.test(name)); assert.ok(tracked.length > 0, "the scan found no frontend sources, so it proves nothing"); const offenders = tracked.flatMap((file) => diff --git a/tests/scripts/cdn_vendor_boundary.test.mjs b/tests/scripts/cdn_vendor_boundary.test.mjs index 8bd93e1f..7563bb3d 100644 --- a/tests/scripts/cdn_vendor_boundary.test.mjs +++ b/tests/scripts/cdn_vendor_boundary.test.mjs @@ -7,17 +7,17 @@ // to npm and must work against any self-hosted mirror. Mirrors ui_core_boundary.test.mjs's // CDN_VENDOR_LITERALS rule, scoped to the two packages that rule does not cover. import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = fileURLToPath(new URL("../../", import.meta.url)); function shippedSourceUnder(dir) { - return execFileSync("git", ["ls-files", dir], { cwd: REPO, encoding: "utf8" }) - .split("\n") + return trackedFiles([dir]) .filter((file) => /\.[cm]?[jt]sx?$/.test(file)) .filter((file) => !/\.test\.[jt]sx?$/.test(file)) .map((file) => ({ path: file, text: readFileSync(path.join(REPO, file), "utf8") })); diff --git a/tests/scripts/checks_wiring.test.mjs b/tests/scripts/checks_wiring.test.mjs index 43374b98..149f46e6 100644 --- a/tests/scripts/checks_wiring.test.mjs +++ b/tests/scripts/checks_wiring.test.mjs @@ -26,12 +26,12 @@ // corpus, so this file demonstrates a violation without containing one. It reads // `git ls-files` (the index) so a merely staged file is gated before any commit lands. import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { operationCheckName, repoRoot, responsesOf } from "../../scripts/generate_client.mjs"; +import { trackedFiles } from "./tracked.mjs"; /** The definition site: its prose quotes `unwrap(` verbatim, and it never calls it. */ const DEFINITION = "frontend/ui-core/src/data/errors.ts"; @@ -89,8 +89,7 @@ export function wrongChecks(files, expected) { } const corpus = () => - execFileSync("git", ["ls-files", "frontend"], { cwd: repoRoot(), encoding: "utf8" }) - .split("\n") + trackedFiles(["frontend"], repoRoot()) .filter((line) => /\.[jt]sx?$/.test(line)) .map((file) => ({ path: file, text: readFileSync(path.join(repoRoot(), file), "utf8") })); diff --git a/tests/scripts/cooldown.test.mjs b/tests/scripts/cooldown.test.mjs index 8d840b54..0ae81fed 100644 --- a/tests/scripts/cooldown.test.mjs +++ b/tests/scripts/cooldown.test.mjs @@ -31,6 +31,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const read = (rel) => readFileSync(path.join(root, rel), "utf8"); @@ -192,10 +194,7 @@ test("pnpm is the only Node package manager invoked anywhere", () => { // file's `//` prose about `npx`. What survives is deliberately shape-based // rather than word-based: a manager named as the first argument of a call, or // opening a command string with arguments after it. - const listed = (...globs) => - execFileSync("git", ["ls-files", ...globs], { cwd: root, encoding: "utf8" }) - .split("\n") - .filter(Boolean); + const listed = (...globs) => trackedFiles(globs, root); const offenders = []; const flag = (file, i, line) => offenders.push(`${file}:${i + 1}: ${line.trim()}`); diff --git a/tests/scripts/design_system.test.mjs b/tests/scripts/design_system.test.mjs index b12a3135..d064464e 100644 --- a/tests/scripts/design_system.test.mjs +++ b/tests/scripts/design_system.test.mjs @@ -10,21 +10,20 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { competingStatusPaletteIn, statusPaletteIn } from "@robomous/ui-core/gates"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const SOURCE = /\.(?:ts|tsx|css)$/; const GENERATED = /^frontend\/ui-core\/src\/generated\//; /** Every tracked `frontend` source, minus the generated client. */ function frontendSources() { - const listed = spawnSync("git", ["ls-files", "-z", "frontend"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - return listed.stdout.split("\0").filter((name) => SOURCE.test(name) && !GENERATED.test(name)); + return trackedFiles(["frontend"]).filter((name) => SOURCE.test(name) && !GENERATED.test(name)); } const scan = (fn) => diff --git a/tests/scripts/design_tokens.test.mjs b/tests/scripts/design_tokens.test.mjs index 38d8b09d..2e2f2721 100644 --- a/tests/scripts/design_tokens.test.mjs +++ b/tests/scripts/design_tokens.test.mjs @@ -24,6 +24,8 @@ import { test } from "node:test"; import { brandUsagesIn, colouredClassesIn } from "@robomous/ui-core/gates"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const SOURCE = /\.(?:ts|tsx|css)$/; @@ -31,11 +33,9 @@ const SOURCE = /\.(?:ts|tsx|css)$/; const GENERATED = /^frontend\/ui-core\/src\/generated\//; test("no frontend source puts a colour inside a class name", () => { - const listed = spawnSync("git", ["ls-files", "-z", "frontend"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout - .split("\0") - .filter((name) => SOURCE.test(name) && !GENERATED.test(name)); + const tracked = trackedFiles(["frontend"]).filter( + (name) => SOURCE.test(name) && !GENERATED.test(name), + ); assert.ok(tracked.length > 0, "the scan found no frontend sources, so it proves nothing"); const offenders = tracked.flatMap((file) => @@ -63,11 +63,9 @@ const BRAND_SITES = [ ]; test("the brand colour paints identity only — the wordmark and its styleguide swatch", () => { - const listed = spawnSync("git", ["ls-files", "-z", "frontend"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout - .split("\0") - .filter((name) => SOURCE.test(name) && !GENERATED.test(name)); + const tracked = trackedFiles(["frontend"]).filter( + (name) => SOURCE.test(name) && !GENERATED.test(name), + ); assert.ok(tracked.length > 0, "the scan found no frontend sources, so it proves nothing"); const usages = tracked.flatMap((file) => @@ -106,9 +104,7 @@ test("the brand colour paints identity only — the wordmark and its styleguide const RETIRED_ICON_PACKAGE = ["@tabler", "icons-react"].join("/"); test("no package declares a second icon set, and no source imports one", () => { - const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout.split("\0").filter(Boolean); + const tracked = trackedFiles(); const manifests = tracked.filter((name) => /(?:^|\/)package\.json$/.test(name)); assert.ok(manifests.length > 0, "no manifests were read, so this proves nothing"); diff --git a/tests/scripts/docs_links.test.mjs b/tests/scripts/docs_links.test.mjs index 2fbe3bfa..f4e95a16 100644 --- a/tests/scripts/docs_links.test.mjs +++ b/tests/scripts/docs_links.test.mjs @@ -61,10 +61,11 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); /** The extensions this gate treats as Markdown. See the header for why `.mdx` is here. */ @@ -80,11 +81,7 @@ export const MARKDOWN = [".md", ".mdx"]; * the headings other documents point at. */ function markdownFiles() { - const listed = spawnSync("git", ["-C", REPO, "ls-files", ...MARKDOWN.map((e) => `*${e}`)], { - encoding: "utf8", - }); - assert.equal(listed.status, 0, "git ls-files must succeed"); - return listed.stdout.split("\n").filter(Boolean); + return trackedFiles(MARKDOWN.map((e) => `*${e}`), REPO); } /** Is this a document whose headings this gate can read? */ @@ -195,9 +192,7 @@ export function linksIn(source) { } test("every internal Markdown link resolves to a tracked file", () => { - const tracked = new Set( - spawnSync("git", ["-C", REPO, "ls-files"], { encoding: "utf8" }).stdout.split("\n"), - ); + const tracked = new Set(trackedFiles([], REPO)); const broken = []; for (const file of markdownFiles()) { diff --git a/tests/scripts/e2e_discipline.test.mjs b/tests/scripts/e2e_discipline.test.mjs index 87c05c51..60eb96e8 100644 --- a/tests/scripts/e2e_discipline.test.mjs +++ b/tests/scripts/e2e_discipline.test.mjs @@ -27,10 +27,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); // Both browser-driven suites. `bench/` is under the rule rather than being // exempted from it: a benchmark is the file most tempted to sleep — "give it a @@ -83,9 +84,7 @@ test("the scan finds v1's sync points, and nothing that merely mentions them", ( test("no end-to-end scenario waits on a clock", () => { const offenders = []; for (const suite of SUITES) { - const listed = spawnSync("git", ["ls-files", "-z", suite], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout.split("\0").filter((name) => SOURCE.test(name)); + const tracked = trackedFiles([suite]).filter((name) => SOURCE.test(name)); // Asserted per suite, not over the union: a renamed or emptied directory would // otherwise be covered by its neighbour and the scan would quietly prove less // than it says. diff --git a/tests/scripts/form_buttons.test.mjs b/tests/scripts/form_buttons.test.mjs index a40d3782..9774f812 100644 --- a/tests/scripts/form_buttons.test.mjs +++ b/tests/scripts/form_buttons.test.mjs @@ -38,10 +38,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const TSX = /\.tsx$/; @@ -319,9 +320,7 @@ const RENDERED_INSIDE_FORMS = { /** Every tracked `frontend/**\/*.tsx`, read from the index. */ function trackedTsx() { - const listed = spawnSync("git", ["ls-files", "-z", "frontend"], { cwd: REPO, encoding: "utf8" }); - assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); - const tracked = listed.stdout.split("\0").filter((name) => TSX.test(name)); + const tracked = trackedFiles(["frontend"]).filter((name) => TSX.test(name)); assert.ok(tracked.length > 0, "the scan found no frontend components, so it proves nothing"); return tracked; } diff --git a/tests/scripts/media_core_boundary.test.mjs b/tests/scripts/media_core_boundary.test.mjs index 239c4bae..70192db7 100644 --- a/tests/scripts/media_core_boundary.test.mjs +++ b/tests/scripts/media_core_boundary.test.mjs @@ -7,17 +7,17 @@ // the way `ui_core_boundary.test.mjs` backs ui-core. Type-level mentions of `Blob` and // `File` (`BlobLike`, `FileLike`) are legitimate and pass: only value uses are caught. import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = fileURLToPath(new URL("../../", import.meta.url)); function coreSource() { - return execFileSync("git", ["ls-files", "frontend/media/src"], { cwd: REPO, encoding: "utf8" }) - .split("\n") + return trackedFiles(["frontend/media/src"]) .filter((file) => /\.[cm]?[jt]sx?$/.test(file)) .filter((file) => !/\.test\.[jt]sx?$/.test(file)) .filter((file) => !file.startsWith("frontend/media/src/mediabunny/")) diff --git a/tests/scripts/tracked.mjs b/tests/scripts/tracked.mjs new file mode 100644 index 00000000..b7d2dd7d --- /dev/null +++ b/tests/scripts/tracked.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = fileURLToPath(new URL("../../", import.meta.url)); + +export function trackedFiles(pathspecs = [], root = REPO) { + const listed = spawnSync("git", ["ls-files", "-z", "--", ...pathspecs], { cwd: root, encoding: "utf8" }); + assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`); + return listed.stdout + .split("\0") + .filter(Boolean) + // The index can name a path that is not on disk, e.g. during an unstaged rename or delete. + .filter((name) => existsSync(path.join(root, name))); +} diff --git a/tests/scripts/tracked.test.mjs b/tests/scripts/tracked.test.mjs new file mode 100644 index 00000000..fc7c1f99 --- /dev/null +++ b/tests/scripts/tracked.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { trackedFiles } from "./tracked.mjs"; + +function scratchRepo(files) { + const root = mkdtempSync(path.join(tmpdir(), "visionset-tracked-")); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }); + git("init", "-q"); + git("config", "user.name", "Scratch"); + git("config", "user.email", "scratch@example.invalid"); + git("config", "commit.gpgsign", "false"); + for (const file of files) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), `${file}\n`); + } + git("add", "."); + git("commit", "-q", "-m", "scratch"); + return root; +} + +test("a path the index names but the disk lacks is not listed", () => { + const root = scratchRepo(["a.txt", "b.txt"]); + try { + rmSync(path.join(root, "b.txt")); + assert.deepEqual(trackedFiles([], root), ["a.txt"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("pathspecs narrow the listing and the missing-path filter still applies", () => { + const root = scratchRepo(["src/a.ts", "src/b.ts", "docs/c.md"]); + try { + rmSync(path.join(root, "src/b.ts")); + assert.deepEqual(trackedFiles(["src"], root), ["src/a.ts"]); + assert.deepEqual(trackedFiles(["*.md"], root), ["docs/c.md"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/scripts/ui_core_boundary.test.mjs b/tests/scripts/ui_core_boundary.test.mjs index 451d48fa..f2faf377 100644 --- a/tests/scripts/ui_core_boundary.test.mjs +++ b/tests/scripts/ui_core_boundary.test.mjs @@ -23,20 +23,20 @@ // proven only by its positive case has not been proven against the case it exists // to distinguish from. import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; +import { trackedFiles } from "./tracked.mjs"; + const REPO = fileURLToPath(new URL("../../", import.meta.url)); /** Shipped `ui-core` source: no tests, no harness. `.mts`/`.cts`/`.mjs`/`.cjs` count as * source too — `tsc` compiles them, and a filter that only knew `.ts`/`.tsx`/`.js`/`.jsx` * would silently stop covering a file the moment one appeared. */ function shippedSource() { - return execFileSync("git", ["ls-files", "frontend/ui-core/src"], { cwd: REPO, encoding: "utf8" }) - .split("\n") + return trackedFiles(["frontend/ui-core/src"]) .filter((file) => /\.[cm]?[jt]sx?$/.test(file)) .filter((file) => !/\.test\.[jt]sx?$/.test(file)) .filter((file) => !file.startsWith("frontend/ui-core/src/testing/"))