@inquirer/testing 3.0.4 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,13 +27,20 @@ yarn add @inquirer/testing --dev
27
27
  </tr>
28
28
  </table>
29
29
 
30
- # Example
30
+ # Usage
31
+
32
+ This package provides two ways to test Inquirer prompts:
33
+
34
+ 1. **Unit testing** with `render()` - Test individual prompts in isolation
35
+ 2. **E2E testing** with `screen` - Test full CLI applications that use Inquirer
36
+
37
+ ## Unit Testing with `render()`
31
38
 
32
- Here's an example of a test running with Jest (though `@inquirer/testing` will work with any runners).
39
+ The `render()` function creates and instruments a command line interface for testing a single prompt.
33
40
 
34
41
  ```ts
35
42
  import { render } from '@inquirer/testing';
36
- import input from './src/index.mjs';
43
+ import input from '@inquirer/input';
37
44
 
38
45
  describe('input prompt', () => {
39
46
  it('handle simple use case', async () => {
@@ -48,7 +55,6 @@ describe('input prompt', () => {
48
55
 
49
56
  events.type('ohn');
50
57
  events.keypress('enter');
51
- // or events.keypress({ name: 'enter' })
52
58
 
53
59
  await expect(answer).resolves.toEqual('John');
54
60
  expect(getScreen()).toMatchInlineSnapshot(`"? What is your name John"`);
@@ -56,23 +62,132 @@ describe('input prompt', () => {
56
62
  });
57
63
  ```
58
64
 
59
- # Usage
60
-
61
- The core utility of `@inquirer/testing` is the `render()` function. This `render` function will create and instrument a command line like interface.
65
+ ### `render()` API
62
66
 
63
67
  `render` takes 2 arguments:
64
68
 
65
69
  1. The Inquirer prompt to test (the return value of `createPrompt()`)
66
70
  2. The prompt configuration (the first prompt argument)
67
71
 
