@moxxy/plugin-terminal 0.27.0

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.
@@ -0,0 +1,368 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { runCommand } from './terminal.js';
3
+ import type { TerminalProcess } from './pty.js';
4
+
5
+ /** A fake shared terminal whose data stream we drive by hand. `feed` lets a
6
+ * test emit chunks; `exit` fires the exit listeners; `writes` records every
7
+ * write so a test can assert serialization / non-interleaving; `kills` counts
8
+ * kill() calls so a test can prove an abort does NOT kill the shared shell. */
9
+ function fakeTerminal(): TerminalProcess & {
10
+ feed(s: string): void;
11
+ exit(code: number): void;
12
+ writes: string[];
13
+ kills: { n: number };
14
+ dataListenerCount(): number;
15
+ exitListenerCount(): number;
16
+ } {
17
+ const dataListeners = new Set<(d: string) => void>();
18
+ const exitListeners = new Set<(c: number) => void>();
19
+ const writes: string[] = [];
20
+ const kills = { n: 0 };
21
+ return {
22
+ backend: 'pipe',
23
+ ptyError: null,
24
+ onData: (cb) => {
25
+ dataListeners.add(cb);
26
+ return () => dataListeners.delete(cb);
27
+ },
28
+ onExit: (cb) => {
29
+ exitListeners.add(cb);
30
+ return () => exitListeners.delete(cb);
31
+ },
32
+ scrollback: () => '',
33
+ write: (d: string) => {
34
+ writes.push(d);
35
+ },
36
+ resize: () => {},
37
+ kill: () => {
38
+ kills.n += 1;
39
+ },
40
+ alive: true,
41
+ feed: (s: string) => {
42
+ for (const cb of [...dataListeners]) cb(s);
43
+ },
44
+ exit: (code: number) => {
45
+ for (const cb of [...exitListeners]) cb(code);
46
+ },
47
+ writes,
48
+ kills,
49
+ dataListenerCount: () => dataListeners.size,
50
+ exitListenerCount: () => exitListeners.size,
51
+ };
52
+ }
53
+
54
+ /** Drain microtasks until `cond` holds. The serialized command's deferred write
55
+ * fires on a microtask after the prior tail resolves, so a fixed number of
56
+ * `await Promise.resolve()` hops flushes it deterministically (no real timers).
57
+ * Bounded so a genuinely-never-true condition fails the test instead of hanging. */
58
+ async function waitFor(cond: () => boolean, maxHops = 50): Promise<void> {
59
+ for (let i = 0; i < maxHops; i += 1) {
60
+ if (cond()) return;
61
+ await Promise.resolve();
62
+ }
63
+ if (!cond()) throw new Error('waitFor: condition not met within microtask budget');
64
+ }
65
+
66
+ describe('runCommand sentinel detection (tail-scan, single RegExp compile)', () => {
67
+ it('completes when the sentinel arrives after many small chunks', async () => {
68
+ const term = fakeTerminal();
69
+ const marker = '__MOXXY_DONE_abc_0__';
70
+ const p = runCommand(term, 'echo hi', marker, 5_000);
71
+
72
+ // Stream a lot of output in tiny chunks (the O(n^2) re-scan path), then the
73
+ // sentinel on its own line with exit code 0.
74
+ for (let i = 0; i < 2000; i += 1) term.feed(`line ${i}\n`);
75
+ term.feed(`${marker} 0\n`);
76
+
77
+ const res = await p;
78
+ expect(res.timedOut).toBe(false);
79
+ expect(res.exitCode).toBe(0);
80
+ expect(res.output).toContain('line 0');
81
+ expect(res.output).toContain('line 1999');
82
+ expect(res.output).not.toContain(marker);
83
+ });
84
+
85
+ it('detects a sentinel split across two chunks (carry-over window)', async () => {
86
+ const term = fakeTerminal();
87
+ const marker = '__MOXXY_DONE_xyz_1__';
88
+ const p = runCommand(term, 'false', marker, 5_000);
89
+
90
+ term.feed('some output\n');
91
+ // Split the sentinel right in the middle of the marker AND the exit code.
92
+ const sentinel = `${marker} 42\n`;
93
+ const cut = marker.length - 3;
94
+ term.feed(sentinel.slice(0, cut));
95
+ term.feed(sentinel.slice(cut));
96
+
97
+ const res = await p;
98
+ expect(res.exitCode).toBe(42);
99
+ expect(res.timedOut).toBe(false);
100
+ });
101
+
102
+ it('does not false-trigger on the echoed command line containing $?', async () => {
103
+ const term = fakeTerminal();
104
+ const marker = '__MOXXY_DONE_q_2__';
105
+ const p = runCommand(term, 'echo done', marker, 5_000);
106
+
107
+ // The shell echoes the printf command itself first (contains the marker but
108
+ // NOT "<marker> <digits>"), then later the real sentinel value line.
109
+ term.feed(`printf '%s %s\\n' "${marker}" "$?"\n`);
110
+ term.feed('echo done\n');
111
+ term.feed('done\n');
112
+ term.feed(`${marker} 0\n`);
113
+
114
+ const res = await p;
115
+ expect(res.exitCode).toBe(0);
116
+ expect(res.output).toBe('done');
117
+ });
118
+
119
+ it('times out without a sentinel and reports timedOut', async () => {
120
+ vi.useFakeTimers();
121
+ try {
122
+ const term = fakeTerminal();
123
+ const p = runCommand(term, 'sleep 9', '__MOXXY_DONE_t_3__', 100);
124
+ term.feed('partial output\n');
125
+ vi.advanceTimersByTime(100);
126
+ const res = await p;
127
+ expect(res.timedOut).toBe(true);
128
+ expect(res.exitCode).toBeNull();
129
+ expect(res.output).toContain('partial output');
130
+ } finally {
131
+ vi.useRealTimers();
132
+ }
133
+ });
134
+
135
+ // HIGH: a flood of output before the sentinel must NOT grow the accumulator
136
+ // unbounded (OOM vector). The sentinel still detects at the tail.
137
+ it('bounds the accumulator while still detecting a trailing sentinel', async () => {
138
+ const term = fakeTerminal();
139
+ const marker = '__MOXXY_DONE_flood_0__';
140
+ const p = runCommand(term, 'yes', marker, 5_000);
141
+
142
+ // ~5MB of output in big chunks — far past the 1MB cap.
143
+ const big = 'A'.repeat(100_000) + '\n';
144
+ for (let i = 0; i < 50; i += 1) term.feed(big);
145
+ term.feed(`${marker} 0\n`);
146
+
147
+ const res = await p;
148
+ expect(res.timedOut).toBe(false);
149
+ expect(res.exitCode).toBe(0);
150
+ // The returned output is the bounded tail, not the full ~5MB.
151
+ expect(res.output.length).toBeLessThanOrEqual(1_000_000);
152
+ expect(res.output).not.toContain(marker);
153
+ });
154
+
155
+ // MEDIUM: command OUTPUT that merely CONTAINS the marker string (a printed
156
+ // transcript, a grep hit) must not false-trigger completion — only the
157
+ // printf's own line, anchored to a newline, ends the command.
158
+ it('does not complete on marker text embedded mid-line in output', async () => {
159
+ vi.useFakeTimers();
160
+ try {
161
+ const term = fakeTerminal();
162
+ const marker = '__MOXXY_DONE_spoof_0__';
163
+ const p = runCommand(term, 'cat transcript', marker, 100);
164
+
165
+ // Hostile output: the literal "<marker> 137" but NOT on its own line.
166
+ term.feed(`prefix ${marker} 137 suffix\n`);
167
+ // Still no real sentinel → must time out, not report exitCode 137.
168
+ vi.advanceTimersByTime(100);
169
+ const res = await p;
170
+ expect(res.timedOut).toBe(true);
171
+ expect(res.exitCode).toBeNull();
172
+ } finally {
173
+ vi.useRealTimers();
174
+ }
175
+ });
176
+
177
+ // MEDIUM: a dead shell must end the command promptly with the shell's exit
178
+ // code instead of hanging to the full timeout.
179
+ it('finishes immediately when the shared shell exits before the sentinel', async () => {
180
+ const term = fakeTerminal();
181
+ const p = runCommand(term, 'sleep 100', '__MOXXY_DONE_exit_0__', 600_000);
182
+ term.feed('starting...\n');
183
+ term.exit(143); // user typed `exit` / shell was killed
184
+ const res = await p;
185
+ expect(res.timedOut).toBe(false);
186
+ expect(res.exitCode).toBe(143);
187
+ expect(res.output).toContain('starting...');
188
+ });
189
+
190
+ // HIGH: two concurrent runCommand calls on the SAME shared shell must not
191
+ // interleave their writes on the single stdin. Command 2's writes are gated on
192
+ // command 1's tail, so they fire a microtask AFTER command 1 resolves — asserting
193
+ // `term.writes` synchronously after `await p1` is too early (the deferred
194
+ // `startWrites.then(writeAndArm)` hasn't run yet). Instead we await BOTH commands
195
+ // to completion and assert the RECORDED write order proves serialization.
196
+ it('serializes concurrent commands on one shell (no interleaved writes)', async () => {
197
+ const term = fakeTerminal();
198
+ const m1 = '__MOXXY_DONE_a_0__';
199
+ const m2 = '__MOXXY_DONE_b_0__';
200
+ const p1 = runCommand(term, 'first', m1, 5_000);
201
+ const p2 = runCommand(term, 'second', m2, 5_000);
202
+
203
+ // The first command (idle shell) writes synchronously; the second waits its
204
+ // turn behind command 1's tail and has written nothing yet.
205
+ expect(term.writes.join('')).toContain('first\n');
206
+ expect(term.writes.join('')).not.toContain('second\n');
207
+
208
+ // Complete the first. `await p1` resumes before command 2's deferred write
209
+ // has flushed, so we drive command 2 to completion too and assert afterwards.
210
+ term.feed(`${m1} 0\n`);
211
+ const r1 = await p1;
212
+ expect(r1.exitCode).toBe(0);
213
+
214
+ // Command 2's write fires on a microtask after p1 resolves; let the queue
215
+ // drain so its command-line lands, then feed its sentinel and await it.
216
+ await waitFor(() => term.writes.join('').includes('second\n'));
217
+ term.feed(`${m2} 5\n`);
218
+ const r2 = await p2;
219
+ expect(r2.exitCode).toBe(5);
220
+
221
+ // Serialization proof: command 2 wrote nothing until command 1 had fully
222
+ // written — 'first\n' precedes 'second\n', and the sentinel printf that
223
+ // captures command 1's `$?` precedes command 2's command line (no interleave).
224
+ const all = term.writes.join('');
225
+ const firstAt = all.indexOf('first\n');
226
+ const secondAt = all.indexOf('second\n');
227
+ expect(firstAt).toBeGreaterThanOrEqual(0);
228
+ expect(secondAt).toBeGreaterThan(firstAt);
229
+ expect(all.indexOf(`"${m1}" "$?"`)).toBeLessThan(secondAt);
230
+ });
231
+
232
+ // MEDIUM: a command ending in a shell continuation (trailing backslash / an
233
+ // open quote) must NOT swallow the sentinel printf as a continuation line —
234
+ // otherwise the sentinel is never emitted and the call hangs to the full
235
+ // timeout. The fix sends a leading newline before the printf to terminate any
236
+ // dangling command line. Assert that newline-prefix is actually on the wire.
237
+ it('prefixes the sentinel printf with a newline so a dangling command line cannot swallow it', async () => {
238
+ const term = fakeTerminal();
239
+ const marker = '__MOXXY_DONE_cont_0__';
240
+ // A command with a trailing backslash — in a raw shell this would treat the
241
+ // next line as a continuation. The leading "\n" before printf breaks that.
242
+ const p = runCommand(term, 'echo hi \\', marker, 5_000);
243
+
244
+ const all = term.writes.join('');
245
+ // The printf line is preceded by a newline (its own write begins with "\n").
246
+ expect(all).toContain(`\nprintf '%s %s\\n' "${marker}" "$?"\n`);
247
+
248
+ // And completion still works end-to-end.
249
+ term.feed(`${marker} 0\n`);
250
+ const res = await p;
251
+ expect(res.exitCode).toBe(0);
252
+ expect(res.timedOut).toBe(false);
253
+ });
254
+
255
+ // A whitespace-only command (passes the schema's min(1)) must not crash
256
+ // cleanOutput's per-line command-stripping and must still complete.
257
+ it('handles a whitespace-only command without stripping blank output lines or crashing', async () => {
258
+ const term = fakeTerminal();
259
+ const marker = '__MOXXY_DONE_ws_0__';
260
+ const p = runCommand(term, ' ', marker, 5_000);
261
+ term.feed('\n'); // a blank output line must survive (commandLines filters empties)
262
+ term.feed('real output\n');
263
+ term.feed(`${marker} 0\n`);
264
+ const res = await p;
265
+ expect(res.exitCode).toBe(0);
266
+ expect(res.output).toContain('real output');
267
+ });
268
+
269
+ // LOW: a multi-line command's echoed lines are all stripped from the output.
270
+ it('strips every echoed line of a multi-line command', async () => {
271
+ const term = fakeTerminal();
272
+ const marker = '__MOXXY_DONE_ml_0__';
273
+ const command = 'echo one\necho two';
274
+ const p = runCommand(term, command, marker, 5_000);
275
+
276
+ // The PTY echoes each command line separately, then the real output + sentinel.
277
+ term.feed('echo one\n');
278
+ term.feed('echo two\n');
279
+ term.feed('one\n');
280
+ term.feed('two\n');
281
+ term.feed(`${marker} 0\n`);
282
+
283
+ const res = await p;
284
+ expect(res.output).toBe('one\ntwo');
285
+ });
286
+ });
287
+
288
+ describe('runCommand abort handling (turn-cancel must not hang the tool slot)', () => {
289
+ // The turn's AbortSignal must end a running command promptly — without it the
290
+ // call blocked the tool slot for up to the full timeout (600s) after the user
291
+ // cancelled the turn.
292
+ it('finishes promptly when the signal aborts mid-run (no timeout wait)', async () => {
293
+ vi.useFakeTimers();
294
+ try {
295
+ const term = fakeTerminal();
296
+ const ac = new AbortController();
297
+ // A 10-minute timeout: only an honored abort can finish this fast.
298
+ const p = runCommand(term, 'sleep 600', '__MOXXY_DONE_ab_0__', 600_000, ac.signal);
299
+ term.feed('working...\n');
300
+ ac.abort();
301
+ const res = await p;
302
+ expect(res.timedOut).toBe(false);
303
+ expect(res.exitCode).toBeNull();
304
+ // Whatever was captured before the abort is returned (degrade, not crash).
305
+ expect(res.output).toContain('working...');
306
+ // The shared shell is user-facing — an abort must NOT kill it.
307
+ expect(term.kills.n).toBe(0);
308
+ } finally {
309
+ vi.useRealTimers();
310
+ }
311
+ });
312
+
313
+ it('returns immediately for a signal already aborted before the call', async () => {
314
+ const term = fakeTerminal();
315
+ const ac = new AbortController();
316
+ ac.abort(); // pre-aborted
317
+ const p = runCommand(term, 'echo hi', '__MOXXY_DONE_ab_1__', 5_000, ac.signal);
318
+ const res = await p;
319
+ expect(res.timedOut).toBe(false);
320
+ expect(res.exitCode).toBeNull();
321
+ expect(res.output).toBe('');
322
+ // Pre-aborted → nothing is ever written to the shell, and it is never killed.
323
+ expect(term.writes.length).toBe(0);
324
+ expect(term.kills.n).toBe(0);
325
+ });
326
+
327
+ it('removes its abort listener on normal completion (no leak across commands)', async () => {
328
+ const term = fakeTerminal();
329
+ const ac = new AbortController();
330
+ const marker = '__MOXXY_DONE_ab_2__';
331
+ const p = runCommand(term, 'echo hi', marker, 5_000, ac.signal);
332
+ term.feed(`${marker} 0\n`);
333
+ const res = await p;
334
+ expect(res.exitCode).toBe(0);
335
+ // After the command finished normally, a later abort must not throw or have
336
+ // any effect (the listener was removed in finish()).
337
+ expect(() => ac.abort()).not.toThrow();
338
+ // The shared shell's own data/exit listeners are all unsubscribed too.
339
+ expect(term.dataListenerCount()).toBe(0);
340
+ expect(term.exitListenerCount()).toBe(0);
341
+ });
342
+
343
+ it('aborts a command still QUEUED behind a slow predecessor without writing it', async () => {
344
+ const term = fakeTerminal();
345
+ const m1 = '__MOXXY_DONE_ab_q1__';
346
+ const m2 = '__MOXXY_DONE_ab_q2__';
347
+ const ac = new AbortController();
348
+ const p1 = runCommand(term, 'slow', m1, 600_000);
349
+ const p2 = runCommand(term, 'queued', m2, 600_000, ac.signal);
350
+
351
+ // Command 1 wrote synchronously; command 2 is queued behind it (not written).
352
+ expect(term.writes.join('')).toContain('slow\n');
353
+ expect(term.writes.join('')).not.toContain('queued\n');
354
+
355
+ // Abort command 2 while it is still queued → it resolves without ever writing.
356
+ ac.abort();
357
+ const r2 = await p2;
358
+ expect(r2.exitCode).toBeNull();
359
+ expect(r2.timedOut).toBe(false);
360
+ expect(term.writes.join('')).not.toContain('queued\n');
361
+
362
+ // Command 1 is unaffected and still completes on its own sentinel.
363
+ term.feed(`${m1} 0\n`);
364
+ const r1 = await p1;
365
+ expect(r1.exitCode).toBe(0);
366
+ expect(term.kills.n).toBe(0);
367
+ });
368
+ });