From b8de043b6795086df97bdf2cef52bc7ce8f253e4 Mon Sep 17 00:00:00 2001 From: Aliyu Ibrahim <271224223+ykargeee-bit@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:28:01 +0100 Subject: [PATCH] test(cli): add deploy E2E, error wording, non-TTY output, and Quick Start suites Closes #192 Closes #196 Closes #197 Closes #199 --- tests/deploy-testnet-e2e.test.ts | 211 +++++++++++++++++++++++++++ tests/error-category-wording.test.ts | 140 ++++++++++++++++++ tests/non-tty-output.test.ts | 114 +++++++++++++++ tests/quick-start-docs.test.ts | 101 +++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 tests/deploy-testnet-e2e.test.ts create mode 100644 tests/error-category-wording.test.ts create mode 100644 tests/non-tty-output.test.ts create mode 100644 tests/quick-start-docs.test.ts diff --git a/tests/deploy-testnet-e2e.test.ts b/tests/deploy-testnet-e2e.test.ts new file mode 100644 index 0000000..6094d9c --- /dev/null +++ b/tests/deploy-testnet-e2e.test.ts @@ -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', + ); + })); +}); diff --git a/tests/error-category-wording.test.ts b/tests/error-category-wording.test.ts new file mode 100644 index 0000000..e6479c5 --- /dev/null +++ b/tests/error-category-wording.test.ts @@ -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 = [ + [ + 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+/); + }); +}); diff --git a/tests/non-tty-output.test.ts b/tests/non-tty-output.test.ts new file mode 100644 index 0000000..e221c0b --- /dev/null +++ b/tests/non-tty-output.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + EXIT_CODES, + ValidationCliError, + handleCliError, +} from '../src/errors/index.js'; +import { + sanitizeTerminalText, + TerminalOutput, + type TextOutputStream, +} from '../src/output/terminal.js'; + +type CaptureStream = TextOutputStream & { isTTY?: boolean }; + +function createCaptureStream(target: string[], isTTY?: boolean): CaptureStream { + const stream: CaptureStream = { + write(chunk: string) { + target.push(chunk); + }, + }; + + if (isTTY !== undefined) { + stream.isTTY = isTTY; + } + + return stream; +} + +function createOutput(): { + readonly output: TerminalOutput; + readonly stdout: string[]; + readonly stderr: string[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + output: new TerminalOutput({ + stdout: createCaptureStream(stdout, false), + stderr: createCaptureStream(stderr, false), + }), + stdout, + stderr, + }; +} + +describe('non-TTY output readability', () => { + it('writes plain, newline-terminated lines without TTY decorations', () => { + const { output, stdout, stderr } = createOutput(); + + output.info('first line'); + output.success('second line'); + output.warning('third line'); + output.error('fourth line'); + + expect(stdout).toEqual(['first line\n', 'second line\n']); + expect(stderr).toEqual(['third line\n', 'fourth line\n']); + }); + + it('does not require TTY-specific stream properties', () => { + const stdout: string[] = []; + const output = new TerminalOutput({ + stdout: createCaptureStream(stdout), + stderr: createCaptureStream([]), + }); + + expect(() => output.info('works without isTTY')).not.toThrow(); + expect(stdout).toEqual(['works without isTTY\n']); + }); + + it('neutralizes ANSI and terminal control characters in non-TTY output', () => { + const { output, stdout } = createOutput(); + + output.info('start\u001b[31mred\u001b[0m\u0000end'); + + expect(stdout).toEqual(['startredend\n']); + }); + + it('preserves tabs and newlines while removing carriage returns', () => { + expect(sanitizeTerminalText('one\ttwo\nthree\rfour')).toBe( + 'one\ttwo\nthreefour', + ); + }); + + it('produces stable output for repeated and multi-line messages', () => { + const { output, stdout } = createOutput(); + + output.info('line one\nline two'); + output.info('line one\nline two'); + + expect(stdout).toEqual(['line one\nline two\n', 'line one\nline two\n']); + }); + + it('writes a bare newline for an empty message', () => { + const { output, stdout } = createOutput(); + + output.info(''); + + expect(stdout).toEqual(['\n']); + }); + + it('does not change exit semantics when rendering to a non-TTY stream', () => { + const { output, stdout, stderr } = createOutput(); + + const exitCode = handleCliError( + new ValidationCliError('Project name is required.'), + output, + ); + + expect(exitCode).toBe(EXIT_CODES.validation); + expect(stdout).toEqual([]); + expect(stderr).toEqual(['Project name is required.\n']); + }); +}); diff --git a/tests/quick-start-docs.test.ts b/tests/quick-start-docs.test.ts new file mode 100644 index 0000000..d198de9 --- /dev/null +++ b/tests/quick-start-docs.test.ts @@ -0,0 +1,101 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { validateStellarForgeConfig } from '../src/config/schema.js'; +import { resolveDevProcessPlan } from '../src/dev/project.js'; +import { EXIT_CODES } from '../src/errors/index.js'; +import { + TEMPLATE_IDS, + getTemplate, +} from '../src/generator/template-registry.js'; +import { createNpmInvocation } from '../src/process/npm.js'; +import { withTempDirectory } from './helpers/index.js'; + +const root = join(import.meta.dirname, '..'); +const quickStart = readFileSync( + join(root, 'docs', 'guides', 'quick-start.md'), + 'utf8', +); + +const templateMatches = [...quickStart.matchAll(/--template ([a-z-]+)/g)]; +const documentedTemplateIds = templateMatches.map((match) => match[1] ?? ''); + +describe('Quick Start documentation', () => { + it('documents only implemented commands', () => { + for (const command of [ + 'stellarforge new', + 'stellarforge doctor', + 'stellarforge dev', + 'stellarforge test', + 'stellarforge deploy', + ]) { + expect(quickStart).toContain(command); + } + + expect(quickStart).not.toContain('stellarforge add'); + expect(quickStart).not.toContain('remote template'); + }); + + it('references only bundled template IDs and documents the default', () => { + for (const templateId of documentedTemplateIds) { + expect(TEMPLATE_IDS).toContain(templateId); + } + + expect(documentedTemplateIds).toContain('api-service'); + expect(documentedTemplateIds).toContain('smart-contract'); + expect(documentedTemplateIds).toContain('full-stack'); + expect(quickStart).toContain('The default template is `basic-app`'); + expect(getTemplate('basic-app').id).toBe('basic-app'); + }); + + it('matches the implemented development script selection order', () => + withTempDirectory((projectRoot) => { + const writeScripts = (scripts: Record): void => { + writeFileSync( + join(projectRoot, 'package.json'), + JSON.stringify({ name: 'quick-start-app', scripts }), + ); + }; + + expect(quickStart).toContain('`dev`, then `start`, then `start:backend`'); + + writeScripts({ dev: 'a', start: 'b', 'start:backend': 'c' }); + expect(resolveDevProcessPlan(projectRoot)[0]?.args).toEqual( + createNpmInvocation(['run', 'dev']).args, + ); + + writeScripts({ start: 'b', 'start:backend': 'c' }); + expect(resolveDevProcessPlan(projectRoot)[0]?.args).toEqual( + createNpmInvocation(['run', 'start']).args, + ); + + writeScripts({ 'start:backend': 'c' }); + expect(resolveDevProcessPlan(projectRoot)[0]?.args).toEqual( + createNpmInvocation(['run', 'start:backend']).args, + ); + })); + + it('uses a configuration example that matches the schema', () => { + const block = /```json\n([\s\S]*?)```/.exec(quickStart); + expect(block).not.toBeNull(); + + const json = (block?.[1] ?? '').trim(); + const parsed: unknown = JSON.parse(json); + expect(() => validateStellarForgeConfig(parsed)).not.toThrow(); + expect(parsed).toMatchObject({ version: 1, network: 'testnet' }); + }); + + it('documents accurate Testnet and secret boundaries', () => { + expect(quickStart).toContain( + 'A failed required diagnostic exits with code `5`.', + ); + expect(EXIT_CODES.diagnostic).toBe(5); + expect(quickStart).toContain('The MVP deployment path is Testnet-only.'); + expect(quickStart).toContain('Mainnet deployment is not implemented.'); + expect(quickStart).toContain( + 'Do not place raw secret keys or seed phrases in project configuration.', + ); + expect(quickStart).not.toContain('defaults to Mainnet'); + expect(quickStart).not.toContain('default network is mainnet'); + }); +});