@elench/testkit 0.1.52 → 0.1.54

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 (52) hide show
  1. package/README.md +14 -0
  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/code-frames.mjs +57 -0
  18. package/lib/cli/presentation/code-frames.test.mjs +71 -0
  19. package/lib/cli/presentation/colors.mjs +29 -0
  20. package/lib/cli/presentation/run-reporter.mjs +100 -0
  21. package/lib/cli/tui/watch-app.mjs +104 -0
  22. package/lib/cli/viewer.mjs +268 -0
  23. package/lib/known-failures/index.mjs +1 -1
  24. package/lib/known-failures/index.test.mjs +46 -0
  25. package/lib/runner/artifacts.mjs +35 -0
  26. package/lib/runner/default-runtime-errors.mjs +66 -0
  27. package/lib/runner/default-runtime-runner.mjs +52 -11
  28. package/lib/runner/failure-details.mjs +31 -0
  29. package/lib/runner/failure-details.test.mjs +51 -0
  30. package/lib/runner/formatting.mjs +207 -0
  31. package/lib/runner/formatting.test.mjs +81 -6
  32. package/lib/runner/logs.mjs +89 -0
  33. package/lib/runner/orchestrator.mjs +51 -20
  34. package/lib/runner/playwright-runner.mjs +15 -7
  35. package/lib/runner/processes.mjs +9 -11
  36. package/lib/runner/reporting.mjs +5 -1
  37. package/lib/runner/reporting.test.mjs +4 -1
  38. package/lib/runner/runtime-contexts.mjs +7 -3
  39. package/lib/runner/runtime-manager.mjs +8 -2
  40. package/lib/runner/runtime-preparation.mjs +9 -4
  41. package/lib/runner/services.mjs +25 -8
  42. package/lib/runner/template-steps.mjs +4 -3
  43. package/lib/runner/triage.mjs +67 -0
  44. package/lib/runner/worker-loop.mjs +8 -7
  45. package/lib/runtime/index.d.ts +60 -0
  46. package/lib/runtime/index.mjs +12 -0
  47. package/lib/runtime-src/k6/checks.js +45 -12
  48. package/lib/runtime-src/k6/http-assertions.js +214 -0
  49. package/lib/runtime-src/k6/http.js +261 -13
  50. package/lib/runtime-src/k6/suite.js +46 -1
  51. package/lib/toolchains/index.mjs +6 -3
  52. package/package.json +13 -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.extraLines.slice(0, 3)) {
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,163 @@ 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 rankedDetails = rankFailureDetails(file.failureDetails || []);
213
+ const primaryDetail = rankedDetails[0] || null;
214
+ const fallbackMessages = rankedDetails
215
+ .map((detail) => detail.message || detail.title)
216
+ .filter(Boolean)
217
+ .map((message) => sanitizeErrorMessage(String(message).trim()));
218
+ const extraLines = [];
219
+ if (primaryDetail) {
220
+ const responseLine = formatFailureResponsePreview(primaryDetail);
221
+ if (responseLine) extraLines.push(responseLine);
222
+ const triageLine = formatTriageLine(file.triage || null);
223
+ if (triageLine) extraLines.push(triageLine);
224
+ const requestLine = formatFailureRequestHint(primaryDetail);
225
+ if (requestLine) extraLines.push(requestLine);
226
+ } else {
227
+ const triageLine = formatTriageLine(file.triage || null);
228
+ if (triageLine) extraLines.push(triageLine);
229
+ }
230
+
231
+ for (const detail of rankedDetails.slice(primaryDetail ? 1 : 0)) {
232
+ const line = sanitizeErrorMessage(String(detail.message || detail.title || "").trim());
233
+ if (line && !extraLines.includes(line)) extraLines.push(line);
234
+ }
235
+ failures.push({
236
+ file,
237
+ primaryMessage: resolvePrimaryFailureMessage(file, suite, primaryDetail, fallbackMessages),
238
+ extraLines,
239
+ });
240
+ }
241
+ }
242
+ }
243
+ return failures.sort((left, right) => left.file.path.localeCompare(right.file.path));
244
+ }
245
+
246
+ function resolvePrimaryFailureMessage(file, suite, primaryDetail, fallbackMessages) {
247
+ if (primaryDetail?.message) {
248
+ return sanitizeErrorMessage(String(primaryDetail.message).trim());
249
+ }
250
+
251
+ const fileError = sanitizeErrorMessage(file.error || "");
252
+ if (fileError && !isThresholdWrapperMessage(fileError)) {
253
+ return fileError;
254
+ }
255
+
256
+ if (fallbackMessages.length > 0) {
257
+ return fallbackMessages[0];
258
+ }
259
+
260
+ if (fileError) {
261
+ return fileError;
262
+ }
263
+
264
+ return sanitizeErrorMessage(suite.error || "Failed");
265
+ }
266
+
267
+ function rankFailureDetails(details) {
268
+ return [...(Array.isArray(details) ? details : [])].sort((left, right) => {
269
+ return failureDetailRank(left) - failureDetailRank(right) || String(left?.key || "").localeCompare(String(right?.key || ""));
270
+ });
271
+ }
272
+
273
+ function failureDetailRank(detail) {
274
+ if (detail?.kind === "http-assertion") return 1;
275
+ if (detail?.request && detail?.response) return 2;
276
+ if (detail?.location || detail?.stack) return 3;
277
+ if (detail?.message) return 4;
278
+ return 5;
279
+ }
280
+
281
+ function formatFailureResponsePreview(detail) {
282
+ const bodyPreview = detail?.response?.bodyPreview;
283
+ if (!bodyPreview) return null;
284
+ return `response: ${sanitizeInline(bodyPreview, 220)}`;
285
+ }
286
+
287
+ function formatFailureRequestHint(detail) {
288
+ const method = detail?.request?.method;
289
+ const path = detail?.request?.path;
290
+ if (!method || !path) return null;
291
+ const requestId = detail?.request?.requestId;
292
+ if (requestId) {
293
+ return `logs: requestId=${requestId}`;
294
+ }
295
+ return `request: ${method} ${path}`;
296
+ }
297
+
298
+ function formatTriageLine(triage) {
299
+ if (!triage) return null;
300
+ if (triage.status === "untriaged") return "triage: untriaged";
301
+
302
+ const entry = triage.entries?.[0];
303
+ if (!entry?.issue) {
304
+ if (triage.status === "validation_unavailable") {
305
+ const reason = triage.availability?.reason ? ` (${triage.availability.reason})` : "";
306
+ return `triage: validation unavailable${reason}`;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ const issueLabel = `#${entry.issue.number}`;
312
+ const validationStatus = entry.validationStatus || null;
313
+ if (validationStatus === "closed_but_failing") {
314
+ return `triage: known issue ${issueLabel} closed but still failing`;
315
+ }
316
+ if (validationStatus === "validation_unavailable") {
317
+ const reason = triage.availability?.mode === "cache" ? "cache" : "validation unavailable";
318
+ return `triage: known issue ${issueLabel} (${reason})`;
319
+ }
320
+ if (entry.github?.state === "open" || entry.state === "open") {
321
+ return `triage: known issue ${issueLabel} open`;
322
+ }
323
+ if (entry.github?.state === "closed" || entry.state === "closed") {
324
+ return `triage: known issue ${issueLabel} closed`;
325
+ }
326
+ return `triage: known issue ${issueLabel}`;
327
+ }
328
+
329
+ function isThresholdWrapperMessage(message) {
330
+ return /Default runtime thresholds failed:/.test(String(message || ""));
331
+ }
332
+
333
+ function sanitizeInline(message, maxLength = 180) {
334
+ return String(message || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
335
+ }
336
+
337
+ function collectServiceErrors(results) {
338
+ const items = [];
339
+ for (const result of results) {
340
+ for (const error of result.errors || []) {
341
+ items.push({
342
+ service: result.name,
343
+ message: sanitizeErrorMessage(error),
344
+ });
345
+ }
346
+ }
347
+ return items;
348
+ }
349
+
143
350
  function pluralize(value, singular, plural) {
144
351
  return value === 1 ? singular : plural;
145
352
  }
@@ -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
 
@@ -164,4 +162,81 @@ describe("runner formatting", () => {
164
162
  expect(lines.join("\n")).toContain("2 closed issues still failing");
165
163
  expect(lines.join("\n")).toContain("1 title mismatch");
166
164
  });
165
+
166
+ it("prefers structured HTTP assertion details over threshold wrapper text", () => {
167
+ const lines = buildRunSummaryLines(
168
+ [
169
+ {
170
+ name: "api",
171
+ skipped: false,
172
+ failed: true,
173
+ suiteCount: 1,
174
+ completedSuiteCount: 1,
175
+ skippedSuiteCount: 0,
176
+ failedSuiteCount: 1,
177
+ totalFileCount: 1,
178
+ passedFileCount: 0,
179
+ failedFileCount: 1,
180
+ skippedFileCount: 0,
181
+ notRunFileCount: 0,
182
+ durationMs: 500,
183
+ suites: [
184
+ {
185
+ failed: true,
186
+ type: "int",
187
+ name: "default",
188
+ framework: "k6",
189
+ failedFiles: ["__testkit__/health/health.int.testkit.ts"],
190
+ durationMs: 500,
191
+ files: [
192
+ {
193
+ path: "__testkit__/health/health.int.testkit.ts",
194
+ status: "failed",
195
+ error: "Default runtime thresholds failed: checks(rate==1.0)",
196
+ failureDetails: [
197
+ {
198
+ kind: "http-assertion",
199
+ key: "GET /health > status is 200",
200
+ title: "status is 200",
201
+ message: "GET /health expected 200, got 404",
202
+ request: {
203
+ method: "GET",
204
+ path: "/health",
205
+ requestId: "req-1",
206
+ },
207
+ response: {
208
+ status: 404,
209
+ bodyPreview: '{"error":"nope"}',
210
+ },
211
+ },
212
+ ],
213
+ triage: {
214
+ status: "known_failure",
215
+ entries: [
216
+ {
217
+ id: "health-is-bad",
218
+ state: "open",
219
+ issue: {
220
+ repo: "acme/example",
221
+ number: 42,
222
+ },
223
+ },
224
+ ],
225
+ },
226
+ },
227
+ ],
228
+ error: null,
229
+ },
230
+ ],
231
+ errors: [],
232
+ },
233
+ ],
234
+ 500
235
+ );
236
+
237
+ expect(lines.join("\n")).toContain("GET /health expected 200, got 404");
238
+ expect(lines.join("\n")).toContain('response: {"error":"nope"}');
239
+ expect(lines.join("\n")).toContain("triage: known issue #42 open");
240
+ expect(lines.join("\n")).not.toContain("Default runtime thresholds failed: checks(rate==1.0)\n response:");
241
+ });
167
242
  });
@@ -0,0 +1,89 @@
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
+ export function findLogSliceByRequestId(absolutePath, requestId, contextLines = 2) {
63
+ if (!absolutePath || !requestId || !fs.existsSync(absolutePath)) return [];
64
+ const lines = fs.readFileSync(absolutePath, "utf8").split(/\r?\n/).filter(Boolean);
65
+ const matches = [];
66
+
67
+ for (let index = 0; index < lines.length; index += 1) {
68
+ if (!lines[index].includes(requestId)) continue;
69
+ const start = Math.max(0, index - contextLines);
70
+ const end = Math.min(lines.length, index + contextLines + 1);
71
+ for (let cursor = start; cursor < end; cursor += 1) {
72
+ if (!matches.includes(lines[cursor])) matches.push(lines[cursor]);
73
+ }
74
+ }
75
+
76
+ return matches;
77
+ }
78
+
79
+ function sanitizePathSegment(value) {
80
+ return String(value)
81
+ .trim()
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9._-]+/g, "-")
84
+ .replace(/^-+|-+$/g, "") || "log";
85
+ }
86
+
87
+ function normalizePath(filePath) {
88
+ return filePath.split(path.sep).join("/");
89
+ }
@@ -15,8 +15,12 @@ import {
15
15
  summarizeDbBackend,
16
16
  } from "./results.mjs";
