@elench/testkit 0.1.51 → 0.1.53

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 (40) hide show
  1. package/README.md +42 -7
  2. package/bin/testkit.mjs +4 -6
  3. package/lib/cli/command-helpers.mjs +170 -0
  4. package/lib/cli/commands/artifacts.mjs +45 -0
  5. package/lib/cli/commands/cleanup.mjs +15 -0
  6. package/lib/cli/commands/db/snapshot/capture.mjs +22 -0
  7. package/lib/cli/commands/destroy.mjs +15 -0
  8. package/lib/cli/commands/known-failures/render.mjs +19 -0
  9. package/lib/cli/commands/known-failures/validate.mjs +20 -0
  10. package/lib/cli/commands/logs.mjs +47 -0
  11. package/lib/cli/commands/run.mjs +23 -0
  12. package/lib/cli/commands/show.mjs +47 -0
  13. package/lib/cli/commands/status.mjs +15 -0
  14. package/lib/cli/commands/watch.mjs +23 -0
  15. package/lib/cli/entrypoint.mjs +83 -0
  16. package/lib/cli/index.mjs +6 -116
  17. package/lib/cli/presentation/run-reporter.mjs +91 -0
  18. package/lib/cli/tui/watch-app.mjs +104 -0
  19. package/lib/cli/viewer.mjs +163 -0
  20. package/lib/runner/artifacts.mjs +35 -0
  21. package/lib/runner/default-runtime-runner.mjs +44 -10
  22. package/lib/runner/formatting.mjs +97 -0
  23. package/lib/runner/formatting.test.mjs +4 -6
  24. package/lib/runner/logs.mjs +72 -0
  25. package/lib/runner/orchestrator.mjs +41 -19
  26. package/lib/runner/playwright-runner.mjs +15 -7
  27. package/lib/runner/processes.mjs +9 -11
  28. package/lib/runner/reporting.mjs +5 -1
  29. package/lib/runner/reporting.test.mjs +4 -1
  30. package/lib/runner/runtime-contexts.mjs +7 -3
  31. package/lib/runner/runtime-manager.mjs +8 -2
  32. package/lib/runner/runtime-preparation.mjs +9 -4
  33. package/lib/runner/services.mjs +25 -8
  34. package/lib/runner/template-steps.mjs +4 -3
  35. package/lib/runner/worker-loop.mjs +8 -7
  36. package/lib/setup/index.d.ts +46 -13
  37. package/lib/setup/index.mjs +47 -0
  38. package/lib/setup/index.test.mjs +109 -1
  39. package/lib/toolchains/index.mjs +6 -3
  40. package/package.json +11 -3
@@ -49,6 +49,56 @@ export function formatSuiteFramework(framework) {
49
49
  }
50
50
 
