@elench/testkit 0.1.54 → 0.1.56

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 (43) hide show
  1. package/README.md +22 -0
  2. package/lib/bundler/index.mjs +1 -1
  3. package/lib/bundler/index.test.mjs +29 -0
  4. package/lib/cli/args.mjs +2 -2
  5. package/lib/cli/args.test.mjs +8 -2
  6. package/lib/cli/command-helpers.mjs +5 -1
  7. package/lib/cli/commands/artifacts.mjs +2 -2
  8. package/lib/cli/commands/logs.mjs +2 -2
  9. package/lib/cli/commands/run.mjs +2 -2
  10. package/lib/cli/commands/show.mjs +2 -2
  11. package/lib/cli/db.mjs +17 -2
  12. package/lib/cli/entrypoint.mjs +2 -1
  13. package/lib/cli/presentation/run-reporter.mjs +25 -0
  14. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  15. package/lib/cli/tui/watch-app.mjs +134 -18
  16. package/lib/cli/viewer.mjs +67 -0
  17. package/lib/config/discovery.mjs +1 -0
  18. package/lib/config/discovery.test.mjs +8 -0
  19. package/lib/database/index.mjs +85 -11
  20. package/lib/database/template-steps.mjs +45 -6
  21. package/lib/database/template-steps.test.mjs +43 -0
  22. package/lib/index.d.ts +58 -0
  23. package/lib/index.mjs +3 -0
  24. package/lib/runner/artifacts.mjs +16 -0
  25. package/lib/runner/default-runtime-runner.mjs +4 -1
  26. package/lib/runner/logs.mjs +54 -6
  27. package/lib/runner/orchestrator.mjs +67 -14
  28. package/lib/runner/planning.mjs +1 -1
  29. package/lib/runner/reporting.mjs +58 -2
  30. package/lib/runner/reporting.test.mjs +85 -2
  31. package/lib/runner/runtime-contexts.mjs +3 -3
  32. package/lib/runner/runtime-preparation.mjs +31 -0
  33. package/lib/runner/setup-operations.mjs +115 -0
  34. package/lib/runner/setup-operations.test.mjs +94 -0
  35. package/lib/runner/suite-selection.mjs +4 -4
  36. package/lib/runner/suite-selection.test.mjs +9 -2
  37. package/lib/runner/template-steps.mjs +129 -11
  38. package/lib/runner/worker-loop.mjs +1 -1
  39. package/lib/runtime-src/k6/checks.js +9 -0
  40. package/lib/runtime-src/k6/scenario-runtime.js +234 -0
  41. package/lib/runtime-src/k6/scenario-suite.js +179 -0
  42. package/lib/toolchains/index.mjs +0 -4
  43. package/package.json +1 -1
@@ -14,7 +14,7 @@ import {
14
14
  recordTaskOutcome,
15
15
  summarizeDbBackend,
16
16
  } from "./results.mjs";
