Files
msd-core/tests/release-tarball-smoke-workflow.test.cjs
Tom Boucher 33afb4f6eb chore(#452): add ESLint 9 flat-config harness with three custom AST rules (#460)
Install eslint@9 + typescript-eslint@8 + globals@16 + eslint-plugin-n@17 +
eslint-plugin-no-only-tests@3 + typescript as devDependencies.

eslint.config.mjs (flat config):
- Global ignores: node_modules, dist, .worktrees, .claude, coverage, the
  12 generated get-shit-done/bin/lib/*.cjs files
- Block for get-shit-done/bin/**/*.cjs + scripts/**/*.cjs: js.recommended +
  eslint-plugin-n + local plugin; generic quality rules (no-var, prefer-const,
  no-unused-vars, no-empty, n/no-process-exit)
- Block for tests/**/*.test.cjs: no-only-tests (error), local timing rules,
  no-restricted-syntax timing bans

eslint-rules/ local plugin (three AST rules, all at warn pending cleanup):
- no-source-grep: flag readFileSync on source .cjs/.js/.ts + text methods
- no-magic-sleep-in-tests: flag Atomics.wait and await-new-Promise(setTimeout)
- no-elapsed-assertion: flag assert*() on timing props (elapsed/duration/took/ms)

tsconfig.lint.json: allowJs + checkJs + noEmit for future type-aware passes.

tests/eslint-rules.test.cjs: 15 RuleTester unit tests (all pass, 0 fail).

package.json: add lint/lint:fix scripts; remove lint:tests (subsumed by ESLint
local/no-source-grep). Rules that produced pre-existing errors downgraded to
warn: no-useless-escape, no-unsafe-finally, no-regex-spaces, no-control-regex,
no-irregular-whitespace. ESLint exits 0 (warnings ok).

.github/workflows/test.yml lint-tests job: add npm ci + ESLint step; remove
"Lint — no source-grep tests" step (now covered by ESLint); bump timeout 3→5
min. .gitignore: add node_modules/.cache/eslint/ entry.

eslint --fix auto-cleaned: no-regex-spaces in tests, prefer-const in state.cjs,
redundant eslint-disable-next-line comments.

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:54:49 -04:00

188 lines
6.4 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// The GitHub Actions YAML is the deployed runtime contract. These tests assert
// on the parsed IR (line-tokenised YAML structure) — not on prose or rendered
// output — to lock the wiring without testing GHA execution semantics.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'install-smoke.yml');
/**
* Minimal structural extractor for this specific YAML shape.
* Returns an object with:
* { onTriggers: string[], steps: Array<{ name?: string, run?: string }> }
*
* Strategy:
* 1. Collect top-level `on:` keys by scanning lines after `^on:` until the
* next top-level key.
* 2. Collect step `run:` blocks by scanning all ` - name:` / ` run:`
* entries. No full YAML parse needed — we only need substring presence.
*/
function parseWorkflowStructure(src) {
const lines = src.split('\n');
// --- Extract on: trigger keys ---
const onTriggers = [];
let inOn = false;
for (const line of lines) {
if (/^on:/.test(line)) { inOn = true; continue; }
if (inOn) {
// A new top-level key ends the `on:` block
if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ')) { inOn = false; continue; }
// Direct children of `on:` are trigger names at 2-space indent
const m = line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_]*):/);
if (m) onTriggers.push(m[1]);
}
}
// --- Extract PR path filters ---
const prPaths = [];
let inPrPaths = false;
let inPullRequest = false;
for (const line of lines) {
if (/^ {2}pull_request:/.test(line)) { inPullRequest = true; continue; }
if (inPullRequest) {
if (/^ {4}paths:/.test(line)) { inPrPaths = true; continue; }
if (inPrPaths) {
const m = line.match(/^ {6}- '(.+)'/);
if (m) { prPaths.push(m[1]); continue; }
// End of paths list
if (/^ {4}[a-zA-Z]/.test(line)) inPrPaths = false;
}
// End of pull_request block
if (/^ {2}[a-zA-Z]/.test(line) && !line.startsWith(' ')) inPullRequest = false;
}
}
// --- Extract all step names + run blocks ---
const steps = [];
let currentStep = null;
let inRun = false;
let runLines = [];
for (const line of lines) {
// Step boundary: 6-space "- name:" or "- uses:"
if (/^ {6}- name:/.test(line)) {
if (currentStep && runLines.length) {
currentStep.run = runLines.join('\n');
}
if (currentStep) steps.push(currentStep);
currentStep = { name: line.replace(/^ {6}- name:\s*/, '').trim() };
inRun = false;
runLines = [];
continue;
}
if (/^ {6}- uses:/.test(line)) {
if (currentStep && runLines.length) {
currentStep.run = runLines.join('\n');
}
if (currentStep) steps.push(currentStep);
currentStep = { uses: line.replace(/^ {6}- uses:\s*/, '').trim() };
inRun = false;
runLines = [];
continue;
}
// uses: field inside a named step (e.g. "- name: Foo\n uses: actions/...")
if (currentStep && /^ {8}uses:\s/.test(line)) {
currentStep.uses = line.replace(/^ {8}uses:\s*/, '').trim();
continue;
}
// run: block inside a step
if (currentStep && /^ {8}run:\s*\|/.test(line)) {
inRun = true;
runLines = [];
continue;
}
if (currentStep && /^ {8}run:\s*(?!\|)/.test(line)) {
// Inline run (no |)
currentStep.run = line.replace(/^ {8}run:\s*/, '').trim();
inRun = false;
continue;
}
if (inRun) {
// Lines deeper than 8 spaces belong to the run block
if (/^ {10}/.test(line) || line.trim() === '') {
runLines.push(line);
} else {
inRun = false;
}
}
}
// Flush final step
if (currentStep) {
if (runLines.length) currentStep.run = runLines.join('\n');
steps.push(currentStep);
}
return { onTriggers, prPaths, steps };
}
describe('install-smoke.yml structural wiring', () => {
const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const { onTriggers, prPaths, steps } = parseWorkflowStructure(src);
test('workflow_call trigger is present (release.yml integration preserved)', () => {
assert.ok(
onTriggers.includes('workflow_call'),
`Expected 'workflow_call' in on: triggers. Found: ${JSON.stringify(onTriggers)}`
);
});
test('lifecycle smoke step calls release-tarball-smoke.cjs', () => {
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
assert.ok(
smokeStep,
'Expected a step whose run block includes "release-tarball-smoke.cjs". ' +
'Steps found: ' + JSON.stringify(steps.map(s => s.name || s.uses))
);
});
test('lifecycle smoke step invokes script with --json flag', () => {
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
assert.ok(smokeStep, 'No lifecycle smoke step found');
assert.ok(
smokeStep.run.includes('--json'),
`Expected --json in lifecycle smoke run block. Got: ${smokeStep.run}`
);
});
test('lifecycle smoke result is checked via jq', () => {
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
assert.ok(smokeStep, 'No lifecycle smoke step found');
assert.ok(
smokeStep.run.includes('jq'),
`Expected jq invocation in lifecycle smoke run block. Got: ${smokeStep.run}`
);
});
test('PR path filter includes scripts/release-tarball-smoke.cjs', () => {
assert.ok(
prPaths.includes('scripts/release-tarball-smoke.cjs'),
`Expected 'scripts/release-tarball-smoke.cjs' in PR paths filter. Found: ${JSON.stringify(prPaths)}`
);
});
test('PR path filter includes tests/release-tarball-smoke.install.test.cjs', () => {
assert.ok(
prPaths.includes('tests/release-tarball-smoke.install.test.cjs'),
`Expected 'tests/release-tarball-smoke.install.test.cjs' in PR paths filter. Found: ${JSON.stringify(prPaths)}`
);
});
test('artifact upload step is present for failure debugging', () => {
const uploadStep = steps.find(
s => s.uses && s.uses.startsWith('actions/upload-artifact')
);
assert.ok(
uploadStep,
'Expected an actions/upload-artifact step for lifecycle smoke failure debugging. ' +
'Steps (name + uses): ' + JSON.stringify(steps.map(s => ({ name: s.name, uses: s.uses })))
);
});
});