17
17
  import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
- import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
19
- import { buildRunSummaryLines, formatError } from "./formatting.mjs";
18
+ import {
19
+ applyKnownFailureIssueValidationToArtifacts,
20
+ applyKnownFailuresToArtifacts,
21
+ loadKnownFailuresConfig,
22
+ } from "./triage.mjs";
23
+ import { formatError } from "./formatting.mjs";
20
24
  import {
21
25
  shouldFailKnownFailureIssueValidation,
22
26
  validateKnownFailureIssues,
@@ -28,6 +32,7 @@ import {
28
32
  writeRunArtifact,
29
33
  writeStatusArtifact,
30
34
  } from "./artifacts.mjs";
35
+ import { createRunLogRegistry } from "./logs.mjs";
31
36
  import {
32
37
  cleanupRunById,
33
38
  cleanupRuns,
@@ -65,6 +70,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
65
70
  productDir,
66
71
  configs[0]?.testkit?.reporting || null
67
72
  );
73
+ const reporter = opts.reporter || null;
74
+ const logRegistry = createRunLogRegistry(productDir);
68
75
  const requestedFiles = opts.fileNames || [];
69
76
  if (requestedFiles.length > 0) {
70
77
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -109,7 +116,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
109
116
  typeValues,
110
117
  suiteSelectors,
111
118
  opts,
112
- execution
119
+ execution,
120
+ reporter
113
121
  );
114
122
  const trackers = buildServiceTrackers(servicePlans, startedAt);
115
123
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
@@ -138,6 +146,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
138
146
  productDir,
139
147
  graphs,
140
148
  lifecycle,
149
+ runtimeOptions: {
150
+ reporter,
151
+ logRegistry,
152
+ },
141
153
  });
