@elench/testkit 0.1.53 → 0.1.55

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 (41) hide show
  1. package/lib/cli/commands/artifacts.mjs +2 -2
  2. package/lib/cli/commands/logs.mjs +2 -2
  3. package/lib/cli/commands/show.mjs +2 -2
  4. package/lib/cli/db.mjs +17 -2
  5. package/lib/cli/presentation/code-frames.mjs +57 -0
  6. package/lib/cli/presentation/code-frames.test.mjs +71 -0
  7. package/lib/cli/presentation/colors.mjs +29 -0
  8. package/lib/cli/presentation/run-reporter.mjs +41 -7
  9. package/lib/cli/presentation/run-reporter.test.mjs +80 -0
  10. package/lib/cli/tui/watch-app.mjs +134 -18
  11. package/lib/cli/viewer.mjs +146 -4
  12. package/lib/database/index.mjs +85 -11
  13. package/lib/database/template-steps.mjs +45 -6
  14. package/lib/database/template-steps.test.mjs +43 -0
  15. package/lib/known-failures/index.mjs +1 -1
  16. package/lib/known-failures/index.test.mjs +46 -0
  17. package/lib/runner/artifacts.mjs +16 -0
  18. package/lib/runner/default-runtime-errors.mjs +66 -0
  19. package/lib/runner/default-runtime-runner.mjs +8 -1
  20. package/lib/runner/failure-details.mjs +31 -0
  21. package/lib/runner/failure-details.test.mjs +51 -0
  22. package/lib/runner/formatting.mjs +114 -4
  23. package/lib/runner/formatting.test.mjs +77 -0
  24. package/lib/runner/logs.mjs +71 -6
  25. package/lib/runner/orchestrator.mjs +63 -7
  26. package/lib/runner/reporting.mjs +52 -2
  27. package/lib/runner/reporting.test.mjs +80 -2
  28. package/lib/runner/runtime-contexts.mjs +3 -3
  29. package/lib/runner/runtime-preparation.mjs +31 -0
  30. package/lib/runner/setup-operations.mjs +115 -0
  31. package/lib/runner/setup-operations.test.mjs +94 -0
  32. package/lib/runner/template-steps.mjs +129 -11
  33. package/lib/runner/triage.mjs +67 -0
  34. package/lib/runtime/index.d.ts +60 -0
  35. package/lib/runtime/index.mjs +12 -0
  36. package/lib/runtime-src/k6/checks.js +45 -12
  37. package/lib/runtime-src/k6/http-assertions.js +214 -0
  38. package/lib/runtime-src/k6/http.js +261 -13
  39. package/lib/runtime-src/k6/suite.js +46 -1
  40. package/lib/toolchains/index.mjs +0 -4
  41. package/package.json +3 -1
@@ -1,7 +1,12 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { formatDuration } from "../runner/formatting.mjs";
4
- import { readLogTail } from "../runner/logs.mjs";
4
+ import { findLogSliceByRequestId, readLogTail } from "../runner/logs.mjs";
5
+ import {
6
+ findFailureLocation,
7
+ formatLocation,
8
+ renderCodeFrame,
9
+ } from "./presentation/code-frames.mjs";
5
10
 
