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..18e2cfc --- /dev/null +++ b/src/testing/project.ts @@ -0,0 +1,86 @@ +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..7dfab81 --- /dev/null +++ b/tests/test-command.test.ts @@ -0,0 +1,90 @@ +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', + ); + })); +});