@cloudflare/sandbox 0.5.6 → 0.6.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/Dockerfile +54 -56
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +11 -6
- package/.turbo/turbo-build.log +0 -23
- package/CHANGELOG.md +0 -463
- package/src/clients/base-client.ts +0 -356
- package/src/clients/command-client.ts +0 -133
- package/src/clients/file-client.ts +0 -300
- package/src/clients/git-client.ts +0 -98
- package/src/clients/index.ts +0 -64
- package/src/clients/interpreter-client.ts +0 -339
- package/src/clients/port-client.ts +0 -105
- package/src/clients/process-client.ts +0 -198
- package/src/clients/sandbox-client.ts +0 -39
- package/src/clients/types.ts +0 -88
- package/src/clients/utility-client.ts +0 -156
- package/src/errors/adapter.ts +0 -238
- package/src/errors/classes.ts +0 -594
- package/src/errors/index.ts +0 -109
- package/src/file-stream.ts +0 -175
- package/src/index.ts +0 -121
- package/src/interpreter.ts +0 -168
- package/src/openai/index.ts +0 -465
- package/src/request-handler.ts +0 -184
- package/src/sandbox.ts +0 -1937
- package/src/security.ts +0 -119
- package/src/sse-parser.ts +0 -147
- package/src/storage-mount/credential-detection.ts +0 -41
- package/src/storage-mount/errors.ts +0 -51
- package/src/storage-mount/index.ts +0 -17
- package/src/storage-mount/provider-detection.ts +0 -93
- package/src/storage-mount/types.ts +0 -17
- package/src/version.ts +0 -6
- package/tests/base-client.test.ts +0 -582
- package/tests/command-client.test.ts +0 -444
- package/tests/file-client.test.ts +0 -831
- package/tests/file-stream.test.ts +0 -310
- package/tests/get-sandbox.test.ts +0 -172
- package/tests/git-client.test.ts +0 -455
- package/tests/openai-shell-editor.test.ts +0 -434
- package/tests/port-client.test.ts +0 -283
- package/tests/process-client.test.ts +0 -649
- package/tests/request-handler.test.ts +0 -292
- package/tests/sandbox.test.ts +0 -890
- package/tests/sse-parser.test.ts +0 -291
- package/tests/storage-mount/credential-detection.test.ts +0 -119
- package/tests/storage-mount/provider-detection.test.ts +0 -77
- package/tests/utility-client.test.ts +0 -339
- package/tests/version.test.ts +0 -16
- package/tests/wrangler.jsonc +0 -35
- package/tsconfig.json +0 -11
- package/tsdown.config.ts +0 -13
- package/vitest.config.ts +0 -31
package/tests/sandbox.test.ts
DELETED
|
@@ -1,890 +0,0 @@
|
|
|
1
|
-
import { Container } from '@cloudflare/containers';
|
|
2
|
-
import type { DurableObjectState } from '@cloudflare/workers-types';
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { connect, Sandbox } from '../src/sandbox';
|
|
5
|
-
|
|
6
|
-
// Mock dependencies before imports
|
|
7
|
-
vi.mock('./interpreter', () => ({
|
|
8
|
-
CodeInterpreter: vi.fn().mockImplementation(() => ({}))
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
vi.mock('@cloudflare/containers', () => {
|
|
12
|
-
const mockSwitchPort = vi.fn((request: Request, port: number) => {
|
|
13
|
-
// Create a new request with the port in the URL path
|
|
14
|
-
const url = new URL(request.url);
|
|
15
|
-
url.pathname = `/proxy/${port}${url.pathname}`;
|
|
16
|
-
return new Request(url, request);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const MockContainer = class Container {
|
|
20
|
-
ctx: any;
|
|
21
|
-
env: any;
|
|
22
|
-
constructor(ctx: any, env: any) {
|
|
23
|
-
this.ctx = ctx;
|
|
24
|
-
this.env = env;
|
|
25
|
-
}
|
|
26
|
-
async fetch(request: Request): Promise<Response> {
|
|
27
|
-
// Mock implementation - will be spied on in tests
|
|
28
|
-
const upgradeHeader = request.headers.get('Upgrade');
|
|
29
|
-
if (upgradeHeader?.toLowerCase() === 'websocket') {
|
|
30
|
-
return new Response('WebSocket Upgraded', {
|
|
31
|
-
status: 200,
|
|
32
|
-
headers: {
|
|
33
|
-
'X-WebSocket-Upgraded': 'true',
|
|
34
|
-
Upgrade: 'websocket',
|
|
35
|
-
Connection: 'Upgrade'
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
return new Response('Mock Container fetch');
|
|
40
|
-
}
|
|
41
|
-
async containerFetch(request: Request, port: number): Promise<Response> {
|
|
42
|
-
// Mock implementation for HTTP path
|
|
43
|
-
return new Response('Mock Container HTTP fetch');
|
|
44
|
-
}
|
|
45
|
-
async getState() {
|
|
46
|
-
// Mock implementation - return healthy state
|
|
47
|
-
return { status: 'healthy' };
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
Container: MockContainer,
|
|
53
|
-
getContainer: vi.fn(),
|
|
54
|
-
switchPort: mockSwitchPort
|
|
55
|
-
};
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('Sandbox - Automatic Session Management', () => {
|
|
59
|
-
let sandbox: Sandbox;
|
|
60
|
-
let mockCtx: Partial<DurableObjectState<{}>>;
|
|
61
|
-
let mockEnv: any;
|
|
62
|
-
|
|
63
|
-
beforeEach(async () => {
|
|
64
|
-
vi.clearAllMocks();
|
|
65
|
-
|
|
66
|
-
// Mock DurableObjectState
|
|
67
|
-
mockCtx = {
|
|
68
|
-
storage: {
|
|
69
|
-
get: vi.fn().mockResolvedValue(null),
|
|
70
|
-
put: vi.fn().mockResolvedValue(undefined),
|
|
71
|
-
delete: vi.fn().mockResolvedValue(undefined),
|
|
72
|
-
list: vi.fn().mockResolvedValue(new Map())
|
|
73
|
-
} as any,
|
|
74
|
-
blockConcurrencyWhile: vi
|
|
75
|
-
.fn()
|
|
76
|
-
.mockImplementation(
|
|
77
|
-
<T>(callback: () => Promise<T>): Promise<T> => callback()
|
|
78
|
-
),
|
|
79
|
-
waitUntil: vi.fn(),
|
|
80
|
-
id: {
|
|
81
|
-
toString: () => 'test-sandbox-id',
|
|
82
|
-
equals: vi.fn(),
|
|
83
|
-
name: 'test-sandbox'
|
|
84
|
-
} as any
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
mockEnv = {};
|
|
88
|
-
|
|
89
|
-
// Create Sandbox instance - SandboxClient is created internally
|
|
90
|
-
const stub = new Sandbox(mockCtx as DurableObjectState<{}>, mockEnv);
|
|
91
|
-
|
|
92
|
-
// Wait for blockConcurrencyWhile to complete
|
|
93
|
-
await vi.waitFor(() => {
|
|
94
|
-
expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
sandbox = Object.assign(stub, {
|
|
98
|
-
wsConnect: connect(stub)
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// Now spy on the client methods that we need for testing
|
|
102
|
-
vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({
|
|
103
|
-
success: true,
|
|
104
|
-
id: 'sandbox-default',
|
|
105
|
-
message: 'Created'
|
|
106
|
-
} as any);
|
|
107
|
-
|
|
108
|
-
vi.spyOn(sandbox.client.commands, 'execute').mockResolvedValue({
|
|
109
|
-
success: true,
|
|
110
|
-
stdout: '',
|
|
111
|
-
stderr: '',
|
|
112
|
-
exitCode: 0,
|
|
113
|
-
command: '',
|
|
114
|
-
timestamp: new Date().toISOString()
|
|
115
|
-
} as any);
|
|
116
|
-
|
|
117
|
-
vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
|
|
118
|
-
success: true,
|
|
119
|
-
path: '/test.txt',
|
|
120
|
-
timestamp: new Date().toISOString()
|
|
121
|
-
} as any);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
afterEach(() => {
|
|
125
|
-
vi.restoreAllMocks();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe('default session management', () => {
|
|
129
|
-
it('should create default session on first operation', async () => {
|
|
130
|
-
vi.mocked(sandbox.client.commands.execute).mockResolvedValueOnce({
|
|
131
|
-
success: true,
|
|
132
|
-
stdout: 'test output',
|
|
133
|
-
stderr: '',
|
|
134
|
-
exitCode: 0,
|
|
135
|
-
command: 'echo test',
|
|
136
|
-
timestamp: new Date().toISOString()
|
|
137
|
-
} as any);
|
|
138
|
-
|
|
139
|
-
await sandbox.exec('echo test');
|
|
140
|
-
|
|
141
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
|
|
142
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith(
|
|
143
|
-
expect.objectContaining({
|
|
144
|
-
id: expect.stringMatching(/^sandbox-/),
|
|
145
|
-
cwd: '/workspace'
|
|
146
|
-
})
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
|
|
150
|
-
'echo test',
|
|
151
|
-
expect.stringMatching(/^sandbox-/),
|
|
152
|
-
undefined
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('should forward exec options to the command client', async () => {
|
|
157
|
-
await sandbox.exec('echo $OPTION', {
|
|
158
|
-
env: { OPTION: 'value' },
|
|
159
|
-
cwd: '/workspace/project',
|
|
160
|
-
timeout: 5000
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
|
|
164
|
-
'echo $OPTION',
|
|
165
|
-
expect.stringMatching(/^sandbox-/),
|
|
166
|
-
{
|
|
167
|
-
timeoutMs: 5000,
|
|
168
|
-
env: { OPTION: 'value' },
|
|
169
|
-
cwd: '/workspace/project'
|
|
170
|
-
}
|
|
171
|
-
);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('should reuse default session across multiple operations', async () => {
|
|
175
|
-
await sandbox.exec('echo test1');
|
|
176
|
-
await sandbox.writeFile('/test.txt', 'content');
|
|
177
|
-
await sandbox.exec('echo test2');
|
|
178
|
-
|
|
179
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
|
|
180
|
-
|
|
181
|
-
const firstSessionId = vi.mocked(sandbox.client.commands.execute).mock
|
|
182
|
-
.calls[0][1];
|
|
183
|
-
const fileSessionId = vi.mocked(sandbox.client.files.writeFile).mock
|
|
184
|
-
.calls[0][2];
|
|
185
|
-
const secondSessionId = vi.mocked(sandbox.client.commands.execute).mock
|
|
186
|
-
.calls[1][1];
|
|
187
|
-
|
|
188
|
-
expect(firstSessionId).toBe(fileSessionId);
|
|
189
|
-
expect(firstSessionId).toBe(secondSessionId);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('should use default session for process management', async () => {
|
|
193
|
-
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
|
|
194
|
-
success: true,
|
|
195
|
-
processId: 'proc-1',
|
|
196
|
-
pid: 1234,
|
|
197
|
-
command: 'sleep 10',
|
|
198
|
-
timestamp: new Date().toISOString()
|
|
199
|
-
} as any);
|
|
200
|
-
|
|
201
|
-
vi.spyOn(sandbox.client.processes, 'listProcesses').mockResolvedValue({
|
|
202
|
-
success: true,
|
|
203
|
-
processes: [
|
|
204
|
-
{
|
|
205
|
-
id: 'proc-1',
|
|
206
|
-
pid: 1234,
|
|
207
|
-
command: 'sleep 10',
|
|
208
|
-
status: 'running',
|
|
209
|
-
startTime: new Date().toISOString()
|
|
210
|
-
}
|
|
211
|
-
],
|
|
212
|
-
timestamp: new Date().toISOString()
|
|
213
|
-
} as any);
|
|
214
|
-
|
|
215
|
-
const process = await sandbox.startProcess('sleep 10');
|
|
216
|
-
const processes = await sandbox.listProcesses();
|
|
217
|
-
|
|
218
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
|
|
219
|
-
|
|
220
|
-
// startProcess uses sessionId (to start process in that session)
|
|
221
|
-
const startSessionId = vi.mocked(sandbox.client.processes.startProcess)
|
|
222
|
-
.mock.calls[0][1];
|
|
223
|
-
expect(startSessionId).toMatch(/^sandbox-/);
|
|
224
|
-
|
|
225
|
-
// listProcesses is sandbox-scoped - no sessionId parameter
|
|
226
|
-
const listProcessesCall = vi.mocked(
|
|
227
|
-
sandbox.client.processes.listProcesses
|
|
228
|
-
).mock.calls[0];
|
|
229
|
-
expect(listProcessesCall).toEqual([]);
|
|
230
|
-
|
|
231
|
-
// Verify the started process appears in the list
|
|
232
|
-
expect(process.id).toBe('proc-1');
|
|
233
|
-
expect(processes).toHaveLength(1);
|
|
234
|
-
expect(processes[0].id).toBe('proc-1');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('should use default session for git operations', async () => {
|
|
238
|
-
vi.spyOn(sandbox.client.git, 'checkout').mockResolvedValue({
|
|
239
|
-
success: true,
|
|
240
|
-
stdout: 'Cloned successfully',
|
|
241
|
-
stderr: '',
|
|
242
|
-
branch: 'main',
|
|
243
|
-
targetDir: '/workspace/repo',
|
|
244
|
-
timestamp: new Date().toISOString()
|
|
245
|
-
} as any);
|
|
246
|
-
|
|
247
|
-
await sandbox.gitCheckout('https://github.com/test/repo.git', {
|
|
248
|
-
branch: 'main'
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledTimes(1);
|
|
252
|
-
expect(sandbox.client.git.checkout).toHaveBeenCalledWith(
|
|
253
|
-
'https://github.com/test/repo.git',
|
|
254
|
-
expect.stringMatching(/^sandbox-/),
|
|
255
|
-
{ branch: 'main', targetDir: undefined }
|
|
256
|
-
);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should initialize session with sandbox name when available', async () => {
|
|
260
|
-
await sandbox.setSandboxName('my-sandbox');
|
|
261
|
-
|
|
262
|
-
await sandbox.exec('pwd');
|
|
263
|
-
|
|
264
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith(
|
|
265
|
-
expect.objectContaining({
|
|
266
|
-
id: 'sandbox-my-sandbox',
|
|
267
|
-
cwd: '/workspace'
|
|
268
|
-
})
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe('explicit session creation', () => {
|
|
274
|
-
it('should create isolated execution session', async () => {
|
|
275
|
-
vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
|
|
276
|
-
success: true,
|
|
277
|
-
id: 'custom-session-123',
|
|
278
|
-
message: 'Created'
|
|
279
|
-
} as any);
|
|
280
|
-
|
|
281
|
-
const session = await sandbox.createSession({
|
|
282
|
-
id: 'custom-session-123',
|
|
283
|
-
env: { NODE_ENV: 'test' },
|
|
284
|
-
cwd: '/test'
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
|
|
288
|
-
id: 'custom-session-123',
|
|
289
|
-
env: { NODE_ENV: 'test' },
|
|
290
|
-
cwd: '/test'
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
expect(session.id).toBe('custom-session-123');
|
|
294
|
-
expect(session.exec).toBeInstanceOf(Function);
|
|
295
|
-
expect(session.startProcess).toBeInstanceOf(Function);
|
|
296
|
-
expect(session.writeFile).toBeInstanceOf(Function);
|
|
297
|
-
expect(session.gitCheckout).toBeInstanceOf(Function);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('should execute operations in specific session context', async () => {
|
|
301
|
-
vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
|
|
302
|
-
success: true,
|
|
303
|
-
id: 'isolated-session',
|
|
304
|
-
message: 'Created'
|
|
305
|
-
} as any);
|
|
306
|
-
|
|
307
|
-
const session = await sandbox.createSession({ id: 'isolated-session' });
|
|
308
|
-
|
|
309
|
-
await session.exec('echo test');
|
|
310
|
-
|
|
311
|
-
expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
|
|
312
|
-
'echo test',
|
|
313
|
-
'isolated-session',
|
|
314
|
-
undefined
|
|
315
|
-
);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('should isolate multiple explicit sessions', async () => {
|
|
319
|
-
vi.mocked(sandbox.client.utils.createSession)
|
|
320
|
-
.mockResolvedValueOnce({
|
|
321
|
-
success: true,
|
|
322
|
-
id: 'session-1',
|
|
323
|
-
message: 'Created'
|
|
324
|
-
} as any)
|
|
325
|
-
.mockResolvedValueOnce({
|
|
326
|
-
success: true,
|
|
327
|
-
id: 'session-2',
|
|
328
|
-
message: 'Created'
|
|
329
|
-
} as any);
|
|
330
|
-
|
|
331
|
-
const session1 = await sandbox.createSession({ id: 'session-1' });
|
|
332
|
-
const session2 = await sandbox.createSession({ id: 'session-2' });
|
|
333
|
-
|
|
334
|
-
await session1.exec('echo build');
|
|
335
|
-
await session2.exec('echo test');
|
|
336
|
-
|
|
337
|
-
const session1Id = vi.mocked(sandbox.client.commands.execute).mock
|
|
338
|
-
.calls[0][1];
|
|
339
|
-
const session2Id = vi.mocked(sandbox.client.commands.execute).mock
|
|
340
|
-
.calls[1][1];
|
|
341
|
-
|
|
342
|
-
expect(session1Id).toBe('session-1');
|
|
343
|
-
expect(session2Id).toBe('session-2');
|
|
344
|
-
expect(session1Id).not.toBe(session2Id);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('should not interfere with default session', async () => {
|
|
348
|
-
vi.mocked(sandbox.client.utils.createSession)
|
|
349
|
-
.mockResolvedValueOnce({
|
|
350
|
-
success: true,
|
|
351
|
-
id: 'sandbox-default',
|
|
352
|
-
message: 'Created'
|
|
353
|
-
} as any)
|
|
354
|
-
.mockResolvedValueOnce({
|
|
355
|
-
success: true,
|
|
356
|
-
id: 'explicit-session',
|
|
357
|
-
message: 'Created'
|
|
358
|
-
} as any);
|
|
359
|
-
|
|
360
|
-
await sandbox.exec('echo default');
|
|
361
|
-
|
|
362
|
-
const explicitSession = await sandbox.createSession({
|
|
363
|
-
id: 'explicit-session'
|
|
364
|
-
});
|
|
365
|
-
await explicitSession.exec('echo explicit');
|
|
366
|
-
|
|
367
|
-
await sandbox.exec('echo default-again');
|
|
368
|
-
|
|
369
|
-
const defaultSessionId1 = vi.mocked(sandbox.client.commands.execute).mock
|
|
370
|
-
.calls[0][1];
|
|
371
|
-
const explicitSessionId = vi.mocked(sandbox.client.commands.execute).mock
|
|
372
|
-
.calls[1][1];
|
|
373
|
-
const defaultSessionId2 = vi.mocked(sandbox.client.commands.execute).mock
|
|
374
|
-
.calls[2][1];
|
|
375
|
-
|
|
376
|
-
expect(defaultSessionId1).toBe('sandbox-default');
|
|
377
|
-
expect(explicitSessionId).toBe('explicit-session');
|
|
378
|
-
expect(defaultSessionId2).toBe('sandbox-default');
|
|
379
|
-
expect(defaultSessionId1).toBe(defaultSessionId2);
|
|
380
|
-
expect(explicitSessionId).not.toBe(defaultSessionId1);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it('should generate session ID if not provided', async () => {
|
|
384
|
-
vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
|
|
385
|
-
success: true,
|
|
386
|
-
id: 'session-generated-123',
|
|
387
|
-
message: 'Created'
|
|
388
|
-
} as any);
|
|
389
|
-
|
|
390
|
-
await sandbox.createSession();
|
|
391
|
-
|
|
392
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith(
|
|
393
|
-
expect.objectContaining({
|
|
394
|
-
id: expect.stringMatching(/^session-/)
|
|
395
|
-
})
|
|
396
|
-
);
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
describe('ExecutionSession operations', () => {
|
|
401
|
-
let session: any;
|
|
402
|
-
|
|
403
|
-
beforeEach(async () => {
|
|
404
|
-
vi.mocked(sandbox.client.utils.createSession).mockResolvedValueOnce({
|
|
405
|
-
success: true,
|
|
406
|
-
id: 'test-session',
|
|
407
|
-
message: 'Created'
|
|
408
|
-
} as any);
|
|
409
|
-
|
|
410
|
-
session = await sandbox.createSession({ id: 'test-session' });
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it('should execute command with session context', async () => {
|
|
414
|
-
await session.exec('pwd');
|
|
415
|
-
expect(sandbox.client.commands.execute).toHaveBeenCalledWith(
|
|
416
|
-
'pwd',
|
|
417
|
-
'test-session',
|
|
418
|
-
undefined
|
|
419
|
-
);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it('should start process with session context', async () => {
|
|
423
|
-
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
|
|
424
|
-
success: true,
|
|
425
|
-
process: {
|
|
426
|
-
id: 'proc-1',
|
|
427
|
-
pid: 1234,
|
|
428
|
-
command: 'sleep 10',
|
|
429
|
-
status: 'running',
|
|
430
|
-
startTime: new Date().toISOString()
|
|
431
|
-
}
|
|
432
|
-
} as any);
|
|
433
|
-
|
|
434
|
-
await session.startProcess('sleep 10');
|
|
435
|
-
|
|
436
|
-
expect(sandbox.client.processes.startProcess).toHaveBeenCalledWith(
|
|
437
|
-
'sleep 10',
|
|
438
|
-
'test-session',
|
|
439
|
-
{}
|
|
440
|
-
);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
it('should write file with session context', async () => {
|
|
444
|
-
vi.spyOn(sandbox.client.files, 'writeFile').mockResolvedValue({
|
|
445
|
-
success: true,
|
|
446
|
-
path: '/test.txt',
|
|
447
|
-
timestamp: new Date().toISOString()
|
|
448
|
-
} as any);
|
|
449
|
-
|
|
450
|
-
await session.writeFile('/test.txt', 'content');
|
|
451
|
-
|
|
452
|
-
expect(sandbox.client.files.writeFile).toHaveBeenCalledWith(
|
|
453
|
-
'/test.txt',
|
|
454
|
-
'content',
|
|
455
|
-
'test-session',
|
|
456
|
-
{ encoding: undefined }
|
|
457
|
-
);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it('should perform git checkout with session context', async () => {
|
|
461
|
-
vi.spyOn(sandbox.client.git, 'checkout').mockResolvedValue({
|
|
462
|
-
success: true,
|
|
463
|
-
stdout: 'Cloned',
|
|
464
|
-
stderr: '',
|
|
465
|
-
branch: 'main',
|
|
466
|
-
targetDir: '/workspace/repo',
|
|
467
|
-
timestamp: new Date().toISOString()
|
|
468
|
-
} as any);
|
|
469
|
-
|
|
470
|
-
await session.gitCheckout('https://github.com/test/repo.git');
|
|
471
|
-
|
|
472
|
-
expect(sandbox.client.git.checkout).toHaveBeenCalledWith(
|
|
473
|
-
'https://github.com/test/repo.git',
|
|
474
|
-
'test-session',
|
|
475
|
-
{ branch: undefined, targetDir: undefined }
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
describe('edge cases and error handling', () => {
|
|
481
|
-
it('should handle session creation errors gracefully', async () => {
|
|
482
|
-
vi.mocked(sandbox.client.utils.createSession).mockRejectedValueOnce(
|
|
483
|
-
new Error('Session creation failed')
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
await expect(sandbox.exec('echo test')).rejects.toThrow(
|
|
487
|
-
'Session creation failed'
|
|
488
|
-
);
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
it('should initialize with empty environment when not set', async () => {
|
|
492
|
-
await sandbox.exec('pwd');
|
|
493
|
-
|
|
494
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith(
|
|
495
|
-
expect.objectContaining({
|
|
496
|
-
id: expect.any(String),
|
|
497
|
-
cwd: '/workspace'
|
|
498
|
-
})
|
|
499
|
-
);
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it('should use updated environment after setEnvVars', async () => {
|
|
503
|
-
await sandbox.setEnvVars({ NODE_ENV: 'production', DEBUG: 'true' });
|
|
504
|
-
|
|
505
|
-
await sandbox.exec('env');
|
|
506
|
-
|
|
507
|
-
expect(sandbox.client.utils.createSession).toHaveBeenCalledWith({
|
|
508
|
-
id: expect.any(String),
|
|
509
|
-
env: { NODE_ENV: 'production', DEBUG: 'true' },
|
|
510
|
-
cwd: '/workspace'
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
describe('port exposure - workers.dev detection', () => {
|
|
516
|
-
beforeEach(async () => {
|
|
517
|
-
await sandbox.setSandboxName('test-sandbox');
|
|
518
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
519
|
-
success: true,
|
|
520
|
-
port: 8080,
|
|
521
|
-
name: 'test-service',
|
|
522
|
-
exposedAt: new Date().toISOString()
|
|
523
|
-
} as any);
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
it('should reject workers.dev domains with CustomDomainRequiredError', async () => {
|
|
527
|
-
const hostnames = [
|
|
528
|
-
'my-worker.workers.dev',
|
|
529
|
-
'my-worker.my-account.workers.dev'
|
|
530
|
-
];
|
|
531
|
-
|
|
532
|
-
for (const hostname of hostnames) {
|
|
533
|
-
try {
|
|
534
|
-
await sandbox.exposePort(8080, { name: 'test', hostname });
|
|
535
|
-
// Should not reach here
|
|
536
|
-
expect.fail('Should have thrown CustomDomainRequiredError');
|
|
537
|
-
} catch (error: any) {
|
|
538
|
-
expect(error.name).toBe('CustomDomainRequiredError');
|
|
539
|
-
expect(error.code).toBe('CUSTOM_DOMAIN_REQUIRED');
|
|
540
|
-
expect(error.message).toContain('workers.dev');
|
|
541
|
-
expect(error.message).toContain('custom domain');
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Verify client method was never called
|
|
546
|
-
expect(sandbox.client.ports.exposePort).not.toHaveBeenCalled();
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it('should accept custom domains and subdomains', async () => {
|
|
550
|
-
const testCases = [
|
|
551
|
-
{ hostname: 'example.com', description: 'apex domain' },
|
|
552
|
-
{ hostname: 'sandbox.example.com', description: 'subdomain' }
|
|
553
|
-
];
|
|
554
|
-
|
|
555
|
-
for (const { hostname } of testCases) {
|
|
556
|
-
const result = await sandbox.exposePort(8080, {
|
|
557
|
-
name: 'test',
|
|
558
|
-
hostname
|
|
559
|
-
});
|
|
560
|
-
expect(result.url).toContain(hostname);
|
|
561
|
-
expect(result.port).toBe(8080);
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
it('should accept localhost for local development', async () => {
|
|
566
|
-
const result = await sandbox.exposePort(8080, {
|
|
567
|
-
name: 'test',
|
|
568
|
-
hostname: 'localhost:8787'
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
expect(result.url).toContain('localhost');
|
|
572
|
-
expect(sandbox.client.ports.exposePort).toHaveBeenCalled();
|
|
573
|
-
});
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
describe('fetch() override - WebSocket detection', () => {
|
|
577
|
-
let superFetchSpy: any;
|
|
578
|
-
|
|
579
|
-
beforeEach(async () => {
|
|
580
|
-
await sandbox.setSandboxName('test-sandbox');
|
|
581
|
-
|
|
582
|
-
// Spy on Container.prototype.fetch to verify WebSocket routing
|
|
583
|
-
superFetchSpy = vi
|
|
584
|
-
.spyOn(Container.prototype, 'fetch')
|
|
585
|
-
.mockResolvedValue(new Response('WebSocket response'));
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
afterEach(() => {
|
|
589
|
-
superFetchSpy?.mockRestore();
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('should detect WebSocket upgrade header and route to super.fetch', async () => {
|
|
593
|
-
const request = new Request('https://example.com/ws', {
|
|
594
|
-
headers: {
|
|
595
|
-
Upgrade: 'websocket',
|
|
596
|
-
Connection: 'Upgrade'
|
|
597
|
-
}
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
const response = await sandbox.fetch(request);
|
|
601
|
-
|
|
602
|
-
// Should route through super.fetch() for WebSocket
|
|
603
|
-
expect(superFetchSpy).toHaveBeenCalledTimes(1);
|
|
604
|
-
expect(await response.text()).toBe('WebSocket response');
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it('should route non-WebSocket requests through containerFetch', async () => {
|
|
608
|
-
// GET request
|
|
609
|
-
const getRequest = new Request('https://example.com/api/data');
|
|
610
|
-
await sandbox.fetch(getRequest);
|
|
611
|
-
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
612
|
-
|
|
613
|
-
vi.clearAllMocks();
|
|
614
|
-
|
|
615
|
-
// POST request
|
|
616
|
-
const postRequest = new Request('https://example.com/api/data', {
|
|
617
|
-
method: 'POST',
|
|
618
|
-
body: JSON.stringify({ data: 'test' }),
|
|
619
|
-
headers: { 'Content-Type': 'application/json' }
|
|
620
|
-
});
|
|
621
|
-
await sandbox.fetch(postRequest);
|
|
622
|
-
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
623
|
-
|
|
624
|
-
vi.clearAllMocks();
|
|
625
|
-
|
|
626
|
-
// SSE request (should not be detected as WebSocket)
|
|
627
|
-
const sseRequest = new Request('https://example.com/events', {
|
|
628
|
-
headers: { Accept: 'text/event-stream' }
|
|
629
|
-
});
|
|
630
|
-
await sandbox.fetch(sseRequest);
|
|
631
|
-
expect(superFetchSpy).not.toHaveBeenCalled();
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
it('should preserve WebSocket request unchanged when calling super.fetch()', async () => {
|
|
635
|
-
const request = new Request('https://example.com/ws', {
|
|
636
|
-
headers: {
|
|
637
|
-
Upgrade: 'websocket',
|
|
638
|
-
Connection: 'Upgrade',
|
|
639
|
-
'Sec-WebSocket-Key': 'test-key-123',
|
|
640
|
-
'Sec-WebSocket-Version': '13'
|
|
641
|
-
}
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
await sandbox.fetch(request);
|
|
645
|
-
|
|
646
|
-
expect(superFetchSpy).toHaveBeenCalledTimes(1);
|
|
647
|
-
const passedRequest = superFetchSpy.mock.calls[0][0] as Request;
|
|
648
|
-
expect(passedRequest.headers.get('Upgrade')).toBe('websocket');
|
|
649
|
-
expect(passedRequest.headers.get('Connection')).toBe('Upgrade');
|
|
650
|
-
expect(passedRequest.headers.get('Sec-WebSocket-Key')).toBe(
|
|
651
|
-
'test-key-123'
|
|
652
|
-
);
|
|
653
|
-
expect(passedRequest.headers.get('Sec-WebSocket-Version')).toBe('13');
|
|
654
|
-
});
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
describe('wsConnect() method', () => {
|
|
658
|
-
it('should route WebSocket request through switchPort to sandbox.fetch', async () => {
|
|
659
|
-
const { switchPort } = await import('@cloudflare/containers');
|
|
660
|
-
const switchPortMock = vi.mocked(switchPort);
|
|
661
|
-
|
|
662
|
-
const request = new Request('http://localhost/ws/echo', {
|
|
663
|
-
headers: {
|
|
664
|
-
Upgrade: 'websocket',
|
|
665
|
-
Connection: 'Upgrade'
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
const fetchSpy = vi.spyOn(sandbox, 'fetch');
|
|
670
|
-
const response = await sandbox.wsConnect(request, 8080);
|
|
671
|
-
|
|
672
|
-
// Verify switchPort was called with correct port
|
|
673
|
-
expect(switchPortMock).toHaveBeenCalledWith(request, 8080);
|
|
674
|
-
|
|
675
|
-
// Verify fetch was called with the switched request
|
|
676
|
-
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
677
|
-
|
|
678
|
-
// Verify response indicates WebSocket upgrade
|
|
679
|
-
expect(response.status).toBe(200);
|
|
680
|
-
expect(response.headers.get('X-WebSocket-Upgraded')).toBe('true');
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
it('should reject invalid ports with SecurityError', async () => {
|
|
684
|
-
const request = new Request('http://localhost/ws/test', {
|
|
685
|
-
headers: { Upgrade: 'websocket', Connection: 'Upgrade' }
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
// Invalid port values
|
|
689
|
-
await expect(sandbox.wsConnect(request, -1)).rejects.toThrow(
|
|
690
|
-
'Invalid or restricted port'
|
|
691
|
-
);
|
|
692
|
-
await expect(sandbox.wsConnect(request, 0)).rejects.toThrow(
|
|
693
|
-
'Invalid or restricted port'
|
|
694
|
-
);
|
|
695
|
-
await expect(sandbox.wsConnect(request, 70000)).rejects.toThrow(
|
|
696
|
-
'Invalid or restricted port'
|
|
697
|
-
);
|
|
698
|
-
|
|
699
|
-
// Privileged ports
|
|
700
|
-
await expect(sandbox.wsConnect(request, 80)).rejects.toThrow(
|
|
701
|
-
'Invalid or restricted port'
|
|
702
|
-
);
|
|
703
|
-
await expect(sandbox.wsConnect(request, 443)).rejects.toThrow(
|
|
704
|
-
'Invalid or restricted port'
|
|
705
|
-
);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it('should preserve request properties through routing', async () => {
|
|
709
|
-
const request = new Request(
|
|
710
|
-
'http://localhost/ws/test?token=abc&room=lobby',
|
|
711
|
-
{
|
|
712
|
-
headers: {
|
|
713
|
-
Upgrade: 'websocket',
|
|
714
|
-
Connection: 'Upgrade',
|
|
715
|
-
'X-Custom-Header': 'custom-value'
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
const fetchSpy = vi.spyOn(sandbox, 'fetch');
|
|
721
|
-
await sandbox.wsConnect(request, 8080);
|
|
722
|
-
|
|
723
|
-
const calledRequest = fetchSpy.mock.calls[0][0];
|
|
724
|
-
|
|
725
|
-
// Verify headers are preserved
|
|
726
|
-
expect(calledRequest.headers.get('Upgrade')).toBe('websocket');
|
|
727
|
-
expect(calledRequest.headers.get('X-Custom-Header')).toBe('custom-value');
|
|
728
|
-
|
|
729
|
-
// Verify query parameters are preserved
|
|
730
|
-
const url = new URL(calledRequest.url);
|
|
731
|
-
expect(url.searchParams.get('token')).toBe('abc');
|
|
732
|
-
expect(url.searchParams.get('room')).toBe('lobby');
|
|
733
|
-
});
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
describe('deleteSession', () => {
|
|
737
|
-
it('should prevent deletion of default session', async () => {
|
|
738
|
-
// Trigger creation of default session
|
|
739
|
-
await sandbox.exec('echo "test"');
|
|
740
|
-
|
|
741
|
-
// Verify default session exists
|
|
742
|
-
expect((sandbox as any).defaultSession).toBeTruthy();
|
|
743
|
-
const defaultSessionId = (sandbox as any).defaultSession;
|
|
744
|
-
|
|
745
|
-
// Attempt to delete default session should throw
|
|
746
|
-
await expect(sandbox.deleteSession(defaultSessionId)).rejects.toThrow(
|
|
747
|
-
`Cannot delete default session '${defaultSessionId}'. Use sandbox.destroy() to terminate the sandbox.`
|
|
748
|
-
);
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
it('should allow deletion of non-default sessions', async () => {
|
|
752
|
-
// Mock the deleteSession API response
|
|
753
|
-
vi.spyOn(sandbox.client.utils, 'deleteSession').mockResolvedValue({
|
|
754
|
-
success: true,
|
|
755
|
-
sessionId: 'custom-session',
|
|
756
|
-
timestamp: new Date().toISOString()
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
// Create a custom session
|
|
760
|
-
await sandbox.createSession({ id: 'custom-session' });
|
|
761
|
-
|
|
762
|
-
// Should successfully delete non-default session
|
|
763
|
-
const result = await sandbox.deleteSession('custom-session');
|
|
764
|
-
expect(result.success).toBe(true);
|
|
765
|
-
expect(result.sessionId).toBe('custom-session');
|
|
766
|
-
});
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
describe('constructPreviewUrl validation', () => {
|
|
770
|
-
it('should throw clear error for ID with uppercase letters without normalizeId', async () => {
|
|
771
|
-
await sandbox.setSandboxName('MyProject-123', false);
|
|
772
|
-
|
|
773
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
774
|
-
success: true,
|
|
775
|
-
port: 8080,
|
|
776
|
-
url: '',
|
|
777
|
-
timestamp: '2023-01-01T00:00:00Z'
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
await expect(
|
|
781
|
-
sandbox.exposePort(8080, { hostname: 'example.com' })
|
|
782
|
-
).rejects.toThrow(/Preview URLs require lowercase sandbox IDs/);
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
it('should construct valid URL for lowercase ID', async () => {
|
|
786
|
-
await sandbox.setSandboxName('my-project', false);
|
|
787
|
-
|
|
788
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
789
|
-
success: true,
|
|
790
|
-
port: 8080,
|
|
791
|
-
url: '',
|
|
792
|
-
timestamp: '2023-01-01T00:00:00Z'
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
const result = await sandbox.exposePort(8080, {
|
|
796
|
-
hostname: 'example.com'
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
expect(result.url).toMatch(
|
|
800
|
-
/^https:\/\/8080-my-project-[a-z0-9_-]{16}\.example\.com\/?$/
|
|
801
|
-
);
|
|
802
|
-
expect(result.port).toBe(8080);
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
it('should construct valid URL with normalized ID', async () => {
|
|
806
|
-
await sandbox.setSandboxName('myproject-123', true);
|
|
807
|
-
|
|
808
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
809
|
-
success: true,
|
|
810
|
-
port: 4000,
|
|
811
|
-
url: '',
|
|
812
|
-
timestamp: '2023-01-01T00:00:00Z'
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
const result = await sandbox.exposePort(4000, { hostname: 'my-app.dev' });
|
|
816
|
-
|
|
817
|
-
expect(result.url).toMatch(
|
|
818
|
-
/^https:\/\/4000-myproject-123-[a-z0-9_-]{16}\.my-app\.dev\/?$/
|
|
819
|
-
);
|
|
820
|
-
expect(result.port).toBe(4000);
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
it('should construct valid localhost URL', async () => {
|
|
824
|
-
await sandbox.setSandboxName('test-sandbox', false);
|
|
825
|
-
|
|
826
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
827
|
-
success: true,
|
|
828
|
-
port: 8080,
|
|
829
|
-
url: '',
|
|
830
|
-
timestamp: '2023-01-01T00:00:00Z'
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
const result = await sandbox.exposePort(8080, {
|
|
834
|
-
hostname: 'localhost:3000'
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
expect(result.url).toMatch(
|
|
838
|
-
/^http:\/\/8080-test-sandbox-[a-z0-9_-]{16}\.localhost:3000\/?$/
|
|
839
|
-
);
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
it('should include helpful guidance in error message', async () => {
|
|
843
|
-
await sandbox.setSandboxName('MyProject-ABC', false);
|
|
844
|
-
|
|
845
|
-
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
|
|
846
|
-
success: true,
|
|
847
|
-
port: 8080,
|
|
848
|
-
url: '',
|
|
849
|
-
timestamp: '2023-01-01T00:00:00Z'
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
await expect(
|
|
853
|
-
sandbox.exposePort(8080, { hostname: 'example.com' })
|
|
854
|
-
).rejects.toThrow(
|
|
855
|
-
/getSandbox\(ns, "MyProject-ABC", \{ normalizeId: true \}\)/
|
|
856
|
-
);
|
|
857
|
-
});
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
describe('timeout configuration validation', () => {
|
|
861
|
-
it('should reject invalid timeout values', async () => {
|
|
862
|
-
// NaN, Infinity, and out-of-range values should all be rejected
|
|
863
|
-
await expect(
|
|
864
|
-
sandbox.setContainerTimeouts({ instanceGetTimeoutMS: NaN })
|
|
865
|
-
).rejects.toThrow();
|
|
866
|
-
|
|
867
|
-
await expect(
|
|
868
|
-
sandbox.setContainerTimeouts({ portReadyTimeoutMS: Infinity })
|
|
869
|
-
).rejects.toThrow();
|
|
870
|
-
|
|
871
|
-
await expect(
|
|
872
|
-
sandbox.setContainerTimeouts({ instanceGetTimeoutMS: -1 })
|
|
873
|
-
).rejects.toThrow();
|
|
874
|
-
|
|
875
|
-
await expect(
|
|
876
|
-
sandbox.setContainerTimeouts({ waitIntervalMS: 999_999 })
|
|
877
|
-
).rejects.toThrow();
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
it('should accept valid timeout values', async () => {
|
|
881
|
-
await expect(
|
|
882
|
-
sandbox.setContainerTimeouts({
|
|
883
|
-
instanceGetTimeoutMS: 30_000,
|
|
884
|
-
portReadyTimeoutMS: 90_000,
|
|
885
|
-
waitIntervalMS: 1000
|
|
886
|
-
})
|
|
887
|
-
).resolves.toBeUndefined();
|
|
888
|
-
});
|
|
889
|
-
});
|
|
890
|
-
});
|