@0xsequence/catapult 1.3.16 → 1.4.0

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.
Files changed (131) hide show
  1. package/README.md +250 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +1 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/index.d.ts +1 -0
  6. package/dist/commands/index.d.ts.map +1 -1
  7. package/dist/commands/index.js +1 -0
  8. package/dist/commands/index.js.map +1 -1
  9. package/dist/commands/list.d.ts.map +1 -1
  10. package/dist/commands/list.js +12 -0
  11. package/dist/commands/list.js.map +1 -1
  12. package/dist/commands/provenance.d.ts +3 -0
  13. package/dist/commands/provenance.d.ts.map +1 -0
  14. package/dist/commands/provenance.js +138 -0
  15. package/dist/commands/provenance.js.map +1 -0
  16. package/dist/commands/run.d.ts.map +1 -1
  17. package/dist/commands/run.js +7 -4
  18. package/dist/commands/run.js.map +1 -1
  19. package/dist/lib/__tests__/deployer.spec.js +118 -1
  20. package/dist/lib/__tests__/deployer.spec.js.map +1 -1
  21. package/dist/lib/__tests__/network-utils.spec.js +53 -8
  22. package/dist/lib/__tests__/network-utils.spec.js.map +1 -1
  23. package/dist/lib/__tests__/provenance.spec.d.ts +2 -0
  24. package/dist/lib/__tests__/provenance.spec.d.ts.map +1 -0
  25. package/dist/lib/__tests__/provenance.spec.js +205 -0
  26. package/dist/lib/__tests__/provenance.spec.js.map +1 -0
  27. package/dist/lib/contracts/__tests__/repository.spec.js +243 -0
  28. package/dist/lib/contracts/__tests__/repository.spec.js.map +1 -1
  29. package/dist/lib/contracts/repository.d.ts +9 -1
  30. package/dist/lib/contracts/repository.d.ts.map +1 -1
  31. package/dist/lib/contracts/repository.js +93 -7
  32. package/dist/lib/contracts/repository.js.map +1 -1
  33. package/dist/lib/core/__tests__/assert-action.spec.d.ts +2 -0
  34. package/dist/lib/core/__tests__/assert-action.spec.d.ts.map +1 -0
  35. package/dist/lib/core/__tests__/assert-action.spec.js +377 -0
  36. package/dist/lib/core/__tests__/assert-action.spec.js.map +1 -0
  37. package/dist/lib/core/__tests__/engine.spec.js +80 -0
  38. package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
  39. package/dist/lib/core/__tests__/loader.spec.js +29 -0
  40. package/dist/lib/core/__tests__/loader.spec.js.map +1 -1
  41. package/dist/lib/core/__tests__/resolver.spec.js +383 -0
  42. package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
  43. package/dist/lib/core/engine.d.ts.map +1 -1
  44. package/dist/lib/core/engine.js +33 -0
  45. package/dist/lib/core/engine.js.map +1 -1
  46. package/dist/lib/core/loader.d.ts +1 -0
  47. package/dist/lib/core/loader.d.ts.map +1 -1
  48. package/dist/lib/core/loader.js +6 -1
  49. package/dist/lib/core/loader.js.map +1 -1
  50. package/dist/lib/core/resolver.d.ts +2 -0
  51. package/dist/lib/core/resolver.d.ts.map +1 -1
  52. package/dist/lib/core/resolver.js +89 -0
  53. package/dist/lib/core/resolver.js.map +1 -1
  54. package/dist/lib/deployer.d.ts.map +1 -1
  55. package/dist/lib/deployer.js +21 -4
  56. package/dist/lib/deployer.js.map +1 -1
  57. package/dist/lib/index.d.ts +1 -0
  58. package/dist/lib/index.d.ts.map +1 -1
  59. package/dist/lib/index.js +1 -0
  60. package/dist/lib/index.js.map +1 -1
  61. package/dist/lib/parsers/__tests__/job.spec.js +77 -0
  62. package/dist/lib/parsers/__tests__/job.spec.js.map +1 -1
  63. package/dist/lib/parsers/__tests__/source.spec.d.ts +2 -0
  64. package/dist/lib/parsers/__tests__/source.spec.d.ts.map +1 -0
  65. package/dist/lib/parsers/__tests__/source.spec.js +121 -0
  66. package/dist/lib/parsers/__tests__/source.spec.js.map +1 -0
  67. package/dist/lib/parsers/index.d.ts +1 -0
  68. package/dist/lib/parsers/index.d.ts.map +1 -1
  69. package/dist/lib/parsers/index.js +1 -0
  70. package/dist/lib/parsers/index.js.map +1 -1
  71. package/dist/lib/parsers/job.d.ts.map +1 -1
  72. package/dist/lib/parsers/job.js +11 -0
  73. package/dist/lib/parsers/job.js.map +1 -1
  74. package/dist/lib/parsers/source.d.ts +4 -0
  75. package/dist/lib/parsers/source.d.ts.map +1 -0
  76. package/dist/lib/parsers/source.js +107 -0
  77. package/dist/lib/parsers/source.js.map +1 -0
  78. package/dist/lib/provenance.d.ts +34 -0
  79. package/dist/lib/provenance.d.ts.map +1 -0
  80. package/dist/lib/provenance.js +645 -0
  81. package/dist/lib/provenance.js.map +1 -0
  82. package/dist/lib/types/actions.d.ts +18 -2
  83. package/dist/lib/types/actions.d.ts.map +1 -1
  84. package/dist/lib/types/actions.js +1 -0
  85. package/dist/lib/types/actions.js.map +1 -1
  86. package/dist/lib/types/contracts.d.ts +3 -0
  87. package/dist/lib/types/contracts.d.ts.map +1 -1
  88. package/dist/lib/types/definitions.d.ts +1 -0
  89. package/dist/lib/types/definitions.d.ts.map +1 -1
  90. package/dist/lib/types/index.d.ts +1 -0
  91. package/dist/lib/types/index.d.ts.map +1 -1
  92. package/dist/lib/types/index.js +1 -0
  93. package/dist/lib/types/index.js.map +1 -1
  94. package/dist/lib/types/source.d.ts +24 -0
  95. package/dist/lib/types/source.d.ts.map +1 -0
  96. package/dist/lib/types/source.js +3 -0
  97. package/dist/lib/types/source.js.map +1 -0
  98. package/dist/lib/types/values.d.ts +33 -1
  99. package/dist/lib/types/values.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/cli.ts +3 -2
  102. package/src/commands/index.ts +2 -1
  103. package/src/commands/list.ts +14 -1
  104. package/src/commands/provenance.ts +120 -0
  105. package/src/commands/run.ts +11 -6
  106. package/src/lib/__tests__/deployer.spec.ts +177 -1
  107. package/src/lib/__tests__/network-utils.spec.ts +63 -14
  108. package/src/lib/__tests__/provenance.spec.ts +208 -0
  109. package/src/lib/contracts/__tests__/repository.spec.ts +270 -2
  110. package/src/lib/contracts/repository.ts +112 -14
  111. package/src/lib/core/__tests__/assert-action.spec.ts +474 -0
  112. package/src/lib/core/__tests__/engine.spec.ts +116 -0
  113. package/src/lib/core/__tests__/loader.spec.ts +34 -1
  114. package/src/lib/core/__tests__/resolver.spec.ts +444 -1
  115. package/src/lib/core/engine.ts +52 -0
  116. package/src/lib/core/loader.ts +8 -2
  117. package/src/lib/core/resolver.ts +116 -0
  118. package/src/lib/deployer.ts +28 -4
  119. package/src/lib/index.ts +4 -1
  120. package/src/lib/parsers/__tests__/job.spec.ts +81 -0
  121. package/src/lib/parsers/__tests__/source.spec.ts +134 -0
  122. package/src/lib/parsers/index.ts +1 -0
  123. package/src/lib/parsers/job.ts +14 -2
  124. package/src/lib/parsers/source.ts +129 -0
  125. package/src/lib/provenance.ts +785 -0
  126. package/src/lib/types/actions.ts +22 -1
  127. package/src/lib/types/contracts.ts +4 -1
  128. package/src/lib/types/definitions.ts +7 -0
  129. package/src/lib/types/index.ts +1 -0
  130. package/src/lib/types/source.ts +26 -0
  131. package/src/lib/types/values.ts +71 -0
