@cloudflare/sandbox 0.3.7 → 0.4.2

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 (120) hide show
  1. package/.turbo/turbo-build.log +44 -0
  2. package/CHANGELOG.md +8 -10
  3. package/Dockerfile +82 -18
  4. package/README.md +89 -824
  5. package/dist/chunk-53JFOF7F.js +2352 -0
  6. package/dist/chunk-53JFOF7F.js.map +1 -0
  7. package/dist/chunk-BFVUNTP4.js +104 -0
  8. package/dist/chunk-BFVUNTP4.js.map +1 -0
  9. package/dist/{chunk-NNGBXDMY.js → chunk-EKSWCBCA.js} +3 -6
  10. package/dist/chunk-EKSWCBCA.js.map +1 -0
  11. package/dist/chunk-JXZMAU2C.js +559 -0
  12. package/dist/chunk-JXZMAU2C.js.map +1 -0
  13. package/dist/{chunk-6UAWTJ5S.js → chunk-Z532A7QC.js} +13 -20
  14. package/dist/{chunk-6UAWTJ5S.js.map → chunk-Z532A7QC.js.map} +1 -1
  15. package/dist/file-stream.d.ts +16 -38
  16. package/dist/file-stream.js +1 -2
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.js +45 -38
  19. package/dist/interpreter.d.ts +3 -3
  20. package/dist/interpreter.js +2 -2
  21. package/dist/request-handler.d.ts +4 -3
  22. package/dist/request-handler.js +4 -7
  23. package/dist/sandbox-D9K2ypln.d.ts +583 -0
  24. package/dist/sandbox.d.ts +3 -3
  25. package/dist/sandbox.js +4 -7
  26. package/dist/security.d.ts +4 -3
  27. package/dist/security.js +3 -3
  28. package/dist/sse-parser.js +1 -1
  29. package/package.json +12 -4
  30. package/src/clients/base-client.ts +280 -0
  31. package/src/clients/command-client.ts +115 -0
  32. package/src/clients/file-client.ts +269 -0
  33. package/src/clients/git-client.ts +92 -0
  34. package/src/clients/index.ts +63 -0
  35. package/src/{interpreter-client.ts → clients/interpreter-client.ts} +148 -171
  36. package/src/clients/port-client.ts +105 -0
  37. package/src/clients/process-client.ts +177 -0
  38. package/src/clients/sandbox-client.ts +41 -0
  39. package/src/clients/types.ts +84 -0
  40. package/src/clients/utility-client.ts +94 -0
  41. package/src/errors/adapter.ts +180 -0
  42. package/src/errors/classes.ts +469 -0
  43. package/src/errors/index.ts +105 -0
  44. package/src/file-stream.ts +119 -117
  45. package/src/index.ts +81 -69
  46. package/src/interpreter.ts +17 -8
  47. package/src/request-handler.ts +69 -43
  48. package/src/sandbox.ts +694 -533
  49. package/src/security.ts +14 -23
  50. package/src/sse-parser.ts +4 -8
  51. package/startup.sh +3 -0
  52. package/tests/base-client.test.ts +328 -0
  53. package/tests/command-client.test.ts +407 -0
  54. package/tests/file-client.test.ts +643 -0
  55. package/tests/file-stream.test.ts +306 -0
  56. package/tests/git-client.test.ts +328 -0
  57. package/tests/port-client.test.ts +301 -0
  58. package/tests/process-client.test.ts +658 -0
  59. package/tests/sandbox.test.ts +465 -0
  60. package/tests/sse-parser.test.ts +290 -0
  61. package/tests/utility-client.test.ts +266 -0
  62. package/tests/wrangler.jsonc +35 -0
  63. package/tsconfig.json +9 -1
  64. package/vitest.config.ts +31 -0
  65. package/container_src/bun.lock +0 -76
  66. package/container_src/circuit-breaker.ts +0 -121
  67. package/container_src/control-process.ts +0 -784
  68. package/container_src/handler/exec.ts +0 -185
  69. package/container_src/handler/file.ts +0 -457
  70. package/container_src/handler/git.ts +0 -130
  71. package/container_src/handler/ports.ts +0 -314
  72. package/container_src/handler/process.ts +0 -568
  73. package/container_src/handler/session.ts +0 -92
  74. package/container_src/index.ts +0 -601
  75. package/container_src/interpreter-service.ts +0 -276
  76. package/container_src/isolation.ts +0 -1213
  77. package/container_src/mime-processor.ts +0 -255
  78. package/container_src/package.json +0 -18
  79. package/container_src/runtime/executors/javascript/node_executor.ts +0 -123
  80. package/container_src/runtime/executors/python/ipython_executor.py +0 -338
  81. package/container_src/runtime/executors/typescript/ts_executor.ts +0 -138
  82. package/container_src/runtime/process-pool.ts +0 -464
  83. package/container_src/shell-escape.ts +0 -42
  84. package/container_src/startup.sh +0 -11
  85. package/container_src/types.ts +0 -131
  86. package/dist/chunk-32UDXUPC.js +0 -671
  87. package/dist/chunk-32UDXUPC.js.map +0 -1
  88. package/dist/chunk-5DILEXGY.js +0 -85
  89. package/dist/chunk-5DILEXGY.js.map +0 -1
  90. package/dist/chunk-D3U63BZP.js +0 -240
  91. package/dist/chunk-D3U63BZP.js.map +0 -1
  92. package/dist/chunk-FXYPFGOZ.js +0 -129
  93. package/dist/chunk-FXYPFGOZ.js.map +0 -1
  94. package/dist/chunk-JTKON2SH.js +0 -113
  95. package/dist/chunk-JTKON2SH.js.map +0 -1
  96. package/dist/chunk-NNGBXDMY.js.map +0 -1
  97. package/dist/chunk-SQLJNZ3K.js +0 -674
  98. package/dist/chunk-SQLJNZ3K.js.map +0 -1
  99. package/dist/chunk-W7TVRPBG.js +0 -108
  100. package/dist/chunk-W7TVRPBG.js.map +0 -1
  101. package/dist/client-B3RUab0s.d.ts +0 -225
  102. package/dist/client.d.ts +0 -4
  103. package/dist/client.js +0 -7
  104. package/dist/client.js.map +0 -1
  105. package/dist/errors.d.ts +0 -95
  106. package/dist/errors.js +0 -27
  107. package/dist/errors.js.map +0 -1
  108. package/dist/interpreter-client.d.ts +0 -4
  109. package/dist/interpreter-client.js +0 -9
  110. package/dist/interpreter-client.js.map +0 -1
  111. package/dist/interpreter-types.d.ts +0 -259
  112. package/dist/interpreter-types.js +0 -9
  113. package/dist/interpreter-types.js.map +0 -1
  114. package/dist/types.d.ts +0 -453
  115. package/dist/types.js +0 -45
  116. package/dist/types.js.map +0 -1
  117. package/src/client.ts +0 -1048
  118. package/src/errors.ts +0 -219
  119. package/src/interpreter-types.ts +0 -390
  120. package/src/types.ts +0 -571
