@elench/testkit 0.1.55 → 0.1.57
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 +81 -0
- package/lib/bundler/index.mjs +1 -1
- package/lib/bundler/index.test.mjs +29 -0
- package/lib/cli/args.mjs +2 -2
- package/lib/cli/args.test.mjs +8 -2
- package/lib/cli/command-helpers.mjs +5 -1
- package/lib/cli/commands/discover.mjs +80 -0
- package/lib/cli/commands/run.mjs +2 -2
- package/lib/cli/entrypoint.mjs +3 -1
- package/lib/cli/presentation/colors.mjs +32 -0
- package/lib/cli/presentation/discovery-reporter.mjs +166 -0
- package/lib/cli/viewer.mjs +30 -0
- package/lib/config/discovery.mjs +107 -45
- package/lib/config/discovery.test.mjs +83 -1
- package/lib/config/index.mjs +21 -3
- package/lib/discovery/index.d.ts +121 -0
- package/lib/discovery/index.mjs +540 -0
- package/lib/discovery/index.test.mjs +182 -0
- package/lib/history/index.d.ts +46 -0
- package/lib/history/index.mjs +166 -0
- package/lib/history/index.test.mjs +115 -0
- package/lib/index.d.ts +58 -0
- package/lib/index.mjs +3 -0
- package/lib/package.test.mjs +5 -0
- package/lib/runner/default-runtime-runner.mjs +4 -1
- package/lib/runner/orchestrator.mjs +21 -8
- package/lib/runner/planning.mjs +1 -1
- package/lib/runner/reporting.mjs +6 -0
- package/lib/runner/reporting.test.mjs +5 -0
- package/lib/runner/suite-selection.mjs +4 -4
- package/lib/runner/suite-selection.test.mjs +9 -2
- package/lib/runner/worker-loop.mjs +1 -1
- package/lib/runtime-src/k6/checks.js +9 -0
- package/lib/runtime-src/k6/scenario-runtime.js +234 -0
- package/lib/runtime-src/k6/scenario-suite.js +179 -0
- package/package.json +5 -1
package/lib/runner/planning.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
suiteSelectionType,
|
|
6
6
|
} from "./suite-selection.mjs";
|
|
7
7
|
|
|
8
|
-
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
8
|
+
const TYPE_ORDER = ["dal", "integration", "e2e", "scenario", "load"];
|
|
9
9
|
|
|
10
10
|
export function taskNeedsLocalRuntime(task) {
|
|
11
11
|
return task.type !== "dal";
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -8,6 +8,7 @@ export function buildStatusArtifact({
|
|
|
8
8
|
fileNames,
|
|
9
9
|
shard,
|
|
10
10
|
serviceFilter,
|
|
11
|
+
scenarioSeed,
|
|
11
12
|
metadata,
|
|
12
13
|
}) {
|
|
13
14
|
const executedResults = results.filter((result) => !result.skipped);
|
|
@@ -68,6 +69,7 @@ export function buildStatusArtifact({
|
|
|
68
69
|
fileNames: [...(fileNames || [])].sort(),
|
|
69
70
|
shard: shard || null,
|
|
70
71
|
serviceFilter: serviceFilter || null,
|
|
72
|
+
scenarioSeed: scenarioSeed || null,
|
|
71
73
|
};
|
|
72
74
|
scope.isFullRun =
|
|
73
75
|
scope.types.length === 1 &&
|
|
@@ -109,6 +111,7 @@ export function buildRunArtifact({
|
|
|
109
111
|
fileNames,
|
|
110
112
|
shard,
|
|
111
113
|
serviceFilter,
|
|
114
|
+
scenarioSeed,
|
|
112
115
|
metadata,
|
|
113
116
|
summarizeDbBackend,
|
|
114
117
|
serviceLogs = [],
|
|
@@ -156,6 +159,7 @@ export function buildRunArtifact({
|
|
|
156
159
|
fileNames,
|
|
157
160
|
shard,
|
|
158
161
|
serviceFilter,
|
|
162
|
+
scenarioSeed: scenarioSeed || null,
|
|
159
163
|
testkitVersion: metadata.testkitVersion,
|
|
160
164
|
},
|
|
161
165
|
summary: {
|
|
@@ -224,6 +228,7 @@ export function buildLiveRunArtifact({
|
|
|
224
228
|
fileNames,
|
|
225
229
|
shard,
|
|
226
230
|
serviceFilter,
|
|
231
|
+
scenarioSeed,
|
|
227
232
|
metadata,
|
|
228
233
|
summarizeDbBackend,
|
|
229
234
|
serviceLogs = [],
|
|
@@ -244,6 +249,7 @@ export function buildLiveRunArtifact({
|
|
|
244
249
|
fileNames,
|
|
245
250
|
shard,
|
|
246
251
|
serviceFilter,
|
|
252
|
+
scenarioSeed,
|
|
247
253
|
metadata,
|
|
248
254
|
summarizeDbBackend,
|
|
249
255
|
serviceLogs,
|
|
@@ -62,6 +62,7 @@ describe("runner reporting", () => {
|
|
|
62
62
|
fileNames: [],
|
|
63
63
|
shard: null,
|
|
64
64
|
serviceFilter: null,
|
|
65
|
+
scenarioSeed: "demo-seed",
|
|
65
66
|
metadata: {
|
|
66
67
|
git: {
|
|
67
68
|
branch: "main",
|
|
@@ -84,6 +85,7 @@ describe("runner reporting", () => {
|
|
|
84
85
|
fileTimeoutSeconds: 60,
|
|
85
86
|
workerCount: 1,
|
|
86
87
|
runtimeInstanceCount: 2,
|
|
88
|
+
scenarioSeed: "demo-seed",
|
|
87
89
|
});
|
|
88
90
|
expect(artifact.summary.services).toEqual({
|
|
89
91
|
total: 1,
|
|
@@ -220,6 +222,7 @@ describe("runner reporting", () => {
|
|
|
220
222
|
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
221
223
|
shard: null,
|
|
222
224
|
serviceFilter: "api",
|
|
225
|
+
scenarioSeed: "demo-seed",
|
|
223
226
|
metadata: {
|
|
224
227
|
git: {
|
|
225
228
|
branch: "main",
|
|
@@ -247,6 +250,7 @@ describe("runner reporting", () => {
|
|
|
247
250
|
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
248
251
|
shard: null,
|
|
249
252
|
serviceFilter: "api",
|
|
253
|
+
scenarioSeed: "demo-seed",
|
|
250
254
|
isFullRun: false,
|
|
251
255
|
},
|
|
252
256
|
summary: {
|
|
@@ -291,6 +295,7 @@ describe("runner reporting", () => {
|
|
|
291
295
|
fileNames: [],
|
|
292
296
|
shard: null,
|
|
293
297
|
serviceFilter: null,
|
|
298
|
+
scenarioSeed: null,
|
|
294
299
|
metadata: {
|
|
295
300
|
git: {
|
|
296
301
|
branch: "main",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const USER_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
|
|
1
|
+
const USER_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
2
2
|
|
|
3
3
|
export function normalizeTypeValues(values = []) {
|
|
4
4
|
const expanded = [];
|
|
@@ -9,7 +9,7 @@ export function normalizeTypeValues(values = []) {
|
|
|
9
9
|
if (!value) continue;
|
|
10
10
|
if (!USER_TYPES.has(value)) {
|
|
11
11
|
throw new Error(
|
|
12
|
-
`Unknown type "${value}". Expected one of: int, e2e, dal, load, pw, all.`
|
|
12
|
+
`Unknown type "${value}". Expected one of: int, e2e, scenario, dal, load, pw, all.`
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
expanded.push(value);
|
|
@@ -25,7 +25,7 @@ export function normalizeTypeValues(values = []) {
|
|
|
25
25
|
throw new Error(`"--type all" cannot be combined with other types.`);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const order = ["int", "e2e", "dal", "load", "pw", "all"];
|
|
28
|
+
const order = ["int", "e2e", "scenario", "dal", "load", "pw", "all"];
|
|
29
29
|
return deduped.sort((left, right) => order.indexOf(left) - order.indexOf(right));
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -52,7 +52,7 @@ export function parseSuiteSelectors(values = []) {
|
|
|
52
52
|
const name = typeMatch[2].trim();
|
|
53
53
|
if (!USER_TYPES.has(type) || type === "all") {
|
|
54
54
|
throw new Error(
|
|
55
|
-
`Unknown suite selector type "${type}". Expected one of: int, e2e, dal, load, pw.`
|
|
55
|
+
`Unknown suite selector type "${type}". Expected one of: int, e2e, scenario, dal, load, pw.`
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
if (!name) {
|
|
@@ -11,14 +11,20 @@ import {
|
|
|
11
11
|
describe("runner suite selection", () => {
|
|
12
12
|
it("normalizes selected type values", () => {
|
|
13
13
|
expect(normalizeTypeValues([])).toEqual(["all"]);
|
|
14
|
-
expect(normalizeTypeValues(["int,e2e", "dal"])).toEqual([
|
|
14
|
+
expect(normalizeTypeValues(["int,e2e,scenario", "dal"])).toEqual([
|
|
15
|
+
"int",
|
|
16
|
+
"e2e",
|
|
17
|
+
"scenario",
|
|
18
|
+
"dal",
|
|
19
|
+
]);
|
|
15
20
|
expect(() => normalizeTypeValues(["all", "int"])).toThrow("cannot be combined");
|
|
16
21
|
expect(() => normalizeTypeValues(["jest"])).toThrow("Unknown type");
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
it("parses suite selectors", () => {
|
|
20
|
-
expect(parseSuiteSelectors(["auth,dal:queries"])).toEqual([
|
|
25
|
+
expect(parseSuiteSelectors(["auth,scenario:journeys,dal:queries"])).toEqual([
|
|
21
26
|
{ kind: "plain", name: "auth", raw: "auth" },
|
|
27
|
+
{ kind: "typed", type: "scenario", name: "journeys", raw: "scenario:journeys" },
|
|
22
28
|
{ kind: "typed", type: "dal", name: "queries", raw: "dal:queries" },
|
|
23
29
|
]);
|
|
24
30
|
expect(() => parseSuiteSelectors(["all:auth"])).toThrow("Unknown suite selector type");
|
|
@@ -36,6 +42,7 @@ describe("runner suite selection", () => {
|
|
|
36
42
|
|
|
37
43
|
it("maps discovered suites to user-facing selection types", () => {
|
|
38
44
|
expect(suiteSelectionType("integration", "k6")).toBe("int");
|
|
45
|
+
expect(suiteSelectionType("scenario", "k6")).toBe("scenario");
|
|
39
46
|
expect(suiteSelectionType("e2e", "playwright")).toBe("pw");
|
|
40
47
|
expect(suiteSelectionType("dal", "k6")).toBe("dal");
|
|
41
48
|
});
|
|
@@ -2,7 +2,7 @@ import { formatError } from "./formatting.mjs";
|
|
|
2
2
|
import { runDalTask, runHttpK6Task } from "./default-runtime-runner.mjs";
|
|
3
3
|
import { runPlaywrightTask } from "./playwright-runner.mjs";
|
|
4
4
|
|
|
5
|
-
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
5
|
+
const HTTP_K6_TYPES = new Set(["integration", "e2e", "scenario", "load"]);
|
|
6
6
|
|
|
7
7
|
export function createWorker(workerId, productDir) {
|
|
8
8
|
return {
|
|
@@ -136,6 +136,15 @@ export function recordFailureDetail(detail) {
|
|
|
136
136
|
existing.count += normalized.count;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
export function getFailureCollectionSnapshot() {
|
|
140
|
+
return {
|
|
141
|
+
phase: failureState.phase,
|
|
142
|
+
groupStack: [...failureState.groupStack],
|
|
143
|
+
failureCount: failureState.detailsByKey.size,
|
|
144
|
+
keys: [...failureState.detailsByKey.keys()].sort(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
139
148
|
function createFailureState() {
|
|
140
149
|
return {
|
|
141
150
|
phase: "exec",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { emitArtifact } from "./artifacts.js";
|
|
2
|
+
import { getFailureCollectionSnapshot, group } from "./checks.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SCENARIO_SEED = "default";
|
|
5
|
+
|
|
6
|
+
export function createScenarioRuntime(options = {}) {
|
|
7
|
+
const suiteSeed = normalizeSeed(options.seed);
|
|
8
|
+
const state = {
|
|
9
|
+
seed: suiteSeed,
|
|
10
|
+
scenarioName: null,
|
|
11
|
+
choices: {},
|
|
12
|
+
notes: {},
|
|
13
|
+
resources: [],
|
|
14
|
+
resourceState: new Map(),
|
|
15
|
+
steps: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
get seed() {
|
|
20
|
+
return state.seed;
|
|
21
|
+
},
|
|
22
|
+
get scenarioName() {
|
|
23
|
+
return state.scenarioName;
|
|
24
|
+
},
|
|
25
|
+
resource(name, factory, options = {}) {
|
|
26
|
+
return createScenarioResource(state, name, factory, options);
|
|
27
|
+
},
|
|
28
|
+
pick(name, choices) {
|
|
29
|
+
return pickChoice(state, name, choices);
|
|
30
|
+
},
|
|
31
|
+
maybe(name, probability = 0.5) {
|
|
32
|
+
return maybeChoice(state, name, probability);
|
|
33
|
+
},
|
|
34
|
+
choose(name, shapeOrChoices) {
|
|
35
|
+
return chooseScenario(state, name, shapeOrChoices);
|
|
36
|
+
},
|
|
37
|
+
note(name, value) {
|
|
38
|
+
const normalizedName = normalizeLabel(name, "note");
|
|
39
|
+
state.notes[normalizedName] = cloneForArtifact(value);
|
|
40
|
+
return value;
|
|
41
|
+
},
|
|
42
|
+
step(name, fn) {
|
|
43
|
+
return runScenarioStep(state, name, fn);
|
|
44
|
+
},
|
|
45
|
+
emitArtifact() {
|
|
46
|
+
emitScenarioArtifact(state);
|
|
47
|
+
},
|
|
48
|
+
snapshot() {
|
|
49
|
+
return buildScenarioArtifactPayload(state);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createScenarioResource(state, name, factory, options = {}) {
|
|
55
|
+
const normalizedName = normalizeLabel(name, "resource");
|
|
56
|
+
if (typeof factory !== "function") {
|
|
57
|
+
throw new Error(`scenario.resource("${normalizedName}") requires a factory function`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scope = normalizeResourceScope(options.scope);
|
|
61
|
+
if (!state.resources.some((entry) => entry.name === normalizedName)) {
|
|
62
|
+
state.resources.push({ name: normalizedName, scope });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
get() {
|
|
67
|
+
if (scope === "step") {
|
|
68
|
+
return factory();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (state.resourceState.has(normalizedName)) {
|
|
72
|
+
return state.resourceState.get(normalizedName);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const value = factory();
|
|
76
|
+
state.resourceState.set(normalizedName, value);
|
|
77
|
+
return value;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickChoice(state, name, choices) {
|
|
83
|
+
const normalizedName = normalizeLabel(name, "choice");
|
|
84
|
+
const values = Array.isArray(choices) ? choices : [];
|
|
85
|
+
if (values.length === 0) {
|
|
86
|
+
throw new Error(`scenario.pick("${normalizedName}") requires at least one choice`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const index = chooseIndex(state.seed, state.scenarioName || "scenario", normalizedName, values.length);
|
|
90
|
+
const value = values[index];
|
|
91
|
+
state.choices[normalizedName] = cloneForArtifact(value);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function maybeChoice(state, name, probability = 0.5) {
|
|
96
|
+
const normalizedName = normalizeLabel(name, "choice");
|
|
97
|
+
const numericProbability = Number(probability);
|
|
98
|
+
if (!Number.isFinite(numericProbability) || numericProbability < 0 || numericProbability > 1) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`scenario.maybe("${normalizedName}") probability must be between 0 and 1`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ratio = chooseRatio(state.seed, state.scenarioName || "scenario", normalizedName);
|
|
105
|
+
const value = ratio < numericProbability;
|
|
106
|
+
state.choices[normalizedName] = value;
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function chooseScenario(state, name, shapeOrChoices) {
|
|
111
|
+
const normalizedName = normalizeLabel(name, "scenario");
|
|
112
|
+
state.scenarioName = normalizedName;
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(shapeOrChoices)) {
|
|
115
|
+
const selected = pickChoice(state, `${normalizedName}:variant`, shapeOrChoices);
|
|
116
|
+
return selected;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!shapeOrChoices || typeof shapeOrChoices !== "object") {
|
|
120
|
+
throw new Error(`scenario.choose("${normalizedName}") requires an object or array`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return shapeOrChoices;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runScenarioStep(state, name, fn) {
|
|
127
|
+
const stepName = normalizeLabel(name, "unnamed step");
|
|
128
|
+
if (typeof fn !== "function") {
|
|
129
|
+
throw new Error(`scenario.step("${stepName}") requires a function`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entry = {
|
|
133
|
+
name: stepName,
|
|
134
|
+
startedAt: new Date().toISOString(),
|
|
135
|
+
durationMs: 0,
|
|
136
|
+
status: "passed",
|
|
137
|
+
};
|
|
138
|
+
state.steps.push(entry);
|
|
139
|
+
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
const before = getFailureCollectionSnapshot();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
return group(stepName, () => fn());
|
|
145
|
+
} catch (error) {
|
|
146
|
+
entry.status = "failed";
|
|
147
|
+
entry.error = error instanceof Error ? error.message : String(error);
|
|
148
|
+
throw error;
|
|
149
|
+
} finally {
|
|
150
|
+
const after = getFailureCollectionSnapshot();
|
|
151
|
+
entry.durationMs = Date.now() - startedAt;
|
|
152
|
+
entry.finishedAt = new Date().toISOString();
|
|
153
|
+
if (entry.status !== "failed" && after.failureCount > before.failureCount) {
|
|
154
|
+
entry.status = "failed";
|
|
155
|
+
entry.failureCount = after.failureCount - before.failureCount;
|
|
156
|
+
} else if (after.failureCount > before.failureCount) {
|
|
157
|
+
entry.failureCount = after.failureCount - before.failureCount;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function emitScenarioArtifact(state) {
|
|
163
|
+
const payload = buildScenarioArtifactPayload(state);
|
|
164
|
+
emitArtifact("scenario", payload, {
|
|
165
|
+
kind: "testkit.scenario",
|
|
166
|
+
summary: buildScenarioSummary(payload),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildScenarioArtifactPayload(state) {
|
|
171
|
+
return {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
seed: state.seed,
|
|
174
|
+
scenarioName: state.scenarioName,
|
|
175
|
+
choices: cloneForArtifact(state.choices),
|
|
176
|
+
notes: cloneForArtifact(state.notes),
|
|
177
|
+
resources: state.resources.map((entry) => ({ ...entry })),
|
|
178
|
+
steps: state.steps.map((entry) => ({ ...entry })),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildScenarioSummary(payload) {
|
|
183
|
+
const failedStep = payload.steps.find((entry) => entry.status === "failed");
|
|
184
|
+
if (failedStep) {
|
|
185
|
+
return `${payload.scenarioName || "scenario"} seed=${payload.seed} failed at ${failedStep.name}`;
|
|
186
|
+
}
|
|
187
|
+
return `${payload.scenarioName || "scenario"} seed=${payload.seed} (${payload.steps.length} steps)`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function chooseIndex(seed, scenarioName, choiceName, length) {
|
|
191
|
+
if (length <= 1) return 0;
|
|
192
|
+
const value = hashString(`${seed}:${scenarioName}:${choiceName}`);
|
|
193
|
+
return value % length;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function chooseRatio(seed, scenarioName, choiceName) {
|
|
197
|
+
const value = hashString(`${seed}:${scenarioName}:${choiceName}`);
|
|
198
|
+
return value / 0xffffffff;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hashString(input) {
|
|
202
|
+
let hash = 2166136261;
|
|
203
|
+
const source = String(input);
|
|
204
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
205
|
+
hash ^= source.charCodeAt(index);
|
|
206
|
+
hash = Math.imul(hash, 16777619);
|
|
207
|
+
}
|
|
208
|
+
return hash >>> 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeSeed(value) {
|
|
212
|
+
if (value === undefined || value === null) return DEFAULT_SCENARIO_SEED;
|
|
213
|
+
const normalized = String(value).trim();
|
|
214
|
+
return normalized.length > 0 ? normalized : DEFAULT_SCENARIO_SEED;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeLabel(value, fallback) {
|
|
218
|
+
if (typeof value !== "string") return fallback;
|
|
219
|
+
const normalized = value.trim();
|
|
220
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeResourceScope(value) {
|
|
224
|
+
const normalized = normalizeLabel(value, "scenario");
|
|
225
|
+
if (normalized === "file") return "file";
|
|
226
|
+
if (normalized === "scenario") return "scenario";
|
|
227
|
+
if (normalized === "step") return "step";
|
|
228
|
+
throw new Error(`Unsupported scenario resource scope "${value}"`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cloneForArtifact(value) {
|
|
232
|
+
if (value === undefined) return null;
|
|
233
|
+
return JSON.parse(JSON.stringify(value));
|
|
234
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { fail } from "k6";
|
|
2
|
+
import {
|
|
3
|
+
defaultOptions,
|
|
4
|
+
emitFailureCollectionArtifact,
|
|
5
|
+
recordFailureDetail,
|
|
6
|
+
recordRuntimeFailure,
|
|
7
|
+
startFailureCollection,
|
|
8
|
+
} from "./checks.js";
|
|
9
|
+
import {
|
|
10
|
+
createHttpClient,
|
|
11
|
+
emitHttpTraceCollectionArtifact,
|
|
12
|
+
getEnv,
|
|
13
|
+
startHttpTraceCollection,
|
|
14
|
+
} from "./http.js";
|
|
15
|
+
import {
|
|
16
|
+
clearRuntimeContext,
|
|
17
|
+
registerRuntimeContext,
|
|
18
|
+
resolveHttpProfile,
|
|
19
|
+
} from "../../setup/runtime.mjs";
|
|
20
|
+
import { createScenarioRuntime } from "./scenario-runtime.js";
|
|
21
|
+
|
|
22
|
+
export function defineScenarioSuite(configOrRun, maybeRun) {
|
|
23
|
+
const { config, run } = normalizeSuiteArgs(configOrRun, maybeRun);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
get options() {
|
|
27
|
+
return mergeProfileConfig(config).options || defaultOptions;
|
|
28
|
+
},
|
|
29
|
+
setup() {
|
|
30
|
+
const resolved = resolveRuntimeConfig(config);
|
|
31
|
+
startFailureCollection("setup");
|
|
32
|
+
startHttpTraceCollection("setup");
|
|
33
|
+
try {
|
|
34
|
+
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
35
|
+
if (typeof resolved.auth?.setup !== "function") return null;
|
|
36
|
+
return resolved.auth.setup({ env: resolved.env });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
recordFailureDetail(buildRuntimeExceptionDetail("setup", error));
|
|
39
|
+
recordRuntimeFailure();
|
|
40
|
+
fail(formatFatalSuiteError("setup", error));
|
|
41
|
+
} finally {
|
|
42
|
+
emitFailureCollectionArtifact();
|
|
43
|
+
emitHttpTraceCollectionArtifact();
|
|
44
|
+
clearRuntimeContext();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
exec(setupData) {
|
|
48
|
+
const resolved = resolveRuntimeConfig(config);
|
|
49
|
+
const scenario = createScenarioRuntime({
|
|
50
|
+
seed: resolved.env.rawEnv.TESTKIT_SCENARIO_SEED,
|
|
51
|
+
});
|
|
52
|
+
startFailureCollection("exec");
|
|
53
|
+
startHttpTraceCollection("exec");
|
|
54
|
+
try {
|
|
55
|
+
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
56
|
+
return run({
|
|
57
|
+
env: resolved.env,
|
|
58
|
+
req: resolved.client.request,
|
|
59
|
+
rawReq: resolved.client.raw,
|
|
60
|
+
getWithHeaders: resolved.client.getWithHeaders,
|
|
61
|
+
setupData,
|
|
62
|
+
session: setupData,
|
|
63
|
+
scenario,
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
recordFailureDetail(buildRuntimeExceptionDetail("exec", error));
|
|
67
|
+
recordRuntimeFailure();
|
|
68
|
+
fail(formatFatalSuiteError("exec", error));
|
|
69
|
+
} finally {
|
|
70
|
+
scenario.emitArtifact();
|
|
71
|
+
emitFailureCollectionArtifact();
|
|
72
|
+
emitHttpTraceCollectionArtifact();
|
|
73
|
+
clearRuntimeContext();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.57",
|
|
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",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./lib/runtime/index.d.ts",
|
|
18
18
|
"default": "./lib/runtime/index.mjs"
|
|
19
19
|
},
|
|
20
|
+
"./discovery": {
|
|
21
|
+
"types": "./lib/discovery/index.d.ts",
|
|
22
|
+
"default": "./lib/discovery/index.mjs"
|
|
23
|
+
},
|
|
20
24
|
"./known-failures": {
|
|
21
25
|
"types": "./lib/known-failures/index.d.ts",
|
|
22
26
|
"default": "./lib/known-failures/index.mjs"
|