@@ -0,0 +1,474 @@
1
+ import { ExecutionEngine } from '../engine'
2
+ import { ExecutionContext } from '../context'
3
+ import { ContractRepository } from '../../contracts/repository'
4
+ import { Action, Network } from '../../types'
5
+ import { VerificationPlatformRegistry } from '../../verification/etherscan'
6
+
7
+ describe('Assert Action', () => {
8
+ let engine: ExecutionEngine
9
+ let context: ExecutionContext
10
+ let mockNetwork: Network
11
+ let mockRegistry: ContractRepository
12
+ let templates: Map<string, any>
13
+
14
+ beforeEach(() => {
15
+ mockNetwork = { name: 'testnet', chainId: 999, rpcUrl: 'http://localhost:8545' }
16
+ mockRegistry = new ContractRepository()
17
+
18
+ // Create a mock context that doesn't require a real connection
19
+ context = {
20
+ getNetwork: () => mockNetwork,
21
+ setOutput: jest.fn(),
22
+ getOutput: jest.fn(),
23
+ setContextPath: jest.fn(),
24
+ getContextPath: jest.fn(),
25
+ dispose: jest.fn()
26
+ } as any
27
+
28
+ templates = new Map()
29
+ const verificationRegistry = new VerificationPlatformRegistry()
30
+ engine = new ExecutionEngine(templates, { verificationRegistry })
31
+ })
32
+
33
+ describe('assert primitive action', () => {
34
+ it('should pass when eq comparison is true', async () => {
35
+ const action: Action = {
36
+ type: 'assert',
37
+ name: 'test-assert-eq',
38
+ arguments: {
39
+ actual: '42',
40
+ eq: '42'
41
+ }
42
+ }
43
+
44
+ await (engine as any).executePrimitive(action, context, new Map())
45
+
46
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-eq.actual', '42')
47
+ })
48
+
49
+ it('should fail when eq comparison is false', async () => {
50
+ const action: Action = {
51
+ type: 'assert',
52
+ name: 'test-assert-fail',
53
+ arguments: {
54
+ actual: '42',
55
+ eq: '99'
56
+ }
57
+ }
58
+
59
+ await expect(
60
+ (engine as any).executePrimitive(action, context, new Map())
61
+ ).rejects.toThrow(/assert failed.*actual=42.*expected=99.*op=eq/)
62
+ })
63
+
64
+ it('should include custom message on failure', async () => {
65
+ const action: Action = {
66
+ type: 'assert',
67
+ name: 'test-assert-msg',
68
+ arguments: {
69
+ actual: '10',
70
+ eq: '20',
71
+ message: 'balance mismatch'
72
+ }
73
+ }
74
+
75
+ await expect(
76
+ (engine as any).executePrimitive(action, context, new Map())
77
+ ).rejects.toThrow(/assert failed: balance mismatch.*actual=10.*expected=20.*op=eq/)
78
+ })
79
+
80
+ it('should pass neq comparison', async () => {
81
+ const action: Action = {
82
+ type: 'assert',
83
+ name: 'test-assert-neq',
84
+ arguments: {
85
+ actual: '10',
86
+ neq: '20'
87
+ }
88
+ }
89
+
90
+ await (engine as any).executePrimitive(action, context, new Map())
91
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-neq.actual', '10')
92
+ })
93
+
94
+ it('should fail neq comparison when values are equal', async () => {
95
+ const action: Action = {
96
+ type: 'assert',
97
+ name: 'test-assert-neq-fail',
98
+ arguments: {
99
+ actual: '10',
100
+ neq: '10'
101
+ }
102
+ }
103
+
104
+ await expect(
105
+ (engine as any).executePrimitive(action, context, new Map())
106
+ ).rejects.toThrow(/assert failed.*op=neq/)
107
+ })
108
+
109
+ it('should pass gte comparison', async () => {
110
+ const action: Action = {
111
+ type: 'assert',
112
+ name: 'test-assert-gte',
113
+ arguments: {
114
+ actual: '100',
115
+ gte: '50'
116
+ }
117
+ }
118
+
119
+ await (engine as any).executePrimitive(action, context, new Map())
120
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-gte.actual', '100')
121
+ })
122
+
123
+ it('should pass gte when equal', async () => {
124
+ const action: Action = {
125
+ type: 'assert',
126
+ name: 'test-assert-gte-equal',
127
+ arguments: {
128
+ actual: '50',
129
+ gte: '50'
130
+ }
131
+ }
132
+
133
+ await (engine as any).executePrimitive(action, context, new Map())
134
+ })
135
+
136
+ it('should fail gte when less', async () => {
137
+ const action: Action = {
138
+ type: 'assert',
139
+ name: 'test-assert-gte-fail',
140
+ arguments: {
141
+ actual: '10',
142
+ gte: '100'
143
+ }
144
+ }
145
+
146
+ await expect(
147
+ (engine as any).executePrimitive(action, context, new Map())
148
+ ).rejects.toThrow(/assert failed.*op=gte/)
149
+ })
150
+
151
+ it('should pass lt comparison', async () => {
152
+ const action: Action = {
153
+ type: 'assert',
154
+ name: 'test-assert-lt',
155
+ arguments: {
156
+ actual: '10',
157
+ lt: '100'
158
+ }
159
+ }
160
+
161
+ await (engine as any).executePrimitive(action, context, new Map())
162
+ })
163
+
164
+ it('should fail lt when greater', async () => {
165
+ const action: Action = {
166
+ type: 'assert',
167
+ name: 'test-assert-lt-fail',
168
+ arguments: {
169
+ actual: '100',
170
+ lt: '10'
171
+ }
172
+ }
173
+
174
+ await expect(
175
+ (engine as any).executePrimitive(action, context, new Map())
176
+ ).rejects.toThrow(/assert failed.*op=lt/)
177
+ })
178
+
179
+ it('should pass lte comparison', async () => {
180
+ const action: Action = {
181
+ type: 'assert',
182
+ name: 'test-assert-lte',
183
+ arguments: {
184
+ actual: '10',
185
+ lte: '100'
186
+ }
187
+ }
188
+
189
+ await (engine as any).executePrimitive(action, context, new Map())
190
+ })
191
+
192
+ it('should pass lte when equal', async () => {
193
+ const action: Action = {
194
+ type: 'assert',
195
+ name: 'test-assert-lte-equal',
196
+ arguments: {
197
+ actual: '100',
198
+ lte: '100'
199
+ }
200
+ }
201
+
202
+ await (engine as any).executePrimitive(action, context, new Map())
203
+ })
204
+
205
+ it('should fail lte when greater', async () => {
206
+ const action: Action = {
207
+ type: 'assert',
208
+ name: 'test-assert-lte-fail',
209
+ arguments: {
210
+ actual: '100',
211
+ lte: '10'
212
+ }
213
+ }
214
+
215
+ await expect(
216
+ (engine as any).executePrimitive(action, context, new Map())
217
+ ).rejects.toThrow(/assert failed.*op=lte/)
218
+ })
219
+
220
+ it('should pass gt comparison', async () => {
221
+ const action: Action = {
222
+ type: 'assert',
223
+ name: 'test-assert-gt',
224
+ arguments: {
225
+ actual: '100',
226
+ gt: '10'
227
+ }
228
+ }
229
+
230
+ await (engine as any).executePrimitive(action, context, new Map())
231
+ })
232
+
233
+ it('should fail gt when less', async () => {
234
+ const action: Action = {
235
+ type: 'assert',
236
+ name: 'test-assert-gt-fail',
237
+ arguments: {
238
+ actual: '10',
239
+ gt: '100'
240
+ }
241
+ }
242
+
243
+ await expect(
244
+ (engine as any).executePrimitive(action, context, new Map())
245
+ ).rejects.toThrow(/assert failed.*op=gt/)
246
+ })
247
+
248
+ it('should use `to` + `signature` form (call resolver)', async () => {
249
+ // Mock the resolver to simulate a call returning a value
250
+ const mockResolver = {
251
+ resolve: jest.fn(async (value: any, ctx: any, scope: any) => {
252
+ if (value.type === 'call') {
253
+ return '0xDepositManagerAddress'
254
+ }
255
+ if (value.type === 'basic-arithmetic') {
256
+ const [a, b] = value.arguments.values
257
+ if (value.arguments.operation === 'eq') {
258
+ return a === b
259
+ }
260
+ }
261
+ return value
262
+ })
263
+ }
264
+ ;(engine as any).resolver = mockResolver
265
+
266
+ const action: Action = {
267
+ type: 'assert',
268
+ name: 'test-assert-call',
269
+ arguments: {
270
+ to: '0xSomeProxyAddress',
271
+ signature: 'depositManager() returns (address)',
272
+ eq: '0xDepositManagerAddress'
273
+ }
274
+ }
275
+
276
+ await (engine as any).executePrimitive(action, context, new Map())
277
+
278
+ expect(mockResolver.resolve).toHaveBeenCalledWith(
279
+ { type: 'call', arguments: { to: '0xSomeProxyAddress', signature: 'depositManager() returns (address)', values: [] } },
280
+ context,
281
+ new Map()
282
+ )
283
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-call.actual', '0xDepositManagerAddress')
284
+ })
285
+
286
+ it('should use `actual` form with read-balance resolver', async () => {
287
+ // Mock the resolver to simulate a read-balance returning a value
288
+ const mockResolver = {
289
+ resolve: jest.fn(async (value: any, ctx: any, scope: any) => {
290
+ if (value.type === 'read-balance') {
291
+ return '1000000000000000000' // 1 ETH
292
+ }
293
+ if (value.type === 'basic-arithmetic') {
294
+ const [a, b] = value.arguments.values
295
+ if (value.arguments.operation === 'gte') {
296
+ return BigInt(a) >= BigInt(b)
297
+ }
298
+ }
299
+ return value
300
+ })
301
+ }
302
+ ;(engine as any).resolver = mockResolver
303
+
304
+ const action: Action = {
305
+ type: 'assert',
306
+ name: 'test-assert-read-balance',
307
+ arguments: {
308
+ actual: { type: 'read-balance', arguments: { address: '0xDeployer' } },
309
+ gte: '1000000000000000000',
310
+ message: 'deployer underfunded'
311
+ }
312
+ }
313
+
314
+ await (engine as any).executePrimitive(action, context, new Map())
315
+
316
+ expect(mockResolver.resolve).toHaveBeenCalledWith(
317
+ { type: 'read-balance', arguments: { address: '0xDeployer' } },
318
+ context,
319
+ new Map()
320
+ )
321
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-read-balance.actual', '1000000000000000000')
322
+ })
323
+
324
+ it('should not store outputs when action has no name', async () => {
325
+ const action: Action = {
326
+ type: 'assert',
327
+ arguments: {
328
+ actual: '42',
329
+ eq: '42'
330
+ }
331
+ }
332
+
333
+ await (engine as any).executePrimitive(action, context, new Map())
334
+
335
+ expect(context.setOutput).not.toHaveBeenCalled()
336
+ })
337
+
338
+ it('should not store outputs when action has custom output', async () => {
339
+ const action: Action = {
340
+ type: 'assert',
341
+ name: 'test-assert-custom',
342
+ arguments: {
343
+ actual: '42',
344
+ eq: '42'
345
+ }
346
+ }
347
+
348
+ await (engine as any).executePrimitive(action, context, new Map(), true)
349
+
350
+ // With hasCustomOutput=true, the .actual output should not be stored by the assert case
351
+ // (the custom output handling is done elsewhere in executeAction)
352
+ const setOutputCalls = (context.setOutput as jest.Mock).mock.calls
353
+ const actualOutputs = setOutputCalls.filter((c: any[]) => c[0].includes('.actual'))
354
+ expect(actualOutputs.length).toBe(0)
355
+ })
356
+
357
+ it('should fail when no comparator key is provided', async () => {
358
+ const action: Action = {
359
+ type: 'assert',
360
+ name: 'test-assert-no-comparator',
361
+ arguments: {
362
+ actual: '42'
363
+ }
364
+ }
365
+
366
+ await expect(
367
+ (engine as any).executePrimitive(action, context, new Map())
368
+ ).rejects.toThrow(/assert must have exactly one of/)
369
+ })
370
+
371
+ it('should fail when more than one comparator key is provided', async () => {
372
+ const action: Action = {
373
+ type: 'assert',
374
+ name: 'test-assert-multi-comparator',
375
+ arguments: {
376
+ actual: '42',
377
+ eq: '42',
378
+ gte: '1'
379
+ }
380
+ }
381
+
382
+ await expect(
383
+ (engine as any).executePrimitive(action, context, new Map())
384
+ ).rejects.toThrow(/assert must have exactly one comparator, but got: eq, gte/)
385
+ })
386
+
387
+ it('should resolve values from context variables', async () => {
388
+ // Mock the resolver to simulate template variable resolution
389
+ // returning the same value for both actual and expected
390
+ const mockResolver = {
391
+ resolve: jest.fn(async (value: any, ctx: any, scope: any) => {
392
+ if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
393
+ return 'resolved-value'
394
+ }
395
+ if (value.type === 'basic-arithmetic') {
396
+ const [a, b] = value.arguments.values
397
+ if (value.arguments.operation === 'eq') {
398
+ return a === b
399
+ }
400
+ }
401
+ return value
402
+ })
403
+ }
404
+ ;(engine as any).resolver = mockResolver
405
+
406
+ const action: Action = {
407
+ type: 'assert',
408
+ name: 'test-assert-context',
409
+ arguments: {
410
+ actual: '{{myValue}}',
411
+ eq: '{{myExpected}}'
412
+ }
413
+ }
414
+
415
+ await (engine as any).executePrimitive(action, context, new Map())
416
+
417
+ // Both {{myValue}} and {{myExpected}} resolve to 'resolved-value', so eq returns true
418
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-context.actual', 'resolved-value')
419
+ })
420
+
421
+ it('should handle boolean values in eq comparison', async () => {
422
+ const action: Action = {
423
+ type: 'assert',
424
+ name: 'test-assert-bool',
425
+ arguments: {
426
+ actual: true,
427
+ eq: true
428
+ }
429
+ }
430
+
431
+ await (engine as any).executePrimitive(action, context, new Map())
432
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-bool.actual', true)
433
+ })
434
+
435
+ it('should handle large number comparisons', async () => {
436
+ const action: Action = {
437
+ type: 'assert',
438
+ name: 'test-assert-large',
439
+ arguments: {
440
+ actual: '115792089237316195423570985008687907853269984665640564039457584007913129639935',
441
+ gte: '10000000000000000000000000000000000000000000000000000000000000000000000000000'
442
+ }
443
+ }
444
+
445
+ await (engine as any).executePrimitive(action, context, new Map())
446
+ expect(context.setOutput).toHaveBeenCalledWith('test-assert-large.actual', '115792089237316195423570985008687907853269984665640564039457584007913129639935')
447
+ })
448
+
449
+ it('should describe call form as signature in error message', async () => {
450
+ const mockResolver = {
451
+ resolve: jest.fn(async (value: any, ctx: any, scope: any) => {
452
+ if (value.type === 'call') return 'wrong-address'
453
+ if (value.type === 'basic-arithmetic') return false
454
+ return value
455
+ })
456
+ }
457
+ ;(engine as any).resolver = mockResolver
458
+
459
+ const action: Action = {
460
+ type: 'assert',
461
+ name: 'test-assert-call-desc',
462
+ arguments: {
463
+ to: '0xProxy',
464
+ signature: 'getOwner() returns (address)',
465
+ eq: '0xCorrectOwner'
466
+ }
467
+ }
468
+
469
+ await expect(
470
+ (engine as any).executePrimitive(action, context, new Map())
471
+ ).rejects.toThrow(/assert failed.*getOwner\(\) returns \(address\)/)
472
+ })
473
+ })
474
+ })
@@ -1886,4 +1886,120 @@ describe('ExecutionEngine', () => {
1886
1886
  )
1887
1887
  })
