@adobe/aio-lib-sandbox 0.1.0-alpha.5 → 0.1.0-alpha.7

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 ADDED
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ coverage/
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
@@ -218,3 +218,24 @@ const sandbox = await Sandbox.create({
218
218
  }
219
219
  })
220
220
  ```
221
+
222
+ ## Development
223
+
224
+ Install development dependencies:
225
+
226
+ ```bash
227
+ npm install
228
+ ```
229
+
230
+ To run the same checks used by CI:
231
+
232
+ ```bash
233
+ npm test
234
+ ```
235
+
236
+ Linting is powered by ESLint:
237
+
238
+ ```bash
239
+ npm run lint
240
+ npm run lint-fix
241
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aio-lib-sandbox",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
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
- "test": "jest --ci",
27
- "lint": "node --check src/**/*.js"
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,7 +36,7 @@ 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
@@ -76,7 +76,7 @@ class Sandbox {
76
76
  * @param {object} [options.policy] network policy (e.g. egress allowlist)
77
77
  * @returns {Promise<Sandbox>} connected sandbox instance
78
78
  */
79
- static async create(options = {}) {
79
+ static async create (options = {}) {
80
80
  console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
81
81
  const creds = resolveCredentials(options)
82
82
 
@@ -93,7 +93,7 @@ class Sandbox {
93
93
  if (options.policy !== undefined) body.policy = options.policy
94
94
  if (options.ports !== undefined) body.ports = options.ports
95
95
 
96
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
96
+ const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes`
97
97
  const payload = await apiRequest('POST', url, creds.apiKey, body)
98
98
 
99
99
  const sandboxId = payload.sandboxId
@@ -131,10 +131,10 @@ class Sandbox {
131
131
  * @param {string} [options.auth] Runtime API key
132
132
  * @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
133
133
  */
134
- static async get(sandboxId, options = {}) {
134
+ static async get (sandboxId, options = {}) {
135
135
  console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
136
136
  const creds = resolveCredentials(options)
137
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox/${sandboxId}`
137
+ const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
138
138
  const payload = await apiRequest('GET', url, creds.apiKey)
139
139
 
140
140
  return new Sandbox({
@@ -157,7 +157,7 @@ class Sandbox {
157
157
  *
158
158
  * @type {object}
159
159
  */
160
- static get sizes() {
160
+ static get sizes () {
161
161
  return SANDBOX_SIZES
162
162
  }
163
163
 
@@ -165,19 +165,19 @@ class Sandbox {
165
165
  * Exposes `resolveCredentials` as a static helper (useful for testing).
166
166
  *
167
167
  * @param {object} overrides credential overrides
168
- * @returns {{ apiHost: string, namespace: string, apiKey: string }}
168
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
169
169
  */
170
- static resolveCredentials(overrides = {}) {
170
+ static resolveCredentials (overrides = {}) {
171
171
  return resolveCredentials(overrides)
172
172
  }
173
173
 
174
174
  /**
175
175
  * Exposes `normalizeSize` as a static helper (useful for testing).
176
176
  *
177
- * @param {string|object|undefined} size
178
- * @returns {string}
177
+ * @param {string|object|undefined} size sandbox size name or resource spec
178
+ * @returns {string} normalized sandbox size name
179
179
  */
180
- static normalizeSize(size) {
180
+ static normalizeSize (size) {
181
181
  return normalizeSize(size)
182
182
  }
183
183
 
@@ -190,7 +190,7 @@ class Sandbox {
190
190
  *
191
191
  * @returns {Promise<void>}
192
192
  */
193
- connect() {
193
+ connect () {
194
194
  if (!this.ws) {
195
195
  this.ws = new SandboxSocket({
196
196
  id: this.id,
@@ -215,10 +215,10 @@ class Sandbox {
215
215
  * @param {number} [options.timeout] timeout in milliseconds (foreground only)
216
216
  * @param {boolean} [options.detached] when true, run as a detached background process
217
217
  * @param {string|Buffer} [options.stdin] data to send to stdin at startup
218
- * @param {function} [options.onOutput] callback called with `(data, stream)` for each output chunk
219
- * @returns {Promise}
218
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for each output chunk
219
+ * @returns {Promise} command result, or a detached command handle when `options.detached` is true
220
220
  */
221
- exec(command, options = {}) {
221
+ exec (command, options = {}) {
222
222
  try {
223
223
  this.ensureOpen()
224
224
  } catch (error) {
@@ -238,12 +238,12 @@ class Sandbox {
238
238
  }
239
239
 
240
240
  /**
241
- * @param {string} execId
241
+ * @param {string} execId execution id to run inside the sandbox
242
242
  * @param {string} command
243
243
  * @param {object} options
244
244
  * @private
245
245
  */
246
- async sendExecFrameAndAwaitResponse(execId, command, options) {
246
+ async sendExecFrameAndAwaitResponse (execId, command, options) {
247
247
  const detached = !!options.detached
248
248
  const frame = { type: 'exec.run', execId, command, ...(detached && { detached: true }) }
249
249
 
@@ -283,10 +283,10 @@ class Sandbox {
283
283
  *
284
284
  * @param {string} execId the execId returned by the original `exec()` call
285
285
  * @param {object} [options] re-attach options
286
- * @param {function} [options.onOutput] callback called with `(data, stream)` for live output
287
- * @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>}
286
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for live output
287
+ * @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>} command handle
288
288
  */
289
- getCommand(execId, options = {}) {
289
+ getCommand (execId, options = {}) {
290
290
  try {
291
291
  this.ensureOpen()
292
292
  } catch (error) {
@@ -320,7 +320,7 @@ class Sandbox {
320
320
  * @param {string} execId execution id
321
321
  * @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
322
322
  */
323
- kill(execId, signal = 'SIGTERM') {
323
+ kill (execId, signal = 'SIGTERM') {
324
324
  this.ensureOpen()
325
325
  this.sendFrame({ type: 'exec.kill', execId, signal })
326
326
  }
@@ -332,7 +332,7 @@ class Sandbox {
332
332
  * @param {string} execId execution id from `exec()`
333
333
  * @param {string|Buffer} data data to write
334
334
  */
335
- writeStdin(execId, data) {
335
+ writeStdin (execId, data) {
336
336
  this.ensureOpen()
337
337
  const frame = { type: 'exec.input', execId }
338
338
  if (Buffer.isBuffer(data)) {
@@ -350,7 +350,7 @@ class Sandbox {
350
350
  *
351
351
  * @param {string} execId execution id from `exec()`
352
352
  */
353
- closeStdin(execId) {
353
+ closeStdin (execId) {
354
354
  this.ensureOpen()
355
355
  this.sendFrame({ type: 'exec.endInput', execId })
356
356
  }
@@ -365,7 +365,7 @@ class Sandbox {
365
365
  * @param {string} path path inside the sandbox
366
366
  * @returns {Promise<string>} file contents as a UTF-8 string
367
367
  */
368
- readFile(path) {
368
+ readFile (path) {
369
369
  try {
370
370
  this.ensureOpen()
371
371
  } catch (error) {
@@ -395,7 +395,7 @@ class Sandbox {
395
395
  * @param {string|Buffer} content file contents
396
396
  * @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
397
397
  */
398
- writeFile(path, content) {
398
+ writeFile (path, content) {
399
399
  try {
400
400
  this.ensureOpen()
401
401
  } catch (error) {
@@ -428,7 +428,7 @@ class Sandbox {
428
428
  * @param {string} path directory path inside the sandbox
429
429
  * @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
430
430
  */
431
- listFiles(path) {
431
+ listFiles (path) {
432
432
  try {
433
433
  this.ensureOpen()
434
434
  } catch (error) {
@@ -479,7 +479,7 @@ class Sandbox {
479
479
  if (url === undefined) {
480
480
  throw new SandboxPortNotProvisionedError(
481
481
  `Port ${port} was not provisioned for sandbox '${this.id}'. ` +
482
- "Declare it in create({ ports: [...] }) to get a preview URL."
482
+ 'Declare it in create({ ports: [...] }) to get a preview URL.'
483
483
  )
484
484
  }
485
485
 
@@ -491,9 +491,9 @@ class Sandbox {
491
491
  *
492
492
  * @returns {Promise<object>} destroy response payload
493
493
  */
494
- async destroy() {
494
+ async destroy () {
495
495
  const base = this.managementEndpoint || this.apiHost
496
- const url = `${base}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`
496
+ const url = `${base}/api/v1/namespaces/${this.namespace}/sandboxes/${this.id}`
497
497
  this.ws?.beginIntentionalClose()
498
498
 
499
499
  let payload
@@ -516,33 +516,33 @@ class Sandbox {
516
516
  /**
517
517
  * Schedules a timeout that kills `execId` and rejects its pending entry.
518
518
  *
519
- * @param {string} execId
519
+ * @param {string} execId exec identifier to reject when the timeout fires
520
520
  * @param {string} command human-readable command string (for the error message)
521
521
  * @param {number} ms timeout in milliseconds
522
522
  * @returns {ReturnType<setTimeout>} the timer handle (stored on the entry for cancellation)
523
523
  */
524
- scheduleTimeout(execId, command, ms) {
524
+ scheduleTimeout (execId, command, ms) {
525
525
  return setTimeout(() => {
526
526
  try {
527
527
  this.kill(execId)
528
528
  } catch (_) {
529
529
  // ignore errors
530
530
  }
531
-
531
+
532
532
  this.ws.rejectExec(execId, new SandboxTimeoutError(
533
533
  `Command '${command}' exceeded timeout of ${ms}ms`
534
534
  ))
535
535
  }, ms)
536
536
  }
537
537
 
538
- ensureOpen() {
538
+ ensureOpen () {
539
539
  if (!this.ws) {
540
540
  throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
541
541
  }
542
542
  this.ws.ensureOpen()
543
543
  }
544
544
 
545
- sendFrame(frame) {
545
+ sendFrame (frame) {
546
546
  this.ws.send(frame)
547
547
  }
548
548
  }
@@ -556,7 +556,7 @@ class Sandbox {
556
556
  * every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
557
557
  *
558
558
  * @param {object|null|undefined} raw the `previewUrls` field from the API response
559
- * @returns {Map<number, string>}
559
+ * @returns {Map<number, string>} preview URLs keyed by port number
560
560
  */
561
561
  function parsePreviewUrls (raw) {
562
562
  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 = `/ws/v1/namespaces/${namespace}/sandbox/${sandboxId}/exec`
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((res, rej) => { waitResolve = res; waitReject = rej })
194
+ waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
195
195
  }
196
196
 
197
197
  let resolve, reject
198
- const ackPromise = new Promise((res, rej) => { resolve = res; reject = rej })
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 {object} existingExec entry from pendingExecs
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 (existingExec, onOutput) {
538
- if (!onOutput) return
539
- const prev = existingExec.onOutput
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((res, rej) => { waitResolve = res; waitReject = rej })
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
@@ -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/ws/v1/namespaces/ns/sandbox/sb-test/exec',
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/ws/v1/namespaces/ns/sandbox/sb-new/exec',
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/sandbox',
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/ws/v1/namespaces/ns/sandbox/sb-pol/exec',
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/ws/v1/namespaces/ns/sandbox/sb-ports/exec',
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/ws/v1/namespaces/ns/sandbox/sb-env/exec',
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
@@ -355,7 +364,7 @@ describe('Sandbox', () => {
355
364
  await new Promise(resolve => setImmediate(resolve))
356
365
  sockets[0].open()
357
366
  sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-noep' })
358
- const sandbox = await createPromise
367
+ await createPromise
359
368
 
360
369
  expect(sockets[0].url).toContain('wss://')
361
370
  expect(sockets[0].url).toContain('sb-noep')
@@ -408,6 +417,38 @@ describe('Sandbox', () => {
408
417
  Sandbox.get('sb-x', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'bad' })
409
418
  ).rejects.toThrow(SandboxUnauthorizedError)
410
419
  })
420
+
421
+ test('throws SandboxTimeoutError on 504', async () => {
422
+ global.fetch = jest.fn().mockResolvedValue({
423
+ ok: false,
424
+ status: 504,
425
+ text: () => Promise.resolve('gateway timeout')
426
+ })
427
+
428
+ await expect(
429
+ Sandbox.get('sb-slow', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
430
+ ).rejects.toThrow(SandboxTimeoutError)
431
+ })
432
+
433
+ test('throws SandboxClientError on unexpected API status', async () => {
434
+ global.fetch = jest.fn().mockResolvedValue({
435
+ ok: false,
436
+ status: 500,
437
+ text: () => Promise.resolve('server error')
438
+ })
439
+
440
+ await expect(
441
+ Sandbox.get('sb-error', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
442
+ ).rejects.toThrow(SandboxClientError)
443
+ })
444
+
445
+ test('wraps fetch failures in SandboxClientError', async () => {
446
+ global.fetch = jest.fn().mockRejectedValue(new Error('network unavailable'))
447
+
448
+ await expect(
449
+ Sandbox.get('sb-network', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
450
+ ).rejects.toThrow(SandboxClientError)
451
+ })
411
452
  })
412
453
 
413
454
  // -------------------------------------------------------------------------
@@ -844,7 +885,7 @@ describe('Sandbox', () => {
844
885
  expect(result.status).toBe('destroyed')
845
886
  expect(sandbox.status).toBe('destroyed')
846
887
  expect(mockFetch).toHaveBeenCalledWith(
847
- expect.stringContaining('/sandbox/sb-test'),
888
+ expect.stringContaining('/sandboxes/sb-test'),
848
889
  expect.objectContaining({ method: 'DELETE' })
849
890
  )
850
891
  })