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
211 changes: 211 additions & 0 deletions tests/deploy-testnet-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { createDeployCommand } from '../src/commands/deploy.js';
import type { ManagedChildProcess } from '../src/dev/orchestrator.js';
import {
EXIT_CODES,
SubprocessCliError,
ValidationCliError,
runWithCliErrorBoundary,
} from '../src/errors/index.js';
import {
createCapturedTerminalOutput,
withTempDirectory,
} from './helpers/index.js';

const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4';

class MockDeployProcess implements ManagedChildProcess {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
#exitListener:
| ((code: number | null, signal: NodeJS.Signals | null) => void)
| undefined;
#errorListener: ((error: Error) => 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;
} else {
this.#errorListener = listener as (error: Error) => void;
}

return this;
}

kill(): boolean {
return true;
}

exitWith(code: number | null, signal: NodeJS.Signals | null = null): void {
this.#exitListener?.(code, signal);
}

fail(error: Error): void {
this.#errorListener?.(error);
}
}

function writeCargoWorkspace(root: string): void {
writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n');
}

describe('Stellar Testnet deployment end-to-end fixtures', () => {
it('rejects unsafe source input before any deploy process is started', () =>
withTempDirectory(async (root) => {
writeCargoWorkspace(root);
let spawned = false;
const command = createDeployCommand({
cwd: () => root,
spawnProcess: () => {
spawned = true;
return new MockDeployProcess();
},
});

await expect(
command.parseAsync([
'node',
'deploy',
'--network',
'testnet',
'--source',
'../escape-attempt',
]),
).rejects.toBeInstanceOf(ValidationCliError);
expect(spawned).toBe(false);
}));

it('does not reflect rejected secret-bearing source input', async () => {
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => '/workspace',
output: captured.output,
spawnProcess: () => new MockDeployProcess(),
});

const exitCode = await runWithCliErrorBoundary(async () => {
await command.parseAsync([
'node',
'deploy',
'--network',
'testnet',
'--source',
'seed SENTINEL-DEPLOY-SOURCE words',
]);
}, captured.output);

expect(exitCode).toBe(EXIT_CODES.validation);
expect(captured.stderrText()).not.toContain('SENTINEL-DEPLOY-SOURCE');
});

it('surfaces a mocked successful deploy with a deterministic contract ID', () =>
withTempDirectory(async (root) => {
writeCargoWorkspace(root);
const child = new MockDeployProcess();
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => root,
output: captured.output,
spawnProcess: () => child,
});

const running = command.parseAsync([
'node',
'deploy',
'--network',
'testnet',
'--source',
'deployer',
]);
child.stdout.write(`Contract deployed with ID: ${CONTRACT_ID}\n`);
child.exitWith(0);
await running;

expect(captured.stdoutText()).toContain(
'Deploying smart contract to Stellar Testnet.',
);
expect(captured.stdoutText()).toContain(
`[stellar:deploy] Contract deployed with ID: ${CONTRACT_ID}`,
);
expect(captured.stderrText()).toBe('');
}));

it('does not change exit semantics when extra deploy output is present', () =>
withTempDirectory(async (root) => {
writeCargoWorkspace(root);
const child = new MockDeployProcess();
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => root,
output: captured.output,
spawnProcess: () => child,
});

const running = command.parseAsync([
'node',
'deploy',
'--network',
'testnet',
'--source',
'deployer',
]);
child.stdout.write(`Contract deployed with ID: ${CONTRACT_ID}\n`);
child.stdout.write('note: extra informational line\n');
child.stderr.write('warning: simulated non-fatal notice\n');
child.stdout.write('error-like text that is not fatal\n');
child.exitWith(0);
await running;

expect(captured.stdoutText()).toContain(CONTRACT_ID);
expect(captured.stderrText()).toContain('simulated non-fatal notice');
}));

it('propagates a mocked failed deploy as a subprocess failure', () =>
withTempDirectory(async (root) => {
writeCargoWorkspace(root);
const child = new MockDeployProcess();
const captured = createCapturedTerminalOutput();
const command = createDeployCommand({
cwd: () => root,
output: captured.output,
spawnProcess: () => child,
});

const running = command.parseAsync([
'node',
'deploy',
'--network',
'testnet',
'--source',
'deployer',
]);
child.stderr.write('seed=SENTINEL-DEPLOY-OUTPUT\n');
child.exitWith(1);

const error = await running.then(
() => undefined,
(reason: unknown) => reason,
);

expect(error).toBeInstanceOf(SubprocessCliError);
expect((error as SubprocessCliError).userMessage).not.toContain(
'SENTINEL-DEPLOY-OUTPUT',
);
}));
});
140 changes: 140 additions & 0 deletions tests/error-category-wording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
EXIT_CODES,
InternalCliError,
SubprocessCliError,
ValidationCliError,
runWithCliErrorBoundary,
} from '../src/errors/index.js';
import { createCapturedTerminalOutput } from './helpers/index.js';

