@elench/testkit 0.1.42 → 0.1.43

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.
package/README.md CHANGED
@@ -68,6 +68,9 @@ export default defineTestkitSetup({
68
68
  workers: 8,
69
69
  fileTimeoutSeconds: 60,
70
70
  },
71
+ reporting: {
72
+ knownFailuresFile: "testkit.known-failures.json",
73
+ },
71
74
  services: {
72
75
  api: service({
73
76
  ...tsxService({
@@ -141,9 +144,17 @@ for:
141
144
  - migrate / seed commands
142
145
  - test-local migrate / seed overrides
143
146
  - named HTTP suite profiles
147
+ - known-failure annotation merge for enriched status/run artifacts
144
148
  - repo-declared suite/file skip policies with explicit reasons
145
149
  - telemetry upload configuration
146
150
 
151
+ If `reporting.knownFailuresFile` is configured, `testkit` enriches
152
+ `.testkit/results/latest.json` and `testkit.status.json` with:
153
+
154
+ - per-file `failureDetails`
155
+ - per-file `triage` metadata (issue, classification, description)
156
+ - top-level `triageSummary` counts for known vs untriaged failures
157
+
147
158
  ## Authoring
148
159
 
149
160
  HTTP suites:
@@ -30,6 +30,7 @@ export async function loadConfigs(opts = {}) {
30
30
  const productDir = resolveProductDir(process.cwd(), opts.dir);
31
31
  const { setup, setupFile } = await loadTestkitSetup(productDir);
32
32
  const execution = normalizeRepoExecution(setup.execution);
33
+ const reporting = normalizeReportingConfig(setup.reporting);
33
34
  const explicitServices = setup.services || {};
34
35
  const discovery = discoverProject(productDir, explicitServices);
35
36
  const serviceNames = new Set([
@@ -46,6 +47,7 @@ export async function loadConfigs(opts = {}) {
46
47
  setup,
47
48
  setupFile,
48
49
  execution,
50
+ reporting,
49
51
  explicitService: explicitServices[name] || {},
50
52
  discoveredService: discovery.services[name] || null,
51
53
  suites: discovery.suitesByService[name] || {},
@@ -105,6 +107,7 @@ function normalizeServiceConfig({
105
107
  setup,
106
108
  setupFile,
107
109
  execution,
110
+ reporting,
108
111
  explicitService,
109
112
  discoveredService,
110
113
  suites,
@@ -157,6 +160,7 @@ function normalizeServiceConfig({
157
160
  suites,
158
161
  testkit: {
159
162
  execution,
163
+ reporting,
160
164
  dependsOn: explicitService.dependsOn || [],
161
165
  database,
162
166
  databaseFrom: explicitService.databaseFrom,
@@ -172,6 +176,19 @@ function normalizeServiceConfig({
172
176
  };
173
177
  }
174
178
 
179
+ function normalizeReportingConfig(value) {
180
+ if (!value) return null;
181
+
182
+ const knownFailuresFile = normalizeOptionalString(value.knownFailuresFile);
183
+ if (!knownFailuresFile) {
184
+ throw new Error('testkit.setup.ts reporting.knownFailuresFile must be a non-empty string');
185
+ }
186
+
187
+ return {
188
+ knownFailuresFile,
189
+ };
190
+ }
191
+
175
192
  function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
176
193
  if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
177
194
  if (explicitService.local === false) {
@@ -282,6 +299,12 @@ function normalizeRuntimeConfig(value, serviceName) {
282
299
  };
283
300
  }
284
301
 
302
+ function normalizeOptionalString(value) {
303
+ if (typeof value !== "string") return null;
304
+ const normalized = value.trim();
305
+ return normalized.length > 0 ? normalized : null;
306
+ }
307
+
285
308
  function normalizeLifecycle(value) {
286
309
  if (!value) return undefined;
287
310
  if (!value.cmd && !value.testkitCmd) {
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { mergeFailureDetails } from "../runner/failure-details.mjs";
2
3
 
3
4
  export function parsePlaywrightJsonResults(stdout, cwd) {
4
5
  if (!stdout.trim()) {
@@ -16,34 +17,42 @@ export function parsePlaywrightJsonResults(stdout, cwd) {
16
17
  }
17
18
 
18
19
  const fileResults = new Map();
19
- visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
20
+ visitPlaywrightSuites(parsed.suites || [], null, [], fileResults, cwd);
20
21
  return {
21
22
  fileResults: sanitizePlaywrightFileResults(fileResults),
22
23
  errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
23
24
  };
24
25
  }
25
26
 
26
- export function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
27
+ export function visitPlaywrightSuites(suites, inheritedFile, inheritedTitlePath, fileResults, cwd) {
27
28
  for (const suite of suites || []) {
28
29
  const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
30
+ const suiteTitle = normalizeSuiteTitle(suite?.title, suiteFile);
31
+ const suiteTitlePath = suiteTitle
32
+ ? [...inheritedTitlePath, suiteTitle].filter(Boolean)
33
+ : inheritedTitlePath;
29
34
  for (const child of suite.suites || []) {
30
- visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
35
+ visitPlaywrightSuites([child], suiteFile, suiteTitlePath, fileResults, cwd);
31
36
  }
32
37
  for (const spec of suite.specs || []) {
33
- collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
38
+ collectPlaywrightSpec(spec, suiteFile, suiteTitlePath, fileResults, cwd);
34
39
  }
35
40
  }
36
41
  }
37
42
 
38
- export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
43
+ export function collectPlaywrightSpec(spec, inheritedFile, suiteTitlePath, fileResults, cwd) {
39
44
  const file = normalizeReportedFile(extractReporterFile(spec) || inheritedFile, cwd);
40
45
  if (!file) return;
46
+ const specTitle = firstLine(spec?.title || "Playwright spec failed") || "Playwright spec failed";
47
+ const specPath = [...suiteTitlePath, specTitle].filter(Boolean);
48
+ const failureKey = specPath.join(" > ");
41
49
 
42
50
  const current = fileResults.get(file) || {
43
51
  failed: false,
44
52
  status: "passed",
45
53
  error: null,
46
54
  durationMs: 0,
55
+ failureDetails: [],
47
56
  passedCount: 0,
48
57
  skippedCount: 0,
49
58
  };
@@ -62,6 +71,13 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
62
71
  current.failed = true;
63
72
  current.status = "failed";
64
73
  current.error ||= extractPlaywrightFailure(final, spec, test);
74
+ current.failureDetails.push({
75
+ kind: "playwright-spec",
76
+ key: failureKey,
77
+ title: specTitle,
78
+ suitePath: suiteTitlePath,
79
+ message: extractPlaywrightFailure(final, spec, test),
80
+ });
65
81
  continue;
66
82
  }
67
83
 
@@ -76,6 +92,7 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
76
92
  if (!current.failed) {
77
93
  current.status = current.passedCount === 0 && current.skippedCount > 0 ? "skipped" : "passed";
78
94
  }
95
+ current.failureDetails = mergeFailureDetails(current.failureDetails);
79
96
  fileResults.set(file, current);
80
97
  }
81
98
 
@@ -155,7 +172,19 @@ function sanitizePlaywrightFileResults(fileResults) {
155
172
  status: result.status,
156
173
  error: result.error,
157
174
  durationMs: result.durationMs,
175
+ failureDetails: mergeFailureDetails(result.failureDetails),
158
176
  });
159
177
  }
160
178
  return sanitized;
161
179
  }
180
+
181
+ function normalizeSuiteTitle(title, reportedFile) {
182
+ const normalizedTitle = firstLine(title || "");
183
+ if (!normalizedTitle) return null;
184
+ if (!reportedFile) return normalizedTitle;
185
+ const fileName = reportedFile.split("/").pop();
186
+ if (normalizedTitle === reportedFile || normalizedTitle === fileName) {
187
+ return null;
188
+ }
189
+ return normalizedTitle;
190
+ }
@@ -52,6 +52,15 @@ describe("playwright-report", () => {
52
52
  status: "failed",
53
53
  error: "boom",
54
54
  durationMs: 15,
55
+ failureDetails: [
56
+ {
57
+ kind: "playwright-spec",
58
+ key: "auth works",
59
+ title: "auth works",
60
+ count: 1,
61
+ message: "boom",
62
+ },
63
+ ],
55
64
  });
56
65
  });
57
66
 
@@ -86,6 +95,7 @@ describe("playwright-report", () => {
86
95
  status: "skipped",
87
96
  error: null,
88
97
  durationMs: 7,
98
+ failureDetails: [],
89
99
  });
90
100
  });
91
101
 
@@ -134,6 +144,7 @@ describe("playwright-report", () => {
134
144
  status: "passed",
135
145
  error: null,
136
146
  durationMs: 8,
147
+ failureDetails: [],
137
148
  });
138
149
  });
139
150
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "../shared/file-timeout.mjs";
10
10
  import { persistTaskArtifacts } from "./artifacts.mjs";
11
11
  import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
12
+ import { collectFailureDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
12
13
  import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
13
14
  import { readDatabaseUrl } from "./state-io.mjs";
14
15
  import { buildTaskExecutionEnv } from "./template.mjs";
@@ -105,11 +106,13 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
105
106
  if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
106
107
 
107
108
  const summary = readDefaultRuntimeSummary(summaryFile);
109
+ const rawRuntimeArtifacts = [...stdout.artifacts, ...stderr.artifacts];
108
110
  const runtimeArtifacts = persistTaskArtifacts(
109
111
  targetConfig.productDir,
110
112
  task,
111
- [...stdout.artifacts, ...stderr.artifacts]
113
+ rawRuntimeArtifacts
112
114
  );
115
+ const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
113
116
  const runtimeError = timedOut
114
117
  ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
115
118
  : determineDefaultRuntimeFailure(result, summary, getFirstLine);
@@ -123,6 +126,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
123
126
  startedAt,
124
127
  finishedAt,
125
128
  artifacts: runtimeArtifacts,
129
+ failureDetails,
126
130
  };
127
131
  }
128
132
 
@@ -0,0 +1,91 @@
1
+ export function normalizeFailureDetail(detail) {
2
+ if (!detail || typeof detail !== "object") return null;
3
+
4
+ const kind = normalizeNonEmptyString(detail.kind);
5
+ const key = normalizeNonEmptyString(detail.key);
6
+ const title = normalizeNonEmptyString(detail.title);
7
+ if (!kind || !key || !title) return null;
8
+
9
+ const normalized = {
10
+ kind,
11
+ key,
12
+ title,
13
+ count: normalizePositiveInteger(detail.count) || 1,
14
+ };
15
+
16
+ const groupPath = normalizeStringArray(detail.groupPath);
17
+ if (groupPath.length > 0) normalized.groupPath = groupPath;
18
+
19
+ const checkName = normalizeNonEmptyString(detail.checkName);
20
+ if (checkName) normalized.checkName = checkName;
21
+
22
+ const suitePath = normalizeStringArray(detail.suitePath);
23
+ if (suitePath.length > 0) normalized.suitePath = suitePath;
24
+
25
+ const phase = normalizeNonEmptyString(detail.phase);
26
+ if (phase) normalized.phase = phase;
27
+
28
+ const message = normalizeNonEmptyString(detail.message);
29
+ if (message) normalized.message = message;
30
+
31
+ return normalized;
32
+ }
33
+
34
+ export function mergeFailureDetails(details) {
35
+ const mergedByKey = new Map();
36
+
37
+ for (const detail of Array.isArray(details) ? details : []) {
38
+ const normalized = normalizeFailureDetail(detail);
39
+ if (!normalized) continue;
40
+ const dedupeKey = `${normalized.kind}::${normalized.key}`;
41
+ const existing = mergedByKey.get(dedupeKey);
42
+ if (!existing) {
43
+ mergedByKey.set(dedupeKey, { ...normalized });
44
+ continue;
45
+ }
46
+ existing.count += normalized.count;
47
+ if (!existing.message && normalized.message) {
48
+ existing.message = normalized.message;
49
+ }
50
+ }
51
+
52
+ return [...mergedByKey.values()].sort(
53
+ (left, right) =>
54
+ left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key)
55
+ );
56
+ }
57
+
58
+ export function collectFailureDetailsFromRuntimeArtifacts(artifacts) {
59
+ const details = [];
60
+
61
+ for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
62
+ if (artifact?.kind !== "testkit.failure-details") continue;
63
+ const phase = normalizeNonEmptyString(artifact?.data?.phase);
64
+ for (const detail of Array.isArray(artifact?.data?.failures) ? artifact.data.failures : []) {
65
+ details.push({
66
+ ...detail,
67
+ phase: detail?.phase || phase || null,
68
+ });
69
+ }
70
+ }
71
+
72
+ return mergeFailureDetails(details);
73
+ }
74
+
75
+ function normalizeStringArray(value) {
76
+ if (!Array.isArray(value)) return [];
77
+ return value
78
+ .map((entry) => normalizeNonEmptyString(entry))
79
+ .filter(Boolean);
80
+ }
81
+
82
+ function normalizeNonEmptyString(value) {
83
+ if (typeof value !== "string") return null;
84
+ const normalized = value.trim();
85
+ return normalized.length > 0 ? normalized : null;
86
+ }
87
+
88
+ function normalizePositiveInteger(value) {
89
+ if (!Number.isInteger(value) || value <= 0) return null;
90
+ return value;
91
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ collectFailureDetailsFromRuntimeArtifacts,
4
+ mergeFailureDetails,
5
+ } from "./failure-details.mjs";
6
+
7
+ describe("runner failure details", () => {
8
+ it("merges duplicate failure details by kind and key", () => {
9
+ expect(
10
+ mergeFailureDetails([
11
+ { kind: "k6-check", key: "group > check", title: "check" },
12
+ { kind: "k6-check", key: "group > check", title: "check" },
13
+ { kind: "playwright-spec", key: "spec title", title: "spec title" },
14
+ ])
15
+ ).toEqual([
16
+ {
17
+ kind: "k6-check",
18
+ key: "group > check",
19
+ title: "check",
20
+ count: 2,
21
+ },
22
+ {
23
+ kind: "playwright-spec",
24
+ key: "spec title",
25
+ title: "spec title",
26
+ count: 1,
27
+ },
28
+ ]);
29
+ });
30
+
31
+ it("extracts and normalizes runtime failure-detail artifacts", () => {
32
+ expect(
33
+ collectFailureDetailsFromRuntimeArtifacts([
34
+ {
35
+ kind: "testkit.failure-details",
36
+ data: {
37
+ phase: "exec",
38
+ failures: [
39
+ {
40
+ kind: "k6-check",
41
+ key: "status is 200",
42
+ title: "status is 200",
43
+ },
44
+ {
45
+ kind: "k6-check",
46
+ key: "status is 200",
47
+ title: "status is 200",
48
+ },
49
+ ],
50
+ },
51
+ },
52
+ ])
53
+ ).toEqual([
54
+ {
55
+ kind: "k6-check",
56
+ key: "status is 200",
57
+ title: "status is 200",
58
+ count: 2,
59
+ phase: "exec",
60
+ },
61
+ ]);
62
+ });
63
+ });
@@ -15,6 +15,7 @@ import {
15
15
  summarizeDbBackend,
16
16
  } from "./results.mjs";
17
17
  import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
+ import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
18
19
  import { buildRunSummaryLines, formatError } from "./formatting.mjs";
19
20
  import {
20
21
  loadTimings,
@@ -56,6 +57,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
56
57
  },
57
58
  testkitVersion: readPackageMetadata().version,
58
59
  };
60
+ const knownFailures = loadKnownFailuresConfig(
61
+ productDir,
62
+ configs[0]?.testkit?.reporting || null
63
+ );
59
64
  const requestedFiles = opts.fileNames || [];
60
65
  if (requestedFiles.length > 0) {
61
66
  const unmatchedFiles = findUnmatchedRequestedFiles(
@@ -168,7 +173,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
168
173
  results = configs.map((config) =>
169
174
  finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
170
175
  );
171
- const artifact = buildRunArtifact({
176
+ const runArtifact = buildRunArtifact({
172
177
  productDir,
173
178
  results,
174
179
  startedAt,
@@ -185,12 +190,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
185
190
  metadata,
186
191
  summarizeDbBackend,
187
192
  });
188
-
189
- writeRunArtifact(productDir, artifact);
190
- if (opts.writeStatus) {
191
- writeStatusArtifact(
192
- productDir,
193
- buildStatusArtifact({
193
+ const statusArtifact = opts.writeStatus
194
+ ? buildStatusArtifact({
194
195
  productDir,
195
196
  results,
196
197
  typeValues,
@@ -200,11 +201,20 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
200
201
  serviceFilter: opts.serviceFilter || null,
201
202
  metadata,
202
203
  })
203
- );
204
+ : null;
205
+ const enrichedArtifacts = applyKnownFailuresToArtifacts(
206
+ runArtifact,
207
+ statusArtifact,
208
+ knownFailures
209
+ );
210
+
211
+ writeRunArtifact(productDir, enrichedArtifacts.runArtifact);
212
+ if (opts.writeStatus) {
213
+ writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
204
214
  }
205
215
 
206
216
  printRunSummary(results, finishedAt - startedAt);
207
- await reportTelemetry(telemetry, artifact);
217
+ await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
208
218
  if (results.some((result) => result.failed)) exitCode = 1;
209
219
  if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
210
220
  } finally {
@@ -76,5 +76,6 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
76
76
  durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
77
77
  startedAt,
78
78
  finishedAt,
79
+ failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
79
80
  };
80
81
  }
@@ -23,9 +23,15 @@ export function buildStatusArtifact({
23
23
  path: file.path,
24
24
  status: file.status,
25
25
  };
26
+ if (file.error) {
27
+ test.error = file.error;
28
+ }
26
29
  if (file.reason) {
27
30
  test.reason = file.reason;
28
31
  }
32
+ if (Array.isArray(file.failureDetails) && file.failureDetails.length > 0) {
33
+ test.failureDetails = file.failureDetails;
34
+ }
29
35
  tests.push(test);
30
36
  }
31
37
  }
@@ -72,7 +78,7 @@ export function buildStatusArtifact({
72
78
  scope.serviceFilter === null;
73
79
 
74
80
  return {
75
- schemaVersion: 3,
81
+ schemaVersion: 4,
76
82
  source: "testkit",
77
83
  notice: "Generated file. Do not edit manually.",
78
84
  product: {
@@ -121,7 +127,7 @@ export function buildRunArtifact({
121
127
  const dbBackend = summarizeDbBackend(results);
122
128
 
123
129
  return {
124
- schemaVersion: 3,
130
+ schemaVersion: 4,
125
131
  source: "testkit",
126
132
  generatedAt: new Date(finishedAt).toISOString(),
127
133
  product: {
@@ -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(3);
81
+ expect(artifact.schemaVersion).toBe(4);
82
82
  expect(artifact.run).toMatchObject({
83
83
  workers: 2,
84
84
  fileTimeoutSeconds: 60,
@@ -149,7 +149,7 @@ describe("runner reporting", () => {
149
149
  });
150
150
 
151
151
  expect(status).toEqual({
152
- schemaVersion: 3,
152
+ schemaVersion: 4,
153
153
  source: "testkit",
154
154
  notice: "Generated file. Do not edit manually.",
155
155
  product: {
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { mergeFailureDetails } from "./failure-details.mjs";
2
3
 
3
4
  export function buildServiceTrackers(servicePlans, startedAt) {
4
5
  const trackers = new Map();
@@ -46,6 +47,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
46
47
  reason: null,
47
48
  status: "not_run",
48
49
  artifacts: [],
50
+ failureDetails: [],
49
51
  },
50
52
  ];
51
53
  }),
@@ -59,6 +61,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
59
61
  reason: file.reason,
60
62
  status: "skipped",
61
63
  artifacts: [],
64
+ failureDetails: [],
62
65
  },
63
66
  ]),
64
67
  ]),
@@ -121,6 +124,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
121
124
  existingFileResult.reason = outcome.reason || null;
122
125
  existingFileResult.status = status;
123
126
  existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
127
+ existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
124
128
  } else {
125
129
  suite.fileResultsByPath.set(normalizedPath, {
126
130
  path: normalizedPath,
@@ -130,6 +134,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
130
134
  reason: outcome.reason || null,
131
135
  status,
132
136
  artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
137
+ failureDetails: mergeFailureDetails(outcome.failureDetails),
133
138
  });
134
139
  }
135
140
  if (status === "failed" && !suite.failedFileSet.has(task.file)) {
@@ -247,6 +252,9 @@ function finalizeSuite(suite) {
247
252
  durationMs: file.durationMs,
248
253
  error: file.error,
249
254
  reason: file.reason,
255
+ ...(Array.isArray(file.failureDetails) && file.failureDetails.length > 0
256
+ ? { failureDetails: file.failureDetails }
257
+ : {}),
250
258
  ...(Array.isArray(file.artifacts) && file.artifacts.length > 0
251
259
  ? { artifacts: file.artifacts }
252
260
  : {}),
@@ -0,0 +1,330 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const CLASSIFICATIONS = new Set([
5
+ "expected_failure",
6
+ "infra",
7
+ "product_bug",
8
+ "stale_test",
9
+ "test_bug",
10
+ ]);
11
+ const STATES = new Set(["closed", "open"]);
12
+
13
+ export function loadKnownFailuresConfig(productDir, config) {
14
+ const relativePath = config?.knownFailuresFile;
15
+ if (!relativePath) return null;
16
+
17
+ const absolutePath = path.resolve(productDir, relativePath);
18
+ if (!fs.existsSync(absolutePath)) {
19
+ throw new Error(`Known failures file not found: ${relativePath}`);
20
+ }
21
+
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
25
+ } catch (error) {
26
+ throw new Error(
27
+ `Could not parse known failures file ${relativePath}: ${formatErrorMessage(error)}`
28
+ );
29
+ }
30
+
31
+ return normalizeKnownFailuresDocument(parsed, relativePath);
32
+ }
33
+
34
+ export function applyKnownFailuresToArtifacts(runArtifact, statusArtifact, knownFailures) {
35
+ if (!knownFailures) return { runArtifact, statusArtifact };
36
+
37
+ const runEntries = extractRunFileEntries(runArtifact);
38
+ const statusEntries = extractStatusFileEntries(statusArtifact);
39
+ const fileSummaries = new Map();
40
+
41
+ for (const entry of [...runEntries, ...statusEntries]) {
42
+ const key = buildFileIdentity(entry.service, entry.type, entry.path);
43
+ if (!fileSummaries.has(key)) {
44
+ fileSummaries.set(key, {
45
+ service: entry.service,
46
+ type: entry.type,
47
+ path: entry.path,
48
+ status: entry.status,
49
+ error: entry.error || null,
50
+ failureDetails: Array.isArray(entry.failureDetails) ? entry.failureDetails : [],
51
+ });
52
+ }
53
+ }
54
+
55
+ const matchesByFileKey = new Map();
56
+ const matchedByFailedEntryIds = new Set();
57
+
58
+ for (const entry of knownFailures.entries) {
59
+ for (const fileSummary of fileSummaries.values()) {
60
+ if (!matchesKnownFailureEntry(entry, fileSummary)) continue;
61
+ const fileKey = buildFileIdentity(fileSummary.service, fileSummary.type, fileSummary.path);
62
+ if (!matchesByFileKey.has(fileKey)) {
63
+ matchesByFileKey.set(fileKey, []);
64
+ }
65
+ matchesByFileKey.get(fileKey).push(toArtifactTriageEntry(entry));
66
+ if (fileSummary.status === "failed") {
67
+ matchedByFailedEntryIds.add(entry.id);
68
+ }
69
+ }
70
+ }
71
+
72
+ for (const entry of [...runEntries, ...statusEntries]) {
73
+ const fileKey = buildFileIdentity(entry.service, entry.type, entry.path);
74
+ const matches = matchesByFileKey.get(fileKey) || [];
75
+ if (matches.length === 0) {
76
+ if (entry.status === "failed") {
77
+ setEntryTriage(entry, {
78
+ status: "untriaged",
79
+ entries: [],
80
+ });
81
+ }
82
+ continue;
83
+ }
84
+
85
+ setEntryTriage(entry, {
86
+ status: entry.status === "failed" ? "known_failure" : "known_issue_not_reproduced",
87
+ classifications: [...new Set(matches.map((match) => match.classification))].sort(),
88
+ entries: matches,
89
+ });
90
+ }
91
+
92
+ const summaryTests = statusArtifact?.tests || runEntries;
93
+ const triageSummary = buildTriageSummary(
94
+ summaryTests,
95
+ knownFailures.entries,
96
+ matchedByFailedEntryIds
97
+ );
98
+ runArtifact.triageSummary = triageSummary;
99
+ if (statusArtifact) {
100
+ statusArtifact.triageSummary = triageSummary;
101
+ }
102
+ return { runArtifact, statusArtifact };
103
+ }
104
+
105
+ export function normalizeKnownFailuresDocument(document, relativePath = "known failures file") {
106
+ if (!document || typeof document !== "object") {
107
+ throw new Error(`${relativePath} must contain a JSON object`);
108
+ }
109
+ if (document.schemaVersion !== 1) {
110
+ throw new Error(`${relativePath} schemaVersion must be 1`);
111
+ }
112
+ if (!Array.isArray(document.entries)) {
113
+ throw new Error(`${relativePath} entries must be an array`);
114
+ }
115
+
116
+ const ids = new Set();
117
+ const entries = document.entries.map((entry, index) => {
118
+ const normalized = normalizeKnownFailureEntry(entry, `${relativePath} entries[${index}]`);
119
+ if (ids.has(normalized.id)) {
120
+ throw new Error(`${relativePath} has duplicate entry id "${normalized.id}"`);
121
+ }
122
+ ids.add(normalized.id);
123
+ return normalized;
124
+ });
125
+
126
+ return {
127
+ schemaVersion: 1,
128
+ issueRepo: normalizeOptionalString(document.issueRepo),
129
+ entries,
130
+ };
131
+ }
132
+
133
+ function normalizeKnownFailureEntry(entry, label) {
134
+ if (!entry || typeof entry !== "object") {
135
+ throw new Error(`${label} must be an object`);
136
+ }
137
+
138
+ const id = requireNonEmptyString(entry.id, `${label}.id`);
139
+ const title = requireNonEmptyString(entry.title, `${label}.title`);
140
+ const classification = requireEnumValue(
141
+ entry.classification,
142
+ CLASSIFICATIONS,
143
+ `${label}.classification`
144
+ );
145
+ const state = requireEnumValue(entry.state, STATES, `${label}.state`);
146
+ const description = requireNonEmptyString(entry.description, `${label}.description`);
147
+ const whyFailing = requireNonEmptyString(entry.whyFailing, `${label}.whyFailing`);
148
+ const lastReviewedAt = requireNonEmptyString(entry.lastReviewedAt, `${label}.lastReviewedAt`);
149
+ if (!Array.isArray(entry.matches) || entry.matches.length === 0) {
150
+ throw new Error(`${label}.matches must be a non-empty array`);
151
+ }
152
+
153
+ return {
154
+ id,
155
+ title,
156
+ classification,
157
+ state,
158
+ issue: normalizeKnownFailureIssue(entry.issue, `${label}.issue`),
159
+ description,
160
+ whyFailing,
161
+ lastReviewedAt,
162
+ matches: entry.matches.map((match, index) =>
163
+ normalizeKnownFailureMatch(match, `${label}.matches[${index}]`)
164
+ ),
165
+ };
166
+ }
167
+
168
+ function normalizeKnownFailureIssue(issue, label) {
169
+ if (!issue || typeof issue !== "object") {
170
+ throw new Error(`${label} must be an object`);
171
+ }
172
+
173
+ const repo = requireNonEmptyString(issue.repo, `${label}.repo`);
174
+ const number = issue.number;
175
+ if (!Number.isInteger(number) || number <= 0) {
176
+ throw new Error(`${label}.number must be a positive integer`);
177
+ }
178
+ const url = requireNonEmptyString(issue.url, `${label}.url`);
179
+
180
+ return { repo, number, url };
181
+ }
182
+
183
+ function normalizeKnownFailureMatch(match, label) {
184
+ if (!match || typeof match !== "object") {
185
+ throw new Error(`${label} must be an object`);
186
+ }
187
+
188
+ const pathValue = requireNonEmptyString(match.path, `${label}.path`);
189
+ const normalized = {
190
+ path: pathValue,
191
+ };
192
+
193
+ const service = normalizeOptionalString(match.service);
194
+ if (service) normalized.service = service;
195
+
196
+ const type = normalizeOptionalString(match.type);
197
+ if (type) normalized.type = type;
198
+
199
+ const failureKey = normalizeOptionalString(match.failureKey);
200
+ if (failureKey) normalized.failureKey = failureKey;
201
+
202
+ const errorIncludes = normalizeOptionalString(match.errorIncludes);
203
+ if (errorIncludes) normalized.errorIncludes = errorIncludes;
204
+
205
+ return normalized;
206
+ }
207
+
208
+ function matchesKnownFailureEntry(entry, fileSummary) {
209
+ return entry.matches.some((match) => matchesKnownFailureMatch(match, fileSummary));
210
+ }
211
+
212
+ function matchesKnownFailureMatch(match, fileSummary) {
213
+ if (match.service && match.service !== fileSummary.service) return false;
214
+ if (match.type && match.type !== fileSummary.type) return false;
215
+ if (match.path !== fileSummary.path) return false;
216
+ if (match.failureKey) {
217
+ const failureKeys = Array.isArray(fileSummary.failureDetails)
218
+ ? fileSummary.failureDetails.map((detail) => detail.key)
219
+ : [];
220
+ if (!failureKeys.includes(match.failureKey)) return false;
221
+ }
222
+ if (match.errorIncludes && !String(fileSummary.error || "").includes(match.errorIncludes)) {
223
+ return false;
224
+ }
225
+ return true;
226
+ }
227
+
228
+ function toArtifactTriageEntry(entry) {
229
+ return {
230
+ id: entry.id,
231
+ title: entry.title,
232
+ classification: entry.classification,
233
+ state: entry.state,
234
+ issue: entry.issue,
235
+ description: entry.description,
236
+ whyFailing: entry.whyFailing,
237
+ lastReviewedAt: entry.lastReviewedAt,
238
+ };
239
+ }
240
+
241
+ function buildTriageSummary(tests, entries, matchedEntryIds) {
242
+ const failedTests = tests.filter((test) => test.status === "failed");
243
+ const knownFailedTests = failedTests.filter((test) => test.triage?.status === "known_failure");
244
+ const byClassification = {};
245
+
246
+ for (const test of knownFailedTests) {
247
+ for (const classification of test.triage?.classifications || []) {
248
+ byClassification[classification] = (byClassification[classification] || 0) + 1;
249
+ }
250
+ }
251
+
252
+ return {
253
+ failed: {
254
+ total: failedTests.length,
255
+ known: knownFailedTests.length,
256
+ untriaged: failedTests.length - knownFailedTests.length,
257
+ byClassification,
258
+ },
259
+ entries: {
260
+ total: entries.length,
261
+ matchedByFailedTests: matchedEntryIds.size,
262
+ unmatched: entries.length - matchedEntryIds.size,
263
+ },
264
+ };
265
+ }
266
+
267
+ function extractRunFileEntries(runArtifact) {
268
+ const entries = [];
269
+
270
+ for (const service of runArtifact.services || []) {
271
+ for (const suite of service.suites || []) {
272
+ for (const file of suite.files || []) {
273
+ entries.push({
274
+ target: file,
275
+ service: service.name,
276
+ type: suite.type,
277
+ path: file.path,
278
+ status: file.status,
279
+ error: file.error || null,
280
+ failureDetails: Array.isArray(file.failureDetails) ? file.failureDetails : [],
281
+ });
282
+ }
283
+ }
284
+ }
285
+
286
+ return entries;
287
+ }
288
+
289
+ function extractStatusFileEntries(statusArtifact) {
290
+ return statusArtifact?.tests || [];
291
+ }
292
+
293
+ function setEntryTriage(entry, triage) {
294
+ if (entry?.target) {
295
+ entry.target.triage = triage;
296
+ return;
297
+ }
298
+ entry.triage = triage;
299
+ }
300
+
301
+ function buildFileIdentity(service, type, filePath) {
302
+ return `${service}::${type}::${filePath}`;
303
+ }
304
+
305
+ function requireNonEmptyString(value, label) {
306
+ const normalized = normalizeOptionalString(value);
307
+ if (!normalized) {
308
+ throw new Error(`${label} must be a non-empty string`);
309
+ }
310
+ return normalized;
311
+ }
312
+
313
+ function requireEnumValue(value, allowed, label) {
314
+ const normalized = requireNonEmptyString(value, label);
315
+ if (!allowed.has(normalized)) {
316
+ throw new Error(`${label} must be one of: ${[...allowed].sort().join(", ")}`);
317
+ }
318
+ return normalized;
319
+ }
320
+
321
+ function normalizeOptionalString(value) {
322
+ if (typeof value !== "string") return null;
323
+ const normalized = value.trim();
324
+ return normalized.length > 0 ? normalized : null;
325
+ }
326
+
327
+ function formatErrorMessage(error) {
328
+ if (error instanceof Error) return error.message;
329
+ return String(error);
330
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyKnownFailuresToArtifacts,
4
+ normalizeKnownFailuresDocument,
5
+ } from "./triage.mjs";
6
+
7
+ describe("runner triage", () => {
8
+ it("matches exact failure keys and enriches both artifacts", () => {
9
+ const knownFailures = normalizeKnownFailuresDocument({
10
+ schemaVersion: 1,
11
+ issueRepo: "acme/repo",
12
+ entries: [
13
+ {
14
+ id: "bad-message",
15
+ title: "Bad message bug",
16
+ classification: "product_bug",
17
+ state: "open",
18
+ issue: {
19
+ repo: "acme/repo",
20
+ number: 12,
21
+ url: "https://github.com/acme/repo/issues/12",
22
+ },
23
+ description: "The API returns the wrong message.",
24
+ whyFailing: "The endpoint payload is wrong.",
25
+ lastReviewedAt: "2026-04-27",
26
+ matches: [
27
+ {
28
+ service: "api",
29
+ type: "int",
30
+ path: "__testkit__/http/failing.int.testkit.ts",
31
+ failureKey: "returns the wrong message",
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ });
37
+
38
+ const runArtifact = {
39
+ services: [
40
+ {
41
+ name: "api",
42
+ suites: [
43
+ {
44
+ type: "int",
45
+ files: [
46
+ {
47
+ path: "__testkit__/http/failing.int.testkit.ts",
48
+ status: "failed",
49
+ error: "Default runtime thresholds failed: checks(rate==1.0)",
50
+ failureDetails: [
51
+ {
52
+ kind: "k6-check",
53
+ key: "returns the wrong message",
54
+ title: "returns the wrong message",
55
+ count: 1,
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ };
65
+ const statusArtifact = {
66
+ tests: [
67
+ {
68
+ service: "api",
69
+ type: "int",
70
+ path: "__testkit__/http/failing.int.testkit.ts",
71
+ status: "failed",
72
+ error: "Default runtime thresholds failed: checks(rate==1.0)",
73
+ failureDetails: [
74
+ {
75
+ kind: "k6-check",
76
+ key: "returns the wrong message",
77
+ title: "returns the wrong message",
78
+ count: 1,
79
+ },
80
+ ],
81
+ },
82
+ ],
83
+ };
84
+
85
+ const enriched = applyKnownFailuresToArtifacts(runArtifact, statusArtifact, knownFailures);
86
+ expect(enriched.statusArtifact.tests[0].triage).toMatchObject({
87
+ status: "known_failure",
88
+ classifications: ["product_bug"],
89
+ });
90
+ expect(enriched.runArtifact.services[0].suites[0].files[0].triage.entries[0]).toMatchObject({
91
+ id: "bad-message",
92
+ issue: {
93
+ number: 12,
94
+ },
95
+ });
96
+ expect(enriched.statusArtifact.triageSummary).toEqual({
97
+ failed: {
98
+ total: 1,
99
+ known: 1,
100
+ untriaged: 0,
101
+ byClassification: {
102
+ product_bug: 1,
103
+ },
104
+ },
105
+ entries: {
106
+ total: 1,
107
+ matchedByFailedTests: 1,
108
+ unmatched: 0,
109
+ },
110
+ });
111
+ });
112
+
113
+ it("marks unmatched failed tests as untriaged", () => {
114
+ const enriched = applyKnownFailuresToArtifacts(
115
+ {
116
+ services: [
117
+ {
118
+ name: "api",
119
+ suites: [
120
+ {
121
+ type: "int",
122
+ files: [
123
+ {
124
+ path: "__testkit__/http/failing.int.testkit.ts",
125
+ status: "failed",
126
+ error: "boom",
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ },
134
+ {
135
+ tests: [
136
+ {
137
+ service: "api",
138
+ type: "int",
139
+ path: "__testkit__/http/failing.int.testkit.ts",
140
+ status: "failed",
141
+ error: "boom",
142
+ },
143
+ ],
144
+ },
145
+ normalizeKnownFailuresDocument({
146
+ schemaVersion: 1,
147
+ entries: [],
148
+ })
149
+ );
150
+
151
+ expect(enriched.statusArtifact.tests[0].triage).toEqual({
152
+ status: "untriaged",
153
+ entries: [],
154
+ });
155
+ });
156
+ });
@@ -1,6 +1,6 @@
1
1
  import rawHttp from "k6/http";
2
2
  import { Rate, Trend } from "k6/metrics";
3
- import { check, fail, group, sleep } from "k6";
3
+ import { fail, sleep } from "k6";
4
4
  import {
5
5
  formatWaitForTimeoutError,
6
6
  normalizeWaitIntervalSeconds,
@@ -8,6 +8,7 @@ import {
8
8
  remainingFileTimeoutMs,
9
9
  remainingFileTimeoutSeconds,
10
10
  } from "../shared/file-timeout.mjs";
11
+ import { check, group } from "../runtime-src/k6/checks.js";
11
12
 
12
13
  export { check, fail, group, sleep };
13
14
  export { Rate, Trend };
@@ -1,6 +1,9 @@
1
+ import { check as k6Check, group as k6Group } from "k6";
1
2
  import { Rate } from "k6/metrics";
3
+ import { emitArtifact } from "./artifacts.js";
2
4
 
3
5
  export const runtimeFailures = new Rate("testkit_runtime_failures");
6
+ const failureState = createFailureState();
4
7
 
5
8
  export function singleIterationOptions(overrides = {}) {
6
9
  return {
@@ -16,6 +19,41 @@ export function singleIterationOptions(overrides = {}) {
16
19
 
17
20
  export const defaultOptions = singleIterationOptions();
18
21
 
22
+ export function check(value, checks) {
23
+ let allPassed = true;
24
+
25
+ for (const [name, predicate] of Object.entries(checks || {})) {
26
+ const checkName = normalizeLabel(name, "unnamed check");
27
+ const passed = k6Check(value, { [checkName]: predicate });
28
+ if (!passed) {
29
+ recordFailureDetail({
30
+ kind: "k6-check",
31
+ key: buildFailureKey(failureState.groupStack, checkName),
32
+ title: checkName,
33
+ checkName,
34
+ groupPath: [...failureState.groupStack],
35
+ phase: failureState.phase,
36
+ });
37
+ allPassed = false;
38
+ }
39
+ }
40
+
41
+ return allPassed;
42
+ }
43
+
44
+ export function group(name, fn) {
45
+ const groupName = normalizeLabel(name, "unnamed group");
46
+
47
+ return k6Group(groupName, () => {
48
+ failureState.groupStack.push(groupName);
49
+ try {
50
+ return fn();
51
+ } finally {
52
+ failureState.groupStack.pop();
53
+ }
54
+ });
55
+ }
56
+
19
57
  export function json(res) {
20
58
  return JSON.parse(res.body);
21
59
  }
@@ -46,3 +84,95 @@ export function isSorted(rows, field, direction = "asc") {
46
84
  export function recordRuntimeFailure() {
47
85
  runtimeFailures.add(1);
48
86
  }
87
+
88
+ export function startFailureCollection(phase) {
89
+ failureState.phase = normalizeLabel(phase, "exec");
90
+ failureState.groupStack = [];
91
+ failureState.detailsByKey = new Map();
92
+ }
93
+
94
+ export function emitFailureCollectionArtifact() {
95
+ const failures = [...failureState.detailsByKey.values()]
96
+ .sort((left, right) => left.key.localeCompare(right.key))
97
+ .map((detail) => ({ ...detail }));
98
+
99
+ if (failures.length > 0) {
100
+ emitArtifact(
101
+ "failure-details",
102
+ {
103
+ phase: failureState.phase,
104
+ failures,
105
+ },
106
+ {
107
+ kind: "testkit.failure-details",
108
+ summary: `${failures.length} failure detail(s)`,
109
+ }
110
+ );
111
+ }
112
+
113
+ startFailureCollection(failureState.phase);
114
+ }
115
+
116
+ export function recordFailureDetail(detail) {
117
+ const normalized = normalizeFailureDetail(detail);
118
+ if (!normalized) return;
119
+
120
+ const existing = failureState.detailsByKey.get(normalized.key);
121
+ if (!existing) {
122
+ failureState.detailsByKey.set(normalized.key, normalized);
123
+ return;
124
+ }
125
+
126
+ existing.count += normalized.count;
127
+ }
128
+
129
+ function createFailureState() {
130
+ return {
131
+ phase: "exec",
132
+ groupStack: [],
133
+ detailsByKey: new Map(),
134
+ };
135
+ }
136
+
137
+ function normalizeFailureDetail(detail) {
138
+ if (!detail || typeof detail !== "object") return null;
139
+
140
+ const kind = normalizeLabel(detail.kind, null);
141
+ const key = normalizeLabel(detail.key, null);
142
+ const title = normalizeLabel(detail.title, null);
143
+ if (!kind || !key || !title) return null;
144
+
145
+ const normalized = {
146
+ kind,
147
+ key,
148
+ title,
149
+ count: 1,
150
+ };
151
+
152
+ const checkName = normalizeLabel(detail.checkName, null);
153
+ if (checkName) normalized.checkName = checkName;
154
+
155
+ const phase = normalizeLabel(detail.phase, null);
156
+ if (phase) normalized.phase = phase;
157
+
158
+ const message = normalizeLabel(detail.message, null);
159
+ if (message) normalized.message = message;
160
+
161
+ const groupPath = Array.isArray(detail.groupPath)
162
+ ? detail.groupPath.map((entry) => normalizeLabel(entry, null)).filter(Boolean)
163
+ : [];
164
+ if (groupPath.length > 0) normalized.groupPath = groupPath;
165
+
166
+ return normalized;
167
+ }
168
+
169
+ function buildFailureKey(groupPath, title) {
170
+ if (!Array.isArray(groupPath) || groupPath.length === 0) return title;
171
+ return [...groupPath, title].join(" > ");
172
+ }
173
+
174
+ function normalizeLabel(value, fallback) {
175
+ if (typeof value !== "string") return fallback;
176
+ const normalized = value.trim();
177
+ return normalized.length > 0 ? normalized : fallback;
178
+ }
@@ -1,5 +1,10 @@
1
1
  import { fail } from "k6";
2
- import { defaultOptions, recordRuntimeFailure } from "./checks.js";
2
+ import {
3
+ defaultOptions,
4
+ emitFailureCollectionArtifact,
5
+ recordRuntimeFailure,
6
+ startFailureCollection,
7
+ } from "./checks.js";
3
8
  import { createDalContext, openDb } from "./dal.js";
4
9
 
5
10
  export function defineDalSuite(configOrRun, maybeRun) {
@@ -11,14 +16,18 @@ export function defineDalSuite(configOrRun, maybeRun) {
11
16
  options: config.options || defaultOptions,
12
17
  setup() {
13
18
  if (typeof config.setup !== "function") return null;
19
+ startFailureCollection("setup");
14
20
  try {
15
21
  return config.setup({ db, dal });
16
22
  } catch (error) {
17
23
  recordRuntimeFailure();
18
24
  fail(formatFatalSuiteError("setup", error));
25
+ } finally {
26
+ emitFailureCollectionArtifact();
19
27
  }
20
28
  },
21
29
  exec(setupData) {
30
+ startFailureCollection("exec");
22
31
  try {
23
32
  return run({
24
33
  db,
@@ -28,6 +37,8 @@ export function defineDalSuite(configOrRun, maybeRun) {
28
37
  } catch (error) {
29
38
  recordRuntimeFailure();
30
39
  fail(formatFatalSuiteError("exec", error));
40
+ } finally {
41
+ emitFailureCollectionArtifact();
31
42
  }
32
43
  },
33
44
  };
@@ -1,5 +1,10 @@
1
1
  import { fail } from "k6";
2
- import { defaultOptions, recordRuntimeFailure } from "./checks.js";
2
+ import {
3
+ defaultOptions,
4
+ emitFailureCollectionArtifact,
5
+ recordRuntimeFailure,
6
+ startFailureCollection,
7
+ } from "./checks.js";
3
8
  import { createHttpClient, getEnv } from "./http.js";
4
9
  import {
5
10
  clearRuntimeContext,
@@ -16,6 +21,7 @@ export function defineHttpSuite(configOrRun, maybeRun) {
16
21
  },
17
22
  setup() {
18
23
  const resolved = resolveRuntimeConfig(config);
24
+ startFailureCollection("setup");
19
25
  try {
20
26
  registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
21
27
  if (typeof resolved.auth?.setup !== "function") return null;
@@ -24,11 +30,13 @@ export function defineHttpSuite(configOrRun, maybeRun) {
24
30
  recordRuntimeFailure();
25
31
  fail(formatFatalSuiteError("setup", error));
26
32
  } finally {
33
+ emitFailureCollectionArtifact();
27
34
  clearRuntimeContext();
28
35
  }
29
36
  },
30
37
  exec(setupData) {
31
38
  const resolved = resolveRuntimeConfig(config);
39
+ startFailureCollection("exec");
32
40
  try {
33
41
  registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
34
42
  return run({
@@ -43,6 +51,7 @@ export function defineHttpSuite(configOrRun, maybeRun) {
43
51
  recordRuntimeFailure();
44
52
  fail(formatFatalSuiteError("exec", error));
45
53
  } finally {
54
+ emitFailureCollectionArtifact();
46
55
  clearRuntimeContext();
47
56
  }
48
57
  },
@@ -90,6 +90,9 @@ export interface TestkitSetup {
90
90
  profiles?: {
91
91
  http?: Record<string, HttpSuiteConfig>;
92
92
  };
93
+ reporting?: {
94
+ knownFailuresFile?: string;
95
+ };
93
96
  services?: Record<string, ServiceConfig>;
94
97
  telemetry?: {
95
98
  enabled?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",