@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.
- 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 +16 -7
- package/lib/cli/viewer.mjs +109 -4
- package/lib/known-failures/index.mjs +1 -1
- package/lib/known-failures/index.test.mjs +46 -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 +17 -0
- package/lib/runner/orchestrator.mjs +10 -1
- 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/package.json +3 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { evaluateCheck, recordFailureDetail } from "./checks.js";
|
|
2
|
+
import { safeJson, summarizeHttpTrace, toBodyPreview } from "./http.js";
|
|
3
|
+
|
|
4
|
+
export function expectStatus(response, expected, label = null) {
|
|
5
|
+
return expectStatusOneOf(response, [expected], label);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function expectStatusOneOf(response, expectedValues, label = null) {
|
|
9
|
+
const values = normalizeExpectedValues(expectedValues);
|
|
10
|
+
const title = normalizeLabel(label, buildExpectedStatusTitle(values));
|
|
11
|
+
const trace = summarizeHttpTrace(response);
|
|
12
|
+
const actual = Number(response?.status ?? 0);
|
|
13
|
+
|
|
14
|
+
return evaluateCheck(
|
|
15
|
+
response,
|
|
16
|
+
title,
|
|
17
|
+
(res) => values.includes(Number(res?.status ?? 0)),
|
|
18
|
+
() => buildHttpAssertionDetail({
|
|
19
|
+
kind: "http-assertion",
|
|
20
|
+
title,
|
|
21
|
+
trace,
|
|
22
|
+
expected: values.length === 1 ? values[0] : values,
|
|
23
|
+
actual,
|
|
24
|
+
response,
|
|
25
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} expected ${formatExpectedValues(values)}, got ${actual}`,
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function expectNotStatus(response, unexpected, label = null) {
|
|
31
|
+
const title = normalizeLabel(label, `status is not ${unexpected}`);
|
|
32
|
+
const trace = summarizeHttpTrace(response);
|
|
33
|
+
const actual = Number(response?.status ?? 0);
|
|
34
|
+
|
|
35
|
+
return evaluateCheck(
|
|
36
|
+
response,
|
|
37
|
+
title,
|
|
38
|
+
(res) => Number(res?.status ?? 0) !== unexpected,
|
|
39
|
+
() => buildHttpAssertionDetail({
|
|
40
|
+
kind: "http-assertion",
|
|
41
|
+
title,
|
|
42
|
+
trace,
|
|
43
|
+
expected: { not: unexpected },
|
|
44
|
+
actual,
|
|
45
|
+
response,
|
|
46
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} expected not ${unexpected}, got ${actual}`,
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function expectJson(response, predicate, label = null) {
|
|
52
|
+
const title = normalizeLabel(label, "response JSON matches expectation");
|
|
53
|
+
const trace = summarizeHttpTrace(response);
|
|
54
|
+
const jsonResult = safeJson(response);
|
|
55
|
+
|
|
56
|
+
if (!jsonResult.ok) {
|
|
57
|
+
recordFailureDetail(
|
|
58
|
+
buildHttpAssertionDetail({
|
|
59
|
+
kind: "http-json-assertion",
|
|
60
|
+
title,
|
|
61
|
+
trace,
|
|
62
|
+
expected: "valid JSON response",
|
|
63
|
+
actual: "invalid JSON response",
|
|
64
|
+
response,
|
|
65
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} did not return valid JSON`,
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return evaluateCheck(
|
|
72
|
+
jsonResult.value,
|
|
73
|
+
title,
|
|
74
|
+
(value) => Boolean(predicate(value)),
|
|
75
|
+
() => ({
|
|
76
|
+
kind: "json-assertion",
|
|
77
|
+
key: buildTraceFailureKey(trace, title),
|
|
78
|
+
title,
|
|
79
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} JSON assertion failed`,
|
|
80
|
+
request: trace ? buildRequestSummary(trace) : null,
|
|
81
|
+
response: trace ? buildResponseSummary(trace) : null,
|
|
82
|
+
actual: toValuePreview(jsonResult.value),
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function expectJsonPath(response, path, predicate, label = null) {
|
|
88
|
+
const title = normalizeLabel(label, `response JSON path ${path} matches expectation`);
|
|
89
|
+
const trace = summarizeHttpTrace(response);
|
|
90
|
+
const jsonResult = safeJson(response);
|
|
91
|
+
|
|
92
|
+
if (!jsonResult.ok) {
|
|
93
|
+
recordFailureDetail(
|
|
94
|
+
buildHttpAssertionDetail({
|
|
95
|
+
kind: "http-json-assertion",
|
|
96
|
+
title,
|
|
97
|
+
trace,
|
|
98
|
+
expected: `valid JSON containing ${path}`,
|
|
99
|
+
actual: "invalid JSON response",
|
|
100
|
+
response,
|
|
101
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} did not return valid JSON`,
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const actualValue = getJsonPathValue(jsonResult.value, path);
|
|
108
|
+
return evaluateCheck(
|
|
109
|
+
actualValue,
|
|
110
|
+
title,
|
|
111
|
+
(value) => Boolean(predicate(value)),
|
|
112
|
+
() => ({
|
|
113
|
+
kind: "json-path-assertion",
|
|
114
|
+
key: buildTraceFailureKey(trace, title),
|
|
115
|
+
title,
|
|
116
|
+
message: `${trace?.method || "HTTP"} ${trace?.path || "(unknown path)"} JSON path ${path} assertion failed`,
|
|
117
|
+
expected: path,
|
|
118
|
+
actual: toValuePreview(actualValue),
|
|
119
|
+
request: trace ? buildRequestSummary(trace) : null,
|
|
120
|
+
response: trace ? buildResponseSummary(trace) : null,
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildHttpAssertionDetail({ kind, title, trace, expected, actual, response, message }) {
|
|
126
|
+
return {
|
|
127
|
+
kind,
|
|
128
|
+
key: buildTraceFailureKey(trace, title),
|
|
129
|
+
title,
|
|
130
|
+
message,
|
|
131
|
+
expected,
|
|
132
|
+
actual,
|
|
133
|
+
traceId: trace?.id || null,
|
|
134
|
+
request: trace ? buildRequestSummary(trace) : null,
|
|
135
|
+
response: trace ? buildResponseSummary(trace, response) : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildRequestSummary(trace) {
|
|
140
|
+
return {
|
|
141
|
+
id: trace.id,
|
|
142
|
+
requestId: trace.requestId,
|
|
143
|
+
method: trace.method,
|
|
144
|
+
path: trace.path,
|
|
145
|
+
url: trace.url,
|
|
146
|
+
headers: trace.requestHeaders || {},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildResponseSummary(trace, response = null) {
|
|
151
|
+
return {
|
|
152
|
+
status: (trace.response?.status ?? Number(response?.status ?? 0)) || null,
|
|
153
|
+
contentType: trace.response?.contentType ?? null,
|
|
154
|
+
bodyPreview: trace.response?.bodyPreview ?? toBodyPreview(response),
|
|
155
|
+
bodyTruncated: Boolean(trace.response?.bodyTruncated),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildTraceFailureKey(trace, title) {
|
|
160
|
+
if (trace?.path && trace?.method) {
|
|
161
|
+
return `${trace.method} ${trace.path} > ${title}`;
|
|
162
|
+
}
|
|
163
|
+
return title;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildExpectedStatusTitle(expectedValues) {
|
|
167
|
+
return `status is ${formatExpectedValues(expectedValues)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatExpectedValues(values) {
|
|
171
|
+
if (!Array.isArray(values) || values.length === 0) return "expected";
|
|
172
|
+
if (values.length === 1) return String(values[0]);
|
|
173
|
+
return values.map((value) => String(value)).join(" or ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeExpectedValues(values) {
|
|
177
|
+
const normalized = (Array.isArray(values) ? values : [values])
|
|
178
|
+
.map((value) => Number(value))
|
|
179
|
+
.filter((value) => Number.isFinite(value));
|
|
180
|
+
|
|
181
|
+
if (normalized.length === 0) {
|
|
182
|
+
throw new Error("expectStatusOneOf requires at least one numeric expected status");
|
|
183
|
+
}
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getJsonPathValue(value, path) {
|
|
188
|
+
const tokens = String(path || "")
|
|
189
|
+
.split(".")
|
|
190
|
+
.map((token) => token.trim())
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
|
|
193
|
+
let current = value;
|
|
194
|
+
for (const token of tokens) {
|
|
195
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
196
|
+
current = current[token];
|
|
197
|
+
}
|
|
198
|
+
return current;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function toValuePreview(value) {
|
|
202
|
+
if (value == null) return value;
|
|
203
|
+
if (typeof value === "string") return value.length > 240 ? `${value.slice(0, 237)}...` : value;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.stringify(value).slice(0, 240);
|
|
206
|
+
} catch {
|
|
207
|
+
return String(value);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeLabel(value, fallback) {
|
|
212
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
213
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
214
|
+
}
|
|
@@ -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
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
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": {
|