@elench/testkit 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/lib/cli.mjs +2 -1
- package/lib/config.mjs +237 -33
- package/lib/database.mjs +656 -0
- package/lib/runner.mjs +275 -98
- package/package.json +1 -1
package/lib/runner.mjs
CHANGED
|
@@ -3,12 +3,15 @@ import path from "path";
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import net from "net";
|
|
5
5
|
import { execa, execaCommand } from "execa";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveDalBinary, resolveServiceCwd } from "./config.mjs";
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
cleanupOrphanedLocalInfrastructure,
|
|
9
|
+
destroyRuntimeDatabase,
|
|
10
|
+
destroyServiceDatabaseCache,
|
|
11
|
+
isDatabaseStateDir,
|
|
12
|
+
prepareDatabaseRuntime,
|
|
13
|
+
showServiceDatabaseStatus,
|
|
14
|
+
} from "./database.mjs";
|
|
12
15
|
|
|
13
16
|
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
14
17
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
@@ -18,6 +21,7 @@ const PORT_STRIDE = 100;
|
|
|
18
21
|
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
19
22
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
20
23
|
const targetSpan = Math.max(1, opts.jobs || 1);
|
|
24
|
+
const startedAt = Date.now();
|
|
21
25
|
const results = await Promise.all(
|
|
22
26
|
configs.map(async (config, targetSlot) => {
|
|
23
27
|
console.log(`\n══ ${config.name} ══`);
|
|
@@ -28,7 +32,8 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
28
32
|
})
|
|
29
33
|
);
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
printRunSummary(results, Date.now() - startedAt);
|
|
36
|
+
if (results.some((result) => result.failed)) process.exit(1);
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export async function destroy(config) {
|
|
@@ -36,28 +41,28 @@ export async function destroy(config) {
|
|
|
36
41
|
|
|
37
42
|
const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
|
|
38
43
|
for (const stateDir of runtimeStateDirs) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
await runScript("neon-down.sh", {
|
|
43
|
-
NEON_PROJECT_ID: projectId,
|
|
44
|
-
STATE_DIR: stateDir,
|
|
44
|
+
await destroyRuntimeDatabase({
|
|
45
|
+
productDir: config.productDir,
|
|
46
|
+
stateDir,
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
await destroyServiceDatabaseCache(config.productDir, config.name);
|
|
48
51
|
fs.rmSync(config.stateDir, { recursive: true, force: true });
|
|
52
|
+
await cleanupOrphanedLocalInfrastructure(config.productDir);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export function showStatus(config) {
|
|
52
56
|
if (!fs.existsSync(config.stateDir)) {
|
|
53
57
|
console.log("No state — run tests first.");
|
|
54
|
-
|
|
58
|
+
} else {
|
|
59
|
+
printStateDir(config.stateDir, " ");
|
|
55
60
|
}
|
|
56
|
-
|
|
57
|
-
printStateDir(config.stateDir, " ");
|
|
61
|
+
showServiceDatabaseStatus(config.productDir, config.name);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
|
|
65
|
+
const startedAt = Date.now();
|
|
61
66
|
const suites = applyShard(
|
|
62
67
|
collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
|
|
63
68
|
opts.shard
|
|
@@ -66,7 +71,17 @@ async function runService(targetConfig, configMap, suiteType, suiteNames, opts,
|
|
|
66
71
|
console.log(
|
|
67
72
|
`No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
68
73
|
);
|
|
69
|
-
return
|
|
74
|
+
return {
|
|
75
|
+
name: targetConfig.name,
|
|
76
|
+
failed: false,
|
|
77
|
+
skipped: true,
|
|
78
|
+
suiteCount: 0,
|
|
79
|
+
completedSuiteCount: 0,
|
|
80
|
+
failedSuiteCount: 0,
|
|
81
|
+
durationMs: Date.now() - startedAt,
|
|
82
|
+
workers: [],
|
|
83
|
+
errors: [],
|
|
84
|
+
};
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
|
|
@@ -83,17 +98,35 @@ async function runService(targetConfig, configMap, suiteType, suiteNames, opts,
|
|
|
83
98
|
|
|
84
99
|
const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
|
|
85
100
|
let failed = false;
|
|
101
|
+
const workers = [];
|
|
102
|
+
const errors = [];
|
|
103
|
+
let failedSuiteCount = 0;
|
|
104
|
+
let completedSuiteCount = 0;
|
|
86
105
|
|
|
87
106
|
for (const result of results) {
|
|
88
107
|
if (result.status === "rejected") {
|
|
89
108
|
failed = true;
|
|
90
109
|
console.error(result.reason);
|
|
110
|
+
errors.push(formatError(result.reason));
|
|
91
111
|
continue;
|
|
92
112
|
}
|
|
93
|
-
|
|
113
|
+
workers.push(result.value);
|
|
114
|
+
completedSuiteCount += result.value.completedSuiteCount;
|
|
115
|
+
failedSuiteCount += result.value.failedSuiteCount;
|
|
116
|
+
if (result.value.failed) failed = true;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
return
|
|
119
|
+
return {
|
|
120
|
+
name: targetConfig.name,
|
|
121
|
+
failed,
|
|
122
|
+
skipped: false,
|
|
123
|
+
suiteCount: suites.length,
|
|
124
|
+
completedSuiteCount,
|
|
125
|
+
failedSuiteCount,
|
|
126
|
+
durationMs: Date.now() - startedAt,
|
|
127
|
+
workers,
|
|
128
|
+
errors,
|
|
129
|
+
};
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
@@ -356,9 +389,12 @@ function resolveWorkerConfig(
|
|
|
356
389
|
? {
|
|
357
390
|
...config.testkit.database,
|
|
358
391
|
branchName:
|
|
392
|
+
config.testkit.database.provider === "neon" &&
|
|
359
393
|
config.testkit.database.branchName !== undefined
|
|
360
394
|
? finalizeString(config.testkit.database.branchName, context)
|
|
361
|
-
:
|
|
395
|
+
: config.testkit.database.provider === "neon"
|
|
396
|
+
? `${targetConfig.name}-${config.name}-w${workerId}-testkit`
|
|
397
|
+
: undefined,
|
|
362
398
|
}
|
|
363
399
|
: undefined;
|
|
364
400
|
|
|
@@ -421,17 +457,18 @@ function resolveWorkerConfig(
|
|
|
421
457
|
}
|
|
422
458
|
|
|
423
459
|
async function runWorkerPlan(plan) {
|
|
460
|
+
const startedAt = Date.now();
|
|
424
461
|
console.log(
|
|
425
462
|
`\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
|
|
426
463
|
);
|
|
427
464
|
|
|
428
465
|
let startedServices = [];
|
|
429
466
|
let failed = false;
|
|
467
|
+
const suiteResults = [];
|
|
468
|
+
let fatalError = null;
|
|
430
469
|
|
|
431
470
|
try {
|
|
432
471
|
await prepareDatabases(plan.runtimeConfigs);
|
|
433
|
-
await runMigrations(plan.runtimeConfigs);
|
|
434
|
-
await runSeeds(plan.runtimeConfigs);
|
|
435
472
|
|
|
436
473
|
if (needsLocalRuntime(plan.suites)) {
|
|
437
474
|
startedServices = await startLocalServices(plan.runtimeConfigs);
|
|
@@ -442,71 +479,70 @@ async function runWorkerPlan(plan) {
|
|
|
442
479
|
`\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
|
|
443
480
|
);
|
|
444
481
|
const result = await runSuite(plan.targetConfig, suite);
|
|
482
|
+
suiteResults.push(result);
|
|
445
483
|
if (result.failed) failed = true;
|
|
446
484
|
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
fatalError = error;
|
|
487
|
+
failed = true;
|
|
488
|
+
throw error;
|
|
447
489
|
} finally {
|
|
448
490
|
await stopLocalServices(startedServices);
|
|
449
491
|
}
|
|
450
492
|
|
|
451
|
-
return
|
|
493
|
+
return {
|
|
494
|
+
workerId: plan.workerId,
|
|
495
|
+
failed,
|
|
496
|
+
fatalError: fatalError ? formatError(fatalError) : null,
|
|
497
|
+
durationMs: Date.now() - startedAt,
|
|
498
|
+
suiteCount: plan.suites.length,
|
|
499
|
+
completedSuiteCount: suiteResults.length,
|
|
500
|
+
failedSuiteCount: suiteResults.filter((result) => result.failed).length,
|
|
501
|
+
suites: suiteResults,
|
|
502
|
+
};
|
|
452
503
|
}
|
|
453
504
|
|
|
454
505
|
async function prepareDatabases(runtimeConfigs) {
|
|
455
506
|
for (const config of runtimeConfigs) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
await runScript("neon-up.sh", {
|
|
463
|
-
NEON_PROJECT_ID: db.projectId,
|
|
464
|
-
NEON_DB_NAME: db.dbName,
|
|
465
|
-
NEON_BRANCH_NAME: db.branchName,
|
|
466
|
-
NEON_RESET: db.reset === false ? "false" : "true",
|
|
467
|
-
STATE_DIR: config.stateDir,
|
|
507
|
+
await prepareDatabaseRuntime(config, {
|
|
508
|
+
runMigrate: config.testkit.migrate
|
|
509
|
+
? (databaseUrl) => runMigrate(config, databaseUrl)
|
|
510
|
+
: null,
|
|
511
|
+
runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
|
|
468
512
|
});
|
|
469
|
-
|
|
470
|
-
fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
|
|
471
513
|
}
|
|
472
514
|
}
|
|
473
515
|
|
|
474
|
-
async function
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (!migrate) continue;
|
|
516
|
+
async function runMigrate(config, databaseUrl) {
|
|
517
|
+
const migrate = config.testkit.migrate;
|
|
518
|
+
if (!migrate) return;
|
|
478
519
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
520
|
+
const env = buildExecutionEnv(config);
|
|
521
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
482
522
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
523
|
+
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
524
|
+
await execaCommand(migrate.cmd, {
|
|
525
|
+
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
526
|
+
env,
|
|
527
|
+
stdio: "inherit",
|
|
528
|
+
shell: true,
|
|
529
|
+
});
|
|
491
530
|
}
|
|
492
531
|
|
|
493
|
-
async function
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
if (!seed) continue;
|
|
532
|
+
async function runSeed(config, databaseUrl) {
|
|
533
|
+
const seed = config.testkit.seed;
|
|
534
|
+
if (!seed) return;
|
|
497
535
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
536
|
+
const env = buildExecutionEnv(config);
|
|
537
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
501
538
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
539
|
+
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
540
|
+
await execaCommand(seed.cmd, {
|
|
541
|
+
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
542
|
+
env,
|
|
543
|
+
stdio: "inherit",
|
|
544
|
+
shell: true,
|
|
545
|
+
});
|
|
510
546
|
}
|
|
511
547
|
|
|
512
548
|
async function startLocalServices(runtimeConfigs) {
|
|
@@ -545,12 +581,15 @@ async function startLocalService(config) {
|
|
|
545
581
|
const child = spawn(config.testkit.local.start, {
|
|
546
582
|
cwd,
|
|
547
583
|
env,
|
|
584
|
+
detached: true,
|
|
548
585
|
shell: true,
|
|
549
586
|
stdio: ["ignore", "pipe", "pipe"],
|
|
550
587
|
});
|
|
551
588
|
|
|
552
|
-
|
|
553
|
-
|
|
589
|
+
const outputDrains = [
|
|
590
|
+
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
|
|
591
|
+
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
|
|
592
|
+
];
|
|
554
593
|
|
|
555
594
|
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
556
595
|
|
|
@@ -562,11 +601,11 @@ async function startLocalService(config) {
|
|
|
562
601
|
process: child,
|
|
563
602
|
});
|
|
564
603
|
} catch (error) {
|
|
565
|
-
await stopChildProcess(child);
|
|
604
|
+
await stopChildProcess(child, outputDrains);
|
|
566
605
|
throw error;
|
|
567
606
|
}
|
|
568
607
|
|
|
569
|
-
return { name: config.name, child };
|
|
608
|
+
return { name: config.name, child, outputDrains };
|
|
570
609
|
}
|
|
571
610
|
|
|
572
611
|
async function runSuite(targetConfig, suite) {
|
|
@@ -593,7 +632,9 @@ async function runHttpK6Suite(targetConfig, suite) {
|
|
|
593
632
|
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
|
|
594
633
|
}
|
|
595
634
|
|
|
635
|
+
const startedAt = Date.now();
|
|
596
636
|
let failed = false;
|
|
637
|
+
const failedFiles = [];
|
|
597
638
|
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
598
639
|
const absFile = path.join(targetConfig.productDir, file);
|
|
599
640
|
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
@@ -605,10 +646,11 @@ async function runHttpK6Suite(targetConfig, suite) {
|
|
|
605
646
|
});
|
|
606
647
|
} catch {
|
|
607
648
|
failed = true;
|
|
649
|
+
failedFiles.push(file);
|
|
608
650
|
}
|
|
609
651
|
});
|
|
610
652
|
|
|
611
|
-
return
|
|
653
|
+
return buildSuiteResult(suite, failed, startedAt, failedFiles);
|
|
612
654
|
}
|
|
613
655
|
|
|
614
656
|
async function runDalSuite(targetConfig, suite) {
|
|
@@ -618,7 +660,9 @@ async function runDalSuite(targetConfig, suite) {
|
|
|
618
660
|
}
|
|
619
661
|
|
|
620
662
|
const k6Binary = resolveDalBinary();
|
|
663
|
+
const startedAt = Date.now();
|
|
621
664
|
let failed = false;
|
|
665
|
+
const failedFiles = [];
|
|
622
666
|
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
623
667
|
const absFile = path.join(targetConfig.productDir, file);
|
|
624
668
|
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
@@ -630,10 +674,11 @@ async function runDalSuite(targetConfig, suite) {
|
|
|
630
674
|
});
|
|
631
675
|
} catch {
|
|
632
676
|
failed = true;
|
|
677
|
+
failedFiles.push(file);
|
|
633
678
|
}
|
|
634
679
|
});
|
|
635
680
|
|
|
636
|
-
return
|
|
681
|
+
return buildSuiteResult(suite, failed, startedAt, failedFiles);
|
|
637
682
|
}
|
|
638
683
|
|
|
639
684
|
async function runPlaywrightSuite(targetConfig, suite) {
|
|
@@ -648,6 +693,7 @@ async function runPlaywrightSuite(targetConfig, suite) {
|
|
|
648
693
|
const files = suite.files.map((file) =>
|
|
649
694
|
path.relative(cwd, path.join(targetConfig.productDir, file))
|
|
650
695
|
);
|
|
696
|
+
const startedAt = Date.now();
|
|
651
697
|
|
|
652
698
|
try {
|
|
653
699
|
await execa("npx", ["playwright", "test", ...files], {
|
|
@@ -655,31 +701,37 @@ async function runPlaywrightSuite(targetConfig, suite) {
|
|
|
655
701
|
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
656
702
|
stdio: "inherit",
|
|
657
703
|
});
|
|
658
|
-
return
|
|
704
|
+
return buildSuiteResult(suite, false, startedAt);
|
|
659
705
|
} catch {
|
|
660
|
-
return
|
|
706
|
+
return buildSuiteResult(suite, true, startedAt);
|
|
661
707
|
}
|
|
662
708
|
}
|
|
663
709
|
|
|
664
710
|
async function stopLocalServices(started) {
|
|
665
711
|
for (const service of [...started].reverse()) {
|
|
666
|
-
await stopChildProcess(service.child);
|
|
712
|
+
await stopChildProcess(service.child, service.outputDrains);
|
|
667
713
|
}
|
|
668
714
|
}
|
|
669
715
|
|
|
670
|
-
async function stopChildProcess(child) {
|
|
671
|
-
if (!child
|
|
716
|
+
async function stopChildProcess(child, outputDrains = []) {
|
|
717
|
+
if (!child) return;
|
|
718
|
+
if (child.exitCode !== null) {
|
|
719
|
+
await Promise.all(outputDrains);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
672
722
|
|
|
673
|
-
child
|
|
723
|
+
killChildProcess(child, "SIGTERM");
|
|
674
724
|
const exited = await Promise.race([
|
|
675
725
|
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
676
726
|
sleep(5_000).then(() => false),
|
|
677
727
|
]);
|
|
678
728
|
|
|
679
729
|
if (!exited && child.exitCode === null) {
|
|
680
|
-
child
|
|
730
|
+
killChildProcess(child, "SIGKILL");
|
|
681
731
|
await new Promise((resolve) => child.once("exit", resolve));
|
|
682
732
|
}
|
|
733
|
+
|
|
734
|
+
await Promise.all(outputDrains);
|
|
683
735
|
}
|
|
684
736
|
|
|
685
737
|
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
@@ -757,6 +809,124 @@ function resolvePlaywrightBrowsersPath(configuredPath) {
|
|
|
757
809
|
return configuredPath;
|
|
758
810
|
}
|
|
759
811
|
|
|
812
|
+
function buildSuiteResult(suite, failed, startedAt, failedFiles = []) {
|
|
813
|
+
return {
|
|
814
|
+
name: suite.name,
|
|
815
|
+
type: suite.type,
|
|
816
|
+
framework: suite.framework,
|
|
817
|
+
failed,
|
|
818
|
+
fileCount: suite.files.length,
|
|
819
|
+
failedFiles,
|
|
820
|
+
durationMs: Date.now() - startedAt,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function printRunSummary(results, durationMs) {
|
|
825
|
+
const totalServices = results.length;
|
|
826
|
+
const executedServices = results.filter((result) => !result.skipped);
|
|
827
|
+
const skippedServices = results.filter((result) => result.skipped);
|
|
828
|
+
const failedServices = executedServices.filter((result) => result.failed);
|
|
829
|
+
const passedServices = executedServices.filter((result) => !result.failed);
|
|
830
|
+
const totalSuites = executedServices.reduce((sum, result) => sum + result.suiteCount, 0);
|
|
831
|
+
const completedSuites = executedServices.reduce(
|
|
832
|
+
(sum, result) => sum + result.completedSuiteCount,
|
|
833
|
+
0
|
|
834
|
+
);
|
|
835
|
+
const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
|
|
836
|
+
const passedSuites = completedSuites - failedSuites;
|
|
837
|
+
|
|
838
|
+
console.log("\n══ Summary ══");
|
|
839
|
+
console.log(
|
|
840
|
+
[
|
|
841
|
+
`services ${passedServices.length}/${executedServices.length} passed`,
|
|
842
|
+
`suites ${passedSuites}/${totalSuites} passed`,
|
|
843
|
+
skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
|
|
844
|
+
`duration ${formatDuration(durationMs)}`,
|
|
845
|
+
]
|
|
846
|
+
.filter(Boolean)
|
|
847
|
+
.join(" · ")
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
for (const result of results) {
|
|
851
|
+
const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
|
|
852
|
+
const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
|
|
853
|
+
console.log(
|
|
854
|
+
`${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
if (result.failed) {
|
|
858
|
+
const failedSuiteResults = result.workers.flatMap((worker) =>
|
|
859
|
+
worker.suites.filter((suite) => suite.failed)
|
|
860
|
+
);
|
|
861
|
+
for (const suite of failedSuiteResults) {
|
|
862
|
+
const fileDetail =
|
|
863
|
+
suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
|
|
864
|
+
console.log(
|
|
865
|
+
` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
for (const error of result.errors) {
|
|
869
|
+
console.log(` - worker error: ${error}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (failedServices.length > 0) {
|
|
875
|
+
console.log(`\nResult: FAILED (${failedServices.length}/${totalServices} services failed)`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log("\nResult: PASSED");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function longestServiceName(results) {
|
|
883
|
+
return results.reduce((max, result) => Math.max(max, result.name.length), 4);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function formatDuration(durationMs) {
|
|
887
|
+
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
888
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
889
|
+
const seconds = totalSeconds % 60;
|
|
890
|
+
if (minutes === 0) return `${seconds}s`;
|
|
891
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function formatError(error) {
|
|
895
|
+
if (error instanceof Error) return error.message;
|
|
896
|
+
return String(error);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function formatServiceSummary(result) {
|
|
900
|
+
const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
|
|
901
|
+
const notRun = result.suiteCount - result.completedSuiteCount;
|
|
902
|
+
let detail = `${passedSuites}/${result.suiteCount} suites passed`;
|
|
903
|
+
if (notRun > 0) {
|
|
904
|
+
detail += `, ${notRun} not run`;
|
|
905
|
+
}
|
|
906
|
+
return detail;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function killChildProcess(child, signal) {
|
|
910
|
+
if (!child?.pid) return;
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
process.kill(-child.pid, signal);
|
|
914
|
+
return;
|
|
915
|
+
} catch (error) {
|
|
916
|
+
if (error?.code !== "ESRCH") {
|
|
917
|
+
// Fall back to the direct child if process-group signalling is unavailable.
|
|
918
|
+
} else {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
child.kill(signal);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (error?.code !== "ESRCH") throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
760
930
|
function readDatabaseUrl(stateDir) {
|
|
761
931
|
return readStateValue(path.join(stateDir, "database_url"));
|
|
762
932
|
}
|
|
@@ -780,21 +950,33 @@ function printStateDir(dir, indent) {
|
|
|
780
950
|
}
|
|
781
951
|
|
|
782
952
|
function pipeOutput(stream, prefix) {
|
|
783
|
-
if (!stream) return;
|
|
953
|
+
if (!stream) return Promise.resolve();
|
|
784
954
|
|
|
785
955
|
let pending = "";
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
956
|
+
return new Promise((resolve) => {
|
|
957
|
+
let settled = false;
|
|
958
|
+
const settle = () => {
|
|
959
|
+
if (settled) return;
|
|
960
|
+
settled = true;
|
|
961
|
+
resolve();
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
stream.on("data", (chunk) => {
|
|
965
|
+
pending += chunk.toString();
|
|
966
|
+
const lines = pending.split(/\r?\n/);
|
|
967
|
+
pending = lines.pop() || "";
|
|
968
|
+
for (const line of lines) {
|
|
969
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
stream.on("end", () => {
|
|
973
|
+
if (pending.length > 0) {
|
|
974
|
+
console.log(`${prefix} ${pending}`);
|
|
975
|
+
}
|
|
976
|
+
settle();
|
|
977
|
+
});
|
|
978
|
+
stream.on("close", settle);
|
|
979
|
+
stream.on("error", settle);
|
|
798
980
|
});
|
|
799
981
|
}
|
|
800
982
|
|
|
@@ -986,12 +1168,7 @@ function findRuntimeStateDirs(rootDir) {
|
|
|
986
1168
|
const visit = (dir) => {
|
|
987
1169
|
if (!fs.existsSync(dir)) return;
|
|
988
1170
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
989
|
-
|
|
990
|
-
(entry) =>
|
|
991
|
-
entry.isFile() &&
|
|
992
|
-
(entry.name === "neon_project_id" || entry.name === "neon_branch_id")
|
|
993
|
-
);
|
|
994
|
-
if (hasRuntimeFiles) {
|
|
1171
|
+
if (isDatabaseStateDir(dir)) {
|
|
995
1172
|
found.push(dir);
|
|
996
1173
|
}
|
|
997
1174
|
|