@elench/testkit 0.1.31 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cli/args.mjs +1 -1
- package/lib/cli/index.mjs +5 -0
- package/lib/runner/index.mjs +198 -103
- package/lib/runner/lifecycle.mjs +349 -0
- package/package.json +1 -1
package/lib/cli/args.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
|
|
3
3
|
export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
|
|
4
|
-
export const LIFECYCLE = new Set(["status", "destroy"]);
|
|
4
|
+
export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
|
|
5
5
|
export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
|
|
6
6
|
|
|
7
7
|
export function resolveCliSelection({ first, second, serviceNames }) {
|
package/lib/cli/index.mjs
CHANGED
|
@@ -56,6 +56,11 @@ export function run() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Lifecycle commands
|
|
59
|
+
if (type === "cleanup") {
|
|
60
|
+
await runner.cleanup(allConfigs[0]?.productDir || process.cwd());
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
if (type === "status" || type === "destroy") {
|
|
60
65
|
for (const config of configs) {
|
|
61
66
|
if (configs.length > 1) console.log(`\n── ${config.name} ──`);
|
package/lib/runner/index.mjs
CHANGED
|
@@ -90,6 +90,16 @@ import {
|
|
|
90
90
|
writeGraphMetadata as writeGraphMetadataModel,
|
|
91
91
|
} from "./state.mjs";
|
|
92
92
|
import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
|
|
93
|
+
import {
|
|
94
|
+
cleanupRunById,
|
|
95
|
+
cleanupRuns,
|
|
96
|
+
cleanupStaleRuns,
|
|
97
|
+
createRunLifecycle,
|
|
98
|
+
findPortOwner,
|
|
99
|
+
formatRunSummary,
|
|
100
|
+
isPidRunning,
|
|
101
|
+
listRunManifests,
|
|
102
|
+
} from "./lifecycle.mjs";
|
|
93
103
|
|
|
94
104
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
95
105
|
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
@@ -100,6 +110,7 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
100
110
|
const startedAt = Date.now();
|
|
101
111
|
const telemetry = configs[0]?.telemetry || null;
|
|
102
112
|
const productDir = configs[0]?.productDir || process.cwd();
|
|
113
|
+
await cleanupStaleRuns(productDir);
|
|
103
114
|
const metadata = {
|
|
104
115
|
git: collectGitMetadata(productDir),
|
|
105
116
|
host: {
|
|
@@ -134,80 +145,97 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
134
145
|
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
135
146
|
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
136
147
|
let workerCount = 0;
|
|
148
|
+
let exitCode = 0;
|
|
149
|
+
const lifecycle = createRunLifecycle(productDir);
|
|
150
|
+
lifecycle.markRunning();
|
|
151
|
+
lifecycle.installSignalHandlers();
|
|
152
|
+
let results = [];
|
|
153
|
+
let finishedAt = Date.now();
|
|
154
|
+
try {
|
|
155
|
+
if (executedPlans.length > 0) {
|
|
156
|
+
const timings = loadTimings(productDir);
|
|
157
|
+
const graphs = buildRuntimeGraphs(executedPlans);
|
|
158
|
+
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
159
|
+
workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
|
|
160
|
+
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
161
|
+
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
162
|
+
createWorker(index + 1, productDir)
|
|
163
|
+
);
|
|
164
|
+
const timingUpdates = [];
|
|
137
165
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
144
|
-
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
145
|
-
createWorker(index + 1, productDir)
|
|
146
|
-
);
|
|
147
|
-
const timingUpdates = [];
|
|
148
|
-
|
|
149
|
-
const workerResults = await Promise.allSettled(
|
|
150
|
-
workers.map((worker) =>
|
|
151
|
-
runWorker(worker, queue, graphByKey, trackers, timingUpdates)
|
|
152
|
-
)
|
|
153
|
-
);
|
|
166
|
+
const workerResults = await Promise.allSettled(
|
|
167
|
+
workers.map((worker) =>
|
|
168
|
+
runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle)
|
|
169
|
+
)
|
|
170
|
+
);
|
|
154
171
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
for (const result of workerResults) {
|
|
173
|
+
if (result.status === "rejected") {
|
|
174
|
+
const message = formatError(result.reason);
|
|
175
|
+
for (const tracker of trackers.values()) {
|
|
176
|
+
if (!tracker.skipped) addTrackerError(tracker, message);
|
|
177
|
+
}
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
saveTimings(productDir, timings, timingUpdates);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const finishedAt = Date.now();
|
|
168
|
-
const results = configs.map((config) =>
|
|
169
|
-
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
170
|
-
);
|
|
171
|
-
const artifact = buildRunArtifact({
|
|
172
|
-
productDir,
|
|
173
|
-
results,
|
|
174
|
-
startedAt,
|
|
175
|
-
finishedAt,
|
|
176
|
-
requestedJobs: opts.jobs || 1,
|
|
177
|
-
workerCount,
|
|
178
|
-
suiteType,
|
|
179
|
-
suiteNames,
|
|
180
|
-
fileNames: requestedFiles,
|
|
181
|
-
framework: opts.framework || "all",
|
|
182
|
-
shard: opts.shard || null,
|
|
183
|
-
serviceFilter: opts.serviceFilter || null,
|
|
184
|
-
metadata,
|
|
185
|
-
});
|
|
186
180
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
181
|
+
saveTimings(productDir, timings, timingUpdates);
|
|
182
|
+
}
|
|
183
|
+
finishedAt = Date.now();
|
|
184
|
+
results = configs.map((config) =>
|
|
185
|
+
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
186
|
+
);
|
|
187
|
+
const artifact = buildRunArtifact({
|
|
190
188
|
productDir,
|
|
191
|
-
|
|
189
|
+
results,
|
|
190
|
+
startedAt,
|
|
191
|
+
finishedAt,
|
|
192
|
+
requestedJobs: opts.jobs || 1,
|
|
193
|
+
workerCount,
|
|
194
|
+
suiteType,
|
|
195
|
+
suiteNames,
|
|
196
|
+
fileNames: requestedFiles,
|
|
197
|
+
framework: opts.framework || "all",
|
|
198
|
+
shard: opts.shard || null,
|
|
199
|
+
serviceFilter: opts.serviceFilter || null,
|
|
200
|
+
metadata,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
writeRunArtifact(productDir, artifact);
|
|
204
|
+
if (opts.writeStatus) {
|
|
205
|
+
writeStatusArtifact(
|
|
192
206
|
productDir,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
207
|
+
buildStatusArtifact({
|
|
208
|
+
productDir,
|
|
209
|
+
results,
|
|
210
|
+
suiteType,
|
|
211
|
+
suiteNames,
|
|
212
|
+
fileNames: requestedFiles,
|
|
213
|
+
framework: opts.framework || "all",
|
|
214
|
+
shard: opts.shard || null,
|
|
215
|
+
serviceFilter: opts.serviceFilter || null,
|
|
216
|
+
metadata,
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
printRunSummary(results, finishedAt - startedAt);
|
|
222
|
+
await reportTelemetry(telemetry, artifact);
|
|
223
|
+
if (results.some((result) => result.failed)) exitCode = 1;
|
|
224
|
+
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
225
|
+
} finally {
|
|
226
|
+
lifecycle.removeSignalHandlers();
|
|
227
|
+
lifecycle.markFinished(
|
|
228
|
+
exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
|
|
202
229
|
);
|
|
230
|
+
await cleanupRunById(productDir, lifecycle.runId);
|
|
231
|
+
await cleanupRuns(productDir, { includeActive: false });
|
|
232
|
+
lifecycle.removeManifest();
|
|
233
|
+
process.exitCode = exitCode;
|
|
203
234
|
}
|
|
204
|
-
|
|
205
|
-
printRunSummary(results, finishedAt - startedAt);
|
|
206
|
-
await reportTelemetry(telemetry, artifact);
|
|
207
|
-
if (results.some((result) => result.failed)) process.exit(1);
|
|
208
235
|
}
|
|
209
236
|
|
|
210
237
|
export async function destroy(config) {
|
|
238
|
+
await cleanupRuns(config.productDir, { includeActive: true });
|
|
211
239
|
const roots = new Set([config.stateDir, ...findGraphDirsForService(config.productDir, config.name)]);
|
|
212
240
|
|
|
213
241
|
for (const rootDir of roots) {
|
|
@@ -227,6 +255,7 @@ export async function destroy(config) {
|
|
|
227
255
|
}
|
|
228
256
|
|
|
229
257
|
export function showStatus(config) {
|
|
258
|
+
printRunStatus(config.productDir);
|
|
230
259
|
const graphDirs = findGraphDirsForService(config.productDir, config.name);
|
|
231
260
|
const hasDirectState = fs.existsSync(config.stateDir);
|
|
232
261
|
const hasGraphState = graphDirs.length > 0;
|
|
@@ -247,6 +276,21 @@ export function showStatus(config) {
|
|
|
247
276
|
showServiceDatabaseStatus(config.productDir, config.name);
|
|
248
277
|
}
|
|
249
278
|
|
|
279
|
+
export async function cleanup(productDir) {
|
|
280
|
+
const summary = await cleanupRuns(productDir, { includeActive: false });
|
|
281
|
+
if (summary.cleaned.length === 0 && summary.skippedActive.length === 0) {
|
|
282
|
+
console.log("No stale runs to clean.");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const manifest of summary.cleaned) {
|
|
287
|
+
console.log(`Cleaned stale run ${formatRunSummary(manifest)}`);
|
|
288
|
+
}
|
|
289
|
+
for (const manifest of summary.skippedActive) {
|
|
290
|
+
console.log(`Active run still present: ${formatRunSummary(manifest)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
250
294
|
function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
251
295
|
return configs.map((config) => {
|
|
252
296
|
console.log(`\n══ ${config.name} ══`);
|
|
@@ -304,19 +348,20 @@ function createWorker(workerId, productDir) {
|
|
|
304
348
|
};
|
|
305
349
|
}
|
|
306
350
|
|
|
307
|
-
async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
|
|
351
|
+
async function runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle) {
|
|
308
352
|
const startedAt = Date.now();
|
|
309
353
|
console.log(`\n══ global worker ${worker.workerId} ══`);
|
|
310
354
|
const errors = [];
|
|
311
355
|
|
|
312
356
|
try {
|
|
313
357
|
while (true) {
|
|
358
|
+
if (lifecycle.isStopRequested()) break;
|
|
314
359
|
const batch = claimNextBatch(queue, worker.currentGraphKey);
|
|
315
360
|
if (!batch) break;
|
|
316
361
|
|
|
317
362
|
try {
|
|
318
|
-
const context = await ensureWorkerGraph(worker, batch, graphByKey);
|
|
319
|
-
const outcomes = await runBatch(context, batch);
|
|
363
|
+
const context = await ensureWorkerGraph(worker, batch, graphByKey, lifecycle);
|
|
364
|
+
const outcomes = await runBatch(context, batch, lifecycle);
|
|
320
365
|
for (const outcome of outcomes) {
|
|
321
366
|
recordTaskOutcome(trackers, outcome.task, outcome);
|
|
322
367
|
timingUpdates.push({
|
|
@@ -329,11 +374,11 @@ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
|
|
|
329
374
|
const message = formatError(error);
|
|
330
375
|
errors.push(message);
|
|
331
376
|
recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
|
|
332
|
-
await resetCurrentGraph(worker);
|
|
377
|
+
await resetCurrentGraph(worker, lifecycle);
|
|
333
378
|
}
|
|
334
379
|
}
|
|
335
380
|
} finally {
|
|
336
|
-
await cleanupWorker(worker);
|
|
381
|
+
await cleanupWorker(worker, lifecycle);
|
|
337
382
|
}
|
|
338
383
|
|
|
339
384
|
return {
|
|
@@ -350,14 +395,14 @@ function claimNextBatch(queue, preferredGraphKey) {
|
|
|
350
395
|
return claimNextBatchModel(queue, preferredGraphKey);
|
|
351
396
|
}
|
|
352
397
|
|
|
353
|
-
async function ensureWorkerGraph(worker, batch, graphByKey) {
|
|
398
|
+
async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
|
|
354
399
|
const graph = graphByKey.get(batch.graphKey);
|
|
355
400
|
if (!graph) {
|
|
356
401
|
throw new Error(`Unknown graph "${batch.graphKey}"`);
|
|
357
402
|
}
|
|
358
403
|
|
|
359
404
|
if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
|
|
360
|
-
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
|
|
405
|
+
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
|
|
361
406
|
worker.graphSwitches += 1;
|
|
362
407
|
worker.currentGraphKey = null;
|
|
363
408
|
}
|
|
@@ -366,6 +411,7 @@ async function ensureWorkerGraph(worker, batch, graphByKey) {
|
|
|
366
411
|
if (!context) {
|
|
367
412
|
context = createGraphContext(worker, graph);
|
|
368
413
|
worker.graphContexts.set(batch.graphKey, context);
|
|
414
|
+
lifecycle.trackGraphContext(context);
|
|
369
415
|
}
|
|
370
416
|
|
|
371
417
|
if (!context.prepared) {
|
|
@@ -374,7 +420,7 @@ async function ensureWorkerGraph(worker, batch, graphByKey) {
|
|
|
374
420
|
}
|
|
375
421
|
|
|
376
422
|
if (batchNeedsLocalRuntime(batch) && !context.started) {
|
|
377
|
-
context.startedServices = await startLocalServices(context.runtimeConfigs);
|
|
423
|
+
context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
|
|
378
424
|
context.started = true;
|
|
379
425
|
}
|
|
380
426
|
|
|
@@ -407,40 +453,40 @@ function createGraphContext(worker, graph) {
|
|
|
407
453
|
};
|
|
408
454
|
}
|
|
409
455
|
|
|
410
|
-
async function deactivateGraphContext(context) {
|
|
456
|
+
async function deactivateGraphContext(context, lifecycle) {
|
|
411
457
|
if (!context?.started) return;
|
|
412
|
-
await stopLocalServices(context.startedServices);
|
|
458
|
+
await stopLocalServices(context.startedServices, lifecycle);
|
|
413
459
|
context.started = false;
|
|
414
460
|
context.startedServices = [];
|
|
415
461
|
}
|
|
416
462
|
|
|
417
|
-
async function resetCurrentGraph(worker) {
|
|
463
|
+
async function resetCurrentGraph(worker, lifecycle) {
|
|
418
464
|
if (!worker.currentGraphKey) return;
|
|
419
|
-
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
|
|
465
|
+
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
|
|
420
466
|
worker.currentGraphKey = null;
|
|
421
467
|
}
|
|
422
468
|
|
|
423
|
-
async function cleanupWorker(worker) {
|
|
469
|
+
async function cleanupWorker(worker, lifecycle) {
|
|
424
470
|
for (const context of worker.graphContexts.values()) {
|
|
425
|
-
await deactivateGraphContext(context);
|
|
471
|
+
await deactivateGraphContext(context, lifecycle);
|
|
426
472
|
}
|
|
427
473
|
worker.currentGraphKey = null;
|
|
428
474
|
}
|
|
429
475
|
|
|
430
|
-
async function runBatch(context, batch) {
|
|
476
|
+
async function runBatch(context, batch, lifecycle) {
|
|
431
477
|
const targetConfig = context.configByName.get(batch.targetName);
|
|
432
478
|
if (!targetConfig) {
|
|
433
479
|
throw new Error(`Worker graph missing target config "${batch.targetName}"`);
|
|
434
480
|
}
|
|
435
481
|
|
|
436
482
|
if (batch.framework === "playwright") {
|
|
437
|
-
return runPlaywrightBatch(targetConfig, batch);
|
|
483
|
+
return runPlaywrightBatch(targetConfig, batch, lifecycle);
|
|
438
484
|
}
|
|
439
485
|
if (batch.type === "dal") {
|
|
440
|
-
return runDalBatch(targetConfig, batch);
|
|
486
|
+
return runDalBatch(targetConfig, batch, lifecycle);
|
|
441
487
|
}
|
|
442
488
|
if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
|
|
443
|
-
return runHttpK6Batch(targetConfig, batch);
|
|
489
|
+
return runHttpK6Batch(targetConfig, batch, lifecycle);
|
|
444
490
|
}
|
|
445
491
|
|
|
446
492
|
throw new Error(
|
|
@@ -491,13 +537,13 @@ async function runSeed(config, databaseUrl) {
|
|
|
491
537
|
});
|
|
492
538
|
}
|
|
493
539
|
|
|
494
|
-
async function startLocalServices(runtimeConfigs) {
|
|
540
|
+
async function startLocalServices(runtimeConfigs, lifecycle) {
|
|
495
541
|
const started = [];
|
|
496
542
|
|
|
497
543
|
try {
|
|
498
544
|
for (const config of runtimeConfigs) {
|
|
499
545
|
if (!config.testkit.local) continue;
|
|
500
|
-
const proc = await startLocalService(config);
|
|
546
|
+
const proc = await startLocalService(config, lifecycle);
|
|
501
547
|
started.push(proc);
|
|
502
548
|
}
|
|
503
549
|
} catch (error) {
|
|
@@ -508,7 +554,7 @@ async function startLocalServices(runtimeConfigs) {
|
|
|
508
554
|
return started;
|
|
509
555
|
}
|
|
510
556
|
|
|
511
|
-
async function startLocalService(config) {
|
|
557
|
+
async function startLocalService(config, lifecycle) {
|
|
512
558
|
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
513
559
|
const env = buildExecutionEnv(config, config.testkit.local.env);
|
|
514
560
|
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
@@ -524,18 +570,13 @@ async function startLocalService(config) {
|
|
|
524
570
|
await assertLocalServicePortsAvailable(config);
|
|
525
571
|
|
|
526
572
|
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
527
|
-
const child =
|
|
528
|
-
cwd,
|
|
529
|
-
env,
|
|
530
|
-
detached: true,
|
|
531
|
-
shell: true,
|
|
532
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
533
|
-
});
|
|
573
|
+
const child = startDetachedCommand(config.testkit.local.start, cwd, env);
|
|
534
574
|
|
|
535
575
|
const outputDrains = [
|
|
536
576
|
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
|
|
537
577
|
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
|
|
538
578
|
];
|
|
579
|
+
lifecycle.registerService(config, child, cwd);
|
|
539
580
|
|
|
540
581
|
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
541
582
|
|
|
@@ -545,16 +586,18 @@ async function startLocalService(config) {
|
|
|
545
586
|
url: config.testkit.local.readyUrl,
|
|
546
587
|
timeoutMs: readyTimeoutMs,
|
|
547
588
|
process: child,
|
|
589
|
+
signal: lifecycle.signal,
|
|
548
590
|
});
|
|
549
591
|
} catch (error) {
|
|
550
592
|
await stopChildProcess(child, outputDrains);
|
|
593
|
+
lifecycle.unregisterService(child.pid);
|
|
551
594
|
throw error;
|
|
552
595
|
}
|
|
553
596
|
|
|
554
597
|
return { name: config.name, child, outputDrains };
|
|
555
598
|
}
|
|
556
599
|
|
|
557
|
-
async function runHttpK6Batch(targetConfig, batch) {
|
|
600
|
+
async function runHttpK6Batch(targetConfig, batch, lifecycle) {
|
|
558
601
|
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
559
602
|
if (!baseUrl) {
|
|
560
603
|
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
|
|
@@ -565,11 +608,11 @@ async function runHttpK6Batch(targetConfig, batch) {
|
|
|
565
608
|
);
|
|
566
609
|
|
|
567
610
|
return Promise.all(
|
|
568
|
-
batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl))
|
|
611
|
+
batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
|
|
569
612
|
);
|
|
570
613
|
}
|
|
571
614
|
|
|
572
|
-
async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
615
|
+
async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
|
|
573
616
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
574
617
|
const k6Binary = resolveK6Binary();
|
|
575
618
|
const bundledFile = await bundleK6File({
|
|
@@ -585,10 +628,10 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
|
585
628
|
"-e",
|
|
586
629
|
`BASE_URL=${baseUrl}`,
|
|
587
630
|
bundledFile,
|
|
588
|
-
]);
|
|
631
|
+
], lifecycle);
|
|
589
632
|
}
|
|
590
633
|
|
|
591
|
-
async function runDalBatch(targetConfig, batch) {
|
|
634
|
+
async function runDalBatch(targetConfig, batch, lifecycle) {
|
|
592
635
|
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
593
636
|
if (!databaseUrl) {
|
|
594
637
|
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
@@ -599,11 +642,11 @@ async function runDalBatch(targetConfig, batch) {
|
|
|
599
642
|
);
|
|
600
643
|
|
|
601
644
|
return Promise.all(
|
|
602
|
-
batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl))
|
|
645
|
+
batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
|
|
603
646
|
);
|
|
604
647
|
}
|
|
605
648
|
|
|
606
|
-
async function runDalTask(targetConfig, task, databaseUrl) {
|
|
649
|
+
async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
|
|
607
650
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
608
651
|
const k6Binary = resolveK6Binary();
|
|
609
652
|
const bundledFile = await bundleK6File({
|
|
@@ -619,10 +662,10 @@ async function runDalTask(targetConfig, task, databaseUrl) {
|
|
|
619
662
|
"-e",
|
|
620
663
|
`DATABASE_URL=${databaseUrl}`,
|
|
621
664
|
bundledFile,
|
|
622
|
-
]);
|
|
665
|
+
], lifecycle);
|
|
623
666
|
}
|
|
624
667
|
|
|
625
|
-
async function runPlaywrightBatch(targetConfig, batch) {
|
|
668
|
+
async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
|
|
626
669
|
const local = targetConfig.testkit.local;
|
|
627
670
|
if (!local?.baseUrl) {
|
|
628
671
|
throw new Error(
|
|
@@ -647,6 +690,8 @@ async function runPlaywrightBatch(targetConfig, batch) {
|
|
|
647
690
|
cwd,
|
|
648
691
|
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
649
692
|
reject: false,
|
|
693
|
+
cancelSignal: lifecycle.signal,
|
|
694
|
+
forceKillAfterDelay: 5_000,
|
|
650
695
|
}
|
|
651
696
|
);
|
|
652
697
|
|
|
@@ -694,9 +739,10 @@ async function runPlaywrightBatch(targetConfig, batch) {
|
|
|
694
739
|
});
|
|
695
740
|
}
|
|
696
741
|
|
|
697
|
-
async function stopLocalServices(started) {
|
|
742
|
+
async function stopLocalServices(started, lifecycle) {
|
|
698
743
|
for (const service of [...started].reverse()) {
|
|
699
744
|
await stopChildProcess(service.child, service.outputDrains);
|
|
745
|
+
lifecycle?.unregisterService(service.child.pid);
|
|
700
746
|
}
|
|
701
747
|
}
|
|
702
748
|
|
|
@@ -721,10 +767,13 @@ async function stopChildProcess(child, outputDrains = []) {
|
|
|
721
767
|
await Promise.all(outputDrains);
|
|
722
768
|
}
|
|
723
769
|
|
|
724
|
-
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
770
|
+
async function waitForReady({ name, url, timeoutMs, process, signal }) {
|
|
725
771
|
const start = Date.now();
|
|
726
772
|
|
|
727
773
|
while (Date.now() - start < timeoutMs) {
|
|
774
|
+
if (signal?.aborted) {
|
|
775
|
+
throw signal.reason || new Error(`Service "${name}" startup aborted`);
|
|
776
|
+
}
|
|
728
777
|
if (process.exitCode !== null) {
|
|
729
778
|
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
730
779
|
}
|
|
@@ -1085,7 +1134,7 @@ function formatError(error) {
|
|
|
1085
1134
|
return formatErrorModel(error);
|
|
1086
1135
|
}
|
|
1087
1136
|
|
|
1088
|
-
async function runDefaultRuntimeTask(targetConfig, task, args) {
|
|
1137
|
+
async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle) {
|
|
1089
1138
|
const k6Binary = resolveK6Binary();
|
|
1090
1139
|
const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
|
|
1091
1140
|
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
@@ -1094,6 +1143,8 @@ async function runDefaultRuntimeTask(targetConfig, task, args) {
|
|
|
1094
1143
|
cwd: targetConfig.productDir,
|
|
1095
1144
|
env: buildExecutionEnv(targetConfig),
|
|
1096
1145
|
reject: false,
|
|
1146
|
+
cancelSignal: lifecycle.signal,
|
|
1147
|
+
forceKillAfterDelay: 5_000,
|
|
1097
1148
|
});
|
|
1098
1149
|
|
|
1099
1150
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
@@ -1317,14 +1368,38 @@ async function assertLocalServicePortsAvailable(config) {
|
|
|
1317
1368
|
seen.add(key);
|
|
1318
1369
|
|
|
1319
1370
|
if (await isPortInUse(socket)) {
|
|
1371
|
+
await cleanupStaleRuns(config.productDir);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (await isPortInUse(socket)) {
|
|
1375
|
+
const owner = findPortOwner(config.productDir, socket);
|
|
1376
|
+
const ownerDetail = owner
|
|
1377
|
+
? owner.active
|
|
1378
|
+
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.workerLabel}:${owner.service.serviceName}.`
|
|
1379
|
+
: ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
|
|
1380
|
+
: "";
|
|
1320
1381
|
throw new Error(
|
|
1321
1382
|
`Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
|
|
1322
|
-
`Stop the existing process and rerun testkit
|
|
1383
|
+
`Stop the existing process and rerun testkit.${ownerDetail}`
|
|
1323
1384
|
);
|
|
1324
1385
|
}
|
|
1325
1386
|
}
|
|
1326
1387
|
}
|
|
1327
1388
|
|
|
1389
|
+
function printRunStatus(productDir) {
|
|
1390
|
+
const manifests = listRunManifests(productDir);
|
|
1391
|
+
if (manifests.length === 0) return;
|
|
1392
|
+
|
|
1393
|
+
console.log(" runs/");
|
|
1394
|
+
for (const manifest of manifests) {
|
|
1395
|
+
const state = isPidRunning(manifest.pid) ? "active" : "stale";
|
|
1396
|
+
const ports = [...new Set((manifest.services || []).flatMap((service) => (service.ports || []).map((socket) => `${socket.host}:${socket.port}`)))];
|
|
1397
|
+
console.log(
|
|
1398
|
+
` ${manifest.runId}: ${state} pid=${manifest.pid}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1328
1403
|
function socketFromUrl(rawUrl) {
|
|
1329
1404
|
return socketFromUrlModel(rawUrl);
|
|
1330
1405
|
}
|
|
@@ -1385,6 +1460,26 @@ function killChildProcess(child, signal) {
|
|
|
1385
1460
|
}
|
|
1386
1461
|
}
|
|
1387
1462
|
|
|
1463
|
+
function startDetachedCommand(command, cwd, env) {
|
|
1464
|
+
if (process.platform === "win32") {
|
|
1465
|
+
return spawn(command, {
|
|
1466
|
+
cwd,
|
|
1467
|
+
env,
|
|
1468
|
+
detached: true,
|
|
1469
|
+
shell: true,
|
|
1470
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const shell = process.env.SHELL || "/bin/sh";
|
|
1475
|
+
return spawn(shell, ["-lc", `exec ${command}`], {
|
|
1476
|
+
cwd,
|
|
1477
|
+
env,
|
|
1478
|
+
detached: true,
|
|
1479
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1388
1483
|
function readDatabaseUrl(stateDir) {
|
|
1389
1484
|
return readStateValue(path.join(stateDir, "database_url"));
|
|
1390
1485
|
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
|
|
5
|
+
|
|
6
|
+
const RUN_SCHEMA_VERSION = 1;
|
|
7
|
+
const RUNS_DIRNAME = path.join(".testkit", "_runs");
|
|
8
|
+
const TERMINATION_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
export function createRunLifecycle(productDir) {
|
|
11
|
+
const runId = buildRunId();
|
|
12
|
+
const manifestPath = path.join(getRunsDir(productDir), `${runId}.json`);
|
|
13
|
+
const abortController = new AbortController();
|
|
14
|
+
const state = {
|
|
15
|
+
schemaVersion: RUN_SCHEMA_VERSION,
|
|
16
|
+
runId,
|
|
17
|
+
productDir,
|
|
18
|
+
pid: process.pid,
|
|
19
|
+
status: "starting",
|
|
20
|
+
startedAt: new Date().toISOString(),
|
|
21
|
+
interruptReason: null,
|
|
22
|
+
services: [],
|
|
23
|
+
graphDirs: [],
|
|
24
|
+
workerStateDirs: [],
|
|
25
|
+
runtimeStateDirs: [],
|
|
26
|
+
};
|
|
27
|
+
const signalListeners = [];
|
|
28
|
+
|
|
29
|
+
function persist() {
|
|
30
|
+
fs.mkdirSync(getRunsDir(productDir), { recursive: true });
|
|
31
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mutate(mutator) {
|
|
35
|
+
mutator(state);
|
|
36
|
+
persist();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const api = {
|
|
40
|
+
runId,
|
|
41
|
+
manifestPath,
|
|
42
|
+
signal: abortController.signal,
|
|
43
|
+
isStopRequested() {
|
|
44
|
+
return abortController.signal.aborted;
|
|
45
|
+
},
|
|
46
|
+
markRunning() {
|
|
47
|
+
mutate((draft) => {
|
|
48
|
+
draft.status = "running";
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
markFinished(status = "finished") {
|
|
52
|
+
mutate((draft) => {
|
|
53
|
+
draft.status = status;
|
|
54
|
+
draft.finishedAt = new Date().toISOString();
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
requestStop(reason = "interrupted") {
|
|
58
|
+
if (!abortController.signal.aborted) {
|
|
59
|
+
abortController.abort(new Error(`testkit run interrupted (${reason})`));
|
|
60
|
+
}
|
|
61
|
+
mutate((draft) => {
|
|
62
|
+
draft.status = "interrupting";
|
|
63
|
+
draft.interruptReason = reason;
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
trackGraphContext(context) {
|
|
67
|
+
mutate((draft) => {
|
|
68
|
+
pushUnique(draft.graphDirs, context.graphDir);
|
|
69
|
+
pushUnique(draft.workerStateDirs, context.workerStateDir);
|
|
70
|
+
for (const runtimeConfig of context.runtimeConfigs || []) {
|
|
71
|
+
if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
registerService(config, child, cwd) {
|
|
76
|
+
const ports = collectConfigPorts(config);
|
|
77
|
+
mutate((draft) => {
|
|
78
|
+
draft.services = draft.services.filter((service) => service.pid !== child.pid);
|
|
79
|
+
draft.services.push({
|
|
80
|
+
serviceName: config.name,
|
|
81
|
+
workerLabel: config.workerLabel,
|
|
82
|
+
command: config.testkit.local?.start || null,
|
|
83
|
+
cwd,
|
|
84
|
+
pid: child.pid,
|
|
85
|
+
processGroupId: child.pid,
|
|
86
|
+
ports,
|
|
87
|
+
startedAt: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
unregisterService(childPid) {
|
|
92
|
+
mutate((draft) => {
|
|
93
|
+
draft.services = draft.services.filter((service) => service.pid !== childPid);
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
installSignalHandlers() {
|
|
97
|
+
const install = (eventName, handler) => {
|
|
98
|
+
process.on(eventName, handler);
|
|
99
|
+
signalListeners.push([eventName, handler]);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleSignal = (signalName) => {
|
|
103
|
+
api.requestStop(signalName);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
install("SIGINT", () => handleSignal("SIGINT"));
|
|
107
|
+
install("SIGTERM", () => handleSignal("SIGTERM"));
|
|
108
|
+
install("SIGHUP", () => handleSignal("SIGHUP"));
|
|
109
|
+
},
|
|
110
|
+
removeSignalHandlers() {
|
|
111
|
+
while (signalListeners.length > 0) {
|
|
112
|
+
const [eventName, handler] = signalListeners.pop();
|
|
113
|
+
process.off(eventName, handler);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
removeManifest() {
|
|
117
|
+
removeManifestFile(productDir, runId);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return api;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function cleanupRuns(productDir, { includeActive = false } = {}) {
|
|
125
|
+
const manifests = listRunManifests(productDir);
|
|
126
|
+
const summary = {
|
|
127
|
+
cleaned: [],
|
|
128
|
+
skippedActive: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const manifest of manifests) {
|
|
132
|
+
const isActive = isPidRunning(manifest.pid);
|
|
133
|
+
if (isActive && !includeActive) {
|
|
134
|
+
summary.skippedActive.push(manifest);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
138
|
+
summary.cleaned.push(manifest);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
142
|
+
return summary;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function cleanupStaleRuns(productDir) {
|
|
146
|
+
const manifests = listRunManifests(productDir);
|
|
147
|
+
const cleaned = [];
|
|
148
|
+
|
|
149
|
+
for (const manifest of manifests) {
|
|
150
|
+
if (isPidRunning(manifest.pid)) continue;
|
|
151
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
152
|
+
cleaned.push(manifest);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
156
|
+
return cleaned;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function cleanupRunById(productDir, runId) {
|
|
160
|
+
const manifest = listRunManifests(productDir).find((entry) => entry.runId === runId);
|
|
161
|
+
if (!manifest) return false;
|
|
162
|
+
await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
|
|
163
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listRunManifests(productDir) {
|
|
168
|
+
const runsDir = getRunsDir(productDir);
|
|
169
|
+
if (!fs.existsSync(runsDir)) return [];
|
|
170
|
+
|
|
171
|
+
return fs
|
|
172
|
+
.readdirSync(runsDir)
|
|
173
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
174
|
+
.map((entry) => readRunManifest(path.join(runsDir, entry)))
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.sort((left, right) => String(left.startedAt || "").localeCompare(String(right.startedAt || "")));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function findPortOwner(productDir, { host, port }) {
|
|
180
|
+
const manifests = listRunManifests(productDir);
|
|
181
|
+
for (const manifest of manifests) {
|
|
182
|
+
for (const service of manifest.services || []) {
|
|
183
|
+
for (const socket of service.ports || []) {
|
|
184
|
+
if (normalizeHost(socket.host) === normalizeHost(host) && Number(socket.port) === Number(port)) {
|
|
185
|
+
return {
|
|
186
|
+
manifest,
|
|
187
|
+
service,
|
|
188
|
+
active: isPidRunning(manifest.pid),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function formatRunSummary(manifest) {
|
|
198
|
+
const workerLabels = [...new Set((manifest.services || []).map((service) => service.workerLabel).filter(Boolean))];
|
|
199
|
+
return `${manifest.runId} pid=${manifest.pid}${workerLabels.length > 0 ? ` workers=${workerLabels.join(",")}` : ""}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isPidRunning(pid) {
|
|
203
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
204
|
+
try {
|
|
205
|
+
process.kill(Number(pid), 0);
|
|
206
|
+
return true;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return error?.code === "EPERM";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = false } = {}) {
|
|
213
|
+
for (const service of [...(manifest.services || [])].reverse()) {
|
|
214
|
+
await terminateOwnedProcess(service);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (removeRuntimeState) {
|
|
218
|
+
const runtimeStateDirs = [...new Set(manifest.runtimeStateDirs || [])].sort((a, b) => b.length - a.length);
|
|
219
|
+
for (const stateDir of runtimeStateDirs) {
|
|
220
|
+
await destroyRuntimeDatabase({ productDir, stateDir });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const workerStateDir of [...new Set(manifest.workerStateDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
224
|
+
fs.rmSync(workerStateDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
228
|
+
pruneEmptyParents(graphDir, path.join(productDir, ".testkit", "_graphs"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
removeManifestFile(productDir, manifest.runId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function terminateOwnedProcess(service) {
|
|
236
|
+
const pid = Number(service.processGroupId || service.pid);
|
|
237
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
238
|
+
if (!isPidRunning(pid)) return;
|
|
239
|
+
|
|
240
|
+
killProcessGroup(pid, "SIGTERM");
|
|
241
|
+
const exited = await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
242
|
+
if (!exited) {
|
|
243
|
+
killProcessGroup(pid, "SIGKILL");
|
|
244
|
+
await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function killProcessGroup(pid, signal) {
|
|
249
|
+
try {
|
|
250
|
+
process.kill(-pid, signal);
|
|
251
|
+
return;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error?.code !== "ESRCH") {
|
|
254
|
+
// Fall through and try the direct pid.
|
|
255
|
+
} else {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, signal);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code !== "ESRCH") throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
268
|
+
const startedAt = Date.now();
|
|
269
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
270
|
+
if (!isPidRunning(pid)) return true;
|
|
271
|
+
await sleep(100);
|
|
272
|
+
}
|
|
273
|
+
return !isPidRunning(pid);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readRunManifest(filePath) {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
279
|
+
if (!parsed?.runId) return null;
|
|
280
|
+
return parsed;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function collectConfigPorts(config) {
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
const ports = [];
|
|
289
|
+
for (const rawUrl of [config.testkit.local?.baseUrl, config.testkit.local?.readyUrl]) {
|
|
290
|
+
if (!rawUrl) continue;
|
|
291
|
+
try {
|
|
292
|
+
const parsed = new URL(rawUrl);
|
|
293
|
+
const host = normalizeHost(parsed.hostname);
|
|
294
|
+
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
295
|
+
const key = `${host}:${port}`;
|
|
296
|
+
if (seen.has(key)) continue;
|
|
297
|
+
seen.add(key);
|
|
298
|
+
ports.push({ host, port });
|
|
299
|
+
} catch {
|
|
300
|
+
// Ignore malformed URLs here; startup validation handles them elsewhere.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return ports;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function pushUnique(list, value) {
|
|
307
|
+
if (!value) return;
|
|
308
|
+
if (!list.includes(value)) list.push(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getRunsDir(productDir) {
|
|
312
|
+
return path.join(productDir, RUNS_DIRNAME);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildRunId() {
|
|
316
|
+
return `${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function normalizeHost(host) {
|
|
320
|
+
if (!host) return "127.0.0.1";
|
|
321
|
+
return host === "::1" ? "127.0.0.1" : host;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function pruneEmptyParents(startDir, stopDir) {
|
|
325
|
+
let current = startDir;
|
|
326
|
+
while (current && current.startsWith(stopDir)) {
|
|
327
|
+
if (!fs.existsSync(current)) {
|
|
328
|
+
current = path.dirname(current);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const entries = fs.readdirSync(current);
|
|
332
|
+
if (entries.length > 0) break;
|
|
333
|
+
fs.rmSync(current, { recursive: true, force: true });
|
|
334
|
+
if (current === stopDir) break;
|
|
335
|
+
current = path.dirname(current);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sleep(ms) {
|
|
340
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function removeManifestFile(productDir, runId) {
|
|
344
|
+
const runsDir = getRunsDir(productDir);
|
|
345
|
+
fs.rmSync(path.join(runsDir, `${runId}.json`), { force: true });
|
|
346
|
+
if (fs.existsSync(runsDir) && fs.readdirSync(runsDir).length === 0) {
|
|
347
|
+
fs.rmSync(runsDir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
}
|