142
154
  const timingUpdates = [];
143
155
 
@@ -153,7 +165,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
153
165
  lifecycle,
154
166
  claimNextTask,
155
167
  recordTaskOutcome,
156
- recordGraphError
168
+ recordGraphError,
169
+ reporter
157
170
  )
158
171
  )
159
172
  );
@@ -194,6 +207,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
194
207
  serviceFilter: opts.serviceFilter || null,
195
208
  metadata,
196
209
  summarizeDbBackend,
210
+ serviceLogs: logRegistry.listServiceLogs(),
197
211
  });
198
212
  const statusArtifact = opts.writeStatus
199
213
  ? buildStatusArtifact({
@@ -220,6 +234,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
220
234
  config: configs[0]?.testkit?.reporting?.issueValidation || null,
221
235
  gitMetadata: metadata.git,
222
236
  });
237
+ applyKnownFailureIssueValidationToArtifacts(
238
+ enrichedArtifacts.runArtifact,
239
+ enrichedArtifacts.statusArtifact,
240
+ knownFailureIssueValidation
241
+ );
223
242
  attachKnownFailureIssueValidation(
224
243
  enrichedArtifacts.runArtifact,
225
244
  enrichedArtifacts.statusArtifact,
@@ -231,13 +250,19 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
231
250
  writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
232
251
  }
