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