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.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- 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
|
+
});
|