@elench/testkit 0.1.53 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,7 +69,7 @@ export function buildCompactRunSummaryLines(
69
69
  for (const failure of failures) {
70
70
  lines.push(` ${failure.file.path}`);
71
71
  lines.push(` ${failure.primaryMessage}`);
72
- for (const detail of failure.extraDetails.slice(0, 2)) {
72
+ for (const detail of failure.extraLines.slice(0, 3)) {
73
73
  lines.push(` ${detail}`);
74
74
  }
75
75
  }
@@ -209,14 +209,33 @@ function collectFailedFiles(results) {
209
209
  for (const suite of result.suites || []) {
210
210
  for (const file of suite.files || []) {
211
211
  if (file.status !== "failed") continue;
212
- const detailMessages = (file.failureDetails || [])
212
+ const rankedDetails = rankFailureDetails(file.failureDetails || []);
213
+ const primaryDetail = rankedDetails[0] || null;
214
+ const fallbackMessages = rankedDetails
213
215
  .map((detail) => detail.message || detail.title)
214
216
  .filter(Boolean)
215
217
  .map((message) => sanitizeErrorMessage(String(message).trim()));
218
+ const extraLines = [];
219
+ if (primaryDetail) {
220
+ const responseLine = formatFailureResponsePreview(primaryDetail);
221
+ if (responseLine) extraLines.push(responseLine);
222
+ const triageLine = formatTriageLine(file.triage || null);
223
+ if (triageLine) extraLines.push(triageLine);
224
+ const requestLine = formatFailureRequestHint(primaryDetail);
225
+ if (requestLine) extraLines.push(requestLine);
226
+ } else {
227
+ const triageLine = formatTriageLine(file.triage || null);
228
+ if (triageLine) extraLines.push(triageLine);
229
+ }
230
+
231
+ for (const detail of rankedDetails.slice(primaryDetail ? 1 : 0)) {
232
+ const line = sanitizeErrorMessage(String(detail.message || detail.title || "").trim());
233
+ if (line && !extraLines.includes(line)) extraLines.push(line);
234
+ }
216
235
  failures.push({
217
236
  file,
218
- primaryMessage: sanitizeErrorMessage(file.error || detailMessages[0] || suite.error || "Failed"),
219
- extraDetails: detailMessages.slice(file.error ? 0 : 1),
237
+ primaryMessage: resolvePrimaryFailureMessage(file, suite, primaryDetail, fallbackMessages),
238
+ extraLines,
220
239
  });
221
240
  }
222
241
  }
@@ -224,6 +243,97 @@ function collectFailedFiles(results) {
224
243
  return failures.sort((left, right) => left.file.path.localeCompare(right.file.path));
225
244
  }
226
245
 
246
+ function resolvePrimaryFailureMessage(file, suite, primaryDetail, fallbackMessages) {
247
+ if (primaryDetail?.message) {
248
+ return sanitizeErrorMessage(String(primaryDetail.message).trim());
249
+ }
250
+
251
+ const fileError = sanitizeErrorMessage(file.error || "");
252
+ if (fileError && !isThresholdWrapperMessage(fileError)) {
253
+ return fileError;
254
+ }
255
+
256
+ if (fallbackMessages.length > 0) {
257
+ return fallbackMessages[0];
258
+ }
259
+
260
+ if (fileError) {
261
+ return fileError;
262
+ }
263
+
264
+ return sanitizeErrorMessage(suite.error || "Failed");
265
+ }
266
+
267
+ function rankFailureDetails(details) {
268
+ return [...(Array.isArray(details) ? details : [])].sort((left, right) => {
269
+ return failureDetailRank(left) - failureDetailRank(right) || String(left?.key || "").localeCompare(String(right?.key || ""));
270
+ });
271
+ }
272
+
273
+ function failureDetailRank(detail) {
274
+ if (detail?.kind === "http-assertion") return 1;
275
+ if (detail?.request && detail?.response) return 2;
276
+ if (detail?.location || detail?.stack) return 3;
277
+ if (detail?.message) return 4;
278
+ return 5;
279
+ }
280
+
281
+ function formatFailureResponsePreview(detail) {
282
+ const bodyPreview = detail?.response?.bodyPreview;
283
+ if (!bodyPreview) return null;
284
+ return `response: ${sanitizeInline(bodyPreview, 220)}`;
285
+ }
286
+
287
+ function formatFailureRequestHint(detail) {
288
+ const method = detail?.request?.method;
289
+ const path = detail?.request?.path;
290
+ if (!method || !path) return null;
291
+ const requestId = detail?.request?.requestId;
292
+ if (requestId) {
293
+ return `logs: requestId=${requestId}`;
294
+ }
295
+ return `request: ${method} ${path}`;
296
+ }
297
+
298
+ function formatTriageLine(triage) {
299
+ if (!triage) return null;
300
+ if (triage.status === "untriaged") return "triage: untriaged";
301
+
302
+ const entry = triage.entries?.[0];
303
+ if (!entry?.issue) {
304
+ if (triage.status === "validation_unavailable") {
305
+ const reason = triage.availability?.reason ? ` (${triage.availability.reason})` : "";
306
+ return `triage: validation unavailable${reason}`;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ const issueLabel = `#${entry.issue.number}`;
312
+ const validationStatus = entry.validationStatus || null;
313
+ if (validationStatus === "closed_but_failing") {
314
+ return `triage: known issue ${issueLabel} closed but still failing`;
315
+ }
316
+ if (validationStatus === "validation_unavailable") {
317
+ const reason = triage.availability?.mode === "cache" ? "cache" : "validation unavailable";
318
+ return `triage: known issue ${issueLabel} (${reason})`;
319
+ }
320
+ if (entry.github?.state === "open" || entry.state === "open") {
321
+ return `triage: known issue ${issueLabel} open`;
322
+ }
323
+ if (entry.github?.state === "closed" || entry.state === "closed") {
324
+ return `triage: known issue ${issueLabel} closed`;
325
+ }
326
+ return `triage: known issue ${issueLabel}`;
327
+ }
328
+
329
+ function isThresholdWrapperMessage(message) {
330
+ return /Default runtime thresholds failed:/.test(String(message || ""));
331
+ }
332
+
333
+ function sanitizeInline(message, maxLength = 180) {
334
+ return String(message || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
335
+ }
336
+
227
337
  function collectServiceErrors(results) {
228
338
  const items = [];
229
339
  for (const result of results) {
@@ -162,4 +162,81 @@ describe("runner formatting", () => {
162
162
  expect(lines.join("\n")).toContain("2 closed issues still failing");
163
163
  expect(lines.join("\n")).toContain("1 title mismatch");
164
164
  });
165
+
166
+ it("prefers structured HTTP assertion details over threshold wrapper text", () => {
167
+ const lines = buildRunSummaryLines(
168
+ [
169
+ {
170
+ name: "api",
171
+ skipped: false,
172
+ failed: true,
173
+ suiteCount: 1,
174
+ completedSuiteCount: 1,
175
+ skippedSuiteCount: 0,
176
+ failedSuiteCount: 1,
177
+ totalFileCount: 1,
178
+ passedFileCount: 0,
179
+ failedFileCount: 1,
180
+ skippedFileCount: 0,
181
+ notRunFileCount: 0,
182
+ durationMs: 500,
183
+ suites: [
184
+ {
185
+ failed: true,
186
+ type: "int",
187
+ name: "default",
188
+ framework: "k6",
189
+ failedFiles: ["__testkit__/health/health.int.testkit.ts"],
190
+ durationMs: 500,
191
+ files: [
192
+ {
193
+ path: "__testkit__/health/health.int.testkit.ts",
194
+ status: "failed",
195
+ error: "Default runtime thresholds failed: checks(rate==1.0)",
196
+ failureDetails: [
197
+ {
198
+ kind: "http-assertion",
199
+ key: "GET /health > status is 200",
200
+ title: "status is 200",
201
+ message: "GET /health expected 200, got 404",
202
+ request: {
203
+ method: "GET",
204
+ path: "/health",
205
+ requestId: "req-1",
206
+ },
207
+ response: {
208
+ status: 404,
209
+ bodyPreview: '{"error":"nope"}',
210
+ },
211
+ },
212
+ ],
213
+ triage: {
214
+ status: "known_failure",
215
+ entries: [
216
+ {
217
+ id: "health-is-bad",
218
+ state: "open",
219
+ issue: {
220
+ repo: "acme/example",
221
+ number: 42,
222
+ },
223
+ },
224
+ ],
225
+ },
226
+ },
227
+ ],
228
+ error: null,
229
+ },
230
+ ],
231
+ errors: [],
232
+ },
233
+ ],
234
+ 500
235
+ );
236
+
237
+ expect(lines.join("\n")).toContain("GET /health expected 200, got 404");
238
+ expect(lines.join("\n")).toContain('response: {"error":"nope"}');
239
+ expect(lines.join("\n")).toContain("triage: known issue #42 open");
240
+ expect(lines.join("\n")).not.toContain("Default runtime thresholds failed: checks(rate==1.0)\n response:");
241
+ });
165
242
  });
@@ -59,6 +59,23 @@ export function readLogTail(absolutePath, lineCount = 80) {
59
59
  return lines.slice(Math.max(0, lines.length - lineCount));
60
60
  }
61
61
 
62
+ export function findLogSliceByRequestId(absolutePath, requestId, contextLines = 2) {
63
+ if (!absolutePath || !requestId || !fs.existsSync(absolutePath)) return [];
64
+ const lines = fs.readFileSync(absolutePath, "utf8").split(/\r?\n/).filter(Boolean);
65
+ const matches = [];
66
+
67
+ for (let index = 0; index < lines.length; index += 1) {
68
+ if (!lines[index].includes(requestId)) continue;
69
+ const start = Math.max(0, index - contextLines);
70
+ const end = Math.min(lines.length, index + contextLines + 1);
71
+ for (let cursor = start; cursor < end; cursor += 1) {
72
+ if (!matches.includes(lines[cursor])) matches.push(lines[cursor]);
73
+ }
74
+ }
75
+
76
+ return matches;
77
+ }
78
+
62
79
  function sanitizePathSegment(value) {
63
80
  return String(value)
64
81
  .trim()
@@ -15,7 +15,11 @@ 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
+ import {
19
+ applyKnownFailureIssueValidationToArtifacts,
20
+ applyKnownFailuresToArtifacts,
21
+ loadKnownFailuresConfig,
22
+ } from "./triage.mjs";
19
23
  import { formatError } from "./formatting.mjs";
20
24
  import {
21
25
  shouldFailKnownFailureIssueValidation,
@@ -230,6 +234,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
230
234
  config: configs[0]?.testkit?.reporting?.issueValidation || null,
231
235
  gitMetadata: metadata.git,
232
236
  });
237
+ applyKnownFailureIssueValidationToArtifacts(
238
+ enrichedArtifacts.runArtifact,
239
+ enrichedArtifacts.statusArtifact,
240
+ knownFailureIssueValidation
241
+ );
233
242
  attachKnownFailureIssueValidation(
234
243
  enrichedArtifacts.runArtifact,
235
244
  enrichedArtifacts.statusArtifact,
@@ -80,6 +80,49 @@ export function applyKnownFailuresToArtifacts(runArtifact, statusArtifact, known
80
80
  return { runArtifact, statusArtifact };
81
81
  }
82
82
 
83
+ export function applyKnownFailureIssueValidationToArtifacts(
84
+ runArtifact,
85
+ statusArtifact,
86
+ issueValidation
87
+ ) {
88
+ if (!issueValidation) return { runArtifact, statusArtifact };
89
+
90
+ const validationById = new Map(
91
+ (issueValidation.entries || []).map((entry) => [entry.id, entry])
92
+ );
93
+
94
+ for (const entry of [...extractRunFileEntries(runArtifact), ...extractStatusFileEntries(statusArtifact)]) {
95
+ const triage = entry.target ? entry.target.triage : entry.triage;
96
+ if (!triage?.entries?.length) continue;
97
+
98
+ const matchedValidationEntries = triage.entries
99
+ .map((triageEntry) => validationById.get(triageEntry.id))
100
+ .filter(Boolean);
101
+
102
+ if (matchedValidationEntries.length === 0) continue;
103
+
104
+ const enrichedEntries = triage.entries.map((triageEntry) => {
105
+ const validationEntry = validationById.get(triageEntry.id);
106
+ if (!validationEntry) return triageEntry;
107
+ return {
108
+ ...triageEntry,
109
+ github: validationEntry.github,
110
+ validationStatus: validationEntry.status,
111
+ findings: validationEntry.findings,
112
+ };
113
+ });
114
+
115
+ const nextTriage = {
116
+ ...triage,
117
+ entries: enrichedEntries,
118
+ availability: summarizeIssueValidationAvailability(issueValidation, matchedValidationEntries),
119
+ };
120
+ setEntryTriage(entry, nextTriage);
121
+ }
122
+
123
+ return { runArtifact, statusArtifact };
124
+ }
125
+
83
126
  function toArtifactTriageEntry(entry) {
84
127
  return {
85
128
  id: entry.id,
@@ -152,3 +195,27 @@ function setEntryTriage(entry, triage) {
152
195
  }
153
196
  entry.triage = triage;
154
197
  }
198
+
199
+ function summarizeIssueValidationAvailability(issueValidation, entries) {
200
+ if (entries.some((entry) => entry.github?.cached)) {
201
+ return {
202
+ mode: "cache",
203
+ reason: issueValidation.availability?.usedCachedFallback
204
+ ? "used stale cache"
205
+ : "used cached issue metadata",
206
+ };
207
+ }
208
+ if (entries.some((entry) => entry.status === "validation_unavailable")) {
209
+ const globalReason = (issueValidation.findings || []).find(
210
+ (finding) => finding.code === "validation_unavailable"
211
+ );
212
+ return {
213
+ mode: "offline",
214
+ reason: globalReason?.message || "validation unavailable",
215
+ };
216
+ }
217
+ return {
218
+ mode: "live",
219
+ reason: null,
220
+ };
221
+ }
@@ -9,6 +9,9 @@ export interface RuntimeCookie {
9
9
  }
10
10
 
11
11
  export interface RuntimeResponse {
12
+ __testkit?: {
13
+ httpTrace?: RuntimeHttpTrace;
14
+ };
12
15
  body: string;
13
16
  cookies?: Record<string, RuntimeCookie[]>;
14
17
  headers?: Record<string, string | string[] | undefined>;
@@ -18,6 +21,24 @@ export interface RuntimeResponse {
18
21
  };
19
22
  }
20
23
 
24
+ export interface RuntimeHttpTrace {
25
+ id: string;
26
+ requestId: string;
27
+ startedAt: string;
28
+ finishedAt?: string;
29
+ durationMs?: number;
30
+ method: RuntimeMethod;
31
+ path: string;
32
+ url: string;
33
+ requestHeaders: Record<string, string | string[] | null>;
34
+ response?: {
35
+ status: number | null;
36
+ contentType: string | string[] | null;
37
+ bodyPreview: string | null;
38
+ bodyTruncated: boolean;
39
+ };
40
+ }
41
+
21
42
  export interface RuntimeOptions {
22
43
  [key: string]: unknown;
23
44
  thresholds?: Record<string, unknown>;
@@ -144,6 +165,12 @@ export declare const check: <T>(
144
165
  value: T,
145
166
  checks: Record<string, (value: T) => boolean>
146
167
  ) => boolean;
168
+ export declare const evaluateCheck: <T>(
169
+ value: T,
170
+ checkName: string,
171
+ predicate: (value: T) => boolean,
172
+ detailFactory?: (() => Record<string, unknown>) | null
173
+ ) => boolean;
147
174
  export declare const fail: (message: string) => never;
148
175
  export declare const group: (name: string, fn: () => void) => void;
149
176
  export declare const sleep: (seconds?: number) => void;
@@ -152,6 +179,9 @@ export declare const http: RuntimeHttpClient;
152
179
 
153
180
  export declare function file(data: unknown, filename?: string, contentType?: string): unknown;
154
181
  export declare function json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
182
+ export declare function safeJson<T = unknown>(
183
+ response: Pick<RuntimeResponse, "body">
184
+ ): { ok: true; value: T } | { ok: false; error: string };
155
185
  export declare function remainingTimeSeconds(): number;
156
186
  export declare function waitFor<T>(
157
187
  read: () => T,
@@ -188,6 +218,9 @@ export declare function truncate(db: RuntimeDb, ...tables: string[]): void;
188
218
  export declare function getTestkitContext(): TestkitRuntimeContext;
189
219
 
190
220
  export declare function getEnv(): RuntimeEnv;
221
+ export declare function getHttpTrace(response: RuntimeResponse): RuntimeHttpTrace | null;
222
+ export declare function summarizeHttpTrace(response: RuntimeResponse): RuntimeHttpTrace | null;
223
+ export declare function toBodyPreview(response: RuntimeResponse): string | null;
191
224
  export declare function createHttpClient<TSetup = unknown>(
192
225
  config: HttpClientConfig<TSetup>
193
226
  ): HttpClient<TSetup>;
@@ -207,6 +240,33 @@ export declare function makeGetWithHeaders<TSetup = unknown>(
207
240
  getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
208
241
  ): HttpClient<TSetup>["getWithHeaders"];
209
242
 
243
+ export declare function expectStatus(
244
+ response: RuntimeResponse,
245
+ expected: number,
246
+ label?: string | null
247
+ ): boolean;
248
+ export declare function expectStatusOneOf(
249
+ response: RuntimeResponse,
250
+ expectedValues: number[],
251
+ label?: string | null
252
+ ): boolean;
253
+ export declare function expectNotStatus(
254
+ response: RuntimeResponse,
255
+ unexpected: number,
256
+ label?: string | null
257
+ ): boolean;
258
+ export declare function expectJson<T = unknown>(
259
+ response: RuntimeResponse,
260
+ predicate: (value: T) => boolean,
261
+ label?: string | null
262
+ ): boolean;
263
+ export declare function expectJsonPath(
264
+ response: RuntimeResponse,
265
+ path: string,
266
+ predicate: (value: unknown) => boolean,
267
+ label?: string | null
268
+ ): boolean;
269
+
210
270
  declare global {
211
271
  const __ENV: Record<string, string | undefined>;
212
272
  }
@@ -26,10 +26,18 @@ export {
26
26
  allMatch,
27
27
  contains,
28
28
  defaultOptions,
29
+ evaluateCheck,
29
30
  isSorted,
30
31
  json,
31
32
  singleIterationOptions,
32
33
  } from "../runtime-src/k6/checks.js";
34
+ export {
35
+ expectJson,
36
+ expectJsonPath,
37
+ expectNotStatus,
38
+ expectStatus,
39
+ expectStatusOneOf,
40
+ } from "../runtime-src/k6/http-assertions.js";
33
41
  export {
34
42
  createDalContext,
35
43
  openDb,
@@ -39,9 +47,13 @@ export {
39
47
  createHttpClient,
40
48
  defaultOptions as httpDefaultOptions,
41
49
  getEnv,
50
+ getHttpTrace,
42
51
  makeGetWithHeaders,
43
52
  makeRawReq,
44
53
  makeReq,
54
+ safeJson,
55
+ summarizeHttpTrace,
56
+ toBodyPreview,
45
57
  } from "../runtime-src/k6/http.js";
46
58
 
47
59
  export function getTestkitContext() {
@@ -24,23 +24,33 @@ export function check(value, checks) {
24
24
 
25
25
  for (const [name, predicate] of Object.entries(checks || {})) {
26
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
- }
27
+ const passed = evaluateCheck(value, checkName, predicate);
28
+ if (!passed) allPassed = false;
39
29
  }
40
30
 
41
31
  return allPassed;
42
32
  }
43
33
 
34
+ export function evaluateCheck(value, checkName, predicate, detailFactory = null) {
35
+ const normalizedName = normalizeLabel(checkName, "unnamed check");
36
+ const passed = k6Check(value, { [normalizedName]: predicate });
37
+ if (!passed) {
38
+ recordFailureDetail(
39
+ typeof detailFactory === "function"
40
+ ? detailFactory()
41
+ : {
42
+ kind: "k6-check",
43
+ key: buildFailureKey(failureState.groupStack, normalizedName),
44
+ title: normalizedName,
45
+ checkName: normalizedName,
46
+ groupPath: [...failureState.groupStack],
47
+ phase: failureState.phase,
48
+ }
49
+ );
50
+ }
51
+ return passed;
52
+ }
53
+
44
54
  export function group(name, fn) {
45
55
  const groupName = normalizeLabel(name, "unnamed group");
46
56
 
@@ -158,6 +168,24 @@ function normalizeFailureDetail(detail) {
158
168
  const message = normalizeLabel(detail.message, null);
159
169
  if (message) normalized.message = message;
160
170
 
171
+ if (detail.expected !== undefined) normalized.expected = detail.expected;
172
+ if (detail.actual !== undefined) normalized.actual = detail.actual;
173
+
174
+ const traceId = normalizeLabel(detail.traceId, null);
175
+ if (traceId) normalized.traceId = traceId;
176
+
177
+ const request = normalizeObject(detail.request);
178
+ if (request) normalized.request = request;
179
+
180
+ const response = normalizeObject(detail.response);
181
+ if (response) normalized.response = response;
182
+
183
+ const location = normalizeObject(detail.location);
184
+ if (location) normalized.location = location;
185
+
186
+ const stack = normalizeLabel(detail.stack, null);
187
+ if (stack) normalized.stack = stack;
188
+
161
189
  const groupPath = Array.isArray(detail.groupPath)
162
190
  ? detail.groupPath.map((entry) => normalizeLabel(entry, null)).filter(Boolean)
163
191
  : [];
@@ -176,3 +204,8 @@ function normalizeLabel(value, fallback) {
176
204
  const normalized = value.trim();
177
205
  return normalized.length > 0 ? normalized : fallback;
178
206
  }
207
+
208
+ function normalizeObject(value) {
209
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
210
+ return JSON.parse(JSON.stringify(value));
211
+ }