@@ -0,0 +1,306 @@
1
+ import type { FileMetadata } from '@repo/shared';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { collectFile, streamFile } from '../src/file-stream';
4
+
5
+ describe('File Streaming Utilities', () => {
6
+ /**
7
+ * Helper to create a mock SSE stream for testing
8
+ */
9
+ function createMockSSEStream(events: string[]): ReadableStream<Uint8Array> {
10
+ return new ReadableStream({
11
+ start(controller) {
12
+ for (const event of events) {
13
+ controller.enqueue(new TextEncoder().encode(event));
14
+ }
15
+ controller.close();
16
+ }
17
+ });
18
+ }
19
+
20
+ describe('streamFile', () => {
21
+ it('should stream text file chunks and return metadata', async () => {
22
+ const stream = createMockSSEStream([
23
+ 'data: {"type":"metadata","mimeType":"text/plain","size":11,"isBinary":false,"encoding":"utf-8"}\n\n',
24
+ 'data: {"type":"chunk","data":"Hello"}\n\n',
25
+ 'data: {"type":"chunk","data":" World"}\n\n',
26
+ 'data: {"type":"complete","bytesRead":11}\n\n',
27
+ ]);
28
+
29
+ const chunks: string[] = [];
30
+ const generator = streamFile(stream);
31
+ let result = await generator.next();
32
+
33
+ // Collect chunks
34
+ while (!result.done) {
35
+ chunks.push(result.value as string);
36
+ result = await generator.next();
37
+ }
38
+
39
+ // Metadata is the return value
40
+ const metadata = result.value;
41
+
42
+ expect(chunks).toEqual(['Hello', ' World']);
43
+ expect(metadata).toEqual({
44
+ mimeType: 'text/plain',
45
+ size: 11,
46
+ isBinary: false,
47
+ encoding: 'utf-8',
48
+ });
49
+ });
50
+
51
+ it('should stream binary file with base64 decoding', async () => {
52
+ // Base64 encoded "test" = "dGVzdA=="
53
+ const stream = createMockSSEStream([
54
+ 'data: {"type":"metadata","mimeType":"image/png","size":4,"isBinary":true,"encoding":"base64"}\n\n',
55
+ 'data: {"type":"chunk","data":"dGVzdA=="}\n\n',
56
+ 'data: {"type":"complete","bytesRead":4}\n\n',
57
+ ]);
58
+
59
+ const chunks: (string | Uint8Array)[] = [];
60
+ const generator = streamFile(stream);
61
+ let result = await generator.next();
62
+
63
+ // Collect chunks
64
+ while (!result.done) {
65
+ chunks.push(result.value);
66
+ result = await generator.next();
67
+ }
68
+
69
+ const metadata = result.value;
70
+
71
+ // For binary files, chunks should be Uint8Array
72
+ expect(chunks.length).toBeGreaterThan(0);
73
+ expect(chunks[0]).toBeInstanceOf(Uint8Array);
74
+
75
+ // Verify we can reconstruct the original data
76
+ const allBytes = new Uint8Array(
77
+ chunks.reduce((acc, chunk) => {
78
+ if (chunk instanceof Uint8Array) {
79
+ return acc + chunk.length;
80
+ }
81
+ return acc;
82
+ }, 0)
83
+ );
84
+
85
+ let offset = 0;
86
+ for (const chunk of chunks) {
87
+ if (chunk instanceof Uint8Array) {
88
+ allBytes.set(chunk, offset);
89
+ offset += chunk.length;
90
+ }
91
+ }
92
+
93
+ const decoded = new TextDecoder().decode(allBytes);
94
+ expect(decoded).toBe('test');
95
+
96
+ expect(metadata?.isBinary).toBe(true);
97
+ expect(metadata?.encoding).toBe('base64');
98
+ expect(metadata?.mimeType).toBe('image/png');
99
+ });
100
+
101
+ it('should handle empty files', async () => {
102
+ const stream = createMockSSEStream([
103
+ 'data: {"type":"metadata","mimeType":"text/plain","size":0,"isBinary":false,"encoding":"utf-8"}\n\n',
104
+ 'data: {"type":"complete","bytesRead":0}\n\n',
105
+ ]);
106
+
107
+ const chunks: string[] = [];
108
+ const generator = streamFile(stream);
109
+ let result = await generator.next();
110
+
111
+ while (!result.done) {
112
+ chunks.push(result.value as string);
113
+ result = await generator.next();
114
+ }
115
+
116
+ const metadata = result.value;
117
+
118
+ expect(chunks).toEqual([]);
119
+ expect(metadata?.size).toBe(0);
120
+ });
121
+
122
+ it('should handle error events', async () => {
123
+ const stream = createMockSSEStream([
124
+ 'data: {"type":"metadata","mimeType":"text/plain","size":100,"isBinary":false,"encoding":"utf-8"}\n\n',
125
+ 'data: {"type":"chunk","data":"Hello"}\n\n',
126
+ 'data: {"type":"error","error":"Read error: Permission denied"}\n\n',
127
+ ]);
128
+
129
+ const generator = streamFile(stream);
130
+
131
+ try {
132
+ let result = await generator.next();
133
+ while (!result.done) {
134
+ result = await generator.next();
135
+ }
136
+ // Should have thrown
137
+ expect(true).toBe(false);
138
+ } catch (error) {
139
+ expect((error as Error).message).toContain('Read error: Permission denied');
140
+ }
141
+ });
142
+ });
143
+
144
+ describe('collectFile', () => {
145
+ it('should collect entire text file into string', async () => {
146
+ const stream = createMockSSEStream([
147
+ 'data: {"type":"metadata","mimeType":"text/plain","size":11,"isBinary":false,"encoding":"utf-8"}\n\n',
148
+ 'data: {"type":"chunk","data":"Hello"}\n\n',
149
+ 'data: {"type":"chunk","data":" World"}\n\n',
150
+ 'data: {"type":"complete","bytesRead":11}\n\n',
151
+ ]);
152
+
153
+ const result = await collectFile(stream);
154
+
155
+ expect(result.content).toBe('Hello World');
156
+ expect(result.metadata).toEqual({
157
+ mimeType: 'text/plain',
158
+ size: 11,
159
+ isBinary: false,
160
+ encoding: 'utf-8',
161
+ });
162
+ });
163
+
164
+ it('should collect entire binary file into Uint8Array', async () => {
165
+ // Base64 encoded "test" = "dGVzdA=="
166
+ const stream = createMockSSEStream([
167
+ 'data: {"type":"metadata","mimeType":"image/png","size":4,"isBinary":true,"encoding":"base64"}\n\n',
168
+ 'data: {"type":"chunk","data":"dGVzdA=="}\n\n',
169
+ 'data: {"type":"complete","bytesRead":4}\n\n',
170
+ ]);
171
+
172
+ const result = await collectFile(stream);
173
+
174
+ expect(result.content).toBeInstanceOf(Uint8Array);
175
+ expect(result.metadata.isBinary).toBe(true);
176
+
177
+ // Decode to verify content
178
+ const decoded = new TextDecoder().decode(result.content as Uint8Array);
179
+ expect(decoded).toBe('test');
180
+ });
181
+
182
+ it('should handle empty files', async () => {
183
+ const stream = createMockSSEStream([
184
+ 'data: {"type":"metadata","mimeType":"text/plain","size":0,"isBinary":false,"encoding":"utf-8"}\n\n',
185
+ 'data: {"type":"complete","bytesRead":0}\n\n',
186
+ ]);
187
+
188
+ const result = await collectFile(stream);
189
+
190
+ expect(result.content).toBe('');
191
+ expect(result.metadata.size).toBe(0);
192
+ });
193
+
194
+ it('should propagate errors from stream', async () => {
195
+ const stream = createMockSSEStream([
196
+ 'data: {"type":"metadata","mimeType":"text/plain","size":100,"isBinary":false,"encoding":"utf-8"}\n\n',
197
+ 'data: {"type":"chunk","data":"Hello"}\n\n',
198
+ 'data: {"type":"error","error":"File not found"}\n\n',
199
+ ]);
200
+
201
+ await expect(collectFile(stream)).rejects.toThrow('File not found');
202
+ });
203
+
204
+ it('should handle large text files efficiently', async () => {
205
+ // Create a stream with many chunks
206
+ const chunkCount = 100;
207
+ const events = [
208
+ 'data: {"type":"metadata","mimeType":"text/plain","size":500,"isBinary":false,"encoding":"utf-8"}\n\n',
209
+ ];
210
+
211
+ for (let i = 0; i < chunkCount; i++) {
212
+ events.push(`data: {"type":"chunk","data":"chunk${i}"}\n\n`);
213
+ }
214
+
215
+ events.push('data: {"type":"complete","bytesRead":500}\n\n');
216
+
217
+ const stream = createMockSSEStream(events);
218
+ const result = await collectFile(stream);
219
+
220
+ expect(typeof result.content).toBe('string');
221
+ expect(result.content).toContain('chunk0');
222
+ expect(result.content).toContain('chunk99');
223
+ expect(result.metadata.encoding).toBe('utf-8');
224
+ });
225
+
226
+ it('should handle large binary files efficiently', async () => {
227
+ // Create a stream with many base64 chunks
228
+ const chunkCount = 100;
229
+ const events = [
230
+ 'data: {"type":"metadata","mimeType":"application/octet-stream","size":400,"isBinary":true,"encoding":"base64"}\n\n',
231
+ ];
232
+
233
+ for (let i = 0; i < chunkCount; i++) {
234
+ // Each "AAAA" base64 chunk decodes to 3 bytes (0x00, 0x00, 0x00)
235
+ events.push('data: {"type":"chunk","data":"AAAA"}\n\n');
236
+ }
237
+
238
+ events.push('data: {"type":"complete","bytesRead":400}\n\n');
239
+
240
+ const stream = createMockSSEStream(events);
241
+ const result = await collectFile(stream);
242
+
243
+ expect(result.content).toBeInstanceOf(Uint8Array);
244
+ expect((result.content as Uint8Array).length).toBeGreaterThan(0);
245
+ expect(result.metadata.isBinary).toBe(true);
246
+ });
247
+ });
248
+
249
+ describe('edge cases', () => {
250
+ it('should handle streams with no metadata event', async () => {
251
+ const stream = createMockSSEStream([
252
+ 'data: {"type":"chunk","data":"Hello"}\n\n',
253
+ 'data: {"type":"complete","bytesRead":5}\n\n',
254
+ ]);
255
+
256
+ // Without metadata, we don't know if it's binary or text
257
+ // The implementation should throw
258
+ const generator = streamFile(stream);
259
+
260
+ try {
261
+ let result = await generator.next();
262
+ while (!result.done) {
263
+ result = await generator.next();
264
+ }
265
+ // Should have thrown
266
+ expect(true).toBe(false);
267
+ } catch (error) {
268
+ expect((error as Error).message).toContain('Received chunk before metadata');
269
+ }
270
+ });
271
+
272
+ it('should handle malformed JSON in SSE events', async () => {
273
+ const stream = createMockSSEStream([
274
+ 'data: {"type":"metadata","mimeType":"text/plain","size":5,"isBinary":false,"encoding":"utf-8"}\n\n',
275
+ 'data: {invalid json\n\n',
276
+ 'data: {"type":"complete","bytesRead":5}\n\n',
277
+ ]);
278
+
279
+ // Malformed JSON is logged but doesn't break the stream
280
+ // It should complete successfully but with no chunks
281
+ const result = await collectFile(stream);
282
+ expect(result.content).toBe('');
283
+ });
284
+
285
+ it('should handle base64 padding correctly', async () => {
286
+ // Test various base64 strings with different padding
287
+ const testCases = [
288
+ { input: 'YQ==', expected: 'a' }, // 1 byte, 2 padding
289
+ { input: 'YWI=', expected: 'ab' }, // 2 bytes, 1 padding
290
+ { input: 'YWJj', expected: 'abc' }, // 3 bytes, no padding
291
+ ];
292
+
293
+ for (const testCase of testCases) {
294
+ const stream = createMockSSEStream([
295
+ `data: {"type":"metadata","mimeType":"application/octet-stream","size":${testCase.expected.length},"isBinary":true,"encoding":"base64"}\n\n`,
296
+ `data: {"type":"chunk","data":"${testCase.input}"}\n\n`,
297
+ `data: {"type":"complete","bytesRead":${testCase.expected.length}}\n\n`,
298
+ ]);
299
+
300
+ const result = await collectFile(stream);
301
+ const decoded = new TextDecoder().decode(result.content as Uint8Array);
302
+ expect(decoded).toBe(testCase.expected);
303
+ }
304
+ });
305
+ });
306
+ });
@@ -0,0 +1,328 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { GitCheckoutResponse } from '../src/clients';
3
+ import { GitClient } from '../src/clients/git-client';
4
+ import {
5
+ GitAuthenticationError,
6
+ GitBranchNotFoundError,
7
+ GitCheckoutError,
8
+ GitCloneError,
9
+ GitError,
10
+ GitNetworkError,
11
+ GitRepositoryNotFoundError,
12
+ InvalidGitUrlError,
13
+ SandboxError
14
+ } from '../src/errors';
15
+
16
+ describe('GitClient', () => {
17
+ let client: GitClient;
18
+ let mockFetch: ReturnType<typeof vi.fn>;
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+
23
+ mockFetch = vi.fn();
24
+ global.fetch = mockFetch as unknown as typeof fetch;
25
+
26
+ client = new GitClient({
27
+ baseUrl: 'http://test.com',
28
+ port: 3000,
29
+ });
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ describe('repository cloning', () => {
37
+ it('should clone public repositories successfully', async () => {
38
+ const mockResponse: GitCheckoutResponse = {
39
+ success: true,
40
+ stdout: 'Cloning into \'react\'...\nReceiving objects: 100% (1284/1284), done.',
41
+ stderr: '',
42
+ exitCode: 0,
43
+ repoUrl: 'https://github.com/facebook/react.git',
44
+ branch: 'main',
45
+ targetDir: 'react',
46
+ timestamp: '2023-01-01T00:00:00Z',
47
+ };
48
+
49
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
50
+
51
+ const result = await client.checkout('https://github.com/facebook/react.git', 'test-session');
52
+
53
+ expect(result.success).toBe(true);
54
+ expect(result.repoUrl).toBe('https://github.com/facebook/react.git');
55
+ expect(result.branch).toBe('main');
56
+ expect(result.exitCode).toBe(0);
57
+ });
58
+
59
+ it('should clone repositories to specific branches', async () => {
60
+ const mockResponse: GitCheckoutResponse = {
61
+ success: true,
62
+ stdout: 'Cloning into \'project\'...\nSwitching to branch \'development\'',
63
+ stderr: '',
64
+ exitCode: 0,
65
+ repoUrl: 'https://github.com/company/project.git',
66
+ branch: 'development',
67
+ targetDir: 'project',
68
+ timestamp: '2023-01-01T00:00:00Z',
69
+ };
70
+
71
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
72
+
73
+ const result = await client.checkout(
74
+ 'https://github.com/company/project.git',
75
+ 'test-session',
76
+ { branch: 'development' }
77
+ );
78
+
79
+ expect(result.success).toBe(true);
80
+ expect(result.branch).toBe('development');
81
+ });
82
+
83
+ it('should clone repositories to custom directories', async () => {
84
+ const mockResponse: GitCheckoutResponse = {
85
+ success: true,
86
+ stdout: 'Cloning into \'workspace/my-app\'...\nDone.',
87
+ stderr: '',
88
+ exitCode: 0,
89
+ repoUrl: 'https://github.com/user/my-app.git',
90
+ branch: 'main',
91
+ targetDir: 'workspace/my-app',
92
+ timestamp: '2023-01-01T00:00:00Z',
93
+ };
94
+
95
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
96
+
97
+ const result = await client.checkout(
98
+ 'https://github.com/user/my-app.git',
99
+ 'test-session',
100
+ { targetDir: 'workspace/my-app' }
101
+ );
102
+
103
+ expect(result.success).toBe(true);
104
+ expect(result.targetDir).toBe('workspace/my-app');
105
+ });
106
+
107
+ it('should handle large repository clones with warnings', async () => {
108
+ const mockResponse: GitCheckoutResponse = {
109
+ success: true,
110
+ stdout: 'Cloning into \'linux\'...\nReceiving objects: 100% (8125432/8125432), 2.34 GiB, done.',
111
+ stderr: 'warning: filtering not recognized by server',
112
+ exitCode: 0,
113
+ repoUrl: 'https://github.com/torvalds/linux.git',
114
+ branch: 'master',
115
+ targetDir: 'linux',
116
+ timestamp: '2023-01-01T00:05:30Z',
117
+ };
118
+
119
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
120
+
121
+ const result = await client.checkout('https://github.com/torvalds/linux.git', 'test-session');
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.stderr).toContain('warning:');
125
+ });
126
+
127
+ it('should handle SSH repository URLs', async () => {
128
+ const mockResponse: GitCheckoutResponse = {
129
+ success: true,
130
+ stdout: 'Cloning into \'private-project\'...\nDone.',
131
+ stderr: '',
132
+ exitCode: 0,
133
+ repoUrl: 'git@github.com:company/private-project.git',
134
+ branch: 'main',
135
+ targetDir: 'private-project',
136
+ timestamp: '2023-01-01T00:00:00Z',
137
+ };
138
+
139
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
140
+
141
+ const result = await client.checkout('git@github.com:company/private-project.git', 'test-session');
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(result.repoUrl).toBe('git@github.com:company/private-project.git');
145
+ });
146
+
147
+ it('should handle concurrent repository operations', async () => {
148
+ mockFetch.mockImplementation((url: string, options: RequestInit) => {
149
+ const body = JSON.parse(options.body as string);
150
+ const repoName = body.repoUrl.split('/').pop().replace('.git', '');
151
+
152
+ return Promise.resolve(new Response(JSON.stringify({
153
+ success: true,
154
+ stdout: `Cloning into '${repoName}'...\nDone.`,
155
+ stderr: '',
156
+ exitCode: 0,
157
+ repoUrl: body.repoUrl,
158
+ branch: body.branch || 'main',
159
+ targetDir: body.targetDir || repoName,
160
+ timestamp: new Date().toISOString(),
161
+ })));
162
+ });
163
+
164
+ const operations = await Promise.all([
165
+ client.checkout('https://github.com/facebook/react.git', 'session-1'),
166
+ client.checkout('https://github.com/microsoft/vscode.git', 'session-2'),
167
+ client.checkout('https://github.com/nodejs/node.git', 'session-3', { branch: 'v18.x' }),
168
+ ]);
169
+
170
+ expect(operations).toHaveLength(3);
171
+ operations.forEach(result => {
172
+ expect(result.success).toBe(true);
173
+ });
174
+ expect(mockFetch).toHaveBeenCalledTimes(3);
175
+ });
176
+ });
177
+
178
+ describe('repository error handling', () => {
179
+ it('should handle repository not found errors', async () => {
180
+ mockFetch.mockResolvedValue(new Response(
181
+ JSON.stringify({ error: 'Repository not found', code: 'GIT_REPOSITORY_NOT_FOUND' }),
182
+ { status: 404 }
183
+ ));
184
+
185
+ await expect(client.checkout('https://github.com/user/nonexistent.git', 'test-session'))
186
+ .rejects.toThrow(GitRepositoryNotFoundError);
187
+ });
188
+
189
+ it('should handle authentication failures', async () => {
190
+ mockFetch.mockResolvedValue(new Response(
191
+ JSON.stringify({ error: 'Authentication failed', code: 'GIT_AUTH_FAILED' }),
192
+ { status: 401 }
193
+ ));
194
+
195
+ await expect(client.checkout('https://github.com/company/private.git', 'test-session'))
196
+ .rejects.toThrow(GitAuthenticationError);
197
+ });
198
+
199
+ it('should handle branch not found errors', async () => {
200
+ mockFetch.mockResolvedValue(new Response(
201
+ JSON.stringify({ error: 'Branch not found', code: 'GIT_BRANCH_NOT_FOUND' }),
202
+ { status: 404 }
203
+ ));
204
+
205
+ await expect(client.checkout(
206
+ 'https://github.com/user/repo.git',
207
+ 'test-session',
208
+ { branch: 'nonexistent-branch' }
209
+ )).rejects.toThrow(GitBranchNotFoundError);
210
+ });
211
+
212
+ it('should handle network errors', async () => {
213
+ mockFetch.mockResolvedValue(new Response(
214
+ JSON.stringify({ error: 'Network error', code: 'GIT_NETWORK_ERROR' }),
215
+ { status: 503 }
216
+ ));
217
+
218
+ await expect(client.checkout('https://github.com/user/repo.git', 'test-session'))
219
+ .rejects.toThrow(GitNetworkError);
220
+ });
221
+
222
+ it('should handle clone failures', async () => {
223
+ mockFetch.mockResolvedValue(new Response(
224
+ JSON.stringify({ error: 'Clone failed', code: 'GIT_CLONE_FAILED' }),
225
+ { status: 507 }
226
+ ));
227
+
228
+ await expect(client.checkout('https://github.com/large/repository.git', 'test-session'))
229
+ .rejects.toThrow(GitCloneError);
230
+ });
231
+
232
+ it('should handle checkout failures', async () => {
233
+ mockFetch.mockResolvedValue(new Response(
234
+ JSON.stringify({ error: 'Checkout failed', code: 'GIT_CHECKOUT_FAILED' }),
235
+ { status: 409 }
236
+ ));
237
+
238
+ await expect(client.checkout(
239
+ 'https://github.com/user/repo.git',
240
+ 'test-session',
241
+ { branch: 'feature-branch' }
242
+ )).rejects.toThrow(GitCheckoutError);
243
+ });
244
+
245
+ it('should handle invalid Git URLs', async () => {
246
+ mockFetch.mockResolvedValue(new Response(
247
+ JSON.stringify({ error: 'Invalid Git URL', code: 'INVALID_GIT_URL' }),
248
+ { status: 400 }
249
+ ));
250
+
251
+ await expect(client.checkout('not-a-valid-url', 'test-session'))
252
+ .rejects.toThrow(InvalidGitUrlError);
253
+ });
254
+
255
+ it('should handle partial clone failures', async () => {
256
+ const mockResponse: GitCheckoutResponse = {
257
+ success: false,
258
+ stdout: 'Cloning into \'repo\'...\nReceiving objects: 45% (450/1000)',
259
+ stderr: 'error: RPC failed\nfatal: early EOF',
260
+ exitCode: 128,
261
+ repoUrl: 'https://github.com/problematic/repo.git',
262
+ branch: 'main',
263
+ targetDir: 'repo',
264
+ timestamp: '2023-01-01T00:01:30Z',
265
+ };
266
+
267
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
268
+
269
+ const result = await client.checkout('https://github.com/problematic/repo.git', 'test-session');
270
+
271
+ expect(result.success).toBe(false);
272
+ expect(result.exitCode).toBe(128);
273
+ expect(result.stderr).toContain('RPC failed');
274
+ });
275
+ });
276
+
277
+ describe('error handling edge cases', () => {
278
+ it('should handle network failures', async () => {
279
+ mockFetch.mockRejectedValue(new Error('Network connection failed'));
280
+
281
+ await expect(client.checkout('https://github.com/user/repo.git', 'test-session'))
282
+ .rejects.toThrow('Network connection failed');
283
+ });
284
+
285
+ it('should handle malformed server responses', async () => {
286
+ mockFetch.mockResolvedValue(new Response('invalid json {', { status: 200 }));
287
+
288
+ await expect(client.checkout('https://github.com/user/repo.git', 'test-session'))
289
+ .rejects.toThrow(SandboxError);
290
+ });
291
+
292
+ it('should map server errors to client errors', async () => {
293
+ const serverErrorScenarios = [
294
+ { status: 400, code: 'INVALID_GIT_URL', error: InvalidGitUrlError },
295
+ { status: 401, code: 'GIT_AUTH_FAILED', error: GitAuthenticationError },
296
+ { status: 404, code: 'GIT_REPOSITORY_NOT_FOUND', error: GitRepositoryNotFoundError },
297
+ { status: 404, code: 'GIT_BRANCH_NOT_FOUND', error: GitBranchNotFoundError },
298
+ { status: 500, code: 'GIT_OPERATION_FAILED', error: GitError },
299
+ { status: 503, code: 'GIT_NETWORK_ERROR', error: GitNetworkError },
300
+ ];
301
+
302
+ for (const scenario of serverErrorScenarios) {
303
+ mockFetch.mockResolvedValueOnce(new Response(
304
+ JSON.stringify({ error: 'Test error', code: scenario.code }),
305
+ { status: scenario.status }
306
+ ));
307
+
308
+ await expect(client.checkout('https://github.com/test/repo.git', 'test-session'))
309
+ .rejects.toThrow(scenario.error);
310
+ }
311
+ });
312
+ });
313
+
314
+ describe('constructor options', () => {
315
+ it('should initialize with minimal options', () => {
316
+ const minimalClient = new GitClient();
317
+ expect(minimalClient).toBeInstanceOf(GitClient);
318
+ });
319
+
320
+ it('should initialize with full options', () => {
321
+ const fullOptionsClient = new GitClient({
322
+ baseUrl: 'http://custom.com',
323
+ port: 8080,
324
+ });
325
+ expect(fullOptionsClient).toBeInstanceOf(GitClient);
326
+ });
327
+ });
328
+ });