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,1002 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ /**
3
+ * Tests for FilesystemService
4
+ */
5
+
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import * as crypto from 'crypto';
10
+ import * as fossilDelta from 'fossil-delta';
11
+ import { FilesystemService } from '../FilesystemService.js';
12
+ import { ErrorCode } from '../../types.js';
13
+
14
+ describe('FilesystemService', () => {
15
+ let service: FilesystemService;
16
+ let testRoot: string;
17
+ let mockSocket: any;
18
+
19
+ beforeEach(async () => {
20
+ // Create temporary test directory
21
+ testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-test-'));
22
+
23
+ service = new FilesystemService(testRoot, {
24
+ maxFileSize: '10MB',
25
+ watchIgnorePatterns: ['.git', 'node_modules'],
26
+ });
27
+
28
+ mockSocket = {
29
+ id: 'test-socket',
30
+ data: { uid: 'test-user', deviceId: 'test-device' },
31
+ emit: vi.fn(),
32
+ on: vi.fn(),
33
+ off: vi.fn(),
34
+ broadcast: {
35
+ emit: vi.fn(),
36
+ },
37
+ };
38
+ });
39
+
40
+ afterEach(async () => {
41
+ // Clean up test directory
42
+ try {
43
+ await fs.rm(testRoot, { recursive: true, force: true });
44
+ } catch {}
45
+ });
46
+
47
+ describe('Path Validation & Security', () => {
48
+ it('should accept valid relative paths', async () => {
49
+ await fs.writeFile(path.join(testRoot, 'test.txt'), 'content');
50
+
51
+ const result = await service.handle('exists', { path: '/test.txt' }, mockSocket);
52
+
53
+ expect(result.exists).toBe(true);
54
+ });
55
+
56
+ it('should prevent directory traversal attacks', async () => {
57
+ await expect(
58
+ service.handle('exists', { path: '../../etc/passwd' }, mockSocket)
59
+ ).rejects.toMatchObject({
60
+ code: ErrorCode.INVALID_PATH,
61
+ message: expect.stringContaining('directory traversal'),
62
+ });
63
+ });
64
+
65
+ it('should prevent access outside root directory', async () => {
66
+ // The path validation happens during normalization
67
+ // When path escapes root, it gets clamped to root, so file won't exist
68
+ const result = await service.handle(
69
+ 'exists',
70
+ { path: '/../../../../etc/passwd' },
71
+ mockSocket
72
+ );
73
+
74
+ // Should not find /etc/passwd since it's outside the test root
75
+ expect(result.exists).toBe(false);
76
+ });
77
+
78
+ it('should normalize paths correctly', async () => {
79
+ await fs.writeFile(path.join(testRoot, 'test.txt'), 'content');
80
+
81
+ const result = await service.handle('exists', { path: '//test.txt' }, mockSocket);
82
+
83
+ expect(result.exists).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('File Operations - exists', () => {
88
+ it('should return true for existing file', async () => {
89
+ await fs.writeFile(path.join(testRoot, 'exists.txt'), 'content');
90
+
91
+ const result = await service.handle('exists', { path: '/exists.txt' }, mockSocket);
92
+
93
+ expect(result).toEqual({ exists: true });
94
+ });
95
+
96
+ it('should return false for non-existing file', async () => {
97
+ const result = await service.handle('exists', { path: '/missing.txt' }, mockSocket);
98
+
99
+ expect(result).toEqual({ exists: false });
100
+ });
101
+
102
+ it('should return true for existing directory', async () => {
103
+ await fs.mkdir(path.join(testRoot, 'testdir'));
104
+
105
+ const result = await service.handle('exists', { path: '/testdir' }, mockSocket);
106
+
107
+ expect(result).toEqual({ exists: true });
108
+ });
109
+ });
110
+
111
+ describe('File Operations - readFile', () => {
112
+ it('should read text file with UTF-8 encoding', async () => {
113
+ const content = 'Hello, World! 你好';
114
+ await fs.writeFile(path.join(testRoot, 'hello.txt'), content, 'utf8');
115
+
116
+ const result = await service.handle(
117
+ 'readFile',
118
+ { path: '/hello.txt', encoding: 'utf8' },
119
+ mockSocket
120
+ );
121
+
122
+ expect(result.contents).toBe(content);
123
+ expect(result.encoding).toBe('utf8');
124
+ expect(result.sha256).toBeTruthy();
125
+ expect(result.size).toBe(Buffer.from(content, 'utf8').length);
126
+ });
127
+
128
+ it('should compute correct SHA256 hash', async () => {
129
+ const content = 'test content';
130
+ await fs.writeFile(path.join(testRoot, 'test.txt'), content);
131
+
132
+ const result = await service.handle(
133
+ 'readFile',
134
+ { path: '/test.txt' },
135
+ mockSocket
136
+ );
137
+
138
+ const expectedHash = crypto.createHash('sha256').update(content).digest('hex');
139
+ expect(result.sha256).toBe(expectedHash);
140
+ });
141
+
142
+ it('should default to UTF-8 encoding', async () => {
143
+ await fs.writeFile(path.join(testRoot, 'default.txt'), 'content');
144
+
145
+ const result = await service.handle(
146
+ 'readFile',
147
+ { path: '/default.txt' },
148
+ mockSocket
149
+ );
150
+
151
+ expect(result.encoding).toBe('utf8');
152
+ });
153
+
154
+ it('should handle binary files', async () => {
155
+ const buffer = Buffer.from([0x00, 0x01, 0x02, 0xFF]);
156
+ await fs.writeFile(path.join(testRoot, 'binary.dat'), buffer);
157
+
158
+ const result = await service.handle(
159
+ 'readFile',
160
+ { path: '/binary.dat', encoding: 'binary', requestId: 123 },
161
+ mockSocket
162
+ );
163
+
164
+ expect(result.encoding).toBe('binary');
165
+ expect(result.sha256).toBeTruthy();
166
+ expect(result.buffer).toBeInstanceOf(Buffer);
167
+ expect(result.buffer).toEqual(buffer);
168
+ });
169
+
170
+ it('should throw error for non-existing file', async () => {
171
+ await expect(
172
+ service.handle('readFile', { path: '/missing.txt' }, mockSocket)
173
+ ).rejects.toMatchObject({
174
+ code: ErrorCode.FILE_NOT_FOUND,
175
+ });
176
+ });
177
+
178
+ it('should reject files exceeding size limit', async () => {
179
+ // Create a large file (11MB, exceeds 10MB limit)
180
+ const largeContent = 'x'.repeat(11 * 1024 * 1024);
181
+ await fs.writeFile(path.join(testRoot, 'large.txt'), largeContent);
182
+
183
+ await expect(
184
+ service.handle('readFile', { path: '/large.txt' }, mockSocket)
185
+ ).rejects.toMatchObject({
186
+ code: ErrorCode.FILE_TOO_LARGE,
187
+ message: expect.stringContaining('too large'),
188
+ });
189
+ });
190
+ });
191
+
192
+ describe('File Operations - writeFile', () => {
193
+ it('should write text file', async () => {
194
+ const content = 'test content';
195
+
196
+ const result = await service.handle(
197
+ 'write',
198
+ { path: '/new.txt', contents: content },
199
+ mockSocket
200
+ );
201
+
202
+ expect(result.success).toBe(true);
203
+ expect(result.sha256).toBeTruthy();
204
+
205
+ const written = await fs.readFile(path.join(testRoot, 'new.txt'), 'utf8');
206
+ expect(written).toBe(content);
207
+ });
208
+
209
+ it('should overwrite existing file', async () => {
210
+ await fs.writeFile(path.join(testRoot, 'overwrite.txt'), 'old content');
211
+
212
+ await service.handle(
213
+ 'write',
214
+ { path: '/overwrite.txt', contents: 'new content' },
215
+ mockSocket
216
+ );
217
+
218
+ const content = await fs.readFile(path.join(testRoot, 'overwrite.txt'), 'utf8');
219
+ expect(content).toBe('new content');
220
+ });
221
+
222
+ it('should detect write conflicts with expectedHash', async () => {
223
+ await fs.writeFile(path.join(testRoot, 'conflict.txt'), 'original');
224
+
225
+ const wrongHash = 'wrong-hash-value';
226
+
227
+ await expect(
228
+ service.handle(
229
+ 'write',
230
+ {
231
+ path: '/conflict.txt',
232
+ contents: 'modified',
233
+ expectedHash: wrongHash,
234
+ },
235
+ mockSocket
236
+ )
237
+ ).rejects.toMatchObject({
238
+ code: ErrorCode.WRITE_CONFLICT,
239
+ message: expect.stringContaining('modified on server'),
240
+ });
241
+ });
242
+
243
+ it('should allow write when expectedHash matches', async () => {
244
+ const original = 'original content';
245
+ await fs.writeFile(path.join(testRoot, 'match.txt'), original);
246
+
247
+ const correctHash = crypto.createHash('sha256').update(original).digest('hex');
248
+
249
+ const result = await service.handle(
250
+ 'write',
251
+ {
252
+ path: '/match.txt',
253
+ contents: 'new content',
254
+ expectedHash: correctHash,
255
+ },
256
+ mockSocket
257
+ );
258
+
259
+ expect(result.success).toBe(true);
260
+ });
261
+
262
+ it('should write binary file from base64-encoded string', async () => {
263
+ const bytes = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header
264
+ const base64 = bytes.toString('base64');
265
+
266
+ const result = await service.handle(
267
+ 'write',
268
+ { path: '/image.png', contents: base64, encoding: 'binary' },
269
+ mockSocket
270
+ );
271
+
272
+ expect(result.success).toBe(true);
273
+ expect(result.sha256).toBeTruthy();
274
+
275
+ const written = await fs.readFile(path.join(testRoot, 'image.png'));
276
+ expect(written).toEqual(bytes);
277
+ });
278
+
279
+ it('should write binary file atomically from base64-encoded string', async () => {
280
+ const bytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]);
281
+ const base64 = bytes.toString('base64');
282
+
283
+ const result = await service.handle(
284
+ 'write',
285
+ { path: '/data.bin', contents: base64, encoding: 'binary', atomic: true },
286
+ mockSocket
287
+ );
288
+
289
+ expect(result.success).toBe(true);
290
+
291
+ const written = await fs.readFile(path.join(testRoot, 'data.bin'));
292
+ expect(written).toEqual(bytes);
293
+ });
294
+
295
+ it('should write empty binary file', async () => {
296
+ const result = await service.handle(
297
+ 'write',
298
+ { path: '/empty.bin', contents: '', encoding: 'binary' },
299
+ mockSocket
300
+ );
301
+
302
+ expect(result.success).toBe(true);
303
+
304
+ const written = await fs.readFile(path.join(testRoot, 'empty.bin'));
305
+ expect(written.length).toBe(0);
306
+ });
307
+
308
+ it('should preserve all byte values when writing binary via base64', async () => {
309
+ // Full range of byte values 0x00–0xFF
310
+ const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
311
+ const base64 = bytes.toString('base64');
312
+
313
+ await service.handle(
314
+ 'write',
315
+ { path: '/allbytes.bin', contents: base64, encoding: 'binary' },
316
+ mockSocket
317
+ );
318
+
319
+ const written = await fs.readFile(path.join(testRoot, 'allbytes.bin'));
320
+ expect(written).toEqual(bytes);
321
+ });
322
+
323
+ it('should set executable permission when requested', async () => {
324
+ await service.handle(
325
+ 'write',
326
+ {
327
+ path: '/script.sh',
328
+ contents: '#!/bin/bash\necho hello',
329
+ executable: true,
330
+ },
331
+ mockSocket
332
+ );
333
+
334
+ const stats = await fs.stat(path.join(testRoot, 'script.sh'));
335
+ // Check if executable bit is set (mode & 0o100)
336
+ expect(stats.mode & 0o100).toBeTruthy();
337
+ });
338
+ });
339
+
340
+ describe('File Operations - patchFile (Fossil Delta)', () => {
341
+ it('should apply fossil-delta patch successfully', async () => {
342
+ const original = Buffer.from('Hello, World!');
343
+ const modified = Buffer.from('Hello, Universe!');
344
+ await fs.writeFile(path.join(testRoot, 'patch.txt'), original);
345
+
346
+ const delta = fossilDelta.create(original, modified);
347
+ const baseHash = crypto.createHash('sha256').update(original).digest('hex');
348
+ const newHash = crypto.createHash('sha256').update(modified).digest('hex');
349
+
350
+ const result = await service.handle(
351
+ 'patchFile',
352
+ {
353
+ path: '/patch.txt',
354
+ delta: delta, // Pass Buffer directly
355
+ baseHash,
356
+ newHash,
357
+ },
358
+ mockSocket
359
+ );
360
+
361
+ expect(result.success).toBe(true);
362
+ expect(result.finalHash).toBe(newHash);
363
+
364
+ const patched = await fs.readFile(path.join(testRoot, 'patch.txt'));
365
+ expect(patched.toString()).toBe('Hello, Universe!');
366
+ });
367
+
368
+ it('should reject patch when base hash mismatches', async () => {
369
+ await fs.writeFile(path.join(testRoot, 'mismatch.txt'), 'content');
370
+
371
+ const delta = Buffer.from([0x00]);
372
+ const wrongBaseHash = 'wrong-hash';
373
+
374
+ await expect(
375
+ service.handle(
376
+ 'patchFile',
377
+ {
378
+ path: '/mismatch.txt',
379
+ delta: Array.from(delta),
380
+ baseHash: wrongBaseHash,
381
+ },
382
+ mockSocket
383
+ )
384
+ ).rejects.toMatchObject({
385
+ code: ErrorCode.WRITE_CONFLICT,
386
+ message: expect.stringContaining('Base hash mismatch'),
387
+ });
388
+ });
389
+
390
+ it('should verify final hash after patching', async () => {
391
+ const original = Buffer.from('test');
392
+ const modified = Buffer.from('tested');
393
+ await fs.writeFile(path.join(testRoot, 'verify.txt'), original);
394
+
395
+ const delta = fossilDelta.create(original, modified);
396
+ const baseHash = crypto.createHash('sha256').update(original).digest('hex');
397
+ const wrongNewHash = 'wrong-final-hash';
398
+
399
+ await expect(
400
+ service.handle(
401
+ 'patchFile',
402
+ {
403
+ path: '/verify.txt',
404
+ delta: delta, // Pass Buffer directly
405
+ baseHash,
406
+ newHash: wrongNewHash,
407
+ },
408
+ mockSocket
409
+ )
410
+ ).rejects.toMatchObject({
411
+ code: ErrorCode.DELTA_PATCH_FAILED,
412
+ message: expect.stringContaining('Final hash mismatch'),
413
+ });
414
+ });
415
+ });
416
+
417
+ describe('File Operations - getFileHash', () => {
418
+ it('should return file hash and metadata', async () => {
419
+ const content = 'test content';
420
+ await fs.writeFile(path.join(testRoot, 'hash.txt'), content);
421
+
422
+ const result = await service.handle('getFileHash', { path: '/hash.txt' }, mockSocket);
423
+
424
+ const expectedHash = crypto.createHash('sha256').update(content).digest('hex');
425
+ expect(result.hash).toBe(expectedHash);
426
+ expect(result.size).toBe(Buffer.from(content).length);
427
+ expect(result.mtime).toBeGreaterThan(0);
428
+ });
429
+ });
430
+
431
+ describe('File Operations - remove', () => {
432
+ it('should remove file', async () => {
433
+ await fs.writeFile(path.join(testRoot, 'remove.txt'), 'content');
434
+
435
+ const result = await service.handle('remove', { path: '/remove.txt' }, mockSocket);
436
+
437
+ expect(result.success).toBe(true);
438
+
439
+ await expect(fs.access(path.join(testRoot, 'remove.txt'))).rejects.toThrow();
440
+ });
441
+
442
+ it('should remove directory recursively', async () => {
443
+ const dirPath = path.join(testRoot, 'removedir');
444
+ await fs.mkdir(dirPath);
445
+ await fs.writeFile(path.join(dirPath, 'file.txt'), 'content');
446
+
447
+ const result = await service.handle('remove', { path: '/removedir' }, mockSocket);
448
+
449
+ expect(result.success).toBe(true);
450
+ await expect(fs.access(dirPath)).rejects.toThrow();
451
+ });
452
+
453
+ it('should succeed for non-existing file (idempotent)', async () => {
454
+ const result = await service.handle('remove', { path: '/nonexistent.txt' }, mockSocket);
455
+ expect(result.success).toBe(true);
456
+ });
457
+ });
458
+
459
+ describe('Directory Operations - mkdir', () => {
460
+ it('should create directory', async () => {
461
+ const result = await service.handle('mkdir', { path: '/newdir' }, mockSocket);
462
+
463
+ expect(result.success).toBe(true);
464
+
465
+ const stats = await fs.stat(path.join(testRoot, 'newdir'));
466
+ expect(stats.isDirectory()).toBe(true);
467
+ });
468
+
469
+ it('should create nested directories with mkdirp', async () => {
470
+ const result = await service.handle('mkdirp', { path: '/a/b/c' }, mockSocket);
471
+
472
+ expect(result.success).toBe(true);
473
+
474
+ const stats = await fs.stat(path.join(testRoot, 'a', 'b', 'c'));
475
+ expect(stats.isDirectory()).toBe(true);
476
+ });
477
+ });
478
+
479
+ describe('Directory Operations - readdir', () => {
480
+ beforeEach(async () => {
481
+ await fs.mkdir(path.join(testRoot, 'readdir-test'));
482
+ await fs.writeFile(path.join(testRoot, 'readdir-test', 'file1.txt'), '');
483
+ await fs.writeFile(path.join(testRoot, 'readdir-test', 'file2.txt'), '');
484
+ await fs.mkdir(path.join(testRoot, 'readdir-test', 'subdir'));
485
+ });
486
+
487
+ it('should list directory contents', async () => {
488
+ const result = await service.handle('readdir', { path: '/readdir-test' }, mockSocket);
489
+
490
+ expect(result.entries).toHaveLength(3);
491
+ expect(result.entries).toEqual(
492
+ expect.arrayContaining(['file1.txt', 'file2.txt', 'subdir'])
493
+ );
494
+ });
495
+
496
+ it('should skip files when skipFiles is true', async () => {
497
+ const result = await service.handle(
498
+ 'readdir',
499
+ { path: '/readdir-test', skipFiles: true },
500
+ mockSocket
501
+ );
502
+
503
+ expect(result.entries).toHaveLength(1);
504
+ expect(result.entries[0]).toBe('subdir');
505
+ });
506
+
507
+ it('should skip folders when skipFolders is true', async () => {
508
+ const result = await service.handle(
509
+ 'readdir',
510
+ { path: '/readdir-test', skipFolders: true },
511
+ mockSocket
512
+ );
513
+
514
+ expect(result.entries).toHaveLength(2);
515
+ expect(result.entries).toEqual(
516
+ expect.arrayContaining(['file1.txt', 'file2.txt'])
517
+ );
518
+ });
519
+
520
+ it('should throw error for non-existing directory', async () => {
521
+ await expect(
522
+ service.handle('readdir', { path: '/nonexistent' }, mockSocket)
523
+ ).rejects.toMatchObject({
524
+ code: ErrorCode.FILE_NOT_FOUND,
525
+ });
526
+ });
527
+ });
528
+
529
+ describe('Directory Operations - readdirDeep', () => {
530
+ beforeEach(async () => {
531
+ // Create nested directory structure
532
+ await fs.mkdir(path.join(testRoot, 'deep'));
533
+ await fs.writeFile(path.join(testRoot, 'deep', 'root.txt'), '');
534
+ await fs.mkdir(path.join(testRoot, 'deep', 'level1'));
535
+ await fs.writeFile(path.join(testRoot, 'deep', 'level1', 'file1.txt'), '');
536
+ await fs.mkdir(path.join(testRoot, 'deep', 'level1', 'level2'));
537
+ await fs.writeFile(path.join(testRoot, 'deep', 'level1', 'level2', 'file2.txt'), '');
538
+ await fs.mkdir(path.join(testRoot, 'deep', '.git'));
539
+ await fs.writeFile(path.join(testRoot, 'deep', '.git', 'config'), '');
540
+ await fs.mkdir(path.join(testRoot, 'deep', 'node_modules'));
541
+ await fs.writeFile(path.join(testRoot, 'deep', 'node_modules', 'package.json'), '');
542
+ });
543
+
544
+ it('should recursively list all files and folders', async () => {
545
+ const result = await service.handle('readdirDeep', { path: '/deep' }, mockSocket);
546
+
547
+ expect(result).toContain('deep/root.txt');
548
+ expect(result).toContain('deep/level1/file1.txt');
549
+ expect(result).toContain('deep/level1/level2/file2.txt');
550
+
551
+ expect(result).toContain('deep/level1');
552
+ expect(result).toContain('deep/level1/level2');
553
+
554
+ // Should NOT include .git by default (auto-ignored)
555
+ // node_modules is included
556
+ expect(result).toContain('deep/node_modules');
557
+ });
558
+
559
+ it('should return only files when folders=false', async () => {
560
+ const result = await service.handle(
561
+ 'readdirDeep',
562
+ { path: '/deep', files: true, folders: false },
563
+ mockSocket
564
+ );
565
+
566
+ expect(result.length).toBeGreaterThan(0);
567
+ expect(result).toContain('deep/root.txt');
568
+ expect(result).toContain('deep/level1/file1.txt');
569
+ // Should not contain folders
570
+ expect(result).not.toContain('deep/level1');
571
+ });
572
+
573
+ it('should return only folders when files=false', async () => {
574
+ const result = await service.handle(
575
+ 'readdirDeep',
576
+ { path: '/deep', files: false, folders: true },
577
+ mockSocket
578
+ );
579
+
580
+ expect(result.length).toBeGreaterThan(0);
581
+ expect(result).toContain('deep/level1');
582
+ expect(result).toContain('deep/level1/level2');
583
+ // Should not contain files
584
+ expect(result).not.toContain('deep/root.txt');
585
+ });
586
+
587
+ it('should filter ignored names', async () => {
588
+ const result = await service.handle(
589
+ 'readdirDeep',
590
+ { path: '/deep', ignoreName: '.git:node_modules' },
591
+ mockSocket
592
+ );
593
+
594
+ // Should NOT contain .git or node_modules
595
+ expect(result.some((f: string) => f.includes('.git'))).toBe(false);
596
+ expect(result.some((f: string) => f.includes('node_modules'))).toBe(false);
597
+
598
+ // Should still contain other files/folders
599
+ expect(result).toContain('deep/root.txt');
600
+ expect(result).toContain('deep/level1');
601
+ });
602
+
603
+ it('should handle empty ignoreName gracefully', async () => {
604
+ const result = await service.handle(
605
+ 'readdirDeep',
606
+ { path: '/deep', ignoreName: '' },
607
+ mockSocket
608
+ );
609
+
610
+ expect(result.length).toBeGreaterThan(0);
611
+ });
612
+
613
+ it('should handle ignoreName with spaces', async () => {
614
+ const result = await service.handle(
615
+ 'readdirDeep',
616
+ { path: '/deep', ignoreName: ' .git : node_modules ' },
617
+ mockSocket
618
+ );
619
+
620
+ expect(result.some((f: string) => f.includes('.git'))).toBe(false);
621
+ expect(result.some((f: string) => f.includes('node_modules'))).toBe(false);
622
+ });
623
+
624
+ it('should throw error for non-existing directory', async () => {
625
+ await expect(
626
+ service.handle('readdirDeep', { path: '/nonexistent' }, mockSocket)
627
+ ).rejects.toMatchObject({
628
+ code: ErrorCode.FILE_NOT_FOUND,
629
+ });
630
+ });
631
+
632
+ it('should handle empty directory', async () => {
633
+ await fs.mkdir(path.join(testRoot, 'empty'));
634
+
635
+ const result = await service.handle('readdirDeep', { path: '/empty' }, mockSocket);
636
+
637
+ expect(result).toEqual([]);
638
+ });
639
+
640
+ it('should filter results with matchPattern regex', async () => {
641
+ const result = await service.handle(
642
+ 'readdirDeep',
643
+ { path: '/deep', matchPattern: '\\.txt$' },
644
+ mockSocket
645
+ );
646
+
647
+ // Should only contain .txt files
648
+ expect(result).toContain('deep/root.txt');
649
+ expect(result).toContain('deep/level1/file1.txt');
650
+ expect(result).toContain('deep/level1/level2/file2.txt');
651
+
652
+ // Should NOT contain non-.txt files
653
+ expect(result.some((f: string) => f.endsWith('.json'))).toBe(false);
654
+ });
655
+
656
+ it('should filter folders with matchPattern regex', async () => {
657
+ const result = await service.handle(
658
+ 'readdirDeep',
659
+ { path: '/deep', files: false, folders: true, matchPattern: 'level' },
660
+ mockSocket
661
+ );
662
+
663
+ // Should only contain folders with "level" in path
664
+ expect(result).toContain('deep/level1');
665
+ expect(result).toContain('deep/level1/level2');
666
+
667
+ // Should NOT contain node_modules
668
+ expect(result).not.toContain('deep/node_modules');
669
+ });
670
+
671
+ it('should limit results when limit parameter is provided', async () => {
672
+ const result = await service.handle(
673
+ 'readdirDeep',
674
+ { path: '/deep', limit: 2 },
675
+ mockSocket
676
+ );
677
+
678
+ // Should return exactly 2 results (folders + files combined)
679
+ expect(result.length).toBe(2);
680
+ });
681
+
682
+ it('should combine matchPattern and limit correctly', async () => {
683
+ const result = await service.handle(
684
+ 'readdirDeep',
685
+ { path: '/deep', matchPattern: '\\.txt$', limit: 2 },
686
+ mockSocket
687
+ );
688
+
689
+ // Should return max 2 .txt files
690
+ expect(result.length).toBeLessThanOrEqual(2);
691
+
692
+ // All returned files should match the pattern
693
+ result.forEach((file: string) => {
694
+ expect(file).toMatch(/\.txt$/);
695
+ });
696
+ });
697
+
698
+ it('should not truncate when limit is not reached', async () => {
699
+ const result = await service.handle(
700
+ 'readdirDeep',
701
+ { path: '/deep', limit: 1000 },
702
+ mockSocket
703
+ );
704
+
705
+ // Should return all results when limit is high
706
+ expect(result.length).toBeGreaterThan(0);
707
+ });
708
+
709
+ it('should handle limit of 0', async () => {
710
+ const result = await service.handle(
711
+ 'readdirDeep',
712
+ { path: '/deep', limit: 0 },
713
+ mockSocket
714
+ );
715
+
716
+ expect(result).toEqual([]);
717
+ });
718
+
719
+ it('should handle limit of 1', async () => {
720
+ const result = await service.handle(
721
+ 'readdirDeep',
722
+ { path: '/deep', limit: 1 },
723
+ mockSocket
724
+ );
725
+
726
+ expect(result.length).toBe(1);
727
+ });
728
+
729
+ it('should return all results when no limit or pattern', async () => {
730
+ const result = await service.handle(
731
+ 'readdirDeep',
732
+ { path: '/deep' },
733
+ mockSocket
734
+ );
735
+
736
+ expect(result.length).toBeGreaterThan(0);
737
+ });
738
+
739
+ it('should handle invalid regex in matchPattern', async () => {
740
+ await expect(
741
+ service.handle(
742
+ 'readdirDeep',
743
+ { path: '/deep', matchPattern: '[invalid(' },
744
+ mockSocket
745
+ )
746
+ ).rejects.toMatchObject({
747
+ code: ErrorCode.INVALID_PARAMS,
748
+ });
749
+ });
750
+
751
+ it('should apply matchPattern to full relative path', async () => {
752
+ const result = await service.handle(
753
+ 'readdirDeep',
754
+ { path: '/deep', matchPattern: '^deep/level1/' },
755
+ mockSocket
756
+ );
757
+
758
+ // Should only match paths starting with "deep/level1/"
759
+ result.forEach((path: string) => {
760
+ expect(path).toMatch(/^deep\/level1\//);
761
+ });
762
+
763
+ // Should NOT contain root.txt or node_modules
764
+ expect(result).not.toContain('deep/root.txt');
765
+ expect(result).not.toContain('deep/node_modules');
766
+ });
767
+ });
768
+
769
+ describe('Directory Operations - rmdir', () => {
770
+ it('should remove empty directory', async () => {
771
+ await fs.mkdir(path.join(testRoot, 'emptydir'));
772
+
773
+ const result = await service.handle('rmdir', { path: '/emptydir' }, mockSocket);
774
+
775
+ expect(result.success).toBe(true);
776
+ await expect(fs.access(path.join(testRoot, 'emptydir'))).rejects.toThrow();
777
+ });
778
+
779
+ it('should fail to remove non-empty directory', async () => {
780
+ await fs.mkdir(path.join(testRoot, 'nonempty'));
781
+ await fs.writeFile(path.join(testRoot, 'nonempty', 'file.txt'), '');
782
+
783
+ await expect(
784
+ service.handle('rmdir', { path: '/nonempty' }, mockSocket)
785
+ ).rejects.toThrow();
786
+ });
787
+ });
788
+
789
+ describe('File Metadata - lstat', () => {
790
+ it('should return file metadata', async () => {
791
+ await fs.writeFile(path.join(testRoot, 'stat.txt'), 'content');
792
+
793
+ const result = await service.handle('lstat', { path: '/stat.txt' }, mockSocket);
794
+
795
+ expect(result).toMatchObject({
796
+ mode: expect.any(Number),
797
+ size: expect.any(Number),
798
+ mtimeMs: expect.any(Number),
799
+ ctimeMs: expect.any(Number),
800
+ atimeMs: expect.any(Number),
801
+ isFile: true,
802
+ isDirectory: false,
803
+ isSymbolicLink: false,
804
+ });
805
+ });
806
+
807
+ it('should return directory metadata', async () => {
808
+ await fs.mkdir(path.join(testRoot, 'statdir'));
809
+
810
+ const result = await service.handle('lstat', { path: '/statdir' }, mockSocket);
811
+
812
+ expect(result.isFile).toBe(false);
813
+ expect(result.isDirectory).toBe(true);
814
+ });
815
+ });
816
+
817
+ describe('File Manipulation - mv (move/rename)', () => {
818
+ it('should rename file', async () => {
819
+ await fs.writeFile(path.join(testRoot, 'old.txt'), 'content');
820
+
821
+ const result = await service.handle(
822
+ 'mv',
823
+ { src: '/old.txt', target: '/new.txt' },
824
+ mockSocket
825
+ );
826
+
827
+ expect(result).toBe('file');
828
+
829
+ await expect(fs.access(path.join(testRoot, 'old.txt'))).rejects.toThrow();
830
+ const content = await fs.readFile(path.join(testRoot, 'new.txt'), 'utf8');
831
+ expect(content).toBe('content');
832
+ });
833
+
834
+ it('should prevent overwrite when opts.overwrite is false', async () => {
835
+ await fs.writeFile(path.join(testRoot, 'src.txt'), 'source');
836
+ await fs.writeFile(path.join(testRoot, 'dst.txt'), 'destination');
837
+
838
+ await expect(
839
+ service.handle(
840
+ 'mv',
841
+ { src: '/src.txt', target: '/dst.txt', opts: { overwrite: false } },
842
+ mockSocket
843
+ )
844
+ ).rejects.toMatchObject({
845
+ code: ErrorCode.INVALID_PATH,
846
+ message: expect.stringContaining('already exists'),
847
+ });
848
+ });
849
+
850
+ it('should allow overwrite when opts.overwrite is true', async () => {
851
+ await fs.writeFile(path.join(testRoot, 'src2.txt'), 'source');
852
+ await fs.writeFile(path.join(testRoot, 'dst2.txt'), 'destination');
853
+
854
+ const result = await service.handle(
855
+ 'mv',
856
+ { src: '/src2.txt', target: '/dst2.txt', opts: { overwrite: true } },
857
+ mockSocket
858
+ );
859
+
860
+ expect(result).toBe('file');
861
+
862
+ const content = await fs.readFile(path.join(testRoot, 'dst2.txt'), 'utf8');
863
+ expect(content).toBe('source');
864
+ });
865
+ });
866
+
867
+ describe('File Manipulation - copy', () => {
868
+ it('should copy file', async () => {
869
+ await fs.writeFile(path.join(testRoot, 'original.txt'), 'content');
870
+
871
+ const result = await service.handle(
872
+ 'copy',
873
+ { oldpath: '/original.txt', path: '/copy.txt' },
874
+ mockSocket
875
+ );
876
+
877
+ expect(result).toBe('file');
878
+
879
+ const original = await fs.readFile(path.join(testRoot, 'original.txt'), 'utf8');
880
+ const copied = await fs.readFile(path.join(testRoot, 'copy.txt'), 'utf8');
881
+ expect(copied).toBe(original);
882
+ });
883
+
884
+ it('should prevent overwrite when opts.overwrite is false', async () => {
885
+ await fs.writeFile(path.join(testRoot, 'src-copy.txt'), 'source');
886
+ await fs.writeFile(path.join(testRoot, 'dst-copy.txt'), 'destination');
887
+
888
+ await expect(
889
+ service.handle(
890
+ 'copy',
891
+ {
892
+ oldpath: '/src-copy.txt',
893
+ path: '/dst-copy.txt',
894
+ opts: { overwrite: false },
895
+ },
896
+ mockSocket
897
+ )
898
+ ).rejects.toMatchObject({
899
+ code: ErrorCode.INVALID_PATH,
900
+ message: expect.stringContaining('already exists'),
901
+ });
902
+ });
903
+ });
904
+
905
+ describe('Bulk Operations - bulkExists', () => {
906
+ it('should check existence of multiple files with relative base path', async () => {
907
+ // Create test files
908
+ await fs.writeFile(path.join(testRoot, 'file1.txt'), 'content1');
909
+ await fs.writeFile(path.join(testRoot, 'file2.txt'), 'content2');
910
+ await fs.mkdir(path.join(testRoot, 'subdir'));
911
+ await fs.writeFile(path.join(testRoot, 'subdir', 'file3.txt'), 'content3');
912
+
913
+ const result = await service.handle(
914
+ 'bulkExists',
915
+ {
916
+ path: '/',
917
+ paths: ['file1.txt', 'file2.txt', 'missing.txt', 'subdir/file3.txt'],
918
+ },
919
+ mockSocket
920
+ );
921
+
922
+ expect(result).toEqual([1, 1, 0, 1]);
923
+ });
924
+
925
+ it('should check existence with basePath /', async () => {
926
+ // Create nested directory structure
927
+ await fs.mkdir(path.join(testRoot, 'home'));
928
+ await fs.mkdir(path.join(testRoot, 'home', 'user'));
929
+ await fs.mkdir(path.join(testRoot, 'home', 'user', 'project'));
930
+ await fs.writeFile(path.join(testRoot, 'home', 'user', 'project', 'index.js'), 'console.log("hello")');
931
+ await fs.writeFile(path.join(testRoot, 'home', 'user', 'project', 'README.md'), '# Project');
932
+
933
+ const result = await service.handle(
934
+ 'bulkExists',
935
+ {
936
+ path: '/',
937
+ paths: ['home/user/project/index.js', 'home/user/project/README.md', 'home/user/project/missing.js'],
938
+ },
939
+ mockSocket
940
+ );
941
+
942
+ expect(result).toEqual([1, 1, 0]);
943
+ });
944
+
945
+ it('should validate paths and prevent directory traversal in bulkExists', async () => {
946
+ await fs.writeFile(path.join(testRoot, 'safe.txt'), 'content');
947
+
948
+ const result = await service.handle(
949
+ 'bulkExists',
950
+ {
951
+ path: '/',
952
+ paths: ['safe.txt', '../../etc/passwd'],
953
+ },
954
+ mockSocket
955
+ );
956
+
957
+ // safe.txt exists, but ../../etc/passwd should be clamped and return 0
958
+ expect(result[0]).toBe(1);
959
+ expect(result[1]).toBe(0);
960
+ });
961
+
962
+ it('should return empty array when paths is empty', async () => {
963
+ const result = await service.handle(
964
+ 'bulkExists',
965
+ {
966
+ path: '/',
967
+ paths: [],
968
+ },
969
+ mockSocket
970
+ );
971
+
972
+ expect(result).toEqual([]);
973
+ });
974
+
975
+ it('should throw error when paths parameter is not an array', async () => {
976
+ await expect(
977
+ service.handle(
978
+ 'bulkExists',
979
+ {
980
+ path: '/',
981
+ paths: 'not-an-array',
982
+ },
983
+ mockSocket
984
+ )
985
+ ).rejects.toMatchObject({
986
+ code: ErrorCode.INVALID_PARAMS,
987
+ message: expect.stringContaining('paths must be an array'),
988
+ });
989
+ });
990
+ });
991
+
992
+ describe('Unknown Methods', () => {
993
+ it('should throw error for unknown method', async () => {
994
+ await expect(
995
+ service.handle('unknownMethod', {}, mockSocket)
996
+ ).rejects.toMatchObject({
997
+ code: ErrorCode.METHOD_NOT_FOUND,
998
+ message: expect.stringContaining('Method not found'),
999
+ });
1000
+ });
1001
+ });
1002
+ });