51
51
  export function buildRunSummaryLines(results, durationMs, knownFailureIssueValidation = null) {
52
+ return buildCompactRunSummaryLines(results, durationMs, knownFailureIssueValidation);
53
+ }
54
+
55
+ export function buildCompactRunSummaryLines(
56
+ results,
57
+ durationMs,
58
+ knownFailureIssueValidation = null
59
+ ) {
60
+ const totals = summarizeResults(results);
61
+ const lines = [
62
+ "",
63
+ `Summary: ${totals.passedFiles} passed, ${totals.failedFiles} failed, ${totals.skippedFiles} skipped, ${totals.notRunFiles} not run across ${totals.totalFiles} ${pluralize(totals.totalFiles, "file", "files")} in ${formatDuration(durationMs)}`,
64
+ ];
65
+
66
+ const failures = collectFailedFiles(results);
67
+ if (failures.length > 0) {
68
+ lines.push("", "Failures:");
69
+ for (const failure of failures) {
70
+ lines.push(` ${failure.file.path}`);
71
+ lines.push(` ${failure.primaryMessage}`);
72
+ for (const detail of failure.extraDetails.slice(0, 2)) {
73
+ lines.push(` ${detail}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ const serviceErrors = collectServiceErrors(results);
79
+ if (serviceErrors.length > 0) {
80
+ lines.push("", "Runtime Errors:");
81
+ for (const item of serviceErrors) {
82
+ lines.push(` ${item.service}`);
83
+ lines.push(` ${item.message}`);
84
+ }
85
+ }
86
+
87
+ const knownFailureIssueLines = buildKnownFailureIssueValidationSummaryLines(
88
+ knownFailureIssueValidation
89
+ );
90
+ if (knownFailureIssueLines.length > 0) {
91
+ lines.push(...knownFailureIssueLines);
92
+ }
93
+
94
+ lines.push("");
95
+ lines.push(
96
+ totals.failedServices > 0 ? `Result: FAILED (${totals.failedServices}/${totals.totalServices} services failed)` : "Result: PASSED"
97
+ );
98
+ return lines;
99
+ }
100
+
101
+ export function buildDebugRunSummaryLines(results, durationMs, knownFailureIssueValidation = null) {
52
102
  const totalServices = results.length;
53
103
  const executedServices = results.filter((result) => !result.skipped);
54
104
  const skippedServices = results.filter((result) => result.skipped);
@@ -140,6 +190,53 @@ function sanitizeErrorMessage(message) {
140
190
  .replace(/[\\/]vendor[\\/]k6\b/g, "default-runtime");
141
191
  }
142
192
 
193
+ function summarizeResults(results) {
194
+ const executedServices = results.filter((result) => !result.skipped);
195
+ return {
196
+ totalServices: results.length,
197
+ failedServices: executedServices.filter((result) => result.failed).length,
198
+ totalFiles: executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0),
199
+ passedFiles: executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0),
200
+ failedFiles: executedServices.reduce((sum, result) => sum + (result.failedFileCount || 0), 0),
201
+ skippedFiles: executedServices.reduce((sum, result) => sum + (result.skippedFileCount || 0), 0),
202
+ notRunFiles: executedServices.reduce((sum, result) => sum + (result.notRunFileCount || 0), 0),
203
+ };
204
+ }
205
+
206
+ function collectFailedFiles(results) {
207
+ const failures = [];
208
+ for (const result of results) {
209
+ for (const suite of result.suites || []) {
210
+ for (const file of suite.files || []) {
211
+ if (file.status !== "failed") continue;
212
+ const detailMessages = (file.failureDetails || [])
213
+ .map((detail) => detail.message || detail.title)
214
+ .filter(Boolean)
215
+ .map((message) => sanitizeErrorMessage(String(message).trim()));
216
+ failures.push({
217
+ file,
218
+ primaryMessage: sanitizeErrorMessage(file.error || detailMessages[0] || suite.error || "Failed"),
219
+ extraDetails: detailMessages.slice(file.error ? 0 : 1),
220
+ });
221
+ }
222
+ }
223
+ }
224
+ return failures.sort((left, right) => left.file.path.localeCompare(right.file.path));
225
+ }
226
+
227
+ function collectServiceErrors(results) {
228
+ const items = [];
229
+ for (const result of results) {
230
+ for (const error of result.errors || []) {
231
+ items.push({
232
+ service: result.name,
233
+ message: sanitizeErrorMessage(error),
234
+ });
235
+ }
236
+ }
237
+ return items;
238
+ }
239
+
143
240
  function pluralize(value, singular, plural) {
144
241
  return value === 1 ? singular : plural;
145
242
  }
@@ -94,9 +94,9 @@ describe("runner formatting", () => {
94
94
  20_000
95
95
  );
96
96
 
97
- expect(lines.join("\n")).toContain("services 0/1 passed");
98
- expect(lines.join("\n")).toContain("FAIL frontend");
99
- expect(lines.join("\n")).toContain("worker error: worker broke");
97
+ expect(lines.join("\n")).toContain("Summary: 2 passed, 0 failed, 0 skipped, 0 not run across 3 files");
98
+ expect(lines.join("\n")).toContain("Runtime Errors:");
99
+ expect(lines.join("\n")).toContain("worker broke");
100
100
  expect(lines.at(-1)).toBe("Result: FAILED (1/1 services failed)");
101
101
  });
102
102
 
@@ -123,9 +123,7 @@ describe("runner formatting", () => {
123
123
  0
124
124
  );
125
125
 
126
- expect(lines.join("\n")).toContain("suites 1 skipped");
127
- expect(lines.join("\n")).toContain("files 1 skipped");
128
- expect(lines.join("\n")).toContain("SKIP api");
126
+ expect(lines.join("\n")).toContain("Summary: 0 passed, 0 failed, 1 skipped, 0 not run across 1 file");
129
127
  expect(lines.at(-1)).toBe("Result: PASSED");
130
128
  });
131
129
 
@@ -0,0 +1,72 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const RESULT_LOGS_DIRNAME = "logs";
5
+
6
+ export function createRunLogRegistry(productDir) {
7
+ const records = new Map();
8
+
9
+ return {
10
+ ensureServiceLogRecord(config) {
11
+ const key = `${config.runtimeLabel || config.name}:${config.name}`;
12
+ const existing = records.get(key);
13
+ if (existing) return existing;
14
+
15
+ const fileName = `${sanitizePathSegment(config.runtimeLabel || config.name)}__${sanitizePathSegment(config.name)}.log`;
16
+ const relativePath = path.join(".testkit", "results", RESULT_LOGS_DIRNAME, fileName);
17
+ const absolutePath = path.join(productDir, relativePath);
18
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
19
+ const stream = fs.createWriteStream(absolutePath, { flags: "a" });
20
+ const record = {
21
+ key,
22
+ serviceName: config.name,
23
+ runtimeLabel: config.runtimeLabel || config.name,
24
+ path: normalizePath(relativePath),
25
+ absolutePath,
26
+ stream,
27
+ };
28
+ records.set(key, record);
29
+ return record;
30
+ },
31
+ append(record, streamName, line) {
32
+ if (!record || typeof line !== "string") return;
33
+ record.stream.write(`${new Date().toISOString()} [${streamName}] ${line}\n`);
34
+ },
35
+ listServiceLogs() {
36
+ return [...records.values()]
37
+ .map((record) => ({
38
+ serviceName: record.serviceName,
39
+ runtimeLabel: record.runtimeLabel,
40
+ path: record.path,
41
+ }))
42
+ .sort(
43
+ (left, right) =>
44
+ left.serviceName.localeCompare(right.serviceName) ||
45
+ left.runtimeLabel.localeCompare(right.runtimeLabel)
46
+ );
47
+ },
48
+ closeAll() {
49
+ for (const record of records.values()) {
50
+ record.stream.end();
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ export function readLogTail(absolutePath, lineCount = 80) {
57
+ if (!absolutePath || !fs.existsSync(absolutePath)) return [];
58
+ const lines = fs.readFileSync(absolutePath, "utf8").split(/\r?\n/).filter(Boolean);
59
+ return lines.slice(Math.max(0, lines.length - lineCount));
60
+ }
61
+
62
+ function sanitizePathSegment(value) {
63
+ return String(value)
64
+ .trim()
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9._-]+/g, "-")
67
+ .replace(/^-+|-+$/g, "") || "log";
68
+ }
69
+
70
+ function normalizePath(filePath) {
71
+ return filePath.split(path.sep).join("/");
72
+ }
@@ -16,7 +16,7 @@ import {
16
16
  } from "./results.mjs";
17
17
  import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
18
  import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
19
- import { buildRunSummaryLines, formatError } from "./formatting.mjs";
19
+ import { formatError } from "./formatting.mjs";
20
20
  import {
21
21
  shouldFailKnownFailureIssueValidation,
22
22
  validateKnownFailureIssues,
@@ -28,6 +28,7 @@ import {
28
28
  writeRunArtifact,
29
29
  writeStatusArtifact,
30
30
  } from "./artifacts.mjs";
31
+ import { createRunLogRegistry } from "./logs.mjs";
31
32
  import {
32
33
  cleanupRunById,
33
34
  cleanupRuns,
@@ -65,6 +66,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
65
66
  productDir,
66
67
  configs[0]?.testkit?.reporting || null
67
68
  );
69
+ const reporter = opts.reporter || null;
70
+ const logRegistry = createRunLogRegistry(productDir);
68
71
  const requestedFiles = opts.fileNames || [];
69
72
  if (requestedFiles.length > 0) {
70
73
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -109,7 +112,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
109
112
  typeValues,
110
113
  suiteSelectors,
111
114
  opts,
112
- execution
115
+ execution,
116
+ reporter
113
117
  );
114
118
  const trackers = buildServiceTrackers(servicePlans, startedAt);
115
119
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
@@ -138,6 +142,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
138
142
  productDir,
139
143
  graphs,
140
144
  lifecycle,
145
+ runtimeOptions: {
146
+ reporter,
147
+ logRegistry,
148
+ },
141
149
  });
142
150
  const timingUpdates = [];
143
151
 
@@ -153,7 +161,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
153
161
  lifecycle,
154
162
  claimNextTask,
155
163
  recordTaskOutcome,
156
- recordGraphError
164
+ recordGraphError,
165
+ reporter
157
166
  )
158
167
  )
159
168
  );
@@ -194,6 +203,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
194
203
  serviceFilter: opts.serviceFilter || null,
195
204
  metadata,
196
205
  summarizeDbBackend,
206
+ serviceLogs: logRegistry.listServiceLogs(),
197
207
  });
198
208
  const statusArtifact = opts.writeStatus
199
209
  ? buildStatusArtifact({
@@ -231,13 +241,19 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
231
241
  writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
232
242
  }
233
243
 
234
- printRunSummary(results, finishedAt - startedAt, knownFailureIssueValidation);
235
- await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
244
+ reporter?.runSummary?.(results, finishedAt - startedAt, knownFailureIssueValidation);
245
+ await reportTelemetry(telemetry, enrichedArtifacts.runArtifact, reporter);
236
246
  if (results.some((result) => result.failed)) exitCode = 1;
237
247
  if (shouldFailKnownFailureIssueValidation(knownFailureIssueValidation)) {
238
248
  exitCode = 1;
239
249
  }
240
250
  if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
251
+ return {
252
+ runArtifact: enrichedArtifacts.runArtifact,
253
+ statusArtifact: enrichedArtifacts.statusArtifact,
254
+ results,
255
+ exitCode,
256
+ };
241
257
  } finally {
242
258
  if (lifecycle.isStopRequested()) {
243
259
  exitCode = Math.max(exitCode, 130);
@@ -250,21 +266,22 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
250
266
  await cleanupRuns(productDir, { includeActive: false });
251
267
  lifecycle.removeManifest();
252
268
  lifecycle.dispose();
269
+ logRegistry.closeAll();
253
270
  process.exitCode = exitCode;
254
271
  }
255
272
  }
256
273
 
257
- function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution) {
274
+ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution, reporter) {
258
275
  return configs.map((config) => {
259
- console.log(`\n══ ${config.name} ══`);
260
276
  const suites = applyShard(
261
277
  collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts),
262
278
  opts.shard
263
279
  );
264
280
 
265
281
  if (suites.length === 0) {
266
- console.log(
267
- `No test files for ${config.name} types=${typeValues.join(",") || "all"} suites=${suiteSelectors.map((selector) => selector.raw).join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"} — skipping`
282
+ reporter?.serviceSkipped?.(
283
+ config,
284
+ `no matching files (types=${typeValues.join(",") || "all"} suites=${suiteSelectors.map((selector) => selector.raw).join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"})`
268
285
  );
269
286
  return {
270
287
  config,
@@ -277,6 +294,17 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
277
294
  };
278
295
  }
279
296
 
297
+ for (const suite of suites) {
298
+ for (const skippedFile of suite.skippedFiles || []) {
299
+ reporter?.plannedSkip?.({
300
+ serviceName: config.name,
301
+ type: suite.displayType || suite.type,
302
+ file: skippedFile.path,
303
+ reason: skippedFile.reason,
304
+ });
305
+ }
306
+ }
307
+
280
308
  const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
281
309
  return {
282
310
  config,
@@ -290,30 +318,24 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
290
318
  });
291
319
  }
292
320
 
293
- function printRunSummary(results, durationMs, knownFailureIssueValidation = null) {
294
- for (const line of buildRunSummaryLines(results, durationMs, knownFailureIssueValidation)) {
295
- console.log(line);
296
- }
297
- }
298
-
299
- async function reportTelemetry(telemetry, artifact) {
321
+ async function reportTelemetry(telemetry, artifact, reporter = null) {
300
322
  if (!telemetry?.enabled) return;
301
323
 
302
324
  try {
303
325
  const outcome = await uploadTelemetryArtifact(telemetry, artifact);
304
326
  if (outcome?.ok) {
305
- console.log("Telemetry: uploaded run artifact");
327
+ reporter?.telemetry?.("Telemetry: uploaded run artifact");
306
328
  return;
307
329
  }
308
330
  if (outcome?.reason === "missing-token") {
309
- console.log(
331
+ reporter?.telemetry?.(
310
332
  `Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
311
333
  );
312
334
  return;
313
335
  }
314
336
  if (outcome?.reason && !outcome.skipped) return;
315
337
  } catch (error) {
316
- console.log(`Telemetry: upload failed (${formatError(error)})`);
338
+ reporter?.telemetry?.(`Telemetry: upload failed (${formatError(error)})`);
317
339
  }
318
340
  }
319
341
 
@@ -3,14 +3,14 @@ import { execa } from "execa";
3
3
  import { parsePlaywrightJsonResults } from "../reporters/playwright.mjs";
4
4
  import { resolveServiceCwd } from "../config/index.mjs";
5
5
  import { formatFileTimeoutBudgetError } from "../shared/file-timeout.mjs";
6
+ import { persistTaskOutputArtifacts } from "./artifacts.mjs";
6
7
  import { settleSubprocess } from "./default-runtime-runner.mjs";
7
8
  import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
8
- import { printBufferedOutput } from "./processes.mjs";
9
9
  import { normalizePathSeparators } from "./state.mjs";
10
10
  import { buildPlaywrightEnv } from "./template.mjs";
11
11
  import { killChildProcess } from "./processes.mjs";
12
12
 
13
- export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
13
+ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease, reporter = null) {
14
14
  const local = targetConfig.testkit.local;
15
15
  if (!local?.baseUrl) {
16
16
  throw new Error(
@@ -44,7 +44,7 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
44
44
  if (subprocess.pid) interruptSubprocess();
45
45
  else subprocess.once?.("spawn", interruptSubprocess);
46
46
  }
47
- console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
47
+ reporter?.taskStarted?.(task, targetConfig);
48
48
  let result;
49
49
  let timedOut;
50
50
  try {
@@ -53,15 +53,22 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
53
53
  lifecycle.unregisterProcess(subprocess.pid);
54
54
  }
55
55
 
56
- if (result.stderr) {
57
- printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
58
- }
59
-
60
56
  const parsed = parsePlaywrightJsonResults(result.stdout || "", cwd);
61
57
  const finishedAt = Date.now();
62
58
  const durationMs = finishedAt - startedAt;
63
59
  const relativeFile = normalizePathSeparators(requestedFile);
64
60
  const fileResult = parsed.fileResults.get(relativeFile);
61
+ const outputArtifacts = persistTaskOutputArtifacts(targetConfig.productDir, task, [
62
+ result.stderr
63
+ ? {
64
+ name: "playwright-stderr",
65
+ kind: "runtime.output",
66
+ summary: result.stderr.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "captured stderr",
67
+ stream: "stderr",
68
+ text: result.stderr,
69
+ }
70
+ : null,
71
+ ]);
65
72
  const genericError = timedOut
66
73
  ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
67
74
  : result.exitCode === 0
@@ -76,6 +83,7 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
76
83
  durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
77
84
  startedAt,
78
85
  finishedAt,
86
+ artifacts: outputArtifacts,
79
87
  failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
80
88
  };
81
89
  }
@@ -55,9 +55,12 @@ export function killChildProcess(child, signal) {
55
55
  }
56
56
  }
57
57
 
58
- export function pipeOutput(stream, prefix) {
58
+ export function captureOutput(stream, options = {}) {
59
59
  if (!stream) return Promise.resolve();
60
60
 
61
+ const onLine = typeof options.onLine === "function" ? options.onLine : null;
62
+ const liveWriter = typeof options.liveWriter === "function" ? options.liveWriter : null;
63
+ const livePrefix = options.livePrefix || "";
61
64
  let pending = "";
62
65
  return new Promise((resolve) => {
63
66
  let settled = false;
@@ -72,12 +75,15 @@ export function pipeOutput(stream, prefix) {
72
75
  const lines = pending.split(/\r?\n/);
73
76
  pending = lines.pop() || "";
74
77
  for (const line of lines) {
75
- if (line.length > 0) console.log(`${prefix} ${line}`);
78
+ if (line.length === 0) continue;
79
+ onLine?.(line);
80
+ if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${line}` : line);
76
81
  }
77
82
  });
78
83
  stream.on("end", () => {
79
84
  if (pending.length > 0) {
80
- console.log(`${prefix} ${pending}`);
85
+ onLine?.(pending);
86
+ if (liveWriter) liveWriter(livePrefix ? `${livePrefix} ${pending}` : pending);
81
87
  }
82
88
  settle();
83
89
  });
@@ -86,14 +92,6 @@ export function pipeOutput(stream, prefix) {
86
92
  });
87
93
  }
88
94
 
89
- export function printBufferedOutput(output, prefix) {
90
- for (const line of output.split(/\r?\n/)) {
91
- if (line.trim().length > 0) {
92
- console.log(`${prefix} ${line}`);
93
- }
94
- }
95
- }
96
-
97
95
  export async function stopChildProcess(child, outputDrains = []) {
98
96
  if (!child) return;
99
97
  if (child.exitCode !== null) {
@@ -111,6 +111,7 @@ export function buildRunArtifact({
111
111
  serviceFilter,
112
112
  metadata,
113
113
  summarizeDbBackend,
114
+ serviceLogs = [],
114
115
  }) {
115
116
  const executed = results.filter((result) => !result.skipped);
116
117
  const effectivelySkippedServices = executed.filter(isEffectivelySkippedService);
@@ -127,7 +128,7 @@ export function buildRunArtifact({
127
128
  const dbBackend = summarizeDbBackend(results);
128
129
 
129
130
  return {
130
- schemaVersion: 6,
131
+ schemaVersion: 7,
131
132
  source: "testkit",
132
133
  generatedAt: new Date(finishedAt).toISOString(),
133
134
  product: {
@@ -176,6 +177,9 @@ export function buildRunArtifact({
176
177
  notRun: notRunFiles,
177
178
  },
178
179
  },
180
+ logs: {
181
+ services: serviceLogs,
182
+ },
179
183
  services: results.map((result) => ({
180
184
  name: result.name,
181
185
  failed: result.failed,
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
78
78
  });
79
79
 
80
80
  expect(artifact.product.name).toBe("my-product");
81
- expect(artifact.schemaVersion).toBe(6);
81
+ expect(artifact.schemaVersion).toBe(7);
82
82
  expect(artifact.run).toMatchObject({
83
83
  workers: 2,
84
84
  fileTimeoutSeconds: 60,
@@ -107,6 +107,9 @@ describe("runner reporting", () => {
107
107
  });
108
108
  expect(artifact.services[0].durationMs).toBe(1200);
109
109
  expect(artifact.services[0].totalTaskDurationMs).toBe(2400);
110
+ expect(artifact.logs).toEqual({
111
+ services: [],
112
+ });
110
113
  });
111
114
 
112
115
  it("builds deterministic status artifacts", () => {
@@ -35,12 +35,12 @@ export function createRuntimeInstanceContext(runtimeId, graph, productDir) {
35
35
  };
36
36
  }
37
37
 
38
- export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
38
+ export async function ensureRuntimeInstanceReady(context, task, lifecycle, options = {}) {
39
39
  if (!context.prepared) {
40
40
  if (!context.preparationPromise) {
41
41
  context.preparationPromise = (async () => {
42
42
  await prepareDatabases(context.runtimeConfigs);
43
- await prepareRuntimeServices(context.runtimeConfigs);
43
+ await prepareRuntimeServices(context.runtimeConfigs, options);
44
44
  context.prepared = true;
45
45
  })().finally(() => {
46
46
  context.preparationPromise = null;
@@ -52,7 +52,11 @@ export async function ensureRuntimeInstanceReady(context, task, lifecycle) {
52
52
  if (taskNeedsLocalRuntime(task) && !context.started) {
53
53
  if (!context.startupPromise) {
54
54
  context.startupPromise = (async () => {
55
- context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
55
+ context.startedServices = await startLocalServices(
56
+ context.runtimeConfigs,
57
+ lifecycle,
58
+ options
59
+ );
56
60
  context.started = true;
57
61
  })().finally(() => {
58
62
  context.startupPromise = null;
@@ -8,7 +8,7 @@ import {
8
8
  ensureRuntimeInstanceReady,
9
9
  } from "./runtime-contexts.mjs";
10
10
 
11
- export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {} }) {
11
+ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}, runtimeOptions = {} }) {
12
12
  const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
13
13
  const pools = new Map();
14
14
  const locks = new Map();
@@ -19,6 +19,7 @@ export function createRuntimeManager({ productDir, graphs, lifecycle, hooks = {}
19
19
  createRuntimeInstanceContext,
20
20
  ensureRuntimeInstanceReady,
21
21
  sleep,
22
+ options: runtimeOptions,
22
23
  ...hooks,
23
24
  };
24
25
 
@@ -199,7 +200,12 @@ async function getReadyContext(slot, productDir, task, lifecycle, runtimeHooks)
199
200
  slot.contextPromise = Promise.resolve(slot.context);
200
201
  }
201
202
  await slot.contextPromise;
202
- await runtimeHooks.ensureRuntimeInstanceReady(slot.context, task, lifecycle);
203
+ await runtimeHooks.ensureRuntimeInstanceReady(
204
+ slot.context,
205
+ task,
206
+ lifecycle,
207
+ runtimeHooks.options || {}
208
+ );
203
209
  return slot.context;
204
210
  }
205
211
 
@@ -13,13 +13,13 @@ import {
13
13
 
14
14
  const MANIFEST_FILE = "prepare-manifest.json";
15
15
 
16
- export async function prepareRuntimeServices(runtimeConfigs) {
16
+ export async function prepareRuntimeServices(runtimeConfigs, options = {}) {
17
17
  for (const config of runtimeConfigs) {
18
- await prepareRuntimeService(config);
18
+ await prepareRuntimeService(config, options);
19
19
  }
20
20
  }
21
21
 
22
- export async function prepareRuntimeService(config) {
22
+ export async function prepareRuntimeService(config, options = {}) {
23
23
  const prepare = config.testkit.runtime.prepare;
24
24
  if (!prepare || prepare.steps.length === 0) return;
25
25
 
@@ -43,12 +43,17 @@ export async function prepareRuntimeService(config) {
43
43
  }
44
44
 
45
45
  try {
46
- await announceResolvedToolchain(config, await resolveConfiguredToolchain(config));
46
+ await announceResolvedToolchain(
47
+ config,
48
+ await resolveConfiguredToolchain(config),
49
+ options.reporter
50
+ );
47
51
  await runConfiguredSteps({
48
52
  config,
49
53
  steps: prepare.steps,
50
54
  env,
51
55
  labelPrefix: "runtime:prepare",
56
+ reporter: options.reporter,
52
57
  });
53
58
  writePrepareManifest(manifestPath, {
54
59
  fingerprint,