@sandro-sikic/maker 1.0.7 → 1.0.9
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 +244 -0
- package/docs/API.md +191 -0
- package/docs/USAGE.md +572 -0
- package/example.js +13 -0
- package/index.js +31 -76
- package/package.json +4 -9
- package/test/index.test.js +1161 -0
- package/__tests__/index.test.js +0 -119
- package/babel.config.js +0 -10
- package/jest.config.cjs +0 -8
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('ora', () => ({
|
|
4
|
+
default: () => ({ start: () => ({ stop: () => {} }) }),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { run, onExit, init, prompt, spinner } from '../index.js';
|
|
8
|
+
|
|
9
|
+
describe('run()', () => {
|
|
10
|
+
it('captures stdout for successful commands (mocked)', async () => {
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
vi.doMock('child_process', () => ({
|
|
13
|
+
spawn: () => {
|
|
14
|
+
let stdoutCb;
|
|
15
|
+
let closeCb;
|
|
16
|
+
const child = {
|
|
17
|
+
stdout: {
|
|
18
|
+
on: (ev, cb) => {
|
|
19
|
+
if (ev === 'data') stdoutCb = cb;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
stderr: { on: () => {} },
|
|
23
|
+
on: (ev, cb) => {
|
|
24
|
+
if (ev === 'close') closeCb = cb;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
process.nextTick(() => {
|
|
28
|
+
if (stdoutCb) stdoutCb(Buffer.from('hello\n'));
|
|
29
|
+
if (closeCb) closeCb(0);
|
|
30
|
+
});
|
|
31
|
+
return child;
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
const { run: mockedRun } = await import('../index.js');
|
|
35
|
+
const res = await mockedRun('anything');
|
|
36
|
+
console.log('DEBUG run stdout test ->', res);
|
|
37
|
+
expect(res.isError).toBe(false);
|
|
38
|
+
expect(res.code).toBe(0);
|
|
39
|
+
expect(res.stdout).toMatch(/hello/i);
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('captures stderr and non-zero exit code (mocked)', async () => {
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
vi.doMock('child_process', () => ({
|
|
46
|
+
spawn: () => {
|
|
47
|
+
let stderrCb;
|
|
48
|
+
let closeCb;
|
|
49
|
+
const child = {
|
|
50
|
+
stdout: { on: () => {} },
|
|
51
|
+
stderr: {
|
|
52
|
+
on: (ev, cb) => {
|
|
53
|
+
if (ev === 'data') stderrCb = cb;
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
on: (ev, cb) => {
|
|
57
|
+
if (ev === 'close') closeCb = cb;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
process.nextTick(() => {
|
|
61
|
+
if (stderrCb) stderrCb(Buffer.from('err\n'));
|
|
62
|
+
if (closeCb) closeCb(3);
|
|
63
|
+
});
|
|
64
|
+
return child;
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
const { run: mockedRun } = await import('../index.js');
|
|
68
|
+
const res = await mockedRun('anything');
|
|
69
|
+
expect(res.isError).toBe(true);
|
|
70
|
+
expect(res.code).toBe(3);
|
|
71
|
+
expect(res.stderr).toMatch(/err/);
|
|
72
|
+
vi.resetModules();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('trims stdout and stderr to the provided maxLines (mocked)', async () => {
|
|
76
|
+
vi.resetModules();
|
|
77
|
+
vi.doMock('child_process', () => ({
|
|
78
|
+
spawn: () => {
|
|
79
|
+
let stdoutCb;
|
|
80
|
+
let stderrCb;
|
|
81
|
+
let closeCb;
|
|
82
|
+
const child = {
|
|
83
|
+
stdout: {
|
|
84
|
+
on: (ev, cb) => {
|
|
85
|
+
if (ev === 'data') stdoutCb = cb;
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
stderr: {
|
|
89
|
+
on: (ev, cb) => {
|
|
90
|
+
if (ev === 'data') stderrCb = cb;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
on: (ev, cb) => {
|
|
94
|
+
if (ev === 'close') closeCb = cb;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
process.nextTick(() => {
|
|
98
|
+
if (stdoutCb)
|
|
99
|
+
stdoutCb(
|
|
100
|
+
Buffer.from(
|
|
101
|
+
Array.from({ length: 20 })
|
|
102
|
+
.map((_, i) => i)
|
|
103
|
+
.join('\n') + '\n',
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
if (stderrCb)
|
|
107
|
+
stderrCb(
|
|
108
|
+
Buffer.from(
|
|
109
|
+
Array.from({ length: 20 })
|
|
110
|
+
.map((_, i) => i + 'e')
|
|
111
|
+
.join('\n') + '\n',
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
if (closeCb) closeCb(0);
|
|
115
|
+
});
|
|
116
|
+
return child;
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
const { run: mockedRun } = await import('../index.js');
|
|
120
|
+
const res = await mockedRun('anything', { maxLines: 5 });
|
|
121
|
+
// only the last lines should be present
|
|
122
|
+
expect(res.stdout).toMatch(/15/);
|
|
123
|
+
expect(res.stdout).not.toMatch(/0/);
|
|
124
|
+
expect(
|
|
125
|
+
res.stdout.split(/\r?\n/).filter(Boolean).length,
|
|
126
|
+
).toBeLessThanOrEqual(5);
|
|
127
|
+
expect(res.stderr).toMatch(/15e/);
|
|
128
|
+
expect(res.stderr).not.toMatch(/0e/);
|
|
129
|
+
expect(
|
|
130
|
+
res.stderr.split(/\r?\n/).filter(Boolean).length,
|
|
131
|
+
).toBeLessThanOrEqual(5);
|
|
132
|
+
vi.resetModules();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('streams child output to parent stdout/stderr (mocked)', async () => {
|
|
136
|
+
vi.resetModules();
|
|
137
|
+
vi.doMock('child_process', () => ({
|
|
138
|
+
spawn: () => {
|
|
139
|
+
let stdoutCb;
|
|
140
|
+
let stderrCb;
|
|
141
|
+
let closeCb;
|
|
142
|
+
const child = {
|
|
143
|
+
stdout: {
|
|
144
|
+
on: (ev, cb) => {
|
|
145
|
+
if (ev === 'data') stdoutCb = cb;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
stderr: {
|
|
149
|
+
on: (ev, cb) => {
|
|
150
|
+
if (ev === 'data') stderrCb = cb;
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
on: (ev, cb) => {
|
|
154
|
+
if (ev === 'close') closeCb = cb;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
process.nextTick(() => {
|
|
158
|
+
if (stdoutCb) stdoutCb(Buffer.from('hello\n'));
|
|
159
|
+
if (stderrCb) stderrCb(Buffer.from('bye\n'));
|
|
160
|
+
if (closeCb) closeCb(0);
|
|
161
|
+
});
|
|
162
|
+
return child;
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
const { run: mockedRun } = await import('../index.js');
|
|
166
|
+
const spyOut = vi
|
|
167
|
+
.spyOn(process.stdout, 'write')
|
|
168
|
+
.mockImplementation(() => true);
|
|
169
|
+
const spyErr = vi
|
|
170
|
+
.spyOn(process.stderr, 'write')
|
|
171
|
+
.mockImplementation(() => true);
|
|
172
|
+
|
|
173
|
+
await mockedRun('anything');
|
|
174
|
+
|
|
175
|
+
expect(spyOut).toHaveBeenCalledWith(expect.stringMatching(/hello/));
|
|
176
|
+
expect(spyErr).toHaveBeenCalledWith(expect.stringMatching(/bye/));
|
|
177
|
+
|
|
178
|
+
spyOut.mockRestore();
|
|
179
|
+
spyErr.mockRestore();
|
|
180
|
+
vi.resetModules();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('resolves with error info when spawn emits error', async () => {
|
|
184
|
+
// load a fresh module with child_process mocked so `run()` sees the mocked spawn
|
|
185
|
+
vi.resetModules();
|
|
186
|
+
vi.doMock('child_process', () => ({
|
|
187
|
+
spawn: () => ({
|
|
188
|
+
stdout: null,
|
|
189
|
+
stderr: null,
|
|
190
|
+
on: (ev, cb) => {
|
|
191
|
+
if (ev === 'error')
|
|
192
|
+
setTimeout(() => cb(new Error('mock spawn error')), 0);
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
const { run: mockedRun } = await import('../index.js');
|
|
198
|
+
const res = await mockedRun('does-not-matter');
|
|
199
|
+
|
|
200
|
+
expect(res.isError).toBe(true);
|
|
201
|
+
expect(res.code).toBeNull();
|
|
202
|
+
expect(res.error).toBeInstanceOf(Error);
|
|
203
|
+
expect(res.error.message).toMatch(/mock spawn error/);
|
|
204
|
+
|
|
205
|
+
// restore module cache for subsequent tests
|
|
206
|
+
vi.resetModules();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('onExit()', () => {
|
|
211
|
+
it('throws if callback is not a function', () => {
|
|
212
|
+
expect(() => onExit(123)).toThrow(TypeError);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('returns an unsubscribe function', () => {
|
|
216
|
+
const off = onExit(() => {});
|
|
217
|
+
expect(typeof off).toBe('function');
|
|
218
|
+
off();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('invokes callback on SIGINT and exits after async callback', async () => {
|
|
222
|
+
const cb = vi.fn(async () => {
|
|
223
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
224
|
+
});
|
|
225
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
226
|
+
const off = onExit(cb);
|
|
227
|
+
process.emit('SIGINT');
|
|
228
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
229
|
+
expect(cb).toHaveBeenCalled();
|
|
230
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
231
|
+
off();
|
|
232
|
+
exitSpy.mockRestore();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('does not call callback after unsubscribe', () => {
|
|
236
|
+
const cb = vi.fn();
|
|
237
|
+
const off = onExit(cb);
|
|
238
|
+
off();
|
|
239
|
+
process.emit('SIGINT');
|
|
240
|
+
expect(cb).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('only runs callback once even if SIGINT emitted multiple times', async () => {
|
|
244
|
+
const cb = vi.fn(async () => {});
|
|
245
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
246
|
+
const off = onExit(cb);
|
|
247
|
+
process.emit('SIGINT');
|
|
248
|
+
process.emit('SIGINT');
|
|
249
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
250
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
251
|
+
off();
|
|
252
|
+
exitSpy.mockRestore();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('catches errors from callback and still exits', async () => {
|
|
256
|
+
const err = new Error('boom');
|
|
257
|
+
const cb = vi.fn(async () => {
|
|
258
|
+
throw err;
|
|
259
|
+
});
|
|
260
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
261
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
262
|
+
const off = onExit(cb);
|
|
263
|
+
process.emit('SIGINT');
|
|
264
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
265
|
+
expect(cb).toHaveBeenCalled();
|
|
266
|
+
expect(consoleSpy).toHaveBeenCalledWith('onExit callback error:', err);
|
|
267
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
268
|
+
off();
|
|
269
|
+
consoleSpy.mockRestore();
|
|
270
|
+
exitSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('init()', () => {
|
|
275
|
+
it('does nothing when stdin/stdout are TTY', () => {
|
|
276
|
+
const origStdinTTY = process.stdin.isTTY;
|
|
277
|
+
const origStdoutTTY = process.stdout.isTTY;
|
|
278
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
279
|
+
process.stdin.isTTY = true;
|
|
280
|
+
process.stdout.isTTY = true;
|
|
281
|
+
expect(() => init()).not.toThrow();
|
|
282
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
283
|
+
exitSpy.mockRestore();
|
|
284
|
+
process.stdin.isTTY = origStdinTTY;
|
|
285
|
+
process.stdout.isTTY = origStdoutTTY;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('exits with code 1 and logs error when not in a TTY', () => {
|
|
289
|
+
const origStdinTTY = process.stdin.isTTY;
|
|
290
|
+
const origStdoutTTY = process.stdout.isTTY;
|
|
291
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
292
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
293
|
+
process.stdin.isTTY = false;
|
|
294
|
+
process.stdout.isTTY = false;
|
|
295
|
+
init();
|
|
296
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
297
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
298
|
+
expect.stringContaining('This TUI requires an interactive terminal'),
|
|
299
|
+
);
|
|
300
|
+
consoleSpy.mockRestore();
|
|
301
|
+
exitSpy.mockRestore();
|
|
302
|
+
process.stdin.isTTY = origStdinTTY;
|
|
303
|
+
process.stdout.isTTY = origStdoutTTY;
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('exports', () => {
|
|
308
|
+
it('exposes `prompt` and `spinner`', () => {
|
|
309
|
+
expect(prompt).toBeDefined();
|
|
310
|
+
expect(typeof spinner).toBe('function');
|
|
311
|
+
const s = spinner('x');
|
|
312
|
+
expect(typeof s.start).toBe('function');
|
|
313
|
+
expect(typeof s.start().stop).toBe('function');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('run() - additional edge cases', () => {
|
|
318
|
+
it('combines stdout and stderr in output field', async () => {
|
|
319
|
+
vi.resetModules();
|
|
320
|
+
vi.doMock('child_process', () => ({
|
|
321
|
+
spawn: () => {
|
|
322
|
+
let stdoutCb;
|
|
323
|
+
let stderrCb;
|
|
324
|
+
let closeCb;
|
|
325
|
+
const child = {
|
|
326
|
+
stdout: {
|
|
327
|
+
on: (ev, cb) => {
|
|
328
|
+
if (ev === 'data') stdoutCb = cb;
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
stderr: {
|
|
332
|
+
on: (ev, cb) => {
|
|
333
|
+
if (ev === 'data') stderrCb = cb;
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
on: (ev, cb) => {
|
|
337
|
+
if (ev === 'close') closeCb = cb;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
process.nextTick(() => {
|
|
341
|
+
if (stdoutCb) stdoutCb(Buffer.from('out\n'));
|
|
342
|
+
if (stderrCb) stderrCb(Buffer.from('err\n'));
|
|
343
|
+
if (closeCb) closeCb(0);
|
|
344
|
+
});
|
|
345
|
+
return child;
|
|
346
|
+
},
|
|
347
|
+
}));
|
|
348
|
+
const { run: mockedRun } = await import('../index.js');
|
|
349
|
+
const res = await mockedRun('anything');
|
|
350
|
+
expect(res.output).toContain('out');
|
|
351
|
+
expect(res.output).toContain('err');
|
|
352
|
+
expect(res.output).toBe(res.stdout + res.stderr);
|
|
353
|
+
vi.resetModules();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('handles null stdout stream gracefully', async () => {
|
|
357
|
+
vi.resetModules();
|
|
358
|
+
vi.doMock('child_process', () => ({
|
|
359
|
+
spawn: () => {
|
|
360
|
+
let closeCb;
|
|
361
|
+
const child = {
|
|
362
|
+
stdout: null,
|
|
363
|
+
stderr: { on: () => {} },
|
|
364
|
+
on: (ev, cb) => {
|
|
365
|
+
if (ev === 'close') closeCb = cb;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
process.nextTick(() => {
|
|
369
|
+
if (closeCb) closeCb(0);
|
|
370
|
+
});
|
|
371
|
+
return child;
|
|
372
|
+
},
|
|
373
|
+
}));
|
|
374
|
+
const { run: mockedRun } = await import('../index.js');
|
|
375
|
+
const res = await mockedRun('anything');
|
|
376
|
+
expect(res.code).toBe(0);
|
|
377
|
+
expect(res.stdout).toBe('');
|
|
378
|
+
expect(res.isError).toBe(false);
|
|
379
|
+
vi.resetModules();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('handles null stderr stream gracefully', async () => {
|
|
383
|
+
vi.resetModules();
|
|
384
|
+
vi.doMock('child_process', () => ({
|
|
385
|
+
spawn: () => {
|
|
386
|
+
let closeCb;
|
|
387
|
+
const child = {
|
|
388
|
+
stdout: { on: () => {} },
|
|
389
|
+
stderr: null,
|
|
390
|
+
on: (ev, cb) => {
|
|
391
|
+
if (ev === 'close') closeCb = cb;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
process.nextTick(() => {
|
|
395
|
+
if (closeCb) closeCb(0);
|
|
396
|
+
});
|
|
397
|
+
return child;
|
|
398
|
+
},
|
|
399
|
+
}));
|
|
400
|
+
const { run: mockedRun } = await import('../index.js');
|
|
401
|
+
const res = await mockedRun('anything');
|
|
402
|
+
expect(res.code).toBe(0);
|
|
403
|
+
expect(res.stderr).toBe('');
|
|
404
|
+
expect(res.isError).toBe(false);
|
|
405
|
+
vi.resetModules();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('handles multiple data chunks correctly', async () => {
|
|
409
|
+
vi.resetModules();
|
|
410
|
+
vi.doMock('child_process', () => ({
|
|
411
|
+
spawn: () => {
|
|
412
|
+
let stdoutCb;
|
|
413
|
+
let closeCb;
|
|
414
|
+
const child = {
|
|
415
|
+
stdout: {
|
|
416
|
+
on: (ev, cb) => {
|
|
417
|
+
if (ev === 'data') stdoutCb = cb;
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
stderr: { on: () => {} },
|
|
421
|
+
on: (ev, cb) => {
|
|
422
|
+
if (ev === 'close') closeCb = cb;
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
process.nextTick(() => {
|
|
426
|
+
if (stdoutCb) {
|
|
427
|
+
stdoutCb(Buffer.from('chunk1\n'));
|
|
428
|
+
stdoutCb(Buffer.from('chunk2\n'));
|
|
429
|
+
stdoutCb(Buffer.from('chunk3\n'));
|
|
430
|
+
}
|
|
431
|
+
if (closeCb) closeCb(0);
|
|
432
|
+
});
|
|
433
|
+
return child;
|
|
434
|
+
},
|
|
435
|
+
}));
|
|
436
|
+
const { run: mockedRun } = await import('../index.js');
|
|
437
|
+
const res = await mockedRun('anything');
|
|
438
|
+
expect(res.stdout).toContain('chunk1');
|
|
439
|
+
expect(res.stdout).toContain('chunk2');
|
|
440
|
+
expect(res.stdout).toContain('chunk3');
|
|
441
|
+
vi.resetModules();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('handles output with no trailing newline', async () => {
|
|
445
|
+
vi.resetModules();
|
|
446
|
+
vi.doMock('child_process', () => ({
|
|
447
|
+
spawn: () => {
|
|
448
|
+
let stdoutCb;
|
|
449
|
+
let closeCb;
|
|
450
|
+
const child = {
|
|
451
|
+
stdout: {
|
|
452
|
+
on: (ev, cb) => {
|
|
453
|
+
if (ev === 'data') stdoutCb = cb;
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
stderr: { on: () => {} },
|
|
457
|
+
on: (ev, cb) => {
|
|
458
|
+
if (ev === 'close') closeCb = cb;
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
process.nextTick(() => {
|
|
462
|
+
if (stdoutCb) stdoutCb(Buffer.from('no newline'));
|
|
463
|
+
if (closeCb) closeCb(0);
|
|
464
|
+
});
|
|
465
|
+
return child;
|
|
466
|
+
},
|
|
467
|
+
}));
|
|
468
|
+
const { run: mockedRun } = await import('../index.js');
|
|
469
|
+
const res = await mockedRun('anything');
|
|
470
|
+
expect(res.stdout).toBe('no newline');
|
|
471
|
+
expect(res.isError).toBe(false);
|
|
472
|
+
vi.resetModules();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('preserves Windows-style line endings (CRLF)', async () => {
|
|
476
|
+
vi.resetModules();
|
|
477
|
+
vi.doMock('child_process', () => ({
|
|
478
|
+
spawn: () => {
|
|
479
|
+
let stdoutCb;
|
|
480
|
+
let closeCb;
|
|
481
|
+
const child = {
|
|
482
|
+
stdout: {
|
|
483
|
+
on: (ev, cb) => {
|
|
484
|
+
if (ev === 'data') stdoutCb = cb;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
stderr: { on: () => {} },
|
|
488
|
+
on: (ev, cb) => {
|
|
489
|
+
if (ev === 'close') closeCb = cb;
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
process.nextTick(() => {
|
|
493
|
+
if (stdoutCb) stdoutCb(Buffer.from('line1\r\nline2\r\n'));
|
|
494
|
+
if (closeCb) closeCb(0);
|
|
495
|
+
});
|
|
496
|
+
return child;
|
|
497
|
+
},
|
|
498
|
+
}));
|
|
499
|
+
const { run: mockedRun } = await import('../index.js');
|
|
500
|
+
const res = await mockedRun('anything');
|
|
501
|
+
expect(res.stdout).toContain('\r\n');
|
|
502
|
+
expect(res.stdout).toMatch(/line1\r\nline2/);
|
|
503
|
+
vi.resetModules();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('uses default maxLines of 10000 when not specified', async () => {
|
|
507
|
+
vi.resetModules();
|
|
508
|
+
vi.doMock('child_process', () => ({
|
|
509
|
+
spawn: () => {
|
|
510
|
+
let stdoutCb;
|
|
511
|
+
let closeCb;
|
|
512
|
+
const child = {
|
|
513
|
+
stdout: {
|
|
514
|
+
on: (ev, cb) => {
|
|
515
|
+
if (ev === 'data') stdoutCb = cb;
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
stderr: { on: () => {} },
|
|
519
|
+
on: (ev, cb) => {
|
|
520
|
+
if (ev === 'close') closeCb = cb;
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
process.nextTick(() => {
|
|
524
|
+
// Send 50 lines - should all be kept with default maxLines
|
|
525
|
+
if (stdoutCb) {
|
|
526
|
+
const lines =
|
|
527
|
+
Array.from({ length: 50 })
|
|
528
|
+
.map((_, i) => `line${i}`)
|
|
529
|
+
.join('\n') + '\n';
|
|
530
|
+
stdoutCb(Buffer.from(lines));
|
|
531
|
+
}
|
|
532
|
+
if (closeCb) closeCb(0);
|
|
533
|
+
});
|
|
534
|
+
return child;
|
|
535
|
+
},
|
|
536
|
+
}));
|
|
537
|
+
const { run: mockedRun } = await import('../index.js');
|
|
538
|
+
const res = await mockedRun('anything'); // No maxLines option
|
|
539
|
+
expect(res.stdout).toContain('line0');
|
|
540
|
+
expect(res.stdout).toContain('line49');
|
|
541
|
+
const lineCount = res.stdout.split(/\r?\n/).filter(Boolean).length;
|
|
542
|
+
expect(lineCount).toBe(50);
|
|
543
|
+
vi.resetModules();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('returns error null when command completes successfully', async () => {
|
|
547
|
+
vi.resetModules();
|
|
548
|
+
vi.doMock('child_process', () => ({
|
|
549
|
+
spawn: () => {
|
|
550
|
+
let closeCb;
|
|
551
|
+
const child = {
|
|
552
|
+
stdout: { on: () => {} },
|
|
553
|
+
stderr: { on: () => {} },
|
|
554
|
+
on: (ev, cb) => {
|
|
555
|
+
if (ev === 'close') closeCb = cb;
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
process.nextTick(() => {
|
|
559
|
+
if (closeCb) closeCb(0);
|
|
560
|
+
});
|
|
561
|
+
return child;
|
|
562
|
+
},
|
|
563
|
+
}));
|
|
564
|
+
const { run: mockedRun } = await import('../index.js');
|
|
565
|
+
const res = await mockedRun('anything');
|
|
566
|
+
expect(res.error).toBeNull();
|
|
567
|
+
expect(res.isError).toBe(false);
|
|
568
|
+
vi.resetModules();
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
describe('onExit() - additional edge cases', () => {
|
|
573
|
+
it('handles synchronous callback', async () => {
|
|
574
|
+
const cb = vi.fn(() => {
|
|
575
|
+
// Synchronous callback
|
|
576
|
+
return 'done';
|
|
577
|
+
});
|
|
578
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
579
|
+
const off = onExit(cb);
|
|
580
|
+
process.emit('SIGINT');
|
|
581
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
582
|
+
expect(cb).toHaveBeenCalled();
|
|
583
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
584
|
+
off();
|
|
585
|
+
exitSpy.mockRestore();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('stops spinner before exiting', async () => {
|
|
589
|
+
const stopFn = vi.fn();
|
|
590
|
+
const startFn = vi.fn(() => ({ stop: stopFn }));
|
|
591
|
+
const mockSpinner = vi.fn(() => ({ start: startFn }));
|
|
592
|
+
|
|
593
|
+
vi.resetModules();
|
|
594
|
+
vi.doMock('ora', () => ({
|
|
595
|
+
default: mockSpinner,
|
|
596
|
+
}));
|
|
597
|
+
|
|
598
|
+
const { onExit: mockedOnExit } = await import('../index.js');
|
|
599
|
+
const cb = vi.fn(async () => {});
|
|
600
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
601
|
+
|
|
602
|
+
const off = mockedOnExit(cb);
|
|
603
|
+
process.emit('SIGINT');
|
|
604
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
605
|
+
|
|
606
|
+
expect(mockSpinner).toHaveBeenCalledWith('Gracefully shutting down...');
|
|
607
|
+
expect(startFn).toHaveBeenCalled();
|
|
608
|
+
expect(stopFn).toHaveBeenCalled();
|
|
609
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
610
|
+
|
|
611
|
+
off();
|
|
612
|
+
exitSpy.mockRestore();
|
|
613
|
+
vi.resetModules();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('supports multiple independent handlers', async () => {
|
|
617
|
+
const cb1 = vi.fn(async () => {});
|
|
618
|
+
const cb2 = vi.fn(async () => {});
|
|
619
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
620
|
+
|
|
621
|
+
const off1 = onExit(cb1);
|
|
622
|
+
const off2 = onExit(cb2);
|
|
623
|
+
|
|
624
|
+
process.emit('SIGINT');
|
|
625
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
626
|
+
|
|
627
|
+
expect(cb1).toHaveBeenCalled();
|
|
628
|
+
expect(cb2).toHaveBeenCalled();
|
|
629
|
+
|
|
630
|
+
off1();
|
|
631
|
+
off2();
|
|
632
|
+
exitSpy.mockRestore();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe('init() - additional edge cases', () => {
|
|
637
|
+
it('exits when only stdin is not TTY', () => {
|
|
638
|
+
const origStdinTTY = process.stdin.isTTY;
|
|
639
|
+
const origStdoutTTY = process.stdout.isTTY;
|
|
640
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
641
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
642
|
+
|
|
643
|
+
process.stdin.isTTY = false;
|
|
644
|
+
process.stdout.isTTY = true;
|
|
645
|
+
|
|
646
|
+
init();
|
|
647
|
+
|
|
648
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
649
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
650
|
+
|
|
651
|
+
consoleSpy.mockRestore();
|
|
652
|
+
exitSpy.mockRestore();
|
|
653
|
+
process.stdin.isTTY = origStdinTTY;
|
|
654
|
+
process.stdout.isTTY = origStdoutTTY;
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('exits when only stdout is not TTY', () => {
|
|
658
|
+
const origStdinTTY = process.stdin.isTTY;
|
|
659
|
+
const origStdoutTTY = process.stdout.isTTY;
|
|
660
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
661
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
662
|
+
|
|
663
|
+
process.stdin.isTTY = true;
|
|
664
|
+
process.stdout.isTTY = false;
|
|
665
|
+
|
|
666
|
+
init();
|
|
667
|
+
|
|
668
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
669
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
670
|
+
|
|
671
|
+
consoleSpy.mockRestore();
|
|
672
|
+
exitSpy.mockRestore();
|
|
673
|
+
process.stdin.isTTY = origStdinTTY;
|
|
674
|
+
process.stdout.isTTY = origStdoutTTY;
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
describe('run() - enhanced edge cases', () => {
|
|
679
|
+
it('trims correctly when exceeding maxLines during streaming', async () => {
|
|
680
|
+
vi.resetModules();
|
|
681
|
+
vi.doMock('child_process', () => ({
|
|
682
|
+
spawn: () => {
|
|
683
|
+
let stdoutCb;
|
|
684
|
+
let closeCb;
|
|
685
|
+
const child = {
|
|
686
|
+
stdout: {
|
|
687
|
+
on: (ev, cb) => {
|
|
688
|
+
if (ev === 'data') stdoutCb = cb;
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
stderr: { on: () => {} },
|
|
692
|
+
on: (ev, cb) => {
|
|
693
|
+
if (ev === 'close') closeCb = cb;
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
process.nextTick(() => {
|
|
697
|
+
if (stdoutCb) {
|
|
698
|
+
// Send 15 lines in 3 batches
|
|
699
|
+
stdoutCb(Buffer.from('1\n2\n3\n4\n5\n'));
|
|
700
|
+
stdoutCb(Buffer.from('6\n7\n8\n9\n10\n'));
|
|
701
|
+
stdoutCb(Buffer.from('11\n12\n13\n14\n15\n'));
|
|
702
|
+
}
|
|
703
|
+
if (closeCb) closeCb(0);
|
|
704
|
+
});
|
|
705
|
+
return child;
|
|
706
|
+
},
|
|
707
|
+
}));
|
|
708
|
+
const { run: mockedRun } = await import('../index.js');
|
|
709
|
+
const res = await mockedRun('anything', { maxLines: 5 });
|
|
710
|
+
// Should only have last 5 lines
|
|
711
|
+
expect(res.stdout).toMatch(/11/);
|
|
712
|
+
expect(res.stdout).toMatch(/15/);
|
|
713
|
+
expect(res.stdout).not.toMatch(/^1$/m);
|
|
714
|
+
expect(res.stdout).not.toMatch(/^5$/m);
|
|
715
|
+
vi.resetModules();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('handles both stdout and stderr with different line counts and trimming', async () => {
|
|
719
|
+
vi.resetModules();
|
|
720
|
+
vi.doMock('child_process', () => ({
|
|
721
|
+
spawn: () => {
|
|
722
|
+
let stdoutCb;
|
|
723
|
+
let stderrCb;
|
|
724
|
+
let closeCb;
|
|
725
|
+
const child = {
|
|
726
|
+
stdout: {
|
|
727
|
+
on: (ev, cb) => {
|
|
728
|
+
if (ev === 'data') stdoutCb = cb;
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
stderr: {
|
|
732
|
+
on: (ev, cb) => {
|
|
733
|
+
if (ev === 'data') stderrCb = cb;
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
on: (ev, cb) => {
|
|
737
|
+
if (ev === 'close') closeCb = cb;
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
process.nextTick(() => {
|
|
741
|
+
// stdout: 10 lines
|
|
742
|
+
if (stdoutCb) {
|
|
743
|
+
stdoutCb(
|
|
744
|
+
Buffer.from(
|
|
745
|
+
Array.from({ length: 10 })
|
|
746
|
+
.map((_, i) => `out${i}`)
|
|
747
|
+
.join('\n') + '\n',
|
|
748
|
+
),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
// stderr: 8 lines
|
|
752
|
+
if (stderrCb) {
|
|
753
|
+
stderrCb(
|
|
754
|
+
Buffer.from(
|
|
755
|
+
Array.from({ length: 8 })
|
|
756
|
+
.map((_, i) => `err${i}`)
|
|
757
|
+
.join('\n') + '\n',
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
if (closeCb) closeCb(0);
|
|
762
|
+
});
|
|
763
|
+
return child;
|
|
764
|
+
},
|
|
765
|
+
}));
|
|
766
|
+
const { run: mockedRun } = await import('../index.js');
|
|
767
|
+
const res = await mockedRun('anything', { maxLines: 3 });
|
|
768
|
+
expect(res.stdout).toMatch(/out7/);
|
|
769
|
+
expect(res.stdout).toMatch(/out9/);
|
|
770
|
+
expect(res.stdout).not.toMatch(/out0/);
|
|
771
|
+
expect(res.stderr).toMatch(/err5/);
|
|
772
|
+
expect(res.stderr).toMatch(/err7/);
|
|
773
|
+
expect(res.stderr).not.toMatch(/err0/);
|
|
774
|
+
vi.resetModules();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('handles output with only newlines', async () => {
|
|
778
|
+
vi.resetModules();
|
|
779
|
+
vi.doMock('child_process', () => ({
|
|
780
|
+
spawn: () => {
|
|
781
|
+
let stdoutCb;
|
|
782
|
+
let closeCb;
|
|
783
|
+
const child = {
|
|
784
|
+
stdout: {
|
|
785
|
+
on: (ev, cb) => {
|
|
786
|
+
if (ev === 'data') stdoutCb = cb;
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
stderr: { on: () => {} },
|
|
790
|
+
on: (ev, cb) => {
|
|
791
|
+
if (ev === 'close') closeCb = cb;
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
process.nextTick(() => {
|
|
795
|
+
if (stdoutCb) stdoutCb(Buffer.from('\n\n\n'));
|
|
796
|
+
if (closeCb) closeCb(0);
|
|
797
|
+
});
|
|
798
|
+
return child;
|
|
799
|
+
},
|
|
800
|
+
}));
|
|
801
|
+
const { run: mockedRun } = await import('../index.js');
|
|
802
|
+
const res = await mockedRun('anything');
|
|
803
|
+
// trimToLastNLines removes trailing newlines, processes lines, then adds back one
|
|
804
|
+
expect(res.stdout).toBe('\n');
|
|
805
|
+
expect(res.isError).toBe(false);
|
|
806
|
+
vi.resetModules();
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('handles empty stdout and stderr', async () => {
|
|
810
|
+
vi.resetModules();
|
|
811
|
+
vi.doMock('child_process', () => ({
|
|
812
|
+
spawn: () => {
|
|
813
|
+
let closeCb;
|
|
814
|
+
const child = {
|
|
815
|
+
stdout: { on: () => {} },
|
|
816
|
+
stderr: { on: () => {} },
|
|
817
|
+
on: (ev, cb) => {
|
|
818
|
+
if (ev === 'close') closeCb = cb;
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
process.nextTick(() => {
|
|
822
|
+
if (closeCb) closeCb(0);
|
|
823
|
+
});
|
|
824
|
+
return child;
|
|
825
|
+
},
|
|
826
|
+
}));
|
|
827
|
+
const { run: mockedRun } = await import('../index.js');
|
|
828
|
+
const res = await mockedRun('anything');
|
|
829
|
+
expect(res.stdout).toBe('');
|
|
830
|
+
expect(res.stderr).toBe('');
|
|
831
|
+
expect(res.output).toBe('');
|
|
832
|
+
expect(res.isError).toBe(false);
|
|
833
|
+
vi.resetModules();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('handles very long single line without newline', async () => {
|
|
837
|
+
vi.resetModules();
|
|
838
|
+
vi.doMock('child_process', () => ({
|
|
839
|
+
spawn: () => {
|
|
840
|
+
let stdoutCb;
|
|
841
|
+
let closeCb;
|
|
842
|
+
const child = {
|
|
843
|
+
stdout: {
|
|
844
|
+
on: (ev, cb) => {
|
|
845
|
+
if (ev === 'data') stdoutCb = cb;
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
stderr: { on: () => {} },
|
|
849
|
+
on: (ev, cb) => {
|
|
850
|
+
if (ev === 'close') closeCb = cb;
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
process.nextTick(() => {
|
|
854
|
+
if (stdoutCb) stdoutCb(Buffer.from('x'.repeat(10000)));
|
|
855
|
+
if (closeCb) closeCb(0);
|
|
856
|
+
});
|
|
857
|
+
return child;
|
|
858
|
+
},
|
|
859
|
+
}));
|
|
860
|
+
const { run: mockedRun } = await import('../index.js');
|
|
861
|
+
const res = await mockedRun('anything');
|
|
862
|
+
expect(res.stdout.length).toBe(10000);
|
|
863
|
+
expect(res.stdout).toBe('x'.repeat(10000));
|
|
864
|
+
vi.resetModules();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('properly handles exit code 127 (command not found)', async () => {
|
|
868
|
+
vi.resetModules();
|
|
869
|
+
vi.doMock('child_process', () => ({
|
|
870
|
+
spawn: () => {
|
|
871
|
+
let stderrCb;
|
|
872
|
+
let closeCb;
|
|
873
|
+
const child = {
|
|
874
|
+
stdout: { on: () => {} },
|
|
875
|
+
stderr: {
|
|
876
|
+
on: (ev, cb) => {
|
|
877
|
+
if (ev === 'data') stderrCb = cb;
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
on: (ev, cb) => {
|
|
881
|
+
if (ev === 'close') closeCb = cb;
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
process.nextTick(() => {
|
|
885
|
+
if (stderrCb) stderrCb(Buffer.from('command not found\n'));
|
|
886
|
+
if (closeCb) closeCb(127);
|
|
887
|
+
});
|
|
888
|
+
return child;
|
|
889
|
+
},
|
|
890
|
+
}));
|
|
891
|
+
const { run: mockedRun } = await import('../index.js');
|
|
892
|
+
const res = await mockedRun('nonexistent-cmd');
|
|
893
|
+
expect(res.isError).toBe(true);
|
|
894
|
+
expect(res.code).toBe(127);
|
|
895
|
+
expect(res.stderr).toMatch(/command not found/);
|
|
896
|
+
vi.resetModules();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('handles alternating stdout/stderr chunks', async () => {
|
|
900
|
+
vi.resetModules();
|
|
901
|
+
vi.doMock('child_process', () => ({
|
|
902
|
+
spawn: () => {
|
|
903
|
+
let stdoutCb;
|
|
904
|
+
let stderrCb;
|
|
905
|
+
let closeCb;
|
|
906
|
+
const child = {
|
|
907
|
+
stdout: {
|
|
908
|
+
on: (ev, cb) => {
|
|
909
|
+
if (ev === 'data') stdoutCb = cb;
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
stderr: {
|
|
913
|
+
on: (ev, cb) => {
|
|
914
|
+
if (ev === 'data') stderrCb = cb;
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
on: (ev, cb) => {
|
|
918
|
+
if (ev === 'close') closeCb = cb;
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
process.nextTick(() => {
|
|
922
|
+
if (stdoutCb) stdoutCb(Buffer.from('out1\n'));
|
|
923
|
+
if (stderrCb) stderrCb(Buffer.from('err1\n'));
|
|
924
|
+
if (stdoutCb) stdoutCb(Buffer.from('out2\n'));
|
|
925
|
+
if (stderrCb) stderrCb(Buffer.from('err2\n'));
|
|
926
|
+
if (closeCb) closeCb(0);
|
|
927
|
+
});
|
|
928
|
+
return child;
|
|
929
|
+
},
|
|
930
|
+
}));
|
|
931
|
+
const { run: mockedRun } = await import('../index.js');
|
|
932
|
+
const res = await mockedRun('anything');
|
|
933
|
+
expect(res.stdout).toContain('out1');
|
|
934
|
+
expect(res.stdout).toContain('out2');
|
|
935
|
+
expect(res.stderr).toContain('err1');
|
|
936
|
+
expect(res.stderr).toContain('err2');
|
|
937
|
+
expect(res.output).toContain('out1');
|
|
938
|
+
expect(res.output).toContain('err1');
|
|
939
|
+
vi.resetModules();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('handles maxLines of 1 correctly', async () => {
|
|
943
|
+
vi.resetModules();
|
|
944
|
+
vi.doMock('child_process', () => ({
|
|
945
|
+
spawn: () => {
|
|
946
|
+
let stdoutCb;
|
|
947
|
+
let closeCb;
|
|
948
|
+
const child = {
|
|
949
|
+
stdout: {
|
|
950
|
+
on: (ev, cb) => {
|
|
951
|
+
if (ev === 'data') stdoutCb = cb;
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
stderr: { on: () => {} },
|
|
955
|
+
on: (ev, cb) => {
|
|
956
|
+
if (ev === 'close') closeCb = cb;
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
process.nextTick(() => {
|
|
960
|
+
if (stdoutCb) stdoutCb(Buffer.from('line1\nline2\nline3\n'));
|
|
961
|
+
if (closeCb) closeCb(0);
|
|
962
|
+
});
|
|
963
|
+
return child;
|
|
964
|
+
},
|
|
965
|
+
}));
|
|
966
|
+
const { run: mockedRun } = await import('../index.js');
|
|
967
|
+
const res = await mockedRun('anything', { maxLines: 1 });
|
|
968
|
+
expect(res.stdout).toBe('line3\n');
|
|
969
|
+
expect(res.stdout).not.toContain('line1');
|
|
970
|
+
expect(res.stdout).not.toContain('line2');
|
|
971
|
+
vi.resetModules();
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it('handles mixed line endings (LF and CRLF)', async () => {
|
|
975
|
+
vi.resetModules();
|
|
976
|
+
vi.doMock('child_process', () => ({
|
|
977
|
+
spawn: () => {
|
|
978
|
+
let stdoutCb;
|
|
979
|
+
let closeCb;
|
|
980
|
+
const child = {
|
|
981
|
+
stdout: {
|
|
982
|
+
on: (ev, cb) => {
|
|
983
|
+
if (ev === 'data') stdoutCb = cb;
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
stderr: { on: () => {} },
|
|
987
|
+
on: (ev, cb) => {
|
|
988
|
+
if (ev === 'close') closeCb = cb;
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
process.nextTick(() => {
|
|
992
|
+
if (stdoutCb) stdoutCb(Buffer.from('unix\nwindows\r\nunix2\n'));
|
|
993
|
+
if (closeCb) closeCb(0);
|
|
994
|
+
});
|
|
995
|
+
return child;
|
|
996
|
+
},
|
|
997
|
+
}));
|
|
998
|
+
const { run: mockedRun } = await import('../index.js');
|
|
999
|
+
const res = await mockedRun('anything');
|
|
1000
|
+
expect(res.stdout).toContain('unix\n');
|
|
1001
|
+
expect(res.stdout).toContain('windows\r\n');
|
|
1002
|
+
expect(res.stdout).toContain('unix2');
|
|
1003
|
+
vi.resetModules();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('handles trailing multiple newlines correctly when trimming', async () => {
|
|
1007
|
+
vi.resetModules();
|
|
1008
|
+
vi.doMock('child_process', () => ({
|
|
1009
|
+
spawn: () => {
|
|
1010
|
+
let stdoutCb;
|
|
1011
|
+
let closeCb;
|
|
1012
|
+
const child = {
|
|
1013
|
+
stdout: {
|
|
1014
|
+
on: (ev, cb) => {
|
|
1015
|
+
if (ev === 'data') stdoutCb = cb;
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
stderr: { on: () => {} },
|
|
1019
|
+
on: (ev, cb) => {
|
|
1020
|
+
if (ev === 'close') closeCb = cb;
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
process.nextTick(() => {
|
|
1024
|
+
if (stdoutCb) {
|
|
1025
|
+
// 10 lines followed by multiple trailing newlines
|
|
1026
|
+
stdoutCb(
|
|
1027
|
+
Buffer.from(
|
|
1028
|
+
Array.from({ length: 10 })
|
|
1029
|
+
.map((_, i) => i)
|
|
1030
|
+
.join('\n') + '\n\n\n\n',
|
|
1031
|
+
),
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
if (closeCb) closeCb(0);
|
|
1035
|
+
});
|
|
1036
|
+
return child;
|
|
1037
|
+
},
|
|
1038
|
+
}));
|
|
1039
|
+
const { run: mockedRun } = await import('../index.js');
|
|
1040
|
+
const res = await mockedRun('anything', { maxLines: 5 });
|
|
1041
|
+
expect(res.stdout).toMatch(/5/);
|
|
1042
|
+
expect(res.stdout).toMatch(/9/);
|
|
1043
|
+
// Trailing newline should be preserved
|
|
1044
|
+
expect(res.stdout).toMatch(/\n$/);
|
|
1045
|
+
vi.resetModules();
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
describe('onExit() - enhanced edge cases', () => {
|
|
1050
|
+
it('handles callback that returns a value (not a promise)', async () => {
|
|
1051
|
+
const cb = vi.fn(() => 42);
|
|
1052
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
1053
|
+
const off = onExit(cb);
|
|
1054
|
+
process.emit('SIGINT');
|
|
1055
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1056
|
+
expect(cb).toHaveBeenCalled();
|
|
1057
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
1058
|
+
off();
|
|
1059
|
+
exitSpy.mockRestore();
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('handles callback throwing synchronously', async () => {
|
|
1063
|
+
const cb = vi.fn(() => {
|
|
1064
|
+
throw new Error('sync error');
|
|
1065
|
+
});
|
|
1066
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1067
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
1068
|
+
const off = onExit(cb);
|
|
1069
|
+
process.emit('SIGINT');
|
|
1070
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1071
|
+
expect(cb).toHaveBeenCalled();
|
|
1072
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
1073
|
+
'onExit callback error:',
|
|
1074
|
+
expect.any(Error),
|
|
1075
|
+
);
|
|
1076
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
1077
|
+
off();
|
|
1078
|
+
consoleSpy.mockRestore();
|
|
1079
|
+
exitSpy.mockRestore();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it('handles spinner that does not have a stop method', async () => {
|
|
1083
|
+
const mockSpinner = vi.fn(() => ({ start: () => ({}) })); // No stop method
|
|
1084
|
+
|
|
1085
|
+
vi.resetModules();
|
|
1086
|
+
vi.doMock('ora', () => ({
|
|
1087
|
+
default: mockSpinner,
|
|
1088
|
+
}));
|
|
1089
|
+
|
|
1090
|
+
const { onExit: mockedOnExit } = await import('../index.js');
|
|
1091
|
+
const cb = vi.fn(async () => {});
|
|
1092
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
1093
|
+
|
|
1094
|
+
const off = mockedOnExit(cb);
|
|
1095
|
+
process.emit('SIGINT');
|
|
1096
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1097
|
+
|
|
1098
|
+
// Should not throw, should still exit
|
|
1099
|
+
expect(cb).toHaveBeenCalled();
|
|
1100
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
1101
|
+
|
|
1102
|
+
off();
|
|
1103
|
+
exitSpy.mockRestore();
|
|
1104
|
+
vi.resetModules();
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('throws TypeError with descriptive message for non-function', () => {
|
|
1108
|
+
expect(() => onExit(null)).toThrow(TypeError);
|
|
1109
|
+
expect(() => onExit(null)).toThrow('onExit requires a callback function');
|
|
1110
|
+
expect(() => onExit(undefined)).toThrow(TypeError);
|
|
1111
|
+
expect(() => onExit('string')).toThrow(TypeError);
|
|
1112
|
+
expect(() => onExit({})).toThrow(TypeError);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('can be called with an async arrow function', async () => {
|
|
1116
|
+
const cb = vi.fn(async () => {
|
|
1117
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1118
|
+
});
|
|
1119
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
1120
|
+
const off = onExit(cb);
|
|
1121
|
+
process.emit('SIGINT');
|
|
1122
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
1123
|
+
expect(cb).toHaveBeenCalled();
|
|
1124
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
1125
|
+
off();
|
|
1126
|
+
exitSpy.mockRestore();
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('unsubscribe can be called multiple times safely', () => {
|
|
1130
|
+
const cb = vi.fn();
|
|
1131
|
+
const off = onExit(cb);
|
|
1132
|
+
off(); // First unsubscribe
|
|
1133
|
+
off(); // Second unsubscribe - should not throw
|
|
1134
|
+
process.emit('SIGINT');
|
|
1135
|
+
expect(cb).not.toHaveBeenCalled();
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
describe('prompt and spinner re-exports', () => {
|
|
1140
|
+
it('prompt re-export handles default export', () => {
|
|
1141
|
+
expect(prompt).toBeDefined();
|
|
1142
|
+
// prompt should be the inquirer object or its default
|
|
1143
|
+
expect(typeof prompt).toBeTruthy();
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it('spinner creates a spinner with correct API', () => {
|
|
1147
|
+
const s = spinner('Loading...');
|
|
1148
|
+
expect(s).toBeDefined();
|
|
1149
|
+
expect(typeof s.start).toBe('function');
|
|
1150
|
+
const started = s.start();
|
|
1151
|
+
expect(typeof started.stop).toBe('function');
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it('spinner can be started and stopped multiple times', () => {
|
|
1155
|
+
const s = spinner('Test');
|
|
1156
|
+
const started1 = s.start();
|
|
1157
|
+
started1.stop();
|
|
1158
|
+
const started2 = s.start();
|
|
1159
|
+
expect(typeof started2.stop).toBe('function');
|
|
1160
|
+
});
|
|
1161
|
+
});
|