@adobe/aio-lib-sandbox 0.1.0-alpha.6 → 0.1.0-alpha.8
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/README.md +28 -0
- package/package.json +14 -3
- package/src/Sandbox.js +50 -36
- package/src/utils.js +29 -2
- package/src/ws.js +27 -27
- package/test/Sandbox.test.js +161 -8
package/.eslintignore
ADDED
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["@adobe/eslint-config-aio-lib-config"],
|
|
3
|
+
"settings": {
|
|
4
|
+
"jsdoc": {
|
|
5
|
+
"ignorePrivate": true
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"rules": {
|
|
9
|
+
"no-param-reassign": ["warn", { "props": true }],
|
|
10
|
+
"jsdoc/tag-lines": [
|
|
11
|
+
"error",
|
|
12
|
+
"never",
|
|
13
|
+
{
|
|
14
|
+
"startLines": null
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"parserOptions": {
|
|
19
|
+
"ecmaVersion": 2020
|
|
20
|
+
}
|
|
21
|
+
}
|
package/README.md
CHANGED
|
@@ -74,12 +74,19 @@ const { Sandbox } = require('@adobe/aio-lib-sandbox')
|
|
|
74
74
|
const sandbox = await Sandbox.create({
|
|
75
75
|
name: 'my-sandbox',
|
|
76
76
|
type: 'cpu:default',
|
|
77
|
+
idleTimeout: 900,
|
|
77
78
|
maxLifetime: 3600,
|
|
78
79
|
ports: [3000, 8080],
|
|
79
80
|
envs: { API_KEY: 'your-api-key' }
|
|
80
81
|
})
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
#### Sandbox lifetime model
|
|
85
|
+
|
|
86
|
+
A sandbox is always deleted when `maxLifetime` has elapsed. It will also be deleted after the `idleTimeout` has elapsed, if there has been no activity.
|
|
87
|
+
|
|
88
|
+
To keep a sandbox alive, send at least one command or check the status every `idleTimeout` seconds.
|
|
89
|
+
|
|
83
90
|
### Get Status
|
|
84
91
|
|
|
85
92
|
```js
|
|
@@ -218,3 +225,24 @@ const sandbox = await Sandbox.create({
|
|
|
218
225
|
}
|
|
219
226
|
})
|
|
220
227
|
```
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
Install development dependencies:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
npm install
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
To run the same checks used by CI:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
npm test
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Linting is powered by ESLint:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
npm run lint
|
|
247
|
+
npm run lint-fix
|
|
248
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/aio-lib-sandbox",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"description": "JavaScript SDK for Adobe Runtime Sandboxes",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,11 +19,22 @@
|
|
|
19
19
|
"ws": "^8.19.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
+
"@adobe/eslint-config-aio-lib-config": "^4.0.0",
|
|
23
|
+
"eslint": "^8.57.1",
|
|
24
|
+
"eslint-config-standard": "^17.1.0",
|
|
25
|
+
"eslint-plugin-import": "^2.31.0",
|
|
26
|
+
"eslint-plugin-jest": "^27.9.0",
|
|
27
|
+
"eslint-plugin-jsdoc": "^48.11.0",
|
|
28
|
+
"eslint-plugin-n": "^15.7.0",
|
|
29
|
+
"eslint-plugin-node": "^11.1.0",
|
|
30
|
+
"eslint-plugin-promise": "^6.6.0",
|
|
22
31
|
"jest": "^29",
|
|
23
32
|
"jest-junit": "^16.0.0"
|
|
24
33
|
},
|
|
25
34
|
"scripts": {
|
|
26
|
-
"
|
|
27
|
-
"lint": "
|
|
35
|
+
"lint": "eslint src test",
|
|
36
|
+
"lint-fix": "eslint src test --fix",
|
|
37
|
+
"test": "npm run unit-tests && npm run lint",
|
|
38
|
+
"unit-tests": "jest --ci"
|
|
28
39
|
}
|
|
29
40
|
}
|
package/src/Sandbox.js
CHANGED
|
@@ -36,12 +36,13 @@ class Sandbox {
|
|
|
36
36
|
* @param {object} options sandbox options
|
|
37
37
|
* @private
|
|
38
38
|
*/
|
|
39
|
-
constructor(options) {
|
|
39
|
+
constructor (options) {
|
|
40
40
|
this.id = options.id
|
|
41
41
|
this.endpoint = options.endpoint
|
|
42
42
|
this.status = options.status
|
|
43
43
|
this.cluster = options.cluster
|
|
44
44
|
this.region = options.region
|
|
45
|
+
this.idleTimeout = options.idleTimeout
|
|
45
46
|
this.maxLifetime = options.maxLifetime
|
|
46
47
|
|
|
47
48
|
this.namespace = options.namespace
|
|
@@ -70,13 +71,16 @@ class Sandbox {
|
|
|
70
71
|
* @param {string} [options.name] sandbox display name
|
|
71
72
|
* @param {string} [options.type] sandbox type (default: `'cpu:default'`)
|
|
72
73
|
* @param {string|object} [options.size] sandbox size tier (name or spec object)
|
|
73
|
-
* @param {number} [options.
|
|
74
|
+
* @param {number} [options.idleTimeout] seconds of inactivity before the sandbox is terminated
|
|
75
|
+
* (default: 900, max: 10800). The idle timer resets on every WebSocket message or status-check
|
|
76
|
+
* request.
|
|
77
|
+
* @param {number} [options.maxLifetime] maximum lifetime in seconds (default: 3600, max: 10800)
|
|
74
78
|
* @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
|
|
75
79
|
* @param {object} [options.envs] environment variables to inject into the sandbox
|
|
76
80
|
* @param {object} [options.policy] network policy (e.g. egress allowlist)
|
|
77
81
|
* @returns {Promise<Sandbox>} connected sandbox instance
|
|
78
82
|
*/
|
|
79
|
-
static async create(options = {}) {
|
|
83
|
+
static async create (options = {}) {
|
|
80
84
|
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
81
85
|
const creds = resolveCredentials(options)
|
|
82
86
|
|
|
@@ -84,6 +88,7 @@ class Sandbox {
|
|
|
84
88
|
name: options.name,
|
|
85
89
|
size: normalizeSize(options.size),
|
|
86
90
|
type: options.type || 'cpu:default',
|
|
91
|
+
idleTimeout: options.idleTimeout || 900,
|
|
87
92
|
maxLifetime: options.maxLifetime || 3600
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -93,7 +98,7 @@ class Sandbox {
|
|
|
93
98
|
if (options.policy !== undefined) body.policy = options.policy
|
|
94
99
|
if (options.ports !== undefined) body.ports = options.ports
|
|
95
100
|
|
|
96
|
-
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/
|
|
101
|
+
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes`
|
|
97
102
|
const payload = await apiRequest('POST', url, creds.apiKey, body)
|
|
98
103
|
|
|
99
104
|
const sandboxId = payload.sandboxId
|
|
@@ -105,6 +110,7 @@ class Sandbox {
|
|
|
105
110
|
status: payload.status,
|
|
106
111
|
cluster: payload.cluster,
|
|
107
112
|
region: payload.region,
|
|
113
|
+
idleTimeout: payload.idleTimeout,
|
|
108
114
|
maxLifetime: payload.maxLifetime,
|
|
109
115
|
previewUrls: parsePreviewUrls(payload.previewUrls),
|
|
110
116
|
managementEndpoint: payload.managementEndpoint || null,
|
|
@@ -124,17 +130,23 @@ class Sandbox {
|
|
|
124
130
|
* Credentials are read from the environment automatically.
|
|
125
131
|
* Any value passed explicitly in `options` overrides the environment.
|
|
126
132
|
*
|
|
133
|
+
* Pass the management endpoint so the request is sent to the correct host;
|
|
134
|
+
* falls back to `options.apiHost` when omitted.
|
|
135
|
+
*
|
|
127
136
|
* @param {string} sandboxId the sandbox ID to look up
|
|
128
137
|
* @param {object} [options] credential overrides
|
|
129
138
|
* @param {string} [options.apiHost] Runtime API host
|
|
130
139
|
* @param {string} [options.namespace] Runtime namespace
|
|
131
140
|
* @param {string} [options.auth] Runtime API key
|
|
141
|
+
* @param {string} [options.managementEndpoint] per-sandbox management endpoint returned by
|
|
142
|
+
* `Sandbox.create()`. Falls back to `apiHost` otherwise.
|
|
132
143
|
* @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
|
|
133
144
|
*/
|
|
134
|
-
static async get(sandboxId, options = {}) {
|
|
145
|
+
static async get (sandboxId, options = {}) {
|
|
135
146
|
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
136
147
|
const creds = resolveCredentials(options)
|
|
137
|
-
const
|
|
148
|
+
const base = options.managementEndpoint || creds.apiHost
|
|
149
|
+
const url = `${base}/api/v1/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
|
|
138
150
|
const payload = await apiRequest('GET', url, creds.apiKey)
|
|
139
151
|
|
|
140
152
|
return new Sandbox({
|
|
@@ -143,7 +155,9 @@ class Sandbox {
|
|
|
143
155
|
status: payload.status,
|
|
144
156
|
cluster: payload.cluster,
|
|
145
157
|
region: payload.region,
|
|
158
|
+
idleTimeout: payload.idleTimeout,
|
|
146
159
|
maxLifetime: payload.maxLifetime,
|
|
160
|
+
managementEndpoint: payload.managementEndpoint || options.managementEndpoint || null,
|
|
147
161
|
previewUrls: parsePreviewUrls(payload.previewUrls),
|
|
148
162
|
namespace: creds.namespace,
|
|
149
163
|
apiHost: creds.apiHost,
|
|
@@ -157,7 +171,7 @@ class Sandbox {
|
|
|
157
171
|
*
|
|
158
172
|
* @type {object}
|
|
159
173
|
*/
|
|
160
|
-
static get sizes() {
|
|
174
|
+
static get sizes () {
|
|
161
175
|
return SANDBOX_SIZES
|
|
162
176
|
}
|
|
163
177
|
|
|
@@ -165,19 +179,19 @@ class Sandbox {
|
|
|
165
179
|
* Exposes `resolveCredentials` as a static helper (useful for testing).
|
|
166
180
|
*
|
|
167
181
|
* @param {object} overrides credential overrides
|
|
168
|
-
* @returns {{ apiHost: string, namespace: string, apiKey: string }}
|
|
182
|
+
* @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
|
|
169
183
|
*/
|
|
170
|
-
static resolveCredentials(overrides = {}) {
|
|
184
|
+
static resolveCredentials (overrides = {}) {
|
|
171
185
|
return resolveCredentials(overrides)
|
|
172
186
|
}
|
|
173
187
|
|
|
174
188
|
/**
|
|
175
189
|
* Exposes `normalizeSize` as a static helper (useful for testing).
|
|
176
190
|
*
|
|
177
|
-
* @param {string|object|undefined} size
|
|
178
|
-
* @returns {string}
|
|
191
|
+
* @param {string|object|undefined} size sandbox size name or resource spec
|
|
192
|
+
* @returns {string} normalized sandbox size name
|
|
179
193
|
*/
|
|
180
|
-
static normalizeSize(size) {
|
|
194
|
+
static normalizeSize (size) {
|
|
181
195
|
return normalizeSize(size)
|
|
182
196
|
}
|
|
183
197
|
|
|
@@ -190,7 +204,7 @@ class Sandbox {
|
|
|
190
204
|
*
|
|
191
205
|
* @returns {Promise<void>}
|
|
192
206
|
*/
|
|
193
|
-
connect() {
|
|
207
|
+
connect () {
|
|
194
208
|
if (!this.ws) {
|
|
195
209
|
this.ws = new SandboxSocket({
|
|
196
210
|
id: this.id,
|
|
@@ -215,10 +229,10 @@ class Sandbox {
|
|
|
215
229
|
* @param {number} [options.timeout] timeout in milliseconds (foreground only)
|
|
216
230
|
* @param {boolean} [options.detached] when true, run as a detached background process
|
|
217
231
|
* @param {string|Buffer} [options.stdin] data to send to stdin at startup
|
|
218
|
-
* @param {
|
|
219
|
-
* @returns {Promise}
|
|
232
|
+
* @param {Function} [options.onOutput] callback called with `(data, stream)` for each output chunk
|
|
233
|
+
* @returns {Promise} command result, or a detached command handle when `options.detached` is true
|
|
220
234
|
*/
|
|
221
|
-
exec(command, options = {}) {
|
|
235
|
+
exec (command, options = {}) {
|
|
222
236
|
try {
|
|
223
237
|
this.ensureOpen()
|
|
224
238
|
} catch (error) {
|
|
@@ -238,12 +252,12 @@ class Sandbox {
|
|
|
238
252
|
}
|
|
239
253
|
|
|
240
254
|
/**
|
|
241
|
-
* @param {string} execId
|
|
255
|
+
* @param {string} execId execution id to run inside the sandbox
|
|
242
256
|
* @param {string} command
|
|
243
257
|
* @param {object} options
|
|
244
258
|
* @private
|
|
245
259
|
*/
|
|
246
|
-
async sendExecFrameAndAwaitResponse(execId, command, options) {
|
|
260
|
+
async sendExecFrameAndAwaitResponse (execId, command, options) {
|
|
247
261
|
const detached = !!options.detached
|
|
248
262
|
const frame = { type: 'exec.run', execId, command, ...(detached && { detached: true }) }
|
|
249
263
|
|
|
@@ -283,10 +297,10 @@ class Sandbox {
|
|
|
283
297
|
*
|
|
284
298
|
* @param {string} execId the execId returned by the original `exec()` call
|
|
285
299
|
* @param {object} [options] re-attach options
|
|
286
|
-
* @param {
|
|
287
|
-
* @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>}
|
|
300
|
+
* @param {Function} [options.onOutput] callback called with `(data, stream)` for live output
|
|
301
|
+
* @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>} command handle
|
|
288
302
|
*/
|
|
289
|
-
getCommand(execId, options = {}) {
|
|
303
|
+
getCommand (execId, options = {}) {
|
|
290
304
|
try {
|
|
291
305
|
this.ensureOpen()
|
|
292
306
|
} catch (error) {
|
|
@@ -320,7 +334,7 @@ class Sandbox {
|
|
|
320
334
|
* @param {string} execId execution id
|
|
321
335
|
* @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
|
|
322
336
|
*/
|
|
323
|
-
kill(execId, signal = 'SIGTERM') {
|
|
337
|
+
kill (execId, signal = 'SIGTERM') {
|
|
324
338
|
this.ensureOpen()
|
|
325
339
|
this.sendFrame({ type: 'exec.kill', execId, signal })
|
|
326
340
|
}
|
|
@@ -332,7 +346,7 @@ class Sandbox {
|
|
|
332
346
|
* @param {string} execId execution id from `exec()`
|
|
333
347
|
* @param {string|Buffer} data data to write
|
|
334
348
|
*/
|
|
335
|
-
writeStdin(execId, data) {
|
|
349
|
+
writeStdin (execId, data) {
|
|
336
350
|
this.ensureOpen()
|
|
337
351
|
const frame = { type: 'exec.input', execId }
|
|
338
352
|
if (Buffer.isBuffer(data)) {
|
|
@@ -350,7 +364,7 @@ class Sandbox {
|
|
|
350
364
|
*
|
|
351
365
|
* @param {string} execId execution id from `exec()`
|
|
352
366
|
*/
|
|
353
|
-
closeStdin(execId) {
|
|
367
|
+
closeStdin (execId) {
|
|
354
368
|
this.ensureOpen()
|
|
355
369
|
this.sendFrame({ type: 'exec.endInput', execId })
|
|
356
370
|
}
|
|
@@ -365,7 +379,7 @@ class Sandbox {
|
|
|
365
379
|
* @param {string} path path inside the sandbox
|
|
366
380
|
* @returns {Promise<string>} file contents as a UTF-8 string
|
|
367
381
|
*/
|
|
368
|
-
readFile(path) {
|
|
382
|
+
readFile (path) {
|
|
369
383
|
try {
|
|
370
384
|
this.ensureOpen()
|
|
371
385
|
} catch (error) {
|
|
@@ -395,7 +409,7 @@ class Sandbox {
|
|
|
395
409
|
* @param {string|Buffer} content file contents
|
|
396
410
|
* @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
|
|
397
411
|
*/
|
|
398
|
-
writeFile(path, content) {
|
|
412
|
+
writeFile (path, content) {
|
|
399
413
|
try {
|
|
400
414
|
this.ensureOpen()
|
|
401
415
|
} catch (error) {
|
|
@@ -428,7 +442,7 @@ class Sandbox {
|
|
|
428
442
|
* @param {string} path directory path inside the sandbox
|
|
429
443
|
* @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
|
|
430
444
|
*/
|
|
431
|
-
listFiles(path) {
|
|
445
|
+
listFiles (path) {
|
|
432
446
|
try {
|
|
433
447
|
this.ensureOpen()
|
|
434
448
|
} catch (error) {
|
|
@@ -479,7 +493,7 @@ class Sandbox {
|
|
|
479
493
|
if (url === undefined) {
|
|
480
494
|
throw new SandboxPortNotProvisionedError(
|
|
481
495
|
`Port ${port} was not provisioned for sandbox '${this.id}'. ` +
|
|
482
|
-
|
|
496
|
+
'Declare it in create({ ports: [...] }) to get a preview URL.'
|
|
483
497
|
)
|
|
484
498
|
}
|
|
485
499
|
|
|
@@ -491,9 +505,9 @@ class Sandbox {
|
|
|
491
505
|
*
|
|
492
506
|
* @returns {Promise<object>} destroy response payload
|
|
493
507
|
*/
|
|
494
|
-
async destroy() {
|
|
508
|
+
async destroy () {
|
|
495
509
|
const base = this.managementEndpoint || this.apiHost
|
|
496
|
-
const url = `${base}/api/v1/namespaces/${this.namespace}/
|
|
510
|
+
const url = `${base}/api/v1/namespaces/${this.namespace}/sandboxes/${this.id}`
|
|
497
511
|
this.ws?.beginIntentionalClose()
|
|
498
512
|
|
|
499
513
|
let payload
|
|
@@ -516,33 +530,33 @@ class Sandbox {
|
|
|
516
530
|
/**
|
|
517
531
|
* Schedules a timeout that kills `execId` and rejects its pending entry.
|
|
518
532
|
*
|
|
519
|
-
* @param {string} execId
|
|
533
|
+
* @param {string} execId exec identifier to reject when the timeout fires
|
|
520
534
|
* @param {string} command human-readable command string (for the error message)
|
|
521
535
|
* @param {number} ms timeout in milliseconds
|
|
522
536
|
* @returns {ReturnType<setTimeout>} the timer handle (stored on the entry for cancellation)
|
|
523
537
|
*/
|
|
524
|
-
scheduleTimeout(execId, command, ms) {
|
|
538
|
+
scheduleTimeout (execId, command, ms) {
|
|
525
539
|
return setTimeout(() => {
|
|
526
540
|
try {
|
|
527
541
|
this.kill(execId)
|
|
528
542
|
} catch (_) {
|
|
529
543
|
// ignore errors
|
|
530
544
|
}
|
|
531
|
-
|
|
545
|
+
|
|
532
546
|
this.ws.rejectExec(execId, new SandboxTimeoutError(
|
|
533
547
|
`Command '${command}' exceeded timeout of ${ms}ms`
|
|
534
548
|
))
|
|
535
549
|
}, ms)
|
|
536
550
|
}
|
|
537
551
|
|
|
538
|
-
ensureOpen() {
|
|
552
|
+
ensureOpen () {
|
|
539
553
|
if (!this.ws) {
|
|
540
554
|
throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
|
|
541
555
|
}
|
|
542
556
|
this.ws.ensureOpen()
|
|
543
557
|
}
|
|
544
558
|
|
|
545
|
-
sendFrame(frame) {
|
|
559
|
+
sendFrame (frame) {
|
|
546
560
|
this.ws.send(frame)
|
|
547
561
|
}
|
|
548
562
|
}
|
|
@@ -556,7 +570,7 @@ class Sandbox {
|
|
|
556
570
|
* every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
|
|
557
571
|
*
|
|
558
572
|
* @param {object|null|undefined} raw the `previewUrls` field from the API response
|
|
559
|
-
* @returns {Map<number, string>}
|
|
573
|
+
* @returns {Map<number, string>} preview URLs keyed by port number
|
|
560
574
|
*/
|
|
561
575
|
function parsePreviewUrls (raw) {
|
|
562
576
|
if (!raw || typeof raw !== 'object') {
|
package/src/utils.js
CHANGED
|
@@ -18,10 +18,23 @@ const {
|
|
|
18
18
|
} = require('./errors')
|
|
19
19
|
const { SANDBOX_SIZES } = require('./constants')
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Builds a Basic authorization header from a Runtime API key.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} apiKey Runtime API key
|
|
25
|
+
* @returns {string} Basic authorization header value
|
|
26
|
+
*/
|
|
21
27
|
function buildAuthorizationHeader (apiKey) {
|
|
22
28
|
return `Basic ${Buffer.from(apiKey).toString('base64')}`
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Maps sandbox management API status codes to SDK error classes.
|
|
33
|
+
*
|
|
34
|
+
* @param {number} status HTTP response status
|
|
35
|
+
* @param {string} message error message
|
|
36
|
+
* @returns {SandboxClientError} SDK error matching the response status
|
|
37
|
+
*/
|
|
25
38
|
function createSandboxHttpError (status, message) {
|
|
26
39
|
if (status === 401 || status === 403) {
|
|
27
40
|
return new SandboxUnauthorizedError(message)
|
|
@@ -35,6 +48,12 @@ function createSandboxHttpError (status, message) {
|
|
|
35
48
|
return new SandboxClientError(message)
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Ensures the Runtime API host has a URL scheme.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} host Runtime API host
|
|
55
|
+
* @returns {string} API host with a URL scheme
|
|
56
|
+
*/
|
|
38
57
|
function normalizeApiHost (host) {
|
|
39
58
|
if (!host.match(/^https?:\/\//)) {
|
|
40
59
|
return `https://${host}`
|
|
@@ -42,10 +61,18 @@ function normalizeApiHost (host) {
|
|
|
42
61
|
return host
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Builds the sandbox execution WebSocket endpoint from Runtime API details.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} apiHost Runtime API host
|
|
68
|
+
* @param {string} namespace Runtime namespace
|
|
69
|
+
* @param {string} sandboxId sandbox id
|
|
70
|
+
* @returns {string} sandbox WebSocket endpoint
|
|
71
|
+
*/
|
|
45
72
|
function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
|
|
46
73
|
const url = new URL(apiHost)
|
|
47
74
|
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
|
|
48
|
-
url.pathname = `/
|
|
75
|
+
url.pathname = `/api/v1/namespaces/${namespace}/sandboxes/${sandboxId}/exec`
|
|
49
76
|
url.search = ''
|
|
50
77
|
return url.toString()
|
|
51
78
|
}
|
|
@@ -55,7 +82,7 @@ function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
|
|
|
55
82
|
* explicit overrides. Throws `SandboxInitializationError` for missing values.
|
|
56
83
|
*
|
|
57
84
|
* @param {object} overrides explicit credential overrides
|
|
58
|
-
* @returns {{ apiHost: string, namespace: string, apiKey: string }}
|
|
85
|
+
* @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
|
|
59
86
|
*/
|
|
60
87
|
function resolveCredentials (overrides = {}) {
|
|
61
88
|
const apiHost = overrides.apiHost || process.env.__OW_API_HOST
|
package/src/ws.js
CHANGED
|
@@ -25,7 +25,7 @@ const {
|
|
|
25
25
|
*/
|
|
26
26
|
class SandboxSocket {
|
|
27
27
|
/**
|
|
28
|
-
* @param {object} options
|
|
28
|
+
* @param {object} options socket options
|
|
29
29
|
* @param {string} options.id sandbox id
|
|
30
30
|
* @param {string} options.endpoint WebSocket endpoint URL
|
|
31
31
|
* @param {string} options.token authentication token
|
|
@@ -144,7 +144,7 @@ class SandboxSocket {
|
|
|
144
144
|
/**
|
|
145
145
|
* Serialises `frame` and sends it over the socket.
|
|
146
146
|
*
|
|
147
|
-
* @param {object} frame
|
|
147
|
+
* @param {object} frame WebSocket frame to send
|
|
148
148
|
*/
|
|
149
149
|
send (frame) {
|
|
150
150
|
this.socket.send(JSON.stringify(frame))
|
|
@@ -167,7 +167,7 @@ class SandboxSocket {
|
|
|
167
167
|
/**
|
|
168
168
|
* Closes the underlying socket.
|
|
169
169
|
*
|
|
170
|
-
* @param {object} [options]
|
|
170
|
+
* @param {object} [options] close options
|
|
171
171
|
* @param {boolean} [options.intentional] whether pending work should be drained without error
|
|
172
172
|
*/
|
|
173
173
|
close ({ intentional = false } = {}) {
|
|
@@ -180,22 +180,22 @@ class SandboxSocket {
|
|
|
180
180
|
// ------------------------------------------------------------------
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* Registers a pending exec, sends the frame, and returns the promises
|
|
183
|
+
* Registers a pending exec, sends the frame, and returns the promises
|
|
184
184
|
* that will be settled by subsequent result or ack frames (for detached)
|
|
185
185
|
*
|
|
186
|
-
* @param {string} execId
|
|
186
|
+
* @param {string} execId execution id for the pending command
|
|
187
187
|
* @param {object} frame the frame to send (must include `type` and `execId`)
|
|
188
|
-
* @param {{ detached: boolean, onOutput?: Function }} options
|
|
189
|
-
* @returns {{ ackPromise: Promise, waitPromise: Promise|null }}
|
|
188
|
+
* @param {{ detached: boolean, onOutput?: Function }} options pending exec options
|
|
189
|
+
* @returns {{ ackPromise: Promise, waitPromise: Promise|null }} promises for ack and optional completion
|
|
190
190
|
*/
|
|
191
191
|
sendExec (execId, frame, { detached, onOutput }) {
|
|
192
192
|
let waitResolve, waitReject, waitPromise
|
|
193
193
|
if (detached) {
|
|
194
|
-
waitPromise = new Promise((
|
|
194
|
+
waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
let resolve, reject
|
|
198
|
-
const ackPromise = new Promise((
|
|
198
|
+
const ackPromise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject })
|
|
199
199
|
|
|
200
200
|
this.pendingExecs.set(execId, {
|
|
201
201
|
resolve,
|
|
@@ -225,8 +225,8 @@ class SandboxSocket {
|
|
|
225
225
|
/**
|
|
226
226
|
* Stores a timer handle on a pending exec entry so it can be cleared on completion.
|
|
227
227
|
*
|
|
228
|
-
* @param {string} execId
|
|
229
|
-
* @param {ReturnType<setTimeout>} handle
|
|
228
|
+
* @param {string} execId execution id for the pending command
|
|
229
|
+
* @param {ReturnType<setTimeout>} handle timeout handle to store
|
|
230
230
|
*/
|
|
231
231
|
setExecTimeout (execId, handle) {
|
|
232
232
|
const entry = this.pendingExecs.get(execId)
|
|
@@ -236,8 +236,8 @@ class SandboxSocket {
|
|
|
236
236
|
/**
|
|
237
237
|
* Rejects and removes a pending exec, clearing its timeout.
|
|
238
238
|
*
|
|
239
|
-
* @param {string} execId
|
|
240
|
-
* @param {Error} error
|
|
239
|
+
* @param {string} execId execution id for the pending command
|
|
240
|
+
* @param {Error} error error used to reject the command
|
|
241
241
|
*/
|
|
242
242
|
rejectExec (execId, error) {
|
|
243
243
|
const pending = this.pendingExecs.get(execId)
|
|
@@ -256,8 +256,8 @@ class SandboxSocket {
|
|
|
256
256
|
/**
|
|
257
257
|
* Rejects and removes a pending file operation.
|
|
258
258
|
*
|
|
259
|
-
* @param {string} execId
|
|
260
|
-
* @param {Error} error
|
|
259
|
+
* @param {string} execId file operation id
|
|
260
|
+
* @param {Error} error error used to reject the file operation
|
|
261
261
|
*/
|
|
262
262
|
rejectFileOp (execId, error) {
|
|
263
263
|
const pending = this.pendingFileOps.get(execId)
|
|
@@ -269,7 +269,7 @@ class SandboxSocket {
|
|
|
269
269
|
/**
|
|
270
270
|
* Resolves and removes a pending exec during an intentional sandbox shutdown.
|
|
271
271
|
*
|
|
272
|
-
* @param {string} execId
|
|
272
|
+
* @param {string} execId execution id for the pending command
|
|
273
273
|
*/
|
|
274
274
|
resolveExecOnIntentionalClose (execId) {
|
|
275
275
|
const pending = this.pendingExecs.get(execId)
|
|
@@ -469,7 +469,7 @@ class SandboxSocket {
|
|
|
469
469
|
/**
|
|
470
470
|
* Handles exec.info (response to exec.get) and error frames routed from handleMessage.
|
|
471
471
|
*
|
|
472
|
-
* @param {object} frame
|
|
472
|
+
* @param {object} frame exec.get response or error frame
|
|
473
473
|
*/
|
|
474
474
|
handleGetFrame (frame) {
|
|
475
475
|
const pending = this.pendingGetOps.get(frame.execId)
|
|
@@ -517,12 +517,12 @@ class SandboxSocket {
|
|
|
517
517
|
*
|
|
518
518
|
* @param {object} frame exec.info frame
|
|
519
519
|
* @param {object} pending entry from pendingGetOps
|
|
520
|
-
* @returns {Promise}
|
|
520
|
+
* @returns {Promise} wait promise for the running command
|
|
521
521
|
*/
|
|
522
522
|
resolveExecEntry (frame, pending) {
|
|
523
523
|
const existingExec = this.pendingExecs.get(frame.execId)
|
|
524
524
|
if (existingExec) {
|
|
525
|
-
this.mergeOnOutputCallback(existingExec, pending.onOutput)
|
|
525
|
+
existingExec.onOutput = this.mergeOnOutputCallback(existingExec.onOutput, pending.onOutput)
|
|
526
526
|
return existingExec._waitPromise
|
|
527
527
|
}
|
|
528
528
|
return this.registerReattachedExec(frame, pending.onOutput)
|
|
@@ -531,13 +531,13 @@ class SandboxSocket {
|
|
|
531
531
|
/**
|
|
532
532
|
* Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
|
|
533
533
|
*
|
|
534
|
-
* @param {
|
|
534
|
+
* @param {Function|null|undefined} prev existing callback
|
|
535
535
|
* @param {Function|undefined} onOutput new callback to add
|
|
536
|
+
* @returns {Function|null|undefined} merged callback
|
|
536
537
|
*/
|
|
537
|
-
mergeOnOutputCallback (
|
|
538
|
-
if (!onOutput) return
|
|
539
|
-
|
|
540
|
-
existingExec.onOutput = (data, stream) => {
|
|
538
|
+
mergeOnOutputCallback (prev, onOutput) {
|
|
539
|
+
if (!onOutput) return prev
|
|
540
|
+
return (data, stream) => {
|
|
541
541
|
if (prev) prev(data, stream)
|
|
542
542
|
onOutput(data, stream)
|
|
543
543
|
}
|
|
@@ -549,11 +549,11 @@ class SandboxSocket {
|
|
|
549
549
|
*
|
|
550
550
|
* @param {object} frame exec.info frame
|
|
551
551
|
* @param {Function|undefined} onOutput output callback
|
|
552
|
-
* @returns {Promise}
|
|
552
|
+
* @returns {Promise} wait promise for the reattached command
|
|
553
553
|
*/
|
|
554
554
|
registerReattachedExec (frame, onOutput) {
|
|
555
555
|
let waitResolve, waitReject
|
|
556
|
-
const waitPromise = new Promise((
|
|
556
|
+
const waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
|
|
557
557
|
this.pendingExecs.set(frame.execId, {
|
|
558
558
|
resolve: () => {},
|
|
559
559
|
reject: () => {},
|
|
@@ -576,7 +576,7 @@ class SandboxSocket {
|
|
|
576
576
|
* @param {object} frame exec.info frame
|
|
577
577
|
* @param {Promise} waitPromise resolves when the process exits
|
|
578
578
|
* @param {object} sandbox Sandbox instance for delegating control operations
|
|
579
|
-
* @returns {object}
|
|
579
|
+
* @returns {object} command handle with wait and control helpers
|
|
580
580
|
*/
|
|
581
581
|
buildCommandObject (frame, waitPromise, sandbox) {
|
|
582
582
|
const { execId } = frame
|
package/test/Sandbox.test.js
CHANGED
|
@@ -67,7 +67,7 @@ class FakeWebSocket extends EventEmitter {
|
|
|
67
67
|
|
|
68
68
|
const BASE_OPTIONS = {
|
|
69
69
|
id: 'sb-test',
|
|
70
|
-
endpoint: 'wss://runtime.example.net/
|
|
70
|
+
endpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-test/exec',
|
|
71
71
|
status: 'ready',
|
|
72
72
|
namespace: 'ns',
|
|
73
73
|
apiHost: 'https://runtime.example.net',
|
|
@@ -80,6 +80,9 @@ const BASE_OPTIONS = {
|
|
|
80
80
|
|
|
81
81
|
let sockets
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
*
|
|
85
|
+
*/
|
|
83
86
|
function setupWebSocket () {
|
|
84
87
|
sockets = []
|
|
85
88
|
WebSocket.OPEN = 1
|
|
@@ -90,6 +93,12 @@ function setupWebSocket () {
|
|
|
90
93
|
})
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Builds a connected sandbox backed by the fake WebSocket.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} opts sandbox option overrides
|
|
100
|
+
* @returns {Promise<Sandbox>} connected sandbox instance
|
|
101
|
+
*/
|
|
93
102
|
async function buildConnectedSandbox (opts = {}) {
|
|
94
103
|
const sandbox = new Sandbox({ ...BASE_OPTIONS, ...opts })
|
|
95
104
|
const connectPromise = sandbox.connect()
|
|
@@ -200,7 +209,7 @@ describe('Sandbox', () => {
|
|
|
200
209
|
ok: true,
|
|
201
210
|
json: () => Promise.resolve({
|
|
202
211
|
sandboxId: 'sb-new',
|
|
203
|
-
wsEndpoint: 'wss://runtime.example.net/
|
|
212
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-new/exec',
|
|
204
213
|
status: 'ready',
|
|
205
214
|
token: 'tok-new',
|
|
206
215
|
maxLifetime: 3600,
|
|
@@ -231,7 +240,7 @@ describe('Sandbox', () => {
|
|
|
231
240
|
[3000, 'https://sb-new-3000.preview.example.net']
|
|
232
241
|
]))
|
|
233
242
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
234
|
-
'https://runtime.example.net/api/v1/namespaces/ns/
|
|
243
|
+
'https://runtime.example.net/api/v1/namespaces/ns/sandboxes',
|
|
235
244
|
expect.objectContaining({ method: 'POST' })
|
|
236
245
|
)
|
|
237
246
|
})
|
|
@@ -241,7 +250,7 @@ describe('Sandbox', () => {
|
|
|
241
250
|
ok: true,
|
|
242
251
|
json: () => Promise.resolve({
|
|
243
252
|
sandboxId: 'sb-pol',
|
|
244
|
-
wsEndpoint: 'wss://runtime.example.net/
|
|
253
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-pol/exec',
|
|
245
254
|
status: 'ready',
|
|
246
255
|
token: 'tok-pol',
|
|
247
256
|
maxLifetime: 3600
|
|
@@ -272,7 +281,7 @@ describe('Sandbox', () => {
|
|
|
272
281
|
ok: true,
|
|
273
282
|
json: () => Promise.resolve({
|
|
274
283
|
sandboxId: 'sb-ports',
|
|
275
|
-
wsEndpoint: 'wss://runtime.example.net/
|
|
284
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-ports/exec',
|
|
276
285
|
status: 'ready',
|
|
277
286
|
token: 'tok-ports',
|
|
278
287
|
maxLifetime: 3600,
|
|
@@ -312,7 +321,7 @@ describe('Sandbox', () => {
|
|
|
312
321
|
ok: true,
|
|
313
322
|
json: () => Promise.resolve({
|
|
314
323
|
sandboxId: 'sb-env',
|
|
315
|
-
wsEndpoint: 'wss://runtime.example.net/
|
|
324
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-env/exec',
|
|
316
325
|
status: 'ready',
|
|
317
326
|
token: 'tok-env',
|
|
318
327
|
maxLifetime: 3600
|
|
@@ -333,6 +342,98 @@ describe('Sandbox', () => {
|
|
|
333
342
|
await expect(Sandbox.create({ name: 'no-creds' })).rejects.toThrow(SandboxInitializationError)
|
|
334
343
|
})
|
|
335
344
|
|
|
345
|
+
test('sends default idleTimeout 900 and maxLifetime 3600 when not specified', async () => {
|
|
346
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
347
|
+
ok: true,
|
|
348
|
+
json: () => Promise.resolve({
|
|
349
|
+
sandboxId: 'sb-defaults',
|
|
350
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-defaults/exec',
|
|
351
|
+
status: 'ready',
|
|
352
|
+
token: 'tok-defaults',
|
|
353
|
+
idleTimeout: 900,
|
|
354
|
+
maxLifetime: 3600
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
global.fetch = mockFetch
|
|
358
|
+
|
|
359
|
+
const createPromise = Sandbox.create({
|
|
360
|
+
name: 'defaults-sandbox',
|
|
361
|
+
apiHost: 'https://runtime.example.net',
|
|
362
|
+
namespace: 'ns',
|
|
363
|
+
auth: 'uuid:key'
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
367
|
+
sockets[0].open()
|
|
368
|
+
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-defaults' })
|
|
369
|
+
await createPromise
|
|
370
|
+
|
|
371
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
|
372
|
+
expect(body.idleTimeout).toBe(900)
|
|
373
|
+
expect(body.maxLifetime).toBe(3600)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('forwards explicit idleTimeout in the request body', async () => {
|
|
377
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
378
|
+
ok: true,
|
|
379
|
+
json: () => Promise.resolve({
|
|
380
|
+
sandboxId: 'sb-idle',
|
|
381
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-idle/exec',
|
|
382
|
+
status: 'ready',
|
|
383
|
+
token: 'tok-idle',
|
|
384
|
+
idleTimeout: 1800,
|
|
385
|
+
maxLifetime: 3600
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
global.fetch = mockFetch
|
|
389
|
+
|
|
390
|
+
const createPromise = Sandbox.create({
|
|
391
|
+
name: 'idle-sandbox',
|
|
392
|
+
apiHost: 'https://runtime.example.net',
|
|
393
|
+
namespace: 'ns',
|
|
394
|
+
auth: 'uuid:key',
|
|
395
|
+
idleTimeout: 1800
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
399
|
+
sockets[0].open()
|
|
400
|
+
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-idle' })
|
|
401
|
+
await createPromise
|
|
402
|
+
|
|
403
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
|
404
|
+
expect(body.idleTimeout).toBe(1800)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('stores idleTimeout from the create response on the instance', async () => {
|
|
408
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
409
|
+
ok: true,
|
|
410
|
+
json: () => Promise.resolve({
|
|
411
|
+
sandboxId: 'sb-store',
|
|
412
|
+
wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-store/exec',
|
|
413
|
+
status: 'ready',
|
|
414
|
+
token: 'tok-store',
|
|
415
|
+
idleTimeout: 1800,
|
|
416
|
+
maxLifetime: 3600
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
global.fetch = mockFetch
|
|
420
|
+
|
|
421
|
+
const createPromise = Sandbox.create({
|
|
422
|
+
name: 'store-sandbox',
|
|
423
|
+
apiHost: 'https://runtime.example.net',
|
|
424
|
+
namespace: 'ns',
|
|
425
|
+
auth: 'uuid:key',
|
|
426
|
+
idleTimeout: 1800
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
430
|
+
sockets[0].open()
|
|
431
|
+
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-store' })
|
|
432
|
+
const sandbox = await createPromise
|
|
433
|
+
|
|
434
|
+
expect(sandbox.idleTimeout).toBe(1800)
|
|
435
|
+
})
|
|
436
|
+
|
|
336
437
|
test('falls back to buildWebSocketEndpoint when wsEndpoint absent', async () => {
|
|
337
438
|
const mockFetch = jest.fn().mockResolvedValue({
|
|
338
439
|
ok: true,
|
|
@@ -355,7 +456,7 @@ describe('Sandbox', () => {
|
|
|
355
456
|
await new Promise(resolve => setImmediate(resolve))
|
|
356
457
|
sockets[0].open()
|
|
357
458
|
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-noep' })
|
|
358
|
-
|
|
459
|
+
await createPromise
|
|
359
460
|
|
|
360
461
|
expect(sockets[0].url).toContain('wss://')
|
|
361
462
|
expect(sockets[0].url).toContain('sb-noep')
|
|
@@ -385,6 +486,26 @@ describe('Sandbox', () => {
|
|
|
385
486
|
expect(sandbox.cluster).toBe('cluster-b')
|
|
386
487
|
})
|
|
387
488
|
|
|
489
|
+
test('stores idleTimeout from the get response on the instance', async () => {
|
|
490
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
491
|
+
ok: true,
|
|
492
|
+
json: () => Promise.resolve({
|
|
493
|
+
sandboxId: 'sb-get-idle',
|
|
494
|
+
status: 'running',
|
|
495
|
+
idleTimeout: 1200,
|
|
496
|
+
maxLifetime: 3600
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
const sandbox = await Sandbox.get('sb-get-idle', {
|
|
501
|
+
apiHost: 'https://runtime.example.net',
|
|
502
|
+
namespace: 'ns',
|
|
503
|
+
auth: 'uuid:key'
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
expect(sandbox.idleTimeout).toBe(1200)
|
|
507
|
+
})
|
|
508
|
+
|
|
388
509
|
test('throws SandboxNotFoundError on 404', async () => {
|
|
389
510
|
global.fetch = jest.fn().mockResolvedValue({
|
|
390
511
|
ok: false,
|
|
@@ -408,6 +529,38 @@ describe('Sandbox', () => {
|
|
|
408
529
|
Sandbox.get('sb-x', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'bad' })
|
|
409
530
|
).rejects.toThrow(SandboxUnauthorizedError)
|
|
410
531
|
})
|
|
532
|
+
|
|
533
|
+
test('throws SandboxTimeoutError on 504', async () => {
|
|
534
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
535
|
+
ok: false,
|
|
536
|
+
status: 504,
|
|
537
|
+
text: () => Promise.resolve('gateway timeout')
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
await expect(
|
|
541
|
+
Sandbox.get('sb-slow', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
|
|
542
|
+
).rejects.toThrow(SandboxTimeoutError)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('throws SandboxClientError on unexpected API status', async () => {
|
|
546
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
547
|
+
ok: false,
|
|
548
|
+
status: 500,
|
|
549
|
+
text: () => Promise.resolve('server error')
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
await expect(
|
|
553
|
+
Sandbox.get('sb-error', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
|
|
554
|
+
).rejects.toThrow(SandboxClientError)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('wraps fetch failures in SandboxClientError', async () => {
|
|
558
|
+
global.fetch = jest.fn().mockRejectedValue(new Error('network unavailable'))
|
|
559
|
+
|
|
560
|
+
await expect(
|
|
561
|
+
Sandbox.get('sb-network', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
|
|
562
|
+
).rejects.toThrow(SandboxClientError)
|
|
563
|
+
})
|
|
411
564
|
})
|
|
412
565
|
|
|
413
566
|
// -------------------------------------------------------------------------
|
|
@@ -844,7 +997,7 @@ describe('Sandbox', () => {
|
|
|
844
997
|
expect(result.status).toBe('destroyed')
|
|
845
998
|
expect(sandbox.status).toBe('destroyed')
|
|
846
999
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
847
|
-
expect.stringContaining('/
|
|
1000
|
+
expect.stringContaining('/sandboxes/sb-test'),
|
|
848
1001
|
expect.objectContaining({ method: 'DELETE' })
|
|
849
1002
|
)
|
|
850
1003
|
})
|