@elench/testkit 0.1.81 → 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 +64 -27
- 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 +767 -0
- package/lib/config-api/index.d.ts +92 -108
- package/lib/config-api/index.mjs +22 -12
- package/lib/config-api/index.test.mjs +103 -210
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +34 -10
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +177 -27
- package/lib/runtime/index.mjs +68 -3
- 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 +151 -0
- package/lib/runtime-src/k6/http.js +285 -56
- package/lib/runtime-src/k6/http.test.mjs +205 -0
- package/lib/runtime-src/k6/scenario-suite.js +13 -110
- package/lib/runtime-src/k6/suite.js +13 -107
- 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
- package/lib/config-api/profiles.mjs +0 -640
|
@@ -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
|
+
});
|
|
@@ -7,17 +7,22 @@ import {
|
|
|
7
7
|
startFailureCollection,
|
|
8
8
|
} from "./checks.js";
|
|
9
9
|
import {
|
|
10
|
-
createHttpClient,
|
|
11
10
|
emitHttpTraceCollectionArtifact,
|
|
12
|
-
getEnv,
|
|
13
11
|
startHttpTraceCollection,
|
|
14
12
|
} from "./http.js";
|
|
15
13
|
import {
|
|
16
14
|
clearRuntimeContext,
|
|
17
15
|
registerRuntimeContext,
|
|
18
|
-
resolveHttpProfile,
|
|
19
16
|
} from "../../config-api/runtime.mjs";
|
|
20
17
|
import { createScenarioRuntime } from "./scenario-runtime.js";
|
|
18
|
+
import {
|
|
19
|
+
buildRuntimeExceptionDetail,
|
|
20
|
+
createSuiteActors,
|
|
21
|
+
formatFatalSuiteError,
|
|
22
|
+
mergeProfileConfig,
|
|
23
|
+
normalizeSuiteArgs,
|
|
24
|
+
resolveRuntimeConfig,
|
|
25
|
+
} from "./http-suite-runtime.js";
|
|
21
26
|
|
|
22
27
|
export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
23
28
|
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
@@ -45,7 +50,8 @@ export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
|
45
50
|
}
|
|
46
51
|
},
|
|
47
52
|
exec(setupData) {
|
|
48
|
-
const resolved = resolveRuntimeConfig(config);
|
|
53
|
+
const resolved = resolveRuntimeConfig(config, setupData);
|
|
54
|
+
const actorContext = createSuiteActors(setupData, resolved.client, resolved.profileMeta);
|
|
49
55
|
const scenario = createScenarioRuntime({
|
|
50
56
|
seed: resolved.env.rawEnv.TESTKIT_SCENARIO_SEED,
|
|
51
57
|
});
|
|
@@ -54,12 +60,11 @@ export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
|
54
60
|
try {
|
|
55
61
|
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
56
62
|
return run({
|
|
63
|
+
actor: actorContext.actor,
|
|
64
|
+
actors: actorContext.actors,
|
|
57
65
|
env: resolved.env,
|
|
58
|
-
req: resolved.client
|
|
66
|
+
req: resolved.client,
|
|
59
67
|
rawReq: resolved.client.raw,
|
|
60
|
-
getWithHeaders: resolved.client.getWithHeaders,
|
|
61
|
-
setupData,
|
|
62
|
-
session: setupData,
|
|
63
68
|
scenario,
|
|
64
69
|
});
|
|
65
70
|
} catch (error) {
|
|
@@ -75,105 +80,3 @@ export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
|
75
80
|
},
|
|
76
81
|
};
|
|
77
82
|
}
|
|
78
|
-
|
|
79
|
-
function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
80
|
-
if (typeof configOrRun === "function") {
|
|
81
|
-
return { config: {}, run: configOrRun };
|
|
82
|
-
}
|
|
83
|
-
if (typeof maybeRun !== "function") {
|
|
84
|
-
throw new Error("suite factory requires a run callback");
|
|
85
|
-
}
|
|
86
|
-
return { config: configOrRun || {}, run: maybeRun };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function callHeaders(builder, setupData, env) {
|
|
90
|
-
if (typeof builder !== "function") return {};
|
|
91
|
-
return builder(setupData, { env }) || {};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function mergeProfileConfig(config) {
|
|
95
|
-
if (!config?.profile) return config || {};
|
|
96
|
-
|
|
97
|
-
const profile = resolveHttpProfile(config.profile) || {};
|
|
98
|
-
return {
|
|
99
|
-
...profile,
|
|
100
|
-
...config,
|
|
101
|
-
auth: config.auth ?? profile.auth ?? null,
|
|
102
|
-
headers: config.headers ?? profile.headers,
|
|
103
|
-
rawHeaders: config.rawHeaders ?? profile.rawHeaders,
|
|
104
|
-
options: config.options ?? profile.options,
|
|
105
|
-
env: config.env ?? profile.env,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function resolveRuntimeConfig(config) {
|
|
110
|
-
const resolvedConfig = mergeProfileConfig(config);
|
|
111
|
-
const env = {
|
|
112
|
-
...(resolvedConfig.env || getEnv()),
|
|
113
|
-
rawEnv: __ENV,
|
|
114
|
-
};
|
|
115
|
-
const auth = resolvedConfig.auth || null;
|
|
116
|
-
const client = createHttpClient({
|
|
117
|
-
baseUrl: env.BASE,
|
|
118
|
-
routeHeaders: env.routeParams,
|
|
119
|
-
getHeaders(setupData) {
|
|
120
|
-
return {
|
|
121
|
-
...callHeaders(auth?.headers, setupData, env),
|
|
122
|
-
...callHeaders(resolvedConfig.headers, setupData, env),
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
getRawHeaders(setupData) {
|
|
126
|
-
return callHeaders(resolvedConfig.rawHeaders, setupData, env);
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
resolvedConfig,
|
|
132
|
-
env,
|
|
133
|
-
auth,
|
|
134
|
-
client,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function formatFatalSuiteError(phase, error) {
|
|
139
|
-
if (error instanceof Error) {
|
|
140
|
-
return `Uncaught testkit suite error during ${phase}: ${error.message}`;
|
|
141
|
-
}
|
|
142
|
-
return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function buildRuntimeExceptionDetail(phase, error) {
|
|
146
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
-
const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : "";
|
|
148
|
-
const location = extractLocationFromStack(stack);
|
|
149
|
-
return {
|
|
150
|
-
kind: "runtime-exception",
|
|
151
|
-
key: location
|
|
152
|
-
? `${location.path}:${location.line}:${location.column}`
|
|
153
|
-
: `runtime-exception:${phase}:${message}`,
|
|
154
|
-
title: "Uncaught runtime exception",
|
|
155
|
-
message: `Uncaught testkit suite error during ${phase}: ${message}`,
|
|
156
|
-
location,
|
|
157
|
-
stack,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function extractLocationFromStack(stack) {
|
|
162
|
-
if (!stack) return null;
|
|
163
|
-
const matches = [...String(stack).matchAll(/(file:\/\/[^\s)]+|\/[^\s):]+):(\d+):(\d+)/g)].map(
|
|
164
|
-
(match) => ({
|
|
165
|
-
path: normalizeStackPath(match[1]),
|
|
166
|
-
line: Number(match[2]),
|
|
167
|
-
column: Number(match[3]),
|
|
168
|
-
})
|
|
169
|
-
);
|
|
170
|
-
return matches[0] || null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function normalizeStackPath(rawPath) {
|
|
174
|
-
if (typeof rawPath !== "string") return rawPath;
|
|
175
|
-
if (rawPath.startsWith("file://")) {
|
|
176
|
-
return rawPath.slice("file://".length);
|
|
177
|
-
}
|
|
178
|
-
return rawPath;
|
|
179
|
-
}
|
|
@@ -7,16 +7,21 @@ import {
|
|
|
7
7
|
startFailureCollection,
|
|
8
8
|
} from "./checks.js";
|
|
9
9
|
import {
|
|
10
|
-
createHttpClient,
|
|
11
10
|
emitHttpTraceCollectionArtifact,
|
|
12
|
-
getEnv,
|
|
13
11
|
startHttpTraceCollection,
|
|
14
12
|
} from "./http.js";
|
|
15
13
|
import {
|
|
16
14
|
clearRuntimeContext,
|
|
17
15
|
registerRuntimeContext,
|
|
18
|
-
resolveHttpProfile,
|
|
19
16
|
} from "../../config-api/runtime.mjs";
|
|
17
|
+
import {
|
|
18
|
+
buildRuntimeExceptionDetail,
|
|
19
|
+
createSuiteActors,
|
|
20
|
+
formatFatalSuiteError,
|
|
21
|
+
mergeProfileConfig,
|
|
22
|
+
normalizeSuiteArgs,
|
|
23
|
+
resolveRuntimeConfig,
|
|
24
|
+
} from "./http-suite-runtime.js";
|
|
20
25
|
|
|
21
26
|
export function defineHttpSuite(configOrRun, maybeRun) {
|
|
22
27
|
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
@@ -44,18 +49,18 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
44
49
|
}
|
|
45
50
|
},
|
|
46
51
|
exec(setupData) {
|
|
47
|
-
const resolved = resolveRuntimeConfig(config);
|
|
52
|
+
const resolved = resolveRuntimeConfig(config, setupData);
|
|
53
|
+
const actorContext = createSuiteActors(setupData, resolved.client, resolved.profileMeta);
|
|
48
54
|
startFailureCollection("exec");
|
|
49
55
|
startHttpTraceCollection("exec");
|
|
50
56
|
try {
|
|
51
57
|
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
52
58
|
return run({
|
|
59
|
+
actor: actorContext.actor,
|
|
60
|
+
actors: actorContext.actors,
|
|
53
61
|
env: resolved.env,
|
|
54
|
-
req: resolved.client
|
|
62
|
+
req: resolved.client,
|
|
55
63
|
rawReq: resolved.client.raw,
|
|
56
|
-
getWithHeaders: resolved.client.getWithHeaders,
|
|
57
|
-
setupData,
|
|
58
|
-
session: setupData,
|
|
59
64
|
});
|
|
60
65
|
} catch (error) {
|
|
61
66
|
recordFailureDetail(buildRuntimeExceptionDetail("exec", error));
|
|
@@ -69,102 +74,3 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
69
74
|
},
|
|
70
75
|
};
|
|
71
76
|
}
|
|
72
|
-
|
|
73
|
-
function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
74
|
-
if (typeof configOrRun === "function") {
|
|
75
|
-
return { config: {}, run: configOrRun };
|
|
76
|
-
}
|
|
77
|
-
if (typeof maybeRun !== "function") {
|
|
78
|
-
throw new Error("suite factory requires a run callback");
|
|
79
|
-
}
|
|
80
|
-
return { config: configOrRun || {}, run: maybeRun };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function callHeaders(builder, setupData, env) {
|
|
84
|
-
if (typeof builder !== "function") return {};
|
|
85
|
-
return builder(setupData, { env }) || {};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function mergeProfileConfig(config) {
|
|
89
|
-
if (!config?.profile) return config || {};
|
|
90
|
-
|
|
91
|
-
const profile = resolveHttpProfile(config.profile) || {};
|
|
92
|
-
return {
|
|
93
|
-
...profile,
|
|
94
|
-
...config,
|
|
95
|
-
auth: config.auth ?? profile.auth ?? null,
|
|
96
|
-
headers: config.headers ?? profile.headers,
|
|
97
|
-
rawHeaders: config.rawHeaders ?? profile.rawHeaders,
|
|
98
|
-
options: config.options ?? profile.options,
|
|
99
|
-
env: config.env ?? profile.env,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function resolveRuntimeConfig(config) {
|
|
104
|
-
const resolvedConfig = mergeProfileConfig(config);
|
|
105
|
-
const env = {
|
|
106
|
-
...(resolvedConfig.env || getEnv()),
|
|
107
|
-
rawEnv: __ENV,
|
|
108
|
-
};
|
|
109
|
-
const auth = resolvedConfig.auth || null;
|
|
110
|
-
const client = createHttpClient({
|
|
111
|
-
baseUrl: env.BASE,
|
|
112
|
-
routeHeaders: env.routeParams,
|
|
113
|
-
getHeaders(setupData) {
|
|
114
|
-
return {
|
|
115
|
-
...callHeaders(auth?.headers, setupData, env),
|
|
116
|
-
...callHeaders(resolvedConfig.headers, setupData, env),
|
|
117
|
-
};
|
|
118
|
-
},
|
|
119
|
-
getRawHeaders(setupData) {
|
|
120
|
-
return callHeaders(resolvedConfig.rawHeaders, setupData, env);
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
resolvedConfig,
|
|
126
|
-
env,
|
|
127
|
-
auth,
|
|
128
|
-
client,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function formatFatalSuiteError(phase, error) {
|
|
133
|
-
if (error instanceof Error) {
|
|
134
|
-
return `Uncaught testkit suite error during ${phase}: ${error.message}`;
|
|
135
|
-
}
|
|
136
|
-
return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
|
|
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
|
-
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
extractCookie,
|
|
4
|
+
getSseEventData,
|
|
5
|
+
parseJsonBody,
|
|
6
|
+
parseSseEvents,
|
|
7
|
+
} from "./http-parsing.mjs";
|
|
8
|
+
import {
|
|
9
|
+
deriveDeterministicIp,
|
|
10
|
+
extractErrorMessageFromBody,
|
|
11
|
+
hasStandardErrorShape,
|
|
12
|
+
} from "./error-body.mjs";
|
|
13
|
+
|
|
14
|
+
describe("runtime shared parsing", () => {
|
|
15
|
+
it("extracts cookies from set-cookie headers", () => {
|
|
16
|
+
const response = {
|
|
17
|
+
headers: {
|
|
18
|
+
"set-cookie": [
|
|
19
|
+
"fixture_session=jwt-primary; Path=/",
|
|
20
|
+
"fixture_refresh=refresh-primary; Path=/",
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
expect(extractCookie(response, "fixture_session")).toBe("jwt-primary");
|
|
26
|
+
expect(extractCookie(response, "fixture_refresh")).toBe("refresh-primary");
|
|
27
|
+
expect(extractCookie(response, "missing")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses JSON response bodies", () => {
|
|
31
|
+
expect(parseJsonBody({ body: "{\"ok\":true}" })).toEqual({ ok: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("parses SSE events and extracts typed event data", () => {
|
|
35
|
+
const body = [
|
|
36
|
+
"event: progress",
|
|
37
|
+
"data: {\"step\":1}",
|
|
38
|
+
"",
|
|
39
|
+
"event: complete",
|
|
40
|
+
"data: {\"success\":true}",
|
|
41
|
+
"",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
expect(parseSseEvents(body)).toEqual([
|
|
45
|
+
{ event: "progress", data: { step: 1 } },
|
|
46
|
+
{ event: "complete", data: { success: true } },
|
|
47
|
+
]);
|
|
48
|
+
expect(getSseEventData(body, "complete")).toEqual({ success: true });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("runtime shared error helpers", () => {
|
|
53
|
+
it("extracts human-readable error messages", () => {
|
|
54
|
+
expect(extractErrorMessageFromBody({ error: "bad request" })).toBe("bad request");
|
|
55
|
+
expect(extractErrorMessageFromBody({ error: { message: "nested" } })).toBe("nested");
|
|
56
|
+
expect(extractErrorMessageFromBody({ message: "top-level" })).toBe("top-level");
|
|
57
|
+
expect(extractErrorMessageFromBody({ ok: true })).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("detects the standard structured error shape", () => {
|
|
61
|
+
expect(hasStandardErrorShape({ error: { code: "BAD", message: "boom" } })).toBe(true);
|
|
62
|
+
expect(hasStandardErrorShape({ error: { message: "boom" } })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("derives deterministic test-network IPs", () => {
|
|
66
|
+
expect(deriveDeterministicIp("bourne", 1)).toBe(deriveDeterministicIp("bourne", 1));
|
|
67
|
+
expect(deriveDeterministicIp("bourne", 1)).not.toBe(deriveDeterministicIp("bourne", 2));
|
|
68
|
+
});
|
|
69
|
+
});
|