@adobe/aio-lib-sandbox 0.1.0-alpha.3

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.
@@ -0,0 +1,748 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const EventEmitter = require('node:events')
13
+ const WebSocket = require('ws')
14
+ const Sandbox = require('../src/Sandbox')
15
+ const {
16
+ SandboxClientError,
17
+ SandboxInitializationError,
18
+ SandboxNotFoundError,
19
+ SandboxTimeoutError,
20
+ SandboxUnauthorizedError,
21
+ SandboxWebSocketError
22
+ } = require('../src/errors')
23
+
24
+ jest.mock('ws')
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Fake WebSocket
28
+ // ---------------------------------------------------------------------------
29
+
30
+ class FakeWebSocket extends EventEmitter {
31
+ constructor (url) {
32
+ super()
33
+ this.url = url
34
+ this.readyState = 0
35
+ this.sent = []
36
+ }
37
+
38
+ send (data) { this.sent.push(data) }
39
+
40
+ close () {
41
+ this.readyState = 3
42
+ this.emit('close', 1000, 'closed')
43
+ }
44
+
45
+ closeWith (code, reason = 'closed') {
46
+ this.readyState = 3
47
+ this.emit('close', code, reason)
48
+ }
49
+
50
+ open () {
51
+ this.readyState = WebSocket.OPEN
52
+ this.emit('open')
53
+ }
54
+
55
+ message (payload) {
56
+ const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
57
+ this.emit('message', Buffer.from(data))
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const BASE_OPTIONS = {
66
+ id: 'sb-test',
67
+ endpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-test/exec',
68
+ status: 'ready',
69
+ namespace: 'ns',
70
+ apiHost: 'https://runtime.example.net',
71
+ apiKey: 'uuid:key',
72
+ token: 'tok-abc',
73
+ maxLifetime: 3600,
74
+ cluster: 'cluster-a',
75
+ region: 'va6'
76
+ }
77
+
78
+ let sockets
79
+
80
+ function setupWebSocket () {
81
+ sockets = []
82
+ WebSocket.OPEN = 1
83
+ WebSocket.mockImplementation((url) => {
84
+ const socket = new FakeWebSocket(url)
85
+ sockets.push(socket)
86
+ return socket
87
+ })
88
+ }
89
+
90
+ async function buildConnectedSandbox (opts = {}) {
91
+ const sandbox = new Sandbox({ ...BASE_OPTIONS, ...opts })
92
+ const connectPromise = sandbox.connect()
93
+ sockets[0].open()
94
+ expect(JSON.parse(sockets[0].sent[0])).toEqual({ type: 'auth', token: BASE_OPTIONS.token })
95
+ sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
96
+ await connectPromise
97
+ return sandbox
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Tests
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('Sandbox', () => {
105
+ beforeEach(() => {
106
+ setupWebSocket()
107
+ jest.useRealTimers()
108
+ })
109
+
110
+ afterEach(() => {
111
+ jest.clearAllMocks()
112
+ jest.useRealTimers()
113
+ delete process.env.__OW_API_HOST
114
+ delete process.env.__OW_NAMESPACE
115
+ delete process.env.__OW_API_KEY
116
+ })
117
+
118
+ // -------------------------------------------------------------------------
119
+ // Static helpers
120
+ // -------------------------------------------------------------------------
121
+
122
+ describe('resolveCredentials', () => {
123
+ test('reads from env vars when no overrides provided', () => {
124
+ process.env.__OW_API_HOST = 'https://host.example.net'
125
+ process.env.__OW_NAMESPACE = 'my-ns'
126
+ process.env.__OW_API_KEY = 'k:secret'
127
+
128
+ const creds = Sandbox.resolveCredentials({})
129
+ expect(creds.apiHost).toBe('https://host.example.net')
130
+ expect(creds.namespace).toBe('my-ns')
131
+ expect(creds.apiKey).toBe('k:secret')
132
+ })
133
+
134
+ test('explicit options override env vars', () => {
135
+ process.env.__OW_API_HOST = 'https://env-host.example.net'
136
+ process.env.__OW_NAMESPACE = 'env-ns'
137
+ process.env.__OW_API_KEY = 'env-key'
138
+
139
+ const creds = Sandbox.resolveCredentials({
140
+ apiHost: 'https://explicit.example.net',
141
+ namespace: 'explicit-ns',
142
+ auth: 'explicit-key'
143
+ })
144
+ expect(creds.apiHost).toBe('https://explicit.example.net')
145
+ expect(creds.namespace).toBe('explicit-ns')
146
+ expect(creds.apiKey).toBe('explicit-key')
147
+ })
148
+
149
+ test('prepends https:// when scheme is missing', () => {
150
+ const creds = Sandbox.resolveCredentials({
151
+ apiHost: 'host.example.net',
152
+ namespace: 'ns',
153
+ auth: 'key'
154
+ })
155
+ expect(creds.apiHost).toBe('https://host.example.net')
156
+ })
157
+
158
+ test('throws SandboxInitializationError for missing credentials', () => {
159
+ expect(() => Sandbox.resolveCredentials({})).toThrow(SandboxInitializationError)
160
+ })
161
+ })
162
+
163
+ describe('normalizeSize', () => {
164
+ test('defaults to MEDIUM', () => {
165
+ expect(Sandbox.normalizeSize(undefined)).toBe('MEDIUM')
166
+ })
167
+
168
+ test('accepts valid size name', () => {
169
+ expect(Sandbox.normalizeSize('LARGE')).toBe('LARGE')
170
+ })
171
+
172
+ test('maps a spec object to a size name', () => {
173
+ expect(Sandbox.normalizeSize({ cpu: '500m', memory: '512Mi', gpu: 0 })).toBe('SMALL')
174
+ })
175
+
176
+ test('throws SandboxClientError for unknown size', () => {
177
+ expect(() => Sandbox.normalizeSize('HUGE')).toThrow(SandboxClientError)
178
+ })
179
+ })
180
+
181
+ describe('sizes', () => {
182
+ test('exposes SANDBOX_SIZES as a static getter', () => {
183
+ expect(Sandbox.sizes).toHaveProperty('SMALL')
184
+ expect(Sandbox.sizes).toHaveProperty('MEDIUM')
185
+ expect(Sandbox.sizes).toHaveProperty('LARGE')
186
+ expect(Sandbox.sizes).toHaveProperty('XLARGE')
187
+ })
188
+ })
189
+
190
+ // -------------------------------------------------------------------------
191
+ // Static factories
192
+ // -------------------------------------------------------------------------
193
+
194
+ describe('Sandbox.create()', () => {
195
+ test('creates a sandbox and returns a connected instance', async () => {
196
+ const mockFetch = jest.fn().mockResolvedValue({
197
+ ok: true,
198
+ json: () => Promise.resolve({
199
+ sandboxId: 'sb-new',
200
+ wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-new/exec',
201
+ status: 'ready',
202
+ token: 'tok-new',
203
+ maxLifetime: 3600
204
+ })
205
+ })
206
+ global.fetch = mockFetch
207
+
208
+ const createPromise = Sandbox.create({
209
+ name: 'my-sandbox',
210
+ apiHost: 'https://runtime.example.net',
211
+ namespace: 'ns',
212
+ auth: 'uuid:key'
213
+ })
214
+
215
+ // flush fetch + json() microtasks so the WebSocket is created before we open it
216
+ await new Promise(resolve => setImmediate(resolve))
217
+ sockets[0].open()
218
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-new' })
219
+
220
+ const sandbox = await createPromise
221
+
222
+ expect(sandbox.id).toBe('sb-new')
223
+ expect(sandbox.status).toBe('ready')
224
+ expect(mockFetch).toHaveBeenCalledWith(
225
+ 'https://runtime.example.net/api/v1/namespaces/ns/sandbox',
226
+ expect.objectContaining({ method: 'POST' })
227
+ )
228
+ })
229
+
230
+ test('forwards policy in the request body', async () => {
231
+ const mockFetch = jest.fn().mockResolvedValue({
232
+ ok: true,
233
+ json: () => Promise.resolve({
234
+ sandboxId: 'sb-pol',
235
+ wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-pol/exec',
236
+ status: 'ready',
237
+ token: 'tok-pol',
238
+ maxLifetime: 3600
239
+ })
240
+ })
241
+ global.fetch = mockFetch
242
+
243
+ const policy = { network: { egress: [{ host: 'api.github.com', port: 443 }] } }
244
+ const createPromise = Sandbox.create({
245
+ name: 'policy-sandbox',
246
+ apiHost: 'https://runtime.example.net',
247
+ namespace: 'ns',
248
+ auth: 'uuid:key',
249
+ policy
250
+ })
251
+
252
+ await new Promise(resolve => setImmediate(resolve))
253
+ sockets[0].open()
254
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-pol' })
255
+ await createPromise
256
+
257
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
258
+ expect(body.policy).toEqual(policy)
259
+ })
260
+
261
+ test('reads credentials from env vars', async () => {
262
+ process.env.__OW_API_HOST = 'https://runtime.example.net'
263
+ process.env.__OW_NAMESPACE = 'ns'
264
+ process.env.__OW_API_KEY = 'uuid:key'
265
+
266
+ const mockFetch = jest.fn().mockResolvedValue({
267
+ ok: true,
268
+ json: () => Promise.resolve({
269
+ sandboxId: 'sb-env',
270
+ wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-env/exec',
271
+ status: 'ready',
272
+ token: 'tok-env',
273
+ maxLifetime: 3600
274
+ })
275
+ })
276
+ global.fetch = mockFetch
277
+
278
+ const createPromise = Sandbox.create({ name: 'env-sandbox' })
279
+ await new Promise(resolve => setImmediate(resolve))
280
+ sockets[0].open()
281
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-env' })
282
+ const sandbox = await createPromise
283
+
284
+ expect(sandbox.id).toBe('sb-env')
285
+ })
286
+
287
+ test('throws SandboxInitializationError when credentials are missing', async () => {
288
+ await expect(Sandbox.create({ name: 'no-creds' })).rejects.toThrow(SandboxInitializationError)
289
+ })
290
+
291
+ test('falls back to buildWebSocketEndpoint when wsEndpoint absent', async () => {
292
+ const mockFetch = jest.fn().mockResolvedValue({
293
+ ok: true,
294
+ json: () => Promise.resolve({
295
+ sandboxId: 'sb-noep',
296
+ status: 'ready',
297
+ token: 'tok-noep',
298
+ maxLifetime: 3600
299
+ })
300
+ })
301
+ global.fetch = mockFetch
302
+
303
+ const createPromise = Sandbox.create({
304
+ name: 'no-endpoint',
305
+ apiHost: 'https://runtime.example.net',
306
+ namespace: 'ns',
307
+ auth: 'uuid:key'
308
+ })
309
+
310
+ await new Promise(resolve => setImmediate(resolve))
311
+ sockets[0].open()
312
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-noep' })
313
+ const sandbox = await createPromise
314
+
315
+ expect(sockets[0].url).toContain('wss://')
316
+ expect(sockets[0].url).toContain('sb-noep')
317
+ })
318
+ })
319
+
320
+ describe('Sandbox.get()', () => {
321
+ test('returns a sandbox with status from the API', async () => {
322
+ global.fetch = jest.fn().mockResolvedValue({
323
+ ok: true,
324
+ json: () => Promise.resolve({
325
+ sandboxId: 'sb-get',
326
+ status: 'running',
327
+ cluster: 'cluster-b',
328
+ region: 'va6'
329
+ })
330
+ })
331
+
332
+ const sandbox = await Sandbox.get('sb-get', {
333
+ apiHost: 'https://runtime.example.net',
334
+ namespace: 'ns',
335
+ auth: 'uuid:key'
336
+ })
337
+
338
+ expect(sandbox.id).toBe('sb-get')
339
+ expect(sandbox.status).toBe('running')
340
+ expect(sandbox.cluster).toBe('cluster-b')
341
+ })
342
+
343
+ test('throws SandboxNotFoundError on 404', async () => {
344
+ global.fetch = jest.fn().mockResolvedValue({
345
+ ok: false,
346
+ status: 404,
347
+ text: () => Promise.resolve('not found')
348
+ })
349
+
350
+ await expect(
351
+ Sandbox.get('missing', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
352
+ ).rejects.toThrow(SandboxNotFoundError)
353
+ })
354
+
355
+ test('throws SandboxUnauthorizedError on 401', async () => {
356
+ global.fetch = jest.fn().mockResolvedValue({
357
+ ok: false,
358
+ status: 401,
359
+ text: () => Promise.resolve('unauthorized')
360
+ })
361
+
362
+ await expect(
363
+ Sandbox.get('sb-x', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'bad' })
364
+ ).rejects.toThrow(SandboxUnauthorizedError)
365
+ })
366
+ })
367
+
368
+ // -------------------------------------------------------------------------
369
+ // Connection
370
+ // -------------------------------------------------------------------------
371
+
372
+ describe('connect()', () => {
373
+ test('opens WebSocket, sends auth frame, and resolves on auth.ok', async () => {
374
+ const sandbox = new Sandbox(BASE_OPTIONS)
375
+ const p = sandbox.connect()
376
+ sockets[0].open()
377
+ sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
378
+ await p
379
+ expect(JSON.parse(sockets[0].sent[0])).toEqual({ type: 'auth', token: BASE_OPTIONS.token })
380
+ })
381
+
382
+ test('reuses an existing open socket', async () => {
383
+ const sandbox = await buildConnectedSandbox()
384
+ await sandbox.connect()
385
+ expect(sockets).toHaveLength(1)
386
+ })
387
+
388
+ test('rejects on auth close code 4001 with SandboxUnauthorizedError', async () => {
389
+ const sandbox = new Sandbox(BASE_OPTIONS)
390
+ const p = sandbox.connect()
391
+ sockets[0].open()
392
+ sockets[0].closeWith(4001)
393
+ await expect(p).rejects.toThrow(SandboxUnauthorizedError)
394
+ })
395
+
396
+ test('rejects on unexpected socket close', async () => {
397
+ const sandbox = new Sandbox(BASE_OPTIONS)
398
+ const p = sandbox.connect()
399
+ sockets[0].open()
400
+ sockets[0].closeWith(1006)
401
+ await expect(p).rejects.toThrow(SandboxWebSocketError)
402
+ })
403
+ })
404
+
405
+ // -------------------------------------------------------------------------
406
+ // exec
407
+ // -------------------------------------------------------------------------
408
+
409
+ describe('exec()', () => {
410
+ test('sends exec.run and resolves with stdout/stderr/exitCode', async () => {
411
+ const sandbox = await buildConnectedSandbox()
412
+
413
+ const resultPromise = sandbox.exec('echo hello')
414
+ const frame = JSON.parse(sockets[0].sent[1])
415
+
416
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stdout', data: 'hello\n' })
417
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
418
+
419
+ const result = await resultPromise
420
+ expect(result.stdout).toBe('hello\n')
421
+ expect(result.exitCode).toBe(0)
422
+ })
423
+
424
+ test('accumulates stderr separately', async () => {
425
+ const sandbox = await buildConnectedSandbox()
426
+
427
+ const resultPromise = sandbox.exec('cmd')
428
+ const frame = JSON.parse(sockets[0].sent[1])
429
+
430
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stderr', data: 'err\n' })
431
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 1 })
432
+
433
+ const result = await resultPromise
434
+ expect(result.stderr).toBe('err\n')
435
+ expect(result.exitCode).toBe(1)
436
+ })
437
+
438
+ test('sends stdin and closeStdin when options.stdin is provided', async () => {
439
+ const sandbox = await buildConnectedSandbox()
440
+
441
+ const resultPromise = sandbox.exec('cat', { stdin: 'hello\n' })
442
+ const execFrame = JSON.parse(sockets[0].sent[1])
443
+ const stdinFrame = JSON.parse(sockets[0].sent[2])
444
+ const endFrame = JSON.parse(sockets[0].sent[3])
445
+
446
+ expect(stdinFrame.type).toBe('exec.input')
447
+ expect(stdinFrame.data).toBe('hello\n')
448
+ expect(endFrame.type).toBe('exec.endInput')
449
+
450
+ sockets[0].message({ type: 'exec.exit', execId: execFrame.execId, exitCode: 0 })
451
+ await resultPromise
452
+ })
453
+
454
+ test('calls onOutput callback for each output chunk', async () => {
455
+ const sandbox = await buildConnectedSandbox()
456
+ const chunks = []
457
+
458
+ const resultPromise = sandbox.exec('cmd', { onOutput: (data, stream) => chunks.push({ data, stream }) })
459
+ const frame = JSON.parse(sockets[0].sent[1])
460
+
461
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stdout', data: 'a' })
462
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stderr', data: 'b' })
463
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
464
+
465
+ await resultPromise
466
+ expect(chunks).toEqual([{ data: 'a', stream: 'stdout' }, { data: 'b', stream: 'stderr' }])
467
+ })
468
+
469
+ test('rejects with SandboxTimeoutError when timeout elapses', async () => {
470
+ jest.useFakeTimers()
471
+ const sandbox = await buildConnectedSandbox()
472
+
473
+ const resultPromise = sandbox.exec('sleep 100', { timeout: 1000 })
474
+ jest.advanceTimersByTime(1001)
475
+
476
+ await expect(resultPromise).rejects.toThrow(SandboxTimeoutError)
477
+ })
478
+
479
+ test('returns promise with execId property', async () => {
480
+ const sandbox = await buildConnectedSandbox()
481
+
482
+ const resultPromise = sandbox.exec('echo hi')
483
+ expect(typeof resultPromise.execId).toBe('string')
484
+ expect(resultPromise.execId).toMatch(/^exec-/)
485
+
486
+ const frame = JSON.parse(sockets[0].sent[1])
487
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
488
+ await resultPromise
489
+ })
490
+
491
+ test('rejects with SandboxClientError on exec error frame', async () => {
492
+ const sandbox = await buildConnectedSandbox()
493
+
494
+ const resultPromise = sandbox.exec('bad-cmd')
495
+ const frame = JSON.parse(sockets[0].sent[1])
496
+
497
+ sockets[0].message({ type: 'error', execId: frame.execId, message: 'command not found' })
498
+
499
+ await expect(resultPromise).rejects.toThrow(SandboxClientError)
500
+ })
501
+
502
+ test('rejects when socket is not open', async () => {
503
+ const sandbox = new Sandbox(BASE_OPTIONS)
504
+ await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
505
+ })
506
+ })
507
+
508
+ // -------------------------------------------------------------------------
509
+ // kill / writeStdin / closeStdin
510
+ // -------------------------------------------------------------------------
511
+
512
+ describe('kill()', () => {
513
+ test('sends exec.kill frame', async () => {
514
+ const sandbox = await buildConnectedSandbox()
515
+ sandbox.kill('exec-abc', 'SIGKILL')
516
+
517
+ const frame = JSON.parse(sockets[0].sent[1])
518
+ expect(frame.type).toBe('exec.kill')
519
+ expect(frame.execId).toBe('exec-abc')
520
+ expect(frame.signal).toBe('SIGKILL')
521
+ })
522
+ })
523
+
524
+ describe('writeStdin()', () => {
525
+ test('sends exec.input frame with string data', async () => {
526
+ const sandbox = await buildConnectedSandbox()
527
+ sandbox.writeStdin('exec-abc', 'hello\n')
528
+
529
+ const frame = JSON.parse(sockets[0].sent[1])
530
+ expect(frame.type).toBe('exec.input')
531
+ expect(frame.data).toBe('hello\n')
532
+ expect(frame.encoding).toBeUndefined()
533
+ })
534
+
535
+ test('base64-encodes Buffer data', async () => {
536
+ const sandbox = await buildConnectedSandbox()
537
+ sandbox.writeStdin('exec-abc', Buffer.from('binary'))
538
+
539
+ const frame = JSON.parse(sockets[0].sent[1])
540
+ expect(frame.encoding).toBe('base64')
541
+ expect(Buffer.from(frame.data, 'base64').toString()).toBe('binary')
542
+ })
543
+ })
544
+
545
+ describe('closeStdin()', () => {
546
+ test('sends exec.endInput frame', async () => {
547
+ const sandbox = await buildConnectedSandbox()
548
+ sandbox.closeStdin('exec-abc')
549
+
550
+ const frame = JSON.parse(sockets[0].sent[1])
551
+ expect(frame.type).toBe('exec.endInput')
552
+ expect(frame.execId).toBe('exec-abc')
553
+ })
554
+ })
555
+
556
+ // -------------------------------------------------------------------------
557
+ // File operations
558
+ // -------------------------------------------------------------------------
559
+
560
+ describe('readFile()', () => {
561
+ test('sends file.read and resolves with content', async () => {
562
+ const sandbox = await buildConnectedSandbox()
563
+
564
+ const filePromise = sandbox.readFile('/app/hello.js')
565
+ const frame = JSON.parse(sockets[0].sent[1])
566
+ expect(frame.type).toBe('file.read')
567
+ expect(frame.path).toBe('/app/hello.js')
568
+
569
+ const encoded = Buffer.from('console.log("hi")').toString('base64')
570
+ sockets[0].message({ type: 'file.content', execId: frame.execId, content: encoded, encoding: 'base64' })
571
+
572
+ const content = await filePromise
573
+ expect(content).toBe('console.log("hi")')
574
+ })
575
+
576
+ test('resolves with raw string when no encoding', async () => {
577
+ const sandbox = await buildConnectedSandbox()
578
+
579
+ const filePromise = sandbox.readFile('/text.txt')
580
+ const frame = JSON.parse(sockets[0].sent[1])
581
+ sockets[0].message({ type: 'file.content', execId: frame.execId, content: 'plain text' })
582
+
583
+ expect(await filePromise).toBe('plain text')
584
+ })
585
+
586
+ test('rejects on error frame', async () => {
587
+ const sandbox = await buildConnectedSandbox()
588
+
589
+ const filePromise = sandbox.readFile('/missing')
590
+ const frame = JSON.parse(sockets[0].sent[1])
591
+ sockets[0].message({ type: 'error', execId: frame.execId, message: 'no such file' })
592
+
593
+ await expect(filePromise).rejects.toThrow(SandboxClientError)
594
+ })
595
+ })
596
+
597
+ describe('writeFile()', () => {
598
+ test('sends file.write with base64 content and resolves with write result', async () => {
599
+ const sandbox = await buildConnectedSandbox()
600
+
601
+ const writePromise = sandbox.writeFile('/app/script.js', 'const x = 1')
602
+ const frame = JSON.parse(sockets[0].sent[1])
603
+ expect(frame.type).toBe('file.write')
604
+ expect(frame.encoding).toBe('base64')
605
+
606
+ sockets[0].message({ type: 'file.writeResult', execId: frame.execId, path: frame.path, size: 11, ok: true })
607
+
608
+ const result = await writePromise
609
+ expect(result.ok).toBe(true)
610
+ expect(result.size).toBe(11)
611
+ })
612
+
613
+ test('rejects on failed write result', async () => {
614
+ const sandbox = await buildConnectedSandbox()
615
+
616
+ const writePromise = sandbox.writeFile('/readonly', 'data')
617
+ const frame = JSON.parse(sockets[0].sent[1])
618
+ sockets[0].message({ type: 'file.writeResult', execId: frame.execId, path: frame.path, ok: false })
619
+
620
+ await expect(writePromise).rejects.toThrow(SandboxClientError)
621
+ })
622
+ })
623
+
624
+ describe('listFiles()', () => {
625
+ test('sends file.list and resolves with entries', async () => {
626
+ const sandbox = await buildConnectedSandbox()
627
+
628
+ const listPromise = sandbox.listFiles('.')
629
+ const frame = JSON.parse(sockets[0].sent[1])
630
+ expect(frame.type).toBe('file.list')
631
+
632
+ const entries = [
633
+ { name: 'hello.js', type: 'file', size: 42 },
634
+ { name: 'src', type: 'directory' }
635
+ ]
636
+ sockets[0].message({ type: 'file.entries', execId: frame.execId, entries })
637
+
638
+ expect(await listPromise).toEqual(entries)
639
+ })
640
+
641
+ test('resolves with empty array when entries is absent', async () => {
642
+ const sandbox = await buildConnectedSandbox()
643
+
644
+ const listPromise = sandbox.listFiles('.')
645
+ const frame = JSON.parse(sockets[0].sent[1])
646
+ sockets[0].message({ type: 'file.entries', execId: frame.execId })
647
+
648
+ expect(await listPromise).toEqual([])
649
+ })
650
+ })
651
+
652
+ // -------------------------------------------------------------------------
653
+ // getUrl
654
+ // -------------------------------------------------------------------------
655
+
656
+ describe('getUrl()', () => {
657
+ test('resolves preview URL from template', async () => {
658
+ const sandbox = new Sandbox({
659
+ ...BASE_OPTIONS,
660
+ publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
661
+ })
662
+
663
+ const url = await sandbox.getUrl({ port: 3000 })
664
+ expect(url).toBe('https://sb-test-3000.preview.example.net')
665
+ })
666
+
667
+ test('replaces scheme when protocol option provided', async () => {
668
+ const sandbox = new Sandbox({
669
+ ...BASE_OPTIONS,
670
+ publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
671
+ })
672
+
673
+ const url = await sandbox.getUrl({ port: 3000, protocol: 'wss' })
674
+ expect(url).toBe('wss://sb-test-3000.preview.example.net')
675
+ })
676
+
677
+ test('throws SandboxClientError when publicUrlTemplate is absent', async () => {
678
+ const sandbox = new Sandbox(BASE_OPTIONS)
679
+ await expect(sandbox.getUrl({ port: 3000 })).rejects.toThrow(SandboxClientError)
680
+ })
681
+
682
+ test('throws SandboxClientError for invalid port', async () => {
683
+ const sandbox = new Sandbox({ ...BASE_OPTIONS, publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net' })
684
+ await expect(sandbox.getUrl({ port: 0 })).rejects.toThrow(SandboxClientError)
685
+ await expect(sandbox.getUrl({ port: 70000 })).rejects.toThrow(SandboxClientError)
686
+ await expect(sandbox.getUrl({ port: 'abc' })).rejects.toThrow(SandboxClientError)
687
+ })
688
+ })
689
+
690
+ // -------------------------------------------------------------------------
691
+ // destroy
692
+ // -------------------------------------------------------------------------
693
+
694
+ describe('destroy()', () => {
695
+ test('calls DELETE and closes the socket', async () => {
696
+ const mockFetch = jest.fn().mockResolvedValue({
697
+ ok: true,
698
+ json: () => Promise.resolve({ status: 'destroyed' })
699
+ })
700
+ global.fetch = mockFetch
701
+
702
+ const sandbox = await buildConnectedSandbox()
703
+ const result = await sandbox.destroy()
704
+
705
+ expect(result.status).toBe('destroyed')
706
+ expect(sandbox.status).toBe('destroyed')
707
+ expect(mockFetch).toHaveBeenCalledWith(
708
+ expect.stringContaining('/sandbox/sb-test'),
709
+ expect.objectContaining({ method: 'DELETE' })
710
+ )
711
+ })
712
+
713
+ test('throws SandboxUnauthorizedError on 403', async () => {
714
+ global.fetch = jest.fn().mockResolvedValue({
715
+ ok: false,
716
+ status: 403,
717
+ text: () => Promise.resolve('forbidden')
718
+ })
719
+
720
+ const sandbox = await buildConnectedSandbox()
721
+ await expect(sandbox.destroy()).rejects.toThrow(SandboxUnauthorizedError)
722
+ })
723
+ })
724
+
725
+ // -------------------------------------------------------------------------
726
+ // Socket close drains pending operations
727
+ // -------------------------------------------------------------------------
728
+
729
+ describe('WebSocket close', () => {
730
+ test('rejects all pending execs when socket closes unexpectedly', async () => {
731
+ const sandbox = await buildConnectedSandbox()
732
+
733
+ const resultPromise = sandbox.exec('sleep 60')
734
+ sockets[0].closeWith(1006)
735
+
736
+ await expect(resultPromise).rejects.toThrow(SandboxWebSocketError)
737
+ })
738
+
739
+ test('rejects all pending file ops when socket closes', async () => {
740
+ const sandbox = await buildConnectedSandbox()
741
+
742
+ const filePromise = sandbox.readFile('/heavy-file')
743
+ sockets[0].closeWith(1006)
744
+
745
+ await expect(filePromise).rejects.toThrow(SandboxWebSocketError)
746
+ })
747
+ })
748
+ })