@elench/testkit 0.1.41 → 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({
@@ -83,6 +86,7 @@ export default defineTestkitSetup({
83
86
  }),
84
87
  runtime: {
85
88
  instances: 1,
89
+ maxConcurrentTasks: 4,
86
90
  },
87
91
  requirements: {
88
92
  files: [
@@ -134,14 +138,23 @@ for:
134
138
  - per-file wall clock timeout budget
135
139
  - multi-service graphs
136
140
  - local runtime instance counts
141
+ - per-runtime concurrent task caps
137
142
  - local DB binding configuration
138
143
  - explicit per-file or per-suite locks
139
144
  - migrate / seed commands
140
145
  - test-local migrate / seed overrides
141
146
  - named HTTP suite profiles
147
+ - known-failure annotation merge for enriched status/run artifacts
142
148
  - repo-declared suite/file skip policies with explicit reasons
143
149
  - telemetry upload configuration
144
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
+
145
158
  ## Authoring
146
159
 
147
160
  HTTP suites:
package/bin/testkit.mjs CHANGED
@@ -1,3 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../lib/cli/index.mjs";
3
- run();
3
+
4
+ run().catch((error) => {
5
+ setImmediate(() => {
6
+ throw error;
7
+ });
8
+ });
package/lib/cli/index.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from "./args.mjs";
12
12
  import * as runner from "../runner/index.mjs";
13
13
 
14
- export function run() {
14
+ export async function run(argv = process.argv) {
15
15
  const cli = cac("testkit");
16
16
 
17
17
  cli
@@ -91,5 +91,7 @@ export function run() {
91
91
  });
92
92
 
93
93
  cli.help();
94
- cli.parse();
94
+ const parsed = cli.parse(argv, { run: false });
95
+ await cli.runMatchedCommand();
96
+ return parsed;
95
97
  }
@@ -12,6 +12,7 @@ import {
12
12
  DEFAULT_FILE_TIMEOUT_SECONDS,
13
13
  normalizeDatabaseBinding,
14
14
  normalizeExecutionConfig,
15
+ normalizeRuntimeMaxConcurrentTasks,
15
16
  normalizeRuntimeInstances,
16
17
  } from "../runner/execution-config.mjs";
17
18
 
@@ -29,6 +30,7 @@ export async function loadConfigs(opts = {}) {
29
30
  const productDir = resolveProductDir(process.cwd(), opts.dir);
30
31
  const { setup, setupFile } = await loadTestkitSetup(productDir);
31
32
  const execution = normalizeRepoExecution(setup.execution);
33
+ const reporting = normalizeReportingConfig(setup.reporting);
32
34
  const explicitServices = setup.services || {};
33
35
  const discovery = discoverProject(productDir, explicitServices);
34
36
  const serviceNames = new Set([
@@ -45,6 +47,7 @@ export async function loadConfigs(opts = {}) {
45
47
  setup,
46
48
  setupFile,
47
49
  execution,
50
+ reporting,
48
51
  explicitService: explicitServices[name] || {},
49
52
  discoveredService: discovery.services[name] || null,
50
53
  suites: discovery.suitesByService[name] || {},
@@ -104,6 +107,7 @@ function normalizeServiceConfig({
104
107
  setup,
105
108
  setupFile,
106
109
  execution,
110
+ reporting,
107
111
  explicitService,
108
112
  discoveredService,
109
113
  suites,
@@ -156,6 +160,7 @@ function normalizeServiceConfig({
156
160
  suites,
157
161
  testkit: {
158
162
  execution,
163
+ reporting,
159
164
  dependsOn: explicitService.dependsOn || [],
160
165
  database,
161
166
  databaseFrom: explicitService.databaseFrom,
@@ -171,6 +176,19 @@ function normalizeServiceConfig({
171
176
  };
172
177
  }
173
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
+
174
192
  function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
175
193
  if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
176
194
  if (explicitService.local === false) {
@@ -265,6 +283,7 @@ function normalizeRuntimeConfig(value, serviceName) {
265
283
  if (!value) {
266
284
  return {
267
285
  instances: 1,
286
+ maxConcurrentTasks: Number.POSITIVE_INFINITY,
268
287
  };
269
288
  }
270
289
 
@@ -273,9 +292,19 @@ function normalizeRuntimeConfig(value, serviceName) {
273
292
  value.instances ?? 1,
274
293
  `Service "${serviceName}" runtime.instances`
275
294
  ),
295
+ maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
296
+ value.maxConcurrentTasks,
297
+ `Service "${serviceName}" runtime.maxConcurrentTasks`
298
+ ),
276
299
  };
277
300
  }
278
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
+
279
308
  function normalizeLifecycle(value) {
280
309
  if (!value) return undefined;
281
310
  if (!value.cmd && !value.testkitCmd) {
@@ -578,6 +607,11 @@ function validateServiceConfig({
578
607
  if (runtime.instances < 1) {
579
608
  throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
580
609
  }
610
+ if (runtime.maxConcurrentTasks <= 0) {
611
+ throw new Error(
612
+ `Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`
613
+ );
614
+ }
581
615
 
582
616
  for (const depName of dependsOn || []) {
583
617
  if (depName === name) {
@@ -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,9 +9,11 @@ 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";
16
+ import { killChildProcess } from "./processes.mjs";
15
17
 
16
18
  export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
17
19
  const baseUrl = targetConfig.testkit.local?.baseUrl;
@@ -24,7 +26,9 @@ export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
24
26
  serviceName: targetConfig.name,
25
27
  sourceFile: path.join(targetConfig.productDir, task.file),
26
28
  });
27
- console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
29
+ if (lifecycle.isStopRequested()) {
30
+ throw new Error(`testkit run interrupted before starting ${task.file}`);
31
+ }
28
32
  return runDefaultRuntimeTask(
29
33
  targetConfig,
30
34
  task,
@@ -45,7 +49,9 @@ export async function runDalTask(targetConfig, task, lifecycle, lease) {
45
49
  serviceName: targetConfig.name,
46
50
  sourceFile: path.join(targetConfig.productDir, task.file),
47
51
  });
48
- console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
52
+ if (lifecycle.isStopRequested()) {
53
+ throw new Error(`testkit run interrupted before starting ${task.file}`);
54
+ }
49
55
  return runDefaultRuntimeTask(
50
56
  targetConfig,
51
57
  task,
@@ -74,11 +80,25 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
74
80
  process.env
75
81
  ),
76
82
  reject: false,
77
- cancelSignal: lifecycle.signal,
78
83
  forceKillAfterDelay: 5_000,
79
84
  }
80
85
  );
81
- const { result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds);
86
+ lifecycle.registerProcess(subprocess, () => {
87
+ killChildProcess(subprocess, "SIGINT");
88
+ });
89
+ if (lifecycle.isStopRequested()) {
90
+ const interruptSubprocess = () => killChildProcess(subprocess, "SIGINT");
91
+ if (subprocess.pid) interruptSubprocess();
92
+ else subprocess.once?.("spawn", interruptSubprocess);
93
+ }
94
+ console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
95
+ let result;
96
+ let timedOut;
97
+ try {
98
+ ({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
99
+ } finally {
100
+ lifecycle.unregisterProcess(subprocess.pid);
101
+ }
82
102
 
83
103
  const stdout = parseDefaultRuntimeOutput(result.stdout || "");
84
104
  const stderr = parseDefaultRuntimeOutput(result.stderr || "");
@@ -86,11 +106,13 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
86
106
  if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
87
107
 
88
108
  const summary = readDefaultRuntimeSummary(summaryFile);
109
+ const rawRuntimeArtifacts = [...stdout.artifacts, ...stderr.artifacts];
89
110
  const runtimeArtifacts = persistTaskArtifacts(
90
111
  targetConfig.productDir,
91
112
  task,
92
- [...stdout.artifacts, ...stderr.artifacts]
113
+ rawRuntimeArtifacts
93
114
  );
115
+ const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
94
116
  const runtimeError = timedOut
95
117
  ? formatFileTimeoutBudgetError(fileTimeoutSeconds)
96
118
  : determineDefaultRuntimeFailure(result, summary, getFirstLine);
@@ -104,6 +126,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
104
126
  startedAt,
105
127
  finishedAt,
106
128
  artifacts: runtimeArtifacts,
129
+ failureDetails,
107
130
  };
108
131
  }
109
132
 
@@ -118,7 +141,7 @@ export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
118
141
  new Promise((resolve) => {
119
142
  timeoutHandle = setTimeout(async () => {
120
143
  timedOut = true;
121
- subprocess.kill("SIGTERM");
144
+ killChildProcess(subprocess, "SIGTERM");
122
145
  const result = await subprocess.catch((error) => error);
123
146
  resolve({ result, timedOut: true });
124
147
  }, timeoutMs);
@@ -20,6 +20,23 @@ export function normalizeRuntimeInstances(value, label = "runtime.instances") {
20
20
  return normalizePositiveInteger(value, label);
21
21
  }
22
22
 
23
+ export function parseRuntimeMaxConcurrentTasksOption(
24
+ value,
25
+ label = "runtime.maxConcurrentTasks"
26
+ ) {
27
+ return parsePositiveInteger(value, label);
28
+ }
29
+
30
+ export function normalizeRuntimeMaxConcurrentTasks(
31
+ value,
32
+ label = "runtime.maxConcurrentTasks"
33
+ ) {
34
+ if (value === undefined || value === null) {
35
+ return Number.POSITIVE_INFINITY;
36
+ }
37
+ return normalizePositiveInteger(value, label);
38
+ }
39
+
23
40
  export function normalizeDatabaseBinding(value, label = "database.binding") {
24
41
  const normalized = String(value || "").trim();
25
42
  if (!DATABASE_BINDINGS.has(normalized)) {
@@ -5,8 +5,10 @@ import {
5
5
  DEFAULT_FILE_TIMEOUT_SECONDS,
6
6
  normalizeDatabaseBinding,
7
7
  normalizeExecutionConfig,
8
+ normalizeRuntimeMaxConcurrentTasks,
8
9
  normalizeRuntimeInstances,
9
10
  parseFileTimeoutOption,
11
+ parseRuntimeMaxConcurrentTasksOption,
10
12
  parseRuntimeInstancesOption,
11
13
  parseWorkersOption,
12
14
  resolveExecutionConfig,
@@ -16,9 +18,13 @@ describe("execution-config", () => {
16
18
  it("parses worker and runtime-instance options", () => {
17
19
  expect(parseWorkersOption("8")).toBe(8);
18
20
  expect(parseRuntimeInstancesOption("2")).toBe(2);
21
+ expect(parseRuntimeMaxConcurrentTasksOption("4")).toBe(4);
19
22
  expect(parseFileTimeoutOption("45")).toBe(45);
20
23
  expect(() => parseWorkersOption("0")).toThrow('Invalid --workers value "0"');
21
24
  expect(() => parseRuntimeInstancesOption("0")).toThrow('Invalid runtime.instances value "0"');
25
+ expect(() => parseRuntimeMaxConcurrentTasksOption("0")).toThrow(
26
+ 'Invalid runtime.maxConcurrentTasks value "0"'
27
+ );
22
28
  expect(() => parseFileTimeoutOption("0")).toThrow(
23
29
  'Invalid --file-timeout-seconds value "0"'
24
30
  );
@@ -49,6 +55,8 @@ describe("execution-config", () => {
49
55
 
50
56
  it("normalizes runtime instances and database bindings", () => {
51
57
  expect(normalizeRuntimeInstances(2)).toBe(2);
58
+ expect(normalizeRuntimeMaxConcurrentTasks(undefined)).toBe(Number.POSITIVE_INFINITY);
59
+ expect(normalizeRuntimeMaxConcurrentTasks(3)).toBe(3);
52
60
  for (const binding of DATABASE_BINDINGS) {
53
61
  expect(normalizeDatabaseBinding(binding)).toBe(binding);
54
62
  }
@@ -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
+ });