@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.
@@ -0,0 +1,53 @@
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
+ fs.mkdirSync(stateDir, { recursive: true });
9
+ const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
10
+ const baseConfigPath = findPlaywrightConfig(cwd);
11
+ const normalizedFiles = requestedFiles.map(normalizePathSeparators);
12
+
13
+ let source = "";
14
+ if (baseConfigPath) {
15
+ source =
16
+ `import baseConfig from ${JSON.stringify(pathToFileURL(baseConfigPath).href)};\n` +
17
+ `const resolvedBase = typeof baseConfig === "function" ? await baseConfig() : baseConfig;\n` +
18
+ `export default {\n` +
19
+ ` ...(resolvedBase || {}),\n` +
20
+ ` testDir: ${JSON.stringify(cwd)},\n` +
21
+ ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
22
+ `};\n`;
23
+ } else {
24
+ source =
25
+ `export default {\n` +
26
+ ` testDir: ${JSON.stringify(cwd)},\n` +
27
+ ` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
28
+ `};\n`;
29
+ }
30
+
31
+ fs.writeFileSync(configPath, source);
32
+ return configPath;
33
+ }
34
+
35
+ export function findPlaywrightConfig(cwd) {
36
+ const candidates = [
37
+ "playwright.config.ts",
38
+ "playwright.config.mts",
39
+ "playwright.config.js",
40
+ "playwright.config.mjs",
41
+ "playwright.config.cjs",
42
+ "playwright.config.cts",
43
+ ];
44
+
45
+ for (const candidate of candidates) {
46
+ const candidatePath = path.join(cwd, candidate);
47
+ if (fs.existsSync(candidatePath)) {
48
+ return candidatePath;
49
+ }
50
+ }
51
+
52
+ return null;
53
+ }
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ import net from "net";
2
+ import {
3
+ cleanupStaleRuns,
4
+ findPortOwner,
5
+ formatRunSummary,
6
+ isPidRunning,
7
+ listRunManifests,
8
+ } from "./lifecycle.mjs";
9
+ import { socketFromUrl } from "./template.mjs";
10
+
11
+ export const DEFAULT_READY_TIMEOUT_MS = 120_000;
12
+
13
+ export async function waitForReady({ name, url, timeoutMs, process, signal, sleep }) {
14
+ const start = Date.now();
15
+
16
+ while (Date.now() - start < timeoutMs) {
17
+ if (signal?.aborted) {
18
+ throw signal.reason || new Error(`Service "${name}" startup aborted`);
19
+ }
20
+ if (process.exitCode !== null) {
21
+ throw new Error(`Service "${name}" exited before becoming ready`);
22
+ }
23
+
24
+ try {
25
+ const response = await fetch(url);
26
+ if (response.ok) return;
27
+ } catch {
28
+ // Service still warming up.
29
+ }
30
+
31
+ await sleep(1_000);
32
+ }
33
+
34
+ throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
35
+ }
36
+
37
+ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
38
+ const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
39
+ const seen = new Set();
40
+
41
+ for (const endpoint of endpoints) {
42
+ const socket = socketFromUrl(endpoint);
43
+ if (!socket) continue;
44
+
45
+ const key = `${socket.host}:${socket.port}`;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+
49
+ if (await isPortInUse(socket)) {
50
+ await cleanupStaleRuns(config.productDir);
51
+ }
52
+
53
+ if (await isPortInUse(socket)) {
54
+ const owner = findPortOwner(config.productDir, socket);
55
+ const ownerDetail = owner
56
+ ? owner.active
57
+ ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.workerLabel}:${owner.service.serviceName}.`
58
+ : ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
59
+ : "";
60
+ throw new Error(
61
+ `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
62
+ `Stop the existing process and rerun testkit.${ownerDetail}`
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ export async function isPortInUse({ host, port }) {
69
+ return new Promise((resolve, reject) => {
70
+ const socket = new net.Socket();
71
+ let settled = false;
72
+
73
+ const finish = (value, error = null) => {
74
+ if (settled) return;
75
+ settled = true;
76
+ socket.destroy();
77
+ if (error) {
78
+ reject(error);
79
+ return;
80
+ }
81
+ resolve(value);
82
+ };
83
+
84
+ socket.setTimeout(1_000);
85
+ socket.once("connect", () => finish(true));
86
+ socket.once("timeout", () => finish(false));
87
+ socket.once("error", (error) => {
88
+ if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
89
+ finish(false);
90
+ return;
91
+ }
92
+ finish(false, error);
93
+ });
94
+
95
+ socket.connect(port, host);
96
+ });
97
+ }
98
+
99
+ export function printRunStatus(productDir) {
100
+ const manifests = listRunManifests(productDir);
101
+ if (manifests.length === 0) return;
102
+
103
+ console.log(" runs/");
104
+ for (const manifest of manifests) {
105
+ const state = isPidRunning(manifest.pid) ? "active" : "stale";
106
+ const ports = [
107
+ ...new Set(
108
+ (manifest.services || []).flatMap((service) =>
109
+ (service.ports || []).map((socket) => `${socket.host}:${socket.port}`)
110
+ )
111
+ ),
112
+ ];
113
+ console.log(
114
+ ` ${manifest.runId}: ${state} pid=${manifest.pid}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`
115
+ );
116
+ }
117
+ }
@@ -0,0 +1,180 @@
1
+ import path from "path";
2
+
3
+ export function buildStatusArtifact({
4
+ productDir,
5
+ results,
6
+ suiteType,
7
+ suiteNames,
8
+ fileNames,
9
+ framework,
10
+ shard,
11
+ serviceFilter,
12
+ metadata,
13
+ }) {
14
+ const executedResults = results.filter((result) => !result.skipped);
15
+ const tests = [];
16
+
17
+ for (const result of executedResults) {
18
+ for (const suite of result.suites) {
19
+ for (const file of suite.files) {
20
+ tests.push({
21
+ service: result.name,
22
+ type: suite.type,
23
+ path: file.path,
24
+ status: file.status,
25
+ });
26
+ }
27
+ }
28
+ }
29
+
30
+ tests.sort(
31
+ (left, right) =>
32
+ left.service.localeCompare(right.service) ||
33
+ left.type.localeCompare(right.type) ||
34
+ left.path.localeCompare(right.path)
35
+ );
36
+
37
+ const summary = {
38
+ services: {
39
+ total: executedResults.length,
40
+ passed: executedResults.filter((result) => !result.failed).length,
41
+ failed: executedResults.filter((result) => result.failed).length,
42
+ },
43
+ tests: {
44
+ total: tests.length,
45
+ passed: tests.filter((test) => test.status === "passed").length,
46
+ failed: tests.filter((test) => test.status === "failed").length,
47
+ notRun: tests.filter((test) => test.status === "not_run").length,
48
+ },
49
+ };
50
+
51
+ const scope = {
52
+ suiteType,
53
+ suiteNames: [...(suiteNames || [])].sort(),
54
+ fileNames: [...(fileNames || [])].sort(),
55
+ framework: formatFrameworkForArtifact(framework || "all"),
56
+ shard: shard || null,
57
+ serviceFilter: serviceFilter || null,
58
+ };
59
+ scope.isFullRun =
60
+ scope.suiteNames.length === 0 &&
61
+ scope.fileNames.length === 0 &&
62
+ scope.framework === "all" &&
63
+ scope.shard === null &&
64
+ scope.serviceFilter === null;
65
+
66
+ return {
67
+ schemaVersion: 1,
68
+ source: "testkit",
69
+ notice: "Generated file. Do not edit manually.",
70
+ product: {
71
+ name: path.basename(productDir),
72
+ },
73
+ git: {
74
+ branch: metadata.git?.branch || null,
75
+ commitSha: metadata.git?.commitSha || null,
76
+ },
77
+ testkitVersion: metadata.testkitVersion,
78
+ scope,
79
+ summary,
80
+ tests,
81
+ };
82
+ }
83
+
84
+ export function buildRunArtifact({
85
+ productDir,
86
+ results,
87
+ startedAt,
88
+ finishedAt,
89
+ requestedJobs,
90
+ workerCount,
91
+ suiteType,
92
+ suiteNames,
93
+ fileNames,
94
+ framework,
95
+ shard,
96
+ serviceFilter,
97
+ metadata,
98
+ summarizeDbBackend,
99
+ }) {
100
+ const executed = results.filter((result) => !result.skipped);
101
+ const failedServices = executed.filter((result) => result.failed);
102
+ const totalSuites = executed.reduce((sum, result) => sum + result.suiteCount, 0);
103
+ const completedSuites = executed.reduce((sum, result) => sum + result.completedSuiteCount, 0);
104
+ const failedSuites = executed.reduce((sum, result) => sum + result.failedSuiteCount, 0);
105
+ const totalFiles = executed.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
106
+ const passedFiles = executed.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
107
+ const failedFiles = executed.reduce((sum, result) => sum + (result.failedFileCount || 0), 0);
108
+ const notRunFiles = executed.reduce((sum, result) => sum + (result.notRunFileCount || 0), 0);
109
+ const dbBackend = summarizeDbBackend(results);
110
+
111
+ return {
112
+ schemaVersion: 1,
113
+ source: "testkit",
114
+ generatedAt: new Date(finishedAt).toISOString(),
115
+ product: {
116
+ name: path.basename(productDir),
117
+ directory: productDir,
118
+ },
119
+ git: metadata.git,
120
+ host: metadata.host,
121
+ run: {
122
+ status: failedServices.length > 0 ? "failed" : "passed",
123
+ startedAt: new Date(startedAt).toISOString(),
124
+ finishedAt: new Date(finishedAt).toISOString(),
125
+ durationMs: finishedAt - startedAt,
126
+ requestedJobs,
127
+ workerCount,
128
+ dbBackend,
129
+ suiteType,
130
+ suiteNames,
131
+ fileNames,
132
+ framework: formatFrameworkForArtifact(framework),
133
+ shard,
134
+ serviceFilter,
135
+ testkitVersion: metadata.testkitVersion,
136
+ },
137
+ summary: {
138
+ services: {
139
+ total: executed.length,
140
+ passed: executed.length - failedServices.length,
141
+ failed: failedServices.length,
142
+ },
143
+ suites: {
144
+ total: totalSuites,
145
+ completed: completedSuites,
146
+ passed: completedSuites - failedSuites,
147
+ failed: failedSuites,
148
+ },
149
+ files: {
150
+ total: totalFiles,
151
+ passed: passedFiles,
152
+ failed: failedFiles,
153
+ notRun: notRunFiles,
154
+ },
155
+ },
156
+ services: results.map((result) => ({
157
+ name: result.name,
158
+ failed: result.failed,
159
+ skipped: result.skipped,
160
+ suiteCount: result.suiteCount,
161
+ completedSuiteCount: result.completedSuiteCount,
162
+ failedSuiteCount: result.failedSuiteCount,
163
+ totalFileCount: result.totalFileCount,
164
+ completedFileCount: result.completedFileCount,
165
+ passedFileCount: result.passedFileCount,
166
+ failedFileCount: result.failedFileCount,
167
+ notRunFileCount: result.notRunFileCount,
168
+ durationMs: result.durationMs,
169
+ totalTaskDurationMs: result.totalTaskDurationMs,
170
+ dbBackend: result.dbBackend,
171
+ suites: result.suites,
172
+ errors: result.errors,
173
+ })),
174
+ };
175
+ }
176
+
177
+ function formatFrameworkForArtifact(framework) {
178
+ if (framework === "k6") return "default";
179
+ return framework;
180
+ }