const root = join(import.meta.dirname, '..');
const exitCodeReference = readFileSync(
join(root, 'docs', 'reference', 'exit-codes.md'),
'utf8',
);

function expectSafeUserWording(message: string): void {
expect(message.length).toBeGreaterThan(0);
expect(message.trim()).toBe(message);
expect(message.endsWith('.')).toBe(true);
expect(message).not.toMatch(/[\r\n]/);
expect(message.startsWith('Error')).toBe(false);
expect(message).not.toMatch(/\bat\s+\S+:\d+/);
}

describe('CLI error-category wording', () => {
it('keeps the centralized exit-code contract unchanged', () => {
expect(EXIT_CODES).toEqual({
success: 0,
unexpected: 1,
validation: 2,
subprocess: 3,
internal: 4,
diagnostic: 5,
});
});

it('keeps the exit-code reference aligned with the source constants', () => {
expect(exitCodeReference).toContain('| `0` | Success |');
expect(exitCodeReference).toContain('| `1` | Unexpected failure |');
expect(exitCodeReference).toContain('| `2` | Validation / usage failure |');
expect(exitCodeReference).toContain('| `3` | Subprocess failure |');
expect(exitCodeReference).toContain('| `4` | Internal failure |');
expect(exitCodeReference).toContain('| `5` | Diagnostic failure |');
});

it('uses consistent, safe wording for each error category', () => {
const validation = new ValidationCliError('Project name is required.');
const subprocess = new SubprocessCliError(
'The requested tool exited unsuccessfully.',
);
const internal = new InternalCliError();

expect(validation.name).toBe('ValidationCliError');
expect(validation.exitCode).toBe(EXIT_CODES.validation);
expect(subprocess.name).toBe('SubprocessCliError');
expect(subprocess.exitCode).toBe(EXIT_CODES.subprocess);
expect(internal.name).toBe('InternalCliError');
expect(internal.exitCode).toBe(EXIT_CODES.internal);

const messages = [
validation.userMessage,
subprocess.userMessage,
internal.userMessage,
];

for (const message of messages) {
expectSafeUserWording(message);
}

expect(validation.userMessage).toBe('Project name is required.');
expect(subprocess.userMessage).toBe(
'The requested tool exited unsuccessfully.',
);
expect(internal.userMessage).toBe(
'StellarForge encountered an internal error.',
);
expect(new Set(messages).size).toBe(3);
});

it('renders each category with its stable exit code and no internals', async () => {
const cases: ReadonlyArray<readonly [Error, number, string]> = [
[
new ValidationCliError('Project name is required.'),
EXIT_CODES.validation,
'Project name is required.\n',
],
[
new SubprocessCliError('The requested tool exited unsuccessfully.'),
EXIT_CODES.subprocess,
'The requested tool exited unsuccessfully.\n',
],
[
new InternalCliError(),
EXIT_CODES.internal,
'StellarForge encountered an internal error.\n',
],
];

for (const [error, expectedCode, expectedLine] of cases) {
const { output, stdout, stderr } = createCapturedTerminalOutput();
const exitCode = await runWithCliErrorBoundary(() => {
throw error;
}, output);

expect(exitCode).toBe(expectedCode);
expect(stdout).toEqual([]);
expect(stderr).toEqual([expectedLine]);
}
});

it('keeps an internal cause while hiding it from rendered output', async () => {
const { output, stdout, stderr } = createCapturedTerminalOutput();
const cause = new Error('token=SENTINEL-INTERNAL-CAUSE');
const error = new InternalCliError({ cause });

const exitCode = await runWithCliErrorBoundary(() => {
throw error;
}, output);

expect(exitCode).toBe(EXIT_CODES.internal);
expect(error.cause).toBe(cause);
expect(stdout).toEqual([]);
expect(stderr.join('')).not.toContain('SENTINEL-INTERNAL-CAUSE');
});

it('hides raw unexpected details and stack traces behind one generic category', async () => {
const { output, stdout, stderr } = createCapturedTerminalOutput();
const exitCode = await runWithCliErrorBoundary(() => {
throw new Error('exploded at /Users/secret/src/main.ts:42');
}, output);

expect(exitCode).toBe(EXIT_CODES.unexpected);
expect(stdout).toEqual([]);
expect(stderr).toEqual(['StellarForge encountered an unexpected error.\n']);
expect(stderr.join('')).not.toMatch(/\bat\s+\S+:\d+/);
});
});
Loading