1888
1888
  })
1889
+
1890
+ describe('skip_if behavior', () => {
1891
+ it('should NOT post-check skip_if after job execution', async () => {
1892
+ // This is the key semantic difference: skip_if is ONLY a pre-skip gate
1893
+ // The pre-skip check for skip_if happens in the deployer, not in executeJob
1894
+ // So executeJob runs normally without any post-check for skip_if
1895
+ const jobWithSkipIf: Job = {
1896
+ name: 'skip-if-job',
1897
+ version: '1.0.0',
1898
+ skip_if: [
1899
+ { type: 'basic-arithmetic', arguments: { operation: 'eq', values: ['{{should_skip}}', 1] } }
1900
+ ],
1901
+ actions: [
1902
+ {
1903
+ name: 'generate-payload',
1904
+ type: 'static',
1905
+ arguments: { value: 'payload-data' }
1906
+ }
1907
+ ]
1908
+ }
1909
+
1910
+ // Set context so skip_if would evaluate to false if checked
1911
+ context.setOutput('should_skip', 0)
1912
+
1913
+ // This should succeed because skip_if is NOT post-checked in executeJob
1914
+ // The job has no skip_condition, so no post-check happens
1915
+ await expect(engine.executeJob(jobWithSkipIf, context)).resolves.not.toThrow()
1916
+
1917
+ // Action should have been executed
1918
+ expect(context.getOutput('generate-payload.value')).toBe('payload-data')
1919
+ })
1920
+
1921
+ it('should post-check skip_condition but NOT skip_if', async () => {
1922
+ // Job with skip_condition - should post-check and fail
1923
+ const jobWithSkipCondition: Job = {
1924
+ name: 'skip-condition-job',
1925
+ version: '1.0.0',
1926
+ skip_condition: [
1927
+ { type: 'basic-arithmetic', arguments: { operation: 'eq', values: ['{{converged}}', 1] } }
1928
+ ],
1929
+ actions: [
1930
+ {
1931
+ name: 'do-work',
1932
+ type: 'static',
1933
+ arguments: { value: 'work-done' }
1934
+ }
1935
+ ]
1936
+ }
1937
+
1938
+ // Pre-execution: skip_condition is false (0) -> job runs
1939
+ context.setOutput('converged', 0)
1940
+
1941
+ // Post-execution: skip_condition is STILL false -> should FAIL
1942
+ // The post-check re-evaluates skip_condition and throws if not true
1943
+ await expect(engine.executeJob(jobWithSkipCondition, context)).rejects.toThrow('failed post-execution check')
1944
+ })
1945
+
1946
+ it('should not post-check skip_if (only skip_condition is post-checked)', async () => {
1947
+ // Job with only skip_if - no post-check should happen
1948
+ const jobWithOnlySkipIf: Job = {
1949
+ name: 'skip-if-only-job',
1950
+ version: '1.0.0',
1951
+ skip_if: [
1952
+ { type: 'basic-arithmetic', arguments: { operation: 'eq', values: ['{{should_skip}}', 1] } }
1953
+ ],
1954
+ actions: [
1955
+ {
1956
+ name: 'generate-artifact',
1957
+ type: 'static',
1958
+ arguments: { value: 'artifact-data' }
1959
+ }
1960
+ ]
1961
+ }
1962
+
1963
+ // Set context so skip_if would be true if checked post-execution
1964
+ context.setOutput('should_skip', 1)
1965
+
1966
+ // But since skip_if is NOT post-checked (only skip_condition is),
1967
+ // and the job has no skip_condition, no post-check happens
1968
+ await expect(engine.executeJob(jobWithOnlySkipIf, context)).resolves.not.toThrow()
1969
+
1970
+ // Action should have been executed
1971
+ expect(context.getOutput('generate-artifact.value')).toBe('artifact-data')
1972
+ })
1973
+
1974
+ it('should post-check skip_condition when both skip_if and skip_condition are present', async () => {
1975
+ // Job with BOTH skip_if and skip_condition
1976
+ // skip_if is only pre-checked (in deployer)
1977
+ // skip_condition is both pre-checked and post-checked
1978
+ const jobWithBoth: Job = {
1979
+ name: 'both-conditions-job',
1980
+ version: '1.0.0',
1981
+ skip_condition: [
1982
+ { type: 'basic-arithmetic', arguments: { operation: 'eq', values: ['{{converged}}', 1] } }
1983
+ ],
1984
+ skip_if: [
1985
+ { type: 'basic-arithmetic', arguments: { operation: 'eq', values: ['{{should_skip}}', 1] } }
1986
+ ],
1987
+ actions: [
1988
+ {
1989
+ name: 'do-work',
1990
+ type: 'static',
1991
+ arguments: { value: 'work-done' }
1992
+ }
1993
+ ]
1994
+ }
1995
+
1996
+ // Pre-execution: both conditions are false -> job runs
1997
+ context.setOutput('should_skip', 0)
1998
+ context.setOutput('converged', 0)
1999
+
2000
+ // Post-execution: skip_condition is STILL false -> should FAIL
2001
+ // skip_if is NOT post-checked
2002
+ await expect(engine.executeJob(jobWithBoth, context)).rejects.toThrow('failed post-execution check')
2003
+ })
2004
+ })
1889
2005
  })
