@livingdata/pipex 0.0.9 → 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 (88) hide show
  1. package/README.md +154 -14
  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/exec.js +89 -0
  15. package/dist/cli/commands/export.js +2 -2
  16. package/dist/cli/commands/inspect.js +1 -1
  17. package/dist/cli/commands/list.js +2 -1
  18. package/dist/cli/commands/logs.js +1 -1
  19. package/dist/cli/commands/prune.js +1 -1
  20. package/dist/cli/commands/rm-step.js +41 -0
  21. package/dist/cli/commands/run-bundle.js +59 -0
  22. package/dist/cli/commands/run.js +9 -4
  23. package/dist/cli/commands/show.js +42 -7
  24. package/dist/cli/condition.js +11 -0
  25. package/dist/cli/dag.js +143 -0
  26. package/dist/cli/index.js +6 -0
  27. package/dist/cli/interactive-reporter.js +227 -0
  28. package/dist/cli/pipeline-loader.js +10 -110
  29. package/dist/cli/pipeline-runner.js +164 -78
  30. package/dist/cli/reporter.js +2 -158
  31. package/dist/cli/state.js +8 -0
  32. package/dist/cli/step-loader.js +25 -0
  33. package/dist/cli/step-resolver.js +111 -0
  34. package/dist/cli/step-runner.js +226 -0
  35. package/dist/cli/utils.js +0 -46
  36. package/dist/core/__tests__/bundle.js +663 -0
  37. package/dist/core/__tests__/condition.js +23 -0
  38. package/dist/core/__tests__/dag.js +154 -0
  39. package/dist/core/__tests__/env-file.test.js +41 -0
  40. package/dist/core/__tests__/event-aggregator.js +244 -0
  41. package/dist/core/__tests__/pipeline-loader.js +267 -0
  42. package/dist/core/__tests__/pipeline-runner.js +257 -0
  43. package/dist/core/__tests__/state-persistence.js +80 -0
  44. package/dist/core/__tests__/state.js +58 -0
  45. package/dist/core/__tests__/step-runner.js +118 -0
  46. package/dist/core/__tests__/stream-reporter.js +142 -0
  47. package/dist/core/__tests__/transport.js +50 -0
  48. package/dist/core/__tests__/utils.js +40 -0
  49. package/dist/core/bundle.js +130 -0
  50. package/dist/core/condition.js +11 -0
  51. package/dist/core/dag.js +143 -0
  52. package/dist/core/env-file.js +6 -0
  53. package/dist/core/event-aggregator.js +114 -0
  54. package/dist/core/index.js +14 -0
  55. package/dist/core/pipeline-loader.js +81 -0
  56. package/dist/core/pipeline-runner.js +360 -0
  57. package/dist/core/reporter.js +11 -0
  58. package/dist/core/state.js +110 -0
  59. package/dist/core/step-loader.js +25 -0
  60. package/dist/core/step-resolver.js +117 -0
  61. package/dist/core/step-runner.js +225 -0
  62. package/dist/core/stream-reporter.js +41 -0
  63. package/dist/core/transport.js +9 -0
  64. package/dist/core/utils.js +56 -0
  65. package/dist/engine/__tests__/workspace.js +288 -0
  66. package/dist/engine/docker-executor.js +10 -2
  67. package/dist/engine/index.js +1 -0
  68. package/dist/engine/workspace.js +76 -12
  69. package/dist/errors.js +122 -0
  70. package/dist/index.js +3 -0
  71. package/dist/kits/__tests__/index.js +23 -0
  72. package/dist/kits/builtin/__tests__/node.js +74 -0
  73. package/dist/kits/builtin/__tests__/python.js +67 -0
  74. package/dist/kits/builtin/__tests__/shell.js +74 -0
  75. package/dist/kits/builtin/node.js +10 -5
  76. package/dist/kits/builtin/python.js +10 -5
  77. package/dist/kits/builtin/shell.js +2 -1
  78. package/dist/kits/index.js +2 -1
  79. package/package.json +6 -3
  80. package/dist/cli/types.js +0 -3
  81. package/dist/engine/docker-runtime.js +0 -65
  82. package/dist/engine/runtime.js +0 -2
  83. package/dist/kits/bash.js +0 -19
  84. package/dist/kits/builtin/bash.js +0 -19
  85. package/dist/kits/node.js +0 -56
  86. package/dist/kits/python.js +0 -51
  87. package/dist/kits/types.js +0 -1
  88. 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,41 @@
