Files
fission-ai__openspec/test/utils/interactive.test.ts
Clay Good 9ae75c86ef fix(archive): don't write ANSI escape codes to a redirected (non-TTY) stdout (#1603)
* fix(archive): stop non-TTY confirm prompts from writing ANSI escapes to stdout

`openspec archive` asks up to three yes/no questions through @inquirer's
`confirm`, which renders by writing ANSI cursor-movement escape sequences —
and emits them even when stdout is not a TTY. When archive runs with its
output captured to a file or pipe (an agent's background task, CI), those
escapes are noise, and in some non-TTY hosts the render loop never settles
and repeats `ESC[NNG` moves until the disk fills (reporter hit 19.8 GB).

Add `confirmPrompt` in interactive.ts: a real terminal (stdin AND stdout
TTY) still gets @inquirer's rich prompt; every other case reads one plain
line via node:readline with `terminal:false`, emitting no escapes. Parsing
mirrors @inquirer/confirm exactly (prefix match on y/yes and n/no, else the
default), and an unreadable stdin rejects with an ExitPromptError-shaped
error so the existing #1479 "rerun with --yes" guidance is unchanged.
archive's confirmOrBlock now calls confirmPrompt.

Closes #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(interactive): cover Windows CRLF and drained-stdin paths; doc note

Adds two regression tests surfaced by adversarial review of the #1526 fix:
- Windows CRLF piped input (`y\r\n`) parses as a clean yes with no ANSI —
  the reporter's platform, previously untested (all inputs used `\n`).
- A second prompt after stdin was already drained blocks with an
  ExitPromptError instead of hanging, exercising the readableEnded guard.

Also documents in troubleshooting.md that a redirected/agent archive run
that pipes an answer no longer writes terminal escape codes into the capture.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): align non-interactive classification and handle readline errors

Addresses two review findings on the #1526 confirm-prompt fix:

- confirmPrompt drops to the plain reader whenever either stream is not a
  TTY, but isNonInteractivePromptError only checked stdin. A stdin-TTY /
  stdout-redirected run that hit EOF leaked the raw ExitPromptError instead
  of the #1479 "rerun with --yes" guidance. Classification now also counts a
  redirected stdout, matching how the prompt mode is chosen. (isInteractive,
  used broadly elsewhere, is left untouched.)

- readYesNo never listened for the readline/input 'error' event, so a stdin
  error would hang the promise (and go unhandled). It now settles with the
  underlying fault, guarded so the promise resolves or rejects exactly once.

Tests: TTY-stdin/redirected-stdout EOF is classified non-interactive; an
erroring input stream rejects instead of hanging; the archive usable-terminal
test now models a full terminal (both streams TTY).

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(archive): gate the change picker on a TTY and tidy the reader

Follow-ups from a second review round:

- selectChange (the no-argument change picker) called @inquirer's `select`
  unconditionally. `select` writes ANSI escapes to stdout even when redirected
  — the same #1526 mechanism the confirm prompts were fixed for — so
  `openspec archive > log.txt` with no change name still spewed cursor moves
  into the capture before blocking. Refuse before rendering when either stream
  is not a TTY, with the same "pass a change name / --yes" guidance the caught
  ExitPromptError already gives. A new test asserts the picker is never
  reached in a non-terminal run.

- readYesNo now removes its input-stream 'error' listener on every settle path
  (it lives on the long-lived process.stdin) and closes the readline interface
  on error too, so nothing accumulates across archive's sequential prompts.

- troubleshooting.md now notes the picker also stays clean.