@@ -154,6 +154,39 @@ actions: []`
154
154
  expect(loader.jobs.size).toBe(1)
155
155
  expect(loader.jobs.has('valid-job')).toBe(true)
156
156
  })
157
+
158
+ it('should skip known non-job YAML documents under jobs without warning', async () => {
159
+ const jobsDir = path.join(tempDir, 'jobs')
160
+ const buildInfoDir = path.join(jobsDir, 'stack', 'build-info', 'rc-5')
161
+ await fs.mkdir(buildInfoDir, { recursive: true })
162
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
163
+
164
+ const validJobYaml = `name: "valid-job"
165
+ version: "1"
166
+ actions: []`
167
+
168
+ await fs.writeFile(path.join(jobsDir, 'valid-job.yaml'), validJobYaml)
169
+ await fs.writeFile(path.join(buildInfoDir, 'source.yaml'), `
170
+ type: source
171
+ build_info: {}
172
+ `)
173
+ await fs.writeFile(path.join(jobsDir, 'constants.yaml'), `
174
+ type: constants
175
+ constants:
176
+ value: 1
177
+ `)
178
+
179
+ try {
180
+ const loader = new ProjectLoader(tempDir)
181
+ await loader.load()
182
+
183
+ expect(loader.jobs.size).toBe(1)
184
+ expect(loader.jobs.has('valid-job')).toBe(true)
185
+ expect(warnSpy).not.toHaveBeenCalled()
186
+ } finally {
187
+ warnSpy.mockRestore()
188
+ }
189
+ })
157
190
  })
158
191
 
159
192
  describe('template loading', () => {
@@ -331,4 +364,4 @@ actions:
331
364
  expect(job.description).toBe('Deploy a test contract')
332
365
  })
333
366
  })
334
- })
367
+ })