@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,8 +1,14 @@
1
1
  import http from "k6/http";
2
2
  import { defaultOptions } from "./checks.js";
3
+ import { emitArtifact } from "./artifacts.js";
3
4
 
4
5
  export { defaultOptions };
5
6
 
7
+ const REDACTED_QUERY_PARAMS = new Set(["token", "organizationId"]);
8
+ const REDACTED_HEADERS = new Set(["authorization", "cookie", "x-admin-key"]);
9
+ const TRACE_PREVIEW_LIMIT = 320;
10
+ const traceState = createTraceState();
11
+
6
12
  export function getEnv() {
7
13
  const BASE = __ENV.BASE_URL;
8
14
  const MACHINE_ID = __ENV.MACHINE_ID;
@@ -40,19 +46,23 @@ export function createHttpClient(config) {
40
46
  function request(method, path, setupData, body, extraHeaders = {}) {
41
47
  const url = `${baseUrl}${path}`;
42
48
  const headers = buildHeaders(getHeaders, setupData, extraHeaders);
43
- return runHttpRequest(method, url, body, headers);
49
+ return runHttpRequest(method, path, url, body, headers);
44
50
  }
45
51
 
46
52
  function raw(method, path, body, extraHeaders = {}) {
47
53
  const url = `${baseUrl}${path}`;
48
54
  const headers = buildHeaders(getRawHeaders, null, extraHeaders);
49
- return runHttpRequest(method, url, body, headers);
55
+ return runHttpRequest(method, path, url, body, headers);
50
56
  }
51
57
 
52
58
  function getWithHeaders(path, setupData, extraHeaders = {}) {
53
- return http.get(`${baseUrl}${path}`, {
54
- headers: buildHeaders(getHeaders, setupData, extraHeaders),
55
- });
59
+ return runHttpRequest(
60
+ "GET",
61
+ path,
62
+ `${baseUrl}${path}`,
63
+ null,
64
+ buildHeaders(getHeaders, setupData, extraHeaders)
65
+ );
56
66
  }
57
67
 
58
68
  return {
@@ -117,19 +127,257 @@ export function makeGetWithHeaders(baseUrl, routeHeaders = {}, getHeaders = null
117
127
  }).getWithHeaders;
118
128
  }
119
129
 
120
- function runHttpRequest(method, url, body, headers) {
121
- const options = { headers };
130
+ function runHttpRequest(method, path, url, body, headers) {
131
+ const ordinal = nextTraceOrdinal();
132
+ const requestId = buildRequestId(ordinal);
133
+ const finalHeaders = {
134
+ ...headers,
135
+ "x-request-id": headers?.["x-request-id"] || headers?.["X-Request-Id"] || requestId,
136
+ };
137
+ const trace = createTrace({
138
+ ordinal,
139
+ requestId: finalHeaders["x-request-id"],
140
+ method,
141
+ path,
142
+ url,
143
+ requestHeaders: finalHeaders,
144
+ });
145
+ const options = { headers: finalHeaders };
146
+
147
+ let rawResponse;
148
+ if (method === "GET") rawResponse = http.get(url, options);
149
+ else if (method === "PUT") rawResponse = http.put(url, JSON.stringify(body), options);
150
+ else if (method === "POST") rawResponse = http.post(url, JSON.stringify(body), options);
151
+ else if (method === "PATCH") rawResponse = http.patch(url, JSON.stringify(body), options);
152
+ else if (method === "DELETE") rawResponse = http.del(url, null, options);
153
+ else throw new Error(`unsupported method: ${method}`);
154
+
155
+ finalizeTrace(trace, rawResponse);
156
+ traceState.traces.push(trace);
157
+ trimTraceBuffer();
158
+ return createWrappedResponse(rawResponse, trace);
159
+ }
160
+
161
+ export function startHttpTraceCollection(phase) {
162
+ traceState.phase = normalizeLabel(phase, "exec");
163
+ traceState.traces = [];
164
+ traceState.counter = 0;
165
+ }
166
+
167
+ export function emitHttpTraceCollectionArtifact() {
168
+ const traces = traceState.traces.map((trace) => ({ ...trace }));
169
+ if (traces.length > 0) {
170
+ emitArtifact(
171
+ "http-traces",
172
+ {
173
+ phase: traceState.phase,
174
+ traces,
175
+ },
176
+ {
177
+ kind: "testkit.http-traces",
178
+ summary: `${traces.length} HTTP trace(s)`,
179
+ }
180
+ );
181
+ }
182
+ startHttpTraceCollection(traceState.phase);
183
+ }
122
184
 
123
- if (method === "GET") return http.get(url, options);
124
- if (method === "PUT") return http.put(url, JSON.stringify(body), options);
125
- if (method === "POST") return http.post(url, JSON.stringify(body), options);
126
- if (method === "PATCH") return http.patch(url, JSON.stringify(body), options);
127
- if (method === "DELETE") return http.del(url, null, options);
185
+ export function getHttpTrace(response) {
186
+ return response?.__testkit?.httpTrace || null;
187
+ }
128
188
 
129
- throw new Error(`unsupported method: ${method}`);
189
+ export function summarizeHttpTrace(response) {
190
+ const trace = getHttpTrace(response);
191
+ if (!trace) return null;
192
+ return {
193
+ id: trace.id,
194
+ requestId: trace.requestId,
195
+ method: trace.method,
196
+ path: trace.path,
197
+ url: trace.url,
198
+ requestHeaders: trace.requestHeaders,
199
+ response: trace.response,
200
+ startedAt: trace.startedAt,
201
+ finishedAt: trace.finishedAt,
202
+ durationMs: trace.durationMs,
203
+ };
204
+ }
205
+
206
+ export function safeJson(response) {
207
+ try {
208
+ return {
209
+ ok: true,
210
+ value: JSON.parse(response?.body || ""),
211
+ };
212
+ } catch (error) {
213
+ return {
214
+ ok: false,
215
+ error: error instanceof Error ? error.message : String(error),
216
+ };
217
+ }
218
+ }
219
+
220
+ export function toBodyPreview(response) {
221
+ const contentType = toHeaderText(
222
+ response?.headers?.["Content-Type"] || response?.headers?.["content-type"] || null
223
+ );
224
+ const rawBody = typeof response?.body === "string" ? response.body : "";
225
+ if (!rawBody.trim()) return null;
226
+
227
+ if (contentType?.includes("application/json")) {
228
+ try {
229
+ return truncate(JSON.stringify(JSON.parse(rawBody)), TRACE_PREVIEW_LIMIT);
230
+ } catch {
231
+ return truncate(rawBody, TRACE_PREVIEW_LIMIT);
232
+ }
233
+ }
234
+
235
+ return truncate(rawBody, TRACE_PREVIEW_LIMIT);
236
+ }
237
+
238
+ function createTrace({ ordinal, requestId, method, path, url, requestHeaders }) {
239
+ return {
240
+ id: `${traceState.phase}-${String(ordinal).padStart(3, "0")}`,
241
+ requestId,
242
+ startedAt: new Date().toISOString(),
243
+ method,
244
+ path: sanitizePath(path),
245
+ url: sanitizeUrl(url),
246
+ requestHeaders: sanitizeHeaders(requestHeaders),
247
+ };
248
+ }
249
+
250
+ function finalizeTrace(trace, response) {
251
+ const finishedAt = new Date();
252
+ trace.finishedAt = finishedAt.toISOString();
253
+ trace.durationMs = Math.max(0, finishedAt.getTime() - Date.parse(trace.startedAt));
254
+ trace.response = {
255
+ status: Number(response?.status ?? 0) || null,
256
+ contentType: toHeaderText(
257
+ response?.headers?.["Content-Type"] || response?.headers?.["content-type"] || null
258
+ ),
259
+ bodyPreview: toBodyPreview(response),
260
+ bodyTruncated: typeof response?.body === "string" && response.body.length > TRACE_PREVIEW_LIMIT,
261
+ };
262
+ }
263
+
264
+ function trimTraceBuffer() {
265
+ if (traceState.traces.length <= traceState.maxEntries) return;
266
+ traceState.traces.splice(0, traceState.traces.length - traceState.maxEntries);
267
+ }
268
+
269
+ function createTraceState() {
270
+ return {
271
+ phase: "exec",
272
+ traces: [],
273
+ counter: 0,
274
+ maxEntries: 50,
275
+ };
276
+ }
277
+
278
+ function sanitizePath(rawPath) {
279
+ if (typeof rawPath !== "string") return rawPath;
280
+ const [pathname, query] = rawPath.split("?");
281
+ if (!query) return pathname;
282
+ const parts = query.split("&").map((entry) => {
283
+ const [rawKey, ...rawValueParts] = entry.split("=");
284
+ const key = decodeQueryComponent(rawKey);
285
+ if (!REDACTED_QUERY_PARAMS.has(key)) return entry;
286
+ const encodedKey = rawKey || encodeURIComponent(key);
287
+ return `${encodedKey}=${encodeURIComponent("[REDACTED]")}`;
288
+ });
289
+ return `${pathname}?${parts.join("&")}`;
290
+ }
291
+
292
+ function sanitizeUrl(rawUrl) {
293
+ if (typeof rawUrl !== "string") return rawUrl;
294
+ const protocolIndex = rawUrl.indexOf("://");
295
+ if (protocolIndex === -1) return sanitizePath(rawUrl);
296
+ const pathIndex = rawUrl.indexOf("/", protocolIndex + 3);
297
+ if (pathIndex === -1) return rawUrl;
298
+ return `${rawUrl.slice(0, pathIndex)}${sanitizePath(rawUrl.slice(pathIndex))}`;
299
+ }
300
+
301
+ function sanitizeHeaders(headers) {
302
+ if (!headers || typeof headers !== "object") return {};
303
+ const output = {};
304
+ for (const [name, value] of Object.entries(headers)) {
305
+ const normalizedName = String(name);
306
+ const lower = normalizedName.toLowerCase();
307
+ output[normalizedName] = REDACTED_HEADERS.has(lower) ? "[REDACTED]" : normalizeHeaderValue(value);
308
+ }
309
+ return output;
310
+ }
311
+
312
+ function normalizeHeaderValue(value) {
313
+ if (Array.isArray(value)) return value.map((entry) => String(entry));
314
+ if (value == null) return null;
315
+ return String(value);
316
+ }
317
+
318
+ function toHeaderText(value) {
319
+ const normalized = normalizeHeaderValue(value);
320
+ return Array.isArray(normalized) ? normalized[0] || null : normalized;
321
+ }
322
+
323
+ function buildRequestId(ordinal) {
324
+ const runtimeId = normalizeLabel(__ENV.TESTKIT_RUNTIME_ID, "runtime");
325
+ return `tk_${runtimeId}_${traceState.phase}_${String(ordinal).padStart(4, "0")}`;
326
+ }
327
+
328
+ function nextTraceOrdinal() {
329
+ traceState.counter += 1;
330
+ return traceState.counter;
331
+ }
332
+
333
+ function truncate(value, limit) {
334
+ const normalized = String(value || "").replace(/\s+/g, " ").trim();
335
+ if (normalized.length <= limit) return normalized;
336
+ return `${normalized.slice(0, Math.max(0, limit - 3))}...`;
337
+ }
338
+
339
+ function normalizeLabel(value, fallback) {
340
+ if (typeof value !== "string") return fallback;
341
+ const normalized = value.trim();
342
+ return normalized.length > 0 ? normalized : fallback;
130
343
  }
131
344
 
132
345
  function safeHeaders(builder, setupData) {
133
346
  if (typeof builder !== "function") return {};
134
347
  return builder(setupData) || {};
135
348
  }
349
+
350
+ function createWrappedResponse(rawResponse, trace) {
351
+ if (!rawResponse || typeof rawResponse !== "object") {
352
+ return rawResponse;
353
+ }
354
+
355
+ const wrapped = {};
356
+ for (const key of Object.keys(rawResponse)) {
357
+ wrapped[key] = rawResponse[key];
358
+ }
359
+
360
+ const prototype = Object.getPrototypeOf(rawResponse);
361
+ if (prototype && typeof prototype === "object") {
362
+ for (const name of Object.getOwnPropertyNames(prototype)) {
363
+ if (name === "constructor" || name in wrapped) continue;
364
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
365
+ if (!descriptor || typeof descriptor.value !== "function") continue;
366
+ wrapped[name] = descriptor.value.bind(rawResponse);
367
+ }
368
+ }
369
+
370
+ wrapped.__testkit = {
371
+ httpTrace: trace,
372
+ rawResponse,
373
+ };
374
+ return wrapped;
375
+ }
376
+
377
+ function decodeQueryComponent(value) {
378
+ try {
379
+ return decodeURIComponent(String(value || ""));
380
+ } catch {
381
+ return String(value || "");
382
+ }
383
+ }
@@ -2,10 +2,16 @@ import { fail } from "k6";
2
2
  import {
3
3
  defaultOptions,
4
4
  emitFailureCollectionArtifact,
5
+ recordFailureDetail,
5
6
  recordRuntimeFailure,
6
7
  startFailureCollection,
7
8
  } from "./checks.js";
8
- import { createHttpClient, getEnv } from "./http.js";
9
+ import {
10
+ createHttpClient,
11
+ emitHttpTraceCollectionArtifact,
12
+ getEnv,
13
+ startHttpTraceCollection,
14
+ } from "./http.js";
9
15
  import {
10
16
  clearRuntimeContext,
11
17
  registerRuntimeContext,
@@ -22,21 +28,25 @@ export function defineHttpSuite(configOrRun, maybeRun) {
22
28
  setup() {
23
29
  const resolved = resolveRuntimeConfig(config);
24
30
  startFailureCollection("setup");
31
+ startHttpTraceCollection("setup");
25
32
  try {
26
33
  registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
27
34
  if (typeof resolved.auth?.setup !== "function") return null;
28
35
  return resolved.auth.setup({ env: resolved.env });
29
36
  } catch (error) {
37
+ recordFailureDetail(buildRuntimeExceptionDetail("setup", error));
30
38
  recordRuntimeFailure();
31
39
  fail(formatFatalSuiteError("setup", error));
32
40
  } finally {
33
41
  emitFailureCollectionArtifact();
42
+ emitHttpTraceCollectionArtifact();
34
43
  clearRuntimeContext();
35
44
  }
36
45
  },
37
46
  exec(setupData) {
38
47
  const resolved = resolveRuntimeConfig(config);
39
48
  startFailureCollection("exec");
49
+ startHttpTraceCollection("exec");
40
50
  try {
41
51
  registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
42
52
  return run({
@@ -48,10 +58,12 @@ export function defineHttpSuite(configOrRun, maybeRun) {
48
58
  session: setupData,
49
59
  });
50
60
  } catch (error) {
61
+ recordFailureDetail(buildRuntimeExceptionDetail("exec", error));
51
62
  recordRuntimeFailure();
52
63
  fail(formatFatalSuiteError("exec", error));
53
64
  } finally {
54
65
  emitFailureCollectionArtifact();
66
+ emitHttpTraceCollectionArtifact();
55
67
  clearRuntimeContext();
56
68
  }
57
69
  },
@@ -123,3 +135,36 @@ function formatFatalSuiteError(phase, error) {
123
135
  }
124
136
  return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
125
137
  }
138
+
139
+ function buildRuntimeExceptionDetail(phase, error) {
140
+ const message =
141
+ error instanceof Error ? error.message : String(error);
142
+ const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : "";
143
+ const location = extractLocationFromStack(stack);
144
+ return {
145
+ kind: "runtime-exception",
146
+ key: location ? `${location.path}:${location.line}:${location.column}` : `runtime-exception:${phase}:${message}`,
147
+ title: "Uncaught runtime exception",
148
+ message: `Uncaught testkit suite error during ${phase}: ${message}`,
149
+ location,
150
+ stack,
151
+ };
152
+ }
153
+
154
+ function extractLocationFromStack(stack) {
155
+ if (!stack) return null;
156
+ const matches = [...String(stack).matchAll(/(file:\/\/[^\s)]+|\/[^\s):]+):(\d+):(\d+)/g)].map((match) => ({
157
+ path: normalizeStackPath(match[1]),
158
+ line: Number(match[2]),
159
+ column: Number(match[3]),
160
+ }));
161
+ return matches[0] || null;
162
+ }
163
+
164
+ function normalizeStackPath(rawPath) {
165
+ if (typeof rawPath !== "string") return rawPath;
166
+ if (rawPath.startsWith("file://")) {
167
+ return rawPath.slice("file://".length);
168
+ }
169
+ return rawPath;
170
+ }
@@ -89,11 +89,7 @@ export async function announceResolvedToolchain(config, resolvedToolchain, repor
89
89
  announcedToolchains.add(config);
90
90
  if (reporter?.toolchainResolved) {
91
91
  reporter.toolchainResolved(config, resolvedToolchain);
92
- return;
93
92
  }
94
- console.log(
95
- `[testkit] ${config.runtimeLabel || config.name}:${config.name} toolchain ${resolvedToolchain.summary}`
96
- );
97
93
  }
98
94
 
99
95
  export function applyToolchainEnv(baseEnv, resolvedToolchain, processEnv = process.env) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",
@@ -48,10 +48,12 @@
48
48
  "vitest": "^3.2.4"
49
49
  },
50
50
  "dependencies": {
51
+ "@babel/code-frame": "^7.29.0",
51
52
  "@oclif/core": "^4.10.6",
52
53
  "esbuild": "^0.25.11",
53
54
  "execa": "^9.5.0",
54
55
  "ink": "^7.0.1",
56
+ "picocolors": "^1.1.1",
55
57
  "react": "^19.2.5"
56
58
  },
57
59
  "engines": {