|
| 1 | +import {envFixture, executables} from './env.js' |
| 2 | +import {stripAnsi} from '../helpers/strip-ansi.js' |
| 3 | +import {execa, type Options as ExecaOptions} from 'execa' |
| 4 | +import type {E2EEnv} from './env.js' |
| 5 | +import type * as pty from 'node-pty' |
| 6 | + |
| 7 | +export interface ExecResult { |
| 8 | + stdout: string |
| 9 | + stderr: string |
| 10 | + exitCode: number |
| 11 | +} |
| 12 | + |
| 13 | +export interface SpawnedProcess { |
| 14 | + /** Wait for a string to appear in the PTY output */ |
| 15 | + waitForOutput(text: string, timeoutMs?: number): Promise<void> |
| 16 | + /** Send a single key to the PTY */ |
| 17 | + sendKey(key: string): void |
| 18 | + /** Send a line of text followed by Enter */ |
| 19 | + sendLine(line: string): void |
| 20 | + /** Wait for the process to exit */ |
| 21 | + waitForExit(timeoutMs?: number): Promise<number> |
| 22 | + /** Kill the process */ |
| 23 | + kill(): void |
| 24 | + /** Get all output captured so far (ANSI stripped) */ |
| 25 | + getOutput(): string |
| 26 | + /** The underlying node-pty process */ |
| 27 | + readonly ptyProcess: pty.IPty |
| 28 | +} |
| 29 | + |
| 30 | +export interface CLIProcess { |
| 31 | + /** Execute a CLI command non-interactively via execa */ |
| 32 | + exec(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult> |
| 33 | + /** Execute the create-app binary non-interactively via execa */ |
| 34 | + execCreateApp(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number}): Promise<ExecResult> |
| 35 | + /** Spawn an interactive CLI command via node-pty */ |
| 36 | + spawn(args: string[], opts?: {cwd?: string; env?: NodeJS.ProcessEnv}): Promise<SpawnedProcess> |
| 37 | +} |
| 38 | + |
| 39 | +/** |
| 40 | + * Test-scoped fixture providing CLI process management. |
| 41 | + * Tracks all spawned processes and kills them in teardown. |
| 42 | + */ |
| 43 | +export const cliFixture = envFixture.extend<{cli: CLIProcess}>({ |
| 44 | + cli: async ({env}, use) => { |
| 45 | + const spawnedProcesses: SpawnedProcess[] = [] |
| 46 | + |
| 47 | + const cli: CLIProcess = { |
| 48 | + async exec(args, opts = {}) { |
| 49 | + // 3 min default |
| 50 | + const timeout = opts.timeout ?? 3 * 60 * 1000 |
| 51 | + const execaOpts: ExecaOptions = { |
| 52 | + cwd: opts.cwd, |
| 53 | + env: {...env.processEnv, ...opts.env}, |
| 54 | + timeout, |
| 55 | + reject: false, |
| 56 | + } |
| 57 | + |
| 58 | + if (process.env.DEBUG === '1') { |
| 59 | + console.log(`[e2e] exec: node ${executables.cli} ${args.join(' ')}`) |
| 60 | + } |
| 61 | + |
| 62 | + const result = await execa('node', [executables.cli, ...args], execaOpts) |
| 63 | + |
| 64 | + return { |
| 65 | + stdout: result.stdout ?? '', |
| 66 | + stderr: result.stderr ?? '', |
| 67 | + exitCode: result.exitCode ?? 1, |
| 68 | + } |
| 69 | + }, |
| 70 | + |
| 71 | + async execCreateApp(args, opts = {}) { |
| 72 | + // 5 min default for scaffolding |
| 73 | + const timeout = opts.timeout ?? 5 * 60 * 1000 |
| 74 | + const execaOpts: ExecaOptions = { |
| 75 | + cwd: opts.cwd, |
| 76 | + env: {...env.processEnv, ...opts.env}, |
| 77 | + timeout, |
| 78 | + reject: false, |
| 79 | + } |
| 80 | + |
| 81 | + if (process.env.DEBUG === '1') { |
| 82 | + console.log(`[e2e] exec: node ${executables.createApp} ${args.join(' ')}`) |
| 83 | + } |
| 84 | + |
| 85 | + const result = await execa('node', [executables.createApp, ...args], execaOpts) |
| 86 | + |
| 87 | + return { |
| 88 | + stdout: result.stdout ?? '', |
| 89 | + stderr: result.stderr ?? '', |
| 90 | + exitCode: result.exitCode ?? 1, |
| 91 | + } |
| 92 | + }, |
| 93 | + |
| 94 | + async spawn(args, opts = {}) { |
| 95 | + // Dynamic import to avoid requiring node-pty for Phase 1 tests |
| 96 | + const nodePty = await import('node-pty') |
| 97 | + |
| 98 | + const spawnEnv: {[key: string]: string} = {} |
| 99 | + for (const [key, value] of Object.entries({...env.processEnv, ...opts.env})) { |
| 100 | + if (value !== undefined) { |
| 101 | + spawnEnv[key] = value |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + if (process.env.DEBUG === '1') { |
| 106 | + console.log(`[e2e] spawn: node ${executables.cli} ${args.join(' ')}`) |
| 107 | + } |
| 108 | + |
| 109 | + const ptyProcess = nodePty.spawn('node', [executables.cli, ...args], { |
| 110 | + name: 'xterm-color', |
| 111 | + cols: 120, |
| 112 | + rows: 30, |
| 113 | + cwd: opts.cwd, |
| 114 | + env: spawnEnv, |
| 115 | + }) |
| 116 | + |
| 117 | + let output = '' |
| 118 | + const outputWaiters: {text: string; resolve: () => void; reject: (err: Error) => void}[] = [] |
| 119 | + |
| 120 | + ptyProcess.onData((data: string) => { |
| 121 | + output += data |
| 122 | + if (process.env.DEBUG === '1') { |
| 123 | + process.stdout.write(data) |
| 124 | + } |
| 125 | + |
| 126 | + // Check if any waiters are satisfied (check both raw and stripped output) |
| 127 | + const stripped = stripAnsi(output) |
| 128 | + for (let idx = outputWaiters.length - 1; idx >= 0; idx--) { |
| 129 | + const waiter = outputWaiters[idx] |
| 130 | + if (waiter && (stripped.includes(waiter.text) || output.includes(waiter.text))) { |
| 131 | + waiter.resolve() |
| 132 | + outputWaiters.splice(idx, 1) |
| 133 | + } |
| 134 | + } |
| 135 | + }) |
| 136 | + |
| 137 | + let exitCode: number | undefined |
| 138 | + let exitResolve: ((code: number) => void) | undefined |
| 139 | + |
| 140 | + ptyProcess.onExit(({exitCode: code}) => { |
| 141 | + exitCode = code |
| 142 | + if (exitResolve) { |
| 143 | + exitResolve(code) |
| 144 | + } |
| 145 | + // Reject any remaining output waiters |
| 146 | + for (const waiter of outputWaiters) { |
| 147 | + waiter.reject(new Error(`Process exited (code ${code}) while waiting for output: "${waiter.text}"`)) |
| 148 | + } |
| 149 | + outputWaiters.length = 0 |
| 150 | + }) |
| 151 | + |
| 152 | + const spawned: SpawnedProcess = { |
| 153 | + ptyProcess, |
| 154 | + |
| 155 | + waitForOutput(text: string, timeoutMs = 3 * 60 * 1000) { |
| 156 | + // Check if already in output (raw or stripped) |
| 157 | + if (stripAnsi(output).includes(text) || output.includes(text)) { |
| 158 | + return Promise.resolve() |
| 159 | + } |
| 160 | + |
| 161 | + return new Promise<void>((resolve, reject) => { |
| 162 | + const timer = setTimeout(() => { |
| 163 | + const waiterIdx = outputWaiters.findIndex((waiter) => waiter.text === text) |
| 164 | + if (waiterIdx >= 0) outputWaiters.splice(waiterIdx, 1) |
| 165 | + reject( |
| 166 | + new Error( |
| 167 | + `Timed out after ${timeoutMs}ms waiting for output: "${text}"\n\nCaptured output:\n${stripAnsi( |
| 168 | + output, |
| 169 | + )}`, |
| 170 | + ), |
| 171 | + ) |
| 172 | + }, timeoutMs) |
| 173 | + |
| 174 | + outputWaiters.push({ |
| 175 | + text, |
| 176 | + resolve: () => { |
| 177 | + clearTimeout(timer) |
| 178 | + resolve() |
| 179 | + }, |
| 180 | + reject: (err) => { |
| 181 | + clearTimeout(timer) |
| 182 | + reject(err) |
| 183 | + }, |
| 184 | + }) |
| 185 | + }) |
| 186 | + }, |
| 187 | + |
| 188 | + sendKey(key: string) { |
| 189 | + ptyProcess.write(key) |
| 190 | + }, |
| 191 | + |
| 192 | + sendLine(line: string) { |
| 193 | + ptyProcess.write(`${line}\r`) |
| 194 | + }, |
| 195 | + |
| 196 | + waitForExit(timeoutMs = 60 * 1000) { |
| 197 | + if (exitCode !== undefined) { |
| 198 | + return Promise.resolve(exitCode) |
| 199 | + } |
| 200 | + |
| 201 | + return new Promise<number>((resolve, reject) => { |
| 202 | + const timer = setTimeout(() => { |
| 203 | + reject(new Error(`Timed out after ${timeoutMs}ms waiting for process exit`)) |
| 204 | + }, timeoutMs) |
| 205 | + |
| 206 | + exitResolve = (code) => { |
| 207 | + clearTimeout(timer) |
| 208 | + resolve(code) |
| 209 | + } |
| 210 | + }) |
| 211 | + }, |
| 212 | + |
| 213 | + kill() { |
| 214 | + try { |
| 215 | + ptyProcess.kill() |
| 216 | + // eslint-disable-next-line no-catch-all/no-catch-all |
| 217 | + } catch (_error) { |
| 218 | + // Process may already be dead |
| 219 | + } |
| 220 | + }, |
| 221 | + |
| 222 | + getOutput() { |
| 223 | + return stripAnsi(output) |
| 224 | + }, |
| 225 | + } |
| 226 | + |
| 227 | + spawnedProcesses.push(spawned) |
| 228 | + return spawned |
| 229 | + }, |
| 230 | + } |
| 231 | + |
| 232 | + await use(cli) |
| 233 | + |
| 234 | + // Teardown: kill all spawned processes |
| 235 | + for (const proc of spawnedProcesses) { |
| 236 | + proc.kill() |
| 237 | + } |
| 238 | + }, |
| 239 | +}) |
| 240 | + |
| 241 | +export {type E2EEnv} |
0 commit comments