@livingdata/pipex 0.0.8 → 0.0.10

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 (90) hide show
  1. package/README.md +186 -16
  2. package/dist/__tests__/errors.js +162 -0
  3. package/dist/__tests__/helpers.js +41 -0
  4. package/dist/__tests__/types.js +8 -0
  5. package/dist/cli/__tests__/condition.js +23 -0
  6. package/dist/cli/__tests__/dag.js +154 -0
  7. package/dist/cli/__tests__/pipeline-loader.js +267 -0
  8. package/dist/cli/__tests__/pipeline-runner.js +257 -0
  9. package/dist/cli/__tests__/state-persistence.js +80 -0
  10. package/dist/cli/__tests__/state.js +58 -0
  11. package/dist/cli/__tests__/step-runner.js +116 -0
  12. package/dist/cli/commands/bundle.js +35 -0
  13. package/dist/cli/commands/cat.js +54 -0
  14. package/dist/cli/commands/clean.js +22 -0
  15. package/dist/cli/commands/exec.js +89 -0
  16. package/dist/cli/commands/export.js +32 -0
  17. package/dist/cli/commands/inspect.js +58 -0
  18. package/dist/cli/commands/list.js +39 -0
  19. package/dist/cli/commands/logs.js +54 -0
  20. package/dist/cli/commands/prune.js +26 -0
  21. package/dist/cli/commands/rm-step.js +41 -0
  22. package/dist/cli/commands/rm.js +27 -0
  23. package/dist/cli/commands/run-bundle.js +59 -0
  24. package/dist/cli/commands/run.js +44 -0
  25. package/dist/cli/commands/show.js +108 -0
  26. package/dist/cli/condition.js +11 -0
  27. package/dist/cli/dag.js +143 -0
  28. package/dist/cli/index.js +24 -105
  29. package/dist/cli/interactive-reporter.js +227 -0
  30. package/dist/cli/pipeline-loader.js +10 -110
  31. package/dist/cli/pipeline-runner.js +256 -111
  32. package/dist/cli/reporter.js +2 -107
  33. package/dist/cli/state.js +30 -9
  34. package/dist/cli/step-loader.js +25 -0
  35. package/dist/cli/step-resolver.js +111 -0
  36. package/dist/cli/step-runner.js +226 -0
  37. package/dist/cli/utils.js +3 -0
  38. package/dist/core/__tests__/bundle.js +663 -0
  39. package/dist/core/__tests__/condition.js +23 -0
  40. package/dist/core/__tests__/dag.js +154 -0
  41. package/dist/core/__tests__/env-file.test.js +41 -0
  42. package/dist/core/__tests__/event-aggregator.js +244 -0
  43. package/dist/core/__tests__/pipeline-loader.js +267 -0
  44. package/dist/core/__tests__/pipeline-runner.js +257 -0
  45. package/dist/core/__tests__/state-persistence.js +80 -0
  46. package/dist/core/__tests__/state.js +58 -0
  47. package/dist/core/__tests__/step-runner.js +118 -0
  48. package/dist/core/__tests__/stream-reporter.js +142 -0
  49. package/dist/core/__tests__/transport.js +50 -0
  50. package/dist/core/__tests__/utils.js +40 -0
  51. package/dist/core/bundle.js +130 -0
  52. package/dist/core/condition.js +11 -0
  53. package/dist/core/dag.js +143 -0
  54. package/dist/core/env-file.js +6 -0
  55. package/dist/core/event-aggregator.js +114 -0
  56. package/dist/core/index.js +14 -0
  57. package/dist/core/pipeline-loader.js +81 -0
  58. package/dist/core/pipeline-runner.js +360 -0
  59. package/dist/core/reporter.js +11 -0
  60. package/dist/core/state.js +110 -0
  61. package/dist/core/step-loader.js +25 -0
  62. package/dist/core/step-resolver.js +117 -0
  63. package/dist/core/step-runner.js +225 -0
  64. package/dist/core/stream-reporter.js +41 -0
  65. package/dist/core/transport.js +9 -0
  66. package/dist/core/utils.js +56 -0
  67. package/dist/engine/__tests__/workspace.js +288 -0
  68. package/dist/engine/docker-executor.js +32 -6
  69. package/dist/engine/index.js +1 -0
  70. package/dist/engine/workspace.js +164 -66
  71. package/dist/errors.js +122 -0
  72. package/dist/index.js +3 -0
  73. package/dist/kits/__tests__/index.js +23 -0
  74. package/dist/kits/builtin/__tests__/node.js +74 -0
  75. package/dist/kits/builtin/__tests__/python.js +67 -0
  76. package/dist/kits/builtin/__tests__/shell.js +74 -0
  77. package/dist/kits/builtin/node.js +10 -5
  78. package/dist/kits/builtin/python.js +10 -5
  79. package/dist/kits/builtin/shell.js +2 -1
  80. package/dist/kits/index.js +2 -1
  81. package/package.json +6 -3
  82. package/dist/cli/types.js +0 -3
  83. package/dist/engine/docker-runtime.js +0 -65
  84. package/dist/engine/runtime.js +0 -2
  85. package/dist/kits/bash.js +0 -19
  86. package/dist/kits/builtin/bash.js +0 -19
  87. package/dist/kits/node.js +0 -56
  88. package/dist/kits/python.js +0 -51
  89. package/dist/kits/types.js +0 -1
  90. package/dist/reporter.js +0 -13
