@adobe/aio-lib-sandbox 0.1.0-alpha.4 → 0.1.0-alpha.6
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/README.md +42 -4
- package/RELEASING.md +36 -0
- package/package.json +1 -1
- package/src/Sandbox.js +178 -70
- package/src/errors.js +7 -1
- package/src/index.js +8 -2
- package/src/ws.js +310 -10
- package/test/Sandbox.test.js +552 -17
- package/.github/workflows/check-npm-token.yml +0 -16
package/test/Sandbox.test.js
CHANGED
|
@@ -14,8 +14,11 @@ const WebSocket = require('ws')
|
|
|
14
14
|
const Sandbox = require('../src/Sandbox')
|
|
15
15
|
const {
|
|
16
16
|
SandboxClientError,
|
|
17
|
+
SandboxCommandNotFoundError,
|
|
17
18
|
SandboxInitializationError,
|
|
18
19
|
SandboxNotFoundError,
|
|
20
|
+
SandboxPortNotProvisionedError,
|
|
21
|
+
SandboxInvalidPortError,
|
|
19
22
|
SandboxTimeoutError,
|
|
20
23
|
SandboxUnauthorizedError,
|
|
21
24
|
SandboxWebSocketError
|
|
@@ -200,7 +203,10 @@ describe('Sandbox', () => {
|
|
|
200
203
|
wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-new/exec',
|
|
201
204
|
status: 'ready',
|
|
202
205
|
token: 'tok-new',
|
|
203
|
-
maxLifetime: 3600
|
|
206
|
+
maxLifetime: 3600,
|
|
207
|
+
previewUrls: {
|
|
208
|
+
3000: 'https://sb-new-3000.preview.example.net'
|
|
209
|
+
}
|
|
204
210
|
})
|
|
205
211
|
})
|
|
206
212
|
global.fetch = mockFetch
|
|
@@ -221,6 +227,9 @@ describe('Sandbox', () => {
|
|
|
221
227
|
|
|
222
228
|
expect(sandbox.id).toBe('sb-new')
|
|
223
229
|
expect(sandbox.status).toBe('ready')
|
|
230
|
+
expect(sandbox.previewUrls).toEqual(new Map([
|
|
231
|
+
[3000, 'https://sb-new-3000.preview.example.net']
|
|
232
|
+
]))
|
|
224
233
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
225
234
|
'https://runtime.example.net/api/v1/namespaces/ns/sandbox',
|
|
226
235
|
expect.objectContaining({ method: 'POST' })
|
|
@@ -258,6 +267,42 @@ describe('Sandbox', () => {
|
|
|
258
267
|
expect(body.policy).toEqual(policy)
|
|
259
268
|
})
|
|
260
269
|
|
|
270
|
+
test('forwards ports and populates previewUrls from the response', async () => {
|
|
271
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
272
|
+
ok: true,
|
|
273
|
+
json: () => Promise.resolve({
|
|
274
|
+
sandboxId: 'sb-ports',
|
|
275
|
+
wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-ports/exec',
|
|
276
|
+
status: 'ready',
|
|
277
|
+
token: 'tok-ports',
|
|
278
|
+
maxLifetime: 3600,
|
|
279
|
+
previewUrls: {
|
|
280
|
+
3000: 'https://sb-ports-3000.preview.example.net',
|
|
281
|
+
8080: 'https://sb-ports-8080.preview.example.net'
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
global.fetch = mockFetch
|
|
286
|
+
|
|
287
|
+
const createPromise = Sandbox.create({
|
|
288
|
+
name: 'ports-sandbox',
|
|
289
|
+
apiHost: 'https://runtime.example.net',
|
|
290
|
+
namespace: 'ns',
|
|
291
|
+
auth: 'uuid:key',
|
|
292
|
+
ports: [3000, 8080]
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
296
|
+
sockets[0].open()
|
|
297
|
+
sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-ports' })
|
|
298
|
+
const sandbox = await createPromise
|
|
299
|
+
|
|
300
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
|
301
|
+
expect(body.ports).toEqual([3000, 8080])
|
|
302
|
+
expect(sandbox.getUrl(3000)).toBe('https://sb-ports-3000.preview.example.net')
|
|
303
|
+
expect(sandbox.getUrl(8080)).toBe('https://sb-ports-8080.preview.example.net')
|
|
304
|
+
})
|
|
305
|
+
|
|
261
306
|
test('reads credentials from env vars', async () => {
|
|
262
307
|
process.env.__OW_API_HOST = 'https://runtime.example.net'
|
|
263
308
|
process.env.__OW_NAMESPACE = 'ns'
|
|
@@ -385,6 +430,17 @@ describe('Sandbox', () => {
|
|
|
385
430
|
expect(sockets).toHaveLength(1)
|
|
386
431
|
})
|
|
387
432
|
|
|
433
|
+
test('returns the same in-flight promise when called again before auth completes', async () => {
|
|
434
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
435
|
+
const p1 = sandbox.connect()
|
|
436
|
+
const p2 = sandbox.connect()
|
|
437
|
+
expect(p1).toBe(p2)
|
|
438
|
+
sockets[0].open()
|
|
439
|
+
sockets[0].message({ type: 'auth.ok', sandboxId: BASE_OPTIONS.id })
|
|
440
|
+
await Promise.all([p1, p2])
|
|
441
|
+
expect(sockets).toHaveLength(1)
|
|
442
|
+
})
|
|
443
|
+
|
|
388
444
|
test('rejects on auth close code 4001 with SandboxUnauthorizedError', async () => {
|
|
389
445
|
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
390
446
|
const p = sandbox.connect()
|
|
@@ -400,6 +456,37 @@ describe('Sandbox', () => {
|
|
|
400
456
|
sockets[0].closeWith(1006)
|
|
401
457
|
await expect(p).rejects.toThrow(SandboxWebSocketError)
|
|
402
458
|
})
|
|
459
|
+
|
|
460
|
+
test('rejects with SandboxWebSocketError on socket error event during connect', async () => {
|
|
461
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
462
|
+
const p = sandbox.connect()
|
|
463
|
+
sockets[0].emit('error', new Error('ECONNREFUSED'))
|
|
464
|
+
await expect(p).rejects.toThrow(SandboxWebSocketError)
|
|
465
|
+
await expect(p).rejects.toThrow('ECONNREFUSED')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('rejects when auth frame cannot be sent after open', async () => {
|
|
469
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
470
|
+
const p = sandbox.connect()
|
|
471
|
+
sockets[0].send = () => { throw new Error('broken auth send') }
|
|
472
|
+
|
|
473
|
+
sockets[0].open()
|
|
474
|
+
|
|
475
|
+
await expect(p).rejects.toThrow(SandboxWebSocketError)
|
|
476
|
+
await expect(p).rejects.toThrow('broken auth send')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('resolves in-flight connect when socket is intentionally closed', async () => {
|
|
480
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
481
|
+
const p = sandbox.connect()
|
|
482
|
+
const [routeClose] = sockets[0].listeners('close')
|
|
483
|
+
sockets[0].off('close', routeClose)
|
|
484
|
+
|
|
485
|
+
sandbox.ws.beginIntentionalClose()
|
|
486
|
+
sockets[0].closeWith(1000)
|
|
487
|
+
|
|
488
|
+
await expect(p).resolves.toBeUndefined()
|
|
489
|
+
})
|
|
403
490
|
})
|
|
404
491
|
|
|
405
492
|
// -------------------------------------------------------------------------
|
|
@@ -503,6 +590,18 @@ describe('Sandbox', () => {
|
|
|
503
590
|
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
504
591
|
await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
|
|
505
592
|
})
|
|
593
|
+
|
|
594
|
+
test('rejects exec after socket has closed (ws.ensureOpen path)', async () => {
|
|
595
|
+
const sandbox = await buildConnectedSandbox()
|
|
596
|
+
sockets[0].closeWith(1006)
|
|
597
|
+
await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('rejects with SandboxWebSocketError when socket.send throws during exec', async () => {
|
|
601
|
+
const sandbox = await buildConnectedSandbox()
|
|
602
|
+
sockets[0].send = () => { throw new Error('broken pipe') }
|
|
603
|
+
await expect(sandbox.exec('cmd')).rejects.toThrow(SandboxWebSocketError)
|
|
604
|
+
})
|
|
506
605
|
})
|
|
507
606
|
|
|
508
607
|
// -------------------------------------------------------------------------
|
|
@@ -592,6 +691,18 @@ describe('Sandbox', () => {
|
|
|
592
691
|
|
|
593
692
|
await expect(filePromise).rejects.toThrow(SandboxClientError)
|
|
594
693
|
})
|
|
694
|
+
|
|
695
|
+
test('rejects when socket is not open', async () => {
|
|
696
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
697
|
+
await expect(sandbox.readFile('/file.txt')).rejects.toThrow(SandboxWebSocketError)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test('rejects when socket.send throws', async () => {
|
|
701
|
+
const sandbox = await buildConnectedSandbox()
|
|
702
|
+
sockets[0].send = () => { throw new Error('broken pipe') }
|
|
703
|
+
|
|
704
|
+
await expect(sandbox.readFile('/file.txt')).rejects.toThrow(SandboxWebSocketError)
|
|
705
|
+
})
|
|
595
706
|
})
|
|
596
707
|
|
|
597
708
|
describe('writeFile()', () => {
|
|
@@ -619,6 +730,18 @@ describe('Sandbox', () => {
|
|
|
619
730
|
|
|
620
731
|
await expect(writePromise).rejects.toThrow(SandboxClientError)
|
|
621
732
|
})
|
|
733
|
+
|
|
734
|
+
test('rejects when socket is not open', async () => {
|
|
735
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
736
|
+
await expect(sandbox.writeFile('/file.txt', 'content')).rejects.toThrow(SandboxWebSocketError)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
test('rejects when socket.send throws', async () => {
|
|
740
|
+
const sandbox = await buildConnectedSandbox()
|
|
741
|
+
sockets[0].send = () => { throw new Error('broken pipe') }
|
|
742
|
+
|
|
743
|
+
await expect(sandbox.writeFile('/file.txt', 'content')).rejects.toThrow(SandboxWebSocketError)
|
|
744
|
+
})
|
|
622
745
|
})
|
|
623
746
|
|
|
624
747
|
describe('listFiles()', () => {
|
|
@@ -647,6 +770,18 @@ describe('Sandbox', () => {
|
|
|
647
770
|
|
|
648
771
|
expect(await listPromise).toEqual([])
|
|
649
772
|
})
|
|
773
|
+
|
|
774
|
+
test('rejects when socket is not open', async () => {
|
|
775
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
776
|
+
await expect(sandbox.listFiles('/workspace')).rejects.toThrow(SandboxWebSocketError)
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
test('rejects when socket.send throws', async () => {
|
|
780
|
+
const sandbox = await buildConnectedSandbox()
|
|
781
|
+
sockets[0].send = () => { throw new Error('broken pipe') }
|
|
782
|
+
|
|
783
|
+
await expect(sandbox.listFiles('/workspace')).rejects.toThrow(SandboxWebSocketError)
|
|
784
|
+
})
|
|
650
785
|
})
|
|
651
786
|
|
|
652
787
|
// -------------------------------------------------------------------------
|
|
@@ -654,36 +789,40 @@ describe('Sandbox', () => {
|
|
|
654
789
|
// -------------------------------------------------------------------------
|
|
655
790
|
|
|
656
791
|
describe('getUrl()', () => {
|
|
657
|
-
test('resolves preview URL from
|
|
792
|
+
test('resolves preview URL from previewUrls map', () => {
|
|
658
793
|
const sandbox = new Sandbox({
|
|
659
794
|
...BASE_OPTIONS,
|
|
660
|
-
|
|
795
|
+
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
|
|
661
796
|
})
|
|
662
797
|
|
|
663
|
-
const url =
|
|
798
|
+
const url = sandbox.getUrl(3000)
|
|
664
799
|
expect(url).toBe('https://sb-test-3000.preview.example.net')
|
|
665
800
|
})
|
|
666
801
|
|
|
667
|
-
test('
|
|
802
|
+
test('throws SandboxPortNotProvisionedError when port was not provisioned', () => {
|
|
668
803
|
const sandbox = new Sandbox({
|
|
669
804
|
...BASE_OPTIONS,
|
|
670
|
-
|
|
805
|
+
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
|
|
671
806
|
})
|
|
672
|
-
|
|
673
|
-
const url = await sandbox.getUrl({ port: 3000, protocol: 'wss' })
|
|
674
|
-
expect(url).toBe('wss://sb-test-3000.preview.example.net')
|
|
807
|
+
expect(() => sandbox.getUrl(9999)).toThrow(SandboxPortNotProvisionedError)
|
|
675
808
|
})
|
|
676
809
|
|
|
677
|
-
test('throws
|
|
678
|
-
const sandbox = new Sandbox(
|
|
679
|
-
|
|
810
|
+
test('throws SandboxInvalidPortError for out-of-range port', () => {
|
|
811
|
+
const sandbox = new Sandbox({
|
|
812
|
+
...BASE_OPTIONS,
|
|
813
|
+
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
|
|
814
|
+
})
|
|
815
|
+
expect(() => sandbox.getUrl(0)).toThrow(SandboxInvalidPortError)
|
|
816
|
+
expect(() => sandbox.getUrl(65536)).toThrow(SandboxInvalidPortError)
|
|
680
817
|
})
|
|
681
818
|
|
|
682
|
-
test('throws
|
|
683
|
-
const sandbox = new Sandbox({
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
819
|
+
test('throws SandboxInvalidPortError for non-integer port', () => {
|
|
820
|
+
const sandbox = new Sandbox({
|
|
821
|
+
...BASE_OPTIONS,
|
|
822
|
+
previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
|
|
823
|
+
})
|
|
824
|
+
expect(() => sandbox.getUrl('abc')).toThrow(SandboxInvalidPortError)
|
|
825
|
+
expect(() => sandbox.getUrl(3000.5)).toThrow(SandboxInvalidPortError)
|
|
687
826
|
})
|
|
688
827
|
})
|
|
689
828
|
|
|
@@ -710,6 +849,91 @@ describe('Sandbox', () => {
|
|
|
710
849
|
)
|
|
711
850
|
})
|
|
712
851
|
|
|
852
|
+
test('resolves pending foreground exec when destroy closes the socket', async () => {
|
|
853
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
854
|
+
ok: true,
|
|
855
|
+
json: () => Promise.resolve({ status: 'destroyed' })
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
const sandbox = await buildConnectedSandbox()
|
|
859
|
+
const execPromise = sandbox.exec('sleep 100')
|
|
860
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
861
|
+
|
|
862
|
+
await sandbox.destroy()
|
|
863
|
+
|
|
864
|
+
await expect(execPromise).resolves.toEqual({
|
|
865
|
+
execId: runFrame.execId,
|
|
866
|
+
stdout: '',
|
|
867
|
+
stderr: '',
|
|
868
|
+
exitCode: null,
|
|
869
|
+
destroyed: true
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
test('resolves pending detached exec ack when destroy closes before exec.detached', async () => {
|
|
874
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
875
|
+
ok: true,
|
|
876
|
+
json: () => Promise.resolve({ status: 'destroyed' })
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
const sandbox = await buildConnectedSandbox()
|
|
880
|
+
const commandPromise = sandbox.exec('sleep infinity', { detached: true })
|
|
881
|
+
|
|
882
|
+
await sandbox.destroy()
|
|
883
|
+
|
|
884
|
+
const command = await commandPromise
|
|
885
|
+
expect(command).toMatchObject({
|
|
886
|
+
execId: expect.any(String),
|
|
887
|
+
pid: undefined,
|
|
888
|
+
startedAt: undefined,
|
|
889
|
+
detached: true
|
|
890
|
+
})
|
|
891
|
+
await expect(command.wait()).resolves.toEqual({
|
|
892
|
+
exitCode: null,
|
|
893
|
+
destroyed: true
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
test('resolves pending file and getCommand operations when destroy closes socket', async () => {
|
|
898
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
899
|
+
ok: true,
|
|
900
|
+
json: () => Promise.resolve({ status: 'destroyed' })
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
const sandbox = await buildConnectedSandbox()
|
|
904
|
+
const filePromise = sandbox.readFile('/workspace/file.txt')
|
|
905
|
+
const commandPromise = sandbox.getCommand('exec-running')
|
|
906
|
+
|
|
907
|
+
await sandbox.destroy()
|
|
908
|
+
|
|
909
|
+
await expect(filePromise).resolves.toBeUndefined()
|
|
910
|
+
await expect(commandPromise).resolves.toBeNull()
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
test('resolves detached wait when sandbox destroy triggers a 1005 socket close', async () => {
|
|
914
|
+
const sandbox = await buildConnectedSandbox()
|
|
915
|
+
global.fetch = jest.fn().mockImplementation(async () => {
|
|
916
|
+
sockets[0].closeWith(1005)
|
|
917
|
+
return {
|
|
918
|
+
ok: true,
|
|
919
|
+
json: () => Promise.resolve({ status: 'destroyed' })
|
|
920
|
+
}
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
const commandPromise = sandbox.exec('sleep infinity', { detached: true })
|
|
924
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
925
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1234, startedAt: 100 })
|
|
926
|
+
const command = await commandPromise
|
|
927
|
+
const waitPromise = command.wait()
|
|
928
|
+
|
|
929
|
+
await sandbox.destroy()
|
|
930
|
+
|
|
931
|
+
await expect(waitPromise).resolves.toEqual({
|
|
932
|
+
exitCode: null,
|
|
933
|
+
destroyed: true
|
|
934
|
+
})
|
|
935
|
+
})
|
|
936
|
+
|
|
713
937
|
test('throws SandboxUnauthorizedError on 403', async () => {
|
|
714
938
|
global.fetch = jest.fn().mockResolvedValue({
|
|
715
939
|
ok: false,
|
|
@@ -722,6 +946,297 @@ describe('Sandbox', () => {
|
|
|
722
946
|
})
|
|
723
947
|
})
|
|
724
948
|
|
|
949
|
+
// -------------------------------------------------------------------------
|
|
950
|
+
// Detached exec
|
|
951
|
+
// -------------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
describe('exec() with detached: true', () => {
|
|
954
|
+
test('sends exec.run with detached:true and resolves with command object on exec.detached', async () => {
|
|
955
|
+
const sandbox = await buildConnectedSandbox()
|
|
956
|
+
const chunks = []
|
|
957
|
+
|
|
958
|
+
const commandPromise = sandbox.exec('npm run dev', {
|
|
959
|
+
detached: true,
|
|
960
|
+
onOutput: (data, stream) => chunks.push({ data, stream })
|
|
961
|
+
})
|
|
962
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
963
|
+
expect(runFrame.type).toBe('exec.run')
|
|
964
|
+
expect(runFrame.detached).toBe(true)
|
|
965
|
+
|
|
966
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 9999, startedAt: 1234567890 })
|
|
967
|
+
|
|
968
|
+
const command = await commandPromise
|
|
969
|
+
expect(command.execId).toBe(runFrame.execId)
|
|
970
|
+
expect(command.pid).toBe(9999)
|
|
971
|
+
expect(command.startedAt).toBe(1234567890)
|
|
972
|
+
expect(command.detached).toBe(true)
|
|
973
|
+
expect(typeof command.wait).toBe('function')
|
|
974
|
+
expect(typeof command.kill).toBe('function')
|
|
975
|
+
expect(typeof command.writeStdin).toBe('function')
|
|
976
|
+
expect(typeof command.closeStdin).toBe('function')
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
test('command object writeStdin / closeStdin / kill delegate to sandbox', async () => {
|
|
980
|
+
const sandbox = await buildConnectedSandbox()
|
|
981
|
+
|
|
982
|
+
const commandPromise = sandbox.exec('tail -f /log', { detached: true })
|
|
983
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
984
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 42, startedAt: 1000 })
|
|
985
|
+
const command = await commandPromise
|
|
986
|
+
|
|
987
|
+
command.writeStdin('hello\n')
|
|
988
|
+
const inputFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
989
|
+
expect(inputFrame.type).toBe('exec.input')
|
|
990
|
+
expect(inputFrame.execId).toBe(runFrame.execId)
|
|
991
|
+
expect(inputFrame.data).toBe('hello\n')
|
|
992
|
+
|
|
993
|
+
command.closeStdin()
|
|
994
|
+
const endFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
995
|
+
expect(endFrame.type).toBe('exec.endInput')
|
|
996
|
+
expect(endFrame.execId).toBe(runFrame.execId)
|
|
997
|
+
|
|
998
|
+
command.kill('SIGINT')
|
|
999
|
+
const killFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
1000
|
+
expect(killFrame.type).toBe('exec.kill')
|
|
1001
|
+
expect(killFrame.execId).toBe(runFrame.execId)
|
|
1002
|
+
expect(killFrame.signal).toBe('SIGINT')
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
test('wait() resolves with exitCode when exec.exit arrives', async () => {
|
|
1006
|
+
const sandbox = await buildConnectedSandbox()
|
|
1007
|
+
|
|
1008
|
+
const commandPromise = sandbox.exec('sleep 100', { detached: true })
|
|
1009
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
1010
|
+
|
|
1011
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1234, startedAt: 1000000 })
|
|
1012
|
+
const command = await commandPromise
|
|
1013
|
+
|
|
1014
|
+
const waitPromise = command.wait()
|
|
1015
|
+
sockets[0].message({ type: 'exec.exit', execId: runFrame.execId, exitCode: 0 })
|
|
1016
|
+
|
|
1017
|
+
const result = await waitPromise
|
|
1018
|
+
expect(result.exitCode).toBe(0)
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
test('output frames after exec.detached are delivered to onOutput', async () => {
|
|
1022
|
+
const sandbox = await buildConnectedSandbox()
|
|
1023
|
+
const chunks = []
|
|
1024
|
+
|
|
1025
|
+
const commandPromise = sandbox.exec('npm run dev', {
|
|
1026
|
+
detached: true,
|
|
1027
|
+
onOutput: (data, stream) => chunks.push({ data, stream })
|
|
1028
|
+
})
|
|
1029
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
1030
|
+
|
|
1031
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 9000, startedAt: 1 })
|
|
1032
|
+
await commandPromise
|
|
1033
|
+
|
|
1034
|
+
sockets[0].message({ type: 'exec.output', execId: runFrame.execId, stream: 'stdout', data: 'compiled\n' })
|
|
1035
|
+
expect(chunks).toEqual([{ data: 'compiled\n', stream: 'stdout' }])
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
test('rejects with SandboxClientError when timeout is combined with detached', async () => {
|
|
1039
|
+
const sandbox = await buildConnectedSandbox()
|
|
1040
|
+
await expect(
|
|
1041
|
+
sandbox.exec('npm run dev', { detached: true, timeout: 5000 })
|
|
1042
|
+
).rejects.toThrow(SandboxClientError)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
test('error frame on detached exec rejects wait()', async () => {
|
|
1046
|
+
const sandbox = await buildConnectedSandbox()
|
|
1047
|
+
|
|
1048
|
+
const commandPromise = sandbox.exec('bad-cmd', { detached: true })
|
|
1049
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
1050
|
+
|
|
1051
|
+
// Resolve outer promise first (process started)
|
|
1052
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1, startedAt: 1 })
|
|
1053
|
+
const command = await commandPromise
|
|
1054
|
+
|
|
1055
|
+
const waitPromise = command.wait()
|
|
1056
|
+
// Then an error arrives (e.g. process crashed with error frame)
|
|
1057
|
+
sockets[0].message({ type: 'error', execId: runFrame.execId, message: 'process crashed' })
|
|
1058
|
+
|
|
1059
|
+
await expect(waitPromise).rejects.toThrow(SandboxClientError)
|
|
1060
|
+
})
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
// -------------------------------------------------------------------------
|
|
1064
|
+
// getCommand
|
|
1065
|
+
// -------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
describe('getCommand()', () => {
|
|
1068
|
+
test('sends exec.get and resolves with command object on exec.info', async () => {
|
|
1069
|
+
const sandbox = await buildConnectedSandbox()
|
|
1070
|
+
|
|
1071
|
+
const commandPromise = sandbox.getCommand('exec-d1e2f3a4', { onOutput: () => {} })
|
|
1072
|
+
const getFrame = JSON.parse(sockets[0].sent[1])
|
|
1073
|
+
expect(getFrame.type).toBe('exec.get')
|
|
1074
|
+
expect(getFrame.execId).toBe('exec-d1e2f3a4')
|
|
1075
|
+
|
|
1076
|
+
sockets[0].message({
|
|
1077
|
+
type: 'exec.info',
|
|
1078
|
+
execId: 'exec-d1e2f3a4',
|
|
1079
|
+
command: 'npm run dev',
|
|
1080
|
+
pid: 5678,
|
|
1081
|
+
startedAt: 1711036812,
|
|
1082
|
+
detached: true
|
|
1083
|
+
})
|
|
1084
|
+
|
|
1085
|
+
const command = await commandPromise
|
|
1086
|
+
expect(command.execId).toBe('exec-d1e2f3a4')
|
|
1087
|
+
expect(command.command).toBe('npm run dev')
|
|
1088
|
+
expect(command.pid).toBe(5678)
|
|
1089
|
+
expect(command.startedAt).toBe(1711036812)
|
|
1090
|
+
expect(command.detached).toBe(true)
|
|
1091
|
+
expect(typeof command.wait).toBe('function')
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
test('wait() resolves when exec.exit arrives after getCommand()', async () => {
|
|
1095
|
+
const sandbox = await buildConnectedSandbox()
|
|
1096
|
+
|
|
1097
|
+
const commandPromise = sandbox.getCommand('exec-reattach')
|
|
1098
|
+
JSON.parse(sockets[0].sent[1]) // exec.get frame
|
|
1099
|
+
sockets[0].message({
|
|
1100
|
+
type: 'exec.info',
|
|
1101
|
+
execId: 'exec-reattach',
|
|
1102
|
+
command: 'sleep 60',
|
|
1103
|
+
pid: 1111,
|
|
1104
|
+
startedAt: 100,
|
|
1105
|
+
detached: true
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
const command = await commandPromise
|
|
1109
|
+
const waitPromise = command.wait()
|
|
1110
|
+
|
|
1111
|
+
sockets[0].message({ type: 'exec.exit', execId: 'exec-reattach', exitCode: 143 })
|
|
1112
|
+
const result = await waitPromise
|
|
1113
|
+
expect(result.exitCode).toBe(143)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
test('throws SandboxCommandNotFoundError when error NOT_FOUND is returned', async () => {
|
|
1117
|
+
const sandbox = await buildConnectedSandbox()
|
|
1118
|
+
|
|
1119
|
+
const commandPromise = sandbox.getCommand('exec-gone')
|
|
1120
|
+
JSON.parse(sockets[0].sent[1])
|
|
1121
|
+
sockets[0].message({
|
|
1122
|
+
type: 'error',
|
|
1123
|
+
execId: 'exec-gone',
|
|
1124
|
+
code: 'NOT_FOUND',
|
|
1125
|
+
message: 'no running process for execId'
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
await expect(commandPromise).rejects.toThrow(SandboxCommandNotFoundError)
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
test('rejects when socket is not open', async () => {
|
|
1132
|
+
const sandbox = new Sandbox(BASE_OPTIONS)
|
|
1133
|
+
await expect(sandbox.getCommand('exec-x')).rejects.toThrow(SandboxWebSocketError)
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
test('rejects and clears pending get operation when socket.send throws', async () => {
|
|
1137
|
+
const sandbox = await buildConnectedSandbox()
|
|
1138
|
+
sockets[0].send = () => { throw new Error('broken pipe') }
|
|
1139
|
+
|
|
1140
|
+
await expect(sandbox.getCommand('exec-x')).rejects.toThrow(SandboxWebSocketError)
|
|
1141
|
+
expect(sandbox.ws.pendingGetOps.has('exec-x')).toBe(false)
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
test('reuses existing wait promise when exec is already running in same session', async () => {
|
|
1145
|
+
const sandbox = await buildConnectedSandbox()
|
|
1146
|
+
|
|
1147
|
+
// Start a detached exec so it lands in pendingExecs
|
|
1148
|
+
const commandPromise = sandbox.exec('npm run dev', { detached: true })
|
|
1149
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
1150
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 100, startedAt: 1 })
|
|
1151
|
+
const command = await commandPromise
|
|
1152
|
+
|
|
1153
|
+
// Reattach via getCommand for the same execId
|
|
1154
|
+
const getPromise = sandbox.getCommand(runFrame.execId)
|
|
1155
|
+
sockets[0].message({
|
|
1156
|
+
type: 'exec.info',
|
|
1157
|
+
execId: runFrame.execId,
|
|
1158
|
+
command: 'npm run dev',
|
|
1159
|
+
pid: 100,
|
|
1160
|
+
startedAt: 1,
|
|
1161
|
+
detached: true
|
|
1162
|
+
})
|
|
1163
|
+
const reattached = await getPromise
|
|
1164
|
+
|
|
1165
|
+
// Both wait() calls share the same underlying promise
|
|
1166
|
+
const w1 = command.wait()
|
|
1167
|
+
const w2 = reattached.wait()
|
|
1168
|
+
expect(w1).toBe(w2)
|
|
1169
|
+
|
|
1170
|
+
sockets[0].message({ type: 'exec.exit', execId: runFrame.execId, exitCode: 0 })
|
|
1171
|
+
const [r1, r2] = await Promise.all([w1, w2])
|
|
1172
|
+
expect(r1.exitCode).toBe(0)
|
|
1173
|
+
expect(r2.exitCode).toBe(0)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
test('delivers subsequent output to both original and reattached onOutput callbacks', async () => {
|
|
1177
|
+
const sandbox = await buildConnectedSandbox()
|
|
1178
|
+
const original = []
|
|
1179
|
+
const reattached = []
|
|
1180
|
+
|
|
1181
|
+
const commandPromise = sandbox.exec('npm run dev', {
|
|
1182
|
+
detached: true,
|
|
1183
|
+
onOutput: (data, stream) => original.push({ data, stream })
|
|
1184
|
+
})
|
|
1185
|
+
const runFrame = JSON.parse(sockets[0].sent[1])
|
|
1186
|
+
sockets[0].message({ type: 'exec.detached', execId: runFrame.execId, pid: 1, startedAt: 1 })
|
|
1187
|
+
await commandPromise
|
|
1188
|
+
|
|
1189
|
+
const getPromise = sandbox.getCommand(runFrame.execId, {
|
|
1190
|
+
onOutput: (data, stream) => reattached.push({ data, stream })
|
|
1191
|
+
})
|
|
1192
|
+
sockets[0].message({
|
|
1193
|
+
type: 'exec.info',
|
|
1194
|
+
execId: runFrame.execId,
|
|
1195
|
+
command: 'npm run dev',
|
|
1196
|
+
pid: 1,
|
|
1197
|
+
startedAt: 1,
|
|
1198
|
+
detached: true
|
|
1199
|
+
})
|
|
1200
|
+
await getPromise
|
|
1201
|
+
|
|
1202
|
+
sockets[0].message({ type: 'exec.output', execId: runFrame.execId, stream: 'stdout', data: 'hello\n' })
|
|
1203
|
+
expect(original).toEqual([{ data: 'hello\n', stream: 'stdout' }])
|
|
1204
|
+
expect(reattached).toEqual([{ data: 'hello\n', stream: 'stdout' }])
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
test('command object writeStdin / closeStdin / kill delegate to sandbox', async () => {
|
|
1208
|
+
const sandbox = await buildConnectedSandbox()
|
|
1209
|
+
|
|
1210
|
+
const getPromise = sandbox.getCommand('exec-xyz')
|
|
1211
|
+
sockets[0].message({
|
|
1212
|
+
type: 'exec.info',
|
|
1213
|
+
execId: 'exec-xyz',
|
|
1214
|
+
command: 'tail -f /log',
|
|
1215
|
+
pid: 42,
|
|
1216
|
+
startedAt: 1000,
|
|
1217
|
+
detached: true
|
|
1218
|
+
})
|
|
1219
|
+
const command = await getPromise
|
|
1220
|
+
|
|
1221
|
+
command.writeStdin('hello\n')
|
|
1222
|
+
const inputFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
1223
|
+
expect(inputFrame.type).toBe('exec.input')
|
|
1224
|
+
expect(inputFrame.execId).toBe('exec-xyz')
|
|
1225
|
+
expect(inputFrame.data).toBe('hello\n')
|
|
1226
|
+
|
|
1227
|
+
command.closeStdin()
|
|
1228
|
+
const endFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
1229
|
+
expect(endFrame.type).toBe('exec.endInput')
|
|
1230
|
+
expect(endFrame.execId).toBe('exec-xyz')
|
|
1231
|
+
|
|
1232
|
+
command.kill('SIGINT')
|
|
1233
|
+
const killFrame = JSON.parse(sockets[0].sent[sockets[0].sent.length - 1])
|
|
1234
|
+
expect(killFrame.type).toBe('exec.kill')
|
|
1235
|
+
expect(killFrame.execId).toBe('exec-xyz')
|
|
1236
|
+
expect(killFrame.signal).toBe('SIGINT')
|
|
1237
|
+
})
|
|
1238
|
+
})
|
|
1239
|
+
|
|
725
1240
|
// -------------------------------------------------------------------------
|
|
726
1241
|
// Socket close drains pending operations
|
|
727
1242
|
// -------------------------------------------------------------------------
|
|
@@ -744,5 +1259,25 @@ describe('Sandbox', () => {
|
|
|
744
1259
|
|
|
745
1260
|
await expect(filePromise).rejects.toThrow(SandboxWebSocketError)
|
|
746
1261
|
})
|
|
1262
|
+
|
|
1263
|
+
test('rejects pending getCommand when socket closes before exec.info arrives', async () => {
|
|
1264
|
+
const sandbox = await buildConnectedSandbox()
|
|
1265
|
+
|
|
1266
|
+
const commandPromise = sandbox.getCommand('exec-running')
|
|
1267
|
+
sockets[0].closeWith(1006)
|
|
1268
|
+
|
|
1269
|
+
await expect(commandPromise).rejects.toThrow(SandboxWebSocketError)
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
test('silently ignores incoming messages with invalid JSON', async () => {
|
|
1273
|
+
const sandbox = await buildConnectedSandbox()
|
|
1274
|
+
|
|
1275
|
+
sockets[0].emit('message', Buffer.from('not-valid-json!!!'))
|
|
1276
|
+
|
|
1277
|
+
const resultPromise = sandbox.exec('echo hi')
|
|
1278
|
+
const frame = JSON.parse(sockets[0].sent[1])
|
|
1279
|
+
sockets[0].message({ type: 'exec.exit', execId: frame.execId, exitCode: 0 })
|
|
1280
|
+
await expect(resultPromise).resolves.toMatchObject({ exitCode: 0 })
|
|
1281
|
+
})
|
|
747
1282
|
})
|
|
748
1283
|
})
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
name: check-npm-token
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
workflow_dispatch:
|
|
5
|
-
|
|
6
|
-
jobs:
|
|
7
|
-
check:
|
|
8
|
-
runs-on: ubuntu-latest
|
|
9
|
-
steps:
|
|
10
|
-
- uses: actions/setup-node@v4
|
|
11
|
-
with:
|
|
12
|
-
node-version: 24
|
|
13
|
-
registry-url: 'https://registry.npmjs.org'
|
|
14
|
-
- run: npm whoami
|
|
15
|
-
env:
|
|
16
|
-
NODE_AUTH_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }}
|