@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { discoverTests } from "./index.mjs";
|
|
6
|
+
import { buildHistoryTestId, saveHistory } from "../history/index.mjs";
|
|
7
|
+
|
|
8
|
+
const cleanups = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (cleanups.length > 0) {
|
|
12
|
+
cleanups.pop()();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("public discovery", () => {
|
|
17
|
+
it("returns resolved suites/files with human labels and persisted history", async () => {
|
|
18
|
+
const productDir = createProduct();
|
|
19
|
+
writeFile(
|
|
20
|
+
productDir,
|
|
21
|
+
"testkit.setup.ts",
|
|
22
|
+
`
|
|
23
|
+
import { defineTestkitSetup } from "@elench/testkit/setup";
|
|
24
|
+
|
|
25
|
+
export default defineTestkitSetup({
|
|
26
|
+
services: {
|
|
27
|
+
api: {
|
|
28
|
+
local: {
|
|
29
|
+
cwd: ".",
|
|
30
|
+
start: "node server.js",
|
|
31
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
32
|
+
readyUrl: "http://127.0.0.1:3000"
|
|
33
|
+
},
|
|
34
|
+
requirements: {
|
|
35
|
+
files: [
|
|
36
|
+
{
|
|
37
|
+
path: "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
|
|
38
|
+
locks: ["route-lock"]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
frontend: {
|
|
44
|
+
local: {
|
|
45
|
+
cwd: "frontend",
|
|
46
|
+
start: "node server.js",
|
|
47
|
+
baseUrl: "http://127.0.0.1:3001",
|
|
48
|
+
readyUrl: "http://127.0.0.1:3001"
|
|
49
|
+
},
|
|
50
|
+
dependsOn: ["api"],
|
|
51
|
+
skip: {
|
|
52
|
+
files: [
|
|
53
|
+
{
|
|
54
|
+
path: "frontend/src/app/login/__testkit__/auth.pw.testkit.ts",
|
|
55
|
+
reason: "Auth is stubbed locally"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
`
|
|
63
|
+
);
|
|
64
|
+
writeFile(productDir, "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts");
|
|
65
|
+
writeFile(productDir, "frontend/src/app/login/__testkit__/auth.pw.testkit.ts");
|
|
66
|
+
|
|
67
|
+
saveHistory(productDir, {
|
|
68
|
+
version: 1,
|
|
69
|
+
tests: {
|
|
70
|
+
[buildHistoryTestId("api", "int", "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts")]: {
|
|
71
|
+
id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts"),
|
|
72
|
+
path: "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
|
|
73
|
+
service: "api",
|
|
74
|
+
suiteName: "routes",
|
|
75
|
+
selectionType: "int",
|
|
76
|
+
framework: "k6",
|
|
77
|
+
firstSeenAt: "2026-04-01T00:00:00.000Z",
|
|
78
|
+
lastSeenAt: "2026-04-02T00:00:00.000Z",
|
|
79
|
+
lastRunAt: "2026-04-02T00:00:00.000Z",
|
|
80
|
+
runCount: 4,
|
|
81
|
+
passCount: 3,
|
|
82
|
+
failCount: 1,
|
|
83
|
+
skipCount: 0,
|
|
84
|
+
notRunCount: 0,
|
|
85
|
+
avgDurationMs: 2500,
|
|
86
|
+
durationCount: 4,
|
|
87
|
+
lastStatus: "passed",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await discoverTests({ dir: productDir });
|
|
93
|
+
expect(result.summary).toMatchObject({
|
|
94
|
+
services: 2,
|
|
95
|
+
suites: 2,
|
|
96
|
+
files: 2,
|
|
97
|
+
activeFiles: 1,
|
|
98
|
+
skippedFiles: 1,
|
|
99
|
+
byType: {
|
|
100
|
+
int: 1,
|
|
101
|
+
pw: 1,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(result.history.available).toBe(true);
|
|
105
|
+
expect(result.services.map((entry) => entry.name)).toEqual(["api", "frontend"]);
|
|
106
|
+
|
|
107
|
+
const apiFile = result.files.find((entry) => entry.service === "api");
|
|
108
|
+
expect(apiFile).toMatchObject({
|
|
109
|
+
displayName: "Agent Configs Auth Gate",
|
|
110
|
+
suiteName: "routes",
|
|
111
|
+
groupLabel: "Routes",
|
|
112
|
+
selectionType: "int",
|
|
113
|
+
skipped: false,
|
|
114
|
+
locks: ["route-lock"],
|
|
115
|
+
});
|
|
116
|
+
expect(apiFile.history).toMatchObject({
|
|
117
|
+
firstSeenAt: "2026-04-01T00:00:00.000Z",
|
|
118
|
+
runCount: 4,
|
|
119
|
+
passCount: 3,
|
|
120
|
+
failCount: 1,
|
|
121
|
+
avgDurationMs: 2500,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const frontendFile = result.files.find((entry) => entry.service === "frontend");
|
|
125
|
+
expect(frontendFile).toMatchObject({
|
|
126
|
+
displayName: "Auth",
|
|
127
|
+
selectionType: "pw",
|
|
128
|
+
skipped: true,
|
|
129
|
+
skipReason: "Auth is stubbed locally",
|
|
130
|
+
dependsOn: ["api"],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reports legacy discovery diagnostics without failing in report mode", async () => {
|
|
135
|
+
const productDir = createProduct();
|
|
136
|
+
writeFile(
|
|
137
|
+
productDir,
|
|
138
|
+
"testkit.setup.ts",
|
|
139
|
+
`
|
|
140
|
+
import { defineTestkitSetup } from "@elench/testkit/setup";
|
|
141
|
+
|
|
142
|
+
export default defineTestkitSetup({
|
|
143
|
+
services: {
|
|
144
|
+
api: {
|
|
145
|
+
local: {
|
|
146
|
+
cwd: ".",
|
|
147
|
+
start: "node server.js",
|
|
148
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
149
|
+
readyUrl: "http://127.0.0.1:3000"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
`
|
|
155
|
+
);
|
|
156
|
+
writeFile(productDir, "src/api/routes/__testkit__/health/health.int.testkit.ts");
|
|
157
|
+
writeFile(productDir, "tests/api/integration/legacy.int.testkit.ts");
|
|
158
|
+
|
|
159
|
+
const result = await discoverTests({ dir: productDir, diagnostics: "report" });
|
|
160
|
+
expect(result.files).toHaveLength(1);
|
|
161
|
+
expect(result.diagnostics).toEqual(
|
|
162
|
+
expect.arrayContaining([
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
code: "legacy_path",
|
|
165
|
+
path: "tests/api/integration/legacy.int.testkit.ts",
|
|
166
|
+
}),
|
|
167
|
+
])
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function createProduct() {
|
|
173
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-public-"));
|
|
174
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
175
|
+
return productDir;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeFile(productDir, relativePath, contents = "export {};\n") {
|
|
179
|
+
const absolutePath = path.join(productDir, relativePath);
|
|
180
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
181
|
+
fs.writeFileSync(absolutePath, contents);
|
|
182
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface TestHistorySummary {
|
|
2
|
+
firstSeenAt: string | null;
|
|
3
|
+
lastSeenAt: string | null;
|
|
4
|
+
lastRunAt: string | null;
|
|
5
|
+
runCount: number;
|
|
6
|
+
passCount: number;
|
|
7
|
+
failCount: number;
|
|
8
|
+
skipCount: number;
|
|
9
|
+
avgDurationMs: number;
|
|
10
|
+
lastStatus: "passed" | "failed" | "skipped" | "not_run" | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TestHistoryEntry extends TestHistorySummary {
|
|
14
|
+
id: string;
|
|
15
|
+
path: string;
|
|
16
|
+
service: string;
|
|
17
|
+
suiteName: string;
|
|
18
|
+
selectionType: string;
|
|
19
|
+
framework: string;
|
|
20
|
+
notRunCount: number;
|
|
21
|
+
durationCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TestHistoryDocument {
|
|
25
|
+
version: number;
|
|
26
|
+
tests: Record<string, TestHistoryEntry>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export declare function historyFilePath(productDir: string): string;
|
|
30
|
+
export declare function createEmptyHistory(): TestHistoryDocument;
|
|
31
|
+
export declare function loadHistory(productDir: string): TestHistoryDocument;
|
|
32
|
+
export declare function saveHistory(productDir: string, history: TestHistoryDocument): void;
|
|
33
|
+
export declare function updateHistoryFromRunArtifact(
|
|
34
|
+
history: TestHistoryDocument,
|
|
35
|
+
runArtifact: unknown,
|
|
36
|
+
recordedAt?: string | null
|
|
37
|
+
): TestHistoryDocument;
|
|
38
|
+
export declare function summarizeHistoryForFiles(
|
|
39
|
+
history: TestHistoryDocument,
|
|
40
|
+
files?: Array<{ id: string; service: string; selectionType: string; path: string }>
|
|
41
|
+
): Map<string, TestHistorySummary>;
|
|
42
|
+
export declare function buildHistoryTestId(
|
|
43
|
+
serviceName: string,
|
|
44
|
+
selectionType: string,
|
|
45
|
+
filePath: string
|
|
46
|
+
): string;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const HISTORY_SCHEMA_VERSION = 1;
|
|
5
|
+
const HISTORY_DIRNAME = "history";
|
|
6
|
+
const TEST_HISTORY_FILENAME = "tests.json";
|
|
7
|
+
|
|
8
|
+
export function historyFilePath(productDir) {
|
|
9
|
+
return path.join(productDir, ".testkit", HISTORY_DIRNAME, TEST_HISTORY_FILENAME);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createEmptyHistory() {
|
|
13
|
+
return {
|
|
14
|
+
version: HISTORY_SCHEMA_VERSION,
|
|
15
|
+
tests: {},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadHistory(productDir) {
|
|
20
|
+
const filePath = historyFilePath(productDir);
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
return createEmptyHistory();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
27
|
+
return normalizeHistory(parsed);
|
|
28
|
+
} catch {
|
|
29
|
+
return createEmptyHistory();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveHistory(productDir, history) {
|
|
34
|
+
const filePath = historyFilePath(productDir);
|
|
35
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(filePath, `${JSON.stringify(normalizeHistory(history), null, 2)}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function updateHistoryFromRunArtifact(history, runArtifact, recordedAt = null) {
|
|
40
|
+
const normalized = normalizeHistory(history);
|
|
41
|
+
const seenAt = recordedAt || runArtifact?.generatedAt || new Date().toISOString();
|
|
42
|
+
|
|
43
|
+
for (const service of runArtifact?.services || []) {
|
|
44
|
+
for (const suite of service.suites || []) {
|
|
45
|
+
for (const file of suite.files || []) {
|
|
46
|
+
const id = buildHistoryTestId(service.name, suite.type, file.path);
|
|
47
|
+
const existing = normalized.tests[id];
|
|
48
|
+
const next = existing
|
|
49
|
+
? { ...existing }
|
|
50
|
+
: {
|
|
51
|
+
id,
|
|
52
|
+
path: file.path,
|
|
53
|
+
service: service.name,
|
|
54
|
+
suiteName: suite.name,
|
|
55
|
+
selectionType: suite.type,
|
|
56
|
+
framework: normalizeArtifactFramework(suite.framework),
|
|
57
|
+
firstSeenAt: seenAt,
|
|
58
|
+
lastSeenAt: seenAt,
|
|
59
|
+
lastRunAt: seenAt,
|
|
60
|
+
runCount: 0,
|
|
61
|
+
passCount: 0,
|
|
62
|
+
failCount: 0,
|
|
63
|
+
skipCount: 0,
|
|
64
|
+
notRunCount: 0,
|
|
65
|
+
avgDurationMs: 0,
|
|
66
|
+
durationCount: 0,
|
|
67
|
+
lastStatus: null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
next.path = file.path;
|
|
71
|
+
next.service = service.name;
|
|
72
|
+
next.suiteName = suite.name;
|
|
73
|
+
next.selectionType = suite.type;
|
|
74
|
+
next.framework = normalizeArtifactFramework(suite.framework);
|
|
75
|
+
next.lastSeenAt = seenAt;
|
|
76
|
+
next.lastRunAt = seenAt;
|
|
77
|
+
next.runCount += 1;
|
|
78
|
+
next.lastStatus = file.status;
|
|
79
|
+
|
|
80
|
+
if (file.status === "passed") next.passCount += 1;
|
|
81
|
+
else if (file.status === "failed") next.failCount += 1;
|
|
82
|
+
else if (file.status === "skipped") next.skipCount += 1;
|
|
83
|
+
else next.notRunCount += 1;
|
|
84
|
+
|
|
85
|
+
if (Number(file.durationMs || 0) > 0 && file.status !== "skipped" && file.status !== "not_run") {
|
|
86
|
+
const durationCount = Number(next.durationCount || 0) + 1;
|
|
87
|
+
next.avgDurationMs = Math.max(
|
|
88
|
+
1,
|
|
89
|
+
Math.round(
|
|
90
|
+
((Number(next.avgDurationMs || 0) * Number(next.durationCount || 0)) + Number(file.durationMs || 0)) /
|
|
91
|
+
durationCount
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
next.durationCount = durationCount;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
normalized.tests[id] = next;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function summarizeHistoryForFiles(history, files = []) {
|
|
106
|
+
const normalized = normalizeHistory(history);
|
|
107
|
+
const byId = new Map();
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const entry = normalized.tests[file.id] || normalized.tests[buildHistoryTestId(file.service, file.selectionType, file.path)];
|
|
110
|
+
if (!entry) continue;
|
|
111
|
+
byId.set(file.id, {
|
|
112
|
+
firstSeenAt: entry.firstSeenAt || null,
|
|
113
|
+
lastSeenAt: entry.lastSeenAt || null,
|
|
114
|
+
lastRunAt: entry.lastRunAt || null,
|
|
115
|
+
runCount: Number(entry.runCount || 0),
|
|
116
|
+
passCount: Number(entry.passCount || 0),
|
|
117
|
+
failCount: Number(entry.failCount || 0),
|
|
118
|
+
skipCount: Number(entry.skipCount || 0),
|
|
119
|
+
avgDurationMs: Number(entry.avgDurationMs || 0),
|
|
120
|
+
lastStatus: entry.lastStatus || null,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return byId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildHistoryTestId(serviceName, selectionType, filePath) {
|
|
127
|
+
return [serviceName, selectionType, normalizePath(filePath)].join("|");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeHistory(parsed) {
|
|
131
|
+
const tests = {};
|
|
132
|
+
for (const [id, entry] of Object.entries(parsed?.tests || {})) {
|
|
133
|
+
tests[id] = {
|
|
134
|
+
id,
|
|
135
|
+
path: normalizePath(entry.path || ""),
|
|
136
|
+
service: String(entry.service || ""),
|
|
137
|
+
suiteName: String(entry.suiteName || ""),
|
|
138
|
+
selectionType: String(entry.selectionType || ""),
|
|
139
|
+
framework: normalizeArtifactFramework(entry.framework || "k6"),
|
|
140
|
+
firstSeenAt: entry.firstSeenAt || null,
|
|
141
|
+
lastSeenAt: entry.lastSeenAt || null,
|
|
142
|
+
lastRunAt: entry.lastRunAt || null,
|
|
143
|
+
runCount: Number(entry.runCount || 0),
|
|
144
|
+
passCount: Number(entry.passCount || 0),
|
|
145
|
+
failCount: Number(entry.failCount || 0),
|
|
146
|
+
skipCount: Number(entry.skipCount || 0),
|
|
147
|
+
notRunCount: Number(entry.notRunCount || 0),
|
|
148
|
+
avgDurationMs: Number(entry.avgDurationMs || 0),
|
|
149
|
+
durationCount: Number(entry.durationCount || 0),
|
|
150
|
+
lastStatus: entry.lastStatus || null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
version: HISTORY_SCHEMA_VERSION,
|
|
155
|
+
tests,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeArtifactFramework(value) {
|
|
160
|
+
if (value === "default") return "k6";
|
|
161
|
+
return value || "k6";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizePath(filePath) {
|
|
165
|
+
return String(filePath).split(path.sep).join("/").replace(/^\.\/+/, "");
|
|
166
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildHistoryTestId, createEmptyHistory, summarizeHistoryForFiles, updateHistoryFromRunArtifact } from "./index.mjs";
|
|
3
|
+
|
|
4
|
+
describe("test history", () => {
|
|
5
|
+
it("tracks first seen and pass/fail counters across runs", () => {
|
|
6
|
+
const history = createEmptyHistory();
|
|
7
|
+
const first = updateHistoryFromRunArtifact(
|
|
8
|
+
history,
|
|
9
|
+
{
|
|
10
|
+
generatedAt: "2026-04-30T10:00:00.000Z",
|
|
11
|
+
services: [
|
|
12
|
+
{
|
|
13
|
+
name: "api",
|
|
14
|
+
suites: [
|
|
15
|
+
{
|
|
16
|
+
name: "routes",
|
|
17
|
+
type: "int",
|
|
18
|
+
framework: "default",
|
|
19
|
+
files: [
|
|
20
|
+
{
|
|
21
|
+
path: "src/api/routes/__testkit__/health.int.testkit.ts",
|
|
22
|
+
status: "passed",
|
|
23
|
+
durationMs: 2000,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
const second = updateHistoryFromRunArtifact(
|
|
33
|
+
first,
|
|
34
|
+
{
|
|
35
|
+
generatedAt: "2026-05-01T10:00:00.000Z",
|
|
36
|
+
services: [
|
|
37
|
+
{
|
|
38
|
+
name: "api",
|
|
39
|
+
suites: [
|
|
40
|
+
{
|
|
41
|
+
name: "routes",
|
|
42
|
+
type: "int",
|
|
43
|
+
framework: "default",
|
|
44
|
+
files: [
|
|
45
|
+
{
|
|
46
|
+
path: "src/api/routes/__testkit__/health.int.testkit.ts",
|
|
47
|
+
status: "failed",
|
|
48
|
+
durationMs: 4000,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const id = buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts");
|
|
59
|
+
expect(second.tests[id]).toMatchObject({
|
|
60
|
+
firstSeenAt: "2026-04-30T10:00:00.000Z",
|
|
61
|
+
lastSeenAt: "2026-05-01T10:00:00.000Z",
|
|
62
|
+
lastRunAt: "2026-05-01T10:00:00.000Z",
|
|
63
|
+
runCount: 2,
|
|
64
|
+
passCount: 1,
|
|
65
|
+
failCount: 1,
|
|
66
|
+
skipCount: 0,
|
|
67
|
+
avgDurationMs: 3000,
|
|
68
|
+
lastStatus: "failed",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("summarizes history for discovery file entries", () => {
|
|
73
|
+
const history = {
|
|
74
|
+
version: 1,
|
|
75
|
+
tests: {
|
|
76
|
+
[buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts")]: {
|
|
77
|
+
id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"),
|
|
78
|
+
path: "src/api/routes/__testkit__/health.int.testkit.ts",
|
|
79
|
+
service: "api",
|
|
80
|
+
suiteName: "routes",
|
|
81
|
+
selectionType: "int",
|
|
82
|
+
framework: "k6",
|
|
83
|
+
firstSeenAt: "2026-04-30T10:00:00.000Z",
|
|
84
|
+
lastSeenAt: "2026-05-01T10:00:00.000Z",
|
|
85
|
+
lastRunAt: "2026-05-01T10:00:00.000Z",
|
|
86
|
+
runCount: 2,
|
|
87
|
+
passCount: 1,
|
|
88
|
+
failCount: 1,
|
|
89
|
+
skipCount: 0,
|
|
90
|
+
notRunCount: 0,
|
|
91
|
+
avgDurationMs: 3000,
|
|
92
|
+
durationCount: 2,
|
|
93
|
+
lastStatus: "failed",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const summaries = summarizeHistoryForFiles(history, [
|
|
99
|
+
{
|
|
100
|
+
id: buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"),
|
|
101
|
+
service: "api",
|
|
102
|
+
selectionType: "int",
|
|
103
|
+
path: "src/api/routes/__testkit__/health.int.testkit.ts",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
expect(summaries.get(buildHistoryTestId("api", "int", "src/api/routes/__testkit__/health.int.testkit.ts"))).toMatchObject({
|
|
108
|
+
runCount: 2,
|
|
109
|
+
passCount: 1,
|
|
110
|
+
failCount: 1,
|
|
111
|
+
avgDurationMs: 3000,
|
|
112
|
+
lastStatus: "failed",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
package/lib/index.d.ts
CHANGED
|
@@ -38,6 +38,55 @@ export interface HttpSuiteContext<TSetup = unknown> {
|
|
|
38
38
|
session: TSetup | null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface ScenarioStepResult {
|
|
42
|
+
name: string;
|
|
43
|
+
status: "passed" | "failed";
|
|
44
|
+
startedAt?: string;
|
|
45
|
+
finishedAt?: string;
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
failureCount?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ScenarioResource<TValue = unknown> {
|
|
52
|
+
get(): TValue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ScenarioRuntime {
|
|
56
|
+
readonly seed: string;
|
|
57
|
+
readonly scenarioName: string | null;
|
|
58
|
+
choose<TChoice extends unknown[]>(
|
|
59
|
+
name: string,
|
|
60
|
+
choices: TChoice
|
|
61
|
+
): TChoice[number];
|
|
62
|
+
choose<TShape extends Record<string, unknown>>(
|
|
63
|
+
name: string,
|
|
64
|
+
shape: TShape
|
|
65
|
+
): TShape;
|
|
66
|
+
maybe(name: string, probability?: number): boolean;
|
|
67
|
+
note<TValue = unknown>(name: string, value: TValue): TValue;
|
|
68
|
+
pick<TChoice extends unknown[]>(name: string, choices: TChoice): TChoice[number];
|
|
69
|
+
resource<TValue = unknown>(
|
|
70
|
+
name: string,
|
|
71
|
+
factory: () => TValue,
|
|
72
|
+
options?: { scope?: "file" | "scenario" | "step" }
|
|
73
|
+
): ScenarioResource<TValue>;
|
|
74
|
+
step<TValue = unknown>(name: string, fn: () => TValue): TValue;
|
|
75
|
+
snapshot(): {
|
|
76
|
+
schemaVersion: number;
|
|
77
|
+
seed: string;
|
|
78
|
+
scenarioName: string | null;
|
|
79
|
+
choices: Record<string, unknown>;
|
|
80
|
+
notes: Record<string, unknown>;
|
|
81
|
+
resources: Array<{ name: string; scope: "file" | "scenario" | "step" }>;
|
|
82
|
+
steps: ScenarioStepResult[];
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ScenarioSuiteContext<TSetup = unknown> extends HttpSuiteContext<TSetup> {
|
|
87
|
+
scenario: ScenarioRuntime;
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
export interface HttpSuiteConfig<TSetup = unknown> {
|
|
42
91
|
auth?: AuthAdapter<TSetup> | null;
|
|
43
92
|
env?: RuntimeEnv;
|
|
@@ -68,6 +117,15 @@ export declare function defineHttpSuite<TSetup = unknown>(
|
|
|
68
117
|
run: (context: HttpSuiteContext<TSetup>) => unknown
|
|
69
118
|
): TestkitSuite<TSetup>;
|
|
70
119
|
|
|
120
|
+
export declare function defineScenarioSuite<TSetup = unknown>(
|
|
121
|
+
run: (context: ScenarioSuiteContext<TSetup>) => unknown
|
|
122
|
+
): TestkitSuite<TSetup>;
|
|
123
|
+
|
|
124
|
+
export declare function defineScenarioSuite<TSetup = unknown>(
|
|
125
|
+
config: HttpSuiteConfig<TSetup>,
|
|
126
|
+
run: (context: ScenarioSuiteContext<TSetup>) => unknown
|
|
127
|
+
): TestkitSuite<TSetup>;
|
|
128
|
+
|
|
71
129
|
export declare function defineDalSuite<TSetup = unknown>(
|
|
72
130
|
run: (context: DalSuiteContext<TSetup>) => unknown
|
|
73
131
|
): TestkitSuite<TSetup>;
|
package/lib/index.mjs
CHANGED
package/lib/package.test.mjs
CHANGED
|
@@ -22,6 +22,10 @@ describe("package metadata", () => {
|
|
|
22
22
|
types: "./lib/runtime/index.d.ts",
|
|
23
23
|
default: "./lib/runtime/index.mjs",
|
|
24
24
|
});
|
|
25
|
+
expect(packageJson.exports["./discovery"]).toEqual({
|
|
26
|
+
types: "./lib/discovery/index.d.ts",
|
|
27
|
+
default: "./lib/discovery/index.mjs",
|
|
28
|
+
});
|
|
25
29
|
expect(packageJson.exports["./known-failures"]).toEqual({
|
|
26
30
|
types: "./lib/known-failures/index.d.ts",
|
|
27
31
|
default: "./lib/known-failures/index.mjs",
|
|
@@ -29,6 +33,7 @@ describe("package metadata", () => {
|
|
|
29
33
|
expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
|
|
30
34
|
expect(fs.existsSync(path.join(rootDir, "lib", "setup", "index.d.ts"))).toBe(true);
|
|
31
35
|
expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
|
|
36
|
+
expect(fs.existsSync(path.join(rootDir, "lib", "discovery", "index.d.ts"))).toBe(true);
|
|
32
37
|
expect(fs.existsSync(path.join(rootDir, "lib", "known-failures", "index.d.ts"))).toBe(true);
|
|
33
38
|
});
|
|
34
39
|
});
|
|
@@ -95,7 +95,10 @@ export async function runDefaultRuntimeTask(
|
|
|
95
95
|
env: buildTaskExecutionEnv(
|
|
96
96
|
targetConfig,
|
|
97
97
|
lease,
|
|
98
|
-
|
|
98
|
+
{
|
|
99
|
+
...buildFileTimeoutEnv(fileTimeoutSeconds, startedAt),
|
|
100
|
+
...(task.scenarioSeed ? { TESTKIT_SCENARIO_SEED: task.scenarioSeed } : {}),
|
|
101
|
+
},
|
|
99
102
|
process.env
|
|
100
103
|
),
|
|
101
104
|
reject: false,
|
|
@@ -52,6 +52,7 @@ import { createRuntimeManager } from "./runtime-manager.mjs";
|
|
|
52
52
|
import { createWorker, runWorker } from "./worker-loop.mjs";
|
|
53
53
|
import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
|
|
54
54
|
import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
|
|
55
|
+
import { loadHistory, saveHistory, updateHistoryFromRunArtifact } from "../history/index.mjs";
|
|
55
56
|
|
|
56
57
|
export async function runAll(configs, typeValues, suiteSelectors, opts, allConfigs = configs) {
|
|
57
58
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
@@ -146,6 +147,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
146
147
|
fileNames: requestedFiles,
|
|
147
148
|
shard: opts.shard || null,
|
|
148
149
|
serviceFilter: opts.serviceFilter || null,
|
|
150
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
149
151
|
metadata,
|
|
150
152
|
summarizeDbBackend,
|
|
151
153
|
serviceLogs: logRegistry.listServiceLogs(),
|
|
@@ -173,6 +175,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
173
175
|
const timings = loadTimings(productDir);
|
|
174
176
|
const graphs = buildRuntimeGraphs(executedPlans);
|
|
175
177
|
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
178
|
+
for (const task of queue) {
|
|
179
|
+
task.scenarioSeed = opts.scenarioSeed || null;
|
|
180
|
+
}
|
|
176
181
|
workerCount = Math.max(1, Math.min(execution.workers, queue.length));
|
|
177
182
|
runtimeInstanceCount = graphs.reduce((sum, graph) => sum + graph.instanceCount, 0);
|
|
178
183
|
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
@@ -247,10 +252,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
247
252
|
runtimeStats,
|
|
248
253
|
typeValues,
|
|
249
254
|
suiteSelectors,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
255
|
+
fileNames: requestedFiles,
|
|
256
|
+
shard: opts.shard || null,
|
|
257
|
+
serviceFilter: opts.serviceFilter || null,
|
|
258
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
259
|
+
metadata,
|
|
254
260
|
summarizeDbBackend,
|
|
255
261
|
serviceLogs: logRegistry.listServiceLogs(),
|
|
256
262
|
setupLogs: logRegistry.listSetupLogs(),
|
|
@@ -262,10 +268,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
262
268
|
results,
|
|
263
269
|
typeValues,
|
|
264
270
|
suiteSelectors,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
fileNames: requestedFiles,
|
|
272
|
+
shard: opts.shard || null,
|
|
273
|
+
serviceFilter: opts.serviceFilter || null,
|
|
274
|
+
scenarioSeed: opts.scenarioSeed || null,
|
|
275
|
+
metadata,
|
|
269
276
|
})
|
|
270
277
|
: null;
|
|
271
278
|
const enrichedArtifacts = applyKnownFailuresToArtifacts(
|
|
@@ -296,6 +303,12 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
296
303
|
if (opts.writeStatus) {
|
|
297
304
|
writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
|
|
298
305
|
}
|
|
306
|
+
const nextHistory = updateHistoryFromRunArtifact(
|
|
307
|
+
loadHistory(productDir),
|
|
308
|
+
enrichedArtifacts.runArtifact,
|
|
309
|
+
enrichedArtifacts.runArtifact.generatedAt
|
|
310
|
+
);
|
|
311
|
+
saveHistory(productDir, nextHistory);
|
|
299
312
|
|
|
300
313
|
reporter?.runSummary?.(results, finishedAt - startedAt, knownFailureIssueValidation);
|
|
301
314
|
await reportTelemetry(telemetry, enrichedArtifacts.runArtifact, reporter);
|