1
+ import { join } from 'node:path';
2
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import test from 'ava';
5
+ import { loadEnvFile } from '../env-file.js';
6
+ let tempDir;
7
+ test.beforeEach(async () => {
8
+ tempDir = await mkdtemp(join(tmpdir(), 'pipex-env-'));
9
+ });
10
+ test.afterEach(async () => {
11
+ await rm(tempDir, { recursive: true });
12
+ });
13
+ test('parses KEY=VALUE lines', async (t) => {
14
+ const filePath = join(tempDir, '.env');
15
+ await writeFile(filePath, 'FOO=bar\nBAZ=qux\n');
16
+ const env = await loadEnvFile(filePath);
17
+ t.deepEqual(env, { FOO: 'bar', BAZ: 'qux' });
18
+ });
19
+ test('ignores comments', async (t) => {
20
+ const filePath = join(tempDir, '.env');
21
+ await writeFile(filePath, '# this is a comment\nFOO=bar\n# another comment\n');
22
+ const env = await loadEnvFile(filePath);
23
+ t.deepEqual(env, { FOO: 'bar' });
24
+ });
25
+ test('ignores empty lines', async (t) => {
26
+ const filePath = join(tempDir, '.env');
27
+ await writeFile(filePath, '\nFOO=bar\n\nBAZ=qux\n\n');
28
+ const env = await loadEnvFile(filePath);
29
+ t.deepEqual(env, { FOO: 'bar', BAZ: 'qux' });
30
+ });
31
+ test('handles quoted values', async (t) => {
32
+ const filePath = join(tempDir, '.env');
33
+ await writeFile(filePath, 'FOO="bar baz"\nQUX=\'hello world\'\n');
34
+ const env = await loadEnvFile(filePath);
35
+ t.is(env.FOO, 'bar baz');
36
+ t.is(env.QUX, 'hello world');
37
+ });
38
+ test('throws on missing file', async (t) => {
39
+ const filePath = join(tempDir, 'nonexistent.env');
40
+ await t.throwsAsync(async () => loadEnvFile(filePath), { code: 'ENOENT' });
41
+ });
@@ -0,0 +1,244 @@
1
+ import test from 'ava';
2
+ import { EventAggregator } from '../event-aggregator.js';
3
+ const workspaceId = 'ws-1';
4
+ const jobId = 'job-1';
5
+ let seq = 0;
6
+ function msg(event) {
7
+ return {
8
+ seq: seq++,
9
+ timestamp: new Date().toISOString(),
10
+ version: 1,
11
+ type: event.event,
12
+ event
13
+ };
14
+ }
15
+ test.beforeEach(() => {
16
+ seq = 0;
17
+ });
18
+ test('PIPELINE_START creates a session with pending steps', t => {
19
+ const agg = new EventAggregator();
20
+ agg.consume(msg({
21
+ event: 'PIPELINE_START',
22
+ workspaceId,
23
+ jobId,
24
+ pipelineName: 'my-pipeline',
25
+ steps: [
26
+ { id: 'a', displayName: 'Step A' },
27
+ { id: 'b', displayName: 'Step B' }
28
+ ]
29
+ }));
30
+ const session = agg.getSession(jobId);
31
+ t.truthy(session);
32
+ t.is(session.workspaceId, workspaceId);
33
+ t.is(session.jobId, jobId);
34
+ t.is(session.pipelineName, 'my-pipeline');
35
+ t.is(session.status, 'running');
36
+ t.is(session.steps.size, 2);
37
+ t.is(session.steps.get('a').status, 'pending');
38
+ t.is(session.steps.get('b').status, 'pending');
39
+ });
40
+ test('STEP_STARTING transitions step to running', t => {
41
+ const agg = new EventAggregator();
42
+ agg.consume(msg({
43
+ event: 'PIPELINE_START',
44
+ workspaceId,
45
+ jobId,
46
+ pipelineName: 'test',
47
+ steps: [{ id: 'a', displayName: 'A' }]
48
+ }));
49
+ agg.consume(msg({
50
+ event: 'STEP_STARTING',
51
+ workspaceId,
52
+ jobId,
53
+ step: { id: 'a', displayName: 'A' }
54
+ }));
55
+ const step = agg.getSession(jobId).steps.get('a');
56
+ t.is(step.status, 'running');
57
+ });
58
+ test('STEP_FINISHED transitions step with metadata', t => {
59
+ const agg = new EventAggregator();
60
+ agg.consume(msg({
61
+ event: 'PIPELINE_START',
62
+ workspaceId,
63
+ jobId,
64
+ pipelineName: 'test',
65
+ steps: [{ id: 'a', displayName: 'A' }]
66
+ }));
67
+ agg.consume(msg({
68
+ event: 'STEP_FINISHED',
69
+ workspaceId,
70
+ jobId,
71
+ step: { id: 'a', displayName: 'A' },
72
+ runId: 'run-123',
73
+ durationMs: 500,
74
+ artifactSize: 1024
75
+ }));
76
+ const step = agg.getSession(jobId).steps.get('a');
77
+ t.is(step.status, 'finished');
78
+ t.is(step.runId, 'run-123');
79
+ t.is(step.durationMs, 500);
80
+ t.is(step.artifactSize, 1024);
81
+ });
82
+ test('STEP_FAILED transitions step with exitCode', t => {
83
+ const agg = new EventAggregator();
84
+ agg.consume(msg({
85
+ event: 'PIPELINE_START',
86
+ workspaceId,
87
+ jobId,
88
+ pipelineName: 'test',
89
+ steps: [{ id: 'a', displayName: 'A' }]
90
+ }));
91
+ agg.consume(msg({
92
+ event: 'STEP_FAILED',
93
+ workspaceId,
94
+ jobId,
95
+ step: { id: 'a', displayName: 'A' },
96
+ exitCode: 1
97
+ }));
98
+ const step = agg.getSession(jobId).steps.get('a');
99
+ t.is(step.status, 'failed');
100
+ t.is(step.exitCode, 1);
101
+ });
102
+ test('STEP_SKIPPED transitions step to skipped', t => {
103
+ const agg = new EventAggregator();
104
+ agg.consume(msg({
105
+ event: 'PIPELINE_START',
106
+ workspaceId,
107
+ jobId,
108
+ pipelineName: 'test',
109
+ steps: [{ id: 'a', displayName: 'A' }]
110
+ }));
111
+ agg.consume(msg({
112
+ event: 'STEP_SKIPPED',
113
+ workspaceId,
114
+ jobId,
115
+ step: { id: 'a', displayName: 'A' },
116
+ runId: 'run-cached',
117
+ reason: 'cached'
118
+ }));
119
+ const step = agg.getSession(jobId).steps.get('a');
120
+ t.is(step.status, 'skipped');
121
+ t.is(step.runId, 'run-cached');
122
+ });
123
+ test('PIPELINE_FINISHED sets session to completed', t => {
124
+ const agg = new EventAggregator();
125
+ agg.consume(msg({
126
+ event: 'PIPELINE_START',
127
+ workspaceId,
128
+ jobId,
129
+ pipelineName: 'test',
130
+ steps: []
131
+ }));
132
+ agg.consume(msg({
133
+ event: 'PIPELINE_FINISHED',
134
+ workspaceId,
135
+ jobId,
136
+ totalArtifactSize: 2048
137
+ }));
138
+ const session = agg.getSession(jobId);
139
+ t.is(session.status, 'completed');
140
+ t.truthy(session.finishedAt);
141
+ });
142
+ test('PIPELINE_FAILED sets session to failed', t => {
143
+ const agg = new EventAggregator();
144
+ agg.consume(msg({
145
+ event: 'PIPELINE_START',
146
+ workspaceId,
147
+ jobId,
148
+ pipelineName: 'test',
149
+ steps: []
150
+ }));
151
+ agg.consume(msg({
152
+ event: 'PIPELINE_FAILED',
153
+ workspaceId,
154
+ jobId
155
+ }));
156
+ const session = agg.getSession(jobId);
157
+ t.is(session.status, 'failed');
158
+ t.truthy(session.finishedAt);
159
+ });
160
+ test('getAllSessions returns all sessions', t => {
161
+ const agg = new EventAggregator();
162
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId: 'j1', pipelineName: 'p1', steps: [] }));
163
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId: 'j2', pipelineName: 'p2', steps: [] }));
164
+ t.is(agg.getAllSessions().length, 2);
165
+ });
166
+ test('clear removes all sessions', t => {
167
+ const agg = new EventAggregator();
168
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'test', steps: [] }));
169
+ agg.clear();
170
+ t.is(agg.getAllSessions().length, 0);
171
+ t.is(agg.getSession(jobId), undefined);
172
+ });
173
+ test('getSession returns undefined for unknown jobId', t => {
174
+ const agg = new EventAggregator();
175
+ t.is(agg.getSession('nonexistent'), undefined);
176
+ });
177
+ test('full lifecycle: start → running → finished → completed', t => {
178
+ const agg = new EventAggregator();
179
+ const step = { id: 'a', displayName: 'A' };
180
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'full', steps: [step] }));
181
+ t.is(agg.getSession(jobId).steps.get('a').status, 'pending');
182
+ agg.consume(msg({ event: 'STEP_STARTING', workspaceId, jobId, step }));
183
+ t.is(agg.getSession(jobId).steps.get('a').status, 'running');
184
+ agg.consume(msg({ event: 'STEP_FINISHED', workspaceId, jobId, step, runId: 'r1', durationMs: 100, artifactSize: 50 }));
185
+ t.is(agg.getSession(jobId).steps.get('a').status, 'finished');
186
+ agg.consume(msg({ event: 'PIPELINE_FINISHED', workspaceId, jobId, totalArtifactSize: 50 }));
187
+ t.is(agg.getSession(jobId).status, 'completed');
188
+ t.truthy(agg.getSession(jobId).startedAt);
189
+ t.truthy(agg.getSession(jobId).finishedAt);
190
+ });
191
+ test('multiple steps tracked independently', t => {
192
+ const agg = new EventAggregator();
193
+ const steps = [{ id: 'a', displayName: 'A' }, { id: 'b', displayName: 'B' }];
194
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'multi', steps }));
195
+ agg.consume(msg({ event: 'STEP_STARTING', workspaceId, jobId, step: steps[0] }));
196
+ agg.consume(msg({ event: 'STEP_FINISHED', workspaceId, jobId, step: steps[0], runId: 'r1', durationMs: 100 }));
197
+ agg.consume(msg({ event: 'STEP_STARTING', workspaceId, jobId, step: steps[1] }));
198
+ agg.consume(msg({ event: 'STEP_FAILED', workspaceId, jobId, step: steps[1], exitCode: 2 }));
199
+ const session = agg.getSession(jobId);
200
+ t.is(session.steps.get('a').status, 'finished');
201
+ t.is(session.steps.get('b').status, 'failed');
202
+ t.is(session.steps.get('b').exitCode, 2);
203
+ });
204
+ test('STEP_RETRYING is consumed without error', t => {
205
+ const agg = new EventAggregator();
206
+ const step = { id: 'a', displayName: 'A' };
207
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'retry', steps: [step] }));
208
+ t.notThrows(() => {
209
+ agg.consume(msg({ event: 'STEP_RETRYING', workspaceId, jobId, step, attempt: 1, maxRetries: 3 }));
210
+ });
211
+ });
212
+ test('STEP_WOULD_RUN is consumed without error', t => {
213
+ const agg = new EventAggregator();
214
+ const step = { id: 'a', displayName: 'A' };
215
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'dry', steps: [step] }));
216
+ t.notThrows(() => {
217
+ agg.consume(msg({ event: 'STEP_WOULD_RUN', workspaceId, jobId, step }));
218
+ });
219
+ });
220
+ test('step event without prior PIPELINE_START creates session lazily', t => {
221
+ const agg = new EventAggregator();
222
+ const step = { id: 'x', displayName: 'X' };
223
+ agg.consume(msg({ event: 'STEP_STARTING', workspaceId, jobId: 'orphan', step }));
224
+ const session = agg.getSession('orphan');
225
+ t.truthy(session);
226
+ t.is(session.status, 'running');
227
+ t.is(session.steps.get('x').status, 'running');
228
+ });
229
+ test('startedAt is set from PIPELINE_START message timestamp', t => {
230
+ const agg = new EventAggregator();
231
+ const m = msg({ event: 'PIPELINE_START', workspaceId, jobId, pipelineName: 'ts', steps: [] });
232
+ agg.consume(m);
233
+ t.is(agg.getSession(jobId).startedAt, m.timestamp);
234
+ });
235
+ test('two independent jobs are tracked separately', t => {
236
+ const agg = new EventAggregator();
237
+ const step = { id: 'a', displayName: 'A' };
238
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId: 'j1', pipelineName: 'p1', steps: [step] }));
239
+ agg.consume(msg({ event: 'PIPELINE_START', workspaceId, jobId: 'j2', pipelineName: 'p2', steps: [step] }));
240
+ agg.consume(msg({ event: 'STEP_FINISHED', workspaceId, jobId: 'j1', step, runId: 'r1' }));
241
+ agg.consume(msg({ event: 'STEP_FAILED', workspaceId, jobId: 'j2', step, exitCode: 1 }));
242
+ t.is(agg.getSession('j1').steps.get('a').status, 'finished');
243
+ t.is(agg.getSession('j2').steps.get('a').status, 'failed');
244
+ });