@elench/testkit 0.1.82 → 0.1.83
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/README.md +37 -7
- package/lib/cli/agents/index.mjs +64 -0
- package/lib/cli/agents/investigate.mjs +75 -0
- package/lib/cli/agents/investigation-context.mjs +102 -0
- package/lib/cli/agents/investigation-context.test.mjs +144 -0
- package/lib/cli/agents/prompt-builder.mjs +25 -0
- package/lib/cli/agents/providers/claude.mjs +74 -0
- package/lib/cli/agents/providers/claude.test.mjs +95 -0
- package/lib/cli/agents/providers/codex.mjs +83 -0
- package/lib/cli/agents/providers/codex.test.mjs +93 -0
- package/lib/cli/agents/providers/shared.mjs +134 -0
- package/lib/cli/command-helpers.mjs +53 -25
- package/lib/cli/command-helpers.test.mjs +122 -0
- package/lib/cli/commands/investigate.mjs +87 -0
- package/lib/cli/commands/investigate.test.mjs +83 -0
- package/lib/cli/entrypoint.mjs +3 -0
- package/lib/cli/presentation/colors.mjs +12 -0
- package/lib/cli/presentation/events-reporter.mjs +135 -0
- package/lib/cli/presentation/events-reporter.test.mjs +73 -0
- package/lib/cli/presentation/summary-box.mjs +11 -11
- package/lib/cli/presentation/summary-box.test.mjs +17 -0
- package/lib/cli/presentation/tree-reporter.mjs +159 -0
- package/lib/cli/presentation/tree-reporter.test.mjs +166 -0
- package/lib/cli/tui/run-app.mjs +1 -0
- package/lib/cli/tui/run-session-app.mjs +370 -0
- package/lib/cli/tui/run-session-app.test.mjs +50 -0
- package/lib/cli/tui/run-session-state.mjs +481 -0
- package/lib/cli/tui/run-tree-state.mjs +1 -0
- package/lib/cli/tui/run-tree-state.test.mjs +324 -0
- package/lib/config-api/auth-fixtures.mjs +15 -10
- package/lib/config-api/index.test.mjs +54 -0
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +138 -5
- package/lib/runtime/index.mjs +68 -2
- package/lib/runtime-src/k6/http-assertions.js +31 -1
- package/lib/runtime-src/k6/http-checks.js +120 -0
- package/lib/runtime-src/k6/http-checks.test.mjs +120 -0
- package/lib/runtime-src/k6/http-suite-runtime.js +5 -1
- package/lib/runtime-src/k6/http.js +213 -23
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- package/lib/runtime-src/shared/error-body.mjs +42 -0
- package/lib/runtime-src/shared/http-parsing.mjs +68 -0
- package/lib/runtime-src/shared/http-parsing.test.mjs +69 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
|
@@ -36,13 +36,21 @@ export function createHttpClient(config) {
|
|
|
36
36
|
throw new Error("baseUrl is required");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function buildHeaders(builder, context, extraHeaders = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
function buildHeaders(builder, context, extraHeaders = {}, options = {}) {
|
|
40
|
+
const { includeDefaultHeaders = true, contentTypeJson = true } = options;
|
|
41
|
+
const resolvedHeaders = {
|
|
42
|
+
...(includeDefaultHeaders ? defaultHeaders : {}),
|
|
42
43
|
...safeHeaders(builder, context),
|
|
43
44
|
...routeHeaders,
|
|
44
45
|
...extraHeaders,
|
|
45
46
|
};
|
|
47
|
+
|
|
48
|
+
if (contentTypeJson === false) {
|
|
49
|
+
delete resolvedHeaders["Content-Type"];
|
|
50
|
+
delete resolvedHeaders["content-type"];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return resolvedHeaders;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
function buildHeaderContext(actorName = null) {
|
|
@@ -60,23 +68,75 @@ export function createHttpClient(config) {
|
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
function
|
|
71
|
+
function resolvedHeadersFor(actorName, extraHeaders = {}) {
|
|
72
|
+
ensureKnownActor(actorName);
|
|
73
|
+
const headerContext = buildHeaderContext(actorName);
|
|
74
|
+
return buildHeaders(getHeaders, headerContext, extraHeaders);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolvedRawHeadersFor(actorName = null, extraHeaders = {}) {
|
|
78
|
+
ensureKnownActor(actorName);
|
|
79
|
+
const headerContext = buildHeaderContext(actorName);
|
|
80
|
+
return buildHeaders(getRawHeaders, headerContext, extraHeaders);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requestAs(actorName, method, path, body, extraHeaders = {}, options = {}) {
|
|
64
84
|
ensureKnownActor(actorName);
|
|
65
85
|
const url = `${baseUrl}${path}`;
|
|
66
86
|
const headerContext = buildHeaderContext(actorName);
|
|
67
|
-
const headers = buildHeaders(getHeaders, headerContext, extraHeaders
|
|
68
|
-
|
|
87
|
+
const headers = buildHeaders(getHeaders, headerContext, extraHeaders, {
|
|
88
|
+
contentTypeJson: options.contentTypeJson !== false,
|
|
89
|
+
includeDefaultHeaders: options.includeDefaultHeaders !== false,
|
|
90
|
+
});
|
|
91
|
+
return runHttpRequest(method, path, url, body, headers, headerContext.actor, options);
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
function
|
|
94
|
+
function rawAs(actorName, method, path, body, extraHeaders = {}, options = {}) {
|
|
95
|
+
ensureKnownActor(actorName);
|
|
72
96
|
const url = `${baseUrl}${path}`;
|
|
73
|
-
const headerContext = buildHeaderContext(
|
|
74
|
-
const headers = buildHeaders(getRawHeaders, headerContext, extraHeaders
|
|
75
|
-
|
|
97
|
+
const headerContext = buildHeaderContext(actorName);
|
|
98
|
+
const headers = buildHeaders(getRawHeaders, headerContext, extraHeaders, {
|
|
99
|
+
contentTypeJson: options.contentTypeJson !== false,
|
|
100
|
+
includeDefaultHeaders: options.includeDefaultHeaders !== false,
|
|
101
|
+
});
|
|
102
|
+
return runHttpRequest(method, path, url, body, headers, headerContext.actor, options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function raw(method, path, body, extraHeaders = {}) {
|
|
106
|
+
return rawAs(null, method, path, body, extraHeaders);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestMultipart(actorName, method, path, payload, extraHeaders = {}, options = {}) {
|
|
110
|
+
const body = buildMultipartBody(payload);
|
|
111
|
+
return requestAs(actorName, method, path, body, extraHeaders, {
|
|
112
|
+
...options,
|
|
113
|
+
contentTypeJson: false,
|
|
114
|
+
includeDefaultHeaders: false,
|
|
115
|
+
serialize: "raw",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rawMultipart(actorName, method, path, payload, extraHeaders = {}, options = {}) {
|
|
120
|
+
const body = buildMultipartBody(payload);
|
|
121
|
+
return rawAs(actorName, method, path, body, extraHeaders, {
|
|
122
|
+
...options,
|
|
123
|
+
contentTypeJson: false,
|
|
124
|
+
includeDefaultHeaders: false,
|
|
125
|
+
serialize: "raw",
|
|
126
|
+
});
|
|
76
127
|
}
|
|
77
128
|
|
|
78
129
|
function createActorClient(actorName) {
|
|
130
|
+
const actorRawReq = createRawInvoker(actorName);
|
|
131
|
+
|
|
79
132
|
return {
|
|
133
|
+
headers(extraHeaders = {}) {
|
|
134
|
+
return resolvedHeadersFor(actorName, extraHeaders);
|
|
135
|
+
},
|
|
136
|
+
rawHeaders(extraHeaders = {}) {
|
|
137
|
+
return resolvedRawHeadersFor(actorName, extraHeaders);
|
|
138
|
+
},
|
|
139
|
+
rawReq: actorRawReq,
|
|
80
140
|
delete(path, extraHeaders = {}) {
|
|
81
141
|
return requestAs(actorName, "DELETE", path, null, extraHeaders);
|
|
82
142
|
},
|
|
@@ -95,19 +155,98 @@ export function createHttpClient(config) {
|
|
|
95
155
|
request(method, path, body, extraHeaders = {}) {
|
|
96
156
|
return requestAs(actorName, method, path, body, extraHeaders);
|
|
97
157
|
},
|
|
158
|
+
raw(method, path, body, extraHeaders = {}) {
|
|
159
|
+
return actorRawReq(method, path, body, extraHeaders);
|
|
160
|
+
},
|
|
161
|
+
rawDelete(path, extraHeaders = {}) {
|
|
162
|
+
return actorRawReq.delete(path, extraHeaders);
|
|
163
|
+
},
|
|
164
|
+
rawGet(path, extraHeaders = {}) {
|
|
165
|
+
return actorRawReq.get(path, extraHeaders);
|
|
166
|
+
},
|
|
167
|
+
rawPatch(path, body, extraHeaders = {}) {
|
|
168
|
+
return actorRawReq.patch(path, body, extraHeaders);
|
|
169
|
+
},
|
|
170
|
+
rawPost(path, body, extraHeaders = {}) {
|
|
171
|
+
return actorRawReq.post(path, body, extraHeaders);
|
|
172
|
+
},
|
|
173
|
+
rawPut(path, body, extraHeaders = {}) {
|
|
174
|
+
return actorRawReq.put(path, body, extraHeaders);
|
|
175
|
+
},
|
|
176
|
+
multipart: {
|
|
177
|
+
post(path, payload, extraHeaders = {}) {
|
|
178
|
+
return requestMultipart(actorName, "POST", path, payload, extraHeaders);
|
|
179
|
+
},
|
|
180
|
+
put(path, payload, extraHeaders = {}) {
|
|
181
|
+
return requestMultipart(actorName, "PUT", path, payload, extraHeaders);
|
|
182
|
+
},
|
|
183
|
+
patch(path, payload, extraHeaders = {}) {
|
|
184
|
+
return requestMultipart(actorName, "PATCH", path, payload, extraHeaders);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
rawMultipart: {
|
|
188
|
+
post(path, payload, extraHeaders = {}) {
|
|
189
|
+
return actorRawReq.multipart.post(path, payload, extraHeaders);
|
|
190
|
+
},
|
|
191
|
+
put(path, payload, extraHeaders = {}) {
|
|
192
|
+
return actorRawReq.multipart.put(path, payload, extraHeaders);
|
|
193
|
+
},
|
|
194
|
+
patch(path, payload, extraHeaders = {}) {
|
|
195
|
+
return actorRawReq.multipart.patch(path, payload, extraHeaders);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function createRawInvoker(defaultRawActor = null) {
|
|
202
|
+
const invoker = (method, path, body, extraHeaders = {}) =>
|
|
203
|
+
rawAs(defaultRawActor, method, path, body, extraHeaders);
|
|
204
|
+
|
|
205
|
+
invoker.request = (method, path, body, extraHeaders = {}) =>
|
|
206
|
+
rawAs(defaultRawActor, method, path, body, extraHeaders);
|
|
207
|
+
invoker.get = (path, extraHeaders = {}) => rawAs(defaultRawActor, "GET", path, null, extraHeaders);
|
|
208
|
+
invoker.post = (path, body, extraHeaders = {}) => rawAs(defaultRawActor, "POST", path, body, extraHeaders);
|
|
209
|
+
invoker.put = (path, body, extraHeaders = {}) => rawAs(defaultRawActor, "PUT", path, body, extraHeaders);
|
|
210
|
+
invoker.patch = (path, body, extraHeaders = {}) => rawAs(defaultRawActor, "PATCH", path, body, extraHeaders);
|
|
211
|
+
invoker.delete = (path, extraHeaders = {}) =>
|
|
212
|
+
rawAs(defaultRawActor, "DELETE", path, null, extraHeaders);
|
|
213
|
+
invoker.headers = (extraHeaders = {}) => resolvedRawHeadersFor(defaultRawActor, extraHeaders);
|
|
214
|
+
invoker.as = (actorName) => {
|
|
215
|
+
ensureKnownActor(actorName);
|
|
216
|
+
return createRawInvoker(actorName);
|
|
217
|
+
};
|
|
218
|
+
invoker.multipart = {
|
|
219
|
+
post(path, payload, extraHeaders = {}) {
|
|
220
|
+
return rawMultipart(defaultRawActor, "POST", path, payload, extraHeaders);
|
|
221
|
+
},
|
|
222
|
+
put(path, payload, extraHeaders = {}) {
|
|
223
|
+
return rawMultipart(defaultRawActor, "PUT", path, payload, extraHeaders);
|
|
224
|
+
},
|
|
225
|
+
patch(path, payload, extraHeaders = {}) {
|
|
226
|
+
return rawMultipart(defaultRawActor, "PATCH", path, payload, extraHeaders);
|
|
227
|
+
},
|
|
98
228
|
};
|
|
229
|
+
|
|
230
|
+
return invoker;
|
|
99
231
|
}
|
|
100
232
|
|
|
101
233
|
const defaultClient = createActorClient(defaultActor);
|
|
234
|
+
const rawClient = createRawInvoker(null);
|
|
102
235
|
|
|
103
236
|
return {
|
|
104
237
|
rawHttp: http,
|
|
238
|
+
headers(extraHeaders = {}) {
|
|
239
|
+
return resolvedHeadersFor(defaultActor, extraHeaders);
|
|
240
|
+
},
|
|
241
|
+
rawHeaders(actorName = null, extraHeaders = {}) {
|
|
242
|
+
return resolvedRawHeadersFor(actorName, extraHeaders);
|
|
243
|
+
},
|
|
105
244
|
as(actorName) {
|
|
106
245
|
ensureKnownActor(actorName);
|
|
107
246
|
return createActorClient(actorName);
|
|
108
247
|
},
|
|
109
248
|
request: defaultClient.request,
|
|
110
|
-
raw,
|
|
249
|
+
raw: rawClient,
|
|
111
250
|
get(path, extraHeaders = {}) {
|
|
112
251
|
return defaultClient.get(path, extraHeaders);
|
|
113
252
|
},
|
|
@@ -124,19 +263,41 @@ export function createHttpClient(config) {
|
|
|
124
263
|
return defaultClient.delete(path, extraHeaders);
|
|
125
264
|
},
|
|
126
265
|
rawGet(path, extraHeaders = {}) {
|
|
127
|
-
return
|
|
266
|
+
return rawClient.get(path, extraHeaders);
|
|
128
267
|
},
|
|
129
268
|
rawPost(path, body, extraHeaders = {}) {
|
|
130
|
-
return
|
|
269
|
+
return rawClient.post(path, body, extraHeaders);
|
|
131
270
|
},
|
|
132
271
|
rawPut(path, body, extraHeaders = {}) {
|
|
133
|
-
return
|
|
272
|
+
return rawClient.put(path, body, extraHeaders);
|
|
134
273
|
},
|
|
135
274
|
rawPatch(path, body, extraHeaders = {}) {
|
|
136
|
-
return
|
|
275
|
+
return rawClient.patch(path, body, extraHeaders);
|
|
137
276
|
},
|
|
138
277
|
rawDelete(path, extraHeaders = {}) {
|
|
139
|
-
return
|
|
278
|
+
return rawClient.delete(path, extraHeaders);
|
|
279
|
+
},
|
|
280
|
+
multipart: {
|
|
281
|
+
post(path, payload, extraHeaders = {}) {
|
|
282
|
+
return requestMultipart(defaultActor, "POST", path, payload, extraHeaders);
|
|
283
|
+
},
|
|
284
|
+
put(path, payload, extraHeaders = {}) {
|
|
285
|
+
return requestMultipart(defaultActor, "PUT", path, payload, extraHeaders);
|
|
286
|
+
},
|
|
287
|
+
patch(path, payload, extraHeaders = {}) {
|
|
288
|
+
return requestMultipart(defaultActor, "PATCH", path, payload, extraHeaders);
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
rawMultipart: {
|
|
292
|
+
post(path, payload, extraHeaders = {}) {
|
|
293
|
+
return rawClient.multipart.post(path, payload, extraHeaders);
|
|
294
|
+
},
|
|
295
|
+
put(path, payload, extraHeaders = {}) {
|
|
296
|
+
return rawClient.multipart.put(path, payload, extraHeaders);
|
|
297
|
+
},
|
|
298
|
+
patch(path, payload, extraHeaders = {}) {
|
|
299
|
+
return rawClient.multipart.patch(path, payload, extraHeaders);
|
|
300
|
+
},
|
|
140
301
|
},
|
|
141
302
|
};
|
|
142
303
|
}
|
|
@@ -159,7 +320,7 @@ export function makeRawReq(baseUrl, routeHeaders = {}, getRawHeaders = null) {
|
|
|
159
320
|
}).raw;
|
|
160
321
|
}
|
|
161
322
|
|
|
162
|
-
function runHttpRequest(method, path, url, body, headers, actorRecord = null) {
|
|
323
|
+
function runHttpRequest(method, path, url, body, headers, actorRecord = null, requestOptions = {}) {
|
|
163
324
|
const ordinal = nextTraceOrdinal();
|
|
164
325
|
const requestId = buildRequestId(ordinal);
|
|
165
326
|
const finalHeaders = {
|
|
@@ -175,14 +336,15 @@ function runHttpRequest(method, path, url, body, headers, actorRecord = null) {
|
|
|
175
336
|
actorRecord,
|
|
176
337
|
requestHeaders: finalHeaders,
|
|
177
338
|
});
|
|
178
|
-
const
|
|
339
|
+
const transportOptions = { headers: finalHeaders };
|
|
179
340
|
|
|
180
341
|
let rawResponse;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
else if (method === "
|
|
184
|
-
else if (method === "
|
|
185
|
-
else if (method === "
|
|
342
|
+
const requestBody = serializeRequestBody(body, requestOptions.serialize || "json");
|
|
343
|
+
if (method === "GET") rawResponse = http.get(url, transportOptions);
|
|
344
|
+
else if (method === "PUT") rawResponse = http.put(url, requestBody, transportOptions);
|
|
345
|
+
else if (method === "POST") rawResponse = http.post(url, requestBody, transportOptions);
|
|
346
|
+
else if (method === "PATCH") rawResponse = http.patch(url, requestBody, transportOptions);
|
|
347
|
+
else if (method === "DELETE") rawResponse = http.del(url, null, transportOptions);
|
|
186
348
|
else throw new Error(`unsupported method: ${method}`);
|
|
187
349
|
|
|
188
350
|
finalizeTrace(trace, rawResponse);
|
|
@@ -420,3 +582,31 @@ function decodeQueryComponent(value) {
|
|
|
420
582
|
return String(value || "");
|
|
421
583
|
}
|
|
422
584
|
}
|
|
585
|
+
|
|
586
|
+
function serializeRequestBody(body, mode = "json") {
|
|
587
|
+
if (body == null) return body;
|
|
588
|
+
if (mode === "raw") return body;
|
|
589
|
+
if (typeof body === "string") return body;
|
|
590
|
+
return JSON.stringify(body);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function buildMultipartBody(payload = {}) {
|
|
594
|
+
const fields = payload?.fields || {};
|
|
595
|
+
const files = Array.isArray(payload?.files) ? payload.files : [];
|
|
596
|
+
const body = { ...fields };
|
|
597
|
+
|
|
598
|
+
for (const fileEntry of files) {
|
|
599
|
+
if (!fileEntry || typeof fileEntry !== "object") {
|
|
600
|
+
throw new Error("multipart file entries must be objects");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const field = String(fileEntry.field || "").trim();
|
|
604
|
+
if (!field) {
|
|
605
|
+
throw new Error("multipart file entries require a field name");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
body[field] = http.file(fileEntry.data, fileEntry.filename, fileEntry.contentType);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return body;
|
|
612
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockHttp = {
|
|
4
|
+
del: vi.fn(),
|
|
5
|
+
file: vi.fn((data, filename, contentType) => ({
|
|
6
|
+
__testkitFile: true,
|
|
7
|
+
contentType,
|
|
8
|
+
data,
|
|
9
|
+
filename,
|
|
10
|
+
})),
|
|
11
|
+
get: vi.fn(),
|
|
12
|
+
patch: vi.fn(),
|
|
13
|
+
post: vi.fn(),
|
|
14
|
+
put: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
vi.mock("k6/http", () => ({
|
|
18
|
+
default: mockHttp,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("k6", () => ({
|
|
22
|
+
check: vi.fn((value, checks) =>
|
|
23
|
+
Object.values(checks).every((predicate) => predicate(value))
|
|
24
|
+
),
|
|
25
|
+
group: vi.fn((name, fn) => fn()),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("k6/metrics", () => ({
|
|
29
|
+
Rate: class {
|
|
30
|
+
add() {}
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const { createHttpClient } = await import("./http.js");
|
|
35
|
+
|
|
36
|
+
function makeResponse(status = 200, body = { ok: true }) {
|
|
37
|
+
return {
|
|
38
|
+
status,
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeSessionBundle() {
|
|
45
|
+
return {
|
|
46
|
+
primaryActor: "userA",
|
|
47
|
+
actors: {
|
|
48
|
+
userA: {
|
|
49
|
+
actorIndex: 0,
|
|
50
|
+
actorName: "userA",
|
|
51
|
+
email: "testkit+fixture.user-a@example.test",
|
|
52
|
+
jwt: "jwt-user-a",
|
|
53
|
+
organizationId: "org-alpha",
|
|
54
|
+
organizationKey: "primary",
|
|
55
|
+
organizationName: "Primary Org",
|
|
56
|
+
session: {
|
|
57
|
+
jwt: "jwt-user-a",
|
|
58
|
+
organizationId: "org-alpha",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
userB: {
|
|
62
|
+
actorIndex: 1,
|
|
63
|
+
actorName: "userB",
|
|
64
|
+
email: "testkit+fixture.user-b@example.test",
|
|
65
|
+
jwt: "jwt-user-b",
|
|
66
|
+
organizationId: "org-beta",
|
|
67
|
+
organizationKey: "secondary",
|
|
68
|
+
organizationName: "Secondary Org",
|
|
69
|
+
session: {
|
|
70
|
+
jwt: "jwt-user-b",
|
|
71
|
+
organizationId: "org-beta",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("runtime http client", () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
globalThis.__ENV = { TESTKIT_RUNTIME_ID: "unit" };
|
|
81
|
+
mockHttp.del.mockReset();
|
|
82
|
+
mockHttp.file.mockClear();
|
|
83
|
+
mockHttp.get.mockReset();
|
|
84
|
+
mockHttp.patch.mockReset();
|
|
85
|
+
mockHttp.post.mockReset();
|
|
86
|
+
mockHttp.put.mockReset();
|
|
87
|
+
|
|
88
|
+
mockHttp.get.mockImplementation(() => makeResponse());
|
|
89
|
+
mockHttp.post.mockImplementation(() => makeResponse(201));
|
|
90
|
+
mockHttp.put.mockImplementation(() => makeResponse());
|
|
91
|
+
mockHttp.patch.mockImplementation(() => makeResponse());
|
|
92
|
+
mockHttp.del.mockImplementation(() => makeResponse(204));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("resolves actor-aware auth headers and raw headers", () => {
|
|
96
|
+
const client = createHttpClient({
|
|
97
|
+
baseUrl: "http://api.test",
|
|
98
|
+
defaultActor: "userA",
|
|
99
|
+
routeHeaders: { "x-route": "route-a" },
|
|
100
|
+
sessionBundle: makeSessionBundle(),
|
|
101
|
+
getHeaders({ actor }) {
|
|
102
|
+
return actor?.jwt
|
|
103
|
+
? {
|
|
104
|
+
Authorization: `Bearer ${actor.jwt}`,
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
"X-Organization-Id": actor.organizationId,
|
|
107
|
+
}
|
|
108
|
+
: {};
|
|
109
|
+
},
|
|
110
|
+
getRawHeaders({ actor }) {
|
|
111
|
+
return actor?.organizationId
|
|
112
|
+
? {
|
|
113
|
+
"X-Organization-Id": actor.organizationId,
|
|
114
|
+
}
|
|
115
|
+
: {
|
|
116
|
+
"X-Testkit-Mode": "raw",
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(client.headers({ "X-Test": "1" })).toEqual({
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
Authorization: "Bearer jwt-user-a",
|
|
124
|
+
"X-Organization-Id": "org-alpha",
|
|
125
|
+
"x-route": "route-a",
|
|
126
|
+
"X-Test": "1",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(client.raw.headers()).toEqual({
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
"X-Testkit-Mode": "raw",
|
|
132
|
+
"x-route": "route-a",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(client.raw.as("userB").headers({ "X-Trace": "secondary" })).toEqual({
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
"X-Organization-Id": "org-beta",
|
|
138
|
+
"x-route": "route-a",
|
|
139
|
+
"X-Trace": "secondary",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
client.as("userB").rawReq.get("/api/v1/context", { "X-Test": "1" });
|
|
143
|
+
|
|
144
|
+
expect(mockHttp.get).toHaveBeenCalledWith("http://api.test/api/v1/context", {
|
|
145
|
+
headers: expect.objectContaining({
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"X-Organization-Id": "org-beta",
|
|
148
|
+
"x-route": "route-a",
|
|
149
|
+
"X-Test": "1",
|
|
150
|
+
"x-request-id": expect.stringContaining("tk_unit_exec_"),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("builds multipart requests without forcing json content type", () => {
|
|
156
|
+
const client = createHttpClient({
|
|
157
|
+
baseUrl: "http://api.test",
|
|
158
|
+
defaultActor: "userA",
|
|
159
|
+
sessionBundle: makeSessionBundle(),
|
|
160
|
+
getHeaders({ actor }) {
|
|
161
|
+
return actor?.jwt
|
|
162
|
+
? {
|
|
163
|
+
Authorization: `Bearer ${actor.jwt}`,
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
"X-Organization-Id": actor.organizationId,
|
|
166
|
+
}
|
|
167
|
+
: {};
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
client.multipart.post("/api/v1/uploads", {
|
|
172
|
+
fields: { name: "fixture-upload" },
|
|
173
|
+
files: [
|
|
174
|
+
{
|
|
175
|
+
field: "file",
|
|
176
|
+
data: "hello world",
|
|
177
|
+
filename: "fixture.txt",
|
|
178
|
+
contentType: "text/plain",
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(mockHttp.file).toHaveBeenCalledWith("hello world", "fixture.txt", "text/plain");
|
|
184
|
+
expect(mockHttp.post).toHaveBeenCalledWith(
|
|
185
|
+
"http://api.test/api/v1/uploads",
|
|
186
|
+
{
|
|
187
|
+
file: {
|
|
188
|
+
__testkitFile: true,
|
|
189
|
+
contentType: "text/plain",
|
|
190
|
+
data: "hello world",
|
|
191
|
+
filename: "fixture.txt",
|
|
192
|
+
},
|
|
193
|
+
name: "fixture-upload",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
headers: expect.objectContaining({
|
|
197
|
+
Authorization: "Bearer jwt-user-a",
|
|
198
|
+
"X-Organization-Id": "org-alpha",
|
|
199
|
+
"x-request-id": expect.stringContaining("tk_unit_exec_"),
|
|
200
|
+
}),
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
expect(mockHttp.post.mock.calls[0][2].headers["Content-Type"]).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function extractErrorMessageFromBody(body) {
|
|
2
|
+
if (!body || typeof body !== "object") return null;
|
|
3
|
+
|
|
4
|
+
if ("error" in body) {
|
|
5
|
+
const error = body.error;
|
|
6
|
+
if (typeof error === "string" && error.length > 0) return error;
|
|
7
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
8
|
+
const message = error.message;
|
|
9
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if ("message" in body) {
|
|
14
|
+
const message = body.message;
|
|
15
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hasStandardErrorShape(body) {
|
|
22
|
+
if (!body || typeof body !== "object") return false;
|
|
23
|
+
const error = body.error;
|
|
24
|
+
if (!error || typeof error !== "object") return false;
|
|
25
|
+
return typeof error.code === "string" && typeof error.message === "string";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deriveDeterministicIp(seed, offset = 0) {
|
|
29
|
+
const hash = numericSeed(`${seed}:${offset}`);
|
|
30
|
+
const thirdOctet = (hash % 250) + 1;
|
|
31
|
+
const fourthOctet = (Math.floor(hash / 250) % 250) + 1;
|
|
32
|
+
return `198.51.${thirdOctet}.${fourthOctet}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function numericSeed(seed) {
|
|
36
|
+
let hash = 0;
|
|
37
|
+
const text = String(seed);
|
|
38
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
39
|
+
hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
|
|
40
|
+
}
|
|
41
|
+
return hash;
|
|
42
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export function parseJsonBody(response) {
|
|
2
|
+
return JSON.parse(response.body);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function extractCookie(response, cookieName) {
|
|
6
|
+
const cookies = Object.entries(response?.headers || {})
|
|
7
|
+
.filter(([name]) => String(name).toLowerCase() === "set-cookie")
|
|
8
|
+
.flatMap(([, value]) => (Array.isArray(value) ? value : value ? [value] : []));
|
|
9
|
+
|
|
10
|
+
if (cookies.length === 0) return null;
|
|
11
|
+
|
|
12
|
+
const cookiePattern = new RegExp(`(?:^|,\\s*)${escapeRegExp(cookieName)}=([^;,]+)`);
|
|
13
|
+
for (const headerValue of cookies) {
|
|
14
|
+
const match = cookiePattern.exec(String(headerValue));
|
|
15
|
+
if (match?.[1]) {
|
|
16
|
+
return match[1];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseSseEvents(body) {
|
|
24
|
+
return String(body || "")
|
|
25
|
+
.split(/\n\n+/)
|
|
26
|
+
.map((block) => block.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((block) => {
|
|
29
|
+
const lines = block.split("\n");
|
|
30
|
+
let event = null;
|
|
31
|
+
const dataLines = [];
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (line.startsWith("event:")) {
|
|
35
|
+
event = line.slice("event:".length).trim() || null;
|
|
36
|
+
}
|
|
37
|
+
if (line.startsWith("data:")) {
|
|
38
|
+
dataLines.push(line.slice("data:".length).trim());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rawData = dataLines.join("\n");
|
|
43
|
+
if (!rawData) {
|
|
44
|
+
return { event, data: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return {
|
|
49
|
+
event,
|
|
50
|
+
data: JSON.parse(rawData),
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return {
|
|
54
|
+
event,
|
|
55
|
+
data: rawData,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getSseEventData(body, eventName) {
|
|
62
|
+
const match = parseSseEvents(body).find((event) => event.event === eventName);
|
|
63
|
+
return match?.data ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeRegExp(value) {
|
|
67
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
68
|
+
}
|