spck 0.3.1

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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,702 @@
1
+ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
2
+ /**
3
+ * Tests for TerminalService
4
+ */
5
+
6
+ import { TerminalService } from '../TerminalService.js';
7
+ import { ErrorCode } from '../../types.js';
8
+
9
+ // Mock node-pty
10
+ vi.mock('node-pty', () => {
11
+ const mockPty = {
12
+ onData: vi.fn(),
13
+ onExit: vi.fn(),
14
+ write: vi.fn(),
15
+ kill: vi.fn(),
16
+ resize: vi.fn(),
17
+ };
18
+
19
+ return {
20
+ spawn: vi.fn(() => mockPty),
21
+ };
22
+ });
23
+
24
+ // Mock @xterm/headless
25
+ vi.mock('@xterm/headless', () => {
26
+ const mod = {
27
+ Terminal: vi.fn().mockImplementation(() => ({
28
+ write: vi.fn(),
29
+ resize: vi.fn(),
30
+ loadAddon: vi.fn(),
31
+ })),
32
+ };
33
+ return { default: mod, ...mod };
34
+ });
35
+
36
+ // Mock @xterm/addon-serialize
37
+ vi.mock('@xterm/addon-serialize', () => {
38
+ const mod = {
39
+ SerializeAddon: vi.fn().mockImplementation(() => ({
40
+ serialize: vi.fn(() => 'serialized-buffer'),
41
+ })),
42
+ };
43
+ return { default: mod, ...mod };
44
+ });
45
+
46
+ import * as pty from 'node-pty';
47
+ import XtermHeadlessModule from '@xterm/headless';
48
+ import SerializeAddonModule from '@xterm/addon-serialize';
49
+
50
+ const { Terminal: XtermHeadless } = XtermHeadlessModule as any;
51
+ const { SerializeAddon } = SerializeAddonModule as any;
52
+
53
+ describe('TerminalService', () => {
54
+ let service: TerminalService;
55
+ let mockSocket: any;
56
+ let mockPtyProcess: any;
57
+ let mockXterm: any;
58
+ let mockSerializeAddon: any;
59
+ let ptyDataHandler: Function | null = null;
60
+ let ptyExitHandler: Function | null = null;
61
+
62
+ beforeEach(() => {
63
+ // Reset all mocks
64
+ vi.clearAllMocks();
65
+
66
+ // Set up mock socket (with all SocketInterface properties)
67
+ mockSocket = {
68
+ id: 'test-socket',
69
+ data: { uid: 'test-user-123', deviceId: 'test-device-123' },
70
+ emit: vi.fn(),
71
+ on: vi.fn(),
72
+ off: vi.fn(),
73
+ broadcast: {
74
+ emit: vi.fn(),
75
+ },
76
+ };
77
+
78
+ // Set up mock PTY process
79
+ mockPtyProcess = {
80
+ onData: vi.fn((handler: Function) => {
81
+ ptyDataHandler = handler;
82
+ }),
83
+ onExit: vi.fn((handler: Function) => {
84
+ ptyExitHandler = handler;
85
+ }),
86
+ write: vi.fn(),
87
+ kill: vi.fn(),
88
+ resize: vi.fn(),
89
+ };
90
+
91
+ // Set up mock xterm
92
+ mockXterm = {
93
+ write: vi.fn(),
94
+ resize: vi.fn(),
95
+ loadAddon: vi.fn(),
96
+ };
97
+
98
+ // Set up mock serialize addon
99
+ mockSerializeAddon = {
100
+ serialize: vi.fn(() => 'serialized-buffer-content'),
101
+ };
102
+
103
+ // Configure mocks
104
+ (pty.spawn as Mock).mockReturnValue(mockPtyProcess);
105
+ (XtermHeadless as unknown as Mock).mockImplementation(() => mockXterm);
106
+ (SerializeAddon as unknown as Mock).mockImplementation(() => mockSerializeAddon);
107
+
108
+ // Create service
109
+ service = new TerminalService(() => mockSocket, 10, 10000, '/test/root');
110
+
111
+ // Reset handlers
112
+ ptyDataHandler = null;
113
+ ptyExitHandler = null;
114
+ });
115
+
116
+ describe('Terminal Creation', () => {
117
+ it('should create a new terminal session', async () => {
118
+ const result = await service.handle('create', { cols: 80, rows: 24 });
119
+
120
+ expect(result).toMatch(/^term-/);
121
+ expect(pty.spawn).toHaveBeenCalledWith(
122
+ expect.any(String),
123
+ [],
124
+ expect.objectContaining({
125
+ name: 'xterm-256color',
126
+ cols: 80,
127
+ rows: 24,
128
+ cwd: '/test/root',
129
+ })
130
+ );
131
+ expect(XtermHeadless).toHaveBeenCalledWith({
132
+ allowProposedApi: true,
133
+ cols: 80,
134
+ rows: 24,
135
+ scrollback: 10000,
136
+ });
137
+ });
138
+
139
+ it('should use default dimensions if not provided', async () => {
140
+ const result = await service.handle('create', {});
141
+
142
+ expect(result).toMatch(/^term-/);
143
+ expect(pty.spawn).toHaveBeenCalledWith(
144
+ expect.any(String),
145
+ [],
146
+ expect.objectContaining({
147
+ cols: 80,
148
+ rows: 24,
149
+ })
150
+ );
151
+ });
152
+
153
+ it('should send initial buffer refresh', async () => {
154
+ await service.handle('create', { cols: 80, rows: 24 });
155
+
156
+ expect(mockSocket.emit).toHaveBeenCalledWith(
157
+ 'rpc',
158
+ expect.objectContaining({
159
+ jsonrpc: '2.0',
160
+ method: 'terminal.bufferRefresh',
161
+ params: expect.objectContaining({
162
+ buffer: 'serialized-buffer-content',
163
+ }),
164
+ })
165
+ );
166
+ });
167
+
168
+ it('should set up PTY data handler', async () => {
169
+ await service.handle('create', { cols: 80, rows: 24 });
170
+
171
+ expect(mockPtyProcess.onData).toHaveBeenCalled();
172
+ });
173
+
174
+ it('should set up PTY exit handler', async () => {
175
+ await service.handle('create', { cols: 80, rows: 24 });
176
+
177
+ expect(mockPtyProcess.onExit).toHaveBeenCalled();
178
+ });
179
+
180
+ it('should enforce terminal limit', async () => {
181
+ // Create service with limit of 2
182
+ service = new TerminalService(() => mockSocket, 2, 10000, '/test/root');
183
+
184
+ // Create 2 terminals successfully
185
+ await service.handle('create', { cols: 80, rows: 24 });
186
+ await service.handle('create', { cols: 80, rows: 24 });
187
+
188
+ // Third should fail
189
+ await expect(
190
+ service.handle('create', { cols: 80, rows: 24 })
191
+ ).rejects.toMatchObject({
192
+ code: ErrorCode.TERMINAL_LIMIT_EXCEEDED,
193
+ message: expect.stringContaining('Terminal limit exceeded'),
194
+ });
195
+ });
196
+ });
197
+
198
+ describe('Terminal Activation', () => {
199
+ let terminalId: string;
200
+
201
+ beforeEach(async () => {
202
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
203
+ // Clear emit calls from creation
204
+ mockSocket.emit.mockClear();
205
+ });
206
+
207
+ it('should activate terminal', async () => {
208
+ await service.handle('activate', { terminalId });
209
+
210
+ // Should send buffer refresh
211
+ expect(mockSocket.emit).toHaveBeenCalledWith(
212
+ 'rpc',
213
+ expect.objectContaining({
214
+ method: 'terminal.bufferRefresh',
215
+ params: expect.objectContaining({
216
+ terminalId,
217
+ buffer: 'serialized-buffer-content',
218
+ }),
219
+ })
220
+ );
221
+ });
222
+
223
+ it('should stream output from active terminal only', async () => {
224
+ // Create unique mocks for each terminal
225
+ let dataHandler1: Function | null = null;
226
+ let dataHandler2: Function | null = null;
227
+
228
+ const mockPty1 = {
229
+ onData: vi.fn((handler: Function) => { dataHandler1 = handler; }),
230
+ onExit: vi.fn(),
231
+ write: vi.fn(),
232
+ kill: vi.fn(),
233
+ resize: vi.fn(),
234
+ };
235
+
236
+ const mockPty2 = {
237
+ onData: vi.fn((handler: Function) => { dataHandler2 = handler; }),
238
+ onExit: vi.fn(),
239
+ write: vi.fn(),
240
+ kill: vi.fn(),
241
+ resize: vi.fn(),
242
+ };
243
+
244
+ // Return different mocks for each spawn call
245
+ (pty.spawn as Mock)
246
+ .mockReturnValueOnce(mockPty1)
247
+ .mockReturnValueOnce(mockPty2);
248
+
249
+ // Create two terminals
250
+ const terminal1 = await service.handle('create', { cols: 80, rows: 24 });
251
+ await service.handle('create', { cols: 80, rows: 24 });
252
+
253
+ mockSocket.emit.mockClear();
254
+
255
+ // Activate terminal1
256
+ await service.handle('activate', { terminalId: terminal1 });
257
+
258
+ mockSocket.emit.mockClear();
259
+
260
+ // Simulate output from terminal1 (active)
261
+ dataHandler1!('output from terminal 1');
262
+
263
+ // Simulate output from terminal2 (inactive)
264
+ dataHandler2!('output from terminal 2');
265
+
266
+ // Only terminal1 should stream
267
+ const outputCalls = mockSocket.emit.mock.calls.filter(
268
+ (call: any[]) => call[1].method === 'terminal.output'
269
+ );
270
+
271
+ // Should only have output from terminal1
272
+ expect(outputCalls.length).toBe(1);
273
+ expect(outputCalls[0][1].params.terminalId).toBe(terminal1);
274
+ expect(outputCalls[0][1].params.data).toBe('output from terminal 1');
275
+ });
276
+
277
+ it('should throw error for non-existent terminal', async () => {
278
+ await expect(
279
+ service.handle('activate', { terminalId: 'invalid-id' })
280
+ ).rejects.toMatchObject({
281
+ code: ErrorCode.TERMINAL_NOT_FOUND,
282
+ message: 'Terminal not found',
283
+ });
284
+ });
285
+
286
+ it('should prevent activation of other user terminals', async () => {
287
+ // Create terminal for user-123
288
+ const terminal1 = await service.handle('create', { cols: 80, rows: 24 });
289
+
290
+ // Create new service for different user
291
+ const otherSocket = {
292
+ ...mockSocket,
293
+ data: { uid: 'different-user', deviceId: 'different-device' },
294
+ };
295
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
296
+
297
+ // Try to activate terminal from different user
298
+ await expect(
299
+ otherService.handle('activate', { terminalId: terminal1 })
300
+ ).rejects.toMatchObject({
301
+ code: ErrorCode.TERMINAL_NOT_FOUND,
302
+ });
303
+ });
304
+ });
305
+
306
+ describe('Sending Input', () => {
307
+ let terminalId: string;
308
+
309
+ beforeEach(async () => {
310
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
311
+ });
312
+
313
+ it('should send input to terminal', async () => {
314
+ await service.handle('send', { terminalId, data: 'ls\n' });
315
+
316
+ expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
317
+ });
318
+
319
+ it('should throw error for non-existent terminal', async () => {
320
+ await expect(
321
+ service.handle('send', { terminalId: 'invalid-id', data: 'test' })
322
+ ).rejects.toMatchObject({
323
+ code: ErrorCode.TERMINAL_NOT_FOUND,
324
+ });
325
+ });
326
+
327
+ it('should throw error when sending to exited terminal', async () => {
328
+ // Simulate terminal exit
329
+ if (ptyExitHandler) {
330
+ ptyExitHandler({ exitCode: 0 });
331
+ }
332
+
333
+ await expect(
334
+ service.handle('send', { terminalId, data: 'test' })
335
+ ).rejects.toMatchObject({
336
+ code: ErrorCode.TERMINAL_PROCESS_EXITED,
337
+ message: 'Terminal process has exited',
338
+ });
339
+ });
340
+
341
+ it('should prevent sending to other user terminals', async () => {
342
+ const otherSocket = {
343
+ ...mockSocket,
344
+ data: { uid: 'different-user', deviceId: 'different-device' },
345
+ };
346
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
347
+
348
+ await expect(
349
+ otherService.handle('send', { terminalId, data: 'test' })
350
+ ).rejects.toMatchObject({
351
+ code: ErrorCode.TERMINAL_NOT_FOUND,
352
+ });
353
+ });
354
+ });
355
+
356
+ describe('Resizing Terminal', () => {
357
+ let terminalId: string;
358
+
359
+ beforeEach(async () => {
360
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
361
+ });
362
+
363
+ it('should resize terminal', async () => {
364
+ await service.handle('resize', { terminalId, cols: 120, rows: 30 });
365
+
366
+ expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 30);
367
+ expect(mockXterm.resize).toHaveBeenCalledWith(120, 30);
368
+ });
369
+
370
+ it('should throw error for non-existent terminal', async () => {
371
+ await expect(
372
+ service.handle('resize', { terminalId: 'invalid-id', cols: 100, rows: 50 })
373
+ ).rejects.toMatchObject({
374
+ code: ErrorCode.TERMINAL_NOT_FOUND,
375
+ });
376
+ });
377
+
378
+ it('should prevent resizing other user terminals', async () => {
379
+ const otherSocket = {
380
+ ...mockSocket,
381
+ data: { uid: 'different-user', deviceId: 'different-device' },
382
+ };
383
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
384
+
385
+ await expect(
386
+ otherService.handle('resize', { terminalId, cols: 100, rows: 50 })
387
+ ).rejects.toMatchObject({
388
+ code: ErrorCode.TERMINAL_NOT_FOUND,
389
+ });
390
+ });
391
+ });
392
+
393
+ describe('Terminal Destruction', () => {
394
+ let terminalId: string;
395
+
396
+ beforeEach(async () => {
397
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
398
+ });
399
+
400
+ it('should destroy terminal session', async () => {
401
+ await service.handle('destroy', { terminalId });
402
+
403
+ expect(mockPtyProcess.kill).toHaveBeenCalled();
404
+
405
+ // Terminal should no longer exist
406
+ await expect(
407
+ service.handle('send', { terminalId, data: 'test' })
408
+ ).rejects.toMatchObject({
409
+ code: ErrorCode.TERMINAL_NOT_FOUND,
410
+ });
411
+ });
412
+
413
+ it('should throw error for non-existent terminal', async () => {
414
+ await expect(
415
+ service.handle('destroy', { terminalId: 'invalid-id' })
416
+ ).rejects.toMatchObject({
417
+ code: ErrorCode.TERMINAL_NOT_FOUND,
418
+ });
419
+ });
420
+
421
+ it('should not kill process if already exited', async () => {
422
+ // Simulate exit
423
+ if (ptyExitHandler) {
424
+ ptyExitHandler({ exitCode: 0 });
425
+ }
426
+
427
+ mockPtyProcess.kill.mockClear();
428
+
429
+ await service.handle('destroy', { terminalId });
430
+
431
+ expect(mockPtyProcess.kill).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it('should clear active terminal ID if destroying active terminal', async () => {
435
+ await service.handle('activate', { terminalId });
436
+ await service.handle('destroy', { terminalId });
437
+
438
+ // Create and activate new terminal should work
439
+ const newTerminalId = await service.handle('create', { cols: 80, rows: 24 });
440
+ await expect(
441
+ service.handle('activate', { terminalId: newTerminalId })
442
+ ).resolves.toBeUndefined();
443
+ });
444
+
445
+ it('should prevent destroying other user terminals', async () => {
446
+ const otherSocket = {
447
+ ...mockSocket,
448
+ data: { uid: 'different-user', deviceId: 'different-device' },
449
+ };
450
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
451
+
452
+ await expect(
453
+ otherService.handle('destroy', { terminalId })
454
+ ).rejects.toMatchObject({
455
+ code: ErrorCode.TERMINAL_NOT_FOUND,
456
+ });
457
+ });
458
+ });
459
+
460
+ describe('Buffer Refresh', () => {
461
+ let terminalId: string;
462
+
463
+ beforeEach(async () => {
464
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
465
+ mockSocket.emit.mockClear();
466
+ });
467
+
468
+ it('should refresh terminal buffer', async () => {
469
+ await service.handle('refresh', { terminalId });
470
+
471
+ expect(mockSocket.emit).toHaveBeenCalledWith(
472
+ 'rpc',
473
+ expect.objectContaining({
474
+ method: 'terminal.bufferRefresh',
475
+ params: expect.objectContaining({
476
+ terminalId,
477
+ buffer: 'serialized-buffer-content',
478
+ }),
479
+ })
480
+ );
481
+ });
482
+
483
+ it('should throw error for non-existent terminal', async () => {
484
+ await expect(
485
+ service.handle('refresh', { terminalId: 'invalid-id' })
486
+ ).rejects.toMatchObject({
487
+ code: ErrorCode.TERMINAL_NOT_FOUND,
488
+ });
489
+ });
490
+
491
+ it('should prevent refreshing other user terminals', async () => {
492
+ const otherSocket = {
493
+ ...mockSocket,
494
+ data: { uid: 'different-user', deviceId: 'different-device' },
495
+ };
496
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
497
+
498
+ await expect(
499
+ otherService.handle('refresh', { terminalId })
500
+ ).rejects.toMatchObject({
501
+ code: ErrorCode.TERMINAL_NOT_FOUND,
502
+ });
503
+ });
504
+ });
505
+
506
+ describe('Terminal List', () => {
507
+ it('should list all terminals for user', async () => {
508
+ const terminal1 = await service.handle('create', { cols: 80, rows: 24 });
509
+ const terminal2 = await service.handle('create', { cols: 80, rows: 24 });
510
+
511
+ const result = await service.handle('list', {});
512
+
513
+ expect(result).toEqual([terminal1, terminal2]);
514
+ });
515
+
516
+ it('should return empty array when no terminals exist', async () => {
517
+ const result = await service.handle('list', {});
518
+
519
+ expect(result).toEqual([]);
520
+ });
521
+
522
+ it('should only list terminals for current user', async () => {
523
+ // Create terminal for user-123
524
+ await service.handle('create', { cols: 80, rows: 24 });
525
+
526
+ // Create service for different user
527
+ const otherSocket = {
528
+ ...mockSocket,
529
+ data: { uid: 'different-user', deviceId: 'different-device' },
530
+ };
531
+ const otherService = new TerminalService(() => otherSocket, 10, 10000, '/test/root');
532
+
533
+ // Different user should see empty list
534
+ const result = await otherService.handle('list', {});
535
+ expect(result).toEqual([]);
536
+ });
537
+ });
538
+
539
+ describe('PTY Output Handling', () => {
540
+ let terminalId: string;
541
+
542
+ beforeEach(async () => {
543
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
544
+ mockSocket.emit.mockClear();
545
+ });
546
+
547
+ it('should write PTY output to xterm buffer', async () => {
548
+ // Simulate PTY output
549
+ if (ptyDataHandler) {
550
+ ptyDataHandler('hello world');
551
+ }
552
+
553
+ expect(mockXterm.write).toHaveBeenCalledWith('hello world', expect.any(Function));
554
+ });
555
+
556
+ it('should stream output to client when terminal is active', async () => {
557
+ await service.handle('activate', { terminalId });
558
+ mockSocket.emit.mockClear();
559
+
560
+ // Simulate PTY output
561
+ if (ptyDataHandler) {
562
+ ptyDataHandler('hello world');
563
+ }
564
+
565
+ expect(mockSocket.emit).toHaveBeenCalledWith(
566
+ 'rpc',
567
+ expect.objectContaining({
568
+ method: 'terminal.output',
569
+ params: {
570
+ terminalId,
571
+ data: 'hello world',
572
+ },
573
+ })
574
+ );
575
+ });
576
+
577
+ it('should not stream output when terminal is inactive', async () => {
578
+ // Don't activate, just simulate output
579
+ if (ptyDataHandler) {
580
+ ptyDataHandler('hello world');
581
+ }
582
+
583
+ const outputCalls = mockSocket.emit.mock.calls.filter(
584
+ (call: any[]) => call[1].method === 'terminal.output'
585
+ );
586
+
587
+ expect(outputCalls.length).toBe(0);
588
+ });
589
+ });
590
+
591
+ describe('PTY Exit Handling', () => {
592
+ let terminalId: string;
593
+
594
+ beforeEach(async () => {
595
+ terminalId = await service.handle('create', { cols: 80, rows: 24 });
596
+ mockSocket.emit.mockClear();
597
+ });
598
+
599
+ it('should notify client when terminal exits', async () => {
600
+ // Simulate PTY exit
601
+ if (ptyExitHandler) {
602
+ ptyExitHandler({ exitCode: 0 });
603
+ }
604
+
605
+ expect(mockSocket.emit).toHaveBeenCalledWith(
606
+ 'rpc',
607
+ expect.objectContaining({
608
+ method: 'terminal.exited',
609
+ params: {
610
+ terminalId,
611
+ exitCode: 0,
612
+ },
613
+ })
614
+ );
615
+ });
616
+
617
+ it('should mark terminal as exited', async () => {
618
+ // Simulate PTY exit
619
+ if (ptyExitHandler) {
620
+ ptyExitHandler({ exitCode: 127 });
621
+ }
622
+
623
+ // Sending to exited terminal should fail
624
+ await expect(
625
+ service.handle('send', { terminalId, data: 'test' })
626
+ ).rejects.toMatchObject({
627
+ code: ErrorCode.TERMINAL_PROCESS_EXITED,
628
+ });
629
+ });
630
+
631
+ it('should preserve buffer after exit', async () => {
632
+ // Simulate PTY exit
633
+ if (ptyExitHandler) {
634
+ ptyExitHandler({ exitCode: 0 });
635
+ }
636
+
637
+ // Should still be able to refresh buffer
638
+ await expect(
639
+ service.handle('refresh', { terminalId })
640
+ ).resolves.toBeUndefined();
641
+
642
+ expect(mockSocket.emit).toHaveBeenCalledWith(
643
+ 'rpc',
644
+ expect.objectContaining({
645
+ method: 'terminal.bufferRefresh',
646
+ })
647
+ );
648
+ });
649
+ });
650
+
651
+ describe('Error Handling', () => {
652
+ it('should throw error for unknown method', async () => {
653
+ await expect(
654
+ service.handle('unknownMethod', {})
655
+ ).rejects.toMatchObject({
656
+ code: ErrorCode.METHOD_NOT_FOUND,
657
+ message: expect.stringContaining('Method not found'),
658
+ });
659
+ });
660
+ });
661
+
662
+ describe('Cleanup', () => {
663
+ it('should kill all terminals on cleanup', async () => {
664
+ await service.handle('create', { cols: 80, rows: 24 });
665
+ await service.handle('create', { cols: 80, rows: 24 });
666
+
667
+ // Clear previous calls
668
+ mockPtyProcess.kill.mockClear();
669
+
670
+ service.cleanup();
671
+
672
+ // Both terminals should be killed
673
+ expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
674
+ });
675
+
676
+ it('should not kill already exited terminals', async () => {
677
+ await service.handle('create', { cols: 80, rows: 24 });
678
+
679
+ // Simulate exit
680
+ if (ptyExitHandler) {
681
+ ptyExitHandler({ exitCode: 0 });
682
+ }
683
+
684
+ mockPtyProcess.kill.mockClear();
685
+
686
+ service.cleanup();
687
+
688
+ // Should not kill exited terminal
689
+ expect(mockPtyProcess.kill).not.toHaveBeenCalled();
690
+ });
691
+
692
+ it('should clear all sessions', async () => {
693
+ await service.handle('create', { cols: 80, rows: 24 });
694
+ await service.handle('create', { cols: 80, rows: 24 });
695
+
696
+ service.cleanup();
697
+
698
+ const result = await service.handle('list', {});
699
+ expect(result).toEqual([]);
700
+ });
701
+ });
702
+ });