Skip to content

Commit e784d65

Browse files
committed
test: add cli util
1 parent 1c3697d commit e784d65

File tree

1 file changed

+166
-0
lines changed

1 file changed

+166
-0
lines changed

tests/utils/cli.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { Readable, Writable } from 'node:stream';
2+
import { stripVTControlCharacters } from 'node:util';
3+
import { isCI } from 'std-env';
4+
import { exec, type Options } from 'tinyexec';
5+
6+
// Based on https://github.com/vitest-dev/vitest/blob/main/test/test-utils/index.ts
7+
8+
type Listener = () => void;
9+
type ReadableOrWritable = Readable | Writable;
10+
type Source = 'stdout' | 'stderr';
11+
12+
export class CliController {
13+
stdout = '';
14+
stderr = '';
15+
16+
private stdoutListeners: Listener[] = [];
17+
private stderrListeners: Listener[] = [];
18+
private stdin: ReadableOrWritable;
19+
20+
constructor(options: {
21+
stdin: ReadableOrWritable;
22+
stdout: ReadableOrWritable;
23+
stderr: ReadableOrWritable;
24+
}) {
25+
this.stdin = options.stdin;
26+
27+
for (const source of ['stdout', 'stderr'] as const) {
28+
const stream = options[source];
29+
30+
if ((stream as Readable).readable) {
31+
stream.on('data', (data) => {
32+
this.capture(source, data);
33+
});
34+
} else if (isWritable(stream)) {
35+
const original = stream.write.bind(stream);
36+
37+
// @ts-ignore
38+
stream.write = (data, encoding, callback) => {
39+
this.capture(source, data);
40+
return original(data, encoding, callback);
41+
};
42+
}
43+
}
44+
}
45+
46+
private capture(source: Source, data: unknown) {
47+
const msg = stripVTControlCharacters(`${data}`);
48+
this[source] += msg;
49+
50+
for (const fn of this[`${source}Listeners`]) {
51+
fn();
52+
}
53+
}
54+
55+
write(data: string) {
56+
this.resetOutput();
57+
58+
if ((this.stdin as Readable).readable) {
59+
this.stdin.emit('data', data);
60+
} else if (isWritable(this.stdin)) {
61+
this.stdin.write(data);
62+
}
63+
}
64+
65+
resetOutput() {
66+
this.stdout = '';
67+
this.stderr = '';
68+
}
69+
70+
waitForStdout(expected: string) {
71+
return this.waitForOutput(expected, 'stdout', this.waitForStdout);
72+
}
73+
74+
waitForStderr(expected: string) {
75+
return this.waitForOutput(expected, 'stderr', this.waitForStderr);
76+
}
77+
78+
private waitForOutput(
79+
expected: string,
80+
source: Source,
81+
caller: Parameters<typeof Error.captureStackTrace>[1],
82+
) {
83+
const error = new Error('Timeout');
84+
Error.captureStackTrace(error, caller);
85+
86+
return new Promise<void>((resolve, reject) => {
87+
if (this[source].includes(expected)) {
88+
return resolve();
89+
}
90+
91+
const timeout = setTimeout(
92+
() => {
93+
error.message = `Timeout when waiting for error "${expected}".\nReceived:\nstdout: ${this.stdout}\nstderr: ${this.stderr}`;
94+
reject(error);
95+
},
96+
isCI ? 20_000 : 4_000,
97+
);
98+
99+
const listener = () => {
100+
if (this[source].includes(expected)) {
101+
if (timeout) {
102+
clearTimeout(timeout);
103+
}
104+
105+
resolve();
106+
}
107+
};
108+
109+
this[`${source}Listeners`].push(listener);
110+
});
111+
}
112+
}
113+
114+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
115+
function isWritable(stream: any): stream is Writable {
116+
return stream && typeof stream?.write === 'function';
117+
}
118+
119+
export async function runPubmCli(
120+
command: string,
121+
_options?: Partial<Options>,
122+
...args: string[]
123+
) {
124+
let options = _options;
125+
126+
if (typeof _options === 'string') {
127+
args.unshift(_options);
128+
options = undefined;
129+
}
130+
131+
const subprocess = exec(command, args, options as Options).process;
132+
const controller = new CliController({
133+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
134+
stdin: subprocess!.stdin!,
135+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
136+
stdout: subprocess!.stdout!,
137+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
138+
stderr: subprocess!.stderr!,
139+
});
140+
141+
let setDone: (value?: unknown) => void;
142+
143+
const isDone = new Promise((resolve) => {
144+
setDone = resolve;
145+
});
146+
147+
subprocess?.on('exit', () => setDone());
148+
149+
function output() {
150+
return {
151+
controller,
152+
exitCode: subprocess?.exitCode,
153+
stdout: controller.stdout || '',
154+
stderr: controller.stderr || '',
155+
waitForClose: () => isDone,
156+
};
157+
}
158+
159+
await isDone;
160+
161+
return output();
162+
}
163+
164+
export const DOWN = '\x1B\x5B\x42';
165+
export const UP = '\x1B\x5B\x41';
166+
export const ENTER = '\x0D';

0 commit comments

Comments
 (0)