17
- import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
17
+ import { buildLiveRunArtifact, buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
18
  import {
19
19
  applyKnownFailureIssueValidationToArtifacts,
20
20
  applyKnownFailuresToArtifacts,
@@ -29,10 +29,12 @@ import {
29
29
  loadTimings,
30
30
  resetResultArtifacts,
31
31
  saveTimings,
32
+ writeLiveRunArtifact,
32
33
  writeRunArtifact,
33
34
  writeStatusArtifact,
34
35
  } from "./artifacts.mjs";
35
36
  import { createRunLogRegistry } from "./logs.mjs";
37
+ import { createSetupOperationRegistry } from "./setup-operations.mjs";
36
38
  import {
37
39
  cleanupRunById,
38
40
  cleanupRuns,
@@ -72,6 +74,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
72
74
  );
73
75
  const reporter = opts.reporter || null;
74
76
  const logRegistry = createRunLogRegistry(productDir);
77
+ let workerCount = 0;
78
+ let runtimeInstanceCount = 0;
79
+ let runtimeStats = [];
75
80
  const requestedFiles = opts.fileNames || [];
76
81
  if (requestedFiles.length > 0) {
77
82
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -120,10 +125,41 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
120
125
  reporter
121
126
  );
122
127
  const trackers = buildServiceTrackers(servicePlans, startedAt);
128
+ const writeLiveSnapshot = () => {
129
+ const now = Date.now();
130
+ const partialResults = configs.map((config) =>
131
+ finalizeServiceResult(trackers.get(config.name), startedAt, now)
132
+ );
133
+ writeLiveRunArtifact(
134
+ productDir,
135
+ buildLiveRunArtifact({
136
+ productDir,
137
+ results: partialResults,
138
+ startedAt,
139
+ updatedAt: now,
140
+ execution,
141
+ workerCount,
142
+ runtimeInstanceCount,
143
+ runtimeStats,
144
+ typeValues,
145
+ suiteSelectors,
146
+ fileNames: requestedFiles,
147
+ shard: opts.shard || null,
148
+ serviceFilter: opts.serviceFilter || null,
149
+ scenarioSeed: opts.scenarioSeed || null,
150
+ metadata,
151
+ summarizeDbBackend,
152
+ serviceLogs: logRegistry.listServiceLogs(),
153
+ setupLogs: logRegistry.listSetupLogs(),
154
+ setupOperations: setupRegistry.listOperations(),
155
+ })
156
+ );
157
+ };
158
+ const setupRegistry = createSetupOperationRegistry({
159
+ logRegistry,
160
+ onChange: writeLiveSnapshot,
161
+ });
123
162
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
124
- let workerCount = 0;
125
- let runtimeInstanceCount = 0;
126
- let runtimeStats = [];
127
163
  let exitCode = 0;
128
164
  const lifecycle = createRunLifecycle(productDir);
129
165
  lifecycle.markRunning();
@@ -131,12 +167,16 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
131
167
  let results = [];
132
168
  let finishedAt = Date.now();
133
169
  let knownFailureIssueValidation = null;
170
+ writeLiveSnapshot();
134
171
 
135
172
  try {
136
173
  if (executedPlans.length > 0) {
137
174
  const timings = loadTimings(productDir);
138
175
  const graphs = buildRuntimeGraphs(executedPlans);
139
176
  const queue = buildTaskQueue(executedPlans, graphs, timings);
177
+ for (const task of queue) {
178
+ task.scenarioSeed = opts.scenarioSeed || null;
179
+ }
140
180
  workerCount = Math.max(1, Math.min(execution.workers, queue.length));
141
181
  runtimeInstanceCount = graphs.reduce((sum, graph) => sum + graph.instanceCount, 0);
142
182
  const workers = Array.from({ length: workerCount }, (_unused, index) =>
@@ -149,6 +189,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
149
189
  runtimeOptions: {
150
190
  reporter,
151
191
  logRegistry,
192
+ setupRegistry,
152
193
  },
153
194
  });
154
195
  const timingUpdates = [];
@@ -164,8 +205,14 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
164
205
  timingUpdates,
165
206
  lifecycle,
166
207
  claimNextTask,
167
- recordTaskOutcome,
168
- recordGraphError,
208
+ (allTrackers, task, outcome, now) => {
209
+ recordTaskOutcome(allTrackers, task, outcome, now);
210
+ writeLiveSnapshot();
211
+ },
212
+ (allTrackers, graph, message, now) => {
213
+ recordGraphError(allTrackers, graph, message, now);
214
+ writeLiveSnapshot();
215
+ },
169
216
  reporter
170
217
  )
171
218
  )
@@ -177,9 +224,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
177
224
  for (const tracker of trackers.values()) {
178
225
  if (!tracker.skipped) addTrackerError(tracker, message);
179
226
  }
227
+ writeLiveSnapshot();
180
228
  }
181
229
  }
182
230
  runtimeStats = runtimeManager.getStats();
231
+ writeLiveSnapshot();
183
232
  } finally {
184
233
  await runtimeManager.cleanupAll();
185
234
  }
@@ -202,12 +251,15 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
202
251
  runtimeStats,
203
252
  typeValues,
204
253
  suiteSelectors,
205
- fileNames: requestedFiles,
206
- shard: opts.shard || null,
207
- serviceFilter: opts.serviceFilter || null,
208
- metadata,
254
+ fileNames: requestedFiles,
255
+ shard: opts.shard || null,
256
+ serviceFilter: opts.serviceFilter || null,
257
+ scenarioSeed: opts.scenarioSeed || null,
258
+ metadata,
209
259
  summarizeDbBackend,
210
260
  serviceLogs: logRegistry.listServiceLogs(),
261
+ setupLogs: logRegistry.listSetupLogs(),
262
+ setupOperations: setupRegistry.listOperations(),
211
263
  });
