From 5bd2659395fb262a4528dd045c39b659d322d259 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:34:52 +0100 Subject: [PATCH 1/6] feat: implement unified project test orchestration --- src/commands/root.ts | 4 +- src/commands/test.ts | 29 ++++++++++++ src/testing/project.ts | 84 +++++++++++++++++++++++++++++++++++ tests/root-command.test.ts | 2 + tests/test-command.test.ts | 91 ++++++++++++++++++++++++++++++++++++++ tests/test-project.test.ts | 61 +++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/commands/test.ts create mode 100644 src/testing/project.ts create mode 100644 tests/test-command.test.ts create mode 100644 tests/test-project.test.ts diff --git a/src/commands/root.ts b/src/commands/root.ts index 52c4059..0777bcb 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -3,6 +3,7 @@ import { resolveCliVersion } from '../version.js'; import { createDevCommand } from './dev.js'; import { createDoctorCommand } from './doctor.js'; import { createNewCommand } from './new.js'; +import { createTestCommand } from './test.js'; export function createRootCommand(version = resolveCliVersion()): Command { return new Command() @@ -18,5 +19,6 @@ export function createRootCommand(version = resolveCliVersion()): Command { .action(() => undefined) .addCommand(createDoctorCommand()) .addCommand(createNewCommand()) - .addCommand(createDevCommand()); + .addCommand(createDevCommand()) + .addCommand(createTestCommand()); } diff --git a/src/commands/test.ts b/src/commands/test.ts new file mode 100644 index 0000000..324e690 --- /dev/null +++ b/src/commands/test.ts @@ -0,0 +1,29 @@ +import { Command } from 'commander'; +import { + superviseDevProcesses, + type DevOrchestratorOptions, +} from '../dev/orchestrator.js'; +import type { DevProcessSpec } from '../dev/project.js'; +import { TerminalOutput } from '../output/terminal.js'; +import { resolveTestProcessPlan } from '../testing/project.js'; + +export interface TestCommandOptions extends DevOrchestratorOptions { + readonly cwd?: () => string; + readonly resolvePlan?: (cwd: string) => readonly DevProcessSpec[]; +} + +export function createTestCommand(options: TestCommandOptions = {}): Command { + const cwd = options.cwd ?? process.cwd; + const output = options.output ?? new TerminalOutput(); + const resolvePlan = options.resolvePlan ?? resolveTestProcessPlan; + + return new Command('test') + .description('Run tests for a supported StellarForge project.') + .action(async () => { + const plan = resolvePlan(cwd()); + output.info( + `Running ${plan.length} project test workflow${plan.length === 1 ? '' : 's'}.`, + ); + await superviseDevProcesses(plan, { ...options, output }); + }); +} diff --git a/src/testing/project.ts b/src/testing/project.ts new file mode 100644 index 0000000..206667a --- /dev/null +++ b/src/testing/project.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { DevProcessSpec } from '../dev/project.js'; +import { ValidationCliError } from '../errors/errors.js'; + +interface ProjectPackage { + readonly name?: unknown; + readonly scripts?: unknown; +} + +function resolveNodeTestPlan(projectRoot: string): DevProcessSpec | undefined { + const packagePath = resolve(projectRoot, 'package.json'); + + if (!existsSync(packagePath)) { + return undefined; + } + + let projectPackage: ProjectPackage; + try { + projectPackage = JSON.parse(readFileSync(packagePath, 'utf8')) as ProjectPackage; + } catch (error) { + throw new ValidationCliError('package.json is not valid JSON.', { + cause: error, + }); + } + + if ( + projectPackage.scripts === null || + typeof projectPackage.scripts !== 'object' || + Array.isArray(projectPackage.scripts) + ) { + throw new ValidationCliError('package.json must define a scripts object.'); + } + + const scripts = projectPackage.scripts as Record; + if (typeof scripts.test !== 'string') { + throw new ValidationCliError( + 'This Node.js project does not define a test script. Add a package.json test script before using `stellarforge test`.', + ); + } + + const projectName = + typeof projectPackage.name === 'string' && projectPackage.name.length > 0 + ? projectPackage.name + : 'app'; + + return { + label: `${projectName}:test`, + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: ['run', 'test'], + cwd: projectRoot, + }; +} + +function resolveCargoTestPlan(projectRoot: string): DevProcessSpec | undefined { + if (!existsSync(resolve(projectRoot, 'Cargo.toml'))) { + return undefined; + } + + return { + label: 'cargo:test', + command: process.platform === 'win32' ? 'cargo.exe' : 'cargo', + args: ['test'], + cwd: projectRoot, + }; +} + +export function resolveTestProcessPlan(cwd: string): readonly DevProcessSpec[] { + const projectRoot = resolve(cwd); + const nodePlan = resolveNodeTestPlan(projectRoot); + + if (nodePlan !== undefined) { + return [nodePlan]; + } + + const cargoPlan = resolveCargoTestPlan(projectRoot); + if (cargoPlan !== undefined) { + return [cargoPlan]; + } + + throw new ValidationCliError( + 'No supported StellarForge test workflow was found. Expected a package.json test script or a Cargo.toml workspace.', + ); +} diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index 70bfcce..36deb99 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -10,6 +10,7 @@ describe('StellarForge root command', () => { 'doctor', 'new', 'dev', + 'test', ]); }); @@ -23,6 +24,7 @@ describe('StellarForge root command', () => { expect(help).toContain('doctor'); expect(help).toContain('new'); expect(help).toContain('dev'); + expect(help).toContain('test'); expect(help).toContain('-V, --version'); expect(help).toContain('-h, --help'); }); diff --git a/tests/test-command.test.ts b/tests/test-command.test.ts new file mode 100644 index 0000000..61fef04 --- /dev/null +++ b/tests/test-command.test.ts @@ -0,0 +1,91 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { createTestCommand } from '../src/commands/test.js'; +import type { ManagedChildProcess } from '../src/dev/orchestrator.js'; +import { SubprocessCliError } from '../src/errors/errors.js'; +import { createCapturedTerminalOutput } from './helpers/index.js'; + +class TestChild implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + #exitListener: + | ((code: number | null, signal: NodeJS.Signals | null) => void) + | undefined; + + once(event: 'error', listener: (error: Error) => void): this; + once( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; + once( + event: 'error' | 'exit', + listener: + | ((error: Error) => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + if (event === 'exit') { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } + return this; + } + + kill(): boolean { + return true; + } + + exit(code: number): void { + this.#exitListener?.(code, null); + } +} + +describe('test command', () => { + it('streams project test output and completes on a successful child result', async () => { + const child = new TestChild(); + const captured = createCapturedTerminalOutput(); + const command = createTestCommand({ + cwd: () => '/workspace', + output: captured.output, + resolvePlan: () => [ + { + label: 'demo:test', + command: 'npm', + args: ['run', 'test'], + cwd: '/workspace', + }, + ], + spawnProcess: () => child, + }); + + const running = command.parseAsync(['node', 'test']); + child.stdout.write('all tests passed\n'); + child.exit(0); + await running; + + expect(captured.stdoutText()).toContain('Running 1 project test workflow.'); + expect(captured.stdoutText()).toContain('[demo:test] all tests passed'); + }); + + it('preserves failing project tests as a non-zero subprocess failure', async () => { + const child = new TestChild(); + const command = createTestCommand({ + cwd: () => '/workspace', + resolvePlan: () => [ + { + label: 'demo:test', + command: 'npm', + args: ['run', 'test'], + cwd: '/workspace', + }, + ], + spawnProcess: () => child, + }); + + const running = command.parseAsync(['node', 'test']); + child.exit(9); + + await expect(running).rejects.toBeInstanceOf(SubprocessCliError); + }); +}); diff --git a/tests/test-project.test.ts b/tests/test-project.test.ts new file mode 100644 index 0000000..186c282 --- /dev/null +++ b/tests/test-project.test.ts @@ -0,0 +1,61 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { resolveTestProcessPlan } from '../src/testing/project.js'; +import { withTempDirectory } from './helpers/index.js'; + +describe('project test workflow resolution', () => { + it('resolves a Node.js test script without executing package script text directly', () => + withTempDirectory((root) => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'demo', + scripts: { test: 'node --test && echo never-interpolated' }, + }), + ); + + expect(resolveTestProcessPlan(root)).toEqual([ + { + label: 'demo:test', + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: ['run', 'test'], + cwd: root, + }, + ]); + })); + + it('resolves a Cargo workspace test workflow', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + + expect(resolveTestProcessPlan(root)).toEqual([ + { + label: 'cargo:test', + command: process.platform === 'win32' ? 'cargo.exe' : 'cargo', + args: ['test'], + cwd: root, + }, + ]); + })); + + it('rejects unsupported and malformed projects with actionable guidance', () => + withTempDirectory((root) => { + expect(() => resolveTestProcessPlan(root)).toThrow(ValidationCliError); + expect(() => resolveTestProcessPlan(root)).toThrow( + 'No supported StellarForge test workflow', + ); + + writeFileSync(join(root, 'package.json'), '{not-json'); + expect(() => resolveTestProcessPlan(root)).toThrow('not valid JSON'); + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ scripts: { start: 'node app.js' } }), + ); + expect(() => resolveTestProcessPlan(root)).toThrow( + 'does not define a test script', + ); + })); +}); From 80bda34b50c64c93edb360e6231e4c898cd01b49 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:35:56 +0100 Subject: [PATCH 2/6] chore: refresh CLI-032 validation From b21df4f5bf5b122caf7058d121c39cef3ccd27dd Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:37:52 +0100 Subject: [PATCH 3/6] chore: diagnose CLI-032 formatting --- .github/workflows/format-cli-032.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/format-cli-032.yml diff --git a/.github/workflows/format-cli-032.yml b/.github/workflows/format-cli-032.yml new file mode 100644 index 0000000..16e0366 --- /dev/null +++ b/.github/workflows/format-cli-032.yml @@ -0,0 +1,25 @@ +name: Diagnose CLI-032 formatting + +on: + push: + branches: + - feat/cli-032-test-orchestration + +permissions: + contents: read + +jobs: + format-diff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '22.13.0' + package-manager-cache: false + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npx --no-install prettier --write src/testing/project.ts tests/test-command.test.ts + - run: git diff -- src/testing/project.ts tests/test-command.test.ts From 7919e43f20562cd9c5f7f02438881bfe9ab05de3 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:38:31 +0100 Subject: [PATCH 4/6] style: format test workflow resolver --- src/testing/project.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/testing/project.ts b/src/testing/project.ts index 206667a..18e2cfc 100644 --- a/src/testing/project.ts +++ b/src/testing/project.ts @@ -17,7 +17,9 @@ function resolveNodeTestPlan(projectRoot: string): DevProcessSpec | undefined { let projectPackage: ProjectPackage; try { - projectPackage = JSON.parse(readFileSync(packagePath, 'utf8')) as ProjectPackage; + projectPackage = JSON.parse( + readFileSync(packagePath, 'utf8'), + ) as ProjectPackage; } catch (error) { throw new ValidationCliError('package.json is not valid JSON.', { cause: error, From 8c4b1c160946fb88da9322b4404c4c340adc297a Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:38:46 +0100 Subject: [PATCH 5/6] style: format test command fixture --- tests/test-command.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test-command.test.ts b/tests/test-command.test.ts index 61fef04..7dfab81 100644 --- a/tests/test-command.test.ts +++ b/tests/test-command.test.ts @@ -9,8 +9,7 @@ class TestChild implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( From 57c2322353a23e025d78b856ca3962fc5c2830c2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 23:38:59 +0100 Subject: [PATCH 6/6] chore: remove CLI-032 formatting diagnostic --- .github/workflows/format-cli-032.yml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/format-cli-032.yml diff --git a/.github/workflows/format-cli-032.yml b/.github/workflows/format-cli-032.yml deleted file mode 100644 index 16e0366..0000000 --- a/.github/workflows/format-cli-032.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Diagnose CLI-032 formatting - -on: - push: - branches: - - feat/cli-032-test-orchestration - -permissions: - contents: read - -jobs: - format-diff: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: '22.13.0' - package-manager-cache: false - - run: npm ci --ignore-scripts --no-audit --no-fund - - run: npx --no-install prettier --write src/testing/project.ts tests/test-command.test.ts - - run: git diff -- src/testing/project.ts tests/test-command.test.ts