Refs #1526

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(changeset): add patch changeset for the archive non-TTY fix (#1526)

User-facing patch note for the archive ANSI/disk-fill fix. Also drops an
unnecessary optional-chain on the non-nullable readline handle in readYesNo
(the listener is only attached after the interface exists).

Refs #1526
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:53:41 +00:00

386 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Readable, Writable } from 'node:stream';
import {
confirmPrompt,
isInteractive,
isNonInteractivePromptError,
resolveNoInteractive,
InteractiveOptions,
} from '../../src/utils/interactive.js';
describe('interactive utilities', () => {
let originalOpenSpecInteractive: string | undefined;
let originalCI: string | undefined;
let originalStdinIsTTY: boolean | undefined;
let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
// Save original environment
originalOpenSpecInteractive = process.env.OPEN_SPEC_INTERACTIVE;
originalCI = process.env.CI;
originalStdinIsTTY = process.stdin.isTTY;
originalStdoutIsTTY = process.stdout.isTTY;
// Clear environment for clean testing
delete process.env.OPEN_SPEC_INTERACTIVE;
delete process.env.CI;
});
afterEach(() => {
// Restore original environment
if (originalOpenSpecInteractive !== undefined) {
process.env.OPEN_SPEC_INTERACTIVE = originalOpenSpecInteractive;
} else {
delete process.env.OPEN_SPEC_INTERACTIVE;
}
if (originalCI !== undefined) {
process.env.CI = originalCI;
} else {
delete process.env.CI;
}
// Restore stdin.isTTY
Object.defineProperty(process.stdin, 'isTTY', {
value: originalStdinIsTTY,
writable: true,
configurable: true,
});
// Restore stdout.isTTY
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
writable: true,
configurable: true,
});
});
describe('resolveNoInteractive', () => {
it('should return true when noInteractive is true', () => {
expect(resolveNoInteractive({ noInteractive: true })).toBe(true);
});
it('should return true when interactive is false (Commander.js style)', () => {
// This is how Commander.js handles --no-interactive flag
expect(resolveNoInteractive({ interactive: false })).toBe(true);
});
it('should return false when noInteractive is false', () => {
expect(resolveNoInteractive({ noInteractive: false })).toBe(false);
});
it('should return false when interactive is true', () => {
expect(resolveNoInteractive({ interactive: true })).toBe(false);
});
it('should return false for empty options object', () => {
expect(resolveNoInteractive({})).toBe(false);
});
it('should return false for undefined', () => {
expect(resolveNoInteractive(undefined)).toBe(false);
});
it('should handle boolean value true', () => {
expect(resolveNoInteractive(true)).toBe(true);
});
it('should handle boolean value false', () => {
expect(resolveNoInteractive(false)).toBe(false);
});
it('should prioritize noInteractive over interactive when both set', () => {
// noInteractive: true should win
expect(resolveNoInteractive({ noInteractive: true, interactive: true })).toBe(true);
// If noInteractive is false, check interactive
expect(resolveNoInteractive({ noInteractive: false, interactive: false })).toBe(true);
});
});
describe('isInteractive', () => {
it('should return false when noInteractive is true', () => {
expect(isInteractive({ noInteractive: true })).toBe(false);
});
it('should return false when interactive is false (Commander.js --no-interactive)', () => {
expect(isInteractive({ interactive: false })).toBe(false);
});
it('should return false when OPEN_SPEC_INTERACTIVE env var is 0', () => {
process.env.OPEN_SPEC_INTERACTIVE = '0';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when CI env var is set', () => {
process.env.CI = 'true';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when CI env var is set to any value', () => {
// CI can be set to any value, not just "true"
process.env.CI = '1';
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return false when stdin is not a TTY', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
expect(isInteractive({})).toBe(false);
});
it('should return true when stdin is TTY and no flags disable it', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive({})).toBe(true);
});
it('should return true when stdin is TTY and options are undefined', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true });
expect(isInteractive(undefined)).toBe(true);
});
});
describe('isNonInteractivePromptError', () => {
function setStdinIsTTY(value: boolean): void {
Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true });
}
function setStdoutIsTTY(value: boolean): void {
Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true });
}
function exitPromptError(message: string): Error {
const error = new Error(message);
error.name = 'ExitPromptError';
return error;
}
it('recognizes a prompt that failed with no terminal to answer it', () => {
setStdinIsTTY(false);
expect(
isNonInteractivePromptError(exitPromptError('User force closed the prompt with 0 null'))
).toBe(true);
});
it('recognizes the failure by name alone', () => {
// An @inquirer upgrade may reword the message; the error class is the
// other half of the signal and must stand on its own.
setStdinIsTTY(false);
expect(isNonInteractivePromptError(exitPromptError('prompt closed'))).toBe(true);
});
it('recognizes the failure by message alone', () => {
// ...and vice versa, if the class is ever renamed or duplicated by a
// bundled copy of the library.
setStdinIsTTY(false);
const plain = new Error('User force closed the prompt with 0 null');
expect(isNonInteractivePromptError(plain)).toBe(true);
});
it('treats a SIGINT cancellation as a cancellation, terminal or not', () => {
const sigint = exitPromptError('User force closed the prompt with SIGINT');
setStdinIsTTY(true);
expect(isNonInteractivePromptError(sigint)).toBe(false);
// A script started from a terminal has a piped stdin and still receives
// Ctrl-C: the signal, not the terminal, proves the user was there.
setStdinIsTTY(false);
expect(isNonInteractivePromptError(sigint)).toBe(false);
});
it('honors the same non-interactive signals as isInteractive()', () => {
const failure = exitPromptError('User force closed the prompt with 0 null');
// A full terminal: both streams are TTYs, so nobody-can-answer is false.
setStdinIsTTY(true);
setStdoutIsTTY(true);
expect(isNonInteractivePromptError(failure)).toBe(false);
process.env.CI = 'true';
expect(isNonInteractivePromptError(failure)).toBe(true);
delete process.env.CI;
process.env.OPEN_SPEC_INTERACTIVE = '0';
expect(isNonInteractivePromptError(failure)).toBe(true);
delete process.env.OPEN_SPEC_INTERACTIVE;
expect(isNonInteractivePromptError(failure, { interactive: false })).toBe(true);
});
it('classifies EOF as non-interactive when stdout is redirected, even with a TTY stdin', () => {
// confirmPrompt drops to the plain reader whenever either stream is not a
// TTY. A stdin-TTY-but-stdout-redirected run that reaches EOF must be
// classified the same way it was prompted, so the caller gets the #1479
// "rerun with --yes" guidance instead of a raw ExitPromptError.
setStdinIsTTY(true);
setStdoutIsTTY(false);
expect(
isNonInteractivePromptError(exitPromptError('User force closed the prompt with 0 null'))
).toBe(true);
});
it('ignores unrelated failures', () => {
setStdinIsTTY(false);
expect(isNonInteractivePromptError(new Error('disk full'))).toBe(false);
expect(isNonInteractivePromptError('not an error')).toBe(false);
expect(isNonInteractivePromptError(undefined)).toBe(false);
});
});
describe('confirmPrompt (non-TTY reader, #1526)', () => {
// A writable that keeps every byte written, so a test can assert the exact
// bytes an archive run would append to a redirected log.
function captureOutput(): Writable & { text: () => string } {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
}) as Writable & { text: () => string };
stream.text = () => chunks.join('');
return stream;
}
// A non-TTY input carrying `data`, or an already-ended stream for the EOF case.
function pipedInput(data: string | null): Readable {
return Readable.from(data === null ? [] : [data]);
}
it('reads a piped "y" without emitting any ANSI escape sequences', async () => {
const output = captureOutput();
const result = await confirmPrompt(
{ message: 'Continue?', default: false },
{ input: pipedInput('y\n'), output }
);
expect(result).toBe(true);
// The disk-fill bug was @inquirer writing cursor-move escapes to a
// non-TTY stdout; the plain reader must write none.
expect(output.text()).not.toContain('\u001b'); // no ANSI escape byte
expect(output.text()).toBe('Continue? (y/N) \n');
});
it('reads a piped "n" as false', async () => {
const output = captureOutput();
const result = await confirmPrompt(
{ message: 'Continue?', default: true },
{ input: pipedInput('n\n'), output }
);
expect(result).toBe(false);
expect(output.text()).not.toContain('\u001b'); // no ANSI escape byte
});
it('accepts long forms (yes/no), case-insensitively and trimmed', async () => {
const yes = await confirmPrompt(
{ message: 'Q?', default: false },
{ input: pipedInput(' YES \n'), output: captureOutput() }
);
const no = await confirmPrompt(
{ message: 'Q?', default: true },
{ input: pipedInput('No\n'), output: captureOutput() }
);
expect(yes).toBe(true);
expect(no).toBe(false);
});
it('reads Windows CRLF-terminated input (y\\r\\n) as a clean yes', async () => {
// The bug was reported on Windows, where a piped answer often arrives as
// `y\r\n`. readline splits on \n, leaving `y\r`; trim() drops the \r.
const output = captureOutput();
const result = await confirmPrompt(
{ message: 'Continue?', default: false },
{ input: pipedInput('y\r\n'), output }
);
expect(result).toBe(true);
expect(output.text()).not.toContain('');
});
it('matches @inquirer prefix parsing (yeah/nope), preserving old behavior', async () => {
// @inquirer/confirm parses with /^(y|yes)/i and /^(n|no)/i. On the
// spec-update prompt (default true), a prefix-`n` answer must stay false
// rather than fall through to the default and write specs anyway.
const yeah = await confirmPrompt(
{ message: 'Q?', default: false },
{ input: pipedInput('yeah\n'), output: captureOutput() }
);
const nope = await confirmPrompt(
{ message: 'Q?', default: true },
{ input: pipedInput('nope\n'), output: captureOutput() }
);
expect(yeah).toBe(true);
expect(nope).toBe(false);
});
it('falls back to the default on an empty or unrecognized answer', async () => {
const emptyTrue = await confirmPrompt(
{ message: 'Q?', default: true },
{ input: pipedInput('\n'), output: captureOutput() }
);
const emptyFalse = await confirmPrompt(
{ message: 'Q?', default: false },
{ input: pipedInput('\n'), output: captureOutput() }
);
const garbage = await confirmPrompt(
{ message: 'Q?', default: true },
{ input: pipedInput('maybe\n'), output: captureOutput() }
);
expect(emptyTrue).toBe(true);
expect(emptyFalse).toBe(false);
expect(garbage).toBe(true);
});
it('blocks (does not hang) on a second prompt after stdin was already drained', async () => {
// archive asks up to three questions; only one piped answer was ever
// supported. The first read drains stdin, so a later prompt must reject
// promptly (→ #1479 "rerun with --yes") rather than hang and exit as a
// no-op. This exercises the input.readableEnded guard.
const input = pipedInput('y\n');
const first = await confirmPrompt(
{ message: 'First?', default: false },
{ input, output: captureOutput() }
);
expect(first).toBe(true);
await expect(
confirmPrompt(
{ message: 'Second?', default: false },
{ input, output: captureOutput() }
)
).rejects.toMatchObject({ name: 'ExitPromptError' });
});
it('rejects with an ExitPromptError when no answer can be read (EOF)', async () => {
// This is the seam the #1479 guidance hangs off: confirmOrBlock catches it
// via isNonInteractivePromptError and tells the user to rerun with --yes.
await expect(
confirmPrompt(
{ message: 'Continue?', default: false },
{ input: pipedInput(null), output: captureOutput() }
)
).rejects.toMatchObject({ name: 'ExitPromptError' });
});
it('produces an error isNonInteractivePromptError classifies as non-interactive', async () => {
Object.defineProperty(process.stdin, 'isTTY', { value: false, writable: true, configurable: true });
const error = await confirmPrompt(
{ message: 'Continue?', default: false },
{ input: pipedInput(null), output: captureOutput() }
).catch((e) => e);
expect(isNonInteractivePromptError(error)).toBe(true);
});
it('settles (does not hang) when the input stream errors', async () => {
// readline forwards an input-stream error to its 'error' event; without a
// handler the promise would hang and the error would go unhandled. It must
// reject with the underlying fault instead.
const input = new Readable({
read() {
this.destroy(new Error('stdin exploded'));
},
});
await expect(
confirmPrompt(
{ message: 'Continue?', default: false },
{ input, output: captureOutput() }
)
).rejects.toThrow('stdin exploded');
});
});
});