seahorse-bash-client 1.0.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,555 @@
1
+ /**
2
+ * PTY Manager Unit Tests
3
+ *
4
+ * Tests for verifying PTY session management, command execution,
5
+ * and output buffering functionality.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import { PTYManager } from './pty-manager';
10
+
11
+ describe('PTYManager', () => {
12
+ let ptyManager: PTYManager;
13
+
14
+ beforeEach(() => {
15
+ ptyManager = new PTYManager({
16
+ maxOutputLines: 1000,
17
+ defaultShell: '/bin/bash',
18
+ });
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await ptyManager.shutdown();
23
+ });
24
+
25
+ describe('exec', () => {
26
+ it('should execute a simple command and return output', async () => {
27
+ const result = await ptyManager.exec({
28
+ command: 'echo "hello world"',
29
+ timeout: 5000,
30
+ });
31
+
32
+ expect(result.exitCode).toBe(0);
33
+ expect(result.stdout).toContain('hello world');
34
+ expect(result.timedOut).toBe(false);
35
+ });
36
+
37
+ it('should capture exit code from failed command', async () => {
38
+ const result = await ptyManager.exec({
39
+ command: 'exit 42',
40
+ timeout: 5000,
41
+ });
42
+
43
+ expect(result.exitCode).toBe(42);
44
+ expect(result.timedOut).toBe(false);
45
+ });
46
+
47
+ it('should timeout long-running commands', async () => {
48
+ const result = await ptyManager.exec({
49
+ command: 'sleep 10',
50
+ timeout: 500,
51
+ });
52
+
53
+ expect(result.exitCode).toBe(-1);
54
+ expect(result.timedOut).toBe(true);
55
+ });
56
+
57
+ it('should respect working directory', async () => {
58
+ const result = await ptyManager.exec({
59
+ command: 'pwd',
60
+ cwd: '/tmp',
61
+ timeout: 5000,
62
+ });
63
+
64
+ expect(result.exitCode).toBe(0);
65
+ // PTY may include ANSI codes, so just check contains
66
+ expect(result.stdout).toContain('/tmp');
67
+ });
68
+
69
+ it('should pass environment variables', async () => {
70
+ const result = await ptyManager.exec({
71
+ command: 'echo $MY_VAR',
72
+ env: { MY_VAR: 'test_value' },
73
+ timeout: 5000,
74
+ });
75
+
76
+ expect(result.exitCode).toBe(0);
77
+ expect(result.stdout).toContain('test_value');
78
+ });
79
+
80
+ it('should handle commands with special characters', async () => {
81
+ const result = await ptyManager.exec({
82
+ command: 'echo "test with \'quotes\' and $dollar"',
83
+ timeout: 5000,
84
+ });
85
+
86
+ expect(result.exitCode).toBe(0);
87
+ expect(result.stdout).toContain('quotes');
88
+ });
89
+
90
+ it('should handle multi-line output', async () => {
91
+ const result = await ptyManager.exec({
92
+ command: 'echo -e "line1\\nline2\\nline3"',
93
+ timeout: 5000,
94
+ });
95
+
96
+ expect(result.exitCode).toBe(0);
97
+ expect(result.stdout).toContain('line1');
98
+ expect(result.stdout).toContain('line2');
99
+ expect(result.stdout).toContain('line3');
100
+ });
101
+
102
+ it('should handle stderr output', async () => {
103
+ const result = await ptyManager.exec({
104
+ command: 'echo "error message" >&2',
105
+ timeout: 5000,
106
+ });
107
+
108
+ // PTY combines stdout and stderr
109
+ expect(result.exitCode).toBe(0);
110
+ expect(result.stdout).toContain('error message');
111
+ });
112
+ });
113
+
114
+ describe('spawn', () => {
115
+ it('should create a new PTY session', async () => {
116
+ const result = await ptyManager.spawn({
117
+ command: 'echo "spawn test"',
118
+ });
119
+
120
+ expect(result.sessionId).toMatch(/^pty_/);
121
+ expect(result.pid).toBeGreaterThan(0);
122
+ expect(result.status).toBe('running');
123
+ });
124
+
125
+ it('should track session in list', async () => {
126
+ const spawnResult = await ptyManager.spawn({
127
+ command: 'sleep 1',
128
+ name: 'test-session',
129
+ });
130
+
131
+ const listResult = ptyManager.list({ status: 'all' });
132
+ expect(listResult.sessions.length).toBeGreaterThanOrEqual(1);
133
+
134
+ const session = listResult.sessions.find(
135
+ (s) => s.sessionId === spawnResult.sessionId
136
+ );
137
+ expect(session).toBeDefined();
138
+ expect(session?.name).toBe('test-session');
139
+ });
140
+
141
+ it('should capture output to buffer', async () => {
142
+ const spawnResult = await ptyManager.spawn({
143
+ command: 'echo "buffered output"',
144
+ });
145
+
146
+ // Wait for output to be captured
147
+ await new Promise((resolve) => setTimeout(resolve, 200));
148
+
149
+ const readResult = ptyManager.read({
150
+ sessionId: spawnResult.sessionId,
151
+ limit: 100,
152
+ });
153
+
154
+ expect(readResult.lines.length).toBeGreaterThanOrEqual(1);
155
+ });
156
+
157
+ it('should respect terminal size', async () => {
158
+ const result = await ptyManager.spawn({
159
+ command: 'tput cols && tput lines',
160
+ cols: 80,
161
+ rows: 24,
162
+ });
163
+
164
+ expect(result.sessionId).toMatch(/^pty_/);
165
+ // The command should run in the specified terminal size
166
+ });
167
+ });
168
+
169
+ describe('write', () => {
170
+ it('should write data to a running session', async () => {
171
+ // Start an interactive session
172
+ const spawnResult = await ptyManager.spawn({
173
+ // No command - just a shell
174
+ });
175
+
176
+ await new Promise((resolve) => setTimeout(resolve, 100));
177
+
178
+ const writeResult = ptyManager.write({
179
+ sessionId: spawnResult.sessionId,
180
+ data: 'echo "written data"\n',
181
+ });
182
+
183
+ expect(writeResult.success).toBe(true);
184
+ expect(writeResult.bytesWritten).toBeGreaterThan(0);
185
+ });
186
+
187
+ it('should send special keys', async () => {
188
+ const spawnResult = await ptyManager.spawn({
189
+ command: 'sleep 30',
190
+ });
191
+
192
+ await new Promise((resolve) => setTimeout(resolve, 100));
193
+
194
+ // Send Ctrl+C
195
+ const writeResult = ptyManager.write({
196
+ sessionId: spawnResult.sessionId,
197
+ specialKey: 'ctrl+c',
198
+ });
199
+
200
+ expect(writeResult.success).toBe(true);
201
+ expect(writeResult.bytesWritten).toBe(1); // \x03 is 1 byte
202
+ });
203
+
204
+ it('should fail for non-existent session', () => {
205
+ expect(() =>
206
+ ptyManager.write({
207
+ sessionId: 'non_existent',
208
+ data: 'test',
209
+ })
210
+ ).toThrow('Session not found');
211
+ });
212
+ });
213
+
214
+ describe('read', () => {
215
+ it('should read output from session buffer', async () => {
216
+ const spawnResult = await ptyManager.spawn({
217
+ command: 'for i in 1 2 3 4 5; do echo "line $i"; done',
218
+ });
219
+
220
+ // Wait for output
221
+ await new Promise((resolve) => setTimeout(resolve, 300));
222
+
223
+ const readResult = ptyManager.read({
224
+ sessionId: spawnResult.sessionId,
225
+ limit: 100,
226
+ });
227
+
228
+ expect(readResult.sessionId).toBe(spawnResult.sessionId);
229
+ expect(readResult.lines.length).toBeGreaterThan(0);
230
+ });
231
+
232
+ it('should support pagination with offset and limit', async () => {
233
+ const spawnResult = await ptyManager.spawn({
234
+ command:
235
+ 'for i in $(seq 1 20); do echo "line $i"; done',
236
+ });
237
+
238
+ await new Promise((resolve) => setTimeout(resolve, 300));
239
+
240
+ const readResult = ptyManager.read({
241
+ sessionId: spawnResult.sessionId,
242
+ offset: 5,
243
+ limit: 3,
244
+ });
245
+
246
+ expect(readResult.lines.length).toBeLessThanOrEqual(3);
247
+ });
248
+
249
+ it('should support tail mode', async () => {
250
+ const spawnResult = await ptyManager.spawn({
251
+ command:
252
+ 'for i in $(seq 1 20); do echo "line $i"; done',
253
+ });
254
+
255
+ await new Promise((resolve) => setTimeout(resolve, 300));
256
+
257
+ const readResult = ptyManager.read({
258
+ sessionId: spawnResult.sessionId,
259
+ tail: true,
260
+ limit: 5,
261
+ });
262
+
263
+ expect(readResult.lines.length).toBeLessThanOrEqual(5);
264
+ });
265
+
266
+ it('should support pattern filtering', async () => {
267
+ const spawnResult = await ptyManager.spawn({
268
+ command: 'echo "apple"; echo "banana"; echo "apricot"',
269
+ });
270
+
271
+ await new Promise((resolve) => setTimeout(resolve, 300));
272
+
273
+ const readResult = ptyManager.read({
274
+ sessionId: spawnResult.sessionId,
275
+ pattern: '^a',
276
+ limit: 100,
277
+ });
278
+
279
+ // Should filter to lines starting with 'a'
280
+ const matchingLines = readResult.lines.filter((l) =>
281
+ l.text.toLowerCase().startsWith('a')
282
+ );
283
+ expect(matchingLines.length).toBeGreaterThanOrEqual(0);
284
+ });
285
+
286
+ it('should fail for non-existent session', () => {
287
+ expect(() =>
288
+ ptyManager.read({
289
+ sessionId: 'non_existent',
290
+ })
291
+ ).toThrow('Session not found');
292
+ });
293
+ });
294
+
295
+ describe('list', () => {
296
+ it('should return empty list when no sessions', () => {
297
+ const result = ptyManager.list();
298
+
299
+ expect(result.sessions).toEqual([]);
300
+ expect(result.summary.total).toBe(0);
301
+ });
302
+
303
+ it('should filter by status', async () => {
304
+ // Create a session that will exit
305
+ await ptyManager.spawn({
306
+ command: 'echo "quick"',
307
+ });
308
+
309
+ await new Promise((resolve) => setTimeout(resolve, 200));
310
+
311
+ const runningResult = ptyManager.list({ status: 'running' });
312
+ const allResult = ptyManager.list({ status: 'all' });
313
+
314
+ expect(allResult.sessions.length).toBeGreaterThanOrEqual(
315
+ runningResult.sessions.length
316
+ );
317
+ });
318
+
319
+ it('should include recent output when requested', async () => {
320
+ const spawnResult = await ptyManager.spawn({
321
+ command: 'echo "output test"',
322
+ });
323
+
324
+ await new Promise((resolve) => setTimeout(resolve, 200));
325
+
326
+ const result = ptyManager.list({
327
+ includeOutput: true,
328
+ outputLines: 5,
329
+ });
330
+
331
+ const session = result.sessions.find(
332
+ (s) => s.sessionId === spawnResult.sessionId
333
+ );
334
+ expect(session?.recentOutput).toBeDefined();
335
+ });
336
+ });
337
+
338
+ describe('kill', () => {
339
+ it('should terminate a running session', async () => {
340
+ const spawnResult = await ptyManager.spawn({
341
+ command: 'sleep 60',
342
+ });
343
+
344
+ await new Promise((resolve) => setTimeout(resolve, 100));
345
+
346
+ const killResult = await ptyManager.kill({
347
+ sessionId: spawnResult.sessionId,
348
+ });
349
+
350
+ expect(killResult.sessionId).toBe(spawnResult.sessionId);
351
+ expect(killResult.signal).toBe('SIGTERM');
352
+ });
353
+
354
+ it('should cleanup session when requested', async () => {
355
+ const spawnResult = await ptyManager.spawn({
356
+ command: 'sleep 60',
357
+ });
358
+
359
+ await new Promise((resolve) => setTimeout(resolve, 100));
360
+
361
+ await ptyManager.kill({
362
+ sessionId: spawnResult.sessionId,
363
+ cleanup: true,
364
+ });
365
+
366
+ const listResult = ptyManager.list();
367
+ const session = listResult.sessions.find(
368
+ (s) => s.sessionId === spawnResult.sessionId
369
+ );
370
+ expect(session).toBeUndefined();
371
+ });
372
+
373
+ it('should fail for non-existent session', async () => {
374
+ await expect(
375
+ ptyManager.kill({
376
+ sessionId: 'non_existent',
377
+ })
378
+ ).rejects.toThrow('Session not found');
379
+ });
380
+ });
381
+
382
+ describe('Interactive PTY Tests', () => {
383
+ it('should handle interactive shell input/output', async () => {
384
+ // Start an interactive shell
385
+ const spawnResult = await ptyManager.spawn({});
386
+
387
+ await new Promise((resolve) => setTimeout(resolve, 200));
388
+
389
+ // Write a command
390
+ ptyManager.write({
391
+ sessionId: spawnResult.sessionId,
392
+ data: 'echo "interactive test"\n',
393
+ });
394
+
395
+ // Wait for output
396
+ await new Promise((resolve) => setTimeout(resolve, 300));
397
+
398
+ const readResult = ptyManager.read({
399
+ sessionId: spawnResult.sessionId,
400
+ tail: true,
401
+ limit: 20,
402
+ });
403
+
404
+ // Should have captured the output
405
+ const hasOutput = readResult.lines.some((l) =>
406
+ l.text.includes('interactive test')
407
+ );
408
+ expect(hasOutput).toBe(true);
409
+
410
+ // Cleanup
411
+ await ptyManager.kill({
412
+ sessionId: spawnResult.sessionId,
413
+ cleanup: true,
414
+ });
415
+ });
416
+
417
+ it('should handle rapid input/output', async () => {
418
+ const spawnResult = await ptyManager.spawn({});
419
+
420
+ await new Promise((resolve) => setTimeout(resolve, 100));
421
+
422
+ // Send multiple commands rapidly
423
+ for (let i = 0; i < 5; i++) {
424
+ ptyManager.write({
425
+ sessionId: spawnResult.sessionId,
426
+ data: `echo "rapid ${i}"\n`,
427
+ });
428
+ }
429
+
430
+ // Wait for all outputs
431
+ await new Promise((resolve) => setTimeout(resolve, 500));
432
+
433
+ const readResult = ptyManager.read({
434
+ sessionId: spawnResult.sessionId,
435
+ tail: true,
436
+ limit: 50,
437
+ });
438
+
439
+ // Should have captured multiple outputs
440
+ expect(readResult.lines.length).toBeGreaterThan(0);
441
+
442
+ await ptyManager.kill({
443
+ sessionId: spawnResult.sessionId,
444
+ cleanup: true,
445
+ });
446
+ });
447
+ });
448
+
449
+ describe('Edge Cases', () => {
450
+ it('should handle empty command output', async () => {
451
+ const result = await ptyManager.exec({
452
+ command: 'true',
453
+ timeout: 5000,
454
+ });
455
+
456
+ expect(result.exitCode).toBe(0);
457
+ });
458
+
459
+ it('should handle very long output', async () => {
460
+ const result = await ptyManager.exec({
461
+ command: 'for i in $(seq 1 100); do echo "line $i: padding text here"; done',
462
+ timeout: 10000,
463
+ });
464
+
465
+ expect(result.exitCode).toBe(0);
466
+ expect(result.stdout.length).toBeGreaterThan(1000);
467
+ });
468
+
469
+ it('should handle binary-like output', async () => {
470
+ const result = await ptyManager.exec({
471
+ command: 'echo -e "\\x00\\x01\\x02\\x03"',
472
+ timeout: 5000,
473
+ });
474
+
475
+ expect(result.exitCode).toBe(0);
476
+ });
477
+
478
+ it('should handle ANSI escape sequences', async () => {
479
+ const result = await ptyManager.exec({
480
+ command: 'echo -e "\\033[31mRed\\033[0m"',
481
+ timeout: 5000,
482
+ });
483
+
484
+ expect(result.exitCode).toBe(0);
485
+ // Output should contain ANSI codes
486
+ expect(result.stdout).toContain('\x1b[31m');
487
+ });
488
+
489
+ it('should handle concurrent sessions', async () => {
490
+ const sessions = await Promise.all([
491
+ ptyManager.spawn({ command: 'sleep 2; echo "session 1"' }),
492
+ ptyManager.spawn({ command: 'sleep 2; echo "session 2"' }),
493
+ ptyManager.spawn({ command: 'sleep 2; echo "session 3"' }),
494
+ ]);
495
+
496
+ const listResult = ptyManager.list();
497
+ expect(listResult.sessions.length).toBeGreaterThanOrEqual(3);
498
+
499
+ // Cleanup
500
+ for (const session of sessions) {
501
+ await ptyManager.kill({
502
+ sessionId: session.sessionId,
503
+ cleanup: true,
504
+ });
505
+ }
506
+ });
507
+ });
508
+
509
+ describe('Event Emission', () => {
510
+ it('should emit session:exited event', async () => {
511
+ const exitedPromise = new Promise<void>((resolve) => {
512
+ ptyManager.on('session:exited', (data) => {
513
+ expect(data.sessionId).toMatch(/^pty_/);
514
+ expect(typeof data.exitCode).toBe('number');
515
+ resolve();
516
+ });
517
+ });
518
+
519
+ await ptyManager.spawn({
520
+ command: 'exit 0',
521
+ notifyOnExit: true,
522
+ });
523
+
524
+ // Wait for exit event (with timeout)
525
+ await Promise.race([
526
+ exitedPromise,
527
+ new Promise((_, reject) =>
528
+ setTimeout(() => reject(new Error('Timeout')), 3000)
529
+ ),
530
+ ]);
531
+ });
532
+
533
+ it('should emit output event', async () => {
534
+ const outputPromise = new Promise<void>((resolve) => {
535
+ ptyManager.on('output', (data) => {
536
+ if (data.data.includes('output event test')) {
537
+ resolve();
538
+ }
539
+ });
540
+ });
541
+
542
+ await ptyManager.spawn({
543
+ command: 'echo "output event test"',
544
+ });
545
+
546
+ // Wait for output event (with timeout)
547
+ await Promise.race([
548
+ outputPromise,
549
+ new Promise((_, reject) =>
550
+ setTimeout(() => reject(new Error('Timeout')), 3000)
551
+ ),
552
+ ]);
553
+ });
554
+ });
555
+ });