From bf48d680066c309c461072e100250dbf11207a36 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:42:12 -0500 Subject: [PATCH] fix(docs): validate runner arguments before dispatch (cherry picked from commit db1f86ad27ab4db06685d6ce77b056fe800e933f) --- .github/workflows/ci-check-docs.yml | 2 +- scripts/docs-generators/runner.test.ts | 129 +++++++++++++++++++++++++ scripts/docs-generators/runner.ts | 51 ++++++---- 3 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 scripts/docs-generators/runner.test.ts diff --git a/.github/workflows/ci-check-docs.yml b/.github/workflows/ci-check-docs.yml index d1a78b3274..0c14b7c043 100644 --- a/.github/workflows/ci-check-docs.yml +++ b/.github/workflows/ci-check-docs.yml @@ -55,7 +55,7 @@ jobs: - name: Test generator routing and ownership run: | node docs/node_modules/tsx/dist/cli.mjs scripts/docs-generators/runner.ts --test-routing - node docs/node_modules/tsx/dist/cli.mjs --test scripts/docs-generators/cua-driver.test.ts scripts/docs-generators/cua-driver-platform.test.ts + node docs/node_modules/tsx/dist/cli.mjs --test scripts/docs-generators/runner.test.ts scripts/docs-generators/cua-driver.test.ts scripts/docs-generators/cua-driver-platform.test.ts - name: Select affected generator id: scope env: diff --git a/scripts/docs-generators/runner.test.ts b/scripts/docs-generators/runner.test.ts new file mode 100644 index 0000000000..8293a0f030 --- /dev/null +++ b/scripts/docs-generators/runner.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + cpSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test, { type TestContext } from 'node:test'; + +function runnerFixture(t: TestContext) { + const directory = mkdtempSync(join(tmpdir(), 'cua-docs-runner-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const source = join(directory, 'scripts', 'docs-generators'); + cpSync(__dirname, source, { recursive: true }); + const docs = join(directory, 'docs'); + mkdirSync(docs); + symlinkSync( + resolve(__dirname, '../../docs/node_modules'), + join(docs, 'node_modules'), + 'junction' + ); + copyFileSync(resolve(__dirname, '../../docs/pnpm-lock.yaml'), join(docs, 'pnpm-lock.yaml')); + writeFileSync( + join(directory, 'fixture.mjs'), + 'console.log("FIXTURE_GENERATOR_RAN", JSON.stringify(process.argv.slice(2)));\n' + ); + writeFileSync( + join(source, 'config.json'), + JSON.stringify({ + description: 'Command regression fixture', + generators: { + fixture: { + name: 'Fixture', + language: 'JavaScript', + sourcePath: 'fixture', + docsOutputPath: 'docs', + generatorScript: 'fixture.mjs', + watchPaths: ['fixture/**'], + buildCommand: null, + buildDirectory: '.', + extractionMethod: 'fixture', + outputs: [], + enabled: true, + }, + }, + }) + ); + return (args: string[]) => { + const result = spawnSync( + process.execPath, + [...process.execArgv, join(source, 'runner.ts'), ...args], + { + cwd: directory, + env: { ...process.env, NODE_TEST_CONTEXT: undefined }, + encoding: 'utf8', + timeout: 60_000, + } + ); + assert.ifError(result.error); + return result; + }; +} + +test('help prints usage without starting a generator', (t) => { + const run = runnerFixture(t); + const ordinary = run([]); + assert.equal(ordinary.status, 0, ordinary.stdout + ordinary.stderr); + assert.match(ordinary.stdout, /FIXTURE_GENERATOR_RAN \[\]/); + const help = run(['--help']); + assert.equal(help.status, 0, help.stdout + help.stderr); + assert.match(help.stdout, /Usage:/); + assert.match(help.stdout, /--library/); + assert.doesNotMatch(help.stdout + help.stderr, /FIXTURE_GENERATOR_RAN/); +}); + +test('unknown options fail without starting a generator', (t) => { + const run = runnerFixture(t); + const invalid = run(['--not-a-real-option']); + assert.equal(invalid.status, 1, invalid.stdout + invalid.stderr); + assert.match(invalid.stderr, /Unknown option.*--not-a-real-option/); + assert.doesNotMatch(invalid.stdout + invalid.stderr, /FIXTURE_GENERATOR_RAN/); +}); + +test('an empty library name does not fall back to running all generators', (t) => { + const run = runnerFixture(t); + const invalid = run(['--library', '']); + assert.equal(invalid.status, 1, invalid.stdout + invalid.stderr); + assert.match(invalid.stderr, /Unknown library/); + assert.doesNotMatch(invalid.stdout + invalid.stderr, /FIXTURE_GENERATOR_RAN/); +}); + +test('an empty changed-files path does not fall back to running generators', (t) => { + const run = runnerFixture(t); + const invalid = run(['--changed-files-file', '']); + assert.equal(invalid.status, 1, invalid.stdout + invalid.stderr); + assert.match(invalid.stderr, /Changed-files input not found/); + assert.doesNotMatch(invalid.stdout + invalid.stderr, /FIXTURE_GENERATOR_RAN/); +}); + +test('both check spellings still reach the selected generator', (t) => { + const run = runnerFixture(t); + for (const flag of ['--check', '--check-only']) { + const checked = run(['--library', 'fixture', flag]); + assert.equal(checked.status, 0, checked.stdout + checked.stderr); + assert.match(checked.stdout, /FIXTURE_GENERATOR_RAN \["--check"\]/); + } +}); + +test('list and changed-file selection report without running generators', (t) => { + const run = runnerFixture(t); + const listed = run(['--list']); + assert.equal(listed.status, 0, listed.stdout + listed.stderr); + assert.match(listed.stdout, /Fixture \(JavaScript\)/); + assert.doesNotMatch(listed.stdout + listed.stderr, /FIXTURE_GENERATOR_RAN/); + + const directory = mkdtempSync(join(tmpdir(), 'cua-docs-changes-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const input = join(directory, 'changed-files.txt'); + writeFileSync(input, 'fixture/example.mjs\n'); + const selected = run(['--changed-files-file', input]); + assert.equal(selected.status, 0, selected.stdout + selected.stderr); + assert.equal(selected.stdout.trim(), 'fixture'); +}); diff --git a/scripts/docs-generators/runner.ts b/scripts/docs-generators/runner.ts index 7d99822f0f..3f5bd6cb91 100644 --- a/scripts/docs-generators/runner.ts +++ b/scripts/docs-generators/runner.ts @@ -16,6 +16,7 @@ import { execSync, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { parseArgs } from 'node:util'; // ============================================================================ // Types @@ -68,18 +69,34 @@ const SHARED_GENERATOR_FILES = new Set([ // ============================================================================ async function main() { - const args = process.argv.slice(2); - - // Parse arguments - const checkOnly = args.includes('--check') || args.includes('--check-only'); - const listOnly = args.includes('--list'); - const changedOnly = args.includes('--changed'); - const changedFilesFileIndex = args.indexOf('--changed-files-file'); - const changedFilesFile = - changedFilesFileIndex !== -1 ? args[changedFilesFileIndex + 1] : undefined; - const testRouting = args.includes('--test-routing'); - const libraryIndex = args.indexOf('--library'); - const specificLibrary = libraryIndex !== -1 ? args[libraryIndex + 1] : null; + const { values } = parseArgs({ + options: { + help: { type: 'boolean' }, + list: { type: 'boolean' }, + library: { type: 'string' }, + check: { type: 'boolean' }, + 'check-only': { type: 'boolean' }, + changed: { type: 'boolean' }, + 'changed-files-file': { type: 'string' }, + 'test-routing': { type: 'boolean' }, + }, + }); + if (values.help) { + console.log(`Usage: pnpm --dir docs docs:generate [options] + + --help Show this help without running generators + --list List configured generators + --library Run one configured generator + --check, --check-only Check for documentation drift + --changed Select generators from changed files + --changed-files-file Print generators selected by a changed-file list + --test-routing Verify generator routing`); + return; + } + + const checkOnly = Boolean(values.check || values['check-only']); + const changedFilesFile = values['changed-files-file']; + const specificLibrary = values.library; // Load config if (!fs.existsSync(CONFIG_PATH)) { @@ -89,12 +106,12 @@ async function main() { const config: Config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); - if (testRouting) { + if (values['test-routing']) { testGeneratorRouting(config); return; } - if (changedFilesFile) { + if (changedFilesFile !== undefined) { if (!fs.existsSync(changedFilesFile)) { console.error(`Changed-files input not found: ${changedFilesFile}`); process.exit(1); @@ -108,7 +125,7 @@ async function main() { console.log('==================================\n'); // List mode - if (listOnly) { + if (values.list) { listGenerators(config); return; } @@ -116,14 +133,14 @@ async function main() { // Determine which generators to run let generatorsToRun: string[] = []; - if (specificLibrary) { + if (specificLibrary !== undefined) { if (!config.generators[specificLibrary]) { console.error(`❌ Unknown library: ${specificLibrary}`); console.log('\nAvailable libraries:', Object.keys(config.generators).join(', ')); process.exit(1); } generatorsToRun = [specificLibrary]; - } else if (changedOnly) { + } else if (values.changed) { generatorsToRun = getChangedGenerators(config); if (generatorsToRun.length === 0) { console.log('✅ No documentation-related changes detected.');