212
264
  const statusArtifact = opts.writeStatus
213
265
  ? buildStatusArtifact({
@@ -215,10 +267,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
215
267
  results,
216
268
  typeValues,
217
269
  suiteSelectors,
218
- fileNames: requestedFiles,
219
- shard: opts.shard || null,
220
- serviceFilter: opts.serviceFilter || null,
221
- metadata,
270
+ fileNames: requestedFiles,
271
+ shard: opts.shard || null,
272
+ serviceFilter: opts.serviceFilter || null,
273
+ scenarioSeed: opts.scenarioSeed || null,
274
+ metadata,
222
275
  })
223
276
  : null;
224
277
  const enrichedArtifacts = applyKnownFailuresToArtifacts(
@@ -5,7 +5,7 @@ import {
5
5
  suiteSelectionType,
6
6
  } from "./suite-selection.mjs";
7
7
 
8
- const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
8
+ const TYPE_ORDER = ["dal", "integration", "e2e", "scenario", "load"];
9
9
 
10
10
  export function taskNeedsLocalRuntime(task) {
11
11
  return task.type !== "dal";
@@ -8,6 +8,7 @@ export function buildStatusArtifact({
8
8
  fileNames,
9
9
  shard,
10
10
  serviceFilter,
11
+ scenarioSeed,
11
12
  metadata,
12
13
  }) {
13
14
  const executedResults = results.filter((result) => !result.skipped);
@@ -68,6 +69,7 @@ export function buildStatusArtifact({
68
69
  fileNames: [...(fileNames || [])].sort(),
69
70
  shard: shard || null,
70
71
  serviceFilter: serviceFilter || null,
72
+ scenarioSeed: scenarioSeed || null,
71
73
  };
72
74
  scope.isFullRun =
73
75
  scope.types.length === 1 &&
@@ -109,9 +111,13 @@ export function buildRunArtifact({
109
111
  fileNames,
110
112
  shard,
111
113
  serviceFilter,
114
+ scenarioSeed,
112
115
  metadata,
113
116
  summarizeDbBackend,
114
117
  serviceLogs = [],
118
+ setupLogs = [],
119
+ setupOperations = [],
120
+ runStatus = null,
115
121
  }) {
116
122
  const executed = results.filter((result) => !result.skipped);
117
123
  const effectivelySkippedServices = executed.filter(isEffectivelySkippedService);
@@ -128,7 +134,7 @@ export function buildRunArtifact({
128
134
  const dbBackend = summarizeDbBackend(results);
129
135
 
130
136
  return {
131
- schemaVersion: 7,
137
+ schemaVersion: 8,
132
138
  source: "testkit",
133
139
  generatedAt: new Date(finishedAt).toISOString(),
134
140
  product: {
@@ -138,7 +144,7 @@ export function buildRunArtifact({
138
144
  git: metadata.git,
139
145
  host: metadata.host,
140
146
  run: {
141
- status: failedServices.length > 0 ? "failed" : "passed",
147
+ status: runStatus || (failedServices.length > 0 ? "failed" : "passed"),
142
148
  startedAt: new Date(startedAt).toISOString(),
143
149
  finishedAt: new Date(finishedAt).toISOString(),
144
150
  durationMs: finishedAt - startedAt,
@@ -153,6 +159,7 @@ export function buildRunArtifact({
153
159
  fileNames,
154
160
  shard,
155
161
  serviceFilter,
162
+ scenarioSeed: scenarioSeed || null,
156
163
  testkitVersion: metadata.testkitVersion,
157
164
  },
158
165
  summary: {
@@ -179,6 +186,10 @@ export function buildRunArtifact({
179
186
  },
180
187
  logs: {
181
188
  services: serviceLogs,
189
+ setup: setupLogs,
190
+ },
191
+ setup: {
192
+ operations: setupOperations,
182
193
  },
183
194
  services: results.map((result) => ({
184
195
  name: result.name,
@@ -203,6 +214,51 @@ export function buildRunArtifact({
203
214
  };
204
215
  }
205
216
 
217
+ export function buildLiveRunArtifact({
218
+ productDir,
219
+ results,
220
+ startedAt,
221
+ updatedAt,
222
+ execution,
223
+ workerCount,
224
+ runtimeInstanceCount,
225
+ runtimeStats,
226
+ typeValues,
227
+ suiteSelectors,
228
+ fileNames,
229
+ shard,
230
+ serviceFilter,
231
+ scenarioSeed,
232
+ metadata,
233
+ summarizeDbBackend,
234
+ serviceLogs = [],
235
+ setupLogs = [],
236
+ setupOperations = [],
237
+ }) {
238
+ return buildRunArtifact({
239
+ productDir,
240
+ results,
241
+ startedAt,
242
+ finishedAt: updatedAt,
243
+ execution,
244
+ workerCount,
245
+ runtimeInstanceCount,
246
+ runtimeStats,
247
+ typeValues,
248
+ suiteSelectors,
249
+ fileNames,
250
+ shard,
251
+ serviceFilter,
252
+ scenarioSeed,
253
+ metadata,
254
+ summarizeDbBackend,
255
+ serviceLogs,
256
+ setupLogs,
257
+ setupOperations,
258
+ runStatus: "running",
259
+ });
260
+ }
261
+
206
262
  function isEffectivelySkippedService(result) {
207
263
  return (
208
264
  !result.skipped &&
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
2
+ import { buildLiveRunArtifact, buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
3
3
 
4
4
  describe("runner reporting", () => {
5
5
  it("builds run artifacts", () => {
@@ -62,6 +62,7 @@ describe("runner reporting", () => {
62
62
  fileNames: [],
63
63
  shard: null,
64
64
  serviceFilter: null,
65
+ scenarioSeed: "demo-seed",
65
66
  metadata: {
66
67
  git: {
67
68
  branch: "main",
@@ -78,12 +79,13 @@ describe("runner reporting", () => {
78
79
  });
79
80
 
80
81
  expect(artifact.product.name).toBe("my-product");
81
- expect(artifact.schemaVersion).toBe(7);
82
+ expect(artifact.schemaVersion).toBe(8);
82
83
  expect(artifact.run).toMatchObject({
83
84
  workers: 2,
84
85
  fileTimeoutSeconds: 60,
85
86
  workerCount: 1,
86
87
  runtimeInstanceCount: 2,
88
+ scenarioSeed: "demo-seed",
87
89
  });
88
90
  expect(artifact.summary.services).toEqual({
89
91
  total: 1,
@@ -109,7 +111,85 @@ describe("runner reporting", () => {
109
111
  expect(artifact.services[0].totalTaskDurationMs).toBe(2400);
110
112
  expect(artifact.logs).toEqual({
111
113
  services: [],
114
+ setup: [],
112
115
  });
116
+ expect(artifact.setup).toEqual({
117
+ operations: [],
118
+ });
119
+ });
120
+
121
+ it("builds live run artifacts with running status and setup details", () => {
122
+ const artifact = buildLiveRunArtifact({
123
+ productDir: "/tmp/my-product",
124
+ results: [],
125
+ startedAt: 1_000,
126
+ updatedAt: 2_000,
127
+ execution: {
128
+ workers: 2,
129
+ fileTimeoutSeconds: 60,
130
+ },
131
+ workerCount: 1,
132
+ runtimeInstanceCount: 1,
133
+ runtimeStats: [],
134
+ typeValues: ["int"],
135
+ suiteSelectors: [],
136
+ fileNames: [],
137
+ shard: null,
138
+ serviceFilter: null,
139
+ metadata: {
140
+ git: {
141
+ branch: "main",
142
+ commitSha: "abc",
143
+ repoRoot: "/tmp",
144
+ },
145
+ host: {
146
+ hostname: "local",
147
+ username: "dev",
148
+ },
149
+ testkitVersion: "0.1.54",
150
+ },
151
+ summarizeDbBackend: () => "local",
152
+ serviceLogs: [],
153
+ setupLogs: [
154
+ {
155
+ serviceName: "api",
156
+ runtimeLabel: "api",
157
+ stage: "runtime:prepare",
158
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
159
+ },
160
+ ],
161
+ setupOperations: [
162
+ {
163
+ id: "setup-1",
164
+ serviceName: "api",
165
+ runtimeLabel: "api",
166
+ stage: "runtime:prepare",
167
+ kind: "runtime-prepare",
168
+ summary: "runtime prepare",
169
+ parentId: null,
170
+ status: "running",
171
+ startedAt: "1970-01-01T00:00:01.000Z",
172
+ finishedAt: null,
173
+ durationMs: null,
174
+ error: null,
175
+ logRef: {
176
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
177
+ stage: "runtime:prepare",
178
+ },
179
+ },
180
+ ],
181
+ });
182
+
183
+ expect(artifact.run.status).toBe("running");
184
+ expect(artifact.logs.setup).toEqual([
185
+ {
186
+ serviceName: "api",
187
+ runtimeLabel: "api",
188
+ stage: "runtime:prepare",
189
+ path: ".testkit/results/setup/api__api__runtime-prepare.log",
190
+ },
191
+ ]);
192
+ expect(artifact.setup.operations).toHaveLength(1);
113
193
  });
114
194
 
115
195
  it("builds deterministic status artifacts", () => {
@@ -142,6 +222,7 @@ describe("runner reporting", () => {
142
222
  fileNames: ["tests/api/integration/b.int.testkit.ts"],
143
223
  shard: null,
144
224
  serviceFilter: "api",
225
+ scenarioSeed: "demo-seed",
145
226
  metadata: {
146
227
  git: {
147
228
  branch: "main",
@@ -169,6 +250,7 @@ describe("runner reporting", () => {
169
250
  fileNames: ["tests/api/integration/b.int.testkit.ts"],
170
251
  shard: null,
171
252
  serviceFilter: "api",
253
+ scenarioSeed: "demo-seed",
172
254
  isFullRun: false,
173
255
  },
174
256
  summary: {
@@ -213,6 +295,7 @@ describe("runner reporting", () => {
213
295
  fileNames: [],
214
296
  shard: null,
215
297
  serviceFilter: null,
298
+ scenarioSeed: null,
216
299
  metadata: {
217
300
  git: {
218
301
  branch: "main",
@@ -39,7 +39,7 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle, optio
39
39
  if (!context.prepared) {
40
40
  if (!context.preparationPromise) {
41
41
  context.preparationPromise = (async () => {
42
- await prepareDatabases(context.runtimeConfigs);
42
+ await prepareDatabases(context.runtimeConfigs, options);
43
43
  await prepareRuntimeServices(context.runtimeConfigs, options);
44
44
  context.prepared = true;
45
45
  })().finally(() => {
@@ -80,8 +80,8 @@ export async function cleanupRuntimeInstanceContext(context, lifecycle) {
80
80
  await deactivateRuntimeInstanceContext(context, lifecycle);
81
81
  }
82
82
 
83
- export async function prepareDatabases(runtimeConfigs) {
83
+ export async function prepareDatabases(runtimeConfigs, options = {}) {
84
84
  for (const config of runtimeConfigs) {
85
- await prepareDatabaseRuntime(config);
85
+ await prepareDatabaseRuntime(config, options);
86
86
  }
87
87
  }
@@ -28,6 +28,12 @@ export async function prepareRuntimeService(config, options = {}) {
28
28
  const manifestPath = path.join(prepareDir, MANIFEST_FILE);
29
29
  const existingManifest = readPrepareManifest(manifestPath);
30
30
  if (existingManifest?.fingerprint === fingerprint) {
31
+ options.setupRegistry?.recordCached({
32
+ config,
33
+ stage: "runtime:prepare",
34
+ kind: "runtime-prepare",
35
+ summary: "runtime prepare cache hit",
36
+ });
31
37
  return;
32
38
  }
33
39
 
@@ -42,6 +48,14 @@ export async function prepareRuntimeService(config, options = {}) {
42
48
  env.DATABASE_URL = databaseUrl;
43
49
  }
44
50
 
51
+ const prepareOperation = options.setupRegistry?.start({
52
+ config,
53
+ stage: "runtime:prepare",
54
+ kind: "runtime-prepare",
55
+ summary: "runtime prepare",
56
+ recordLog: false,
57
+ });
58
+
45
59
  try {
46
60
  await announceResolvedToolchain(
47
61
  config,
@@ -54,7 +68,16 @@ export async function prepareRuntimeService(config, options = {}) {
54
68
  env,
55
69
  labelPrefix: "runtime:prepare",
56
70
  reporter: options.reporter,
71
+ setupRegistry: options.setupRegistry || null,
72
+ parentOperation: prepareOperation,
57
73
  });
74
+ const finished = prepareOperation
75
+ ? options.setupRegistry.finish(prepareOperation, {
76
+ status: "passed",
77
+ summary: "runtime prepare",
78
+ })
79
+ : null;
80
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
58
81
  writePrepareManifest(manifestPath, {
59
82
  fingerprint,
60
83
  preparedAt: new Date().toISOString(),
@@ -62,6 +85,14 @@ export async function prepareRuntimeService(config, options = {}) {
62
85
  serviceName: config.name,
63
86
  });
64
87
  } catch (error) {
88
+ const finished = prepareOperation
89
+ ? options.setupRegistry.finish(prepareOperation, {
90
+ status: "failed",
91
+ summary: "runtime prepare",
92
+ error: error?.message || error,
93
+ })
94
+ : null;
95
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
65
96
  fs.rmSync(prepareDir, { recursive: true, force: true });
66
97
  throw error;
67
98
  }
@@ -0,0 +1,115 @@
1
+ export function createSetupOperationRegistry({ logRegistry = null, onChange = null } = {}) {
2
+ const operations = [];
3
+ const byId = new Map();
4
+ let nextId = 1;
5
+
6
+ function emitChange() {
7
+ onChange?.(listOperations());
8
+ }
9
+
10
+ function start({
11
+ config,
12
+ stage,
13
+ kind = "setup",
14
+ summary = null,
15
+ parentId = null,
16
+ recordLog = true,
17
+ }) {
18
+ const startedAt = new Date().toISOString();
19
+ const logRecord = recordLog ? logRegistry?.ensureSetupLogRecord(config, stage) || null : null;
20
+ const operation = {
21
+ id: `setup-${nextId++}`,
22
+ serviceName: config.name,
23
+ runtimeLabel: config.runtimeLabel || config.name,
24
+ stage,
25
+ kind,
26
+ summary,
27
+ parentId,
28
+ status: "running",
29
+ startedAt,
30
+ finishedAt: null,
31
+ durationMs: null,
32
+ error: null,
33
+ logRef: logRecord
34
+ ? {
35
+ path: logRecord.path,
36
+ stage: logRecord.stage,
37
+ }
38
+ : null,
39
+ _logRecord: logRecord,
40
+ };
41
+ operations.push(operation);
42
+ byId.set(operation.id, operation);
43
+ emitChange();
44
+ return operation;
45
+ }
46
+
47
+ function finish(target, { status = "passed", summary, error = null } = {}) {
48
+ const operation = typeof target === "string" ? byId.get(target) : target;
49
+ if (!operation) return null;
50
+ const finishedAt = new Date().toISOString();
51
+ operation.finishedAt = finishedAt;
52
+ operation.durationMs = Math.max(
53
+ 0,
54
+ Date.parse(finishedAt) - Date.parse(operation.startedAt || finishedAt)
55
+ );
56
+ operation.status = status;
57
+ if (summary !== undefined) operation.summary = summary;
58
+ if (error !== null) operation.error = String(error);
59
+ emitChange();
60
+ return operation;
61
+ }
62
+
63
+ function recordCached({ config, stage, kind = "setup", summary = null, parentId = null }) {
64
+ const now = new Date().toISOString();
65
+ const operation = {
66
+ id: `setup-${nextId++}`,
67
+ serviceName: config.name,
68
+ runtimeLabel: config.runtimeLabel || config.name,
69
+ stage,
70
+ kind,
71
+ summary,
72
+ parentId,
73
+ status: "cached",
74
+ startedAt: now,
75
+ finishedAt: now,
76
+ durationMs: 0,
77
+ error: null,
78
+ logRef: null,
79
+ _logRecord: null,
80
+ };
81
+ operations.push(operation);
82
+ byId.set(operation.id, operation);
83
+ emitChange();
84
+ return operation;
85
+ }
86
+
87
+ function listOperations() {
88
+ return operations.map(cloneOperation);
89
+ }
90
+
91
+ return {
92
+ start,
93
+ finish,
94
+ recordCached,
95
+ listOperations,
96
+ };
97
+ }
98
+
99
+ function cloneOperation(operation) {
100
+ return {
101
+ id: operation.id,
102
+ serviceName: operation.serviceName,
103
+ runtimeLabel: operation.runtimeLabel,
104
+ stage: operation.stage,
105
+ kind: operation.kind,
106
+ summary: operation.summary,
107
+ parentId: operation.parentId,
108
+ status: operation.status,
109
+ startedAt: operation.startedAt,
110
+ finishedAt: operation.finishedAt,
111
+ durationMs: operation.durationMs,
112
+ error: operation.error,
113
+ logRef: operation.logRef,
114
+ };
115
+ }