From d0f2268852e027dc4f1ad777b6d9f800129e035d Mon Sep 17 00:00:00 2001 From: "kubestellar-hive[bot]" Date: Fri, 18 Sep 2026 02:34:06 -0400 Subject: [PATCH] test: cover the package.json <-> scripts/ <-> workflows wiring contract Adds tests/workflow-scripts.test.mjs, asserting that every npm run target in a workflow or in another package.json script is defined, that every node scripts/... target and every linter config dotfile exists, that no scripts/*.mjs entry point is orphaned, and that a workflow running a validator also runs the unit suite. These references are plain strings naming files in other places, and no existing test reads package.json or any workflow, so a rename or deletion surfaces only when a contributor or CI runs the command. Closes #277 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: kubestellar-hive[bot] --- tests/workflow-scripts.test.mjs | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/workflow-scripts.test.mjs diff --git a/tests/workflow-scripts.test.mjs b/tests/workflow-scripts.test.mjs new file mode 100644 index 00000000..fd15c272 --- /dev/null +++ b/tests/workflow-scripts.test.mjs @@ -0,0 +1,145 @@ +// Guards the wiring contract between package.json `scripts`, the files under +// scripts/, the config files those scripts pass to their linters, and the +// `npm run` invocations inside .github/workflows/. Every reference here is a +// plain string in one file that has to name something real in another file, +// so nothing in the existing suite catches a rename or a deletion: the break +// only shows up when CI or a contributor actually runs the command. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const workflowDir = join(repoRoot, '.github', 'workflows'); + +const packageJson = JSON.parse( + readFileSync(join(repoRoot, 'package.json'), 'utf8'), +); +const scripts = packageJson.scripts ?? {}; + +const workflowFiles = readdirSync(workflowDir) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .sort(); + +// Collects every `run:` string across jobs.*.steps[] of one workflow. +function runCommands(workflowFile) { + const parsed = parse(readFileSync(join(workflowDir, workflowFile), 'utf8')); + const commands = []; + for (const job of Object.values(parsed?.jobs ?? {})) { + for (const step of job?.steps ?? []) { + if (typeof step?.run === 'string') commands.push(step.run); + } + } + return commands; +} + +// `npm run foo`, `npm run foo -- --bar`, `npm run -s foo`, `npm -s run foo`. +function npmRunTargets(command) { + const targets = []; + // A bare `npm run --loglevel=warn` lists scripts rather than running one, + // so the captured target must not itself look like a flag. + const pattern = /\bnpm\s+(?:-\S+\s+)*run\s+(?:-\S+\s+)*((?!-)[\w:*.-]+)/g; + for (const match of command.matchAll(pattern)) targets.push(match[1]); + return targets; +} + +test('every workflow parses as YAML and declares at least one job', () => { + assert.ok(workflowFiles.length > 0, 'no workflow files found'); + for (const file of workflowFiles) { + const parsed = parse(readFileSync(join(workflowDir, file), 'utf8')); + assert.ok( + Object.keys(parsed?.jobs ?? {}).length > 0, + `${file} declares no jobs`, + ); + } +}); + +test('every `npm run` in a workflow names a defined package.json script', () => { + const missing = []; + for (const file of workflowFiles) { + for (const command of runCommands(file)) { + for (const target of npmRunTargets(command)) { + if (!(target in scripts)) missing.push(`${file}: npm run ${target}`); + } + } + } + assert.deepEqual(missing, [], `undefined scripts invoked by workflows`); +}); + +test('every `npm run` inside a package.json script names a defined script', () => { + const missing = []; + for (const [name, body] of Object.entries(scripts)) { + for (const target of npmRunTargets(body)) { + // The `_list:*` helpers shell out to `npm run` with no argument to + // enumerate scripts, and `npm run seq -- $(...)` expands at runtime; + // a literal glob is not a script name. + if (target.includes('*')) continue; + if (!(target in scripts)) missing.push(`${name}: npm run ${target}`); + } + } + assert.deepEqual(missing, [], 'undefined scripts invoked by other scripts'); +}); + +test('every `node scripts/...` target in package.json exists on disk', () => { + const missing = []; + for (const [name, body] of Object.entries(scripts)) { + for (const match of body.matchAll(/\bnode\s+(scripts\/[\w./-]+)/g)) { + if (!existsSync(join(repoRoot, match[1]))) { + missing.push(`${name} -> ${match[1]}`); + } + } + } + assert.deepEqual(missing, [], 'package.json scripts point at missing files'); +}); + +test('every config file passed to a linter in package.json exists', () => { + // A missing dotfile here makes `npm run check` fail on a fresh clone while + // every unit test still passes, because no test runs the linters. + const missing = []; + for (const [name, body] of Object.entries(scripts)) { + for (const match of body.matchAll( + /(?:^|\s)(?:-c|--config)\s+(\.[\w.-]+)/g, + )) { + if (!existsSync(join(repoRoot, match[1]))) { + missing.push(`${name} -> ${match[1]}`); + } + } + } + assert.deepEqual(missing, [], 'linter config files referenced but absent'); +}); + +test('every scripts/*.mjs entry point is reachable from a package.json script', () => { + const referenced = new Set(); + for (const body of Object.values(scripts)) { + for (const match of body.matchAll(/\bnode\s+(scripts\/[\w./-]+)/g)) { + referenced.add(match[1]); + } + } + const orphans = readdirSync(join(repoRoot, 'scripts')) + .filter((name) => name.endsWith('.mjs')) + .map((name) => `scripts/${name}`) + .filter((path) => !referenced.has(path)); + assert.deepEqual( + orphans, + [], + 'scripts/ entry points no package.json script can run', + ); +}); + +test('the unit suite runs in every workflow that runs a validator', () => { + // A workflow that validates data but skips the unit suite can publish a + // regression the suite would have caught. + const offenders = []; + for (const file of workflowFiles) { + const targets = runCommands(file).flatMap(npmRunTargets); + const runsValidator = targets.some((t) => t.startsWith('validate:')); + if (runsValidator && !targets.includes('test:unit')) offenders.push(file); + } + assert.deepEqual( + offenders, + [], + 'workflows validating data without test:unit', + ); +});