@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.
- package/lib/cli/commands/artifacts.mjs +2 -2
- package/lib/cli/commands/logs.mjs +2 -2
- package/lib/cli/commands/show.mjs +2 -2
- package/lib/cli/db.mjs +17 -2
- package/lib/cli/presentation/code-frames.mjs +57 -0
- package/lib/cli/presentation/code-frames.test.mjs +71 -0
- package/lib/cli/presentation/colors.mjs +29 -0
- package/lib/cli/presentation/run-reporter.mjs +41 -7
- package/lib/cli/presentation/run-reporter.test.mjs +80 -0
- package/lib/cli/tui/watch-app.mjs +134 -18
- package/lib/cli/viewer.mjs +146 -4
- package/lib/database/index.mjs +85 -11
- package/lib/database/template-steps.mjs +45 -6
- package/lib/database/template-steps.test.mjs +43 -0
- package/lib/known-failures/index.mjs +1 -1
- package/lib/known-failures/index.test.mjs +46 -0
- package/lib/runner/artifacts.mjs +16 -0
- package/lib/runner/default-runtime-errors.mjs +66 -0
- package/lib/runner/default-runtime-runner.mjs +8 -1
- package/lib/runner/failure-details.mjs +31 -0
- package/lib/runner/failure-details.test.mjs +51 -0
- package/lib/runner/formatting.mjs +114 -4
- package/lib/runner/formatting.test.mjs +77 -0
- package/lib/runner/logs.mjs +71 -6
- package/lib/runner/orchestrator.mjs +63 -7
- package/lib/runner/reporting.mjs +52 -2
- package/lib/runner/reporting.test.mjs +80 -2
- package/lib/runner/runtime-contexts.mjs +3 -3
- package/lib/runner/runtime-preparation.mjs +31 -0
- package/lib/runner/setup-operations.mjs +115 -0
- package/lib/runner/setup-operations.test.mjs +94 -0
- package/lib/runner/template-steps.mjs +129 -11
- package/lib/runner/triage.mjs +67 -0
- package/lib/runtime/index.d.ts +60 -0
- package/lib/runtime/index.mjs +12 -0
- package/lib/runtime-src/k6/checks.js +45 -12
- package/lib/runtime-src/k6/http-assertions.js +214 -0
- package/lib/runtime-src/k6/http.js +261 -13
- package/lib/runtime-src/k6/suite.js +46 -1
- package/lib/toolchains/index.mjs +0 -4
- package/package.json +3 -1
package/lib/cli/viewer.mjs
CHANGED
|
@@ -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 (
|
|
88
|
+
if (failureDetails.length > 0) {
|
|
74
89
|
lines.push("");
|
|
75
90
|
lines.push("Failure Details:");
|
|
76
|
-
for (const detail of
|
|
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
|
|
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 || []) {
|
package/lib/database/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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);
|
package/lib/runner/artifacts.mjs
CHANGED
|
@@ -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
|
+
}
|