@@ -0,0 +1,154 @@
1
+ import test from 'ava';
2
+ import { CyclicDependencyError, ValidationError } from '../../errors.js';
3
+ import { buildGraph, validateGraph, topologicalLevels, subgraph, leafNodes } from '../dag.js';
4
+ function makeStep(id, inputs) {
5
+ return {
6
+ id,
7
+ image: 'alpine:3.20',
8
+ cmd: ['echo', id],
9
+ inputs: inputs?.map(i => ({ step: i.step, optional: i.optional }))
10
+ };
11
+ }
12
+ // -- buildGraph --------------------------------------------------------------
13
+ test('buildGraph: linear pipeline', t => {
14
+ const steps = [makeStep('a'), makeStep('b', [{ step: 'a' }]), makeStep('c', [{ step: 'b' }])];
15
+ const graph = buildGraph(steps);
16
+ t.deepEqual([...graph.get('a')], []);
17
+ t.deepEqual([...graph.get('b')], ['a']);
18
+ t.deepEqual([...graph.get('c')], ['b']);
19
+ });
20
+ test('buildGraph: diamond', t => {
21
+ const steps = [
22
+ makeStep('a'),
23
+ makeStep('b', [{ step: 'a' }]),
24
+ makeStep('c', [{ step: 'a' }]),
25
+ makeStep('d', [{ step: 'b' }, { step: 'c' }])
26
+ ];
27
+ const graph = buildGraph(steps);
28
+ t.is(graph.get('a').size, 0);
29
+ t.deepEqual([...graph.get('d')].sort(), ['b', 'c']);
30
+ });
31
+ test('buildGraph: step without inputs has empty deps', t => {
32
+ const steps = [makeStep('a'), makeStep('b')];
33
+ const graph = buildGraph(steps);
34
+ t.is(graph.get('a').size, 0);
35
+ t.is(graph.get('b').size, 0);
36
+ });
37
+ // -- validateGraph -----------------------------------------------------------
38
+ test('validateGraph: detects cycle', t => {
39
+ const steps = [
40
+ makeStep('a', [{ step: 'b' }]),
41
+ makeStep('b', [{ step: 'a' }])
42
+ ];
43
+ const graph = buildGraph(steps);
44
+ t.throws(() => {
45
+ validateGraph(graph, steps);
46
+ }, { instanceOf: CyclicDependencyError });
47
+ });
48
+ test('validateGraph: detects missing ref', t => {
49
+ const steps = [makeStep('a', [{ step: 'missing' }])];
50
+ const graph = buildGraph(steps);
51
+ t.throws(() => {
52
+ validateGraph(graph, steps);
53
+ }, { instanceOf: ValidationError, message: /unknown step 'missing'/ });
54
+ });
55
+ test('validateGraph: optional ref to unknown step is OK', t => {
56
+ const steps = [makeStep('a', [{ step: 'missing', optional: true }])];
57
+ const graph = buildGraph(steps);
58
+ t.notThrows(() => {
59
+ validateGraph(graph, steps);
60
+ });
61
+ });
62
+ test('validateGraph: valid DAG passes', t => {
63
+ const steps = [
64
+ makeStep('a'),
65
+ makeStep('b', [{ step: 'a' }]),
66
+ makeStep('c', [{ step: 'a' }]),
67
+ makeStep('d', [{ step: 'b' }, { step: 'c' }])
68
+ ];
69
+ const graph = buildGraph(steps);
70
+ t.notThrows(() => {
71
+ validateGraph(graph, steps);
72
+ });
73
+ });
74
+ // -- topologicalLevels -------------------------------------------------------
75
+ test('topologicalLevels: linear → one per level', t => {
76
+ const steps = [makeStep('a'), makeStep('b', [{ step: 'a' }]), makeStep('c', [{ step: 'b' }])];
77
+ const graph = buildGraph(steps);
78
+ const levels = topologicalLevels(graph);
79
+ t.is(levels.length, 3);
80
+ t.deepEqual(levels[0], ['a']);
81
+ t.deepEqual(levels[1], ['b']);
82
+ t.deepEqual(levels[2], ['c']);
83
+ });
84
+ test('topologicalLevels: diamond → 3 levels', t => {
85
+ const steps = [
86
+ makeStep('a'),
87
+ makeStep('b', [{ step: 'a' }]),
88
+ makeStep('c', [{ step: 'a' }]),
89
+ makeStep('d', [{ step: 'b' }, { step: 'c' }])
90
+ ];
91
+ const graph = buildGraph(steps);
92
+ const levels = topologicalLevels(graph);
93
+ t.is(levels.length, 3);
94
+ t.deepEqual(levels[0], ['a']);
95
+ t.deepEqual(levels[1].sort(), ['b', 'c']);
96
+ t.deepEqual(levels[2], ['d']);
97
+ });
98
+ test('topologicalLevels: independent steps → all level 0', t => {
99
+ const steps = [makeStep('a'), makeStep('b'), makeStep('c')];
100
+ const graph = buildGraph(steps);
101
+ const levels = topologicalLevels(graph);
102
+ t.is(levels.length, 1);
103
+ t.deepEqual(levels[0].sort(), ['a', 'b', 'c']);
104
+ });
105
+ // -- subgraph ----------------------------------------------------------------
106
+ test('subgraph: targeting leaf includes all ancestors', t => {
107
+ const steps = [makeStep('a'), makeStep('b', [{ step: 'a' }]), makeStep('c', [{ step: 'b' }])];
108
+ const graph = buildGraph(steps);
109
+ const result = subgraph(graph, ['c']);
110
+ t.deepEqual([...result].sort(), ['a', 'b', 'c']);
111
+ });
112
+ test('subgraph: targeting root includes only root', t => {
113
+ const steps = [makeStep('a'), makeStep('b', [{ step: 'a' }]), makeStep('c', [{ step: 'b' }])];
114
+ const graph = buildGraph(steps);
115
+ const result = subgraph(graph, ['a']);
116
+ t.deepEqual([...result], ['a']);
117
+ });
118
+ test('subgraph: diamond targeting d includes all', t => {
119
+ const steps = [
120
+ makeStep('a'),
121
+ makeStep('b', [{ step: 'a' }]),
122
+ makeStep('c', [{ step: 'a' }]),
123
+ makeStep('d', [{ step: 'b' }, { step: 'c' }])
124
+ ];
125
+ const graph = buildGraph(steps);
126
+ const result = subgraph(graph, ['d']);
127
+ t.deepEqual([...result].sort(), ['a', 'b', 'c', 'd']);
128
+ });
129
+ // -- leafNodes ---------------------------------------------------------------
130
+ test('leafNodes: linear → last step', t => {
131
+ const steps = [makeStep('a'), makeStep('b', [{ step: 'a' }]), makeStep('c', [{ step: 'b' }])];
132
+ const graph = buildGraph(steps);
133
+ t.deepEqual(leafNodes(graph), ['c']);
134
+ });
135
+ test('leafNodes: diamond → d', t => {
136
+ const steps = [
137
+ makeStep('a'),
138
+ makeStep('b', [{ step: 'a' }]),
139
+ makeStep('c', [{ step: 'a' }]),
140
+ makeStep('d', [{ step: 'b' }, { step: 'c' }])
141
+ ];
142
+ const graph = buildGraph(steps);
143
+ t.deepEqual(leafNodes(graph), ['d']);
144
+ });
145
+ test('leafNodes: two independent chains → two leaves', t => {
146
+ const steps = [
147
+ makeStep('a'),
148
+ makeStep('b', [{ step: 'a' }]),
149
+ makeStep('c'),
150
+ makeStep('d', [{ step: 'c' }])
151
+ ];
152
+ const graph = buildGraph(steps);
153
+ t.deepEqual(leafNodes(graph).sort(), ['b', 'd']);
154
+ });
@@ -0,0 +1,267 @@
1
+ import test from 'ava';
2
+ import { CyclicDependencyError, ValidationError } from '../../errors.js';
3
+ import { PipelineLoader, slugify, parsePipelineFile, mergeEnv, mergeCaches, mergeMounts } from '../pipeline-loader.js';
4
+ // ---------------------------------------------------------------------------
5
+ // slugify
6
+ // ---------------------------------------------------------------------------
7
+ test('slugify converts accented characters', t => {
8
+ t.is(slugify('Étape numéro un'), 'etape-numero-un');
9
+ });
10
+ test('slugify replaces spaces with hyphens', t => {
11
+ t.is(slugify('hello world'), 'hello-world');
12
+ });
13
+ test('slugify replaces special characters', t => {
14
+ t.is(slugify('build@v2!'), 'build-v2');
15
+ t.is(slugify('build@v2!final'), 'build-v2-final');
16
+ });
17
+ test('slugify collapses double hyphens', t => {
18
+ t.is(slugify('a--b'), 'a-b');
19
+ });
20
+ test('slugify strips leading and trailing hyphens', t => {
21
+ t.is(slugify('-hello-'), 'hello');
22
+ });
23
+ // ---------------------------------------------------------------------------
24
+ // parsePipelineFile
25
+ // ---------------------------------------------------------------------------
26
+ test('parsePipelineFile parses valid JSON', t => {
27
+ const result = parsePipelineFile('{"id": "test"}', 'pipeline.json');
28
+ t.is(result.id, 'test');
29
+ });
30
+ test('parsePipelineFile parses YAML for .yaml extension', t => {
31
+ const result = parsePipelineFile('id: test', 'pipeline.yaml');
32
+ t.is(result.id, 'test');
33
+ });
34
+ test('parsePipelineFile parses YAML for .yml extension', t => {
35
+ const result = parsePipelineFile('id: test', 'pipeline.yml');
36
+ t.is(result.id, 'test');
37
+ });
38
+ test('parsePipelineFile throws on invalid JSON', t => {
39
+ t.throws(() => parsePipelineFile('{invalid', 'pipeline.json'));
40
+ });
41
+ // ---------------------------------------------------------------------------
42
+ // mergeEnv
43
+ // ---------------------------------------------------------------------------
44
+ test('mergeEnv returns undefined when both are undefined', t => {
45
+ t.is(mergeEnv(undefined, undefined), undefined);
46
+ });
47
+ test('mergeEnv returns kit env when user is undefined', t => {
48
+ t.deepEqual(mergeEnv({ A: '1' }, undefined), { A: '1' });
49
+ });
50
+ test('mergeEnv returns user env when kit is undefined', t => {
51
+ t.deepEqual(mergeEnv(undefined, { B: '2' }), { B: '2' });
52
+ });
53
+ test('mergeEnv user overrides kit', t => {
54
+ t.deepEqual(mergeEnv({ A: '1' }, { A: '2' }), { A: '2' });
55
+ });
56
+ test('mergeEnv merges both', t => {
57
+ t.deepEqual(mergeEnv({ A: '1' }, { B: '2' }), { A: '1', B: '2' });
58
+ });
59
+ // ---------------------------------------------------------------------------
60
+ // mergeCaches
61
+ // ---------------------------------------------------------------------------
62
+ test('mergeCaches returns undefined when both are undefined', t => {
63
+ t.is(mergeCaches(undefined, undefined), undefined);
64
+ });
65
+ test('mergeCaches concatenates non-overlapping caches', t => {
66
+ const result = mergeCaches([{ name: 'a', path: '/a' }], [{ name: 'b', path: '/b' }]);
67
+ t.deepEqual(result, [
68
+ { name: 'a', path: '/a' },
69
+ { name: 'b', path: '/b' }
70
+ ]);
71
+ });
72
+ test('mergeCaches user wins on same name', t => {
73
+ const result = mergeCaches([{ name: 'x', path: '/kit' }], [{ name: 'x', path: '/user' }]);
74
+ t.deepEqual(result, [{ name: 'x', path: '/user' }]);
75
+ });
76
+ // ---------------------------------------------------------------------------
77
+ // mergeMounts
78
+ // ---------------------------------------------------------------------------
79
+ test('mergeMounts returns undefined when both are undefined', t => {
80
+ t.is(mergeMounts(undefined, undefined), undefined);
81
+ });
82
+ test('mergeMounts concatenates mounts', t => {
83
+ const result = mergeMounts([{ host: 'a', container: '/a' }], [{ host: 'b', container: '/b' }]);
84
+ t.deepEqual(result, [
85
+ { host: 'a', container: '/a' },
86
+ { host: 'b', container: '/b' }
87
+ ]);
88
+ });
89
+ // ---------------------------------------------------------------------------
90
+ // PipelineLoader.parse
91
+ // ---------------------------------------------------------------------------
92
+ const loader = new PipelineLoader();
93
+ test('parse: valid pipeline with raw steps', t => {
94
+ const pipeline = loader.parse(JSON.stringify({
95
+ id: 'my-pipeline',
96
+ steps: [{
97
+ id: 'step1',
98
+ image: 'alpine',
99
+ cmd: ['echo', 'hello']
100
+ }]
101
+ }), 'p.json');
102
+ t.is(pipeline.id, 'my-pipeline');
103
+ t.is(pipeline.steps.length, 1);
104
+ t.is(pipeline.steps[0].id, 'step1');
105
+ });
106
+ test('parse: derives id from name via slugify', t => {
107
+ const pipeline = loader.parse(JSON.stringify({
108
+ name: 'Mon Pipeline',
109
+ steps: [{
110
+ name: 'Première Étape',
111
+ image: 'alpine',
112
+ cmd: ['echo']
113
+ }]
114
+ }), 'p.json');
115
+ t.is(pipeline.id, 'mon-pipeline');
116
+ t.is(pipeline.steps[0].id, 'premiere-etape');
117
+ });
118
+ test('parse: throws ValidationError when neither id nor name on pipeline', t => {
119
+ const error = t.throws(() => loader.parse(JSON.stringify({
120
+ steps: [{ id: 's', image: 'alpine', cmd: ['echo'] }]
121
+ }), 'p.json'), { message: /at least one of "id" or "name"/ });
122
+ t.true(error instanceof ValidationError);
123
+ });
124
+ test('parse: throws ValidationError when neither id nor name on step', t => {
125
+ const error = t.throws(() => loader.parse(JSON.stringify({
126
+ id: 'p',
127
+ steps: [{ image: 'alpine', cmd: ['echo'] }]
128
+ }), 'p.json'), { message: /at least one of "id" or "name"/ });
129
+ t.true(error instanceof ValidationError);
130
+ });
131
+ test('parse: throws ValidationError on empty steps array', t => {
132
+ const error = t.throws(() => loader.parse(JSON.stringify({
133
+ id: 'p', steps: []
134
+ }), 'p.json'), { message: /steps must be a non-empty array/ });
135
+ t.true(error instanceof ValidationError);
136
+ });
137
+ test('parse: throws on invalid identifier with path traversal', t => {
138
+ t.throws(() => loader.parse(JSON.stringify({
139
+ id: 'p',
140
+ steps: [{ id: '../bad', image: 'alpine', cmd: ['echo'] }]
141
+ }), 'p.json'), { message: /must contain only alphanumeric/ });
142
+ });
143
+ test('parse: throws on invalid identifier with special chars', t => {
144
+ t.throws(() => loader.parse(JSON.stringify({
145
+ id: 'p',
146
+ steps: [{ id: 'hello world', image: 'alpine', cmd: ['echo'] }]
147
+ }), 'p.json'), { message: /must contain only alphanumeric/ });
148
+ });
149
+ test('parse: throws when step has no image', t => {
150
+ t.throws(() => loader.parse(JSON.stringify({
151
+ id: 'p',
152
+ steps: [{ id: 's', cmd: ['echo'] }]
153
+ }), 'p.json'), { message: /image is required/ });
154
+ });
155
+ test('parse: throws when step has no cmd', t => {
156
+ t.throws(() => loader.parse(JSON.stringify({
157
+ id: 'p',
158
+ steps: [{ id: 's', image: 'alpine' }]
159
+ }), 'p.json'), { message: /cmd must be a non-empty array/ });
160
+ });
161
+ test('parse: throws ValidationError on duplicate step ids', t => {
162
+ const error = t.throws(() => loader.parse(JSON.stringify({
163
+ id: 'p',
164
+ steps: [
165
+ { id: 's', image: 'alpine', cmd: ['echo'] },
166
+ { id: 's', image: 'alpine', cmd: ['echo'] }
167
+ ]
168
+ }), 'p.json'), { message: /Duplicate step id/ });
169
+ t.true(error instanceof ValidationError);
170
+ });
171
+ test('parse: validates mount host must be relative', t => {
172
+ t.throws(() => loader.parse(JSON.stringify({
173
+ id: 'p',
174
+ steps: [{
175
+ id: 's', image: 'alpine', cmd: ['echo'],
176
+ mounts: [{ host: '/absolute', container: '/c' }]
177
+ }]
178
+ }), 'p.json'), { message: /must be a relative path/ });
179
+ });
180
+ test('parse: validates mount host no ..', t => {
181
+ t.throws(() => loader.parse(JSON.stringify({
182
+ id: 'p',
183
+ steps: [{
184
+ id: 's', image: 'alpine', cmd: ['echo'],
185
+ mounts: [{ host: '../escape', container: '/c' }]
186
+ }]
187
+ }), 'p.json'), { message: /must not contain '\.\.'/ });
188
+ });
189
+ test('parse: validates mount container must be absolute', t => {
190
+ t.throws(() => loader.parse(JSON.stringify({
191
+ id: 'p',
192
+ steps: [{
193
+ id: 's', image: 'alpine', cmd: ['echo'],
194
+ mounts: [{ host: 'src', container: 'relative' }]
195
+ }]
196
+ }), 'p.json'), { message: /must be an absolute path/ });
197
+ });
198
+ test('parse: validates cache path must be absolute', t => {
199
+ t.throws(() => loader.parse(JSON.stringify({
200
+ id: 'p',
201
+ steps: [{
202
+ id: 's', image: 'alpine', cmd: ['echo'],
203
+ caches: [{ name: 'c', path: 'relative' }]
204
+ }]
205
+ }), 'p.json'), { message: /must be an absolute path/ });
206
+ });
207
+ test('parse: validates cache name is a valid identifier', t => {
208
+ t.throws(() => loader.parse(JSON.stringify({
209
+ id: 'p',
210
+ steps: [{
211
+ id: 's', image: 'alpine', cmd: ['echo'],
212
+ caches: [{ name: 'bad name!', path: '/cache' }]
213
+ }]
214
+ }), 'p.json'), { message: /must contain only alphanumeric/ });
215
+ });
216
+ test('parse: resolves kit step (uses → image/cmd)', t => {
217
+ const pipeline = loader.parse(JSON.stringify({
218
+ id: 'p',
219
+ steps: [{
220
+ id: 'b',
221
+ uses: 'shell',
222
+ with: { run: 'echo hello' }
223
+ }]
224
+ }), 'p.json');
225
+ t.is(pipeline.steps[0].image, 'alpine:3.20');
226
+ t.deepEqual(pipeline.steps[0].cmd, ['sh', '-c', 'echo hello']);
227
+ });
228
+ // ---------------------------------------------------------------------------
229
+ // DAG validation
230
+ // ---------------------------------------------------------------------------
231
+ test('parse: detects cycle → CyclicDependencyError', t => {
232
+ const error = t.throws(() => loader.parse(JSON.stringify({
233
+ id: 'p',
234
+ steps: [
235
+ { id: 'a', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'b' }] },
236
+ { id: 'b', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'a' }] }
237
+ ]
238
+ }), 'p.json'), { message: /cycle/ });
239
+ t.true(error instanceof CyclicDependencyError);
240
+ });
241
+ test('parse: missing input ref → error', t => {
242
+ t.throws(() => loader.parse(JSON.stringify({
243
+ id: 'p',
244
+ steps: [
245
+ { id: 'a', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'missing' }] }
246
+ ]
247
+ }), 'p.json'), { message: /unknown step 'missing'/ });
248
+ });
249
+ test('parse: optional input to unknown step → OK', t => {
250
+ t.notThrows(() => loader.parse(JSON.stringify({
251
+ id: 'p',
252
+ steps: [
253
+ { id: 'a', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'missing', optional: true }] }
254
+ ]
255
+ }), 'p.json'));
256
+ });
257
+ test('parse: valid DAG diamond → OK', t => {
258
+ t.notThrows(() => loader.parse(JSON.stringify({
259
+ id: 'p',
260
+ steps: [
261
+ { id: 'a', image: 'alpine', cmd: ['echo'] },
262
+ { id: 'b', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'a' }] },
263
+ { id: 'c', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'a' }] },
264
+ { id: 'd', image: 'alpine', cmd: ['echo'], inputs: [{ step: 'b' }, { step: 'c' }] }
265
+ ]
266
+ }), 'p.json'));
267
+ });