diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63bd27197..ac9d00713 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,31 +160,36 @@ jobs: chmod +x scripts/check-npm-integrity.sh scripts/check-npm-integrity.sh - # Split lanes (issue #3597). Unit is the fast default lane; integration - # and security run alongside it on every PR. `install` and `slow` are - # skipped on PR CI by design — they run on the weekly windows-compat - # workflow and on `main` push only. See docs/TESTING-SUITES.md. + # PR lanes run affected tests only (changed tests + reverse dependencies + # + smoke fallback). Push lanes continue to run full named suites. + - name: Run affected tests (PR) + if: github.event_name == 'pull_request' + shell: bash + run: npm run test:affected + - name: Run unit tests + if: github.event_name != 'pull_request' shell: bash run: npm run test:unit - name: Run integration tests + if: github.event_name != 'pull_request' shell: bash run: npm run test:integration - name: Run security tests + if: github.event_name != 'pull_request' shell: bash run: npm run test:security - # Full-suite gates run on a single canonical lane to keep matrix cost bounded. - # This ensures install + slow tests are always executed in PR CI. + # Install/slow remain push-only checks on the canonical lane. - name: Run install tests - if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + if: github.event_name != 'pull_request' && matrix.os == 'ubuntu-latest' && matrix.node-version == 24 shell: bash run: npm run test:install - name: Run slow tests - if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + if: github.event_name != 'pull_request' && matrix.os == 'ubuntu-latest' && matrix.node-version == 24 shell: bash run: npm run test:slow diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index 623338d42..304d47975 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -67,7 +67,7 @@ The `Tests` workflow runs on: - **Node 24** is the default development lane. - **Node 26** is forward-compat. The lane reports status but does not gate the workflow — `actions/setup-node` may not yet have a stable Node 26 image at any given moment. When it stabilises, flip `continue-on-error` off. -Each matrix cell runs `unit`, `integration`, and `security` on every PR. `install` and `slow` only run on `main`-branch push to keep PR CI fast. Coverage runs in a dedicated `coverage` job on `ubuntu-latest` / Node 24 — running coverage across the full matrix would 9x the cost for no extra coverage data. +Each matrix cell runs `test:affected` on PRs (changed tests + reverse-import dependents + a smoke fallback), then runs full named suites on push lanes. `install` and `slow` remain `main`/release push-only checks on the canonical lane to keep PR CI fast. Coverage runs in a dedicated `coverage` job on `ubuntu-latest` / Node 24 — running coverage across the full matrix would 9x the cost for no extra coverage data. ## Best practices for forward-compat (Node 24/26) diff --git a/package.json b/package.json index c333852b0..a8e71cb94 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "test:install": "node scripts/run-tests.cjs --suite install", "test:security": "node scripts/run-tests.cjs --suite security", "test:slow": "node scripts/run-tests.cjs --suite slow", + "test:affected": "node scripts/run-affected-tests.cjs", "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs", "test:coverage:unit": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs --suite unit", "test:coverage:all": "npm run test:coverage" diff --git a/scripts/affected-tests-lib.cjs b/scripts/affected-tests-lib.cjs new file mode 100644 index 000000000..9fe5f32cf --- /dev/null +++ b/scripts/affected-tests-lib.cjs @@ -0,0 +1,237 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); +const { readdirSync, readFileSync, existsSync } = require('node:fs'); +const path = require('node:path'); + +const CRITICAL_PATHS = [ + '.github/workflows/', + 'package.json', + 'package-lock.json', + 'scripts/run-tests.cjs', + 'scripts/affected-tests-lib.cjs', + 'scripts/run-affected-tests.cjs', +]; + +const DEFAULT_SMOKE_TESTS = [ + 'tests/release-tarball-smoke.install.test.cjs', +]; + +function toPosixPath(input) { + return input.split(path.sep).join('/'); +} + +function parseRelativeSpecifiers(source) { + const specifiers = []; + const requireRe = /require\((['"])(.+?)\1\)/g; + const importFromRe = /from\s+(['"])(.+?)\1/g; + let match; + + while ((match = requireRe.exec(source)) !== null) { + specifiers.push(match[2]); + } + while ((match = importFromRe.exec(source)) !== null) { + specifiers.push(match[2]); + } + + return specifiers.filter(specifier => specifier.startsWith('.')); +} + +function resolveRelativeDependency(repoRoot, fromAbs, specifier) { + const base = path.resolve(path.dirname(fromAbs), specifier); + const candidates = [ + base, + `${base}.js`, + `${base}.cjs`, + `${base}.mjs`, + path.join(base, 'index.js'), + path.join(base, 'index.cjs'), + path.join(base, 'index.mjs'), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return toPosixPath(path.relative(repoRoot, candidate)); + } + } + + return null; +} + +function buildReverseIndex(repoRoot, testFiles) { + const reverse = new Map(); + for (const testFile of testFiles) { + const absTest = path.join(repoRoot, testFile); + const source = readFileSync(absTest, 'utf8'); + const specs = parseRelativeSpecifiers(source); + for (const specifier of specs) { + const dep = resolveRelativeDependency(repoRoot, absTest, specifier); + if (!dep) continue; + if (!reverse.has(dep)) reverse.set(dep, new Set()); + reverse.get(dep).add(testFile); + } + } + return reverse; +} + +function shouldRunFullSuite(changedFiles) { + return changedFiles.some(file => + CRITICAL_PATHS.some(critical => file === critical || file.startsWith(critical)), + ); +} + +function listTestFiles(repoRoot) { + return readdirSync(path.join(repoRoot, 'tests')) + .filter(file => file.endsWith('.test.cjs')) + .map(file => `tests/${file}`) + .sort(); +} + +function pickAffectedTests(changedFiles, allTests, reverseIndex, smokeTests) { + const selected = new Set(); + + for (const file of changedFiles) { + if (file.startsWith('tests/') && file.endsWith('.test.cjs')) { + selected.add(file); + } + const dependents = reverseIndex.get(file); + if (dependents) { + for (const testFile of dependents) selected.add(testFile); + } + } + + for (const file of changedFiles) { + const stem = path.basename(file).replace(/\.[^.]+$/, '').toLowerCase(); + if (!stem) continue; + for (const testFile of allTests) { + if (testFile.toLowerCase().includes(stem)) selected.add(testFile); + } + } + + if (selected.size === 0) { + for (const smokeTest of smokeTests) { + if (allTests.includes(smokeTest)) selected.add(smokeTest); + } + } + + return [...selected].sort(); +} + +function changedFilesSinceBase(repoRoot, baseRef) { + const out = execFileSync( + 'git', + ['diff', '--name-only', '--diff-filter=ACMR', `${baseRef}...HEAD`], + { cwd: repoRoot, encoding: 'utf8' }, + ).trim(); + if (!out) return []; + return out.split('\n').map(line => line.trim()).filter(Boolean); +} + +function runNodeTestFiles(repoRoot, files) { + const defaultConcurrency = process.platform === 'win32' ? 2 : 4; + const concurrency = process.env.TEST_CONCURRENCY + ? `--test-concurrency=${process.env.TEST_CONCURRENCY}` + : `--test-concurrency=${defaultConcurrency}`; + const absoluteFiles = files.map(file => path.join(repoRoot, file)); + + // Keep chunks bounded for Windows CreateProcess command-length limits. + const maxChars = process.env.RUN_TESTS_MAX_CMDLINE_CHARS + ? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS) + : 28000; + const fixed = process.execPath.length + '--test'.length + concurrency.length + 8; + const chunks = []; + let current = []; + let currentLen = fixed; + + for (const file of absoluteFiles) { + const add = file.length + 1; + if (current.length > 0 && currentLen + add > maxChars) { + chunks.push(current); + current = []; + currentLen = fixed; + } + current.push(file); + currentLen += add; + } + if (current.length > 0) chunks.push(current); + + let firstFailure = 0; + for (let i = 0; i < chunks.length; i++) { + if (chunks.length > 1) { + console.error(`affected-tests: chunk ${i + 1}/${chunks.length} (${chunks[i].length} files)`); + } + try { + execFileSync(process.execPath, ['--test', concurrency, ...chunks[i]], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env }, + }); + } catch (error) { + const code = error.status || 1; + if (firstFailure === 0) firstFailure = code; + } + } + if (firstFailure !== 0) process.exit(firstFailure); +} + +function runSuite(repoRoot, suite) { + execFileSync(process.execPath, ['scripts/run-tests.cjs', '--suite', suite], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env }, + }); +} + +function runAllSuites(repoRoot) { + execFileSync(process.execPath, ['scripts/run-tests.cjs'], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env }, + }); +} + +function resolveBaseRef() { + if (process.env.GSD_AFFECTED_BASE) return process.env.GSD_AFFECTED_BASE; + if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; + return 'origin/main'; +} + +function runAffectedTests(options = {}) { + const repoRoot = options.repoRoot || path.resolve(__dirname, '..'); + const smokeTests = options.smokeTests || DEFAULT_SMOKE_TESTS; + const baseRef = options.baseRef || resolveBaseRef(); + const changed = changedFilesSinceBase(repoRoot, baseRef); + + if (changed.length === 0) { + console.error(`affected-tests: no changed files against ${baseRef}; running unit suite`); + runSuite(repoRoot, 'unit'); + return; + } + + if (shouldRunFullSuite(changed)) { + console.error('affected-tests: critical CI/runtime files changed; running full suite'); + runAllSuites(repoRoot); + return; + } + + const allTests = listTestFiles(repoRoot); + const reverseIndex = buildReverseIndex(repoRoot, allTests); + const selected = pickAffectedTests(changed, allTests, reverseIndex, smokeTests); + + console.error(`affected-tests: base=${baseRef} changed=${changed.length} selected=${selected.length}`); + console.error(`affected-tests: ${selected.join(' ')}`); + + runNodeTestFiles(repoRoot, selected); +} + +module.exports = { + CRITICAL_PATHS, + DEFAULT_SMOKE_TESTS, + buildReverseIndex, + parseRelativeSpecifiers, + pickAffectedTests, + resolveBaseRef, + shouldRunFullSuite, + toPosixPath, + runAffectedTests, +}; diff --git a/scripts/run-affected-tests.cjs b/scripts/run-affected-tests.cjs new file mode 100644 index 000000000..224919e34 --- /dev/null +++ b/scripts/run-affected-tests.cjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +'use strict'; + +const { runAffectedTests } = require('./affected-tests-lib.cjs'); + +runAffectedTests(); diff --git a/tests/affected-tests-lib.test.cjs b/tests/affected-tests-lib.test.cjs new file mode 100644 index 000000000..47b4f0536 --- /dev/null +++ b/tests/affected-tests-lib.test.cjs @@ -0,0 +1,86 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseRelativeSpecifiers, + pickAffectedTests, + shouldRunFullSuite, + resolveBaseRef, +} = require('../scripts/affected-tests-lib.cjs'); + +test('parseRelativeSpecifiers captures local require/import paths', () => { + const source = ` + const a = require('./alpha.cjs'); + const b = require("node:assert/strict"); + import c from "../beta.js"; + import d from "external-lib"; + `; + const out = parseRelativeSpecifiers(source); + assert.deepEqual(out, ['./alpha.cjs', '../beta.js']); +}); + +test('shouldRunFullSuite true when critical paths change', () => { + assert.equal(shouldRunFullSuite(['package-lock.json']), true); + assert.equal(shouldRunFullSuite(['.github/workflows/test.yml']), true); + assert.equal(shouldRunFullSuite(['tests/foo.test.cjs']), false); +}); + +test('pickAffectedTests includes direct test changes and reverse-index matches', () => { + const allTests = [ + 'tests/alpha.test.cjs', + 'tests/install.test.cjs', + 'tests/release-tarball-smoke.install.test.cjs', + ]; + const reverse = new Map([ + ['bin/install.js', new Set(['tests/install.test.cjs'])], + ]); + const selected = pickAffectedTests( + ['tests/alpha.test.cjs', 'bin/install.js'], + allTests, + reverse, + ['tests/release-tarball-smoke.install.test.cjs'], + ); + assert.deepEqual(selected, [ + 'tests/alpha.test.cjs', + 'tests/install.test.cjs', + 'tests/release-tarball-smoke.install.test.cjs', + ]); +}); + +test('pickAffectedTests falls back to smoke test when no matches found', () => { + const allTests = ['tests/release-tarball-smoke.install.test.cjs']; + const selected = pickAffectedTests( + ['docs/README.md'], + allTests, + new Map(), + ['tests/release-tarball-smoke.install.test.cjs'], + ); + assert.deepEqual(selected, ['tests/release-tarball-smoke.install.test.cjs']); +}); + +test('resolveBaseRef prefers explicit env override', () => { + const original = { + GSD_AFFECTED_BASE: process.env.GSD_AFFECTED_BASE, + GITHUB_BASE_REF: process.env.GITHUB_BASE_REF, + }; + try { + process.env.GSD_AFFECTED_BASE = 'origin/next'; + process.env.GITHUB_BASE_REF = 'main'; + assert.equal(resolveBaseRef(), 'origin/next'); + + delete process.env.GSD_AFFECTED_BASE; + process.env.GITHUB_BASE_REF = 'next'; + assert.equal(resolveBaseRef(), 'origin/next'); + + delete process.env.GSD_AFFECTED_BASE; + delete process.env.GITHUB_BASE_REF; + assert.equal(resolveBaseRef(), 'origin/main'); + } finally { + if (original.GSD_AFFECTED_BASE === undefined) delete process.env.GSD_AFFECTED_BASE; + else process.env.GSD_AFFECTED_BASE = original.GSD_AFFECTED_BASE; + if (original.GITHUB_BASE_REF === undefined) delete process.env.GITHUB_BASE_REF; + else process.env.GITHUB_BASE_REF = original.GITHUB_BASE_REF; + } +});