@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.
@@ -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 template', async () => {
792
+ test('resolves preview URL from previewUrls map', () => {
658
793
  const sandbox = new Sandbox({
659
794
  ...BASE_OPTIONS,
660
- publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
795
+ previewUrls: new Map([[3000, 'https://sb-test-3000.preview.example.net']])
661
796
  })
662
797
 
663
- const url = await sandbox.getUrl({ port: 3000 })
798
+ const url = sandbox.getUrl(3000)
664
799
  expect(url).toBe('https://sb-test-3000.preview.example.net')
665
800
  })
666
801
 
667
- test('replaces scheme when protocol option provided', async () => {
802
+ test('throws SandboxPortNotProvisionedError when port was not provisioned', () => {
668
803
  const sandbox = new Sandbox({
669
804
  ...BASE_OPTIONS,
670
- publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net'
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 SandboxClientError when publicUrlTemplate is absent', async () => {
678
- const sandbox = new Sandbox(BASE_OPTIONS)
679
- await expect(sandbox.getUrl({ port: 3000 })).rejects.toThrow(SandboxClientError)
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 SandboxClientError for invalid port', async () => {
683
- const sandbox = new Sandbox({ ...BASE_OPTIONS, publicUrlTemplate: 'https://{sandboxId}-{port}.preview.example.net' })
684
- await expect(sandbox.getUrl({ port: 0 })).rejects.toThrow(SandboxClientError)
685
- await expect(sandbox.getUrl({ port: 70000 })).rejects.toThrow(SandboxClientError)
686
- await expect(sandbox.getUrl({ port: 'abc' })).rejects.toThrow(SandboxClientError)
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 }}