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

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,1464 @@
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
+ SandboxCommandNotFoundError,
18
+ SandboxInitializationError,
19
+ SandboxNotFoundError,
20
+ ProtocolVersionMismatchError,
21
+ SandboxPortNotProvisionedError,
22
+ SandboxInvalidPortError,
23
+ SandboxTimeoutError,
24
+ SandboxUnauthorizedError,
25
+ SandboxWebSocketError,
26
+ SandboxMalformedFrameError
27
+ } = require('../src/errors')
28
+
29
+ jest.mock('ws')
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Fake WebSocket
33
+ // ---------------------------------------------------------------------------
34
+
35
+ class FakeWebSocket extends EventEmitter {
36
+ constructor (url) {
37
+ super()
38
+ this.url = url
39
+ this.readyState = 0
40
+ this.sent = []
41
+ }
42
+
43
+ send (data) { this.sent.push(data) }
44
+
45
+ close () {
46
+ this.readyState = 3
47
+ this.emit('close', 1000, 'closed')
48
+ }
49
+
50
+ closeWith (code, reason = 'closed') {
51
+ this.readyState = 3
52
+ this.emit('close', code, reason)
53
+ }
54
+
55
+ open () {
56
+ this.readyState = WebSocket.OPEN
57
+ this.emit('open')
58
+ }
59
+
60
+ message (payload) {
61
+ const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
62
+ this.emit('message', Buffer.from(data))
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const BASE_OPTIONS = {
71
+ id: 'sb-test',
72
+ endpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-test/exec',
73
+ status: 'ready',
74
+ namespace: 'ns',
75
+ apiHost: 'https://runtime.example.net',
76
+ apiKey: 'uuid:key',
77
+ token: 'tok-abc',
78
+ maxLifetime: 3600,
79
+ cluster: 'cluster-a',
80
+ region: 'va6'
81
+ }
82
+
83
+ let sockets
84
+
85
+ /**
86
+ *
87
+ */
88
+ function setupWebSocket () {
89
+ sockets = []
90
+ WebSocket.OPEN = 1
91
+ WebSocket.mockImplementation((url) => {
92
+ const socket = new FakeWebSocket(url)
93
+ sockets.push(socket)
94
+ return socket
95
+ })
96
+ }
97
+
98
+ /**
99
+ * Builds a connected sandbox backed by the fake WebSocket.
100
+ *
101
+ * @param {object} opts sandbox option overrides
102
+ * @returns {Promise<Sandbox>} connected sandbox instance
103
+ */
104
+ async function buildConnectedSandbox (opts = {}) {
105
+ const sandbox = new Sandbox({ ...BASE_OPTIONS, ...opts })
106
+ const connectPromise = sandbox.connect()
107
+ sockets[0].open()
108
+ expect(JSON.parse(sockets[0].sent[0])).toEqual({ type: 'auth', token: BASE_OPTIONS.token })
109
+ sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
110
+ await connectPromise
111
+ return sandbox
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tests
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('Sandbox', () => {
119
+ beforeEach(() => {
120
+ setupWebSocket()
121
+ jest.useRealTimers()
122
+ })
123
+
124
+ afterEach(() => {
125
+ jest.clearAllMocks()
126
+ jest.useRealTimers()
127
+ delete process.env.__OW_API_HOST
128
+ delete process.env.__OW_NAMESPACE
129
+ delete process.env.__OW_API_KEY
130
+ })
131
+
132
+ // -------------------------------------------------------------------------
133
+ // Static helpers
134
+ // -------------------------------------------------------------------------
135
+
136
+ describe('resolveCredentials', () => {
137
+ test('reads from env vars when no overrides provided', () => {
138
+ process.env.__OW_API_HOST = 'https://host.example.net'
139
+ process.env.__OW_NAMESPACE = 'my-ns'
140
+ process.env.__OW_API_KEY = 'k:secret'
141
+
142
+ const creds = Sandbox.resolveCredentials({})
143
+ expect(creds.apiHost).toBe('https://host.example.net')
144
+ expect(creds.namespace).toBe('my-ns')
145
+ expect(creds.apiKey).toBe('k:secret')
146
+ })
147
+
148
+ test('explicit options override env vars', () => {
149
+ process.env.__OW_API_HOST = 'https://env-host.example.net'
150
+ process.env.__OW_NAMESPACE = 'env-ns'
151
+ process.env.__OW_API_KEY = 'env-key'
152
+
153
+ const creds = Sandbox.resolveCredentials({
154
+ apiHost: 'https://explicit.example.net',
155
+ namespace: 'explicit-ns',
156
+ auth: 'explicit-key'
157
+ })
158
+ expect(creds.apiHost).toBe('https://explicit.example.net')
159
+ expect(creds.namespace).toBe('explicit-ns')
160
+ expect(creds.apiKey).toBe('explicit-key')
161
+ })
162
+
163
+ test('prepends https:// when scheme is missing', () => {
164
+ const creds = Sandbox.resolveCredentials({
165
+ apiHost: 'host.example.net',
166
+ namespace: 'ns',
167
+ auth: 'key'
168
+ })
169
+ expect(creds.apiHost).toBe('https://host.example.net')
170
+ })
171
+
172
+ test('throws SandboxInitializationError for missing credentials', () => {
173
+ expect(() => Sandbox.resolveCredentials({})).toThrow(SandboxInitializationError)
174
+ })
175
+ })
176
+
177
+ describe('normalizeSize', () => {
178
+ test('defaults to MEDIUM', () => {
179
+ expect(Sandbox.normalizeSize(undefined)).toBe('MEDIUM')
180
+ })
181
+
182
+ test('accepts valid size name', () => {
183
+ expect(Sandbox.normalizeSize('LARGE')).toBe('LARGE')
184
+ })
185
+
186
+ test('maps a spec object to a size name', () => {
187
+ expect(Sandbox.normalizeSize({ cpu: '500m', memory: '512Mi', gpu: 0 })).toBe('SMALL')
188
+ })
189
+
190
+ test('throws SandboxClientError for unknown size', () => {
191
+ expect(() => Sandbox.normalizeSize('HUGE')).toThrow(SandboxClientError)
192
+ })
193
+ })
194
+
195
+ describe('sizes', () => {
196
+ test('exposes SANDBOX_SIZES as a static getter', () => {
197
+ expect(Sandbox.sizes).toHaveProperty('SMALL')
198
+ expect(Sandbox.sizes).toHaveProperty('MEDIUM')
199
+ expect(Sandbox.sizes).toHaveProperty('LARGE')
200
+ expect(Sandbox.sizes).toHaveProperty('XLARGE')
201
+ })
202
+ })
203
+
204
+ describe('protocolVersion', () => {
205
+ test('exposes the bundled sandbox protocol major', () => {
206
+ expect(Sandbox.protocolVersion).toBe('1')
207
+ })
208
+ })
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Static factories
212
+ // -------------------------------------------------------------------------
213
+
214
+ describe('Sandbox.create()', () => {
215
+ test('creates a sandbox and returns a connected instance', async () => {
216
+ const mockFetch = jest.fn().mockResolvedValue({
217
+ ok: true,
218
+ json: () => Promise.resolve({
219
+ sandboxId: 'sb-new',
220
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-new/exec',
221
+ status: 'ready',
222
+ token: 'tok-new',
223
+ maxLifetime: 3600,
224
+ protocolVersion: '1',
225
+ previewUrls: {
226
+ 3000: 'https://sb-new-3000.preview.example.net'
227
+ }
228
+ })
229
+ })
230
+ global.fetch = mockFetch
231
+
232
+ const createPromise = Sandbox.create({
233
+ name: 'my-sandbox',
234
+ apiHost: 'https://runtime.example.net',
235
+ namespace: 'ns',
236
+ auth: 'uuid:key'
237
+ })
238
+
239
+ // flush fetch + json() microtasks so the WebSocket is created before we open it
240
+ await new Promise(resolve => setImmediate(resolve))
241
+ sockets[0].open()
242
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-new' })
243
+
244
+ const sandbox = await createPromise
245
+
246
+ expect(sandbox.id).toBe('sb-new')
247
+ expect(sandbox.status).toBe('ready')
248
+ expect(sandbox.protocolVersion).toBe('1')
249
+ expect(sandbox.previewUrls).toEqual(new Map([
250
+ [3000, 'https://sb-new-3000.preview.example.net']
251
+ ]))
252
+ expect(mockFetch).toHaveBeenCalledWith(
253
+ 'https://runtime.example.net/api/v1/namespaces/ns/sandboxes',
254
+ expect.objectContaining({ method: 'POST' })
255
+ )
256
+ })
257
+
258
+ test('forwards policy in the request body', async () => {
259
+ const mockFetch = jest.fn().mockResolvedValue({
260
+ ok: true,
261
+ json: () => Promise.resolve({
262
+ sandboxId: 'sb-pol',
263
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-pol/exec',
264
+ status: 'ready',
265
+ token: 'tok-pol',
266
+ maxLifetime: 3600
267
+ })
268
+ })
269
+ global.fetch = mockFetch
270
+
271
+ const policy = { network: { egress: [{ host: 'api.github.com', port: 443 }] } }
272
+ const createPromise = Sandbox.create({
273
+ name: 'policy-sandbox',
274
+ apiHost: 'https://runtime.example.net',
275
+ namespace: 'ns',
276
+ auth: 'uuid:key',
277
+ policy
278
+ })
279
+
280
+ await new Promise(resolve => setImmediate(resolve))
281
+ sockets[0].open()
282
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-pol' })
283
+ await createPromise
284
+
285
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
286
+ expect(body.policy).toEqual(policy)
287
+ })
288
+
289
+ test('forwards ports and populates previewUrls from the response', async () => {
290
+ const mockFetch = jest.fn().mockResolvedValue({
291
+ ok: true,
292
+ json: () => Promise.resolve({
293
+ sandboxId: 'sb-ports',
294
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-ports/exec',
295
+ status: 'ready',
296
+ token: 'tok-ports',
297
+ maxLifetime: 3600,
298
+ previewUrls: {
299
+ 3000: 'https://sb-ports-3000.preview.example.net',
300
+ 8080: 'https://sb-ports-8080.preview.example.net'
301
+ }
302
+ })
303
+ })
304
+ global.fetch = mockFetch
305
+
306
+ const createPromise = Sandbox.create({
307
+ name: 'ports-sandbox',
308
+ apiHost: 'https://runtime.example.net',
309
+ namespace: 'ns',
310
+ auth: 'uuid:key',
311
+ ports: [3000, 8080]
312
+ })
313
+
314
+ await new Promise(resolve => setImmediate(resolve))
315
+ sockets[0].open()
316
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-ports' })
317
+ const sandbox = await createPromise
318
+
319
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
320
+ expect(body.ports).toEqual([3000, 8080])
321
+ expect(sandbox.getUrl(3000)).toBe('https://sb-ports-3000.preview.example.net')
322
+ expect(sandbox.getUrl(8080)).toBe('https://sb-ports-8080.preview.example.net')
323
+ })
324
+
325
+ test('reads credentials from env vars', async () => {
326
+ process.env.__OW_API_HOST = 'https://runtime.example.net'
327
+ process.env.__OW_NAMESPACE = 'ns'
328
+ process.env.__OW_API_KEY = 'uuid:key'
329
+
330
+ const mockFetch = jest.fn().mockResolvedValue({
331
+ ok: true,
332
+ json: () => Promise.resolve({
333
+ sandboxId: 'sb-env',
334
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-env/exec',
335
+ status: 'ready',
336
+ token: 'tok-env',
337
+ maxLifetime: 3600
338
+ })
339
+ })
340
+ global.fetch = mockFetch
341
+
342
+ const createPromise = Sandbox.create({ name: 'env-sandbox' })
343
+ await new Promise(resolve => setImmediate(resolve))
344
+ sockets[0].open()
345
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-env' })
346
+ const sandbox = await createPromise
347
+
348
+ expect(sandbox.id).toBe('sb-env')
349
+ })
350
+
351
+ test('throws SandboxInitializationError when credentials are missing', async () => {
352
+ await expect(Sandbox.create({ name: 'no-creds' })).rejects.toThrow(SandboxInitializationError)
353
+ })
354
+
355
+ test('sends default idleTimeout 900 and maxLifetime 3600 when not specified', async () => {
356
+ const mockFetch = jest.fn().mockResolvedValue({
357
+ ok: true,
358
+ json: () => Promise.resolve({
359
+ sandboxId: 'sb-defaults',
360
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-defaults/exec',
361
+ status: 'ready',
362
+ token: 'tok-defaults',
363
+ idleTimeout: 900,
364
+ maxLifetime: 3600
365
+ })
366
+ })
367
+ global.fetch = mockFetch
368
+
369
+ const createPromise = Sandbox.create({
370
+ name: 'defaults-sandbox',
371
+ apiHost: 'https://runtime.example.net',
372
+ namespace: 'ns',
373
+ auth: 'uuid:key'
374
+ })
375
+
376
+ await new Promise(resolve => setImmediate(resolve))
377
+ sockets[0].open()
378
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-defaults' })
379
+ await createPromise
380
+
381
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
382
+ expect(body.idleTimeout).toBe(900)
383
+ expect(body.maxLifetime).toBe(3600)
384
+ })
385
+
386
+ test('forwards explicit idleTimeout in the request body', async () => {
387
+ const mockFetch = jest.fn().mockResolvedValue({
388
+ ok: true,
389
+ json: () => Promise.resolve({
390
+ sandboxId: 'sb-idle',
391
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-idle/exec',
392
+ status: 'ready',
393
+ token: 'tok-idle',
394
+ idleTimeout: 1800,
395
+ maxLifetime: 3600
396
+ })
397
+ })
398
+ global.fetch = mockFetch
399
+
400
+ const createPromise = Sandbox.create({
401
+ name: 'idle-sandbox',
402
+ apiHost: 'https://runtime.example.net',
403
+ namespace: 'ns',
404
+ auth: 'uuid:key',
405
+ idleTimeout: 1800
406
+ })
407
+
408
+ await new Promise(resolve => setImmediate(resolve))
409
+ sockets[0].open()
410
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-idle' })
411
+ await createPromise
412
+
413
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
414
+ expect(body.idleTimeout).toBe(1800)
415
+ })
416
+
417
+ test('stores idleTimeout from the create response on the instance', async () => {
418
+ const mockFetch = jest.fn().mockResolvedValue({
419
+ ok: true,
420
+ json: () => Promise.resolve({
421
+ sandboxId: 'sb-store',
422
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-store/exec',
423
+ status: 'ready',
424
+ token: 'tok-store',
425
+ idleTimeout: 1800,
426
+ maxLifetime: 3600
427
+ })
428
+ })
429
+ global.fetch = mockFetch
430
+
431
+ const createPromise = Sandbox.create({
432
+ name: 'store-sandbox',
433
+ apiHost: 'https://runtime.example.net',
434
+ namespace: 'ns',
435
+ auth: 'uuid:key',
436
+ idleTimeout: 1800
437
+ })
438
+
439
+ await new Promise(resolve => setImmediate(resolve))
440
+ sockets[0].open()
441
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-store' })
442
+ const sandbox = await createPromise
443
+
444
+ expect(sandbox.idleTimeout).toBe(1800)
445
+ })
446
+
447
+ test('falls back to buildWebSocketEndpoint when wsEndpoint absent', async () => {
448
+ const mockFetch = jest.fn().mockResolvedValue({
449
+ ok: true,
450
+ json: () => Promise.resolve({
451
+ sandboxId: 'sb-noep',
452
+ status: 'ready',
453
+ token: 'tok-noep',
454
+ maxLifetime: 3600
455
+ })
456
+ })
457
+ global.fetch = mockFetch
458
+
459
+ const createPromise = Sandbox.create({
460
+ name: 'no-endpoint',
461
+ apiHost: 'https://runtime.example.net',
462
+ namespace: 'ns',
463
+ auth: 'uuid:key'
464
+ })
465
+
466
+ await new Promise(resolve => setImmediate(resolve))
467
+ sockets[0].open()
468
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-noep' })
469
+ await createPromise
470
+
471
+ expect(sockets[0].url).toContain('wss://')
472
+ expect(sockets[0].url).toContain('sb-noep')
473
+ })
474
+ })
475
+
476
+ describe('Sandbox.get()', () => {
477
+ test('returns a sandbox with status from the API', async () => {
478
+ global.fetch = jest.fn().mockResolvedValue({
479
+ ok: true,
480
+ json: () => Promise.resolve({
481
+ sandboxId: 'sb-get',
482
+ status: 'running',
483
+ cluster: 'cluster-b',
484
+ region: 'va6',
485
+ protocolVersion: '1'
486
+ })
487
+ })
488
+
489
+ const sandbox = await Sandbox.get('sb-get', {
490
+ apiHost: 'https://runtime.example.net',
491
+ namespace: 'ns',
492
+ auth: 'uuid:key'
493
+ })
494
+
495
+ expect(sandbox.id).toBe('sb-get')
496
+ expect(sandbox.status).toBe('running')
497
+ expect(sandbox.cluster).toBe('cluster-b')
498
+ expect(sandbox.protocolVersion).toBe('1')
499
+ })
500
+
501
+ test('stores idleTimeout from the get response on the instance', async () => {
502
+ global.fetch = jest.fn().mockResolvedValue({
503
+ ok: true,
504
+ json: () => Promise.resolve({
505
+ sandboxId: 'sb-get-idle',
506
+ status: 'running',
507
+ idleTimeout: 1200,
508
+ maxLifetime: 3600
509
+ })
510
+ })
511
+
512
+ const sandbox = await Sandbox.get('sb-get-idle', {
513
+ apiHost: 'https://runtime.example.net',
514
+ namespace: 'ns',
515
+ auth: 'uuid:key'
516
+ })
517
+
518
+ expect(sandbox.idleTimeout).toBe(1200)
519
+ })
520
+
521
+ test('throws SandboxNotFoundError on 404', async () => {
522
+ global.fetch = jest.fn().mockResolvedValue({
523
+ ok: false,
524
+ status: 404,
525
+ text: () => Promise.resolve('not found')
526
+ })
527
+
528
+ await expect(
529
+ Sandbox.get('missing', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
530
+ ).rejects.toThrow(SandboxNotFoundError)
531
+ })
532
+
533
+ test('throws SandboxUnauthorizedError on 401', async () => {
534
+ global.fetch = jest.fn().mockResolvedValue({
535
+ ok: false,
536
+ status: 401,
537
+ text: () => Promise.resolve('unauthorized')
538
+ })
539
+
540
+ await expect(
541
+ Sandbox.get('sb-x', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'bad' })
542
+ ).rejects.toThrow(SandboxUnauthorizedError)
543
+ })
544
+
545
+ test('throws SandboxTimeoutError on 504', async () => {
546
+ global.fetch = jest.fn().mockResolvedValue({
547
+ ok: false,
548
+ status: 504,
549
+ text: () => Promise.resolve('gateway timeout')
550
+ })
551
+
552
+ await expect(
553
+ Sandbox.get('sb-slow', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
554
+ ).rejects.toThrow(SandboxTimeoutError)
555
+ })
556
+
557
+ test('throws SandboxClientError on unexpected API status', async () => {
558
+ global.fetch = jest.fn().mockResolvedValue({
559
+ ok: false,
560
+ status: 500,
561
+ text: () => Promise.resolve('server error')
562
+ })
563
+
564
+ await expect(
565
+ Sandbox.get('sb-error', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
566
+ ).rejects.toThrow(SandboxClientError)
567
+ })
568
+
569
+ test('wraps fetch failures in SandboxClientError', async () => {
570
+ global.fetch = jest.fn().mockRejectedValue(new Error('network unavailable'))
571
+
572
+ await expect(
573
+ Sandbox.get('sb-network', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
574
+ ).rejects.toThrow(SandboxClientError)
575
+ })
576
+ })
577
+
578
+ // -------------------------------------------------------------------------
579
+ // Connection
580
+ // -------------------------------------------------------------------------
581
+
582
+ describe('connect()', () => {
583
+ test('opens WebSocket, sends auth frame, and resolves on auth.ok', async () => {
584
+ const sandbox = new Sandbox(BASE_OPTIONS)
585
+ const p = sandbox.connect()
586
+ sockets[0].open()
587
+ sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
588
+ await p
589
+ expect(JSON.parse(sockets[0].sent[0])).toEqual({ type: 'auth', token: BASE_OPTIONS.token })
590
+ })
591
+
592
+ test('reuses an existing open socket', async () => {
593
+ const sandbox = await buildConnectedSandbox()
594
+ await sandbox.connect()
595
+ expect(sockets).toHaveLength(1)
596
+ })
597
+
598
+ test('returns the same in-flight promise when called again before auth completes', async () => {
599
+ const sandbox = new Sandbox(BASE_OPTIONS)
600
+ const p1 = sandbox.connect()
601
+ const p2 = sandbox.connect()
602
+ expect(p1).toBe(p2)
603
+ sockets[0].open()
604
+ sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
605
+ await Promise.all([p1, p2])
606
+ expect(sockets).toHaveLength(1)
607
+ })
608
+
609
+ test('rejects on auth close code 4001 with SandboxUnauthorizedError', async () => {
610
+ const sandbox = new Sandbox(BASE_OPTIONS)
611
+ const p = sandbox.connect()
612
+ sockets[0].open()
613
+ sockets[0].closeWith(4001)
614
+ await expect(p).rejects.toThrow(SandboxUnauthorizedError)
615
+ })
616
+
617
+ test('rejects on protocol mismatch close code 4003 with ProtocolVersionMismatchError', async () => {
618
+ const sandbox = new Sandbox(BASE_OPTIONS)
619
+ const p = sandbox.connect()
620
+ sockets[0].open()
621
+ sockets[0].closeWith(4003)
622
+ await expect(p).rejects.toThrow(ProtocolVersionMismatchError)
623
+ })
624
+
625
+ test('rejects on malformed frame close code 4004 with SandboxMalformedFrameError', async () => {
626
+ const sandbox = new Sandbox(BASE_OPTIONS)
627
+ const p = sandbox.connect()
628
+ sockets[0].open()
629
+ sockets[0].closeWith(4004)
630
+ await expect(p).rejects.toThrow(SandboxMalformedFrameError)
631
+ })
632
+
633
+ test('rejects on unexpected socket close', async () => {
634
+ const sandbox = new Sandbox(BASE_OPTIONS)
635
+ const p = sandbox.connect()
636
+ sockets[0].open()
637
+ sockets[0].closeWith(1006)
638
+ await expect(p).rejects.toThrow(SandboxWebSocketError)
639
+ })
640
+
641
+ test('rejects with SandboxWebSocketError on socket error event during connect', async () => {
642
+ const sandbox = new Sandbox(BASE_OPTIONS)
643
+ const p = sandbox.connect()
644
+ sockets[0].emit('error', new Error('ECONNREFUSED'))
645
+ await expect(p).rejects.toThrow(SandboxWebSocketError)
646
+ await expect(p).rejects.toThrow('ECONNREFUSED')
647
+ })
648
+
649
+ test('rejects when auth frame cannot be sent after open', async () => {
650
+ const sandbox = new Sandbox(BASE_OPTIONS)
651
+ const p = sandbox.connect()
652
+ sockets[0].send = () => { throw new Error('broken auth send') }
653
+
654
+ sockets[0].open()
655
+
656
+ await expect(p).rejects.toThrow(SandboxWebSocketError)
657
+ await expect(p).rejects.toThrow('broken auth send')
658
+ })
659
+
660
+ test('resolves in-flight connect when socket is intentionally closed', async () => {
661
+ const sandbox = new Sandbox(BASE_OPTIONS)
662
+ const p = sandbox.connect()
663
+ const [routeClose] = sockets[0].listeners('close')
664
+ sockets[0].off('close', routeClose)
665
+
666
+ sandbox.ws.beginIntentionalClose()
667
+ sockets[0].closeWith(1000)
668
+
669
+ await expect(p).resolves.toBeUndefined()
670
+ })
671
+ })
672
+
673
+ // -------------------------------------------------------------------------
674
+ // exec
675
+ // -------------------------------------------------------------------------
676
+
677
+ describe('exec()', () => {
678
+ test('sends exec.run and resolves with stdout/stderr/exitCode', async () => {
679
+ const sandbox = await buildConnectedSandbox()
680
+
681
+ const resultPromise = sandbox.exec('echo hello')
682
+ const frame = JSON.parse(sockets[0].sent[1])
683
+
684
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stdout', data: 'hello\n' })
685
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
686
+
687
+ const result = await resultPromise
688
+ expect(result.stdout).toBe('hello\n')
689
+ expect(result.exitCode).toBe(0)
690
+ })
691
+
692
+ test('accumulates stderr separately', async () => {
693
+ const sandbox = await buildConnectedSandbox()
694
+
695
+ const resultPromise = sandbox.exec('cmd')
696
+ const frame = JSON.parse(sockets[0].sent[1])
697
+
698
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stderr', data: 'err\n' })
699
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 1 })
700
+
701
+ const result = await resultPromise
702
+ expect(result.stderr).toBe('err\n')
703
+ expect(result.exitCode).toBe(1)
704
+ })
705
+
706
+ test('sends stdin and closeStdin when options.stdin is provided', async () => {
707
+ const sandbox = await buildConnectedSandbox()
708
+
709
+ const resultPromise = sandbox.exec('cat', { stdin: 'hello\n' })
710
+ const execFrame = JSON.parse(sockets[0].sent[1])
711
+ const stdinFrame = JSON.parse(sockets[0].sent[2])
712
+ const endFrame = JSON.parse(sockets[0].sent[3])
713
+
714
+ expect(stdinFrame.type).toBe('exec.input')
715
+ expect(stdinFrame.data).toBe('hello\n')
716
+ expect(endFrame.type).toBe('exec.endInput')
717
+
718
+ sockets[0].message({ type: 'exec.exit', execId: execFrame.execId, exitCode: 0 })
719
+ await resultPromise
720
+ })
721
+
722
+ test('calls onOutput callback for each output chunk', async () => {
723
+ const sandbox = await buildConnectedSandbox()
724
+ const chunks = []
725
+
726
+ const resultPromise = sandbox.exec('cmd', { onOutput: (data, stream) => chunks.push({ data, stream }) })
727
+ const frame = JSON.parse(sockets[0].sent[1])
728
+
729
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stdout', data: 'a' })
730
+ sockets[0].message({ type: 'exec.output', execId: frame.execId, stream: 'stderr', data: 'b' })
731
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
732
+
733
+ await resultPromise
734
+ expect(chunks).toEqual([{ data: 'a', stream: 'stdout' }, { data: 'b', stream: 'stderr' }])
735
+ })
736
+
737
+ test('rejects with SandboxTimeoutError when timeout elapses', async () => {
738
+ jest.useFakeTimers()
739
+ const sandbox = await buildConnectedSandbox()
740
+
741
+ const resultPromise = sandbox.exec('sleep 100', { timeout: 1000 })
742
+ jest.advanceTimersByTime(1001)
743
+
744
+ await expect(resultPromise).rejects.toThrow(SandboxTimeoutError)
745
+ })
746
+
747
+ test('returns promise with execId property', async () => {
748
+ const sandbox = await buildConnectedSandbox()
749
+
750
+ const resultPromise = sandbox.exec('echo hi')
751
+ expect(typeof resultPromise.execId).toBe('string')
752
+ expect(resultPromise.execId).toMatch(/^exec-/)
753
+
754
+ const frame = JSON.parse(sockets[0].sent[1])
755
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
756
+ await resultPromise
757
+ })
758
+
759
+ test('rejects with SandboxClientError on exec error frame', async () => {
760
+ const sandbox = await buildConnectedSandbox()
761
+
762
+ const resultPromise = sandbox.exec('bad-cmd')
763
+ const frame = JSON.parse(sockets[0].sent[1])
764
+
765
+ sockets[0].message({ type: 'error', execId: frame.execId, message: 'command not found' })
766
+
767
+ await expect(resultPromise).rejects.toThrow(SandboxClientError)
768
+ })
769
+
770
+ test('rejects when socket is not open', async () => {
771
+ const sandbox = new Sandbox(BASE_OPTIONS)
772
+ await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
773
+ })
774
+
775
+ test('rejects exec after socket has closed (ws.ensureOpen path)', async () => {
776
+ const sandbox = await buildConnectedSandbox()
777
+ sockets[0].closeWith(1006)
778
+ await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
779
+ })
780
+
781
+ test('rejects with SandboxWebSocketError when socket.send throws during exec', async () => {
782
+ const sandbox = await buildConnectedSandbox()
783
+ sockets[0].send = () => { throw new Error('broken pipe') }
784
+ await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
785
+ })
786
+ })
787
+
788
+ // -------------------------------------------------------------------------
789
+ // kill / writeStdin / closeStdin
790
+ // -------------------------------------------------------------------------
791
+
792
+ describe('kill()', () => {
793
+ test('sends exec.kill frame', async () => {
794
+ const sandbox = await buildConnectedSandbox()
795
+ sandbox.kill('exec-abc', 'SIGKILL')
796
+
797
+ const frame = JSON.parse(sockets[0].sent[1])
798
+ expect(frame.type).toBe('exec.kill')
799
+ expect(frame.execId).toBe('exec-abc')
800
+ expect(frame.signal).toBe('SIGKILL')
801
+ })
802
+ })
803
+
804
+ describe('writeStdin()', () => {
805
+ test('sends exec.input frame with string data', async () => {
806
+ const sandbox = await buildConnectedSandbox()
807
+ sandbox.writeStdin('exec-abc', 'hello\n')
808
+
809
+ const frame = JSON.parse(sockets[0].sent[1])
810
+ expect(frame.type).toBe('exec.input')
811
+ expect(frame.data).toBe('hello\n')
812
+ expect(frame.encoding).toBeUndefined()
813
+ })
814
+
815
+ test('base64-encodes Buffer data', async () => {
816
+ const sandbox = await buildConnectedSandbox()
817
+ sandbox.writeStdin('exec-abc', Buffer.from('binary'))
818
+
819
+ const frame = JSON.parse(sockets[0].sent[1])
820
+ expect(frame.encoding).toBe('base64')
821
+ expect(Buffer.from(frame.data, 'base64').toString()).toBe('binary')
822
+ })
823
+ })
824
+
825
+ describe('closeStdin()', () => {
826
+ test('sends exec.endInput frame', async () => {
827
+ const sandbox = await buildConnectedSandbox()
828
+ sandbox.closeStdin('exec-abc')
829
+
830
+ const frame = JSON.parse(sockets[0].sent[1])
831
+ expect(frame.type).toBe('exec.endInput')
832
+ expect(frame.execId).toBe('exec-abc')
833
+ })
834
+ })
835
+
836
+ // -------------------------------------------------------------------------
837
+ // File operations
838
+ // -------------------------------------------------------------------------
839
+
840
+ describe('readFile()', () => {
841
+ test('sends file.read and resolves with content', async () => {
842
+ const sandbox = await buildConnectedSandbox()
843
+
844
+ const filePromise = sandbox.readFile('/app/hello.js')
845
+ const frame = JSON.parse(sockets[0].sent[1])
846
+ expect(frame.type).toBe('file.read')
847
+ expect(frame.path).toBe('/app/hello.js')
848
+
849
+ const encoded = Buffer.from('console.log("hi")').toString('base64')
850
+ sockets[0].message({ type: 'file.content', execId: frame.execId, content: encoded, encoding: 'base64' })
851
+
852
+ const content = await filePromise
853
+ expect(content).toBe('console.log("hi")')
854
+ })
855
+
856
+ test('resolves with raw string when no encoding', async () => {
857
+ const sandbox = await buildConnectedSandbox()
858
+
859
+ const filePromise = sandbox.readFile('/text.txt')
860
+ const frame = JSON.parse(sockets[0].sent[1])
861
+ sockets[0].message({ type: 'file.content', execId: frame.execId, content: 'plain text' })
862
+
863
+ expect(await filePromise).toBe('plain text')
864
+ })
865
+
866
+ test('rejects on error frame', async () => {
867
+ const sandbox = await buildConnectedSandbox()
868
+
869
+ const filePromise = sandbox.readFile('/missing')
870
+ const frame = JSON.parse(sockets[0].sent[1])
871
+ sockets[0].message({ type: 'error', execId: frame.execId, message: 'no such file' })
872
+
873
+ await expect(filePromise).rejects.toThrow(SandboxClientError)
874
+ })
875
+
876
+ test('rejects when socket is not open', async () => {
877
+ const sandbox = new Sandbox(BASE_OPTIONS)
878
+ await expect(sandbox.readFile('/file.txt')).rejects.toThrow(SandboxWebSocketError)
879
+ })
880
+
881
+ test('rejects when socket.send throws', async () => {
882
+ const sandbox = await buildConnectedSandbox()
883
+ sockets[0].send = () => { throw new Error('broken pipe') }
884
+
885
+ await expect(sandbox.readFile('/file.txt')).rejects.toThrow(SandboxWebSocketError)
886
+ })
887
+ })
888
+
889
+ describe('writeFile()', () => {
890
+ test('sends file.write with base64 content and resolves with write result', async () => {
891
+ const sandbox = await buildConnectedSandbox()
892
+
893
+ const writePromise = sandbox.writeFile('/app/script.js', 'const x = 1')
894
+ const frame = JSON.parse(sockets[0].sent[1])
895
+ expect(frame.type).toBe('file.write')
896
+ expect(frame.encoding).toBe('base64')
897
+
898
+ sockets[0].message({ type: 'file.writeResult', execId: frame.execId, path: frame.path, size: 11, ok: true })
899
+
900
+ const result = await writePromise
901
+ expect(result.ok).toBe(true)
902
+ expect(result.size).toBe(11)
903
+ })
904
+
905
+ test('rejects on failed write result', async () => {
906
+ const sandbox = await buildConnectedSandbox()
907
+
908
+ const writePromise = sandbox.writeFile('/readonly', 'data')
909
+ const frame = JSON.parse(sockets[0].sent[1])
910
+ sockets[0].message({ type: 'file.writeResult', execId: frame.execId, path: frame.path, ok: false })
911
+
912
+ await expect(writePromise).rejects.toThrow(SandboxClientError)
913
+ })
914
+
915
+ test('rejects when socket is not open', async () => {
916
+ const sandbox = new Sandbox(BASE_OPTIONS)
917
+ await expect(sandbox.writeFile('/file.txt', 'content')).rejects.toThrow(SandboxWebSocketError)
918
+ })
919
+
920
+ test('rejects when socket.send throws', async () => {
921
+ const sandbox = await buildConnectedSandbox()
922
+ sockets[0].send = () => { throw new Error('broken pipe') }
923
+
924
+ await expect(sandbox.writeFile('/file.txt', 'content')).rejects.toThrow(SandboxWebSocketError)
925
+ })
926
+ })
927
+
928
+ describe('listFiles()', () => {
929
+ test('sends file.list and resolves with entries', async () => {
930
+ const sandbox = await buildConnectedSandbox()
931
+
932
+ const listPromise = sandbox.listFiles('.')
933
+ const frame = JSON.parse(sockets[0].sent[1])
934
+ expect(frame.type).toBe('file.list')
935
+
936
+ const entries = [
937
+ { name: 'hello.js', type: 'file', size: 42 },
938
+ { name: 'src', type: 'directory' }
939
+ ]
940
+ sockets[0].message({ type: 'file.entries', execId: frame.execId, entries })
941
+
942
+ expect(await listPromise).toEqual(entries)
943
+ })
944
+
945
+ test('resolves with empty array when entries is absent', async () => {
946
+ const sandbox = await buildConnectedSandbox()
947
+
948
+ const listPromise = sandbox.listFiles('.')
949
+ const frame = JSON.parse(sockets[0].sent[1])
950
+ sockets[0].message({ type: 'file.entries', execId: frame.execId })
951
+
952
+ expect(await listPromise).toEqual([])
953
+ })
954
+
955
+ test('rejects when socket is not open', async () => {
956
+ const sandbox = new Sandbox(BASE_OPTIONS)
957
+ await expect(sandbox.listFiles('/workspace')).rejects.toThrow(SandboxWebSocketError)
958
+ })
959
+
960
+ test('rejects when socket.send throws', async () => {
961
+ const sandbox = await buildConnectedSandbox()
962
+ sockets[0].send = () => { throw new Error('broken pipe') }
963
+
964
+ await expect(sandbox.listFiles('/workspace')).rejects.toThrow(SandboxWebSocketError)
965
+ })
966
+ })
967
+
968
+ // -------------------------------------------------------------------------
969
+ // getUrl
970
+ // -------------------------------------------------------------------------
971
+
972
+ describe('getUrl()', () => {
973
+ test('resolves preview URL from previewUrls map', () => {
974
+ const sandbox = new Sandbox({
975
+ ...BASE_OPTIONS,
976
+ previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
977
+ })
978
+
979
+ const url = sandbox.getUrl(3000)
980
+ expect(url).toBe('https://sb-test-3000.preview.example.net')
981
+ })
982
+
983
+ test('throws SandboxPortNotProvisionedError when port was not provisioned', () => {
984
+ const sandbox = new Sandbox({
985
+ ...BASE_OPTIONS,
986
+ previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
987
+ })
988
+ expect(() => sandbox.getUrl(9999)).toThrow(SandboxPortNotProvisionedError)
989
+ })
990
+
991
+ test('throws SandboxInvalidPortError for out-of-range port', () => {
992
+ const sandbox = new Sandbox({
993
+ ...BASE_OPTIONS,
994
+ previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
995
+ })
996
+ expect(() => sandbox.getUrl(0)).toThrow(SandboxInvalidPortError)
997
+ expect(() => sandbox.getUrl(65536)).toThrow(SandboxInvalidPortError)
998
+ })
999
+
1000
+ test('throws SandboxInvalidPortError for non-integer port', () => {
1001
+ const sandbox = new Sandbox({
1002
+ ...BASE_OPTIONS,
1003
+ previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
1004
+ })
1005
+ expect(() => sandbox.getUrl('abc')).toThrow(SandboxInvalidPortError)
1006
+ expect(() => sandbox.getUrl(3000.5)).toThrow(SandboxInvalidPortError)
1007
+ })
1008
+ })
1009
+
1010
+ // -------------------------------------------------------------------------
1011
+ // destroy
1012
+ // -------------------------------------------------------------------------
1013
+
1014
+ describe('destroy()', () => {
1015
+ test('calls DELETE and closes the socket', async () => {
1016
+ const mockFetch = jest.fn().mockResolvedValue({
1017
+ ok: true,
1018
+ json: () => Promise.resolve({ status: 'destroyed' })
1019
+ })
1020
+ global.fetch = mockFetch
1021
+
1022
+ const sandbox = await buildConnectedSandbox()
1023
+ const result = await sandbox.destroy()
1024
+
1025
+ expect(result.status).toBe('destroyed')
1026
+ expect(sandbox.status).toBe('destroyed')
1027
+ expect(mockFetch).toHaveBeenCalledWith(
1028
+ expect.stringContaining('/sandboxes/sb-test'),
1029
+ expect.objectContaining({ method: 'DELETE' })
1030
+ )
1031
+ })
1032
+
1033
+ test('resolves pending foreground exec when destroy closes the socket', async () => {
1034
+ global.fetch = jest.fn().mockResolvedValue({
1035
+ ok: true,
1036
+ json: () => Promise.resolve({ status: 'destroyed' })
1037
+ })
1038
+
1039
+ const sandbox = await buildConnectedSandbox()
1040
+ const execPromise = sandbox.exec('sleep 100')
1041
+ const runFrame = JSON.parse(sockets[0].sent[1])
1042
+
1043
+ await sandbox.destroy()
1044
+
1045
+ await expect(execPromise).resolves.toEqual({
1046
+ execId: runFrame.execId,
1047
+ stdout: '',
1048
+ stderr: '',
1049
+ exitCode: null,
1050
+ destroyed: true
1051
+ })
1052
+ })
1053
+
1054
+ test('resolves pending detached exec ack when destroy closes before exec.detached', async () => {
1055
+ global.fetch = jest.fn().mockResolvedValue({
1056
+ ok: true,
1057
+ json: () => Promise.resolve({ status: 'destroyed' })
1058
+ })
1059
+
1060
+ const sandbox = await buildConnectedSandbox()
1061
+ const commandPromise = sandbox.exec('sleep infinity', { detached: true })
1062
+
1063
+ await sandbox.destroy()
1064
+
1065
+ const command = await commandPromise
1066
+ expect(command).toMatchObject({
1067
+ execId: expect.any(String),
1068
+ pid: undefined,
1069
+ startedAt: undefined,
1070
+ detached: true
1071
+ })
1072
+ await expect(command.wait()).resolves.toEqual({
1073
+ exitCode: null,
1074
+ destroyed: true
1075
+ })
1076
+ })
1077
+
1078
+ test('resolves pending file and getCommand operations when destroy closes socket', async () => {
1079
+ global.fetch = jest.fn().mockResolvedValue({
1080
+ ok: true,
1081
+ json: () => Promise.resolve({ status: 'destroyed' })
1082
+ })
1083
+
1084
+ const sandbox = await buildConnectedSandbox()
1085
+ const filePromise = sandbox.readFile('/workspace/file.txt')
1086
+ const commandPromise = sandbox.getCommand('exec-running')
1087
+
1088
+ await sandbox.destroy()
1089
+
1090
+ await expect(filePromise).resolves.toBeUndefined()
1091
+ await expect(commandPromise).resolves.toBeNull()
1092
+ })
1093
+
1094
+ test('resolves detached wait when sandbox destroy triggers a 1005 socket close', async () => {
1095
+ const sandbox = await buildConnectedSandbox()
1096
+ global.fetch = jest.fn().mockImplementation(async () => {
1097
+ sockets[0].closeWith(1005)
1098
+ return {
1099
+ ok: true,
1100
+ json: () => Promise.resolve({ status: 'destroyed' })
1101
+ }
1102
+ })
1103
+
1104
+ const commandPromise = sandbox.exec('sleep infinity', { detached: true })
1105
+ const runFrame = JSON.parse(sockets[0].sent[1])
1106
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1234, startedAt: 100 })
1107
+ const command = await commandPromise
1108
+ const waitPromise = command.wait()
1109
+
1110
+ await sandbox.destroy()
1111
+
1112
+ await expect(waitPromise).resolves.toEqual({
1113
+ exitCode: null,
1114
+ destroyed: true
1115
+ })
1116
+ })
1117
+
1118
+ test('throws SandboxUnauthorizedError on 403', async () => {
1119
+ global.fetch = jest.fn().mockResolvedValue({
1120
+ ok: false,
1121
+ status: 403,
1122
+ text: () => Promise.resolve('forbidden')
1123
+ })
1124
+
1125
+ const sandbox = await buildConnectedSandbox()
1126
+ await expect(sandbox.destroy()).rejects.toThrow(SandboxUnauthorizedError)
1127
+ })
1128
+ })
1129
+
1130
+ // -------------------------------------------------------------------------
1131
+ // Detached exec
1132
+ // -------------------------------------------------------------------------
1133
+
1134
+ describe('exec() with detached: true', () => {
1135
+ test('sends exec.run with detached:true and resolves with command object on exec.detached', async () => {
1136
+ const sandbox = await buildConnectedSandbox()
1137
+ const chunks = []
1138
+
1139
+ const commandPromise = sandbox.exec('npm run dev', {
1140
+ detached: true,
1141
+ onOutput: (data, stream) => chunks.push({ data, stream })
1142
+ })
1143
+ const runFrame = JSON.parse(sockets[0].sent[1])
1144
+ expect(runFrame.type).toBe('exec.run')
1145
+ expect(runFrame.detached).toBe(true)
1146
+
1147
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 9999, startedAt: 1234567890 })
1148
+
1149
+ const command = await commandPromise
1150
+ expect(command.execId).toBe(runFrame.execId)
1151
+ expect(command.pid).toBe(9999)
1152
+ expect(command.startedAt).toBe(1234567890)
1153
+ expect(command.detached).toBe(true)
1154
+ expect(typeof command.wait).toBe('function')
1155
+ expect(typeof command.kill).toBe('function')
1156
+ expect(typeof command.writeStdin).toBe('function')
1157
+ expect(typeof command.closeStdin).toBe('function')
1158
+ })
1159
+
1160
+ test('command object writeStdin / closeStdin / kill delegate to sandbox', async () => {
1161
+ const sandbox = await buildConnectedSandbox()
1162
+
1163
+ const commandPromise = sandbox.exec('tail -f /log', { detached: true })
1164
+ const runFrame = JSON.parse(sockets[0].sent[1])
1165
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 42, startedAt: 1000 })
1166
+ const command = await commandPromise
1167
+
1168
+ command.writeStdin('hello\n')
1169
+ const inputFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1170
+ expect(inputFrame.type).toBe('exec.input')
1171
+ expect(inputFrame.execId).toBe(runFrame.execId)
1172
+ expect(inputFrame.data).toBe('hello\n')
1173
+
1174
+ command.closeStdin()
1175
+ const endFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1176
+ expect(endFrame.type).toBe('exec.endInput')
1177
+ expect(endFrame.execId).toBe(runFrame.execId)
1178
+
1179
+ command.kill('SIGINT')
1180
+ const killFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1181
+ expect(killFrame.type).toBe('exec.kill')
1182
+ expect(killFrame.execId).toBe(runFrame.execId)
1183
+ expect(killFrame.signal).toBe('SIGINT')
1184
+ })
1185
+
1186
+ test('wait() resolves with exitCode when exec.exit arrives', async () => {
1187
+ const sandbox = await buildConnectedSandbox()
1188
+
1189
+ const commandPromise = sandbox.exec('sleep 100', { detached: true })
1190
+ const runFrame = JSON.parse(sockets[0].sent[1])
1191
+
1192
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1234, startedAt: 1000000 })
1193
+ const command = await commandPromise
1194
+
1195
+ const waitPromise = command.wait()
1196
+ sockets[0].message({ type: 'exec.exit', execId: runFrame.execId, exitCode: 0 })
1197
+
1198
+ const result = await waitPromise
1199
+ expect(result.exitCode).toBe(0)
1200
+ })
1201
+
1202
+ test('output frames after exec.detached are delivered to onOutput', async () => {
1203
+ const sandbox = await buildConnectedSandbox()
1204
+ const chunks = []
1205
+
1206
+ const commandPromise = sandbox.exec('npm run dev', {
1207
+ detached: true,
1208
+ onOutput: (data, stream) => chunks.push({ data, stream })
1209
+ })
1210
+ const runFrame = JSON.parse(sockets[0].sent[1])
1211
+
1212
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 9000, startedAt: 1 })
1213
+ await commandPromise
1214
+
1215
+ sockets[0].message({ type: 'exec.output', execId: runFrame.execId, stream: 'stdout', data: 'compiled\n' })
1216
+ expect(chunks).toEqual([{ data: 'compiled\n', stream: 'stdout' }])
1217
+ })
1218
+
1219
+ test('rejects with SandboxClientError when timeout is combined with detached', async () => {
1220
+ const sandbox = await buildConnectedSandbox()
1221
+ await expect(
1222
+ sandbox.exec('npm run dev', { detached: true, timeout: 5000 })
1223
+ ).rejects.toThrow(SandboxClientError)
1224
+ })
1225
+
1226
+ test('error frame on detached exec rejects wait()', async () => {
1227
+ const sandbox = await buildConnectedSandbox()
1228
+
1229
+ const commandPromise = sandbox.exec('bad-cmd', { detached: true })
1230
+ const runFrame = JSON.parse(sockets[0].sent[1])
1231
+
1232
+ // Resolve outer promise first (process started)
1233
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1, startedAt: 1 })
1234
+ const command = await commandPromise
1235
+
1236
+ const waitPromise = command.wait()
1237
+ // Then an error arrives (e.g. process crashed with error frame)
1238
+ sockets[0].message({ type: 'error', execId: runFrame.execId, message: 'process crashed' })
1239
+
1240
+ await expect(waitPromise).rejects.toThrow(SandboxClientError)
1241
+ })
1242
+ })
1243
+
1244
+ // -------------------------------------------------------------------------
1245
+ // getCommand
1246
+ // -------------------------------------------------------------------------
1247
+
1248
+ describe('getCommand()', () => {
1249
+ test('sends exec.get and resolves with command object on exec.info', async () => {
1250
+ const sandbox = await buildConnectedSandbox()
1251
+
1252
+ const commandPromise = sandbox.getCommand('exec-d1e2f3a4', { onOutput: () => {} })
1253
+ const getFrame = JSON.parse(sockets[0].sent[1])
1254
+ expect(getFrame.type).toBe('exec.get')
1255
+ expect(getFrame.execId).toBe('exec-d1e2f3a4')
1256
+
1257
+ sockets[0].message({
1258
+ type: 'exec.info',
1259
+ execId: 'exec-d1e2f3a4',
1260
+ command: 'npm run dev',
1261
+ pid: 5678,
1262
+ startedAt: 1711036812,
1263
+ detached: true
1264
+ })
1265
+
1266
+ const command = await commandPromise
1267
+ expect(command.execId).toBe('exec-d1e2f3a4')
1268
+ expect(command.command).toBe('npm run dev')
1269
+ expect(command.pid).toBe(5678)
1270
+ expect(command.startedAt).toBe(1711036812)
1271
+ expect(command.detached).toBe(true)
1272
+ expect(typeof command.wait).toBe('function')
1273
+ })
1274
+
1275
+ test('wait() resolves when exec.exit arrives after getCommand()', async () => {
1276
+ const sandbox = await buildConnectedSandbox()
1277
+
1278
+ const commandPromise = sandbox.getCommand('exec-reattach')
1279
+ JSON.parse(sockets[0].sent[1]) // exec.get frame
1280
+ sockets[0].message({
1281
+ type: 'exec.info',
1282
+ execId: 'exec-reattach',
1283
+ command: 'sleep 60',
1284
+ pid: 1111,
1285
+ startedAt: 100,
1286
+ detached: true
1287
+ })
1288
+
1289
+ const command = await commandPromise
1290
+ const waitPromise = command.wait()
1291
+
1292
+ sockets[0].message({ type: 'exec.exit', execId: 'exec-reattach', exitCode: 143 })
1293
+ const result = await waitPromise
1294
+ expect(result.exitCode).toBe(143)
1295
+ })
1296
+
1297
+ test('throws SandboxCommandNotFoundError when error NOT_FOUND is returned', async () => {
1298
+ const sandbox = await buildConnectedSandbox()
1299
+
1300
+ const commandPromise = sandbox.getCommand('exec-gone')
1301
+ JSON.parse(sockets[0].sent[1])
1302
+ sockets[0].message({
1303
+ type: 'error',
1304
+ execId: 'exec-gone',
1305
+ code: 'NOT_FOUND',
1306
+ message: 'no running process for execId'
1307
+ })
1308
+
1309
+ await expect(commandPromise).rejects.toThrow(SandboxCommandNotFoundError)
1310
+ })
1311
+
1312
+ test('rejects when socket is not open', async () => {
1313
+ const sandbox = new Sandbox(BASE_OPTIONS)
1314
+ await expect(sandbox.getCommand('exec-x')).rejects.toThrow(SandboxWebSocketError)
1315
+ })
1316
+
1317
+ test('rejects and clears pending get operation when socket.send throws', async () => {
1318
+ const sandbox = await buildConnectedSandbox()
1319
+ sockets[0].send = () => { throw new Error('broken pipe') }
1320
+
1321
+ await expect(sandbox.getCommand('exec-x')).rejects.toThrow(SandboxWebSocketError)
1322
+ expect(sandbox.ws.pendingGetOps.has('exec-x')).toBe(false)
1323
+ })
1324
+
1325
+ test('reuses existing wait promise when exec is already running in same session', async () => {
1326
+ const sandbox = await buildConnectedSandbox()
1327
+
1328
+ // Start a detached exec so it lands in pendingExecs
1329
+ const commandPromise = sandbox.exec('npm run dev', { detached: true })
1330
+ const runFrame = JSON.parse(sockets[0].sent[1])
1331
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 100, startedAt: 1 })
1332
+ const command = await commandPromise
1333
+
1334
+ // Reattach via getCommand for the same execId
1335
+ const getPromise = sandbox.getCommand(runFrame.execId)
1336
+ sockets[0].message({
1337
+ type: 'exec.info',
1338
+ execId: runFrame.execId,
1339
+ command: 'npm run dev',
1340
+ pid: 100,
1341
+ startedAt: 1,
1342
+ detached: true
1343
+ })
1344
+ const reattached = await getPromise
1345
+
1346
+ // Both wait() calls share the same underlying promise
1347
+ const w1 = command.wait()
1348
+ const w2 = reattached.wait()
1349
+ expect(w1).toBe(w2)
1350
+
1351
+ sockets[0].message({ type: 'exec.exit', execId: runFrame.execId, exitCode: 0 })
1352
+ const [r1, r2] = await Promise.all([w1, w2])
1353
+ expect(r1.exitCode).toBe(0)
1354
+ expect(r2.exitCode).toBe(0)
1355
+ })
1356
+
1357
+ test('delivers subsequent output to both original and reattached onOutput callbacks', async () => {
1358
+ const sandbox = await buildConnectedSandbox()
1359
+ const original = []
1360
+ const reattached = []
1361
+
1362
+ const commandPromise = sandbox.exec('npm run dev', {
1363
+ detached: true,
1364
+ onOutput: (data, stream) => original.push({ data, stream })
1365
+ })
1366
+ const runFrame = JSON.parse(sockets[0].sent[1])
1367
+ sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1, startedAt: 1 })
1368
+ await commandPromise
1369
+
1370
+ const getPromise = sandbox.getCommand(runFrame.execId, {
1371
+ onOutput: (data, stream) => reattached.push({ data, stream })
1372
+ })
1373
+ sockets[0].message({
1374
+ type: 'exec.info',
1375
+ execId: runFrame.execId,
1376
+ command: 'npm run dev',
1377
+ pid: 1,
1378
+ startedAt: 1,
1379
+ detached: true
1380
+ })
1381
+ await getPromise
1382
+
1383
+ sockets[0].message({ type: 'exec.output', execId: runFrame.execId, stream: 'stdout', data: 'hello\n' })
1384
+ expect(original).toEqual([{ data: 'hello\n', stream: 'stdout' }])
1385
+ expect(reattached).toEqual([{ data: 'hello\n', stream: 'stdout' }])
1386
+ })
1387
+
1388
+ test('command object writeStdin / closeStdin / kill delegate to sandbox', async () => {
1389
+ const sandbox = await buildConnectedSandbox()
1390
+
1391
+ const getPromise = sandbox.getCommand('exec-xyz')
1392
+ sockets[0].message({
1393
+ type: 'exec.info',
1394
+ execId: 'exec-xyz',
1395
+ command: 'tail -f /log',
1396
+ pid: 42,
1397
+ startedAt: 1000,
1398
+ detached: true
1399
+ })
1400
+ const command = await getPromise
1401
+
1402
+ command.writeStdin('hello\n')
1403
+ const inputFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1404
+ expect(inputFrame.type).toBe('exec.input')
1405
+ expect(inputFrame.execId).toBe('exec-xyz')
1406
+ expect(inputFrame.data).toBe('hello\n')
1407
+
1408
+ command.closeStdin()
1409
+ const endFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1410
+ expect(endFrame.type).toBe('exec.endInput')
1411
+ expect(endFrame.execId).toBe('exec-xyz')
1412
+
1413
+ command.kill('SIGINT')
1414
+ const killFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
1415
+ expect(killFrame.type).toBe('exec.kill')
1416
+ expect(killFrame.execId).toBe('exec-xyz')
1417
+ expect(killFrame.signal).toBe('SIGINT')
1418
+ })
1419
+ })
1420
+
1421
+ // -------------------------------------------------------------------------
1422
+ // Socket close drains pending operations
1423
+ // -------------------------------------------------------------------------
1424
+
1425
+ describe('WebSocket close', () => {
1426
+ test('rejects all pending execs when socket closes unexpectedly', async () => {
1427
+ const sandbox = await buildConnectedSandbox()
1428
+
1429
+ const resultPromise = sandbox.exec('sleep 60')
1430
+ sockets[0].closeWith(1006)
1431
+
1432
+ await expect(resultPromise).rejects.toThrow(SandboxWebSocketError)
1433
+ })
1434
+
1435
+ test('rejects all pending file ops when socket closes', async () => {
1436
+ const sandbox = await buildConnectedSandbox()
1437
+
1438
+ const filePromise = sandbox.readFile('/heavy-file')
1439
+ sockets[0].closeWith(1006)
1440
+
1441
+ await expect(filePromise).rejects.toThrow(SandboxWebSocketError)
1442
+ })
1443
+
1444
+ test('rejects pending getCommand when socket closes before exec.info arrives', async () => {
1445
+ const sandbox = await buildConnectedSandbox()
1446
+
1447
+ const commandPromise = sandbox.getCommand('exec-running')
1448
+ sockets[0].closeWith(1006)
1449
+
1450
+ await expect(commandPromise).rejects.toThrow(SandboxWebSocketError)
1451
+ })
1452
+
1453
+ test('silently ignores incoming messages with invalid JSON', async () => {
1454
+ const sandbox = await buildConnectedSandbox()
1455
+
1456
+ sockets[0].emit('message', Buffer.from('not-valid-json!!!'))
1457
+
1458
+ const resultPromise = sandbox.exec('echo hi')
1459
+ const frame = JSON.parse(sockets[0].sent[1])
1460
+ sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
1461
+ await expect(resultPromise).resolves.toMatchObject({ exitCode: 0 })
1462
+ })
1463
+ })
1464
+ })