-
Notifications
You must be signed in to change notification settings - Fork 3
test(cli): drain PTY stdout before resolving E2E exit (fixes flaky help test)
#537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+217
−13
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
db98dfc
test(cli): drain PTY stdout before resolving E2E `exit`
tobyhede c88d9d2
fix(cli): capture non-interactive E2E output over pipes, not a PTY
tobyhede aedfd9d
fix(cli): don't mask signal-terminated child as exit 0 in run() helper
tobyhede df71ed1
test(cli): close run() coverage gaps from auxesis review
tobyhede File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { buildRunResult } from './run.js' | ||
|
|
||
| // Unit coverage for the pure `close`-event → RunResult mapping extracted | ||
| // from `run()`. `run()` itself always spawns the built CLI binary and | ||
| // exposes no handle to signal the child, which makes the signal-terminated | ||
| // path (the headline fix in aedfd9d) impractical to exercise deterministically | ||
| // through an E2E test. Testing the extracted pure function directly avoids | ||
| // needing a timeout/SIGKILL escape hatch or other API surface change. | ||
| describe('buildRunResult', () => { | ||
| it('reports a clean exit', () => { | ||
| const r = buildRunResult(0, null, 'hello\n', '') | ||
| expect(r.exitCode).toBe(0) | ||
| expect(r.signal).toBeNull() | ||
| expect(r.stdout).toBe('hello\n') | ||
| expect(r.output).toBe('hello\n') | ||
| }) | ||
|
|
||
| it('reports a non-zero exit code', () => { | ||
| const r = buildRunResult(1, null, '', 'boom\n') | ||
| expect(r.exitCode).toBe(1) | ||
| expect(r.signal).toBeNull() | ||
| expect(r.stderr).toBe('boom\n') | ||
| }) | ||
|
|
||
| it('keeps exitCode null (never masked to 0) when the child is signal-terminated', () => { | ||
| // Node guarantees exactly one of code/signal is non-null on 'close'. A | ||
| // naive `code ?? 0` would collapse this to a "successful" exit 0, hiding | ||
| // a crash/OOM/SIGTERM kill from callers. | ||
| const r = buildRunResult(null, 'SIGTERM', '', '') | ||
| expect(r.exitCode).toBeNull() | ||
| expect(r.signal).toBe('SIGTERM') | ||
| }) | ||
|
|
||
| it('strips ANSI codes independently from stdout/stderr while combining into output/raw', () => { | ||
| // Build the ESC control byte at runtime (rather than embedding it | ||
| // literally in the source) to keep this file free of non-printable | ||
| // characters. | ||
| const ESC = String.fromCharCode(27) | ||
| const green = `${ESC}[32mok${ESC}[0m` | ||
| const red = `${ESC}[31merr${ESC}[0m` | ||
| const r = buildRunResult(0, null, green, red) | ||
| expect(r.stdout).toBe('ok') | ||
| expect(r.stderr).toBe('err') | ||
| expect(r.output).toBe('okerr') | ||
| expect(r.raw).toBe(green + red) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { spawn } from 'node:child_process' | ||
| import stripAnsi from 'strip-ansi' | ||
| import { STASH_BIN } from './pty.js' | ||
|
|
||
| export interface RunOptions { | ||
| cwd?: string | ||
| env?: Record<string, string> | ||
| } | ||
|
|
||
| export interface RunResult { | ||
| /** | ||
| * The process's numeric exit code, or `null` when it was terminated by a | ||
| * signal (see {@link RunResult.signal}). Never coerced to 0, so a | ||
| * signal-killed child (crash, SIGKILL) is distinguishable from a clean exit. | ||
| */ | ||
| exitCode: number | null | ||
| signal: NodeJS.Signals | null | ||
| /** ANSI-stripped combined stdout + stderr. */ | ||
| output: string | ||
| /** Raw combined stdout + stderr including ANSI escapes. */ | ||
| raw: string | ||
| stdout: string | ||
| stderr: string | ||
| } | ||
|
|
||
| /** | ||
| * Run the built CLI to completion over plain pipes and capture all output. | ||
| * | ||
| * Prefer this over {@link render} for NON-INTERACTIVE commands (`--help`, | ||
| * `--version`, usage/error paths). A pipe buffers the child's output in the | ||
| * Node process and delivers every byte before `close`, so large bursts are | ||
| * never truncated. | ||
| * | ||
| * A PTY, by contrast, loses data on Linux: when the child writes a big burst | ||
| * (the ~5KB `--help` text is a single `console.log`) and exits immediately, | ||
| * the kernel discards whatever is still unread in the pty buffer when the | ||
| * slave closes. On a loaded CI runner node-pty can't drain fast enough, so the | ||
| * tail of the help — the Examples section — goes missing and assertions on it | ||
| * flake. (macOS ptys keep buffered data on close, which is why it only bites | ||
| * in CI.) Post-exit "drain" waits can't help because the bytes are gone before | ||
| * `onData` ever fires. Only interactive tests that must send keystrokes or | ||
| * exercise TTY-specific rendering need {@link render}. | ||
| */ | ||
| export function run(args: string[], opts: RunOptions = {}): Promise<RunResult> { | ||
| const env: Record<string, string> = { | ||
| ...(process.env as Record<string, string>), | ||
| // Mirror render()'s env so both helpers hit the same CLI code paths. | ||
| NO_COLOR: '1', | ||
| FORCE_COLOR: '0', | ||
| CI: 'true', | ||
| ...(opts.env ?? {}), | ||
| } | ||
|
|
||
| const child = spawn(process.execPath, [STASH_BIN, ...args], { | ||
| cwd: opts.cwd ?? process.cwd(), | ||
| env, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| }) | ||
|
|
||
| let stdout = '' | ||
| let stderr = '' | ||
| child.stdout.setEncoding('utf8') | ||
| child.stderr.setEncoding('utf8') | ||
| child.stdout.on('data', (d: string) => { | ||
| stdout += d | ||
| }) | ||
| child.stderr.on('data', (d: string) => { | ||
| stderr += d | ||
| }) | ||
|
|
||
| return new Promise<RunResult>((res, rej) => { | ||
| child.on('error', rej) | ||
| child.on('close', (code, signal) => { | ||
| res(buildRunResult(code, signal, stdout, stderr)) | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Pure `close`-event → {@link RunResult} mapping, factored out of `run()` so | ||
| * the exit-code/signal handling can be unit-tested directly without spawning | ||
| * a real child process (see `run.test.ts`). Node guarantees exactly one of | ||
| * `code`/`signal` is non-null on `'close'` — this must never coerce a null | ||
| * `code` to `0`, or a signal-terminated child (crash, SIGKILL, OOM) would be | ||
| * misreported as a clean exit. | ||
| */ | ||
| export function buildRunResult( | ||
| code: number | null, | ||
| signal: NodeJS.Signals | null, | ||
| stdout: string, | ||
| stderr: string, | ||
| ): RunResult { | ||
| const raw = stdout + stderr | ||
| return { | ||
| exitCode: code, | ||
| signal, | ||
| output: stripAnsi(raw), | ||
| raw, | ||
| stdout: stripAnsi(stdout), | ||
| stderr: stripAnsi(stderr), | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.