@0xsequence/catapult 1.3.6 → 1.3.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.
@@ -36,7 +36,7 @@ describe('Deployer', () => {
36
36
  beforeEach(() => {
37
37
  // Clear all mocks
38
38
  jest.clearAllMocks()
39
-
39
+
40
40
  // Setup mock networks
41
41
  mockNetwork1 = { name: 'mainnet', chainId: 1, rpcUrl: 'https://eth.rpc' }
42
42
  mockNetwork2 = { name: 'polygon', chainId: 137, rpcUrl: 'https://polygon.rpc' }
@@ -166,7 +166,7 @@ describe('Deployer', () => {
166
166
  describe('happy paths', () => {
167
167
  it('should successfully run a simple deployment', async () => {
168
168
  const deployer = new Deployer(deployerOptions)
169
-
169
+
170
170
  await deployer.run()
171
171
 
172
172
  // Verify the flow
@@ -186,18 +186,18 @@ describe('Deployer', () => {
186
186
  if (jobName === 'job2') return new Set(['job1'])
187
187
  return new Set()
188
188
  })
189
-
189
+
190
190
  const options: DeployerOptions = {
191
191
  ...deployerOptions,
192
192
  runJobs: ['job2'] // This should also include job1 due to dependency
193
193
  }
194
-
194
+
195
195
  const deployer = new Deployer(options)
196
196
  await deployer.run()
197
197
 
198
198
  // Should execute job1 (dependency) and job2, but not job3
199
199
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(4) // 2 jobs × 2 networks
200
-
200
+
201
201
  // Verify it was called with the right jobs
202
202
  const executedJobs = mockEngine.executeJob.mock.calls.map(call => call[0].name)
203
203
  expect(executedJobs).toContain('job1')
@@ -210,12 +210,12 @@ describe('Deployer', () => {
210
210
  ...deployerOptions,
211
211
  runOnNetworks: [1] // Only mainnet
212
212
  }
213
-
213
+
214
214
  const deployer = new Deployer(options)
215
215
  await deployer.run()
216
216
 
217
217
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(3) // 3 jobs × 1 network
218
-
218
+
219
219
  // Verify all calls were with mainnet
220
220
  const usedNetworks = MockExecutionContext.mock.calls.map(call => call[0])
221
221
  expect(usedNetworks).toHaveLength(3)
@@ -231,7 +231,7 @@ describe('Deployer', () => {
231
231
  // job3 has only_networks: [1], so should only run on mainnet
232
232
  const job3Calls = mockEngine.executeJob.mock.calls.filter(call => call[0].name === 'job3')
233
233
  expect(job3Calls).toHaveLength(1) // Only on mainnet
234
-
234
+
235
235
  // Verify it was called with mainnet (check the MockExecutionContext calls)
236
236
  const contextCallsForJob3 = MockExecutionContext.mock.calls.filter((_, index) => {
237
237
  const engineCall = mockEngine.executeJob.mock.calls[index]
@@ -246,7 +246,7 @@ describe('Deployer', () => {
246
246
  name: 'job-skip-polygon',
247
247
  skip_networks: [137] // Skip polygon
248
248
  }
249
-
249
+
250
250
  mockLoader.jobs.set('job-skip-polygon', jobWithSkipNetworks)
251
251
  mockGraph.getExecutionOrder.mockReturnValue(['job-skip-polygon'])
252
252
 
@@ -268,13 +268,13 @@ describe('Deployer', () => {
268
268
 
269
269
  // Verify output files (flat)
270
270
  expect(mockFs.writeFile).toHaveBeenCalledTimes(3)
271
-
271
+
272
272
  // Check job1 output file (flat path)
273
273
  const job1OutputCall = mockFs.writeFile.mock.calls.find(call =>
274
274
  call[0] === '/test/project/output/job1.json'
275
275
  )
276
276
  expect(job1OutputCall).toBeDefined()
277
-
277
+
278
278
  const job1Content = JSON.parse(job1OutputCall![1] as string)
279
279
  expect(job1Content).toMatchObject({
280
280
  jobName: 'job1',
@@ -349,11 +349,11 @@ describe('Deployer', () => {
349
349
  { name: 'other-action', template: 'template1', arguments: {} } // no output flag
350
350
  ]
351
351
  }
352
-
352
+
353
353
  mockLoader.jobs.clear()
354
354
  mockLoader.jobs.set('job-with-output-flags', jobWithOutputFlags)
355
355
  mockGraph.getExecutionOrder.mockReturnValue(['job-with-output-flags'])
356
-
356
+
357
357
  // Mock context to return outputs from all actions
358
358
  mockContext.getOutputs.mockReturnValue(new Map<string, any>([
359
359
  ['deploy-action.address', '0xdeployaddress'],
@@ -367,14 +367,14 @@ describe('Deployer', () => {
367
367
 
368
368
  // Verify output file was written
369
369
  expect(mockFs.writeFile).toHaveBeenCalledTimes(1)
370
-
370
+
371
371
  const outputCall = mockFs.writeFile.mock.calls[0]
372
372
  expect(outputCall[0]).toBe('/test/project/output/job-with-output-flags.json')
373
-
373
+
374
374
  const outputContent = JSON.parse(outputCall[1] as string)
375
375
  expect(outputContent.networks).toHaveLength(1)
376
376
  expect(outputContent.networks[0].status).toBe('success')
377
-
377
+
378
378
  // Should only include outputs from deploy-action (output: true)
379
379
  // Should NOT include verify-action (output: false) or other-action (no flag)
380
380
  expect(outputContent.networks[0].outputs).toEqual({
@@ -394,11 +394,11 @@ describe('Deployer', () => {
394
394
  { name: 'action2', template: 'template1', arguments: {}, output: false }
395
395
  ]
396
396
  }
397
-
397
+
398
398
  mockLoader.jobs.clear()
399
399
  mockLoader.jobs.set('job-without-output-flags', jobWithoutOutputFlags)
400
400
  mockGraph.getExecutionOrder.mockReturnValue(['job-without-output-flags'])
401
-
401
+
402
402
  // Mock context to return outputs from all actions
403
403
  mockContext.getOutputs.mockReturnValue(new Map<string, any>([
404
404
  ['action1.result', 'result1'],
@@ -410,10 +410,10 @@ describe('Deployer', () => {
410
410
 
411
411
  // Verify output file was written
412
412
  expect(mockFs.writeFile).toHaveBeenCalledTimes(1)
413
-
413
+
414
414
  const outputCall = mockFs.writeFile.mock.calls[0]
415
415
  const outputContent = JSON.parse(outputCall[1] as string)
416
-
416
+
417
417
  // Should include all outputs (backward compatibility)
418
418
  expect(outputContent.networks[0].outputs).toEqual({
419
419
  'action1.result': 'result1',
@@ -434,11 +434,11 @@ describe('Deployer', () => {
434
434
  { name: 'verify2', template: 'template1', arguments: {}, output: false }
435
435
  ]
436
436
  }
437
-
437
+
438
438
  mockLoader.jobs.clear()
439
439
  mockLoader.jobs.set('job-multiple-outputs', jobWithMultipleOutputs)
440
440
  mockGraph.getExecutionOrder.mockReturnValue(['job-multiple-outputs'])
441
-
441
+
442
442
  // Mock context to return outputs from all actions
443
443
  mockContext.getOutputs.mockReturnValue(new Map<string, any>([
444
444
  ['deploy1.address', '0xdeploy1'],
@@ -453,7 +453,7 @@ describe('Deployer', () => {
453
453
  // Verify output file was written
454
454
  const outputCall = mockFs.writeFile.mock.calls[0]
455
455
  const outputContent = JSON.parse(outputCall[1] as string)
456
-
456
+
457
457
  // Should include outputs from both deploy actions, but not verify actions
458
458
  expect(outputContent.networks[0].outputs).toEqual({
459
459
  'deploy1.address': '0xdeploy1',
@@ -467,7 +467,7 @@ describe('Deployer', () => {
467
467
  mockLoader.load.mockRejectedValue(new Error('Failed to load project'))
468
468
 
469
469
  const deployer = new Deployer(deployerOptions)
470
-
470
+
471
471
  await expect(deployer.run()).rejects.toThrow('Failed to load project')
472
472
  // Note: Error handling is now done via events, not console.error directly
473
473
  })
@@ -478,25 +478,25 @@ describe('Deployer', () => {
478
478
  })
479
479
 
480
480
  const deployer = new Deployer(deployerOptions)
481
-
481
+
482
482
  await expect(deployer.run()).rejects.toThrow('Circular dependency detected')
483
483
  })
484
484
 
485
485
  it('should capture job execution failures and then throw', async () => {
486
486
  mockEngine.executeJob.mockRejectedValue(new Error('Transaction failed'))
487
-
487
+
488
488
  const deployer = new Deployer(deployerOptions)
489
-
489
+
490
490
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
491
-
491
+
492
492
  // Should still write output files with error entries before throwing
493
493
  expect(mockFs.writeFile).toHaveBeenCalled()
494
-
494
+
495
495
  // Check that error entries are recorded
496
496
  const writeFileCalls = mockFs.writeFile.mock.calls
497
497
  const outputFile = writeFileCalls[0]
498
498
  const outputContent = JSON.parse(outputFile[1] as string)
499
-
499
+
500
500
  // Should have error entries for failed executions
501
501
  const errorEntries = outputContent.networks.filter((entry: any) => entry.status === 'error')
502
502
  expect(errorEntries.length).toBeGreaterThan(0)
@@ -507,7 +507,7 @@ describe('Deployer', () => {
507
507
  mockFs.mkdir.mockRejectedValue(new Error('Permission denied'))
508
508
 
509
509
  const deployer = new Deployer(deployerOptions)
510
-
510
+
511
511
  await expect(deployer.run()).rejects.toThrow('Permission denied')
512
512
  })
513
513
 
@@ -515,7 +515,7 @@ describe('Deployer', () => {
515
515
  mockFs.writeFile.mockRejectedValue(new Error('Disk full'))
516
516
 
517
517
  const deployer = new Deployer(deployerOptions)
518
-
518
+
519
519
  await expect(deployer.run()).rejects.toThrow('Disk full')
520
520
  })
521
521
 
@@ -525,14 +525,14 @@ describe('Deployer', () => {
525
525
  })
526
526
 
527
527
  const deployer = new Deployer(deployerOptions)
528
-
528
+
529
529
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
530
-
530
+
531
531
  // Should record context creation failures as error entries before throwing
532
532
  const writeFileCalls = mockFs.writeFile.mock.calls
533
533
  const outputFile = writeFileCalls[0]
534
534
  const outputContent = JSON.parse(outputFile[1] as string)
535
-
535
+
536
536
  const errorEntries = outputContent.networks.filter((entry: any) => entry.status === 'error')
537
537
  expect(errorEntries.length).toBeGreaterThan(0)
538
538
  expect(errorEntries[0].error).toBe('Invalid private key')
@@ -546,7 +546,7 @@ describe('Deployer', () => {
546
546
  name: 'weird-job',
547
547
  only_networks: [999] // Non-existent network
548
548
  }
549
-
549
+
550
550
  mockLoader.jobs.clear()
551
551
  mockLoader.jobs.set('weird-job', weirdJob)
552
552
  mockGraph.getExecutionOrder.mockReturnValue(['weird-job'])
@@ -564,7 +564,7 @@ describe('Deployer', () => {
564
564
  name: 'weird-job',
565
565
  skip_networks: [1, 137] // Skip all available networks
566
566
  }
567
-
567
+
568
568
  mockLoader.jobs.clear()
569
569
  mockLoader.jobs.set('weird-job', weirdJob)
570
570
  mockGraph.getExecutionOrder.mockReturnValue(['weird-job'])
@@ -578,12 +578,12 @@ describe('Deployer', () => {
578
578
 
579
579
  it('should handle runOnNetworks with non-existent chain IDs', async () => {
580
580
  const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
581
-
581
+
582
582
  const options: DeployerOptions = {
583
583
  ...deployerOptions,
584
584
  runOnNetworks: [1, 999, 888] // 999 and 888 don't exist
585
585
  }
586
-
586
+
587
587
  const deployer = new Deployer(options)
588
588
  await deployer.run()
589
589
 
@@ -599,9 +599,9 @@ describe('Deployer', () => {
599
599
  ...deployerOptions,
600
600
  runJobs: ['non-existent-job']
601
601
  }
602
-
602
+
603
603
  const deployer = new Deployer(options)
604
-
604
+
605
605
  await expect(deployer.run()).rejects.toThrow(
606
606
  'Specified job "non-existent-job" not found in project.'
607
607
  )
@@ -611,18 +611,18 @@ describe('Deployer', () => {
611
611
  const brokenContext = {
612
612
  // Missing getOutputs method
613
613
  } as any
614
-
614
+
615
615
  MockExecutionContext.mockImplementation(() => brokenContext)
616
616
 
617
617
  const deployer = new Deployer(deployerOptions)
618
-
618
+
619
619
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
620
-
620
+
621
621
  // Should record the missing method error before throwing
622
622
  const writeFileCalls = mockFs.writeFile.mock.calls
623
623
  const outputFile = writeFileCalls[0]
624
624
  const outputContent = JSON.parse(outputFile[1] as string)
625
-
625
+
626
626
  const errorEntries = outputContent.networks.filter((entry: any) => entry.status === 'error')
627
627
  expect(errorEntries.length).toBeGreaterThan(0)
628
628
  })
@@ -632,7 +632,7 @@ describe('Deployer', () => {
632
632
  ...deployerOptions,
633
633
  networks: []
634
634
  }
635
-
635
+
636
636
  const deployer = new Deployer(options)
637
637
  await deployer.run()
638
638
 
@@ -646,7 +646,7 @@ describe('Deployer', () => {
646
646
  ...deployerOptions,
647
647
  runJobs: []
648
648
  }
649
-
649
+
650
650
  const deployer = new Deployer(options)
651
651
  await deployer.run()
652
652
 
@@ -659,7 +659,7 @@ describe('Deployer', () => {
659
659
  ...deployerOptions,
660
660
  runOnNetworks: []
661
661
  }
662
-
662
+
663
663
  const deployer = new Deployer(options)
664
664
  await deployer.run()
665
665
 
@@ -674,7 +674,7 @@ describe('Deployer', () => {
674
674
  only_networks: [1, 137],
675
675
  skip_networks: [137]
676
676
  }
677
-
677
+
678
678
  mockLoader.jobs.clear()
679
679
  mockLoader.jobs.set('conflicted-job', conflictedJob)
680
680
  mockGraph.getExecutionOrder.mockReturnValue(['conflicted-job'])
@@ -696,20 +696,20 @@ describe('Deployer', () => {
696
696
  })
697
697
 
698
698
  const deployer = new Deployer(deployerOptions)
699
-
699
+
700
700
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
701
-
701
+
702
702
  // Should write output files with error entries before throwing
703
703
  expect(mockFs.writeFile).toHaveBeenCalled()
704
-
704
+
705
705
  const writeFileCalls = mockFs.writeFile.mock.calls
706
706
  const outputFile = writeFileCalls[0]
707
707
  const outputContent = JSON.parse(outputFile[1] as string)
708
-
708
+
709
709
  // All entries should be error entries
710
710
  const errorEntries = outputContent.networks.filter((entry: any) => entry.status === 'error')
711
711
  expect(errorEntries.length).toBeGreaterThan(0)
712
-
712
+
713
713
  // No success entries
714
714
  const successEntries = outputContent.networks.filter((entry: any) => entry.status === 'success')
715
715
  expect(successEntries.length).toBe(0)
@@ -719,7 +719,7 @@ describe('Deployer', () => {
719
719
  // Create 100 jobs to test performance/memory
720
720
  const manyJobs = Array.from({ length: 100 }, (_, i) => `job${i}`)
721
721
  mockGraph.getExecutionOrder.mockReturnValue(manyJobs)
722
-
722
+
723
723
  // Mock loader to have all these jobs
724
724
  for (let i = 0; i < 100; i++) {
725
725
  mockLoader.jobs.set(`job${i}`, {
@@ -756,14 +756,14 @@ describe('Deployer', () => {
756
756
  runJobs: ['job2']
757
757
  }
758
758
  const deployer = new Deployer(options)
759
-
759
+
760
760
  // Initialize the deployer's graph by calling load
761
761
  await mockLoader.load()
762
762
  ;(deployer as any).graph = mockGraph
763
-
763
+
764
764
  // Mock getDependencies to return job1 as dependency of job2
765
765
  mockGraph.getDependencies.mockReturnValueOnce(new Set(['job1']))
766
-
766
+
767
767
  const fullOrder = ['job1', 'job2', 'job3']
768
768
  const plan = (deployer as any).getJobExecutionPlan(fullOrder)
769
769
  expect(plan).toEqual(['job1', 'job2'])
@@ -893,7 +893,7 @@ describe('Deployer', () => {
893
893
  runOnNetworks: [1]
894
894
  }
895
895
  const deployer = new Deployer(options)
896
-
896
+
897
897
  const networks = (deployer as any).getTargetNetworks()
898
898
  expect(networks).toEqual([mockNetwork1])
899
899
  })
@@ -942,7 +942,7 @@ describe('Deployer', () => {
942
942
 
943
943
  mockLoader.jobs.set('job4', job4)
944
944
  mockGraph.getExecutionOrder.mockReturnValue(['job1', 'job2', 'job3', 'job4'])
945
-
945
+
946
946
  // Mock dependencies
947
947
  mockGraph.getDependencies
948
948
  .mockReturnValueOnce(new Set()) // job1 has no deps
@@ -963,7 +963,7 @@ describe('Deployer', () => {
963
963
  const contextCalls = MockExecutionContext.mock.calls
964
964
  const mainnetCalls = contextCalls.filter(call => call[0].chainId === 1)
965
965
  const polygonCalls = contextCalls.filter(call => call[0].chainId === 137)
966
-
966
+
967
967
  expect(mainnetCalls).toHaveLength(4) // All jobs run on mainnet
968
968
  expect(polygonCalls).toHaveLength(2) // Only job1 and job2 run on polygon
969
969
  })
@@ -975,7 +975,7 @@ describe('Deployer', () => {
975
975
  const currentCall = MockExecutionContext.mock.calls[callCount]
976
976
  const network = currentCall ? currentCall[0] : null
977
977
  callCount++
978
-
978
+
979
979
  if (job.name === 'job2' && network && network.chainId === 137) {
980
980
  throw new Error('Polygon execution failed')
981
981
  }
@@ -983,19 +983,19 @@ describe('Deployer', () => {
983
983
  })
984
984
 
985
985
  const deployer = new Deployer(deployerOptions)
986
-
986
+
987
987
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
988
-
988
+
989
989
  // Should capture the partial failure in output files before throwing
990
990
  const writeFileCalls = mockFs.writeFile.mock.calls
991
- const job2Output = writeFileCalls.find(call =>
991
+ const job2Output = writeFileCalls.find(call =>
992
992
  String(call[0]).includes('job2.json')
993
993
  )
994
-
994
+
995
995
  if (job2Output) {
996
996
  const job2Content = JSON.parse(job2Output[1] as string)
997
997
  const errorEntries = job2Content.networks.filter((entry: any) => entry.status === 'error')
998
- expect(errorEntries.some((entry: any) =>
998
+ expect(errorEntries.some((entry: any) =>
999
999
  entry.chainId === '137' && entry.error === 'Polygon execution failed'
1000
1000
  )).toBe(true)
1001
1001
  }
@@ -1019,22 +1019,22 @@ describe('Deployer', () => {
1019
1019
 
1020
1020
  // Verify outputs are correctly segregated by network since they have different outputs
1021
1021
  const writeFileCalls = mockFs.writeFile.mock.calls
1022
- const job1Output = writeFileCalls.find(call =>
1022
+ const job1Output = writeFileCalls.find(call =>
1023
1023
  call[0] === '/test/project/output/job1.json'
1024
1024
  )
1025
-
1025
+
1026
1026
  const job1Content = JSON.parse(job1Output![1] as string)
1027
1027
  // Since outputs differ by network, they should be in separate entries
1028
1028
  expect(job1Content.networks).toHaveLength(2)
1029
-
1029
+
1030
1030
  // Find entries for each network
1031
- const network1Entry = job1Content.networks.find((entry: any) =>
1031
+ const network1Entry = job1Content.networks.find((entry: any) =>
1032
1032
  entry.chainIds && entry.chainIds.includes('1')
1033
1033
  )
1034
- const network137Entry = job1Content.networks.find((entry: any) =>
1034
+ const network137Entry = job1Content.networks.find((entry: any) =>
1035
1035
  entry.chainIds && entry.chainIds.includes('137')
1036
1036
  )
1037
-
1037
+
1038
1038
  expect(network1Entry.outputs['action.hash']).toBe('0xhash-1')
1039
1039
  expect(network137Entry.outputs['action.hash']).toBe('0xhash-137')
1040
1040
  })
@@ -1056,10 +1056,10 @@ describe('Deployer', () => {
1056
1056
 
1057
1057
  // Verify identical outputs are grouped together
1058
1058
  const writeFileCalls = mockFs.writeFile.mock.calls
1059
- const job1Output = writeFileCalls.find(call =>
1059
+ const job1Output = writeFileCalls.find(call =>
1060
1060
  call[0] === '/test/project/output/job1.json'
1061
1061
  )
1062
-
1062
+
1063
1063
  const job1Content = JSON.parse(job1Output![1] as string)
1064
1064
  // Since outputs are identical, they should be grouped into one entry
1065
1065
  expect(job1Content.networks).toHaveLength(1)
@@ -1075,7 +1075,7 @@ describe('Deployer', () => {
1075
1075
  const currentCall = MockExecutionContext.mock.calls[callCount]
1076
1076
  const network = currentCall ? currentCall[0] : null
1077
1077
  callCount++
1078
-
1078
+
1079
1079
  if (job.name === 'job1' && network && network.chainId === 137) {
1080
1080
  throw new Error('Polygon execution failed')
1081
1081
  }
@@ -1098,21 +1098,21 @@ describe('Deployer', () => {
1098
1098
 
1099
1099
  // Verify outputs show both success and error states before throwing
1100
1100
  const writeFileCalls = mockFs.writeFile.mock.calls
1101
- const job1Output = writeFileCalls.find(call =>
1101
+ const job1Output = writeFileCalls.find(call =>
1102
1102
  call[0] === '/test/project/output/job1.json'
1103
1103
  )
1104
-
1104
+
1105
1105
  const job1Content = JSON.parse(job1Output![1] as string)
1106
1106
  expect(job1Content.networks).toHaveLength(2) // One success entry, one error entry
1107
-
1107
+
1108
1108
  // Find success and error entries
1109
1109
  const successEntry = job1Content.networks.find((entry: any) => entry.status === 'success')
1110
1110
  const errorEntry = job1Content.networks.find((entry: any) => entry.status === 'error')
1111
-
1111
+
1112
1112
  expect(successEntry).toBeDefined()
1113
1113
  expect(successEntry.chainIds).toEqual(['1'])
1114
1114
  expect(successEntry.outputs['contract.address']).toBe('0x1234567890123456789012345678901234567890')
1115
-
1115
+
1116
1116
  expect(errorEntry).toBeDefined()
1117
1117
  expect(errorEntry.chainId).toBe('137')
1118
1118
  expect(errorEntry.error).toBe('Polygon execution failed')
@@ -1137,9 +1137,9 @@ describe('Deployer', () => {
1137
1137
  mockEngine.executeJob.mockRejectedValueOnce(new Error('First job failed'))
1138
1138
 
1139
1139
  const deployer = new Deployer(options)
1140
-
1140
+
1141
1141
  await expect(deployer.run()).rejects.toThrow('First job failed')
1142
-
1142
+
1143
1143
  // Should only attempt the first execution, not continue to other networks/jobs
1144
1144
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(1)
1145
1145
  })
@@ -1156,9 +1156,9 @@ describe('Deployer', () => {
1156
1156
  mockEngine.executeJob.mockResolvedValue(undefined)
1157
1157
 
1158
1158
  const deployer = new Deployer(options)
1159
-
1159
+
1160
1160
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
1161
-
1161
+
1162
1162
  // Should attempt all executions (2 networks * 1 job = 2 calls)
1163
1163
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(2)
1164
1164
  })
@@ -1175,9 +1175,9 @@ describe('Deployer', () => {
1175
1175
  mockEngine.executeJob.mockResolvedValue(undefined)
1176
1176
 
1177
1177
  const deployer = new Deployer(options)
1178
-
1178
+
1179
1179
  await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
1180
-
1180
+
1181
1181
  // Should attempt all executions
1182
1182
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(2)
1183
1183
  })
@@ -1193,9 +1193,9 @@ describe('Deployer', () => {
1193
1193
  mockEngine.executeJob.mockResolvedValue(undefined)
1194
1194
 
1195
1195
  const deployer = new Deployer(options)
1196
-
1196
+
1197
1197
  await expect(deployer.run()).resolves.not.toThrow()
1198
-
1198
+
1199
1199
  // Should complete all executions
1200
1200
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(2)
1201
1201
  })
@@ -1241,7 +1241,7 @@ describe('Deployer', () => {
1241
1241
  }
1242
1242
 
1243
1243
  const deployer = new Deployer(optionsWithIgnoreVerifyErrors)
1244
-
1244
+
1245
1245
  // Mock event emitter to track events
1246
1246
  const mockEmitEvent = jest.fn()
1247
1247
  ;(deployer as any).events = { emitEvent: mockEmitEvent }
@@ -1266,7 +1266,7 @@ describe('Deployer', () => {
1266
1266
  }
1267
1267
 
1268
1268
  const deployer = new Deployer(optionsWithoutIgnoreVerifyErrors)
1269
-
1269
+
1270
1270
  // Mock event emitter to track events
1271
1271
  const mockEmitEvent = jest.fn()
1272
1272
  ;(deployer as any).events = { emitEvent: mockEmitEvent }
@@ -1291,7 +1291,7 @@ describe('Deployer', () => {
1291
1291
  }
1292
1292
 
1293
1293
  const deployer = new Deployer(optionsWithIgnoreVerifyErrors)
1294
-
1294
+
1295
1295
  // Mock event emitter to track events
1296
1296
  const mockEmitEvent = jest.fn()
1297
1297
  ;(deployer as any).events = { emitEvent: mockEmitEvent }
@@ -1306,4 +1306,346 @@ describe('Deployer', () => {
1306
1306
  )
1307
1307
  })
1308
1308
  })
1309
- })
1309
+
1310
+ describe('job dependency failure handling', () => {
1311
+ // Test constants
1312
+ const TEST_BYTECODES = {
1313
+ SIMPLE_CONTRACT: '0x6080604052348015600e575f5ffd5b5060c180601a5f395ff3fe6080604052348015600e575f5ffd5b50600436106030575f3560e01c806390c52443146034578063d09de08a14604d575b5f5ffd5b603b5f5481565b60405190815260200160405180910390f35b60536055565b005b5f805490806061836068565b9190505550565b5f60018201608457634e487b7160e01b5f52601160045260245ffd5b506001019056fea264697066735822122061c8cc43c72d6b23b16f7a7337dd15b93d71eb94a9d5247911e39f486e1f94f964736f6c634300081e0033',
1314
+ BROKEN_BYTECODE: '0xff'
1315
+ } as const
1316
+
1317
+ it('should fail job B when job A fails due to dependency failure', async () => {
1318
+ // Create jobs with dependency
1319
+ const jobA: Job = {
1320
+ name: 'job-a',
1321
+ version: '1',
1322
+ description: 'Deploy a contract with broken bytecode (will fail)',
1323
+ actions: [
1324
+ {
1325
+ name: 'failing-deploy',
1326
+ type: 'create-contract',
1327
+ arguments: {
1328
+ bytecode: TEST_BYTECODES.BROKEN_BYTECODE,
1329
+ value: '0'
1330
+ },
1331
+ output: true
1332
+ }
1333
+ ]
1334
+ }
1335
+
1336
+ const jobB: Job = {
1337
+ name: 'job-b',
1338
+ version: '1',
1339
+ description: 'Use output from failed job A',
1340
+ depends_on: ['job-a'],
1341
+ actions: [
1342
+ {
1343
+ name: 'use-failed-output',
1344
+ type: 'send-transaction',
1345
+ arguments: {
1346
+ to: '{{job-a.failing-deploy.address}}',
1347
+ data: '0x',
1348
+ value: '0'
1349
+ }
1350
+ }
1351
+ ]
1352
+ }
1353
+
1354
+ // Setup mocks
1355
+ const mockJobs = new Map<string, Job>()
1356
+ mockJobs.set('job-a', jobA)
1357
+ mockJobs.set('job-b', jobB)
1358
+
1359
+ const mockTemplates = new Map<string, Template>()
1360
+
1361
+ MockProjectLoader.mockImplementation(() => ({
1362
+ load: jest.fn().mockResolvedValue(undefined),
1363
+ jobs: mockJobs,
1364
+ templates: mockTemplates
1365
+ } as any))
1366
+
1367
+ MockDependencyGraph.mockImplementation(() => ({
1368
+ getExecutionOrder: jest.fn().mockReturnValue(['job-a', 'job-b']),
1369
+ getDependencies: jest.fn().mockReturnValue(new Set())
1370
+ } as any))
1371
+
1372
+ // Mock engine to simulate job A failure
1373
+ MockExecutionEngine.mockImplementation(() => ({
1374
+ executeJob: jest.fn().mockImplementation(async (job: Job) => {
1375
+ if (job.name === 'job-a') {
1376
+ throw new Error('Contract deployment failed: invalid bytecode')
1377
+ }
1378
+ // job-b should not be executed due to dependency failure
1379
+ })
1380
+ } as any))
1381
+
1382
+ MockExecutionContext.mockImplementation(() => ({
1383
+ dispose: jest.fn().mockResolvedValue(undefined),
1384
+ getNetwork: jest.fn().mockReturnValue(mockNetwork1)
1385
+ } as any))
1386
+
1387
+ const deployer = new Deployer(deployerOptions)
1388
+
1389
+ // Execute deployer - should fail due to job A failure
1390
+ await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
1391
+
1392
+ // Verify that job A failed
1393
+ const results = (deployer as any).results
1394
+ const jobAResult = results.get('job-a')
1395
+ expect(jobAResult).toBeDefined()
1396
+ expect(jobAResult.outputs.get(mockNetwork1.chainId).status).toBe('error')
1397
+
1398
+ // Verify that job B failed due to dependency failure
1399
+ const jobBResult = results.get('job-b')
1400
+ expect(jobBResult).toBeDefined()
1401
+ const jobBError = jobBResult.outputs.get(mockNetwork1.chainId)
1402
+ expect(jobBError.status).toBe('error')
1403
+ expect(jobBError.data).toContain('depends on "job-a", but "job-a" failed')
1404
+ })
1405
+
1406
+ it('should fail job B when referencing non-existent job outputs', async () => {
1407
+ // Create job B that references non-existent job
1408
+ const jobB: Job = {
1409
+ name: 'job-b',
1410
+ version: '1',
1411
+ description: 'Reference non-existent job outputs',
1412
+ actions: [
1413
+ {
1414
+ name: 'use-output-step',
1415
+ type: 'send-transaction',
1416
+ arguments: {
1417
+ to: '{{non-existent-job.deploy-step.address}}',
1418
+ data: '0x',
1419
+ value: '0'
1420
+ }
1421
+ }
1422
+ ]
1423
+ }
1424
+
1425
+ // Setup mocks
1426
+ const mockJobs = new Map<string, Job>()
1427
+ mockJobs.set('job-b', jobB)
1428
+
1429
+ const mockTemplates = new Map<string, Template>()
1430
+
1431
+ MockProjectLoader.mockImplementation(() => ({
1432
+ load: jest.fn().mockResolvedValue(undefined),
1433
+ jobs: mockJobs,
1434
+ templates: mockTemplates
1435
+ } as any))
1436
+
1437
+ MockDependencyGraph.mockImplementation(() => ({
1438
+ getExecutionOrder: jest.fn().mockReturnValue(['job-b']),
1439
+ getDependencies: jest.fn().mockReturnValue(new Set())
1440
+ } as any))
1441
+
1442
+ // Mock engine to simulate expression resolution failure
1443
+ MockExecutionEngine.mockImplementation(() => ({
1444
+ executeJob: jest.fn().mockRejectedValue(new Error('Output for key "non-existent-job.deploy-step.address" not found in context'))
1445
+ } as any))
1446
+
1447
+ MockExecutionContext.mockImplementation(() => ({
1448
+ dispose: jest.fn().mockResolvedValue(undefined),
1449
+ getNetwork: jest.fn().mockReturnValue(mockNetwork1)
1450
+ } as any))
1451
+
1452
+ const deployer = new Deployer(deployerOptions)
1453
+
1454
+ // Execute deployer - should fail due to expression resolution
1455
+ await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
1456
+
1457
+ // Verify that job B failed
1458
+ const results = (deployer as any).results
1459
+ const jobBResult = results.get('job-b')
1460
+ expect(jobBResult).toBeDefined()
1461
+ expect(jobBResult.outputs.get(mockNetwork1.chainId).status).toBe('error')
1462
+ })
1463
+
1464
+ it('should handle job B with no dependency on job A but references job A outputs', async () => {
1465
+ // This tests the edge case where a job references outputs from another job
1466
+ // without explicitly declaring a dependency. This should work if job A succeeds.
1467
+
1468
+ const jobA: Job = {
1469
+ name: 'job-a',
1470
+ version: '1',
1471
+ description: 'Deploy a contract successfully',
1472
+ actions: [
1473
+ {
1474
+ name: 'deploy-step',
1475
+ type: 'create-contract',
1476
+ arguments: {
1477
+ bytecode: TEST_BYTECODES.SIMPLE_CONTRACT,
1478
+ value: '0'
1479
+ },
1480
+ output: true
1481
+ }
1482
+ ]
1483
+ }
1484
+
1485
+ const jobB: Job = {
1486
+ name: 'job-b',
1487
+ version: '1',
1488
+ description: 'Reference job A outputs without explicit dependency',
1489
+ // No depends_on field - this is the key difference
1490
+ actions: [
1491
+ {
1492
+ name: 'use-output-step',
1493
+ type: 'send-transaction',
1494
+ arguments: {
1495
+ to: '{{job-a.deploy-step.address}}',
1496
+ data: '0x',
1497
+ value: '0'
1498
+ }
1499
+ }
1500
+ ]
1501
+ }
1502
+
1503
+ // Setup mocks
1504
+ const mockJobs = new Map<string, Job>()
1505
+ mockJobs.set('job-a', jobA)
1506
+ mockJobs.set('job-b', jobB)
1507
+
1508
+ const mockTemplates = new Map<string, Template>()
1509
+
1510
+ MockProjectLoader.mockImplementation(() => ({
1511
+ load: jest.fn().mockResolvedValue(undefined),
1512
+ jobs: mockJobs,
1513
+ templates: mockTemplates
1514
+ } as any))
1515
+
1516
+ MockDependencyGraph.mockImplementation(() => ({
1517
+ getExecutionOrder: jest.fn().mockReturnValue(['job-a', 'job-b']),
1518
+ getDependencies: jest.fn().mockReturnValue(new Set())
1519
+ } as any))
1520
+
1521
+ // Mock engine to simulate successful execution
1522
+ MockExecutionEngine.mockImplementation(() => ({
1523
+ executeJob: jest.fn().mockImplementation(async (job: Job) => {
1524
+ if (job.name === 'job-a') {
1525
+ // Simulate successful job A execution
1526
+ return Promise.resolve()
1527
+ } else if (job.name === 'job-b') {
1528
+ // Simulate successful job B execution (no dependency check needed)
1529
+ return Promise.resolve()
1530
+ }
1531
+ })
1532
+ } as any))
1533
+
1534
+ MockExecutionContext.mockImplementation(() => ({
1535
+ dispose: jest.fn().mockResolvedValue(undefined),
1536
+ getNetwork: jest.fn().mockReturnValue(mockNetwork1),
1537
+ getOutputs: jest.fn().mockReturnValue(new Map([
1538
+ ['deploy-step.address', '0x5FbDB2315678afecb367f032d93F642f64180aa3'],
1539
+ ['deploy-step.hash', '0xmockdeployhash123']
1540
+ ]))
1541
+ } as any))
1542
+
1543
+ const deployer = new Deployer(deployerOptions)
1544
+
1545
+ // Execute deployer - should succeed since job A succeeds and job B has no explicit dependency
1546
+ await expect(deployer.run()).resolves.not.toThrow()
1547
+
1548
+ // Verify both jobs succeeded
1549
+ const results = (deployer as any).results
1550
+ const jobAResult = results.get('job-a')
1551
+ const jobBResult = results.get('job-b')
1552
+
1553
+ expect(jobAResult).toBeDefined()
1554
+ expect(jobAResult.outputs.get(mockNetwork1.chainId).status).toBe('success')
1555
+
1556
+ expect(jobBResult).toBeDefined()
1557
+ expect(jobBResult.outputs.get(mockNetwork1.chainId).status).toBe('success')
1558
+ })
1559
+
1560
+ it('should fail job B when job A fails, even with complex output references', async () => {
1561
+ // This tests the scenario where job B references multiple outputs from job A
1562
+ // and job A fails, ensuring job B fails due to dependency failure, not expression resolution
1563
+
1564
+ const jobA: Job = {
1565
+ name: 'job-a',
1566
+ version: '1',
1567
+ description: 'Deploy a contract with broken bytecode (will fail)',
1568
+ actions: [
1569
+ {
1570
+ name: 'failing-deploy',
1571
+ type: 'create-contract',
1572
+ arguments: {
1573
+ bytecode: TEST_BYTECODES.BROKEN_BYTECODE,
1574
+ value: '0'
1575
+ },
1576
+ output: true
1577
+ }
1578
+ ]
1579
+ }
1580
+
1581
+ const jobB: Job = {
1582
+ name: 'job-b',
1583
+ version: '1',
1584
+ description: 'Use multiple outputs from failed job A',
1585
+ depends_on: ['job-a'],
1586
+ actions: [
1587
+ {
1588
+ name: 'use-multiple-outputs',
1589
+ type: 'send-transaction',
1590
+ arguments: {
1591
+ to: '{{job-a.failing-deploy.address}}',
1592
+ data: '0x',
1593
+ value: '0'
1594
+ }
1595
+ }
1596
+ ]
1597
+ }
1598
+
1599
+ // Setup mocks
1600
+ const mockJobs = new Map<string, Job>()
1601
+ mockJobs.set('job-a', jobA)
1602
+ mockJobs.set('job-b', jobB)
1603
+
1604
+ const mockTemplates = new Map<string, Template>()
1605
+
1606
+ MockProjectLoader.mockImplementation(() => ({
1607
+ load: jest.fn().mockResolvedValue(undefined),
1608
+ jobs: mockJobs,
1609
+ templates: mockTemplates
1610
+ } as any))
1611
+
1612
+ MockDependencyGraph.mockImplementation(() => ({
1613
+ getExecutionOrder: jest.fn().mockReturnValue(['job-a', 'job-b']),
1614
+ getDependencies: jest.fn().mockReturnValue(new Set())
1615
+ } as any))
1616
+
1617
+ // Mock engine to simulate job A failure
1618
+ MockExecutionEngine.mockImplementation(() => ({
1619
+ executeJob: jest.fn().mockImplementation(async (job: Job) => {
1620
+ if (job.name === 'job-a') {
1621
+ throw new Error('Contract deployment failed: invalid bytecode')
1622
+ }
1623
+ // job-b should not be executed due to dependency failure
1624
+ })
1625
+ } as any))
1626
+
1627
+ MockExecutionContext.mockImplementation(() => ({
1628
+ dispose: jest.fn().mockResolvedValue(undefined),
1629
+ getNetwork: jest.fn().mockReturnValue(mockNetwork1)
1630
+ } as any))
1631
+
1632
+ const deployer = new Deployer(deployerOptions)
1633
+
1634
+ // Execute deployer - should fail due to job A failure
1635
+ await expect(deployer.run()).rejects.toThrow('One or more jobs failed during execution')
1636
+
1637
+ // Verify that job A failed
1638
+ const results = (deployer as any).results
1639
+ const jobAResult = results.get('job-a')
1640
+ expect(jobAResult).toBeDefined()
1641
+ expect(jobAResult.outputs.get(mockNetwork1.chainId).status).toBe('error')
1642
+
1643
+ // Verify that job B failed due to dependency failure
1644
+ const jobBResult = results.get('job-b')
1645
+ expect(jobBResult).toBeDefined()
1646
+ const jobBError = jobBResult.outputs.get(mockNetwork1.chainId)
1647
+ expect(jobBError.status).toBe('error')
1648
+ expect(jobBError.data).toContain('depends on "job-a", but "job-a" failed')
1649
+ })
1650
+ })
1651
+ })