@elench/testkit 0.1.32 → 0.1.34
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/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 -1575
- package/lib/runner/maintenance.mjs +72 -0
- package/lib/runner/orchestrator.mjs +254 -0
- package/lib/runner/playwright-config.mjs +61 -0
- package/lib/runner/playwright-config.test.mjs +58 -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,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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { normalizePathSeparators } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
export function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
7
|
+
const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
|
|
8
|
+
const outputDir = resolvePlaywrightOutputDir(stateDir);
|
|
9
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
10
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
11
|
+
const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
|
|
12
|
+
const baseConfigPath = findPlaywrightConfig(cwd);
|
|
13
|
+
const normalizedFiles = requestedFiles.map(normalizePathSeparators);
|
|
14
|
+
|
|
15
|
+
let source = "";
|
|
16
|
+
if (baseConfigPath) {
|
|
17
|
+
source =
|
|
18
|
+
`import baseConfig from ${JSON.stringify(pathToFileURL(baseConfigPath).href)};\n` +
|
|
19
|
+
`const resolvedBase = typeof baseConfig === "function" ? await baseConfig() : baseConfig;\n` +
|
|
20
|
+
`export default {\n` +
|
|
21
|
+
` ...(resolvedBase || {}),\n` +
|
|
22
|
+
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
23
|
+
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
24
|
+
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
25
|
+
`};\n`;
|
|
26
|
+
} else {
|
|
27
|
+
source =
|
|
28
|
+
`export default {\n` +
|
|
29
|
+
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
30
|
+
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
31
|
+
` outputDir: ${JSON.stringify(outputDir)},\n` +
|
|
32
|
+
`};\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fs.writeFileSync(configPath, source);
|
|
36
|
+
return configPath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolvePlaywrightOutputDir(stateDir) {
|
|
40
|
+
return path.join(stateDir, "playwright-output");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findPlaywrightConfig(cwd) {
|
|
44
|
+
const candidates = [
|
|
45
|
+
"playwright.config.ts",
|
|
46
|
+
"playwright.config.mts",
|
|
47
|
+
"playwright.config.js",
|
|
48
|
+
"playwright.config.mjs",
|
|
49
|
+
"playwright.config.cjs",
|
|
50
|
+
"playwright.config.cts",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const candidatePath = path.join(cwd, candidate);
|
|
55
|
+
if (fs.existsSync(candidatePath)) {
|
|
56
|
+
return candidatePath;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
ensurePlaywrightTestConfig,
|
|
8
|
+
findPlaywrightConfig,
|
|
9
|
+
resolvePlaywrightOutputDir,
|
|
10
|
+
} from "./playwright-config.mjs";
|
|
11
|
+
|
|
12
|
+
const cleanups = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
while (cleanups.length > 0) {
|
|
16
|
+
cleanups.pop()();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function makeTempDir(prefix) {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
22
|
+
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("runner-playwright-config", () => {
|
|
27
|
+
it("uses a shard-local output directory under the state dir", async () => {
|
|
28
|
+
const productDir = makeTempDir("testkit-playwright-product-");
|
|
29
|
+
const stateDir = path.join(productDir, ".testkit", "worker-3");
|
|
30
|
+
const cwd = path.join(productDir, "frontend");
|
|
31
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
path.join(cwd, "playwright.config.mjs"),
|
|
34
|
+
"export default { outputDir: 'shared-test-results' };\n"
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const configPath = ensurePlaywrightTestConfig(
|
|
38
|
+
{ productDir, stateDir },
|
|
39
|
+
cwd,
|
|
40
|
+
["frontend/__testkit__/homepage/homepage.pw.testkit.ts"]
|
|
41
|
+
);
|
|
42
|
+
const generated = await import(pathToFileURL(configPath).href + `?t=${Date.now()}`);
|
|
43
|
+
|
|
44
|
+
const expectedOutputDir = resolvePlaywrightOutputDir(stateDir);
|
|
45
|
+
expect(generated.default.outputDir).toBe(expectedOutputDir);
|
|
46
|
+
expect(fs.existsSync(expectedOutputDir)).toBe(true);
|
|
47
|
+
expect(fs.readFileSync(configPath, "utf8")).toContain(
|
|
48
|
+
`outputDir: ${JSON.stringify(expectedOutputDir)}`
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("finds a supported playwright config file", () => {
|
|
53
|
+
const cwd = makeTempDir("testkit-playwright-cwd-");
|
|
54
|
+
fs.writeFileSync(path.join(cwd, "playwright.config.ts"), "export default {};\n");
|
|
55
|
+
|
|
56
|
+
expect(findPlaywrightConfig(cwd)).toBe(path.join(cwd, "playwright.config.ts"));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
import {
|
|
4
|
+
parsePlaywrightJsonResults,
|
|
5
|
+
} from "../reporters/playwright.mjs";
|
|
6
|
+
import { resolveServiceCwd, } from "../config/index.mjs";
|
|
7
|
+
import { formatPlaywrightBatchFiles } from "./formatting.mjs";
|
|
8
|
+
import { printBufferedOutput } from "./processes.mjs";
|
|
9
|
+
import { ensurePlaywrightTestConfig } from "./playwright-config.mjs";
|
|
10
|
+
import { buildPlaywrightEnv } from "./template.mjs";
|
|
11
|
+
import { normalizePathSeparators } from "./state.mjs";
|
|
12
|
+
|
|
13
|
+
export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
|
|
14
|
+
const local = targetConfig.testkit.local;
|
|
15
|
+
if (!local?.baseUrl) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(
|
|
22
|
+
`\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
26
|
+
const requestedFiles = batch.tasks.map((task) =>
|
|
27
|
+
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
28
|
+
);
|
|
29
|
+
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const result = await execa(
|
|
32
|
+
"npx",
|
|
33
|
+
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
|
|
34
|
+
{
|
|
35
|
+
cwd,
|
|
36
|
+
env: buildPlaywrightEnv(targetConfig, local.baseUrl, process.env),
|
|
37
|
+
reject: false,
|
|
38
|
+
cancelSignal: lifecycle.signal,
|
|
39
|
+
forceKillAfterDelay: 5_000,
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result.stderr) {
|
|
44
|
+
printBufferedOutput(result.stderr, `[${targetConfig.workerLabel}:${targetConfig.name}:playwright]`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
|
|
48
|
+
const finishedAt = Date.now();
|
|
49
|
+
const batchDurationMs = finishedAt - startedAt;
|
|
50
|
+
const genericError =
|
|
51
|
+
result.exitCode === 0
|
|
52
|
+
? parsed.errors[0] || null
|
|
53
|
+
: parsed.errors[0] ||
|
|
54
|
+
result.stderr.trim() ||
|
|
55
|
+
`Playwright exited with code ${result.exitCode}`;
|
|
56
|
+
|
|
57
|
+
return batch.tasks.map((task) => {
|
|
58
|
+
const relativeFile = normalizePathSeparators(
|
|
59
|
+
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
60
|
+
);
|
|
61
|
+
const fileResult = parsed.fileResults.get(relativeFile);
|
|
62
|
+
if (fileResult) {
|
|
63
|
+
return {
|
|
64
|
+
task,
|
|
65
|
+
failed: fileResult.failed,
|
|
66
|
+
error: fileResult.error,
|
|
67
|
+
durationMs:
|
|
68
|
+
fileResult.durationMs > 0
|
|
69
|
+
? fileResult.durationMs
|
|
70
|
+
: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
71
|
+
startedAt,
|
|
72
|
+
finishedAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
task,
|
|
78
|
+
failed: result.exitCode !== 0,
|
|
79
|
+
error: result.exitCode !== 0 ? genericError : null,
|
|
80
|
+
durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
81
|
+
startedAt,
|
|
82
|
+
finishedAt,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
export function startDetachedCommand(command, cwd, env) {
|
|
4
|
+
if (process.platform === "win32") {
|
|
5
|
+
return spawn(command, {
|
|
6
|
+
cwd,
|
|
7
|
+
env,
|
|
8
|
+
detached: true,
|
|
9
|
+
shell: true,
|
|
10
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
15
|
+
return spawn(shell, ["-lc", `exec ${command}`], {
|
|
16
|
+
cwd,
|
|
17
|
+
env,
|
|
18
|
+
detached: true,
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function killChildProcess(child, signal) {
|
|
24
|
+
if (!child?.pid) return;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
process.kill(-child.pid, signal);
|
|
28
|
+
return;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== "ESRCH") {
|
|
31
|
+
// Fall back to the direct child if process-group signalling is unavailable.
|
|
32
|
+
} else {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
child.kill(signal);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code !== "ESRCH") throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pipeOutput(stream, prefix) {
|
|
45
|
+
if (!stream) return Promise.resolve();
|
|
46
|
+
|
|
47
|
+
let pending = "";
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let settled = false;
|
|
50
|
+
const settle = () => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
stream.on("data", (chunk) => {
|
|
57
|
+
pending += chunk.toString();
|
|
58
|
+
const lines = pending.split(/\r?\n/);
|
|
59
|
+
pending = lines.pop() || "";
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
stream.on("end", () => {
|
|
65
|
+
if (pending.length > 0) {
|
|
66
|
+
console.log(`${prefix} ${pending}`);
|
|
67
|
+
}
|
|
68
|
+
settle();
|
|
69
|
+
});
|
|
70
|
+
stream.on("close", settle);
|
|
71
|
+
stream.on("error", settle);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function printBufferedOutput(output, prefix) {
|
|
76
|
+
for (const line of output.split(/\r?\n/)) {
|
|
77
|
+
if (line.trim().length > 0) {
|
|
78
|
+
console.log(`${prefix} ${line}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function stopChildProcess(child, outputDrains = []) {
|
|
84
|
+
if (!child) return;
|
|
85
|
+
if (child.exitCode !== null) {
|
|
86
|
+
await Promise.all(outputDrains);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
killChildProcess(child, "SIGTERM");
|
|
91
|
+
const exited = await Promise.race([
|
|
92
|
+
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
93
|
+
sleep(5_000).then(() => false),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
if (!exited && child.exitCode === null) {
|
|
97
|
+
killChildProcess(child, "SIGKILL");
|
|
98
|
+
await new Promise((resolve) => child.once("exit", resolve));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await Promise.all(outputDrains);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function sleep(ms) {
|
|
105
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
|
+
}
|