@elench/testkit 0.1.41 → 0.1.43
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 +13 -0
- package/bin/testkit.mjs +6 -1
- package/lib/cli/index.mjs +4 -2
- package/lib/config/index.mjs +34 -0
- package/lib/reporters/playwright.mjs +34 -5
- package/lib/reporters/playwright.test.mjs +11 -0
- package/lib/runner/default-runtime-runner.mjs +29 -6
- package/lib/runner/execution-config.mjs +17 -0
- package/lib/runner/execution-config.test.mjs +8 -0
- package/lib/runner/failure-details.mjs +91 -0
- package/lib/runner/failure-details.test.mjs +63 -0
- package/lib/runner/lifecycle.mjs +99 -1
- package/lib/runner/orchestrator.mjs +26 -9
- package/lib/runner/planning.mjs +28 -6
- package/lib/runner/planning.test.mjs +38 -0
- package/lib/runner/playwright-config.mjs +5 -0
- package/lib/runner/playwright-config.test.mjs +6 -1
- package/lib/runner/playwright-runner.mjs +21 -4
- package/lib/runner/reporting.mjs +10 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/results.mjs +8 -0
- package/lib/runner/runtime-manager.mjs +90 -43
- package/lib/runner/runtime-manager.test.mjs +36 -11
- package/lib/runner/services.mjs +4 -2
- package/lib/runner/triage.mjs +330 -0
- package/lib/runner/triage.test.mjs +156 -0
- package/lib/runner/worker-loop.mjs +8 -2
- package/lib/runtime/index.mjs +2 -1
- package/lib/runtime-src/k6/checks.js +130 -0
- package/lib/runtime-src/k6/dal-suite.js +12 -1
- package/lib/runtime-src/k6/suite.js +10 -1
- package/lib/setup/index.d.ts +4 -0
- package/package.json +1 -1
package/lib/runner/lifecycle.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
|
+
import { execFileSync } from "child_process";
|
|
4
5
|
import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
|
|
5
6
|
|
|
6
7
|
const RUN_SCHEMA_VERSION = 1;
|
|
7
8
|
const RUNS_DIRNAME = path.join(".testkit", "_runs");
|
|
8
9
|
const TERMINATION_TIMEOUT_MS = 5_000;
|
|
10
|
+
const SHUTDOWN_HOLD_TIMEOUT_MS = 10_000;
|
|
9
11
|
|
|
10
12
|
export function createRunLifecycle(productDir) {
|
|
11
13
|
const runId = buildRunId();
|
|
@@ -25,6 +27,9 @@ export function createRunLifecycle(productDir) {
|
|
|
25
27
|
runtimeStateDirs: [],
|
|
26
28
|
};
|
|
27
29
|
const signalListeners = [];
|
|
30
|
+
const managedProcesses = new Set();
|
|
31
|
+
let shutdownHold = null;
|
|
32
|
+
let shutdownHoldTimeout = null;
|
|
28
33
|
|
|
29
34
|
function persist() {
|
|
30
35
|
fs.mkdirSync(getRunsDir(productDir), { recursive: true });
|
|
@@ -36,6 +41,24 @@ export function createRunLifecycle(productDir) {
|
|
|
36
41
|
persist();
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
function ensureShutdownHold() {
|
|
45
|
+
if (shutdownHold) return;
|
|
46
|
+
shutdownHold = setInterval(() => {}, 1_000);
|
|
47
|
+
shutdownHoldTimeout = setTimeout(() => {
|
|
48
|
+
releaseShutdownHold();
|
|
49
|
+
}, SHUTDOWN_HOLD_TIMEOUT_MS);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function releaseShutdownHold() {
|
|
53
|
+
if (!shutdownHold) return;
|
|
54
|
+
clearInterval(shutdownHold);
|
|
55
|
+
shutdownHold = null;
|
|
56
|
+
if (shutdownHoldTimeout) {
|
|
57
|
+
clearTimeout(shutdownHoldTimeout);
|
|
58
|
+
shutdownHoldTimeout = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
const api = {
|
|
40
63
|
runId,
|
|
41
64
|
manifestPath,
|
|
@@ -55,9 +78,18 @@ export function createRunLifecycle(productDir) {
|
|
|
55
78
|
});
|
|
56
79
|
},
|
|
57
80
|
requestStop(reason = "interrupted") {
|
|
81
|
+
ensureShutdownHold();
|
|
58
82
|
if (!abortController.signal.aborted) {
|
|
59
83
|
abortController.abort(new Error(`testkit run interrupted (${reason})`));
|
|
60
84
|
}
|
|
85
|
+
for (const entry of managedProcesses) {
|
|
86
|
+
try {
|
|
87
|
+
entry.terminate?.();
|
|
88
|
+
} catch {
|
|
89
|
+
// Best-effort interruption only.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
terminateDescendantProcesses(process.pid, "SIGINT");
|
|
61
93
|
mutate((draft) => {
|
|
62
94
|
draft.status = "interrupting";
|
|
63
95
|
draft.interruptReason = reason;
|
|
@@ -72,7 +104,22 @@ export function createRunLifecycle(productDir) {
|
|
|
72
104
|
}
|
|
73
105
|
});
|
|
74
106
|
},
|
|
75
|
-
|
|
107
|
+
registerProcess(child, terminate) {
|
|
108
|
+
if (!child) return;
|
|
109
|
+
managedProcesses.add({
|
|
110
|
+
child,
|
|
111
|
+
terminate,
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
unregisterProcess(childPid) {
|
|
115
|
+
for (const entry of managedProcesses) {
|
|
116
|
+
if (entry.child?.pid === childPid) {
|
|
117
|
+
managedProcesses.delete(entry);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
registerService(config, child, cwd, terminate) {
|
|
122
|
+
api.registerProcess(child, terminate);
|
|
76
123
|
const ports = collectConfigPorts(config);
|
|
77
124
|
mutate((draft) => {
|
|
78
125
|
draft.services = draft.services.filter((service) => service.pid !== child.pid);
|
|
@@ -89,6 +136,7 @@ export function createRunLifecycle(productDir) {
|
|
|
89
136
|
});
|
|
90
137
|
},
|
|
91
138
|
unregisterService(childPid) {
|
|
139
|
+
api.unregisterProcess(childPid);
|
|
92
140
|
mutate((draft) => {
|
|
93
141
|
draft.services = draft.services.filter((service) => service.pid !== childPid);
|
|
94
142
|
});
|
|
@@ -116,6 +164,9 @@ export function createRunLifecycle(productDir) {
|
|
|
116
164
|
removeManifest() {
|
|
117
165
|
removeManifestFile(productDir, runId);
|
|
118
166
|
},
|
|
167
|
+
dispose() {
|
|
168
|
+
releaseShutdownHold();
|
|
169
|
+
},
|
|
119
170
|
};
|
|
120
171
|
|
|
121
172
|
return api;
|
|
@@ -264,6 +315,53 @@ function killProcessGroup(pid, signal) {
|
|
|
264
315
|
}
|
|
265
316
|
}
|
|
266
317
|
|
|
318
|
+
function terminateDescendantProcesses(rootPid, signal) {
|
|
319
|
+
const descendants = listDescendantPids(rootPid);
|
|
320
|
+
for (const pid of descendants) {
|
|
321
|
+
try {
|
|
322
|
+
process.kill(pid, signal);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error?.code !== "ESRCH") {
|
|
325
|
+
// Best-effort interruption only.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function listDescendantPids(rootPid) {
|
|
332
|
+
try {
|
|
333
|
+
const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
|
|
334
|
+
encoding: "utf8",
|
|
335
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
336
|
+
});
|
|
337
|
+
const childrenByParent = new Map();
|
|
338
|
+
for (const line of output.split(/\r?\n/)) {
|
|
339
|
+
const trimmed = line.trim();
|
|
340
|
+
if (!trimmed) continue;
|
|
341
|
+
const [pidRaw, parentRaw] = trimmed.split(/\s+/);
|
|
342
|
+
const pid = Number(pidRaw);
|
|
343
|
+
const parentPid = Number(parentRaw);
|
|
344
|
+
if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
|
|
345
|
+
const siblings = childrenByParent.get(parentPid) || [];
|
|
346
|
+
siblings.push(pid);
|
|
347
|
+
childrenByParent.set(parentPid, siblings);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const descendants = [];
|
|
351
|
+
const stack = [...(childrenByParent.get(rootPid) || [])];
|
|
352
|
+
while (stack.length > 0) {
|
|
353
|
+
const pid = stack.pop();
|
|
354
|
+
descendants.push(pid);
|
|
355
|
+
for (const childPid of childrenByParent.get(pid) || []) {
|
|
356
|
+
stack.push(childPid);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return descendants;
|
|
360
|
+
} catch {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
267
365
|
async function waitForPidExit(pid, timeoutMs) {
|
|
268
366
|
const startedAt = Date.now();
|
|
269
367
|
while (Date.now() - startedAt < timeoutMs) {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
summarizeDbBackend,
|
|
16
16
|
} from "./results.mjs";
|
|
17
17
|
import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
18
|
+
import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
|
|
18
19
|
import { buildRunSummaryLines, formatError } from "./formatting.mjs";
|
|
19
20
|
import {
|
|
20
21
|
loadTimings,
|
|
@@ -56,6 +57,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
56
57
|
},
|
|
57
58
|
testkitVersion: readPackageMetadata().version,
|
|
58
59
|
};
|
|
60
|
+
const knownFailures = loadKnownFailuresConfig(
|
|
61
|
+
productDir,
|
|
62
|
+
configs[0]?.testkit?.reporting || null
|
|
63
|
+
);
|
|
59
64
|
const requestedFiles = opts.fileNames || [];
|
|
60
65
|
if (requestedFiles.length > 0) {
|
|
61
66
|
const unmatchedFiles = findUnmatchedRequestedFiles(
|
|
@@ -106,6 +111,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
106
111
|
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
107
112
|
let workerCount = 0;
|
|
108
113
|
let runtimeInstanceCount = 0;
|
|
114
|
+
let runtimeStats = [];
|
|
109
115
|
let exitCode = 0;
|
|
110
116
|
const lifecycle = createRunLifecycle(productDir);
|
|
111
117
|
lifecycle.markRunning();
|
|
@@ -155,6 +161,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
155
161
|
}
|
|
156
162
|
}
|
|
157
163
|
}
|
|
164
|
+
runtimeStats = runtimeManager.getStats();
|
|
158
165
|
} finally {
|
|
159
166
|
await runtimeManager.cleanupAll();
|
|
160
167
|
}
|
|
@@ -166,7 +173,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
166
173
|
results = configs.map((config) =>
|
|
167
174
|
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
168
175
|
);
|
|
169
|
-
const
|
|
176
|
+
const runArtifact = buildRunArtifact({
|
|
170
177
|
productDir,
|
|
171
178
|
results,
|
|
172
179
|
startedAt,
|
|
@@ -174,6 +181,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
174
181
|
execution,
|
|
175
182
|
workerCount,
|
|
176
183
|
runtimeInstanceCount,
|
|
184
|
+
runtimeStats,
|
|
177
185
|
typeValues,
|
|
178
186
|
suiteSelectors,
|
|
179
187
|
fileNames: requestedFiles,
|
|
@@ -182,12 +190,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
182
190
|
metadata,
|
|
183
191
|
summarizeDbBackend,
|
|
184
192
|
});
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (opts.writeStatus) {
|
|
188
|
-
writeStatusArtifact(
|
|
189
|
-
productDir,
|
|
190
|
-
buildStatusArtifact({
|
|
193
|
+
const statusArtifact = opts.writeStatus
|
|
194
|
+
? buildStatusArtifact({
|
|
191
195
|
productDir,
|
|
192
196
|
results,
|
|
193
197
|
typeValues,
|
|
@@ -197,14 +201,26 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
197
201
|
serviceFilter: opts.serviceFilter || null,
|
|
198
202
|
metadata,
|
|
199
203
|
})
|
|
200
|
-
|
|
204
|
+
: null;
|
|
205
|
+
const enrichedArtifacts = applyKnownFailuresToArtifacts(
|
|
206
|
+
runArtifact,
|
|
207
|
+
statusArtifact,
|
|
208
|
+
knownFailures
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
writeRunArtifact(productDir, enrichedArtifacts.runArtifact);
|
|
212
|
+
if (opts.writeStatus) {
|
|
213
|
+
writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
printRunSummary(results, finishedAt - startedAt);
|
|
204
|
-
await reportTelemetry(telemetry,
|
|
217
|
+
await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
|
|
205
218
|
if (results.some((result) => result.failed)) exitCode = 1;
|
|
206
219
|
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
207
220
|
} finally {
|
|
221
|
+
if (lifecycle.isStopRequested()) {
|
|
222
|
+
exitCode = Math.max(exitCode, 130);
|
|
223
|
+
}
|
|
208
224
|
lifecycle.removeSignalHandlers();
|
|
209
225
|
lifecycle.markFinished(
|
|
210
226
|
exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
|
|
@@ -212,6 +228,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
212
228
|
await cleanupRunById(productDir, lifecycle.runId);
|
|
213
229
|
await cleanupRuns(productDir, { includeActive: false });
|
|
214
230
|
lifecycle.removeManifest();
|
|
231
|
+
lifecycle.dispose();
|
|
215
232
|
process.exitCode = exitCode;
|
|
216
233
|
}
|
|
217
234
|
}
|
package/lib/runner/planning.mjs
CHANGED
|
@@ -113,6 +113,10 @@ export function buildRuntimeGraphs(servicePlans) {
|
|
|
113
113
|
if (existing) {
|
|
114
114
|
existing.targetNames.push(plan.config.name);
|
|
115
115
|
existing.instanceCount = Math.max(existing.instanceCount, plan.config.testkit.runtime.instances);
|
|
116
|
+
existing.maxConcurrentTasks = Math.min(
|
|
117
|
+
existing.maxConcurrentTasks,
|
|
118
|
+
resolveGraphMaxConcurrentTasks(plan.runtimeConfigs)
|
|
119
|
+
);
|
|
116
120
|
continue;
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -123,6 +127,7 @@ export function buildRuntimeGraphs(servicePlans) {
|
|
|
123
127
|
targetNames: [plan.config.name],
|
|
124
128
|
dirName: buildGraphDirName(plan.runtimeNames),
|
|
125
129
|
instanceCount: plan.config.testkit.runtime.instances,
|
|
130
|
+
maxConcurrentTasks: resolveGraphMaxConcurrentTasks(plan.runtimeConfigs),
|
|
126
131
|
};
|
|
127
132
|
graphs.push(graph);
|
|
128
133
|
graphByRuntimeKey.set(plan.runtimeKey, graph);
|
|
@@ -166,6 +171,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
|
|
|
166
171
|
orderIndex: suite.orderIndex,
|
|
167
172
|
file,
|
|
168
173
|
locks: resolveTaskLocks(plan.config, suite, file),
|
|
174
|
+
resourceCost: 1,
|
|
169
175
|
timingKey,
|
|
170
176
|
estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
|
|
171
177
|
});
|
|
@@ -183,16 +189,25 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
|
|
|
183
189
|
);
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
export function claimNextTask(queue, preferredGraphKey) {
|
|
192
|
+
export function claimNextTask(queue, preferredGraphKey, isRunnable = () => true) {
|
|
187
193
|
if (queue.length === 0) return null;
|
|
188
194
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
const preferredIndexes = [];
|
|
196
|
+
const fallbackIndexes = [];
|
|
197
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
198
|
+
if (preferredGraphKey && queue[index].graphKey === preferredGraphKey) {
|
|
199
|
+
preferredIndexes.push(index);
|
|
200
|
+
} else {
|
|
201
|
+
fallbackIndexes.push(index);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const index of [...preferredIndexes, ...fallbackIndexes]) {
|
|
206
|
+
if (!isRunnable(queue[index])) continue;
|
|
207
|
+
return queue.splice(index, 1)[0];
|
|
192
208
|
}
|
|
193
|
-
if (index === -1) index = 0;
|
|
194
209
|
|
|
195
|
-
return
|
|
210
|
+
return null;
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
export function buildGraphDirName(runtimeNames) {
|
|
@@ -267,3 +282,10 @@ function normalizePathSeparators(filePath) {
|
|
|
267
282
|
function slugSegment(value) {
|
|
268
283
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
|
|
269
284
|
}
|
|
285
|
+
|
|
286
|
+
function resolveGraphMaxConcurrentTasks(runtimeConfigs) {
|
|
287
|
+
return runtimeConfigs.reduce(
|
|
288
|
+
(currentMin, config) => Math.min(currentMin, config.testkit.runtime.maxConcurrentTasks),
|
|
289
|
+
Number.POSITIVE_INFINITY
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -22,6 +22,7 @@ function makeConfig(name, extras = {}) {
|
|
|
22
22
|
},
|
|
23
23
|
runtime: providedTestkit.runtime || {
|
|
24
24
|
instances: 1,
|
|
25
|
+
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
25
26
|
},
|
|
26
27
|
requirements: providedTestkit.requirements || {
|
|
27
28
|
suites: [],
|
|
@@ -156,6 +157,7 @@ describe("runner-planning", () => {
|
|
|
156
157
|
testkit: {
|
|
157
158
|
runtime: {
|
|
158
159
|
instances: 3,
|
|
160
|
+
maxConcurrentTasks: 2,
|
|
159
161
|
},
|
|
160
162
|
requirements: {
|
|
161
163
|
suites: [
|
|
@@ -226,6 +228,7 @@ describe("runner-planning", () => {
|
|
|
226
228
|
testkit: {
|
|
227
229
|
runtime: {
|
|
228
230
|
instances: 2,
|
|
231
|
+
maxConcurrentTasks: 4,
|
|
229
232
|
},
|
|
230
233
|
},
|
|
231
234
|
});
|
|
@@ -234,6 +237,7 @@ describe("runner-planning", () => {
|
|
|
234
237
|
testkit: {
|
|
235
238
|
runtime: {
|
|
236
239
|
instances: 1,
|
|
240
|
+
maxConcurrentTasks: 2,
|
|
237
241
|
},
|
|
238
242
|
},
|
|
239
243
|
});
|
|
@@ -282,11 +286,13 @@ describe("runner-planning", () => {
|
|
|
282
286
|
expect.objectContaining({
|
|
283
287
|
key: "api",
|
|
284
288
|
instanceCount: 2,
|
|
289
|
+
maxConcurrentTasks: 4,
|
|
285
290
|
targetNames: ["api"],
|
|
286
291
|
}),
|
|
287
292
|
expect.objectContaining({
|
|
288
293
|
key: "api|frontend",
|
|
289
294
|
instanceCount: 1,
|
|
295
|
+
maxConcurrentTasks: 2,
|
|
290
296
|
targetNames: ["frontend"],
|
|
291
297
|
}),
|
|
292
298
|
]);
|
|
@@ -304,4 +310,36 @@ describe("runner-planning", () => {
|
|
|
304
310
|
graphKey: "api",
|
|
305
311
|
});
|
|
306
312
|
});
|
|
313
|
+
|
|
314
|
+
it("skips blocked preferred-graph work and claims the next runnable task", () => {
|
|
315
|
+
const queue = [
|
|
316
|
+
{
|
|
317
|
+
id: 1,
|
|
318
|
+
graphKey: "api",
|
|
319
|
+
file: "blocked.js",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 2,
|
|
323
|
+
graphKey: "api|frontend",
|
|
324
|
+
file: "runnable.js",
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
const claimed = claimNextTask(
|
|
329
|
+
queue,
|
|
330
|
+
"api",
|
|
331
|
+
(task) => task.file === "runnable.js"
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(claimed).toMatchObject({
|
|
335
|
+
id: 2,
|
|
336
|
+
file: "runnable.js",
|
|
337
|
+
});
|
|
338
|
+
expect(queue).toEqual([
|
|
339
|
+
expect.objectContaining({
|
|
340
|
+
id: 1,
|
|
341
|
+
file: "blocked.js",
|
|
342
|
+
}),
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
307
345
|
});
|
|
@@ -28,6 +28,9 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
|
|
|
28
28
|
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
29
29
|
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
30
30
|
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
31
|
+
` workers: 1,\n` +
|
|
32
|
+
` fullyParallel: false,\n` +
|
|
33
|
+
` webServer: undefined,\n` +
|
|
31
34
|
`};\n`;
|
|
32
35
|
} else {
|
|
33
36
|
source =
|
|
@@ -35,6 +38,8 @@ export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles, le
|
|
|
35
38
|
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
36
39
|
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
37
40
|
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
41
|
+
` workers: 1,\n` +
|
|
42
|
+
` fullyParallel: false,\n` +
|
|
38
43
|
`};\n`;
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -31,7 +31,7 @@ describe("runner-playwright-config", () => {
|
|
|
31
31
|
fs.mkdirSync(cwd, { recursive: true });
|
|
32
32
|
fs.writeFileSync(
|
|
33
33
|
path.join(cwd, "playwright.config.mjs"),
|
|
34
|
-
"export default { outputDir: 'shared-test-results' };\n"
|
|
34
|
+
"export default { outputDir: 'shared-test-results', workers: 8, fullyParallel: true, webServer: { command: 'npm run dev', url: 'http://127.0.0.1:3000' } };\n"
|
|
35
35
|
);
|
|
36
36
|
|
|
37
37
|
const configPath = ensurePlaywrightTestConfig(
|
|
@@ -44,10 +44,15 @@ describe("runner-playwright-config", () => {
|
|
|
44
44
|
|
|
45
45
|
const expectedOutputDir = resolvePlaywrightOutputDir(leaseDir);
|
|
46
46
|
expect(generated.default.outputDir).toBe(expectedOutputDir);
|
|
47
|
+
expect(generated.default.workers).toBe(1);
|
|
48
|
+
expect(generated.default.fullyParallel).toBe(false);
|
|
49
|
+
expect(generated.default.webServer).toBeUndefined();
|
|
47
50
|
expect(fs.existsSync(expectedOutputDir)).toBe(true);
|
|
48
51
|
expect(fs.readFileSync(configPath, "utf8")).toContain(
|
|
49
52
|
`outputDir: ${JSON.stringify(expectedOutputDir)}`
|
|
50
53
|
);
|
|
54
|
+
expect(fs.readFileSync(configPath, "utf8")).toContain("workers: 1");
|
|
55
|
+
expect(fs.readFileSync(configPath, "utf8")).toContain("fullyParallel: false");
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
it("requires a lease-scoped directory", () => {
|
|
@@ -8,6 +8,7 @@ import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
|
|
|
8
8
|
import { printBufferedOutput } from "./processes.mjs";
|
|
9
9
|
import { normalizePathSeparators } from "./state.mjs";
|
|
10
10
|
import { buildPlaywrightEnv } from "./template.mjs";
|
|
11
|
+
import { killChildProcess } from "./processes.mjs";
|
|
11
12
|
|
|
12
13
|
export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
13
14
|
const local = targetConfig.testkit.local;
|
|
@@ -17,11 +18,12 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
|
17
18
|
);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
|
|
21
|
-
|
|
22
21
|
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
23
22
|
const requestedFile = path.relative(cwd, path.join(targetConfig.productDir, task.file));
|
|
24
23
|
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, [requestedFile], lease);
|
|
24
|
+
if (lifecycle.isStopRequested()) {
|
|
25
|
+
throw new Error(`testkit run interrupted before starting ${task.file}`);
|
|
26
|
+
}
|
|
25
27
|
const startedAt = Date.now();
|
|
26
28
|
const fileTimeoutSeconds = targetConfig.testkit.execution.fileTimeoutSeconds;
|
|
27
29
|
const subprocess = execa(
|
|
@@ -31,11 +33,25 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
|
31
33
|
cwd,
|
|
32
34
|
env: buildPlaywrightEnv(targetConfig, local.baseUrl, lease, process.env),
|
|
33
35
|
reject: false,
|
|
34
|
-
cancelSignal: lifecycle.signal,
|
|
35
36
|
forceKillAfterDelay: 5_000,
|
|
36
37
|
}
|
|
37
38
|
);
|
|
38
|
-
|
|
39
|
+
lifecycle.registerProcess(subprocess, () => {
|
|
40
|
+
killChildProcess(subprocess, "SIGINT");
|
|
41
|
+
});
|
|
42
|
+
if (lifecycle.isStopRequested()) {
|
|
43
|
+
const interruptSubprocess = () => killChildProcess(subprocess, "SIGINT");
|
|
44
|
+
if (subprocess.pid) interruptSubprocess();
|
|
45
|
+
else subprocess.once?.("spawn", interruptSubprocess);
|
|
46
|
+
}
|
|
47
|
+
console.log(`\n── ${targetConfig.runtimeLabel} playwright:${targetConfig.name} (${task.file}) ──`);
|
|
48
|
+
let result;
|
|
49
|
+
let timedOut;
|
|
50
|
+
try {
|
|
51
|
+
({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
|
|
52
|
+
} finally {
|
|
53
|
+
lifecycle.unregisterProcess(subprocess.pid);
|
|
54
|
+
}
|
|
39
55
|
|
|
40
56
|
if (result.stderr) {
|
|
41
57
|
printBufferedOutput(result.stderr, `[${targetConfig.runtimeLabel}:${targetConfig.name}:playwright]`);
|
|
@@ -60,5 +76,6 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
|
60
76
|
durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
|
|
61
77
|
startedAt,
|
|
62
78
|
finishedAt,
|
|
79
|
+
failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
|
|
63
80
|
};
|
|
64
81
|
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -23,9 +23,15 @@ export function buildStatusArtifact({
|
|
|
23
23
|
path: file.path,
|
|
24
24
|
status: file.status,
|
|
25
25
|
};
|
|
26
|
+
if (file.error) {
|
|
27
|
+
test.error = file.error;
|
|
28
|
+
}
|
|
26
29
|
if (file.reason) {
|
|
27
30
|
test.reason = file.reason;
|
|
28
31
|
}
|
|
32
|
+
if (Array.isArray(file.failureDetails) && file.failureDetails.length > 0) {
|
|
33
|
+
test.failureDetails = file.failureDetails;
|
|
34
|
+
}
|
|
29
35
|
tests.push(test);
|
|
30
36
|
}
|
|
31
37
|
}
|
|
@@ -72,7 +78,7 @@ export function buildStatusArtifact({
|
|
|
72
78
|
scope.serviceFilter === null;
|
|
73
79
|
|
|
74
80
|
return {
|
|
75
|
-
schemaVersion:
|
|
81
|
+
schemaVersion: 4,
|
|
76
82
|
source: "testkit",
|
|
77
83
|
notice: "Generated file. Do not edit manually.",
|
|
78
84
|
product: {
|
|
@@ -97,6 +103,7 @@ export function buildRunArtifact({
|
|
|
97
103
|
execution,
|
|
98
104
|
workerCount,
|
|
99
105
|
runtimeInstanceCount,
|
|
106
|
+
runtimeStats,
|
|
100
107
|
typeValues,
|
|
101
108
|
suiteSelectors,
|
|
102
109
|
fileNames,
|
|
@@ -120,7 +127,7 @@ export function buildRunArtifact({
|
|
|
120
127
|
const dbBackend = summarizeDbBackend(results);
|
|
121
128
|
|
|
122
129
|
return {
|
|
123
|
-
schemaVersion:
|
|
130
|
+
schemaVersion: 4,
|
|
124
131
|
source: "testkit",
|
|
125
132
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
126
133
|
product: {
|
|
@@ -138,6 +145,7 @@ export function buildRunArtifact({
|
|
|
138
145
|
fileTimeoutSeconds: execution.fileTimeoutSeconds,
|
|
139
146
|
workerCount,
|
|
140
147
|
runtimeInstanceCount,
|
|
148
|
+
runtimeStats: runtimeStats || [],
|
|
141
149
|
dbBackend,
|
|
142
150
|
types: typeValues,
|
|
143
151
|
suiteSelectors: suiteSelectors.map((selector) => selector.raw),
|
|
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
expect(artifact.product.name).toBe("my-product");
|
|
81
|
-
expect(artifact.schemaVersion).toBe(
|
|
81
|
+
expect(artifact.schemaVersion).toBe(4);
|
|
82
82
|
expect(artifact.run).toMatchObject({
|
|
83
83
|
workers: 2,
|
|
84
84
|
fileTimeoutSeconds: 60,
|
|
@@ -149,7 +149,7 @@ describe("runner reporting", () => {
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
expect(status).toEqual({
|
|
152
|
-
schemaVersion:
|
|
152
|
+
schemaVersion: 4,
|
|
153
153
|
source: "testkit",
|
|
154
154
|
notice: "Generated file. Do not edit manually.",
|
|
155
155
|
product: {
|
package/lib/runner/results.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import { mergeFailureDetails } from "./failure-details.mjs";
|
|
2
3
|
|
|
3
4
|
export function buildServiceTrackers(servicePlans, startedAt) {
|
|
4
5
|
const trackers = new Map();
|
|
@@ -46,6 +47,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
46
47
|
reason: null,
|
|
47
48
|
status: "not_run",
|
|
48
49
|
artifacts: [],
|
|
50
|
+
failureDetails: [],
|
|
49
51
|
},
|
|
50
52
|
];
|
|
51
53
|
}),
|
|
@@ -59,6 +61,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
59
61
|
reason: file.reason,
|
|
60
62
|
status: "skipped",
|
|
61
63
|
artifacts: [],
|
|
64
|
+
failureDetails: [],
|
|
62
65
|
},
|
|
63
66
|
]),
|
|
64
67
|
]),
|
|
@@ -121,6 +124,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
121
124
|
existingFileResult.reason = outcome.reason || null;
|
|
122
125
|
existingFileResult.status = status;
|
|
123
126
|
existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
|
|
127
|
+
existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
|
|
124
128
|
} else {
|
|
125
129
|
suite.fileResultsByPath.set(normalizedPath, {
|
|
126
130
|
path: normalizedPath,
|
|
@@ -130,6 +134,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
130
134
|
reason: outcome.reason || null,
|
|
131
135
|
status,
|
|
132
136
|
artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
|
|
137
|
+
failureDetails: mergeFailureDetails(outcome.failureDetails),
|
|
133
138
|
});
|
|
134
139
|
}
|
|
135
140
|
if (status === "failed" && !suite.failedFileSet.has(task.file)) {
|
|
@@ -247,6 +252,9 @@ function finalizeSuite(suite) {
|
|
|
247
252
|
durationMs: file.durationMs,
|
|
248
253
|
error: file.error,
|
|
249
254
|
reason: file.reason,
|
|
255
|
+
...(Array.isArray(file.failureDetails) && file.failureDetails.length > 0
|
|
256
|
+
? { failureDetails: file.failureDetails }
|
|
257
|
+
: {}),
|
|
250
258
|
...(Array.isArray(file.artifacts) && file.artifacts.length > 0
|
|
251
259
|
? { artifacts: file.artifacts }
|
|
252
260
|
: {}),
|