Testing prompts
Drive ask() and resolvePrompts() with a scripted reader and writer — no PTY, no fake stdin — and assert both the answers and exactly what was written.
Nothing above caique/terminal touches a stream: ask() takes a reader of lines and a writer
of text. A test hands it both and asserts the conversation.
import assert from 'node:assert/strict';
import { ask } from 'caique/ask';
function conversation(answers) {
const lines = [...answers];
let written = '';
return {
io: { reader: { line: async () => lines.shift() }, writer: { write: (text) => void (written += text) } },
written: () => written,
};
}
const port = conversation(['eighty', '80']);
const answer = await ask({ kind: 'text', message: 'Port?', validate: (v) => (/^\d+$/u.test(v) ? undefined : 'a port is a number') }, port.io);
assert.deepEqual(answer, { ok: true, value: '80' });
assert.equal(port.written(), 'Port? a port is a number\nPort? ');
const closed = conversation([]);
assert.deepEqual(await ask({ kind: 'confirm', message: 'Continue?' }, closed.io), { ok: false, reason: 'cancelled' });
console.log('ok');okThe second case is the one that matters most: a stream that ends — Ctrl-D, a closed pipe, an
empty script — is a cancellation, never an empty answer. decide() needs no stream at all; it
is a pure function of a literal runtime (Never hangs).
Confirmations in CI
Let --yes answer a destructive confirm in CI and scripts, while a required text or select with no value still refuses — --yes never invents an answer to a question with more than two.
Incremental migration
Move from @inquirer/core with one import, from @clack/prompts with one import or a prompt at a time, and then to flag-first prompts where an agent or CI runs the command.