@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.
- package/.eslintignore +2 -0
- package/.eslintrc.json +21 -0
- package/.github/CONTRIBUTING.md +44 -0
- package/.github/workflows/daily.yml +11 -0
- package/.github/workflows/node.js.yml +15 -0
- package/.github/workflows/on-push-publish-to-npm.yml +38 -0
- package/CODE_OF_CONDUCT.md +79 -0
- package/COPYRIGHT +5 -0
- package/LICENSE +201 -0
- package/README.md +244 -0
- package/RELEASING.md +36 -0
- package/jest.config.js +19 -0
- package/package.json +40 -0
- package/src/Sandbox.js +599 -0
- package/src/constants.js +22 -0
- package/src/errors.js +47 -0
- package/src/index.js +44 -0
- package/src/utils.js +170 -0
- package/src/ws.js +609 -0
- package/test/Sandbox.test.js +1464 -0
|
@@ -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
|
+
})
|