@elench/testkit 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cli/args.mjs +1 -1
- package/lib/cli/index.mjs +5 -0
- package/lib/runner/artifacts.mjs +43 -0
- package/lib/runner/default-runtime-errors.mjs +53 -0
- package/lib/runner/default-runtime-errors.test.mjs +49 -0
- package/lib/runner/default-runtime-runner.mjs +119 -0
- package/lib/runner/formatting.mjs +129 -0
- package/lib/runner/formatting.test.mjs +100 -0
- package/lib/runner/index.mjs +2 -1480
- package/lib/runner/lifecycle.mjs +349 -0
- package/lib/runner/maintenance.mjs +72 -0
- package/lib/runner/orchestrator.mjs +254 -0
- package/lib/runner/playwright-config.mjs +53 -0
- package/lib/runner/playwright-runner.mjs +85 -0
- package/lib/runner/processes.mjs +106 -0
- package/lib/runner/readiness.mjs +117 -0
- package/lib/runner/reporting.mjs +180 -0
- package/lib/runner/reporting.test.mjs +193 -0
- package/lib/runner/results.mjs +36 -266
- package/lib/runner/results.test.mjs +4 -204
- package/lib/runner/runtime-contexts.mjs +133 -0
- package/lib/runner/selection.mjs +33 -0
- package/lib/runner/selection.test.mjs +25 -0
- package/lib/runner/services.mjs +73 -0
- package/lib/runner/state-io.mjs +25 -0
- package/lib/runner/worker-loop.mjs +95 -0
- package/package.json +1 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
|
|
5
|
+
|
|
6
|
+
const RUN_SCHEMA_VERSION = 1;
|
|
7
|
+
const RUNS_DIRNAME = path.join(".testkit", "_runs");
|
|
8
|
+
const TERMINATION_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
export function createRunLifecycle(productDir) {
|
|
11
|
+
const runId = buildRunId();
|
|
12
|
+
const manifestPath = path.join(getRunsDir(productDir), `${runId}.json`);
|
|
13
|
+
const abortController = new AbortController();
|
|
14
|
+
const state = {
|
|
15
|
+
schemaVersion: RUN_SCHEMA_VERSION,
|
|
16
|
+
runId,
|
|
17
|
+
productDir,
|
|
18
|
+
pid: process.pid,
|
|
19
|
+
status: "starting",
|
|
20
|
+
startedAt: new Date().toISOString(),
|
|
21
|
+
interruptReason: null,
|
|
22
|
+
services: [],
|
|
23
|
+
graphDirs: [],
|
|
24
|
+
workerStateDirs: [],
|
|
25
|
+
runtimeStateDirs: [],
|
|
26
|
+
};
|
|
27
|
+
const signalListeners = [];
|
|
28
|
+
|
|
29
|
+
function persist() {
|
|
30
|
+
fs.mkdirSync(getRunsDir(productDir), { recursive: true });
|
|
31
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mutate(mutator) {
|
|
35
|
+
mutator(state);
|
|
36
|
+
persist();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const api = {
|
|
40
|
+
runId,
|
|
41
|
+
manifestPath,
|
|
42
|
+
signal: abortController.signal,
|
|
43
|
+
isStopRequested() {
|
|
44
|
+
return abortController.signal.aborted;
|
|
45
|
+
},
|
|
46
|
+
markRunning() {
|
|
47
|
+
mutate((draft) => {
|
|
48
|
+
draft.status = "running";
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
markFinished(status = "finished") {
|
|
52
|
+
mutate((draft) => {
|
|
53
|
+
draft.status = status;
|
|
54
|
+
draft.finishedAt = new Date().toISOString();
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
requestStop(reason = "interrupted") {
|
|
58
|
+
if (!abortController.signal.aborted) {
|
|
59
|
+
abortController.abort(new Error(`testkit run interrupted (${reason})`));
|
|
60
|
+
}
|
|
61
|
+
mutate((draft) => {
|
|
62
|
+
draft.status = "interrupting";
|
|
63
|
+
draft.interruptReason = reason;
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
trackGraphContext(context) {
|
|
67
|
+
mutate((draft) => {
|
|
68
|
+
pushUnique(draft.graphDirs, context.graphDir);
|
|
69
|
+
pushUnique(draft.workerStateDirs, context.workerStateDir);
|
|
70
|
+
for (const runtimeConfig of context.runtimeConfigs || []) {
|
|
71
|
+
if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
registerService(config, child, cwd) {
|
|
76
|
+
const ports = collectConfigPorts(config);
|
|
77
|
+
mutate((draft) => {
|
|
78
|
+
draft.services = draft.services.filter((service) => service.pid !== child.pid);
|
|
79
|
+
draft.services.push({
|
|
80
|
+
serviceName: config.name,
|
|
81
|
+
workerLabel: config.workerLabel,
|
|
82
|
+
command: config.testkit.local?.start || null,
|
|
83
|
+
cwd,
|
|
84
|
+
pid: child.pid,
|
|
85
|
+
processGroupId: child.pid,
|
|
86
|
+
ports,
|
|
87
|
+
startedAt: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
unregisterService(childPid) {
|
|
92
|
+
mutate((draft) => {
|
|
93
|
+
draft.services = draft.services.filter((service) => service.pid !== childPid);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
installSignalHandlers() {
|
|
97
|
+
const install = (eventName, handler) => {
|
|
98
|
+
process.on(eventName, handler);
|
|
99
|
+
signalListeners.push([eventName, handler]);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleSignal = (signalName) => {
|
|
103
|
+
api.requestStop(signalName);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
install("SIGINT", () => handleSignal("SIGINT"));
|
|
107
|
+
install("SIGTERM", () => handleSignal("SIGTERM"));
|
|
108
|
+
install("SIGHUP", () => handleSignal("SIGHUP"));
|
|
109
|
+
},
|
|
110
|
+
removeSignalHandlers() {
|
|
111
|
+
while (signalListeners.length > 0) {
|
|
112
|
+
const [eventName, handler] = signalListeners.pop();
|
|
113
|
+
process.off(eventName, handler);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
removeManifest() {
|
|
117
|
+
removeManifestFile(productDir, runId);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return api;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function cleanupRuns(productDir, { includeActive = false } = {}) {
|
|
125
|
+
const manifests = listRunManifests(productDir);
|
|
126
|
+
const summary = {
|
|
127
|
+
cleaned: [],
|
|
128
|
+
skippedActive: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const manifest of manifests) {
|
|
132
|
+
const isActive = isPidRunning(manifest.pid);
|
|
133
|
+
if (isActive && !includeActive) {
|
|
134
|
+
summary.skippedActive.push(manifest);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
138
|
+
summary.cleaned.push(manifest);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
142
|
+
return summary;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function cleanupStaleRuns(productDir) {
|
|
146
|
+
const manifests = listRunManifests(productDir);
|
|
147
|
+
const cleaned = [];
|
|
148
|
+
|
|
149
|
+
for (const manifest of manifests) {
|
|
150
|
+
if (isPidRunning(manifest.pid)) continue;
|
|
151
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
152
|
+
cleaned.push(manifest);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
156
|
+
return cleaned;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function cleanupRunById(productDir, runId) {
|
|
160
|
+
const manifest = listRunManifests(productDir).find((entry) => entry.runId === runId);
|
|
161
|
+
if (!manifest) return false;
|
|
162
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
163
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listRunManifests(productDir) {
|
|
168
|
+
const runsDir = getRunsDir(productDir);
|
|
169
|
+
if (!fs.existsSync(runsDir)) return [];
|
|
170
|
+
|
|
171
|
+
return fs
|
|
172
|
+
.readdirSync(runsDir)
|
|
173
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
174
|
+
.map((entry) => readRunManifest(path.join(runsDir, entry)))
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.sort((left, right) => String(left.startedAt || "").localeCompare(String(right.startedAt || "")));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function findPortOwner(productDir, { host, port }) {
|
|
180
|
+
const manifests = listRunManifests(productDir);
|
|
181
|
+
for (const manifest of manifests) {
|
|
182
|
+
for (const service of manifest.services || []) {
|
|
183
|
+
for (const socket of service.ports || []) {
|
|
184
|
+
if (normalizeHost(socket.host) === normalizeHost(host) && Number(socket.port) === Number(port)) {
|
|
185
|
+
return {
|
|
186
|
+
manifest,
|
|
187
|
+
service,
|
|
188
|
+
active: isPidRunning(manifest.pid),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function formatRunSummary(manifest) {
|
|
198
|
+
const workerLabels = [...new Set((manifest.services || []).map((service) => service.workerLabel).filter(Boolean))];
|
|
199
|
+
return `${manifest.runId} pid=${manifest.pid}${workerLabels.length > 0 ? ` workers=${workerLabels.join(",")}` : ""}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isPidRunning(pid) {
|
|
203
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
204
|
+
try {
|
|
205
|
+
process.kill(Number(pid), 0);
|
|
206
|
+
return true;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return error?.code === "EPERM";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = false } = {}) {
|
|
213
|
+
for (const service of [...(manifest.services || [])].reverse()) {
|
|
214
|
+
await terminateOwnedProcess(service);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (removeRuntimeState) {
|
|
218
|
+
const runtimeStateDirs = [...new Set(manifest.runtimeStateDirs || [])].sort((a, b) => b.length - a.length);
|
|
219
|
+
for (const stateDir of runtimeStateDirs) {
|
|
220
|
+
await destroyRuntimeDatabase({ productDir, stateDir });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const workerStateDir of [...new Set(manifest.workerStateDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
224
|
+
fs.rmSync(workerStateDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
228
|
+
pruneEmptyParents(graphDir, path.join(productDir, ".testkit", "_graphs"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
removeManifestFile(productDir, manifest.runId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function terminateOwnedProcess(service) {
|
|
236
|
+
const pid = Number(service.processGroupId || service.pid);
|
|
237
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
238
|
+
if (!isPidRunning(pid)) return;
|
|
239
|
+
|
|
240
|
+
killProcessGroup(pid, "SIGTERM");
|
|
241
|
+
const exited = await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
242
|
+
if (!exited) {
|
|
243
|
+
killProcessGroup(pid, "SIGKILL");
|
|
244
|
+
await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function killProcessGroup(pid, signal) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(-pid, signal);
|
|
251
|
+
return;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error?.code !== "ESRCH") {
|
|
254
|
+
// Fall through and try the direct pid.
|
|
255
|
+
} else {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, signal);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code !== "ESRCH") throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
268
|
+
const startedAt = Date.now();
|
|
269
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
270
|
+
if (!isPidRunning(pid)) return true;
|
|
271
|
+
await sleep(100);
|
|
272
|
+
}
|
|
273
|
+
return !isPidRunning(pid);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readRunManifest(filePath) {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
279
|
+
if (!parsed?.runId) return null;
|
|
280
|
+
return parsed;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function collectConfigPorts(config) {
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
const ports = [];
|
|
289
|
+
for (const rawUrl of [config.testkit.local?.baseUrl, config.testkit.local?.readyUrl]) {
|
|
290
|
+
if (!rawUrl) continue;
|
|
291
|
+
try {
|
|
292
|
+
const parsed = new URL(rawUrl);
|
|
293
|
+
const host = normalizeHost(parsed.hostname);
|
|
294
|
+
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
295
|
+
const key = `${host}:${port}`;
|
|
296
|
+
if (seen.has(key)) continue;
|
|
297
|
+
seen.add(key);
|
|
298
|
+
ports.push({ host, port });
|
|
299
|
+
} catch {
|
|
300
|
+
// Ignore malformed URLs here; startup validation handles them elsewhere.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return ports;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function pushUnique(list, value) {
|
|
307
|
+
if (!value) return;
|
|
308
|
+
if (!list.includes(value)) list.push(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getRunsDir(productDir) {
|
|
312
|
+
return path.join(productDir, RUNS_DIRNAME);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildRunId() {
|
|
316
|
+
return `${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeHost(host) {
|
|
320
|
+
if (!host) return "127.0.0.1";
|
|
321
|
+
return host === "::1" ? "127.0.0.1" : host;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pruneEmptyParents(startDir, stopDir) {
|
|
325
|
+
let current = startDir;
|
|
326
|
+
while (current && current.startsWith(stopDir)) {
|
|
327
|
+
if (!fs.existsSync(current)) {
|
|
328
|
+
current = path.dirname(current);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const entries = fs.readdirSync(current);
|
|
332
|
+
if (entries.length > 0) break;
|
|
333
|
+
fs.rmSync(current, { recursive: true, force: true });
|
|
334
|
+
if (current === stopDir) break;
|
|
335
|
+
current = path.dirname(current);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sleep(ms) {
|
|
340
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function removeManifestFile(productDir, runId) {
|
|
344
|
+
const runsDir = getRunsDir(productDir);
|
|
345
|
+
fs.rmSync(path.join(runsDir, `${runId}.json`), { force: true });
|
|
346
|
+
if (fs.existsSync(runsDir) && fs.readdirSync(runsDir).length === 0) {
|
|
347
|
+
fs.rmSync(runsDir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import {
|
|
3
|
+
cleanupOrphanedLocalInfrastructure,
|
|
4
|
+
destroyRuntimeDatabase,
|
|
5
|
+
destroyServiceDatabaseCache,
|
|
6
|
+
isDatabaseStateDir,
|
|
7
|
+
showServiceDatabaseStatus,
|
|
8
|
+
} from "../database/index.mjs";
|
|
9
|
+
import { cleanupRuns, formatRunSummary } from "./lifecycle.mjs";
|
|
10
|
+
import { printRunStatus } from "./readiness.mjs";
|
|
11
|
+
import { findGraphDirsForService, findRuntimeStateDirs } from "./state.mjs";
|
|
12
|
+
import { printStateDir } from "./state-io.mjs";
|
|
13
|
+
|
|
14
|
+
export async function destroy(config) {
|
|
15
|
+
await cleanupRuns(config.productDir, { includeActive: true });
|
|
16
|
+
const roots = new Set([
|
|
17
|
+
config.stateDir,
|
|
18
|
+
...findGraphDirsForService(config.productDir, config.name),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
for (const rootDir of roots) {
|
|
22
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
23
|
+
const runtimeStateDirs = findRuntimeStateDirs(rootDir, isDatabaseStateDir);
|
|
24
|
+
for (const stateDir of runtimeStateDirs) {
|
|
25
|
+
await destroyRuntimeDatabase({
|
|
26
|
+
productDir: config.productDir,
|
|
27
|
+
stateDir,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await destroyServiceDatabaseCache(config.productDir, config.name);
|
|
34
|
+
await cleanupOrphanedLocalInfrastructure(config.productDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function showStatus(config) {
|
|
38
|
+
printRunStatus(config.productDir);
|
|
39
|
+
const graphDirs = findGraphDirsForService(config.productDir, config.name);
|
|
40
|
+
const hasDirectState = fs.existsSync(config.stateDir);
|
|
41
|
+
const hasGraphState = graphDirs.length > 0;
|
|
42
|
+
|
|
43
|
+
if (!hasDirectState && !hasGraphState) {
|
|
44
|
+
console.log("No state — run tests first.");
|
|
45
|
+
} else {
|
|
46
|
+
if (hasDirectState) {
|
|
47
|
+
console.log(" service-state/");
|
|
48
|
+
printStateDir(config.stateDir, " ");
|
|
49
|
+
}
|
|
50
|
+
for (const graphDir of graphDirs) {
|
|
51
|
+
console.log(` graph-state/${graphDir.split("/").at(-1)}/`);
|
|
52
|
+
printStateDir(graphDir, " ");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
showServiceDatabaseStatus(config.productDir, config.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cleanup(productDir) {
|
|
60
|
+
const summary = await cleanupRuns(productDir, { includeActive: false });
|
|
61
|
+
if (summary.cleaned.length === 0 && summary.skippedActive.length === 0) {
|
|
62
|
+
console.log("No stale runs to clean.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const manifest of summary.cleaned) {
|
|
67
|
+
console.log(`Cleaned stale run ${formatRunSummary(manifest)}`);
|
|
68
|
+
}
|
|
69
|
+
for (const manifest of summary.skippedActive) {
|
|
70
|
+
console.log(`Active run still present: ${formatRunSummary(manifest)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyShard,
|
|
3
|
+
buildRuntimeGraphs,
|
|
4
|
+
buildTaskQueue,
|
|
5
|
+
claimNextBatch,
|
|
6
|
+
collectSuites,
|
|
7
|
+
resolveRuntimeConfigs,
|
|
8
|
+
} from "./planning.mjs";
|
|
9
|
+
import {
|
|
10
|
+
addTrackerError,
|
|
11
|
+
buildServiceTrackers,
|
|
12
|
+
finalizeServiceResult,
|
|
13
|
+
recordGraphError,
|
|
14
|
+
recordTaskOutcome,
|
|
15
|
+
summarizeDbBackend,
|
|
16
|
+
} from "./results.mjs";
|
|
17
|
+
import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
18
|
+
import { buildRunSummaryLines, formatError } from "./formatting.mjs";
|
|
19
|
+
import { loadTimings, saveTimings, writeRunArtifact, writeStatusArtifact } from "./artifacts.mjs";
|
|
20
|
+
import {
|
|
21
|
+
cleanupRunById,
|
|
22
|
+
cleanupRuns,
|
|
23
|
+
cleanupStaleRuns,
|
|
24
|
+
createRunLifecycle,
|
|
25
|
+
} from "./lifecycle.mjs";
|
|
26
|
+
import {
|
|
27
|
+
collectGitMetadata,
|
|
28
|
+
readPackageMetadata,
|
|
29
|
+
safeHostname,
|
|
30
|
+
safeUsername,
|
|
31
|
+
} from "./metadata.mjs";
|
|
32
|
+
import { createWorker, runWorker } from "./worker-loop.mjs";
|
|
33
|
+
import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
|
|
34
|
+
import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
|
|
35
|
+
|
|
36
|
+
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
37
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
const telemetry = configs[0]?.telemetry || null;
|
|
40
|
+
const productDir = configs[0]?.productDir || process.cwd();
|
|
41
|
+
await cleanupStaleRuns(productDir);
|
|
42
|
+
const metadata = {
|
|
43
|
+
git: collectGitMetadata(productDir),
|
|
44
|
+
host: {
|
|
45
|
+
hostname: safeHostname(),
|
|
46
|
+
username: safeUsername(),
|
|
47
|
+
},
|
|
48
|
+
testkitVersion: readPackageMetadata().version,
|
|
49
|
+
};
|
|
50
|
+
const requestedFiles = opts.fileNames || [];
|
|
51
|
+
if (requestedFiles.length > 0) {
|
|
52
|
+
const unmatchedFiles = findUnmatchedRequestedFiles(
|
|
53
|
+
configs,
|
|
54
|
+
suiteType,
|
|
55
|
+
suiteNames,
|
|
56
|
+
opts.framework || "all",
|
|
57
|
+
requestedFiles,
|
|
58
|
+
collectSuites,
|
|
59
|
+
normalizePathSeparators
|
|
60
|
+
);
|
|
61
|
+
if (unmatchedFiles.length > 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Requested file${unmatchedFiles.length === 1 ? "" : "s"} did not match any selected suites:\n` +
|
|
64
|
+
unmatchedFiles.map((file) => `- ${file}`).join("\n")
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
opts.writeStatus &&
|
|
70
|
+
!opts.allowPartialStatus &&
|
|
71
|
+
!isFullRunSelection(
|
|
72
|
+
suiteNames,
|
|
73
|
+
requestedFiles,
|
|
74
|
+
opts.framework || "all",
|
|
75
|
+
opts.shard || null,
|
|
76
|
+
opts.serviceFilter || null
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Refusing to overwrite testkit.status.json from a filtered run. " +
|
|
81
|
+
"Run the full suite with --write-status, or pass --allow-partial-status to opt in."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
86
|
+
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
87
|
+
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
88
|
+
let workerCount = 0;
|
|
89
|
+
let exitCode = 0;
|
|
90
|
+
const lifecycle = createRunLifecycle(productDir);
|
|
91
|
+
lifecycle.markRunning();
|
|
92
|
+
lifecycle.installSignalHandlers();
|
|
93
|
+
let results = [];
|
|
94
|
+
let finishedAt = Date.now();
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (executedPlans.length > 0) {
|
|
98
|
+
const timings = loadTimings(productDir);
|
|
99
|
+
const graphs = buildRuntimeGraphs(executedPlans);
|
|
100
|
+
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
101
|
+
workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
|
|
102
|
+
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
103
|
+
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
104
|
+
createWorker(index + 1, productDir)
|
|
105
|
+
);
|
|
106
|
+
const timingUpdates = [];
|
|
107
|
+
|
|
108
|
+
const workerResults = await Promise.allSettled(
|
|
109
|
+
workers.map((worker) =>
|
|
110
|
+
runWorker(
|
|
111
|
+
worker,
|
|
112
|
+
queue,
|
|
113
|
+
graphByKey,
|
|
114
|
+
trackers,
|
|
115
|
+
timingUpdates,
|
|
116
|
+
lifecycle,
|
|
117
|
+
claimNextBatch,
|
|
118
|
+
recordTaskOutcome,
|
|
119
|
+
recordGraphError
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
for (const result of workerResults) {
|
|
125
|
+
if (result.status === "rejected") {
|
|
126
|
+
const message = formatError(result.reason);
|
|
127
|
+
for (const tracker of trackers.values()) {
|
|
128
|
+
if (!tracker.skipped) addTrackerError(tracker, message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
saveTimings(productDir, timings, timingUpdates);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
finishedAt = Date.now();
|
|
137
|
+
results = configs.map((config) =>
|
|
138
|
+
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
139
|
+
);
|
|
140
|
+
const artifact = buildRunArtifact({
|
|
141
|
+
productDir,
|
|
142
|
+
results,
|
|
143
|
+
startedAt,
|
|
144
|
+
finishedAt,
|
|
145
|
+
requestedJobs: opts.jobs || 1,
|
|
146
|
+
workerCount,
|
|
147
|
+
suiteType,
|
|
148
|
+
suiteNames,
|
|
149
|
+
fileNames: requestedFiles,
|
|
150
|
+
framework: opts.framework || "all",
|
|
151
|
+
shard: opts.shard || null,
|
|
152
|
+
serviceFilter: opts.serviceFilter || null,
|
|
153
|
+
metadata,
|
|
154
|
+
summarizeDbBackend,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
writeRunArtifact(productDir, artifact);
|
|
158
|
+
if (opts.writeStatus) {
|
|
159
|
+
writeStatusArtifact(
|
|
160
|
+
productDir,
|
|
161
|
+
buildStatusArtifact({
|
|
162
|
+
productDir,
|
|
163
|
+
results,
|
|
164
|
+
suiteType,
|
|
165
|
+
suiteNames,
|
|
166
|
+
fileNames: requestedFiles,
|
|
167
|
+
framework: opts.framework || "all",
|
|
168
|
+
shard: opts.shard || null,
|
|
169
|
+
serviceFilter: opts.serviceFilter || null,
|
|
170
|
+
metadata,
|
|
171
|
+
})
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
printRunSummary(results, finishedAt - startedAt);
|
|
176
|
+
await reportTelemetry(telemetry, artifact);
|
|
177
|
+
if (results.some((result) => result.failed)) exitCode = 1;
|
|
178
|
+
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
179
|
+
} finally {
|
|
180
|
+
lifecycle.removeSignalHandlers();
|
|
181
|
+
lifecycle.markFinished(
|
|
182
|
+
exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
|
|
183
|
+
);
|
|
184
|
+
await cleanupRunById(productDir, lifecycle.runId);
|
|
185
|
+
await cleanupRuns(productDir, { includeActive: false });
|
|
186
|
+
lifecycle.removeManifest();
|
|
187
|
+
process.exitCode = exitCode;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
192
|
+
return configs.map((config) => {
|
|
193
|
+
console.log(`\n══ ${config.name} ══`);
|
|
194
|
+
const suites = applyShard(
|
|
195
|
+
collectSuites(config, suiteType, suiteNames, opts.framework, opts.fileNames || []),
|
|
196
|
+
opts.shard
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (suites.length === 0) {
|
|
200
|
+
console.log(
|
|
201
|
+
`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"} — skipping`
|
|
202
|
+
);
|
|
203
|
+
return {
|
|
204
|
+
config,
|
|
205
|
+
skipped: true,
|
|
206
|
+
suites: [],
|
|
207
|
+
runtimeConfigs: [],
|
|
208
|
+
runtimeNames: [],
|
|
209
|
+
runtimeKey: null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
|
|
214
|
+
return {
|
|
215
|
+
config,
|
|
216
|
+
skipped: false,
|
|
217
|
+
suites,
|
|
218
|
+
runtimeConfigs,
|
|
219
|
+
runtimeNames: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort(),
|
|
220
|
+
runtimeKey: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort().join("|"),
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function printRunSummary(results, durationMs) {
|
|
226
|
+
for (const line of buildRunSummaryLines(results, durationMs)) {
|
|
227
|
+
console.log(line);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function reportTelemetry(telemetry, artifact) {
|
|
232
|
+
if (!telemetry?.enabled) return;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const outcome = await uploadTelemetryArtifact(telemetry, artifact);
|
|
236
|
+
if (outcome?.ok) {
|
|
237
|
+
console.log("Telemetry: uploaded run artifact");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (outcome?.reason === "missing-token") {
|
|
241
|
+
console.log(
|
|
242
|
+
`Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (outcome?.reason && !outcome.skipped) return;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.log(`Telemetry: upload failed (${formatError(error)})`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizePathSeparators(filePath) {
|
|
253
|
+
return filePath.split("\\").join("/");
|
|
254
|
+
}
|