233
252
 
234
- printRunSummary(results, finishedAt - startedAt, knownFailureIssueValidation);
235
- await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
253
+ reporter?.runSummary?.(results, finishedAt - startedAt, knownFailureIssueValidation);
254
+ await reportTelemetry(telemetry, enrichedArtifacts.runArtifact, reporter);
236
255
  if (results.some((result) => result.failed)) exitCode = 1;
237
256
  if (shouldFailKnownFailureIssueValidation(knownFailureIssueValidation)) {
238
257
  exitCode = 1;
239
258
  }
240
259
  if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
260
+ return {
261
+ runArtifact: enrichedArtifacts.runArtifact,
262
+ statusArtifact: enrichedArtifacts.statusArtifact,
263
+ results,
264
+ exitCode,
265
+ };
241
266
  } finally {
242
267
  if (lifecycle.isStopRequested()) {
243
268
  exitCode = Math.max(exitCode, 130);
@@ -250,21 +275,22 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
250
275
  await cleanupRuns(productDir, { includeActive: false });
251
276
  lifecycle.removeManifest();
252
277
  lifecycle.dispose();
278
+ logRegistry.closeAll();
253
279
  process.exitCode = exitCode;
254
280
  }
255
281
  }
256
282
 
257
- function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution) {
283
+ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opts, execution, reporter) {
258
284
  return configs.map((config) => {
259
- console.log(`\n══ ${config.name} ══`);
260
285
  const suites = applyShard(
261
286
  collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts),
262
287
  opts.shard
263
288
  );
264
289
 
265
290
  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`
291
+ reporter?.serviceSkipped?.(
292
+ config,
293
+ `no matching files (types=${typeValues.join(",") || "all"} suites=${suiteSelectors.map((selector) => selector.raw).join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"})`
268
294
  );
269
295
  return {
270
296
  config,
@@ -277,6 +303,17 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
277
303
  };
278
304
  }
279
305
 
306
+ for (const suite of suites) {
307
+ for (const skippedFile of suite.skippedFiles || []) {
308
+ reporter?.plannedSkip?.({
309
+ serviceName: config.name,
310
+ type: suite.displayType || suite.type,
311
+ file: skippedFile.path,
312
+ reason: skippedFile.reason,
313
+ });
314
+ }
315
+ }
316
+
280
317
  const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
281
318
  return {
282
319
  config,
@@ -290,30 +327,24 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
290
327
  });
291
328
  }
292
329
 
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) {
330
+ async function reportTelemetry(telemetry, artifact, reporter = null) {
300
331
  if (!telemetry?.enabled) return;
301
332
 
302
333
  try {
303
334
  const outcome = await uploadTelemetryArtifact(telemetry, artifact);
304
335
  if (outcome?.ok) {
305
- console.log("Telemetry: uploaded run artifact");
336
+ reporter?.telemetry?.("Telemetry: uploaded run artifact");
306
337
  return;
307
338
  }
308
339
  if (outcome?.reason === "missing-token") {
309
- console.log(
340
+ reporter?.telemetry?.(
310
341
  `Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
311
342
  );
312
343
  return;
313
344
  }
314
345
  if (outcome?.reason && !outcome.skipped) return;
315
346
  } catch (error) {
316
- console.log(`Telemetry: upload failed (${formatError(error)})`);
347
+ reporter?.telemetry?.(`Telemetry: upload failed (${formatError(error)})`);
317
348
  }
318
349
  }
319
350
 
@@ -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
  }