Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/commands/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -18,5 +19,6 @@ export function createRootCommand(version = resolveCliVersion()): Command {
.action(() => undefined)
.addCommand(createDoctorCommand())
.addCommand(createNewCommand())
.addCommand(createDevCommand());
.addCommand(createDevCommand())
.addCommand(createTestCommand());
}
29 changes: 29 additions & 0 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
86 changes: 86 additions & 0 deletions src/testing/project.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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.',
);
}
2 changes: 2 additions & 0 deletions tests/root-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe('StellarForge root command', () => {
'doctor',
'new',
'dev',
'test',
]);
});

Expand All @@ -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');
});
Expand Down
90 changes: 90 additions & 0 deletions tests/test-command.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions tests/test-project.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
}));
});
Loading