68
- `render` then returns a promise that will resolve once the prompt is rendered and the test environment up and running. This promise returns the utilities we'll use to interact with our tests:
72
+ `render` returns a promise that resolves once the prompt is rendered. This promise returns:
73
+
74
+ - `answer` (`Promise`) - Resolves when an answer is provided and valid
75
+ - `getScreen` (`({ raw?: boolean }) => string`) - Returns the current screen content. By default strips ANSI codes
76
+ - `events` - Utilities to interact with the prompt:
77
+ - `keypress(key: string | KeyObject)` - Trigger a keypress event
78
+ - `type(text: string)` - Type text into the prompt
79
+ - `getFullOutput` (`() => Promise<string>`) - Returns the full output interpreted through a virtual terminal, resolving ANSI escape sequences into the actual screen state
80
+
81
+ ### Unit Testing Example
82
+
83
+ You can refer to the [`@inquirer/input` test suite](https://github.com/SBoudrias/Inquirer.js/blob/main/packages/input/input.test.ts) for a comprehensive unit testing example using `render()`.
84
+
85
+ ## E2E Testing with `screen`
86
+
87
+ For testing full CLI applications that use Inquirer prompts internally, use the framework-specific entry points:
88
+
89
+ ### Vitest
90
+
91
+ ```ts
92
+ import { describe, it, expect } from 'vitest';
93
+ import { screen } from '@inquirer/testing/vitest';
94
+
95
+ // Import your CLI AFTER @inquirer/testing/vitest
96
+ import { runMyCli } from './my-cli.js';
97
+
98
+ describe('my CLI', () => {
99
+ it('asks for name and confirms', async () => {
100
+ const result = runMyCli();
101
+
102
+ // First prompt is immediately available
103
+ expect(screen.getScreen()).toContain('What is your name?');
104
+ screen.type('John');
105
+ screen.keypress('enter');
106
+
107
+ // Wait for next prompt
108
+ await screen.next();
109
+ expect(screen.getScreen()).toContain('Confirm?');
110
+ screen.keypress('enter');
111
+
112
+ await result;
113
+ });
114
+ });
115
+ ```
116
+
117
+ ### Jest
118
+
119
+ ```ts
120
+ import { screen } from '@inquirer/testing/jest';
121
+ import { runMyCli } from './my-cli.js';
122
+
123
+ describe('my CLI', () => {
124
+ it('asks for name and confirms', async () => {
125
+ const result = runMyCli();
126
+
127
+ // First prompt is immediately available
128
+ expect(screen.getScreen()).toContain('What is your name?');
129
+ screen.type('John');
130
+ screen.keypress('enter');
131
+
132
+ // Wait for next prompt
133
+ await screen.next();
134
+ expect(screen.getScreen()).toContain('Confirm?');
135
+ screen.keypress('enter');
136
+
137
+ await result;
138
+ });
139
+ });
140
+ ```
141
+
142
+ ### `screen` API
143
+
144
+ The `screen` object provides:
145
+
146
+ - `next()` - Wait for the next screen update (prompt transitions, validation errors, async updates). The initial prompt render is available immediately via `getScreen()` — no `next()` needed
147
+ - `getScreen({ raw?: boolean })` - Get the current prompt screen content. By default strips ANSI codes
148
+ - `getFullOutput({ raw?: boolean })` - Get all accumulated output interpreted through a virtual terminal (returns a `Promise`). By default resolves ANSI escape sequences into actual screen state
149
+ - `type(text)` - Type text (writes to stream AND emits keypresses)
150
+ - `keypress(key)` - Send a keypress event
151
+ - `clear()` - Reset screen state (called automatically before each test)
152
+
153
+ ### Mocking Third-Party Prompts
154
+
155
+ All `@inquirer/*` prompts are mocked automatically. To mock a third-party or custom prompt package, use `wrapPrompt` in your own mock call:
156
+
157
+ #### Vitest
158
+
159
+ ```ts
160
+ import { screen, wrapPrompt } from '@inquirer/testing/vitest';
161
+
162
+ vi.mock('@my-company/custom-prompt', async (importOriginal) => {
163
+ const actual = await importOriginal<typeof import('@my-company/custom-prompt')>();
164
+ return { ...actual, default: wrapPrompt(actual.default) };
165
+ });
166
+ ```
167
+
168
+ #### Jest
169
+
170
+ In Jest, `jest.mock()` factories are hoisted before imports, so `wrapPrompt` must be accessed via `jest.requireActual()` inside the factory:
171
+
172
+ ```ts
173
+ import { screen } from '@inquirer/testing/jest';
174
+
175
+ jest.mock('@my-company/custom-prompt', () => {
176
+ const { wrapPrompt } = jest.requireActual('@inquirer/testing/jest');
177
+ const actual = jest.requireActual('@my-company/custom-prompt');
178
+ return { ...actual, default: wrapPrompt(actual.default) };
179
+ });
180
+ ```
181
+
182
+ ### Important Notes
183
+
184
+ 1. **Import order matters**: Import `@inquirer/testing/vitest` or `@inquirer/testing/jest` BEFORE importing modules that use Inquirer prompts
185
+ 2. **Editor prompt**: The external editor is mocked — `screen.type()` buffers text, and `screen.keypress('enter')` submits it (same pattern as other prompts). Works with both `waitForUserInput: true` and `false`
186
+ 3. **Sequential prompts**: Multiple prompts are supported, but they must run sequentially (not concurrently)
69
187
 
70
- 1. `answer` (`Promise`) This is the promise that'll be resolved once an answer is provided and valid.
71
- 2. `getScreen` (`({ raw: boolean }) => string`) This function returns the state of what is printed on the command line screen at any given time. You can use its return value to validate your prompt is properly rendered. By default this function will strip the ANSI codes (used for colors.)
72
- 3. `events` (`{ keypress: (name | Key) => void, type: (string) => void }`) Is the utilities allowing you to interact with the prompt. Use it to trigger keypress events, or typing any input.
73
- 4. `getFullOutput` (`() => string`) Return a raw dump of everything that got sent on the output stream.
188
+ ### E2E Testing Example
74
189
 
75
- You can refer to [the `@inquirer/input` prompt test suite](https://github.com/SBoudrias/Inquirer.js/blob/main/packages/input/input.test.ts) as a practical example.
190
+ You can refer to the [`@inquirer/demo` test suite](https://github.com/SBoudrias/Inquirer.js/blob/main/packages/demo/demo.test.ts) for a comprehensive E2E testing example using `screen`.
76
191
 
77
192
  # License
78
193
 
@@ -0,0 +1,12 @@
1
+ import { Stream } from 'node:stream';
2
+ export declare class BufferedStream extends Stream.Writable {
3
+ #private;
4
+ columns: number;
5
+ get writeCount(): number;
6
+ _write(chunk: Buffer, _encoding: string, callback: () => void): void;
7
+ getLastChunk({ raw }?: {
8
+ raw?: boolean;
9
+ }): string;
10
+ getFullOutput(): string;
11
+ clear(): void;
12
+ }
@@ -0,0 +1,43 @@
1
+ import { Stream } from 'node:stream';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+ export class BufferedStream extends Stream.Writable {
4
+ // Expose a large column width so cli-width (used by @inquirer/core's breakLines)
5
+ // doesn't hard-wrap output at 80 columns. This prevents artificial line breaks
6
+ // that would break assertions like toContain() in tests.
7
+ columns = 10_000;
8
+ #fullOutput = '';
9
+ #chunks = [];
10
+ #rawChunks = [];
11
+ #writeCount = 0;
12
+ get writeCount() {
13
+ return this.#writeCount;
14
+ }
15
+ _write(chunk, _encoding, callback) {
16
+ const str = chunk.toString();
17
+ this.#fullOutput += str;
18
+ // Keep track of every chunk sent through.
19
+ this.#rawChunks.push(str);
20
+ // Stripping the ANSI codes here because Inquirer will push commands ANSI (like cursor move.)
21
+ // This is probably fine since we don't care about those for testing; but this could become
22
+ // an issue if we ever want to test for those.
23
+ if (stripVTControlCharacters(str).trim().length > 0) {
24
+ this.#chunks.push(str);
25
+ this.#writeCount++;
26
+ this.emit('render');
27
+ }
28
+ callback();
29
+ }
30
+ getLastChunk({ raw } = {}) {
31
+ const chunks = raw ? this.#rawChunks : this.#chunks;
32
+ const lastChunk = chunks.at(-1);
33
+ return lastChunk ?? '';
34
+ }
35
+ getFullOutput() {
36
+ return this.#fullOutput;
37
+ }
38
+ clear() {
39
+ this.#fullOutput = '';
40
+ this.#chunks = [];
41
+ this.#rawChunks = [];
42
+ }
43
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import MuteStream from 'mute-stream';
2
2
  import type { Prompt, Context } from '@inquirer/type';
3
- export declare function render<Value, const Config>(prompt: Prompt<Value, Config>, config: Config, options?: Context): Promise<{
3
+ type RenderOptions = Omit<Context, 'input' | 'output'>;
4
+ export declare function render<Value, const Config>(prompt: Prompt<Value, Config>, config: Config, options?: RenderOptions): Promise<{
4
5
  answer: Promise<Value>;
5
6
  input: MuteStream;
6
7
  events: {
@@ -15,5 +16,8 @@ export declare function render<Value, const Config>(prompt: Prompt<Value, Config
15
16
  getScreen: ({ raw }?: {
16
17
  raw?: boolean;
17
18
  }) => string;
18
- getFullOutput: () => string;
19
+ getFullOutput: ({ raw }?: {
20
+ raw?: boolean;
21
+ }) => Promise<string>;
19
22
  }>;
23
+ export {};
package/dist/index.js CHANGED
@@ -1,40 +1,19 @@
1
- import { Stream } from 'node:stream';
2
1
  import { stripVTControlCharacters } from 'node:util';
3
2
  import MuteStream from 'mute-stream';
4
- class BufferedStream extends Stream.Writable {
5
- #_fullOutput = '';
6
- #_chunks = [];
7
- #_rawChunks = [];
8
- _write(chunk, _encoding, callback) {
9
- const str = chunk.toString();
10
- this.#_fullOutput += str;
11
- // Keep track of every chunk send through.
12
- this.#_rawChunks.push(str);
13
- // Stripping the ANSI codes here because Inquirer will push commands ANSI (like cursor move.)
14
- // This is probably fine since we don't care about those for testing; but this could become
15
- // an issue if we ever want to test for those.
16
- if (stripVTControlCharacters(str).trim().length > 0) {
17
- this.#_chunks.push(str);
18
- }
19
- callback();
20
- }
21
- getLastChunk({ raw }) {
22
- const chunks = raw ? this.#_rawChunks : this.#_chunks;
23
- const lastChunk = chunks.at(-1);
24
- return lastChunk ?? '';
25
- }
26
- getFullOutput() {
27
- return this.#_fullOutput;
28
- }
29
- }
3
+ import { BufferedStream } from './buffered-stream.js';
4
+ import { interpretTerminalOutput } from './terminal.js';
30
5
  export async function render(prompt, config, options) {
31
6
  const input = new MuteStream();
32
7
  input.unmute();
33
8
  const output = new BufferedStream();
34
- const answer = prompt(config, { input, output, ...options });
35
- // Wait for event listeners to be ready
36
- await Promise.resolve();
37
- await Promise.resolve();
9
+ const firstRender = new Promise((resolve) => output.once('render', resolve));
10
+ const answer = prompt(config, { ...options, input, output });
11
+ // The first render is synchronous. If our BufferedStream received a write, we're ready.
12
+ if (output.writeCount === 0) {
13
+ // Our BufferedStream didn't receive a write yet. This happens when the prompt
14
+ // errored before rendering. Race against the answer promise to handle that case.
15
+ await Promise.race([firstRender, answer.catch(() => { })]);
16
+ }
38
17
  const events = {
39
18
  keypress(key) {
40
19
  if (typeof key === 'string') {
@@ -59,8 +38,11 @@ export async function render(prompt, config, options) {
59
38
  const lastScreen = output.getLastChunk({ raw: Boolean(raw) });
60
39
  return raw ? lastScreen : stripVTControlCharacters(lastScreen).trim();
61
40
  },
62
- getFullOutput: () => {
63
- return output.getFullOutput();
41
+ getFullOutput: async ({ raw } = {}) => {
42
+ const fullOutput = output.getFullOutput();
43
+ if (raw)
44
+ return fullOutput;
45
+ return interpretTerminalOutput(fullOutput);
64
46
  },
65
47
  };
66
48
  }
package/dist/jest.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Prompt } from '@inquirer/type';
2
+ import { Screen } from './screen.js';
3
+ declare const screenInstance: Screen;
4
+ export { screenInstance as screen };
5
+ /**
6
+ * Wrap a prompt function to use the shared screen I/O.
7
+ * Use this in your own `jest.mock()` calls to mock third-party prompts.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * jest.mock('@my-company/custom-prompt', () => {
12
+ * const { wrapPrompt } = jest.requireActual('@inquirer/testing/jest');
13
+ * const actual = jest.requireActual('@my-company/custom-prompt');
14
+ * return { ...actual, default: wrapPrompt(actual.default) };
15
+ * });
16
+ * ```
17
+ */
18
+ export declare function wrapPrompt<Value, Config>(prompt: Prompt<Value, Config>): Prompt<Value, Config>;
19
+ export { Screen, type KeypressEvent } from './screen.js';
package/dist/jest.js ADDED
@@ -0,0 +1,119 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */
2
+ // Note: Jest's requireActual returns `any` by design, unlike Vitest's typed importOriginal
3
+ import { Screen } from './screen.js';
4
+ // Global screen instance - exported for tests
5
+ const screenInstance = new Screen();
6
+ export { screenInstance as screen };
7
+ // Reset before each test (Jest's beforeEach)
8
+ beforeEach(() => {
9
+ screenInstance.clear();
10
+ });
11
+ /**
12
+ * Wrap a prompt function to use the shared screen I/O.
13
+ * Use this in your own `jest.mock()` calls to mock third-party prompts.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * jest.mock('@my-company/custom-prompt', () => {
18
+ * const { wrapPrompt } = jest.requireActual('@inquirer/testing/jest');
19
+ * const actual = jest.requireActual('@my-company/custom-prompt');
20
+ * return { ...actual, default: wrapPrompt(actual.default) };
21
+ * });
22
+ * ```
23
+ */
24
+ export function wrapPrompt(prompt) {
25
+ return (config, context) => {
26
+ const output = screenInstance.createOutput();
27
+ const promise = prompt(config, {
28
+ ...context,
29
+ input: screenInstance.input,
30
+ output,
31
+ });
32
+ screenInstance.setActivePromise(promise);
33
+ return promise;
34
+ };
35
+ }
36
+ // Mock individual prompt packages (covers `import input from '@inquirer/input'` style)
37
+ jest.mock('@inquirer/input', () => {
38
+ const actual = jest.requireActual('@inquirer/input');
39
+ return { ...actual, default: wrapPrompt(actual.default) };
40
+ });
41
+ jest.mock('@inquirer/select', () => {
42
+ const actual = jest.requireActual('@inquirer/select');
43
+ return { ...actual, default: wrapPrompt(actual.default) };
44
+ });
45
+ jest.mock('@inquirer/confirm', () => {
46
+ const actual = jest.requireActual('@inquirer/confirm');
47
+ return { ...actual, default: wrapPrompt(actual.default) };
48
+ });
49
+ jest.mock('@inquirer/checkbox', () => {
50
+ const actual = jest.requireActual('@inquirer/checkbox');
51
+ return { ...actual, default: wrapPrompt(actual.default) };
52
+ });
53
+ jest.mock('@inquirer/password', () => {
54
+ const actual = jest.requireActual('@inquirer/password');
55
+ return { ...actual, default: wrapPrompt(actual.default) };
56
+ });
57
+ jest.mock('@inquirer/expand', () => {
58
+ const actual = jest.requireActual('@inquirer/expand');
59
+ return { ...actual, default: wrapPrompt(actual.default) };
60
+ });
61
+ jest.mock('@inquirer/rawlist', () => {
62
+ const actual = jest.requireActual('@inquirer/rawlist');
63
+ return { ...actual, default: wrapPrompt(actual.default) };
64
+ });
65
+ jest.mock('@inquirer/number', () => {
66
+ const actual = jest.requireActual('@inquirer/number');
67
+ return { ...actual, default: wrapPrompt(actual.default) };
68
+ });
69
+ jest.mock('@inquirer/search', () => {
70
+ const actual = jest.requireActual('@inquirer/search');
71
+ return { ...actual, default: wrapPrompt(actual.default) };
72
+ });
73
+ jest.mock('@inquirer/editor', () => {
74
+ const actual = jest.requireActual('@inquirer/editor');
75
+ return { ...actual, default: wrapPrompt(actual.default) };
76
+ });
77
+ // Mock @inquirer/prompts barrel re-exports (covers `import { input } from '@inquirer/prompts'` style).
78
+ // Jest's module mock for individual packages doesn't propagate through barrel re-exports.
79
+ jest.mock('@inquirer/prompts', () => {
80
+ const actual = jest.requireActual('@inquirer/prompts');
81
+ return {
82
+ ...actual,
83
+ input: wrapPrompt(actual.input),
84
+ select: wrapPrompt(actual.select),
85
+ confirm: wrapPrompt(actual.confirm),
86
+ checkbox: wrapPrompt(actual.checkbox),
87
+ password: wrapPrompt(actual.password),
88
+ expand: wrapPrompt(actual.expand),
89
+ rawlist: wrapPrompt(actual.rawlist),
90
+ number: wrapPrompt(actual.number),
91
+ search: wrapPrompt(actual.search),
92
+ editor: wrapPrompt(actual.editor),
93
+ };
94
+ });
95
+ // Mock the external editor to capture typed input instead of spawning a real editor.
96
+ // Buffers all screen.type() calls and submits on screen.keypress('enter'), matching
97
+ // the interaction pattern of other prompts (type → enter).
98
+ jest.mock('@inquirer/external-editor', () => ({
99
+ editAsync: (_text, callback) => {
100
+ let buffer = '';
101
+ const typeSpy = jest
102
+ .spyOn(screenInstance, 'type')
103
+ .mockImplementation((text) => {
104
+ buffer += text;
105
+ });
106
+ const keypressSpy = jest
107
+ .spyOn(screenInstance, 'keypress')
108
+ .mockImplementation((key) => {
109
+ const name = typeof key === 'string' ? key : key.name;
110
+ if (name === 'enter' || name === 'return') {
111
+ typeSpy.mockRestore();
112
+ keypressSpy.mockRestore();
113
+ process.nextTick(() => callback(undefined, buffer));
114
+ }
115
+ });
116
+ },
117
+ }));
118
+ // Re-export Screen class and KeypressEvent type for advanced use cases
119
+ export { Screen } from './screen.js';
@@ -0,0 +1,35 @@
1
+ import MuteStream from 'mute-stream';
2
+ import { BufferedStream } from './buffered-stream.js';
3
+ export type KeypressEvent = {
4
+ name?: string;
5
+ ctrl?: boolean;
6
+ meta?: boolean;
7
+ shift?: boolean;
8
+ };
9
+ export declare class Screen {
10
+ #private;
11
+ constructor();
12
+ get input(): MuteStream;
13
+ createOutput(): BufferedStream;
14
+ setActivePromise(promise: Promise<unknown>): void;
15
+ /**
16
+ * Wait for the next screen update.
17
+ *
18
+ * Handles re-renders within the same prompt (e.g., validation errors,
19
+ * async updates) and prompt transitions in multi-prompt flows
20
+ * (automatically waits for the next prompt).
21
+ *
22
+ * Note: The initial prompt render is available immediately via getScreen()
23
+ * — no next() call is needed before reading it.
24
+ */
25
+ next(): Promise<void>;
26
+ getScreen({ raw }?: {
27
+ raw?: boolean;
28
+ }): string;
29
+ getFullOutput({ raw }?: {
30
+ raw?: boolean;
31
+ }): Promise<string>;
32
+ keypress(key: string | KeypressEvent): void;
33
+ type(text: string): void;
34
+ clear(): void;
35
+ }
package/dist/screen.js ADDED
@@ -0,0 +1,150 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import MuteStream from 'mute-stream';
3
+ import { BufferedStream } from './buffered-stream.js';
4
+ import { interpretTerminalOutput } from './terminal.js';
5
+ export class Screen {
6
+ #input;
7
+ #outputs = [];
8
+ #currentOutput = null;
9
+ #activePromise = null;
10
+ #rendersConsumed = 0;
11
+ #renderResolve = null;
12
+ constructor() {
13
+ this.#input = new MuteStream();
14
+ this.#input.unmute();
15
+ }
16
+ get input() {
17
+ return this.#input;
18
+ }
19
+ createOutput() {
20
+ const output = new BufferedStream();
21
+ this.#outputs.push(output);
22
+ this.#currentOutput = output;
23
+ this.#rendersConsumed = 0;
24
+ // Forward render events for cross-output listening
25
+ output.on('render', () => {
26
+ if (this.#renderResolve) {
27
+ this.#renderResolve();
28
+ }
29
+ });
30
+ return output;
31
+ }
32
+ setActivePromise(promise) {
33
+ this.#activePromise = promise;
34
+ // Auto-consume the initial render. Since createPrompt renders synchronously,
35
+ // getScreen() works immediately after starting a prompt — no next() needed.
36
+ this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0;
37
+ }
38
+ /**
39
+ * Wait for the next screen update.
40
+ *
41
+ * Handles re-renders within the same prompt (e.g., validation errors,
42
+ * async updates) and prompt transitions in multi-prompt flows
43
+ * (automatically waits for the next prompt).
44
+ *
45
+ * Note: The initial prompt render is available immediately via getScreen()
46
+ * — no next() call is needed before reading it.
47
+ */
48
+ async next() {
49
+ if (this.#activePromise) {
50
+ const currentPromise = this.#activePromise;
51
+ // Consume any renders that happened synchronously (e.g., loading state).
52
+ // We want to wait for the next meaningful state change, not an intermediate render.
53
+ this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0;
54
+ // Race: a future render (validation error, async update) vs the promise settling
55
+ // (prompt completed, possibly synchronously before any new render arrives).
56
+ const renderPromise = this.#waitForNextRender();
57
+ const settlePromise = currentPromise.then(() => 'settled', () => 'settled');
58
+ const result = await Promise.race([
59
+ renderPromise.then(() => 'render'),
60
+ settlePromise,
61
+ ]);
62
+ if (result === 'settled') {
63
+ if (this.#activePromise !== currentPromise) {
64
+ // New prompt already started — its render was caught by the race's renderPromise.
65
+ this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0;
66
+ }
67
+ else {
68
+ // Prompt settled but no new prompt yet. Wait for the next prompt's first render.
69
+ await this.#waitForNextRender();
70
+ }
71
+ }
72
+ else {
73
+ // Got a render. Check if the prompt also completed (making this a "done" render).
74
+ // Microtasks (promise settlement) always drain before macrotasks (setImmediate),
75
+ // so if the prompt resolved, its .then wins the race.
76
+ const settled = await Promise.race([
77
+ currentPromise.then(() => true, () => true),
78
+ new Promise((resolve) => setImmediate(() => resolve(false))),
79
+ ]);
80
+ if (settled) {
81
+ if (this.#activePromise !== currentPromise) {
82
+ // New prompt already started — its render was caught by the race.
83
+ this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0;
84
+ }
85
+ else {
86
+ // Prompt completed but no new prompt yet. Wait for it.
87
+ await this.#waitForNextRender();
88
+ }
89
+ }
90
+ }
91
+ }
92
+ else {
93
+ // No active promise — wait for a render (e.g., prompt hasn't started yet)
94
+ await this.#waitForNextRender();
95
+ }
96
+ }
97
+ async #waitForNextRender() {
98
+ const writeCount = this.#currentOutput?.writeCount ?? 0;
99
+ if (writeCount > this.#rendersConsumed) {
100
+ this.#rendersConsumed = writeCount;
101
+ return;
102
+ }
103
+ await new Promise((resolve) => {
104
+ const handler = () => {
105
+ this.#renderResolve = null;
106
+ resolve();
107
+ };
108
+ // Listen on current output (for re-renders within same prompt)
109
+ this.#currentOutput?.once('render', handler);
110
+ // Also set up cross-output listener (for prompt transitions and initial render)
111
+ this.#renderResolve = handler;
112
+ });
113
+ this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0;
114
+ }
115
+ getScreen({ raw } = {}) {
116
+ const lastScreen = this.#currentOutput?.getLastChunk({ raw: Boolean(raw) }) ?? '';
117
+ return raw ? lastScreen : stripVTControlCharacters(lastScreen).trim();
118
+ }
119
+ async getFullOutput({ raw } = {}) {
120
+ const output = this.#outputs.map((o) => o.getFullOutput()).join('');
121
+ if (raw)
122
+ return output;
123
+ return interpretTerminalOutput(output);
124
+ }
125
+ keypress(key) {
126
+ if (typeof key === 'string') {
127
+ this.#input.emit('keypress', null, { name: key });
128
+ }
129
+ else {
130
+ this.#input.emit('keypress', null, key);
131
+ }
132
+ }
133
+ type(text) {
134
+ this.#input.write(text);
135
+ for (const char of text) {
136
+ this.#input.emit('keypress', null, { name: char });
137
+ }
138
+ }
139
+ clear() {
140
+ // Recreate input stream to ensure clean state
141
+ this.#input = new MuteStream();
142
+ this.#input.unmute();
143
+ // Reset output tracking
144
+ this.#outputs = [];
145
+ this.#currentOutput = null;
146
+ this.#activePromise = null;
147
+ this.#rendersConsumed = 0;
148
+ this.#renderResolve = null;
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export declare function interpretTerminalOutput(rawOutput: string, cols?: number, rows?: number): Promise<string>;
@@ -0,0 +1,15 @@
1
+ import { Terminal } from '@xterm/headless';
2
+ export async function interpretTerminalOutput(rawOutput, cols = 10_000, rows = 4000) {
3
+ const term = new Terminal({ cols, rows, allowProposedApi: true, convertEol: true });
4
+ await new Promise((resolve) => term.write(rawOutput, resolve));
5
+ const lines = [];
6
+ for (let i = 0; i < term.rows; i++) {
7
+ lines.push(term.buffer.active.getLine(i)?.translateToString(true) ?? '');
8
+ }
9
+ term.dispose();
10
+ // Trim trailing empty lines
11
+ while (lines.length > 0 && lines.at(-1) === '') {
12
+ lines.pop();
13
+ }
14
+ return lines.join('\n');
15
+ }
@@ -0,0 +1,20 @@
1
+ import type { Prompt } from '@inquirer/type';
2
+ import { Screen } from './screen.js';
3
+ declare const screenInstance: Screen;
4
+ export { screenInstance as screen };
5
+ /**
6
+ * Wrap a prompt function to use the shared screen I/O.
7
+ * Use this in your own `vi.mock()` calls to mock third-party prompts.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { wrapPrompt } from '@inquirer/testing/vitest';
12
+ *
13
+ * vi.mock('@my-company/custom-prompt', async (importOriginal) => {
14
+ * const actual = await importOriginal<typeof import('@my-company/custom-prompt')>();
15
+ * return { ...actual, default: wrapPrompt(actual.default) };
16
+ * });
17
+ * ```
18
+ */
19
+ export declare function wrapPrompt<Value, Config>(prompt: Prompt<Value, Config>): Prompt<Value, Config>;
20
+ export { Screen, type KeypressEvent } from './screen.js';
package/dist/vitest.js ADDED
@@ -0,0 +1,118 @@
1
+ import { vi, beforeEach } from 'vitest';
2
+ import { Screen } from './screen.js';
3
+ // Global screen instance - exported for tests
4
+ const screenInstance = new Screen();
5
+ export { screenInstance as screen };
6
+ // Reset before each test
7
+ beforeEach(() => {
8
+ screenInstance.clear();
9
+ });
10
+ /**
11
+ * Wrap a prompt function to use the shared screen I/O.
12
+ * Use this in your own `vi.mock()` calls to mock third-party prompts.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { wrapPrompt } from '@inquirer/testing/vitest';
17
+ *
18
+ * vi.mock('@my-company/custom-prompt', async (importOriginal) => {
19
+ * const actual = await importOriginal<typeof import('@my-company/custom-prompt')>();
20
+ * return { ...actual, default: wrapPrompt(actual.default) };
21
+ * });
22
+ * ```
23
+ */
24
+ export function wrapPrompt(prompt) {
25
+ return (config, context) => {
26
+ const output = screenInstance.createOutput();
27
+ const promise = prompt(config, {
28
+ ...context,
29
+ input: screenInstance.input,
30
+ output,
31
+ });
32
+ screenInstance.setActivePromise(promise);
33
+ return promise;
34
+ };
35
+ }
36
+ // Mock individual prompt packages (covers `import input from '@inquirer/input'` style)
37
+ vi.mock('@inquirer/input', async (importOriginal) => {
38
+ const actual = await importOriginal();
39
+ return { ...actual, default: wrapPrompt(actual.default) };
40
+ });
41
+ vi.mock('@inquirer/select', async (importOriginal) => {
42
+ const actual = await importOriginal();
43
+ return { ...actual, default: wrapPrompt(actual.default) };
44
+ });
45
+ vi.mock('@inquirer/confirm', async (importOriginal) => {
46
+ const actual = await importOriginal();
47
+ return { ...actual, default: wrapPrompt(actual.default) };
48
+ });
49
+ vi.mock('@inquirer/checkbox', async (importOriginal) => {
50
+ const actual = await importOriginal();
51
+ return { ...actual, default: wrapPrompt(actual.default) };
52
+ });
53
+ vi.mock('@inquirer/password', async (importOriginal) => {
54
+ const actual = await importOriginal();
55
+ return { ...actual, default: wrapPrompt(actual.default) };
56
+ });
57
+ vi.mock('@inquirer/expand', async (importOriginal) => {
58
+ const actual = await importOriginal();
59
+ return { ...actual, default: wrapPrompt(actual.default) };
60
+ });
61
+ vi.mock('@inquirer/rawlist', async (importOriginal) => {
62
+ const actual = await importOriginal();
63
+ return { ...actual, default: wrapPrompt(actual.default) };
64
+ });
65
+ vi.mock('@inquirer/number', async (importOriginal) => {
66
+ const actual = await importOriginal();
67
+ return { ...actual, default: wrapPrompt(actual.default) };
68
+ });
69
+ vi.mock('@inquirer/search', async (importOriginal) => {
70
+ const actual = await importOriginal();
71
+ return { ...actual, default: wrapPrompt(actual.default) };
72
+ });
73
+ vi.mock('@inquirer/editor', async (importOriginal) => {
74
+ const actual = await importOriginal();
75
+ return { ...actual, default: wrapPrompt(actual.default) };
76
+ });
77
+ // Mock @inquirer/prompts barrel re-exports (covers `import { input } from '@inquirer/prompts'` style).
78
+ // While Vitest's module interception often propagates through ESM re-exports, an explicit mock
79
+ // ensures consistent behavior across all environments and bundler configurations.
80
+ vi.mock('@inquirer/prompts', async (importOriginal) => {
81
+ const actual = await importOriginal();
82
+ return {
83
+ ...actual,
84
+ input: wrapPrompt(actual.input),
85
+ select: wrapPrompt(actual.select),
86
+ confirm: wrapPrompt(actual.confirm),
87
+ checkbox: wrapPrompt(actual.checkbox),
88
+ password: wrapPrompt(actual.password),
89
+ expand: wrapPrompt(actual.expand),
90
+ rawlist: wrapPrompt(actual.rawlist),
91
+ number: wrapPrompt(actual.number),
92
+ search: wrapPrompt(actual.search),
93
+ editor: wrapPrompt(actual.editor),
94
+ };
95
+ });
96
+ // Mock the external editor to capture typed input instead of spawning a real editor.
97
+ // Buffers all screen.type() calls and submits on screen.keypress('enter'), matching
98
+ // the interaction pattern of other prompts (type → enter).
99
+ vi.mock('@inquirer/external-editor', () => ({
100
+ editAsync: (_text, callback) => {
101
+ let buffer = '';
102
+ const typeSpy = vi
103
+ .spyOn(screenInstance, 'type')
104
+ .mockImplementation((text) => {
105
+ buffer += text;
106
+ });
107
+ const keypressSpy = vi.spyOn(screenInstance, 'keypress').mockImplementation((key) => {
108
+ const name = typeof key === 'string' ? key : key.name;
109
+ if (name === 'enter' || name === 'return') {
110
+ typeSpy.mockRestore();
111
+ keypressSpy.mockRestore();
112
+ process.nextTick(() => callback(undefined, buffer));
113
+ }
114
+ });
115
+ },
116
+ }));
117
+ // Re-export Screen class and KeypressEvent type for advanced use cases
118
+ export { Screen } from './screen.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inquirer/testing",
3
- "version": "3.0.4",
3
+ "version": "3.1.1",
4
4
  "description": "Inquirer testing utilities",
5
5
  "keywords": [
6
6
  "answer",
@@ -55,12 +55,25 @@
55
55
  "dist"
56
56
  ],
57
57
  "type": "module",
58
- "sideEffects": false,
58
+ "sideEffects": [
59
+ "./src/vitest.ts",
60
+ "./src/jest.ts",
61
+ "./dist/vitest.js",
62
+ "./dist/jest.js"
63
+ ],
59
64
  "exports": {
60
65
  ".": {
61
66
  "types": "./dist/index.d.ts",
62
67
  "default": "./dist/index.js"
63
68
  },
69
+ "./vitest": {
70
+ "types": "./dist/vitest.d.ts",
71
+ "default": "./dist/vitest.js"
72
+ },
73
+ "./jest": {
74
+ "types": "./dist/jest.d.ts",
75
+ "default": "./dist/jest.js"
76
+ },
64
77
  "./package.json": "./package.json"
65
78
  },
66
79
  "publishConfig": {
@@ -71,19 +84,82 @@
71
84
  },
72
85
  "dependencies": {
73
86
  "@inquirer/type": "^4.0.3",
87
+ "@xterm/headless": "^5.5.0",
74
88
  "mute-stream": "^3.0.0"
75
89
  },
76
90
  "devDependencies": {
91
+ "@types/jest": "^29.5.0",
77
92
  "@types/mute-stream": "^0.0.4",
78
93
  "@types/node": "^25.0.2",
79
- "typescript": "^5.9.3"
94
+ "typescript": "^5.9.3",
95
+ "vitest": "^3.0.0"
80
96
  },
81
97
  "peerDependencies": {
82
- "@types/node": ">=18"
98
+ "@inquirer/checkbox": ">=1.0.0",
99
+ "@inquirer/confirm": ">=1.0.0",
100
+ "@inquirer/editor": ">=1.0.0",
101
+ "@inquirer/expand": ">=1.0.0",
102
+ "@inquirer/external-editor": ">=1.0.0",
103
+ "@inquirer/input": ">=1.0.0",
104
+ "@inquirer/number": ">=1.0.0",
105
+ "@inquirer/password": ">=1.0.0",
106
+ "@inquirer/prompts": ">=1.0.0",
107
+ "@inquirer/rawlist": ">=1.0.0",
108
+ "@inquirer/search": ">=1.0.0",
109
+ "@inquirer/select": ">=1.0.0",
110
+ "@types/jest": ">=29.0.0",
111
+ "@types/node": ">=18",
112
+ "jest": ">=29.0.0",
113
+ "vitest": ">=1.0.0"
83
114
  },
84
115
  "peerDependenciesMeta": {
116
+ "@inquirer/checkbox": {
117
+ "optional": true
118
+ },
119
+ "@inquirer/confirm": {
120
+ "optional": true
121
+ },
122
+ "@inquirer/editor": {
123
+ "optional": true
124
+ },
125
+ "@inquirer/expand": {
126
+ "optional": true
127
+ },
128
+ "@inquirer/external-editor": {
129
+ "optional": true
130
+ },
131
+ "@inquirer/input": {
132
+ "optional": true
133
+ },
134
+ "@inquirer/number": {
135
+ "optional": true
136
+ },
137
+ "@inquirer/password": {
138
+ "optional": true
139
+ },
140
+ "@inquirer/prompts": {
141
+ "optional": true
142
+ },
143
+ "@inquirer/rawlist": {
144
+ "optional": true
145
+ },
146
+ "@inquirer/search": {
147
+ "optional": true
148
+ },
149
+ "@inquirer/select": {
150
+ "optional": true
151
+ },
152
+ "@types/jest": {
153
+ "optional": true
154
+ },
85
155
  "@types/node": {
86
156
  "optional": true
157
+ },
158
+ "jest": {
159
+ "optional": true
160
+ },
161
+ "vitest": {
162
+ "optional": true
87
163
  }
88
164
  },
89
165
  "engines": {
@@ -91,5 +167,5 @@
91
167
  },
92
168
  "main": "./dist/index.js",
93
169
  "types": "./dist/index.d.ts",
94
- "gitHead": "99d00a9adc53be8b7edf5926b2ec4ba0b792f68f"
170
+ "gitHead": "48b5d7e8b14d5c9fc4a19a8f1ead20a7593b29e1"
95
171
  }