6
11
  export function loadLatestRunArtifact(productDir) {
7
12
  const artifactPath = path.join(productDir, ".testkit", "results", "latest.json");
@@ -11,6 +16,14 @@ export function loadLatestRunArtifact(productDir) {
11
16
  return JSON.parse(fs.readFileSync(artifactPath, "utf8"));
12
17
  }
13
18
 
19
+ export function loadCurrentRunArtifact(productDir) {
20
+ const livePath = path.join(productDir, ".testkit", "results", "live.json");
21
+ if (fs.existsSync(livePath)) {
22
+ return JSON.parse(fs.readFileSync(livePath, "utf8"));
23
+ }
24
+ return loadLatestRunArtifact(productDir);
25
+ }
26
+
14
27
  export function resolveFileSubject(runArtifact, selector = null, serviceFilter = null) {
15
28
  const files = collectFiles(runArtifact, serviceFilter);
16
29
  if (files.length === 0) {
@@ -63,6 +76,8 @@ export function collectArtifactEntries(productDir, runArtifact, selector = null,
63
76
 
64
77
  export function formatFileDetail(productDir, runArtifact, subject, options = {}) {
65
78
  const lines = [];
79
+ const failureDetails = subject.file.failureDetails || [];
80
+ const primaryDetail = rankFailureDetails(failureDetails)[0] || null;
66
81
  lines.push(`File: ${subject.file.path}`);
67
82
  lines.push(`Service: ${subject.service.name}`);
68
83
  lines.push(`Suite: ${subject.suite.type}:${subject.suite.name}`);
@@ -70,12 +85,53 @@ export function formatFileDetail(productDir, runArtifact, subject, options = {})
70
85
  lines.push(`Duration: ${formatDuration(subject.file.durationMs || 0)}`);
71
86
  if (subject.file.error) lines.push(`Error: ${subject.file.error}`);
72
87
 
73
- if ((subject.file.failureDetails || []).length > 0) {
88
+ if (failureDetails.length > 0) {
74
89
  lines.push("");
75
90
  lines.push("Failure Details:");
76
- for (const detail of subject.file.failureDetails.slice(0, options.failureLimit || 5)) {
91
+ for (const detail of rankFailureDetails(failureDetails).slice(0, options.failureLimit || 5)) {
77
92
  lines.push(` ${detail.title}`);
78
93
  if (detail.message) lines.push(` ${detail.message}`);
94
+ const requestLine = formatRequestLine(detail);
95
+ if (requestLine) lines.push(` ${requestLine}`);
96
+ const responseLine = formatResponseLine(detail);
97
+ if (responseLine) lines.push(` ${responseLine}`);
98
+ const location = findFailureLocation(detail, subject.file.error || "");
99
+ if (location) lines.push(` at ${formatLocation(location, productDir)}`);
100
+ }
101
+ }
102
+
103
+ const codeFrame = renderCodeFrame(findFailureLocation(primaryDetail, subject.file.error || ""), {
104
+ cwd: productDir,
105
+ });
106
+ if (codeFrame.length > 0) {
107
+ lines.push("");
108
+ lines.push("Code Frame:");
109
+ for (const line of codeFrame) lines.push(` ${line}`);
110
+ }
111
+
112
+ if (subject.file.triage) {
113
+ lines.push("");
114
+ lines.push("Triage:");
115
+ const triageLines = formatTriage(subject.file.triage);
116
+ for (const line of triageLines) lines.push(` ${line}`);
117
+ }
118
+
119
+ const setupOperations = getSetupOperationsForService(runArtifact, subject.service.name);
120
+ if (setupOperations.length > 0) {
121
+ lines.push("");
122
+ lines.push("Setup:");
123
+ for (const operation of setupOperations.slice(0, 8)) {
124
+ const duration = operation.durationMs == null ? "" : ` ${formatDuration(operation.durationMs)}`;
125
+ const suffix = operation.summary ? ` ${operation.summary}` : "";
126
+ lines.push(` ${operation.status} ${operation.stage}${duration}${suffix}`);
127
+ if (operation.logRef?.path) lines.push(` ${operation.logRef.path}`);
128
+ if (operation.error) lines.push(` ${operation.error}`);
129
+ if (operation.logRef?.path) {
130
+ const setupLogPath = path.join(productDir, operation.logRef.path);
131
+ for (const line of readLogTail(setupLogPath, 4).slice(-4)) {
132
+ lines.push(` ${line}`);
133
+ }
134
+ }
79
135
  }
80
136
  }
81
137
 
@@ -100,7 +156,12 @@ export function formatFileDetail(productDir, runArtifact, subject, options = {})
100
156
  for (const logRef of logRefs) {
101
157
  lines.push(` ${logRef.runtimeLabel}`);
102
158
  lines.push(` ${logRef.path}`);
103
- const tail = readLogTail(path.join(productDir, logRef.path), options.logTail || 12);
159
+ const logPath = path.join(productDir, logRef.path);
160
+ const requestId = primaryDetail?.request?.requestId || null;
161
+ const tail =
162
+ requestId && findLogSliceByRequestId(logPath, requestId, 2).length > 0
163
+ ? findLogSliceByRequestId(logPath, requestId, 2)
164
+ : readLogTail(logPath, options.logTail || 12);
104
165
  for (const line of tail.slice(-Math.max(0, options.logTail || 12))) {
105
166
  lines.push(` ${line}`);
106
167
  }
@@ -114,11 +175,24 @@ export function getServiceLogRefs(runArtifact, serviceName) {
114
175
  return (runArtifact.logs?.services || []).filter((entry) => entry.serviceName === serviceName);
115
176
  }
116
177
 
178
+ export function getSetupOperationsForService(runArtifact, serviceName) {
179
+ return (runArtifact.setup?.operations || [])
180
+ .filter((entry) => entry.serviceName === serviceName)
181
+ .sort(
182
+ (left, right) =>
183
+ String(left.startedAt || "").localeCompare(String(right.startedAt || "")) ||
184
+ String(left.stage || "").localeCompare(String(right.stage || ""))
185
+ );
186
+ }
187
+
117
188
  export function formatArtifactPreview(payload, maxLines = 6) {
118
189
  if (!payload) return ["artifact payload missing"];
119
190
  if (payload.kind === "agentic-query") {
120
191
  return formatAgenticArtifact(payload, maxLines);
121
192
  }
193
+ if (payload.kind === "testkit.http-traces") {
194
+ return formatHttpTraceArtifact(payload, maxLines);
195
+ }
122
196
  if (payload.contentType === "text/plain" && typeof payload.data?.text === "string") {
123
197
  return payload.data.text
124
198
  .split(/\r?\n/)
@@ -145,6 +219,74 @@ function formatAgenticArtifact(payload, maxLines) {
145
219
  return lines.slice(0, maxLines);
146
220
  }
147
221
 
222
+ function formatHttpTraceArtifact(payload, maxLines) {
223
+ const traces = Array.isArray(payload.data?.traces) ? payload.data.traces : [];
224
+ const lines = [];
225
+ for (const trace of traces.slice(0, maxLines)) {
226
+ lines.push(
227
+ `${trace.method} ${trace.path} -> ${trace.response?.status ?? "?"}${trace.requestId ? ` [${trace.requestId}]` : ""}`
228
+ );
229
+ }
230
+ return lines;
231
+ }
232
+
233
+ function rankFailureDetails(details) {
234
+ return [...(Array.isArray(details) ? details : [])].sort((left, right) => {
235
+ return failureDetailRank(left) - failureDetailRank(right) || String(left?.key || "").localeCompare(String(right?.key || ""));
236
+ });
237
+ }
238
+
239
+ function failureDetailRank(detail) {
240
+ if (detail?.kind === "http-assertion") return 1;
241
+ if (detail?.request && detail?.response) return 2;
242
+ if (detail?.location || detail?.stack) return 3;
243
+ if (detail?.message) return 4;
244
+ return 5;
245
+ }
246
+
247
+ function formatRequestLine(detail) {
248
+ const method = detail?.request?.method;
249
+ const path = detail?.request?.path;
250
+ if (!method || !path) return null;
251
+ const requestId = detail?.request?.requestId;
252
+ return requestId ? `request: ${method} ${path} [${requestId}]` : `request: ${method} ${path}`;
253
+ }
254
+
255
+ function formatResponseLine(detail) {
256
+ if (!detail?.response) return null;
257
+ const parts = [`response: ${detail.response.status ?? "?"}`];
258
+ if (detail.response.contentType) parts.push(detail.response.contentType);
259
+ if (detail.response.bodyPreview) parts.push(detail.response.bodyPreview);
260
+ return parts.join(" ");
261
+ }
262
+
263
+ function formatTriage(triage) {
264
+ const lines = [`status: ${triage.status}`];
265
+ if (triage.classifications?.length) {
266
+ lines.push(`classification: ${triage.classifications.join(", ")}`);
267
+ }
268
+ if (triage.availability?.mode) {
269
+ lines.push(
270
+ `validation: ${triage.availability.mode}${triage.availability.reason ? ` (${triage.availability.reason})` : ""}`
271
+ );
272
+ }
273
+ for (const entry of triage.entries || []) {
274
+ lines.push(`issue: ${entry.issue.repo}#${entry.issue.number}`);
275
+ if (entry.issue.url) lines.push(`url: ${entry.issue.url}`);
276
+ if (entry.github?.state) {
277
+ lines.push(`github state: ${entry.github.state}${entry.github.cached ? " (cache)" : ""}`);
278
+ }
279
+ if (entry.validationStatus) lines.push(`validation status: ${entry.validationStatus}`);
280
+ if (entry.findings?.length) {
281
+ for (const finding of entry.findings.slice(0, 3)) {
282
+ lines.push(`finding: ${finding.message}`);
283
+ }
284
+ }
285
+ break;
286
+ }
287
+ return lines;
288
+ }
289
+
148
290
  function collectFiles(runArtifact, serviceFilter = null) {
149
291
  const files = [];
150
292
  for (const service of runArtifact.services || []) {
@@ -36,25 +36,25 @@ const LOCAL_POLL_INTERVAL_MS = 1_000;
36
36
  const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
37
37
  const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
38
38
 
39
- export async function prepareDatabaseRuntime(config) {
39
+ export async function prepareDatabaseRuntime(config, options = {}) {
40
40
  const db = config.testkit.database;
41
41
  if (!db) return;
42
42
 
43
43
  fs.mkdirSync(config.stateDir, { recursive: true });
44
44
  if (db.provider === "local") {
45
- await prepareLocalDatabase(config);
45
+ await prepareLocalDatabase(config, options);
46
46
  return;
47
47
  }
48
48
 
49
49
  throw new Error(`Unsupported database provider "${db.provider}"`);
50
50
  }
51
51
 
52
- export async function captureDatabaseTemplateSnapshot(config, outputPath) {
52
+ export async function captureDatabaseTemplateSnapshot(config, outputPath, options = {}) {
53
53
  if (!config.testkit.database || config.testkit.database.provider !== "local") {
54
54
  throw new Error(`Service "${config.name}" does not use a local testkit database`);
55
55
  }
56
56
 
57
- await prepareDatabaseRuntime(config);
57
+ await prepareDatabaseRuntime(config, options);
58
58
  const cacheDir = getLocalServiceCacheDir(config.productDir, config.name);
59
59
  const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
60
60
  if (!templateDbName) {
@@ -66,7 +66,41 @@ export async function captureDatabaseTemplateSnapshot(config, outputPath) {
66
66
  throw new Error(`Missing local database container for service "${config.name}"`);
67
67
  }
68
68
 
69
- return captureTemplateSnapshot(config, outputPath, buildDatabaseUrl(infra, templateDbName));
69
+ const snapshotOperation = options.setupRegistry?.start({
70
+ config,
71
+ stage: "template:snapshot",
72
+ kind: "database-snapshot",
73
+ summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
74
+ });
75
+ try {
76
+ const output = await captureTemplateSnapshot(
77
+ config,
78
+ outputPath,
79
+ buildDatabaseUrl(infra, templateDbName),
80
+ {
81
+ reporter: options.reporter || null,
82
+ logRecord: snapshotOperation?._logRecord || null,
83
+ }
84
+ );
85
+ const finished = snapshotOperation
86
+ ? options.setupRegistry.finish(snapshotOperation, {
87
+ status: "passed",
88
+ summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
89
+ })
90
+ : null;
91
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
92
+ return output;
93
+ } catch (error) {
94
+ const finished = snapshotOperation
95
+ ? options.setupRegistry.finish(snapshotOperation, {
96
+ status: "failed",
97
+ summary: `snapshot: ${path.relative(config.productDir, outputPath)}`,
98
+ error: error?.message || error,
99
+ })
100
+ : null;
101
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
102
+ throw error;
103
+ }
70
104
  }
71
105
 
72
106
  export async function destroyRuntimeDatabase({ productDir, stateDir }) {
@@ -138,7 +172,7 @@ export function showServiceDatabaseStatus(productDir, serviceName) {
138
172
  return true;
139
173
  }
140
174
 
141
- async function prepareLocalDatabase(config) {
175
+ async function prepareLocalDatabase(config, options = {}) {
142
176
  const db = config.testkit.database;
143
177
  const productDir = config.productDir;
144
178
  const serviceName = config.name;
@@ -154,7 +188,7 @@ async function prepareLocalDatabase(config) {
154
188
  );
155
189
 
156
190
  await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
157
- await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint);
191
+ await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options);
158
192
  });
159
193
 
160
194
  await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
@@ -162,7 +196,7 @@ async function prepareLocalDatabase(config) {
162
196
  });
163
197
  }
164
198
 
165
- async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint) {
199
+ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options = {}) {
166
200
  const serviceName = config.name;
167
201
  const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
168
202
  const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
@@ -173,6 +207,12 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
173
207
  existingDbName &&
174
208
  (await databaseExists(infra, existingDbName))
175
209
  ) {
210
+ options.setupRegistry?.recordCached({
211
+ config,
212
+ stage: "template",
213
+ kind: "database-template",
214
+ summary: "template cache hit",
215
+ });
176
216
  writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
177
217
  return;
178
218
  }
@@ -186,11 +226,45 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
186
226
 
187
227
  const templateUrl = buildDatabaseUrl(infra, desiredDbName);
188
228
  await createEmptyDatabase(infra, desiredDbName);
229
+ const templateOperation = options.setupRegistry?.start({
230
+ config,
231
+ stage: "template",
232
+ kind: "database-template",
233
+ summary: "template rebuild",
234
+ recordLog: false,
235
+ });
189
236
  try {
190
- await runTemplateStage(config, "migrate", templateUrl);
191
- await runTemplateStage(config, "seed", templateUrl);
192
- await runTemplateStage(config, "verify", templateUrl);
237
+ await runTemplateStage(config, "migrate", templateUrl, {
238
+ reporter: options.reporter || null,
239
+ setupRegistry: options.setupRegistry || null,
240
+ parentOperation: templateOperation,
241
+ });
242
+ await runTemplateStage(config, "seed", templateUrl, {
243
+ reporter: options.reporter || null,
244
+ setupRegistry: options.setupRegistry || null,
245
+ parentOperation: templateOperation,
246
+ });
247
+ await runTemplateStage(config, "verify", templateUrl, {
248
+ reporter: options.reporter || null,
249
+ setupRegistry: options.setupRegistry || null,
250
+ parentOperation: templateOperation,
251
+ });
252
+ const finished = templateOperation
253
+ ? options.setupRegistry.finish(templateOperation, {
254
+ status: "passed",
255
+ summary: "template rebuild",
256
+ })
257
+ : null;
258
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
193
259
  } catch (error) {
260
+ const finished = templateOperation
261
+ ? options.setupRegistry.finish(templateOperation, {
262
+ status: "failed",
263
+ summary: "template rebuild",
264
+ error: error?.message || error,
265
+ })
266
+ : null;
267
+ if (finished) options.reporter?.setupOperationFinished?.(finished);
194
268
  await dropDatabaseIfExists(infra, desiredDbName);
195
269
  throw error;
196
270
  }
@@ -6,8 +6,9 @@ import {
6
6
  collectConfiguredInputs,
7
7
  runConfiguredSteps,
8
8
  } from "../runner/template-steps.mjs";
9
+ import { captureOutput } from "../runner/processes.mjs";
9
10
 
10
- export async function runTemplateStage(config, stageName, databaseUrl) {
11
+ export async function runTemplateStage(config, stageName, databaseUrl, options = {}) {
11
12
  const steps = config.testkit.database?.template?.[stageName] || [];
12
13
  if (steps.length === 0) return;
13
14
 
@@ -21,6 +22,9 @@ export async function runTemplateStage(config, stageName, databaseUrl) {
21
22
  steps,
22
23
  env,
23
24
  labelPrefix: `template:${stageName}`,
25
+ reporter: options.reporter || null,
26
+ setupRegistry: options.setupRegistry || null,
27
+ parentOperation: options.parentOperation || null,
24
28
  });
25
29
  }
26
30
 
@@ -32,12 +36,12 @@ export function collectTemplateInputs(productDir, template = {}) {
32
36
  });
33
37
  }
34
38
 
35
- export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
39
+ export async function captureTemplateSnapshot(config, outputPath, databaseUrl, options = {}) {
36
40
  const templateDbUrl = databaseUrl;
37
41
  const absoluteOutputPath = path.resolve(config.productDir, outputPath);
38
42
  fs.mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
39
43
 
40
- await execa(
44
+ const child = execa(
41
45
  "pg_dump",
42
46
  [
43
47
  "--schema-only",
@@ -53,19 +57,54 @@ export async function captureTemplateSnapshot(config, outputPath, databaseUrl) {
53
57
  ...buildExecutionEnv(config, {}, process.env),
54
58
  DATABASE_URL: templateDbUrl,
55
59
  },
56
- stdio: "inherit",
60
+ stdout: "pipe",
61
+ stderr: "pipe",
62
+ reject: false,
57
63
  }
58
64
  );
65
+ const liveWriter =
66
+ options.reporter?.outputMode === "debug"
67
+ ? (line) => options.reporter.writeDebugLine?.(line)
68
+ : null;
69
+ const logRecord = options.logRecord || null;
70
+ const drains = [
71
+ captureOutput(child.stdout, {
72
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
73
+ liveWriter,
74
+ onLine(line) {
75
+ if (logRecord) logRecord.stream.write(`${new Date().toISOString()} [stdout] ${line}\n`);
76
+ },
77
+ }),
78
+ captureOutput(child.stderr, {
79
+ livePrefix: `[${config.runtimeLabel || config.name}:${config.name}]`,
80
+ liveWriter,
81
+ onLine(line) {
82
+ if (logRecord) logRecord.stream.write(`${new Date().toISOString()} [stderr] ${line}\n`);
83
+ },
84
+ }),
85
+ ];
86
+ const result = await child;
87
+ await Promise.all(drains);
88
+ if (result.exitCode !== 0) {
89
+ throw new Error(result.shortMessage || result.stderr || result.stdout || "pg_dump failed");
90
+ }
59
91
 
60
92
  sanitizeSnapshotFile(absoluteOutputPath);
61
93
  return absoluteOutputPath;
62
94
  }
63
95
 
64
- function sanitizeSnapshotFile(filePath) {
96
+ export function sanitizeSnapshotFile(filePath) {
65
97
  const dump = fs.readFileSync(filePath, "utf8");
66
98
  const sanitized = dump
67
99
  .split("\n")
68
- .filter((line) => line.trim() !== "SET transaction_timeout = 0;")
100
+ .filter((line) => {
101
+ const trimmed = line.trim();
102
+ return (
103
+ trimmed !== "SET transaction_timeout = 0;" &&
104
+ !trimmed.startsWith("\\restrict ") &&
105
+ !trimmed.startsWith("\\unrestrict ")
106
+ );
107
+ })
69
108
  .join("\n");
70
109
 
71
110
  if (sanitized !== dump) {
@@ -0,0 +1,43 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { sanitizeSnapshotFile } from "./template-steps.mjs";
6
+
7
+ const tempDirs = [];
8
+
9
+ afterEach(() => {
10
+ while (tempDirs.length > 0) {
11
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ function makeTempDir(prefix) {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
17
+ tempDirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ describe("template snapshot sanitization", () => {
22
+ it("removes volatile pg_dump control lines", () => {
23
+ const dir = makeTempDir("testkit-template-snapshot-");
24
+ const filePath = path.join(dir, "schema.sql");
25
+ fs.writeFileSync(
26
+ filePath,
27
+ [
28
+ "SET statement_timeout = 0;",
29
+ "SET transaction_timeout = 0;",
30
+ "\\restrict abc123",
31
+ "CREATE TABLE public.widgets (id integer);",
32
+ "\\unrestrict abc123",
33
+ "",
34
+ ].join("\n")
35
+ );
36
+
37
+ sanitizeSnapshotFile(filePath);
38
+
39
+ expect(fs.readFileSync(filePath, "utf8")).toBe(
40
+ ["SET statement_timeout = 0;", "CREATE TABLE public.widgets (id integer);", ""].join("\n")
41
+ );
42
+ });
43
+ });
@@ -194,7 +194,7 @@ export function matchesKnownFailureMatch(match, fileSummary) {
194
194
  if (match.path !== fileSummary.path) return false;
195
195
  if (match.failureKey) {
196
196
  const failureKeys = Array.isArray(fileSummary.failureDetails)
197
- ? fileSummary.failureDetails.map((detail) => detail?.key)
197
+ ? fileSummary.failureDetails.flatMap((detail) => [detail?.key, detail?.title].filter(Boolean))
198
198
  : [];
199
199
  if (!failureKeys.includes(match.failureKey)) return false;
200
200
  }
@@ -59,6 +59,52 @@ describe("known failures core", () => {
59
59
  expect(matches[0].id).toBe("bad-message");
60
60
  });
61
61
 
62
+ it("matches failureKey against a detail title when the key becomes richer", () => {
63
+ const document = normalizeKnownFailuresDocument({
64
+ schemaVersion: 1,
65
+ entries: [
66
+ {
67
+ id: "missing-route",
68
+ title: "Missing route bug",
69
+ classification: "product_bug",
70
+ state: "open",
71
+ issue: {
72
+ repo: "acme/repo",
73
+ number: 13,
74
+ url: "https://github.com/acme/repo/issues/13",
75
+ },
76
+ description: "Wrong status code",
77
+ whyFailing: "The route returns 404",
78
+ lastReviewedAt: "2026-04-28",
79
+ matches: [
80
+ {
81
+ service: "api",
82
+ type: "int",
83
+ path: "__testkit__/health/http-failure.int.testkit.ts",
84
+ failureKey: "GET /missing returns 200",
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ });
90
+
91
+ const matches = findMatchingKnownFailureEntries(document, {
92
+ service: "api",
93
+ type: "int",
94
+ path: "__testkit__/health/http-failure.int.testkit.ts",
95
+ error: "Default runtime thresholds failed: checks(rate==1.0)",
96
+ failureDetails: [
97
+ {
98
+ key: "GET /missing > GET /missing returns 200",
99
+ title: "GET /missing returns 200",
100
+ },
101
+ ],
102
+ });
103
+
104
+ expect(matches).toHaveLength(1);
105
+ expect(matches[0].id).toBe("missing-route");
106
+ });
107
+
62
108
  it("validates status coverage and filesystem matches", () => {
63
109
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-known-failures-"));
64
110
  tempDirs.push(tempDir);
@@ -9,11 +9,20 @@ import {
9
9
  const TIMINGS_FILENAME = "timings.json";
10
10
  const RESULT_ARTIFACTS_DIRNAME = "artifacts";
11
11
  const RESULT_LOGS_DIRNAME = "logs";
12
+ const RESULT_SETUP_DIRNAME = "setup";
13
+ const LIVE_ARTIFACT_FILENAME = "live.json";
12
14
 
13
15
  export function writeRunArtifact(productDir, artifact) {
14
16
  const resultsDir = path.join(productDir, ".testkit", "results");
15
17
  fs.mkdirSync(resultsDir, { recursive: true });
16
18
  fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
19
+ fs.rmSync(path.join(resultsDir, LIVE_ARTIFACT_FILENAME), { force: true });
20
+ }
21
+
22
+ export function writeLiveRunArtifact(productDir, artifact) {
23
+ const resultsDir = path.join(productDir, ".testkit", "results");
24
+ fs.mkdirSync(resultsDir, { recursive: true });
25
+ fs.writeFileSync(path.join(resultsDir, LIVE_ARTIFACT_FILENAME), JSON.stringify(artifact, null, 2));
17
26
  }
18
27
 
19
28
  export function writeStatusArtifact(productDir, artifact) {
@@ -32,6 +41,13 @@ export function resetResultArtifacts(productDir) {
32
41
  recursive: true,
33
42
  force: true,
34
43
  });
44
+ fs.rmSync(path.join(productDir, ".testkit", "results", RESULT_SETUP_DIRNAME), {
45
+ recursive: true,
46
+ force: true,
47
+ });
48
+ fs.rmSync(path.join(productDir, ".testkit", "results", LIVE_ARTIFACT_FILENAME), {
49
+ force: true,
50
+ });
35
51
  }
36
52
 
37
53
  export function persistTaskArtifacts(productDir, task, emittedArtifacts) {
@@ -1,3 +1,6 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
1
4
  export function determineDefaultRuntimeFailure(result, summary, firstLine) {
2
5
  const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "", firstLine);
3
6
  if (fatalRuntimeError) {
@@ -26,6 +29,22 @@ export function extractDefaultRuntimeFatalError(stderr, firstLine) {
26
29
  return matched?.[1]?.trim() || firstLine(stderr);
27
30
  }
28
31
 
32
+ export function extractDefaultRuntimeFatalDetail(stderr, firstLine) {
33
+ if (!stderr || !/source=stacktrace/.test(stderr)) return null;
34
+ const message = extractDefaultRuntimeFatalError(stderr, firstLine);
35
+ if (!message) return null;
36
+
37
+ const location = extractFirstLocation(stderr);
38
+ return {
39
+ kind: "runtime-exception",
40
+ key: location ? `${location.path}:${location.line}:${location.column}` : message,
41
+ title: "Uncaught runtime exception",
42
+ message: `Uncaught testkit suite error: ${message}`,
43
+ location,
44
+ stack: sanitizeStack(stderr),
45
+ };
46
+ }
47
+
29
48
  export function extractDefaultRuntimeThresholdFailures(summary) {
30
49
  const metrics = summary?.metrics;
31
50
  if (!metrics || typeof metrics !== "object") return [];
@@ -51,3 +70,50 @@ export function sanitizeDefaultRuntimeExitError(exitCode, output, firstLine) {
51
70
  }
52
71
  return `Default runtime failed with exit code ${exitCode}`;
53
72
  }
73
+
74
+ function extractFirstLocation(stderr) {
75
+ const locations = [...String(stderr).matchAll(/(file:\/\/[^\s)]+|\/[^\s):]+):(\d+):(\d+)/g)].map((match) => ({
76
+ path: normalizeLocationPath(match[1]),
77
+ line: Number(match[2]),
78
+ column: Number(match[3]),
79
+ }));
80
+ if (locations.length === 0) return null;
81
+
82
+ return (
83
+ locations.find((location) => isPreferredLocation(location.path)) ||
84
+ locations.find((location) => !isRuntimeInternalLocation(location.path)) ||
85
+ locations[0]
86
+ );
87
+ }
88
+
89
+ function normalizeLocationPath(rawPath) {
90
+ if (!rawPath) return rawPath;
91
+ if (rawPath.startsWith("file://")) {
92
+ try {
93
+ return fileURLToPath(rawPath);
94
+ } catch {
95
+ return rawPath;
96
+ }
97
+ }
98
+ return rawPath;
99
+ }
100
+
101
+ function sanitizeStack(stderr) {
102
+ return String(stderr)
103
+ .split(/\r?\n/)
104
+ .map((line) => line.trim())
105
+ .filter(Boolean)
106
+ .join("\n");
107
+ }
108
+
109
+ function isPreferredLocation(filePath) {
110
+ if (typeof filePath !== "string") return false;
111
+ const normalized = filePath.split(path.sep).join("/");
112
+ return normalized.includes("/.testkit/_bundles/") || normalized.includes("/__testkit__/");
113
+ }
114
+
115
+ function isRuntimeInternalLocation(filePath) {
116
+ if (typeof filePath !== "string") return false;
117
+ const normalized = filePath.split(path.sep).join("/");
118
+ return normalized.includes("/testkit/lib/runtime-src/k6/") || normalized.includes("/vendor/k6");
119
+ }