diff --git a/tests/config-identity-alias.test.ts b/tests/config-identity-alias.test.ts new file mode 100644 index 0000000..89f840f --- /dev/null +++ b/tests/config-identity-alias.test.ts @@ -0,0 +1,121 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, +} from '../src/config/project.js'; +import { validateIdentityAlias } from '../src/config/schema.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { withTempDirectory } from './helpers/index.js'; + +const SENTINEL = 'SENTINEL7'; + +const validAliases = [ + 'deployer', + 'a', + 'A', + 'A1', + 'my-identity', + 'my_identity', + 'Team-Deployer_2', + 'a'.repeat(63), + 'a'.repeat(64), +]; + +const invalidAliases = [ + '', + ' ', + '1start', + '-leading', + '_leading', + 'has space', + ' leading', + 'trailing ', + 'two words', + 'a'.repeat(65), + 'alias!', + 'alias.', + 'a.b', + '../identity', + 'alice/bob', + '/absolute', + 'C:\\Users\\deployer', + '~/deployer', + 'alias;id', + 'alias$(id)', + 'alias|id', + 'alias\tid', + 'alias\nid', + 'caf\u00e9', + 'S'.concat('A'.repeat(55)), + 'G'.concat('A'.repeat(55)), +]; + +const sensitiveAliases = [ + `${SENTINEL} words`, + `../${SENTINEL}`, + `${SENTINEL}/child`, + `-${SENTINEL}`, + `${SENTINEL}$(id)`, + `${SENTINEL}.value`, + `${SENTINEL}!`, +]; + +describe('configuration identity alias boundaries', () => { + it.each(validAliases)('accepts identity alias %j', (alias) => { + expect(validateIdentityAlias(alias)).toBe(alias); + }); + + it.each(invalidAliases)('rejects identity alias %j', (alias) => { + expect(() => validateIdentityAlias(alias)).toThrow(ValidationCliError); + }); + + it.each(sensitiveAliases)( + 'does not echo rejected identity alias %j', + (alias) => { + try { + validateIdentityAlias(alias); + throw new Error('Expected identity validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).not.toContain(SENTINEL); + } + }, + ); + + it('loads a supported identity alias from project configuration', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, identity: 'team-deployer' }), + ); + + expect(loadProjectConfig(root)).toEqual({ + version: 1, + identity: 'team-deployer', + }); + })); + + it('rejects a raw StrKey identity in project configuration', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, identity: 'G'.concat('A'.repeat(55)) }), + ); + + expect(() => loadProjectConfig(root)).toThrow(ValidationCliError); + })); + + it('rejects a non-string identity field', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, identity: 42 }), + ); + + expect(() => loadProjectConfig(root)).toThrow( + 'Configuration identity must be a named Stellar CLI identity alias.', + ); + })); +}); diff --git a/tests/config-network-enum.test.ts b/tests/config-network-enum.test.ts new file mode 100644 index 0000000..86bf527 --- /dev/null +++ b/tests/config-network-enum.test.ts @@ -0,0 +1,124 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, +} from '../src/config/project.js'; +import { validateStellarForgeConfig } from '../src/config/schema.js'; +import { resolveTestnetDeploymentPlan } from '../src/deployment/project.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { withTempDirectory } from './helpers/index.js'; + +const ENUM_MESSAGE = + 'Configuration network must be one of: testnet, futurenet, mainnet.'; + +const unsupportedNetworks = [ + 'local', + 'public', + 'TESTNET', + 'Testnet', + 'main', + 'dev', + 'test', + 'future', + ' testnet', + 'testnet ', + 'test net', + '', +]; + +const nonStringNetworks = [1, 0, true, false, null, [], {}]; + +function writeCargoWorkspace(root: string): void { + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); +} + +describe('configuration network enum', () => { + it.each(['testnet', 'futurenet', 'mainnet'])( + 'accepts supported network %j', + (network) => { + expect(validateStellarForgeConfig({ version: 1, network })).toEqual({ + version: 1, + network, + }); + }, + ); + + it.each(unsupportedNetworks)( + 'rejects unsupported network %j with an actionable message', + (network) => { + expect(() => + validateStellarForgeConfig({ version: 1, network }), + ).toThrow(ENUM_MESSAGE); + }, + ); + + it.each(nonStringNetworks)('rejects non-string network %j', (network) => { + expect(() => validateStellarForgeConfig({ version: 1, network })).toThrow( + ENUM_MESSAGE, + ); + }); + + it('does not echo the rejected network value', () => { + try { + validateStellarForgeConfig({ version: 1, network: 'SENTINEL-NET' }); + throw new Error('Expected network validation to fail.'); + } catch (error) { + expect(error).toBeInstanceOf(ValidationCliError); + expect((error as Error).message).not.toContain('SENTINEL-NET'); + } + }); + + it('loads a configuration file with a supported network', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, network: 'futurenet' }), + ); + + expect(loadProjectConfig(root)).toEqual({ + version: 1, + network: 'futurenet', + }); + })); + + it('rejects a configuration file with an unsupported network', () => + withTempDirectory((root) => { + writeFileSync( + join(root, PROJECT_CONFIG_FILENAME), + JSON.stringify({ version: 1, network: 'local' }), + ); + + expect(() => loadProjectConfig(root)).toThrow(ENUM_MESSAGE); + })); + + it.each(['mainnet', 'futurenet', 'main', 'local', 'Testnet', ''])( + 'keeps the deploy command Testnet-only for network %j', + (network) => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect(() => + resolveTestnetDeploymentPlan({ + cwd: root, + network, + source: 'alice', + }), + ).toThrow('only the explicit `--network testnet`'); + }), + ); + + it('deploys a Cargo workspace on the explicit Testnet target', () => + withTempDirectory((root) => { + writeCargoWorkspace(root); + + expect( + resolveTestnetDeploymentPlan({ + cwd: root, + network: 'testnet', + source: 'alice', + }), + ).toHaveLength(1); + })); +}); diff --git a/tests/config-precedence.test.ts b/tests/config-precedence.test.ts new file mode 100644 index 0000000..49b3fea --- /dev/null +++ b/tests/config-precedence.test.ts @@ -0,0 +1,125 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + loadProjectConfig, + PROJECT_CONFIG_FILENAME, + resolveProjectConfig, +} from '../src/config/project.js'; +import { ValidationCliError } from '../src/errors/index.js'; +import { withTempDirectory } from './helpers/index.js'; + +function writeConfig(root: string, value: unknown): void { + writeFileSync(join(root, PROJECT_CONFIG_FILENAME), JSON.stringify(value)); +} + +describe('configuration precedence', () => { + it('prefers explicit CLI overrides over project configuration', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + network: 'futurenet', + identity: 'project-deployer', + }); + + expect( + resolveProjectConfig(root, { + network: 'testnet', + identity: 'cli-deployer', + }), + ).toEqual({ network: 'testnet', identity: 'cli-deployer' }); + })); + + it('uses project configuration when no CLI override is provided', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + network: 'futurenet', + identity: 'project-deployer', + }); + + expect(resolveProjectConfig(root)).toEqual({ + network: 'futurenet', + identity: 'project-deployer', + }); + })); + + it('applies a partial override without discarding the other project value', () => + withTempDirectory((root) => { + writeConfig(root, { + version: 1, + network: 'futurenet', + identity: 'project-deployer', + }); + + expect(resolveProjectConfig(root, { identity: 'cli-deployer' })).toEqual({ + network: 'futurenet', + identity: 'cli-deployer', + }); + expect(resolveProjectConfig(root, { network: 'testnet' })).toEqual({ + network: 'testnet', + identity: 'project-deployer', + }); + })); + + it('does not invent missing values', () => + withTempDirectory((root) => { + writeConfig(root, { version: 1 }); + + expect(resolveProjectConfig(root)).toEqual({}); + expect(resolveProjectConfig(root, {})).toEqual({}); + })); + + it('reports a missing configuration without a default', () => + withTempDirectory((root) => { + expect(loadProjectConfig(root)).toBeUndefined(); + expect(resolveProjectConfig(root)).toEqual({}); + })); + + it('validates a CLI identity override before accepting it', () => + withTempDirectory((root) => { + expect(() => + resolveProjectConfig(root, { identity: '../escape-attempt' }), + ).toThrow(ValidationCliError); + })); + + it('does not consult environment variables for precedence', () => + withTempDirectory((root) => { + const environmentKeys = [ + 'STELLARFORGE_NETWORK', + 'STELLARFORGE_IDENTITY', + 'STELLAR_NETWORK', + 'NETWORK', + 'IDENTITY', + ]; + const previous = new Map(); + for (const key of environmentKeys) { + previous.set(key, process.env[key]); + process.env[key] = 'mainnet'; + } + + try { + expect(resolveProjectConfig(root)).toEqual({}); + + writeConfig(root, { + version: 1, + network: 'testnet', + identity: 'project-deployer', + }); + + expect(resolveProjectConfig(root)).toEqual({ + network: 'testnet', + identity: 'project-deployer', + }); + } finally { + for (const key of environmentKeys) { + const value = previous.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + })); +}); diff --git a/tests/test-orchestration-regression.test.ts b/tests/test-orchestration-regression.test.ts new file mode 100644 index 0000000..ff0cdb6 --- /dev/null +++ b/tests/test-orchestration-regression.test.ts @@ -0,0 +1,228 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +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, ValidationCliError } from '../src/errors/index.js'; +import { createNpmInvocation } from '../src/process/npm.js'; +import { resolveTestProcessPlan } from '../src/testing/project.js'; +import { + createCapturedTerminalOutput, + withTempDirectory, +} 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); + } +} + +function writeNodeProject(root: string, name = 'demo'): void { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name, scripts: { test: 'node --test' } }), + ); +} + +describe('unified test orchestration regression coverage', () => { + it('selects the Node.js test workflow through npm without executing script text', () => + withTempDirectory((root) => { + writeNodeProject(root); + const npm = createNpmInvocation(['run', 'test']); + + expect(resolveTestProcessPlan(root)).toEqual([ + { + label: 'demo:test', + command: npm.command, + args: npm.args, + cwd: root, + }, + ]); + })); + + it('selects the Cargo test workflow for a Cargo workspace', () => + 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('prefers the Node.js workflow when both project forms are present', () => + withTempDirectory((root) => { + writeNodeProject(root); + writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + + expect(resolveTestProcessPlan(root)[0]?.label).toBe('demo:test'); + })); + + it('rejects unsupported and malformed projects with actionable guidance', () => + withTempDirectory((root) => { + 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', + ); + })); + + it('streams a successful Node.js test workflow', 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.stderr.write('coverage summary\n'); + child.exit(0); + await running; + + expect(captured.stdoutText()).toContain('Running 1 project test workflow.'); + expect(captured.stdoutText()).toContain('[demo:test] all tests passed'); + expect(captured.stderrText()).toContain('[demo:test] coverage summary'); + }); + + it('streams a successful Cargo test workflow', async () => { + const child = new TestChild(); + const captured = createCapturedTerminalOutput(); + const command = createTestCommand({ + cwd: () => '/workspace', + output: captured.output, + resolvePlan: () => [ + { + label: 'cargo:test', + command: 'cargo', + args: ['test'], + cwd: '/workspace', + }, + ], + spawnProcess: () => child, + }); + + const running = command.parseAsync(['node', 'test']); + child.stdout.write('test result: ok\n'); + child.exit(0); + await running; + + expect(captured.stdoutText()).toContain('[cargo:test] test result: ok'); + }); + + it('preserves a failing project test as a 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); + + const error = await running.then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).userMessage).toContain('with code 9'); + }); + + it('does not start a process for an unsupported project', () => + withTempDirectory(async (root) => { + let spawnCalled = false; + const command = createTestCommand({ + cwd: () => root, + spawnProcess: () => { + spawnCalled = true; + return new TestChild(); + }, + }); + + await expect(command.parseAsync(['node', 'test'])).rejects.toBeInstanceOf( + ValidationCliError, + ); + expect(spawnCalled).toBe(false); + })); + + it('does not start a process for a malformed project configuration', () => + withTempDirectory(async (root) => { + writeFileSync(join(root, 'package.json'), '{not-json'); + let spawnCalled = false; + const command = createTestCommand({ + cwd: () => root, + spawnProcess: () => { + spawnCalled = true; + return new TestChild(); + }, + }); + + await expect(command.parseAsync(['node', 'test'])).rejects.toThrow( + 'not valid JSON', + ); + expect(spawnCalled).toBe(false); + })); +});