@elench/testkit 0.1.32 → 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,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,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
+ }