@inquirer/testing 3.3.1 → 3.3.3
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/dist/buffered-stream.d.ts +12 -0
- package/dist/buffered-stream.js +43 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +63 -0
- package/dist/jest.d.ts +21 -0
- package/dist/jest.js +198 -0
- package/dist/screen.d.ts +35 -0
- package/dist/screen.js +150 -0
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +20 -0
- package/dist/vitest.d.ts +22 -0
- package/dist/vitest.js +192 -0
- package/package.json +6 -6
|
@@ -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
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import MuteStream from 'mute-stream';
|
|
2
|
+
import type { Prompt, Context } from '@inquirer/type';
|
|
3
|
+
type RenderOptions = Omit<Context, 'input' | 'output'>;
|
|
4
|
+
export declare function render<Value, const Config>(prompt: Prompt<Value, Config>, config: Config, options?: RenderOptions): Promise<{
|
|
5
|
+
answer: Promise<Value>;
|
|
6
|
+
input: MuteStream;
|
|
7
|
+
events: {
|
|
8
|
+
keypress: (key: string | {
|
|
9
|
+
name?: string;
|
|
10
|
+
ctrl?: boolean;
|
|
11
|
+
meta?: boolean;
|
|
12
|
+
shift?: boolean;
|
|
13
|
+
}) => void;
|
|
14
|
+
type: (text: string) => void;
|
|
15
|
+
};
|
|
16
|
+
getScreen: ({ raw }?: {
|
|
17
|
+
raw?: boolean;
|
|
18
|
+
}) => string;
|
|
19
|
+
getFullOutput: ({ raw }?: {
|
|
20
|
+
raw?: boolean;
|
|
21
|
+
}) => Promise<string>;
|
|
22
|
+
nextRender: () => Promise<void>;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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 async function render(prompt, config, options) {
|
|
6
|
+
const input = new MuteStream();
|
|
7
|
+
input.unmute();
|
|
8
|
+
const output = new BufferedStream();
|
|
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
|
+
}
|
|
17
|
+
const events = {
|
|
18
|
+
keypress(key) {
|
|
19
|
+
if (typeof key === 'string') {
|
|
20
|
+
input.emit('keypress', null, { name: key });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
input.emit('keypress', null, key);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
type(text) {
|
|
27
|
+
input.write(text);
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
input.emit('keypress', null, { name: char });
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
let rendersConsumed = output.writeCount;
|
|
34
|
+
function nextRender() {
|
|
35
|
+
const waitForRender = output.writeCount > rendersConsumed
|
|
36
|
+
? Promise.resolve()
|
|
37
|
+
: new Promise((resolve) => output.once('render', resolve));
|
|
38
|
+
// After detecting a render, let the event loop settle so that multiple
|
|
39
|
+
// synchronous-ish renders (e.g. validation: loading → error) are coalesced.
|
|
40
|
+
return waitForRender.then(() => new Promise((resolve) => {
|
|
41
|
+
setImmediate(() => {
|
|
42
|
+
rendersConsumed = output.writeCount;
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
answer,
|
|
49
|
+
input,
|
|
50
|
+
events,
|
|
51
|
+
getScreen: ({ raw } = {}) => {
|
|
52
|
+
const lastScreen = output.getLastChunk({ raw: Boolean(raw) });
|
|
53
|
+
return raw ? lastScreen : stripVTControlCharacters(lastScreen).trim();
|
|
54
|
+
},
|
|
55
|
+
getFullOutput: async ({ raw } = {}) => {
|
|
56
|
+
const fullOutput = output.getFullOutput();
|
|
57
|
+
if (raw)
|
|
58
|
+
return fullOutput;
|
|
59
|
+
return interpretTerminalOutput(fullOutput);
|
|
60
|
+
},
|
|
61
|
+
nextRender,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/jest.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Prompt } from '@inquirer/type';
|
|
2
|
+
import { Screen } from './screen.ts';
|
|
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> | {
|
|
19
|
+
default: Prompt<Value, Config>;
|
|
20
|
+
}): Prompt<Value, Config>;
|
|
21
|
+
export { Screen, type KeypressEvent } from './screen.js';
|
package/dist/jest.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
// Unwrap SWC-style module namespace objects where barrel re-exports like
|
|
26
|
+
// `export { default as input } from '@inquirer/input'` get transformed into
|
|
27
|
+
// `{ default: fn }` instead of the function directly.
|
|
28
|
+
const fn = typeof prompt === 'function' ? prompt : prompt.default;
|
|
29
|
+
return (config, context) => {
|
|
30
|
+
const output = screenInstance.createOutput();
|
|
31
|
+
const promise = fn(config, {
|
|
32
|
+
...context,
|
|
33
|
+
input: screenInstance.input,
|
|
34
|
+
output,
|
|
35
|
+
});
|
|
36
|
+
screenInstance.setActivePromise(promise);
|
|
37
|
+
return promise;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Prompt names used by the @inquirer/prompts barrel mock to wrap only prompt exports.
|
|
41
|
+
const promptNames = [
|
|
42
|
+
'input',
|
|
43
|
+
'select',
|
|
44
|
+
'confirm',
|
|
45
|
+
'checkbox',
|
|
46
|
+
'password',
|
|
47
|
+
'expand',
|
|
48
|
+
'rawlist',
|
|
49
|
+
'number',
|
|
50
|
+
'search',
|
|
51
|
+
'editor',
|
|
52
|
+
];
|
|
53
|
+
// Mock individual prompt packages (covers `import input from '@inquirer/input'` style).
|
|
54
|
+
// All prompt packages are optional peer dependencies, so factories silently skip
|
|
55
|
+
// packages that aren't installed in the consumer's project.
|
|
56
|
+
jest.mock('@inquirer/input', () => {
|
|
57
|
+
try {
|
|
58
|
+
const actual = jest.requireActual('@inquirer/input');
|
|
59
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
jest.mock('@inquirer/select', () => {
|
|
66
|
+
try {
|
|
67
|
+
const actual = jest.requireActual('@inquirer/select');
|
|
68
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
jest.mock('@inquirer/confirm', () => {
|
|
75
|
+
try {
|
|
76
|
+
const actual = jest.requireActual('@inquirer/confirm');
|
|
77
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
jest.mock('@inquirer/checkbox', () => {
|
|
84
|
+
try {
|
|
85
|
+
const actual = jest.requireActual('@inquirer/checkbox');
|
|
86
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
jest.mock('@inquirer/password', () => {
|
|
93
|
+
try {
|
|
94
|
+
const actual = jest.requireActual('@inquirer/password');
|
|
95
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
jest.mock('@inquirer/expand', () => {
|
|
102
|
+
try {
|
|
103
|
+
const actual = jest.requireActual('@inquirer/expand');
|
|
104
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
jest.mock('@inquirer/rawlist', () => {
|
|
111
|
+
try {
|
|
112
|
+
const actual = jest.requireActual('@inquirer/rawlist');
|
|
113
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
jest.mock('@inquirer/number', () => {
|
|
120
|
+
try {
|
|
121
|
+
const actual = jest.requireActual('@inquirer/number');
|
|
122
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
jest.mock('@inquirer/search', () => {
|
|
129
|
+
try {
|
|
130
|
+
const actual = jest.requireActual('@inquirer/search');
|
|
131
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
jest.mock('@inquirer/editor', () => {
|
|
138
|
+
try {
|
|
139
|
+
const actual = jest.requireActual('@inquirer/editor');
|
|
140
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// Mock @inquirer/prompts barrel re-exports (covers `import { input } from '@inquirer/prompts'` style).
|
|
147
|
+
// Jest's module mock for individual packages doesn't propagate through barrel re-exports.
|
|
148
|
+
// Only prompt functions are wrapped; other exports (like Separator) are passed through.
|
|
149
|
+
jest.mock('@inquirer/prompts', () => {
|
|
150
|
+
try {
|
|
151
|
+
const actual = jest.requireActual('@inquirer/prompts');
|
|
152
|
+
const wrapped = { ...actual };
|
|
153
|
+
for (const name of promptNames) {
|
|
154
|
+
if (name in actual) {
|
|
155
|
+
wrapped[name] = wrapPrompt(actual[name]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return wrapped;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
// Mock the external editor to capture typed input instead of spawning a real editor.
|
|
165
|
+
// Buffers all screen.type() calls and submits on screen.keypress('enter'), matching
|
|
166
|
+
// the interaction pattern of other prompts (type → enter).
|
|
167
|
+
jest.mock('@inquirer/external-editor', () => {
|
|
168
|
+
try {
|
|
169
|
+
jest.requireActual('@inquirer/external-editor');
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
editAsync: (_text) => {
|
|
176
|
+
let buffer = '';
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const typeSpy = jest
|
|
179
|
+
.spyOn(screenInstance, 'type')
|
|
180
|
+
.mockImplementation((text) => {
|
|
181
|
+
buffer += text;
|
|
182
|
+
});
|
|
183
|
+
const keypressSpy = jest
|
|
184
|
+
.spyOn(screenInstance, 'keypress')
|
|
185
|
+
.mockImplementation((key) => {
|
|
186
|
+
const name = typeof key === 'string' ? key : key.name;
|
|
187
|
+
if (name === 'enter' || name === 'return') {
|
|
188
|
+
typeSpy.mockRestore();
|
|
189
|
+
keypressSpy.mockRestore();
|
|
190
|
+
resolve(buffer);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
// Re-export Screen class and KeypressEvent type for advanced use cases
|
|
198
|
+
export { Screen } from './screen.js';
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import MuteStream from 'mute-stream';
|
|
2
|
+
import { BufferedStream } from './buffered-stream.ts';
|
|
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>;
|
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import xterm from '@xterm/headless';
|
|
2
|
+
export async function interpretTerminalOutput(rawOutput, cols = 10_000, rows = 4000) {
|
|
3
|
+
const term = new xterm.Terminal({
|
|
4
|
+
cols,
|
|
5
|
+
rows,
|
|
6
|
+
allowProposedApi: true,
|
|
7
|
+
convertEol: true,
|
|
8
|
+
});
|
|
9
|
+
await new Promise((resolve) => term.write(rawOutput, resolve));
|
|
10
|
+
const lines = [];
|
|
11
|
+
for (let i = 0; i < term.rows; i++) {
|
|
12
|
+
lines.push(term.buffer.active.getLine(i)?.translateToString(true) ?? '');
|
|
13
|
+
}
|
|
14
|
+
term.dispose();
|
|
15
|
+
// Trim trailing empty lines
|
|
16
|
+
while (lines.length > 0 && lines.at(-1) === '') {
|
|
17
|
+
lines.pop();
|
|
18
|
+
}
|
|
19
|
+
return lines.join('\n');
|
|
20
|
+
}
|
package/dist/vitest.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Prompt } from '@inquirer/type';
|
|
2
|
+
import { Screen } from './screen.ts';
|
|
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> | {
|
|
20
|
+
default: Prompt<Value, Config>;
|
|
21
|
+
}): Prompt<Value, Config>;
|
|
22
|
+
export { Screen, type KeypressEvent } from './screen.js';
|
package/dist/vitest.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
// Unwrap SWC-style module namespace objects where barrel re-exports like
|
|
26
|
+
// `export { default as input } from '@inquirer/input'` get transformed into
|
|
27
|
+
// `{ default: fn }` instead of the function directly.
|
|
28
|
+
const fn = typeof prompt === 'function' ? prompt : prompt.default;
|
|
29
|
+
return (config, context) => {
|
|
30
|
+
const output = screenInstance.createOutput();
|
|
31
|
+
const promise = fn(config, {
|
|
32
|
+
...context,
|
|
33
|
+
input: screenInstance.input,
|
|
34
|
+
output,
|
|
35
|
+
});
|
|
36
|
+
screenInstance.setActivePromise(promise);
|
|
37
|
+
return promise;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Mock individual prompt packages (covers `import input from '@inquirer/input'` style).
|
|
41
|
+
// All prompt packages are optional peer dependencies, so factories silently skip
|
|
42
|
+
// packages that aren't installed in the consumer's project.
|
|
43
|
+
vi.mock('@inquirer/input', async (importOriginal) => {
|
|
44
|
+
try {
|
|
45
|
+
const actual = await importOriginal();
|
|
46
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
vi.mock('@inquirer/select', async (importOriginal) => {
|
|
53
|
+
try {
|
|
54
|
+
const actual = await importOriginal();
|
|
55
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
vi.mock('@inquirer/confirm', async (importOriginal) => {
|
|
62
|
+
try {
|
|
63
|
+
const actual = await importOriginal();
|
|
64
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
vi.mock('@inquirer/checkbox', async (importOriginal) => {
|
|
71
|
+
try {
|
|
72
|
+
const actual = await importOriginal();
|
|
73
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
vi.mock('@inquirer/password', async (importOriginal) => {
|
|
80
|
+
try {
|
|
81
|
+
const actual = await importOriginal();
|
|
82
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
vi.mock('@inquirer/expand', async (importOriginal) => {
|
|
89
|
+
try {
|
|
90
|
+
const actual = await importOriginal();
|
|
91
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
vi.mock('@inquirer/rawlist', async (importOriginal) => {
|
|
98
|
+
try {
|
|
99
|
+
const actual = await importOriginal();
|
|
100
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
vi.mock('@inquirer/number', async (importOriginal) => {
|
|
107
|
+
try {
|
|
108
|
+
const actual = await importOriginal();
|
|
109
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
vi.mock('@inquirer/search', async (importOriginal) => {
|
|
116
|
+
try {
|
|
117
|
+
const actual = await importOriginal();
|
|
118
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
vi.mock('@inquirer/editor', async (importOriginal) => {
|
|
125
|
+
try {
|
|
126
|
+
const actual = await importOriginal();
|
|
127
|
+
return { ...actual, default: wrapPrompt(actual.default) };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Mock @inquirer/prompts barrel re-exports (covers `import { input } from '@inquirer/prompts'` style).
|
|
134
|
+
// While Vitest's module interception often propagates through ESM re-exports, an explicit mock
|
|
135
|
+
// ensures consistent behavior across all environments and bundler configurations.
|
|
136
|
+
// Only prompt functions are wrapped; other exports (like Separator) are passed through.
|
|
137
|
+
vi.mock('@inquirer/prompts', async (importOriginal) => {
|
|
138
|
+
try {
|
|
139
|
+
const actual = await importOriginal();
|
|
140
|
+
return {
|
|
141
|
+
...actual,
|
|
142
|
+
input: wrapPrompt(actual.input),
|
|
143
|
+
select: wrapPrompt(actual.select),
|
|
144
|
+
confirm: wrapPrompt(actual.confirm),
|
|
145
|
+
checkbox: wrapPrompt(actual.checkbox),
|
|
146
|
+
password: wrapPrompt(actual.password),
|
|
147
|
+
expand: wrapPrompt(actual.expand),
|
|
148
|
+
rawlist: wrapPrompt(actual.rawlist),
|
|
149
|
+
number: wrapPrompt(actual.number),
|
|
150
|
+
search: wrapPrompt(actual.search),
|
|
151
|
+
editor: wrapPrompt(actual.editor),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Mock the external editor to capture typed input instead of spawning a real editor.
|
|
159
|
+
// Buffers all screen.type() calls and submits on screen.keypress('enter'), matching
|
|
160
|
+
// the interaction pattern of other prompts (type → enter).
|
|
161
|
+
vi.mock('@inquirer/external-editor', async (importOriginal) => {
|
|
162
|
+
try {
|
|
163
|
+
await importOriginal();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
editAsync: (_text) => {
|
|
170
|
+
let buffer = '';
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
const typeSpy = vi
|
|
173
|
+
.spyOn(screenInstance, 'type')
|
|
174
|
+
.mockImplementation((text) => {
|
|
175
|
+
buffer += text;
|
|
176
|
+
});
|
|
177
|
+
const keypressSpy = vi
|
|
178
|
+
.spyOn(screenInstance, 'keypress')
|
|
179
|
+
.mockImplementation((key) => {
|
|
180
|
+
const name = typeof key === 'string' ? key : key.name;
|
|
181
|
+
if (name === 'enter' || name === 'return') {
|
|
182
|
+
typeSpy.mockRestore();
|
|
183
|
+
keypressSpy.mockRestore();
|
|
184
|
+
resolve(buffer);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
// Re-export Screen class and KeypressEvent type for advanced use cases
|
|
192
|
+
export { Screen } from './screen.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inquirer/testing",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.3",
|
|
4
4
|
"description": "Inquirer testing utilities",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"answer",
|
|
@@ -83,16 +83,16 @@
|
|
|
83
83
|
"tsc": "tsc"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@inquirer/type": "^4.0.
|
|
86
|
+
"@inquirer/type": "^4.0.5",
|
|
87
87
|
"@xterm/headless": "^6.0.0",
|
|
88
88
|
"mute-stream": "^3.0.0"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@types/jest": "^30.0.0",
|
|
92
92
|
"@types/mute-stream": "^0.0.4",
|
|
93
|
-
"@types/node": "^25.
|
|
94
|
-
"typescript": "^
|
|
95
|
-
"vitest": "^4.
|
|
93
|
+
"@types/node": "^25.5.2",
|
|
94
|
+
"typescript": "^6.0.2",
|
|
95
|
+
"vitest": "^4.1.2"
|
|
96
96
|
},
|
|
97
97
|
"peerDependencies": {
|
|
98
98
|
"@inquirer/checkbox": ">=1.0.0",
|
|
@@ -167,5 +167,5 @@
|
|
|
167
167
|
},
|
|
168
168
|
"main": "./dist/index.js",
|
|
169
169
|
"types": "./dist/index.d.ts",
|
|
170
|
-
"gitHead": "
|
|
170
|
+
"gitHead": "e68fe01d65359e083581c48c4a18cd8f97d88842"
|
|
171
171
|
}
|