@elench/testkit 0.1.11 → 0.1.12
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 +39 -1
- package/lib/cli.mjs +38 -1
- package/lib/config.mjs +31 -0
- package/lib/runner.mjs +502 -103
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,13 @@ npx @elench/testkit e2e
|
|
|
28
28
|
npx @elench/testkit --framework playwright
|
|
29
29
|
npx @elench/testkit --framework k6
|
|
30
30
|
|
|
31
|
+
# Parallelize with isolated worker stacks
|
|
32
|
+
npx @elench/testkit --jobs 3
|
|
33
|
+
|
|
34
|
+
# Run a deterministic shard
|
|
35
|
+
npx @elench/testkit --shard 1/3
|
|
36
|
+
npx @elench/testkit --jobs 2 --shard 2/3
|
|
37
|
+
|
|
31
38
|
# Specific service / suite
|
|
32
39
|
npx @elench/testkit frontend e2e -s auth
|
|
33
40
|
npx @elench/testkit bourne int -s health
|
|
@@ -44,7 +51,7 @@ npx @elench/testkit destroy
|
|
|
44
51
|
3. **Database** — provisions a Neon branch when a service declares one
|
|
45
52
|
4. **Seed** — runs optional product seed commands against the provisioned database
|
|
46
53
|
5. **Runtime** — starts required local services, waits for readiness, and injects test env
|
|
47
|
-
6. **Execution** — runs `k6` suites file-by-file and Playwright suites suite-by-suite
|
|
54
|
+
6. **Execution** — distributes suites across isolated worker stacks, runs `k6` suites file-by-file, and runs Playwright suites suite-by-suite
|
|
48
55
|
7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
|
|
49
56
|
|
|
50
57
|
## File roles
|
|
@@ -52,6 +59,37 @@ npx @elench/testkit destroy
|
|
|
52
59
|
- `runner.manifest.json`: canonical test inventory
|
|
53
60
|
- `testkit.config.json`: local execution and provisioning config
|
|
54
61
|
|
|
62
|
+
## Parallel execution
|
|
63
|
+
|
|
64
|
+
`@elench/testkit` can run suites in parallel with `--jobs <n>`.
|
|
65
|
+
|
|
66
|
+
Each worker gets its own:
|
|
67
|
+
- Neon branch
|
|
68
|
+
- `.testkit` state subtree
|
|
69
|
+
- local service ports
|
|
70
|
+
|
|
71
|
+
This keeps suites isolated while still reusing one stack per worker across multiple assigned suites.
|
|
72
|
+
|
|
73
|
+
Use `--shard <i/n>` to split the selected suite set deterministically before worker scheduling.
|
|
74
|
+
|
|
75
|
+
## Suite metadata
|
|
76
|
+
|
|
77
|
+
`runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"name": "health",
|
|
82
|
+
"files": ["tests/example.js"],
|
|
83
|
+
"testkit": {
|
|
84
|
+
"maxFileConcurrency": 2,
|
|
85
|
+
"weight": 3
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `maxFileConcurrency`: k6-only opt-in for running files within the suite concurrently
|
|
91
|
+
- `weight`: optional scheduling weight when distributing suites across workers
|
|
92
|
+
|
|
55
93
|
## Schema
|
|
56
94
|
|
|
57
95
|
See [testkit-config-schema.md](testkit-config-schema.md).
|
package/lib/cli.mjs
CHANGED
|
@@ -13,6 +13,10 @@ export function run() {
|
|
|
13
13
|
.command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
|
|
14
14
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
15
15
|
.option("--dir <path>", "Explicit product directory")
|
|
16
|
+
.option("--jobs <n>", "Number of isolated worker stacks per service", {
|
|
17
|
+
default: "1",
|
|
18
|
+
})
|
|
19
|
+
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
16
20
|
.option("--framework <name>", "Filter by framework (k6, playwright, all)", {
|
|
17
21
|
default: "all",
|
|
18
22
|
})
|
|
@@ -84,9 +88,42 @@ export function run() {
|
|
|
84
88
|
);
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
const jobs = Number.parseInt(String(options.jobs), 10);
|
|
92
|
+
if (!Number.isInteger(jobs) || jobs <= 0) {
|
|
93
|
+
throw new Error(`Invalid --jobs value "${options.jobs}". Expected a positive integer.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let shard = null;
|
|
97
|
+
if (options.shard) {
|
|
98
|
+
const match = String(options.shard).match(/^(\d+)\/(\d+)$/);
|
|
99
|
+
if (!match) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Invalid --shard value "${options.shard}". Expected the form "i/n", e.g. 1/3.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const index = Number.parseInt(match[1], 10);
|
|
105
|
+
const total = Number.parseInt(match[2], 10);
|
|
106
|
+
if (index <= 0 || total <= 0 || index > total) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Invalid --shard value "${options.shard}". Expected 1 <= i <= n.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
shard = { index, total };
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
const suiteType = type || "all";
|
|
88
115
|
const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
|
|
89
|
-
await runner.runAll(
|
|
116
|
+
await runner.runAll(
|
|
117
|
+
configs,
|
|
118
|
+
suiteType,
|
|
119
|
+
suiteNames,
|
|
120
|
+
{
|
|
121
|
+
...options,
|
|
122
|
+
jobs,
|
|
123
|
+
shard,
|
|
124
|
+
},
|
|
125
|
+
allConfigs
|
|
126
|
+
);
|
|
90
127
|
});
|
|
91
128
|
|
|
92
129
|
cli.help();
|
package/lib/config.mjs
CHANGED
|
@@ -161,6 +161,31 @@ function loadRunnerManifest(productDir) {
|
|
|
161
161
|
`Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
if (suite.testkit !== undefined) {
|
|
166
|
+
if (!isObject(suite.testkit)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Service "${serviceName}" suite "${suite.name}" testkit config must be an object`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
suite.testkit.maxFileConcurrency !== undefined &&
|
|
173
|
+
(!Number.isInteger(suite.testkit.maxFileConcurrency) ||
|
|
174
|
+
suite.testkit.maxFileConcurrency <= 0)
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Service "${serviceName}" suite "${suite.name}" testkit.maxFileConcurrency must be a positive integer`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
suite.testkit.weight !== undefined &&
|
|
182
|
+
(!Number.isInteger(suite.testkit.weight) || suite.testkit.weight <= 0)
|
|
183
|
+
) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Service "${serviceName}" suite "${suite.name}" testkit.weight must be a positive integer`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
164
189
|
}
|
|
165
190
|
}
|
|
166
191
|
}
|
|
@@ -339,6 +364,12 @@ function validateServiceConfig(name, service, configPath) {
|
|
|
339
364
|
requireString(service.local, "start", `Service "${name}" local.start`);
|
|
340
365
|
requireString(service.local, "baseUrl", `Service "${name}" local.baseUrl`);
|
|
341
366
|
requireString(service.local, "readyUrl", `Service "${name}" local.readyUrl`);
|
|
367
|
+
if (
|
|
368
|
+
service.local.port !== undefined &&
|
|
369
|
+
(!Number.isInteger(service.local.port) || service.local.port <= 0)
|
|
370
|
+
) {
|
|
371
|
+
throw new Error(`Service "${name}" local.port must be a positive integer`);
|
|
372
|
+
}
|
|
342
373
|
if (service.local.cwd !== undefined && typeof service.local.cwd !== "string") {
|
|
343
374
|
throw new Error(`Service "${name}" local.cwd must be a string`);
|
|
344
375
|
}
|
package/lib/runner.mjs
CHANGED
|
@@ -13,54 +13,36 @@ import {
|
|
|
13
13
|
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
14
14
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
15
15
|
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
16
|
+
const PORT_STRIDE = 100;
|
|
16
17
|
|
|
17
18
|
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
18
19
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
const targetSpan = Math.max(1, opts.jobs || 1);
|
|
21
|
+
const results = await Promise.all(
|
|
22
|
+
configs.map(async (config, targetSlot) => {
|
|
23
|
+
console.log(`\n══ ${config.name} ══`);
|
|
24
|
+
return runService(config, configMap, suiteType, suiteNames, opts, {
|
|
25
|
+
targetSlot,
|
|
26
|
+
targetSpan,
|
|
27
|
+
});
|
|
28
|
+
})
|
|
29
|
+
);
|
|
26
30
|
|
|
27
|
-
if (
|
|
31
|
+
if (results.some(Boolean)) process.exit(1);
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export async function destroy(config) {
|
|
31
|
-
|
|
32
|
-
if (config.testkit.dependsOn) {
|
|
33
|
-
for (const depName of config.testkit.dependsOn) {
|
|
34
|
-
runtimeServices.push({
|
|
35
|
-
name: depName,
|
|
36
|
-
stateDir: path.join(config.stateDir, "deps", depName),
|
|
37
|
-
testkit: null,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
runtimeServices.push({
|
|
42
|
-
name: config.name,
|
|
43
|
-
stateDir: config.stateDir,
|
|
44
|
-
testkit: config.testkit,
|
|
45
|
-
});
|
|
35
|
+
if (!fs.existsSync(config.stateDir)) return;
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const projectId = serviceConfig.database?.projectId;
|
|
57
|
-
if (projectId) {
|
|
58
|
-
await runScript("neon-down.sh", {
|
|
59
|
-
NEON_PROJECT_ID: projectId,
|
|
60
|
-
STATE_DIR: runtime.stateDir,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
37
|
+
const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
|
|
38
|
+
for (const stateDir of runtimeStateDirs) {
|
|
39
|
+
const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
|
|
40
|
+
if (!projectId) continue;
|
|
41
|
+
|
|
42
|
+
await runScript("neon-down.sh", {
|
|
43
|
+
NEON_PROJECT_ID: projectId,
|
|
44
|
+
STATE_DIR: stateDir,
|
|
45
|
+
});
|
|
64
46
|
}
|
|
65
47
|
|
|
66
48
|
fs.rmSync(config.stateDir, { recursive: true, force: true });
|
|
@@ -75,8 +57,11 @@ export function showStatus(config) {
|
|
|
75
57
|
printStateDir(config.stateDir, " ");
|
|
76
58
|
}
|
|
77
59
|
|
|
78
|
-
async function runService(targetConfig, configMap, suiteType, suiteNames, opts) {
|
|
79
|
-
const suites =
|
|
60
|
+
async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
|
|
61
|
+
const suites = applyShard(
|
|
62
|
+
collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
|
|
63
|
+
opts.shard
|
|
64
|
+
);
|
|
80
65
|
if (suites.length === 0) {
|
|
81
66
|
console.log(
|
|
82
67
|
`No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
@@ -87,25 +72,25 @@ async function runService(targetConfig, configMap, suiteType, suiteNames, opts)
|
|
|
87
72
|
const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
|
|
88
73
|
fs.mkdirSync(targetConfig.stateDir, { recursive: true });
|
|
89
74
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
75
|
+
const jobs = Math.max(1, Math.min(opts.jobs || 1, suites.length));
|
|
76
|
+
const workerPlans = buildWorkerPlans(
|
|
77
|
+
targetConfig,
|
|
78
|
+
runtimeConfigs,
|
|
79
|
+
suites,
|
|
80
|
+
jobs,
|
|
81
|
+
runtimeSlot
|
|
82
|
+
);
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
84
|
+
const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
|
|
85
|
+
let failed = false;
|
|
101
86
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
87
|
+
for (const result of results) {
|
|
88
|
+
if (result.status === "rejected") {
|
|
89
|
+
failed = true;
|
|
90
|
+
console.error(result.reason);
|
|
91
|
+
continue;
|
|
106
92
|
}
|
|
107
|
-
|
|
108
|
-
await stopLocalServices(startedServices);
|
|
93
|
+
if (result.value) failed = true;
|
|
109
94
|
}
|
|
110
95
|
|
|
111
96
|
return failed;
|
|
@@ -119,23 +104,40 @@ function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
|
119
104
|
|
|
120
105
|
const selectedNames = new Set(suiteNames);
|
|
121
106
|
const suites = [];
|
|
107
|
+
let orderIndex = 0;
|
|
122
108
|
|
|
123
109
|
for (const type of types) {
|
|
124
110
|
for (const suite of config.suites[type] || []) {
|
|
125
111
|
const framework = suite.framework || "k6";
|
|
126
112
|
if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
|
|
127
113
|
if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
|
|
114
|
+
|
|
128
115
|
suites.push({
|
|
129
116
|
...suite,
|
|
130
117
|
framework,
|
|
131
118
|
type,
|
|
119
|
+
orderIndex,
|
|
120
|
+
sortKey: `${type}:${suite.name}`,
|
|
121
|
+
weight:
|
|
122
|
+
suite.testkit?.weight ||
|
|
123
|
+
(framework === "playwright"
|
|
124
|
+
? Math.max(2, suite.files.length)
|
|
125
|
+
: Math.max(1, suite.files.length)),
|
|
126
|
+
maxFileConcurrency:
|
|
127
|
+
framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
|
|
132
128
|
});
|
|
129
|
+
orderIndex += 1;
|
|
133
130
|
}
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
return suites;
|
|
137
134
|
}
|
|
138
135
|
|
|
136
|
+
function applyShard(suites, shard) {
|
|
137
|
+
if (!shard) return suites;
|
|
138
|
+
return suites.filter((_, index) => index % shard.total === shard.index - 1);
|
|
139
|
+
}
|
|
140
|
+
|
|
139
141
|
function orderedTypes(types) {
|
|
140
142
|
const ordered = [];
|
|
141
143
|
for (const known of TYPE_ORDER) {
|
|
@@ -175,39 +177,310 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
|
175
177
|
return ordered;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
|
|
180
|
+
function buildWorkerPlans(targetConfig, runtimeConfigs, suites, jobs, runtimeSlot) {
|
|
181
|
+
const buckets = distributeSuites(suites, jobs);
|
|
182
|
+
return buckets
|
|
183
|
+
.map((bucket, index) =>
|
|
184
|
+
createWorkerPlan(targetConfig, runtimeConfigs, bucket.suites, index + 1, runtimeSlot)
|
|
185
|
+
)
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function distributeSuites(suites, jobs) {
|
|
190
|
+
const buckets = Array.from({ length: jobs }, () => ({
|
|
191
|
+
suites: [],
|
|
192
|
+
totalWeight: 0,
|
|
193
|
+
}));
|
|
194
|
+
const ordered = [...suites].sort(
|
|
195
|
+
(a, b) => b.weight - a.weight || a.sortKey.localeCompare(b.sortKey)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
for (const suite of ordered) {
|
|
199
|
+
let bestBucket = buckets[0];
|
|
200
|
+
for (const bucket of buckets.slice(1)) {
|
|
201
|
+
if (bucket.totalWeight < bestBucket.totalWeight) {
|
|
202
|
+
bestBucket = bucket;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
bucket.totalWeight === bestBucket.totalWeight &&
|
|
207
|
+
bucket.suites.length < bestBucket.suites.length
|
|
208
|
+
) {
|
|
209
|
+
bestBucket = bucket;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
bestBucket.suites.push(suite);
|
|
214
|
+
bestBucket.totalWeight += suite.weight;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const bucket of buckets) {
|
|
218
|
+
bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return buckets.filter((bucket) => bucket.suites.length > 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createWorkerPlan(targetConfig, runtimeConfigs, suites, workerId, runtimeSlot) {
|
|
225
|
+
if (suites.length === 0) return null;
|
|
226
|
+
|
|
227
|
+
const workerStateDir = path.join(targetConfig.stateDir, "workers", `worker-${workerId}`);
|
|
228
|
+
const workerRuntimeConfigs = resolveWorkerRuntimeConfigs(
|
|
229
|
+
targetConfig,
|
|
230
|
+
runtimeConfigs,
|
|
231
|
+
workerId,
|
|
232
|
+
workerStateDir,
|
|
233
|
+
runtimeSlot
|
|
234
|
+
);
|
|
235
|
+
const workerTargetConfig = workerRuntimeConfigs.find(
|
|
236
|
+
(config) => config.name === targetConfig.name
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
workerId,
|
|
241
|
+
suites,
|
|
242
|
+
runtimeConfigs: workerRuntimeConfigs,
|
|
243
|
+
targetConfig: workerTargetConfig,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveWorkerRuntimeConfigs(
|
|
248
|
+
targetConfig,
|
|
249
|
+
runtimeConfigs,
|
|
250
|
+
workerId,
|
|
251
|
+
workerStateDir,
|
|
252
|
+
runtimeSlot
|
|
253
|
+
) {
|
|
254
|
+
const portMap = buildPortMap(runtimeConfigs, workerId, runtimeSlot);
|
|
255
|
+
const baseUrlByService = new Map();
|
|
256
|
+
const readyUrlByService = new Map();
|
|
257
|
+
|
|
258
|
+
for (const config of runtimeConfigs) {
|
|
259
|
+
if (!config.testkit.local) continue;
|
|
260
|
+
baseUrlByService.set(
|
|
261
|
+
config.name,
|
|
262
|
+
resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
|
|
263
|
+
workerStateDir,
|
|
264
|
+
portMap,
|
|
265
|
+
baseUrlByService,
|
|
266
|
+
readyUrlByService,
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
readyUrlByService.set(
|
|
270
|
+
config.name,
|
|
271
|
+
resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
|
|
272
|
+
workerStateDir,
|
|
273
|
+
portMap,
|
|
274
|
+
baseUrlByService,
|
|
275
|
+
readyUrlByService,
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const urlMappings = [];
|
|
281
|
+
for (const config of runtimeConfigs) {
|
|
282
|
+
if (!config.testkit.local) continue;
|
|
283
|
+
const resolvedBaseUrl = baseUrlByService.get(config.name);
|
|
284
|
+
const resolvedReadyUrl = readyUrlByService.get(config.name);
|
|
285
|
+
if (resolvedBaseUrl) {
|
|
286
|
+
urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
|
|
287
|
+
}
|
|
288
|
+
if (resolvedReadyUrl) {
|
|
289
|
+
urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return runtimeConfigs.map((config) =>
|
|
294
|
+
resolveWorkerConfig(
|
|
295
|
+
config,
|
|
296
|
+
targetConfig,
|
|
297
|
+
workerId,
|
|
298
|
+
workerStateDir,
|
|
299
|
+
portMap,
|
|
300
|
+
baseUrlByService,
|
|
301
|
+
readyUrlByService,
|
|
302
|
+
urlMappings
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildPortMap(runtimeConfigs, workerId, runtimeSlot) {
|
|
308
|
+
const portMap = new Map();
|
|
309
|
+
const seen = new Map();
|
|
310
|
+
const offset = PORT_STRIDE * ((workerId - 1) + runtimeSlot.targetSlot * runtimeSlot.targetSpan);
|
|
311
|
+
|
|
312
|
+
for (const config of runtimeConfigs) {
|
|
313
|
+
if (!config.testkit.local) continue;
|
|
314
|
+
|
|
315
|
+
const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
316
|
+
if (!basePort) continue;
|
|
317
|
+
|
|
318
|
+
const actualPort = basePort + offset;
|
|
319
|
+
const existing = seen.get(actualPort);
|
|
320
|
+
if (existing) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
|
|
323
|
+
`Assign distinct local.port/baseUrl ports in testkit.config.json.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
seen.set(actualPort, config.name);
|
|
327
|
+
portMap.set(config.name, actualPort);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return portMap;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function resolveWorkerConfig(
|
|
334
|
+
config,
|
|
335
|
+
targetConfig,
|
|
336
|
+
workerId,
|
|
337
|
+
workerStateDir,
|
|
338
|
+
portMap,
|
|
339
|
+
baseUrlByService,
|
|
340
|
+
readyUrlByService,
|
|
341
|
+
urlMappings
|
|
342
|
+
) {
|
|
343
|
+
const stateDir = getWorkerServiceStateDir(workerStateDir, targetConfig.name, config.name);
|
|
344
|
+
const context = {
|
|
345
|
+
workerId,
|
|
346
|
+
serviceName: config.name,
|
|
347
|
+
targetName: targetConfig.name,
|
|
348
|
+
serviceStateDir: stateDir,
|
|
349
|
+
portMap,
|
|
350
|
+
baseUrlByService,
|
|
351
|
+
readyUrlByService,
|
|
352
|
+
urlMappings,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const database = config.testkit.database
|
|
356
|
+
? {
|
|
357
|
+
...config.testkit.database,
|
|
358
|
+
branchName:
|
|
359
|
+
config.testkit.database.branchName !== undefined
|
|
360
|
+
? finalizeString(config.testkit.database.branchName, context)
|
|
361
|
+
: `${targetConfig.name}-${config.name}-w${workerId}-testkit`,
|
|
362
|
+
}
|
|
363
|
+
: undefined;
|
|
364
|
+
|
|
365
|
+
const migrate = config.testkit.migrate
|
|
366
|
+
? {
|
|
367
|
+
...config.testkit.migrate,
|
|
368
|
+
cmd: finalizeString(config.testkit.migrate.cmd, context),
|
|
369
|
+
cwd:
|
|
370
|
+
config.testkit.migrate.cwd !== undefined
|
|
371
|
+
? finalizeString(config.testkit.migrate.cwd, context)
|
|
372
|
+
: config.testkit.migrate.cwd,
|
|
373
|
+
}
|
|
374
|
+
: undefined;
|
|
375
|
+
|
|
376
|
+
const seed = config.testkit.seed
|
|
377
|
+
? {
|
|
378
|
+
...config.testkit.seed,
|
|
379
|
+
cmd: finalizeString(config.testkit.seed.cmd, context),
|
|
380
|
+
cwd:
|
|
381
|
+
config.testkit.seed.cwd !== undefined
|
|
382
|
+
? finalizeString(config.testkit.seed.cwd, context)
|
|
383
|
+
: config.testkit.seed.cwd,
|
|
384
|
+
}
|
|
385
|
+
: undefined;
|
|
386
|
+
|
|
387
|
+
const local = config.testkit.local
|
|
388
|
+
? {
|
|
389
|
+
...config.testkit.local,
|
|
390
|
+
start: finalizeString(config.testkit.local.start, context),
|
|
391
|
+
cwd:
|
|
392
|
+
config.testkit.local.cwd !== undefined
|
|
393
|
+
? finalizeString(config.testkit.local.cwd, context)
|
|
394
|
+
: config.testkit.local.cwd,
|
|
395
|
+
port: portMap.get(config.name) || config.testkit.local.port,
|
|
396
|
+
baseUrl: baseUrlByService.get(config.name),
|
|
397
|
+
readyUrl: readyUrlByService.get(config.name),
|
|
398
|
+
env: Object.fromEntries(
|
|
399
|
+
Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
|
|
400
|
+
key,
|
|
401
|
+
finalizeString(String(value), context),
|
|
402
|
+
])
|
|
403
|
+
),
|
|
404
|
+
}
|
|
405
|
+
: undefined;
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
...config,
|
|
409
|
+
stateDir,
|
|
410
|
+
workerId,
|
|
411
|
+
workerLabel: `w${workerId}`,
|
|
412
|
+
targetName: targetConfig.name,
|
|
413
|
+
testkit: {
|
|
414
|
+
...config.testkit,
|
|
415
|
+
database,
|
|
416
|
+
migrate,
|
|
417
|
+
seed,
|
|
418
|
+
local,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function runWorkerPlan(plan) {
|
|
424
|
+
console.log(
|
|
425
|
+
`\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
let startedServices = [];
|
|
429
|
+
let failed = false;
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
await prepareDatabases(plan.runtimeConfigs);
|
|
433
|
+
await runMigrations(plan.runtimeConfigs);
|
|
434
|
+
await runSeeds(plan.runtimeConfigs);
|
|
435
|
+
|
|
436
|
+
if (needsLocalRuntime(plan.suites)) {
|
|
437
|
+
startedServices = await startLocalServices(plan.runtimeConfigs);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const suite of plan.suites) {
|
|
441
|
+
console.log(
|
|
442
|
+
`\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
|
|
443
|
+
);
|
|
444
|
+
const result = await runSuite(plan.targetConfig, suite);
|
|
445
|
+
if (result.failed) failed = true;
|
|
446
|
+
}
|
|
447
|
+
} finally {
|
|
448
|
+
await stopLocalServices(startedServices);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return failed;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function prepareDatabases(runtimeConfigs) {
|
|
179
455
|
for (const config of runtimeConfigs) {
|
|
180
456
|
const db = config.testkit.database;
|
|
181
457
|
if (!db) continue;
|
|
182
458
|
|
|
183
459
|
requireNeonApiKey();
|
|
184
|
-
|
|
185
|
-
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
186
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
460
|
+
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
187
461
|
|
|
188
462
|
await runScript("neon-up.sh", {
|
|
189
463
|
NEON_PROJECT_ID: db.projectId,
|
|
190
464
|
NEON_DB_NAME: db.dbName,
|
|
191
|
-
NEON_BRANCH_NAME: db.branchName
|
|
465
|
+
NEON_BRANCH_NAME: db.branchName,
|
|
192
466
|
NEON_RESET: db.reset === false ? "false" : "true",
|
|
193
|
-
STATE_DIR: stateDir,
|
|
467
|
+
STATE_DIR: config.stateDir,
|
|
194
468
|
});
|
|
195
469
|
|
|
196
|
-
fs.writeFileSync(path.join(stateDir, "neon_project_id"), db.projectId);
|
|
470
|
+
fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
|
|
197
471
|
}
|
|
198
472
|
}
|
|
199
473
|
|
|
200
|
-
async function runMigrations(runtimeConfigs
|
|
474
|
+
async function runMigrations(runtimeConfigs) {
|
|
201
475
|
for (const config of runtimeConfigs) {
|
|
202
476
|
const migrate = config.testkit.migrate;
|
|
203
477
|
if (!migrate) continue;
|
|
204
478
|
|
|
205
|
-
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
206
479
|
const env = { ...process.env };
|
|
207
|
-
const dbUrl = readDatabaseUrl(stateDir);
|
|
480
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
208
481
|
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
209
482
|
|
|
210
|
-
console.log(`\n── migrate:${config.name} ──`);
|
|
483
|
+
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
211
484
|
await execaCommand(migrate.cmd, {
|
|
212
485
|
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
213
486
|
env,
|
|
@@ -217,17 +490,16 @@ async function runMigrations(runtimeConfigs, targetConfig) {
|
|
|
217
490
|
}
|
|
218
491
|
}
|
|
219
492
|
|
|
220
|
-
async function runSeeds(runtimeConfigs
|
|
493
|
+
async function runSeeds(runtimeConfigs) {
|
|
221
494
|
for (const config of runtimeConfigs) {
|
|
222
495
|
const seed = config.testkit.seed;
|
|
223
496
|
if (!seed) continue;
|
|
224
497
|
|
|
225
|
-
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
226
498
|
const env = { ...process.env };
|
|
227
|
-
const dbUrl = readDatabaseUrl(stateDir);
|
|
499
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
228
500
|
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
229
501
|
|
|
230
|
-
console.log(`\n── seed:${config.name} ──`);
|
|
502
|
+
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
231
503
|
await execaCommand(seed.cmd, {
|
|
232
504
|
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
233
505
|
env,
|
|
@@ -237,14 +509,13 @@ async function runSeeds(runtimeConfigs, targetConfig) {
|
|
|
237
509
|
}
|
|
238
510
|
}
|
|
239
511
|
|
|
240
|
-
async function startLocalServices(runtimeConfigs
|
|
512
|
+
async function startLocalServices(runtimeConfigs) {
|
|
241
513
|
const started = [];
|
|
242
514
|
|
|
243
515
|
try {
|
|
244
516
|
for (const config of runtimeConfigs) {
|
|
245
517
|
if (!config.testkit.local) continue;
|
|
246
|
-
const
|
|
247
|
-
const proc = await startLocalService(config, stateDir);
|
|
518
|
+
const proc = await startLocalService(config);
|
|
248
519
|
started.push(proc);
|
|
249
520
|
}
|
|
250
521
|
} catch (error) {
|
|
@@ -255,25 +526,25 @@ async function startLocalServices(runtimeConfigs, targetConfig) {
|
|
|
255
526
|
return started;
|
|
256
527
|
}
|
|
257
528
|
|
|
258
|
-
async function startLocalService(config
|
|
529
|
+
async function startLocalService(config) {
|
|
259
530
|
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
260
531
|
const env = {
|
|
261
532
|
...process.env,
|
|
262
533
|
...config.testkit.local.env,
|
|
263
534
|
};
|
|
264
|
-
const port =
|
|
535
|
+
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
265
536
|
if (port) {
|
|
266
|
-
env.PORT = port;
|
|
537
|
+
env.PORT = String(port);
|
|
267
538
|
}
|
|
268
539
|
|
|
269
|
-
const dbUrl = readDatabaseUrl(stateDir);
|
|
540
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
270
541
|
if (dbUrl) {
|
|
271
542
|
env.DATABASE_URL = dbUrl;
|
|
272
543
|
}
|
|
273
544
|
|
|
274
545
|
await assertLocalServicePortsAvailable(config);
|
|
275
546
|
|
|
276
|
-
console.log(`Starting ${config.name}: ${config.testkit.local.start}`);
|
|
547
|
+
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
277
548
|
const child = spawn(config.testkit.local.start, {
|
|
278
549
|
cwd,
|
|
279
550
|
env,
|
|
@@ -281,15 +552,14 @@ async function startLocalService(config, stateDir) {
|
|
|
281
552
|
stdio: ["ignore", "pipe", "pipe"],
|
|
282
553
|
});
|
|
283
554
|
|
|
284
|
-
pipeOutput(child.stdout, `[${config.name}]`);
|
|
285
|
-
pipeOutput(child.stderr, `[${config.name}]`);
|
|
555
|
+
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`);
|
|
556
|
+
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`);
|
|
286
557
|
|
|
287
|
-
const readyTimeoutMs =
|
|
288
|
-
config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
558
|
+
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
289
559
|
|
|
290
560
|
try {
|
|
291
561
|
await waitForReady({
|
|
292
|
-
name: config.name
|
|
562
|
+
name: `${config.workerLabel}:${config.name}`,
|
|
293
563
|
url: config.testkit.local.readyUrl,
|
|
294
564
|
timeoutMs: readyTimeoutMs,
|
|
295
565
|
process: child,
|
|
@@ -302,9 +572,9 @@ async function startLocalService(config, stateDir) {
|
|
|
302
572
|
return { name: config.name, child };
|
|
303
573
|
}
|
|
304
574
|
|
|
305
|
-
async function runSuite(targetConfig, suite
|
|
575
|
+
async function runSuite(targetConfig, suite) {
|
|
306
576
|
if (suite.type === "dal") {
|
|
307
|
-
return runDalSuite(targetConfig, suite
|
|
577
|
+
return runDalSuite(targetConfig, suite);
|
|
308
578
|
}
|
|
309
579
|
|
|
310
580
|
if (suite.framework === "playwright") {
|
|
@@ -327,8 +597,9 @@ async function runHttpK6Suite(targetConfig, suite) {
|
|
|
327
597
|
}
|
|
328
598
|
|
|
329
599
|
let failed = false;
|
|
330
|
-
|
|
600
|
+
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
331
601
|
const absFile = path.join(targetConfig.productDir, file);
|
|
602
|
+
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
332
603
|
try {
|
|
333
604
|
await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
334
605
|
cwd: targetConfig.productDir,
|
|
@@ -338,22 +609,22 @@ async function runHttpK6Suite(targetConfig, suite) {
|
|
|
338
609
|
} catch {
|
|
339
610
|
failed = true;
|
|
340
611
|
}
|
|
341
|
-
}
|
|
612
|
+
});
|
|
342
613
|
|
|
343
614
|
return { failed };
|
|
344
615
|
}
|
|
345
616
|
|
|
346
|
-
async function runDalSuite(targetConfig, suite
|
|
347
|
-
const databaseUrl = readDatabaseUrl(stateDir);
|
|
617
|
+
async function runDalSuite(targetConfig, suite) {
|
|
618
|
+
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
348
619
|
if (!databaseUrl) {
|
|
349
620
|
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
350
621
|
}
|
|
351
622
|
|
|
352
623
|
const k6Binary = resolveDalBinary();
|
|
353
624
|
let failed = false;
|
|
354
|
-
|
|
355
|
-
for (const file of suite.files) {
|
|
625
|
+
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
356
626
|
const absFile = path.join(targetConfig.productDir, file);
|
|
627
|
+
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
357
628
|
try {
|
|
358
629
|
await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
|
|
359
630
|
cwd: targetConfig.productDir,
|
|
@@ -363,7 +634,7 @@ async function runDalSuite(targetConfig, suite, stateDir) {
|
|
|
363
634
|
} catch {
|
|
364
635
|
failed = true;
|
|
365
636
|
}
|
|
366
|
-
}
|
|
637
|
+
});
|
|
367
638
|
|
|
368
639
|
return { failed };
|
|
369
640
|
}
|
|
@@ -377,7 +648,9 @@ async function runPlaywrightSuite(targetConfig, suite) {
|
|
|
377
648
|
}
|
|
378
649
|
|
|
379
650
|
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
380
|
-
const files = suite.files.map((file) =>
|
|
651
|
+
const files = suite.files.map((file) =>
|
|
652
|
+
path.relative(cwd, path.join(targetConfig.productDir, file))
|
|
653
|
+
);
|
|
381
654
|
|
|
382
655
|
try {
|
|
383
656
|
await execa("npx", ["playwright", "test", ...files], {
|
|
@@ -387,6 +660,7 @@ async function runPlaywrightSuite(targetConfig, suite) {
|
|
|
387
660
|
BASE_URL: local.baseUrl,
|
|
388
661
|
PLAYWRIGHT_HTML_OPEN: "never",
|
|
389
662
|
TESTKIT_MANAGED_SERVERS: "1",
|
|
663
|
+
TESTKIT_WORKER_ID: String(targetConfig.workerId),
|
|
390
664
|
},
|
|
391
665
|
stdio: "inherit",
|
|
392
666
|
});
|
|
@@ -442,11 +716,11 @@ function needsLocalRuntime(suites) {
|
|
|
442
716
|
return suites.some((suite) => suite.type !== "dal");
|
|
443
717
|
}
|
|
444
718
|
|
|
445
|
-
function
|
|
446
|
-
if (
|
|
447
|
-
return
|
|
719
|
+
function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
720
|
+
if (targetName === serviceName) {
|
|
721
|
+
return workerStateDir;
|
|
448
722
|
}
|
|
449
|
-
return path.join(
|
|
723
|
+
return path.join(workerStateDir, "deps", serviceName);
|
|
450
724
|
}
|
|
451
725
|
|
|
452
726
|
function readDatabaseUrl(stateDir) {
|
|
@@ -490,14 +764,112 @@ function pipeOutput(stream, prefix) {
|
|
|
490
764
|
});
|
|
491
765
|
}
|
|
492
766
|
|
|
767
|
+
async function runWithConcurrency(items, limit, handler) {
|
|
768
|
+
const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
|
|
769
|
+
let nextIndex = 0;
|
|
770
|
+
|
|
771
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
772
|
+
while (nextIndex < items.length) {
|
|
773
|
+
const current = nextIndex;
|
|
774
|
+
nextIndex += 1;
|
|
775
|
+
await handler(items[current], current);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
await Promise.all(workers);
|
|
780
|
+
}
|
|
781
|
+
|
|
493
782
|
function sleep(ms) {
|
|
494
783
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
495
784
|
}
|
|
496
785
|
|
|
497
|
-
function
|
|
786
|
+
function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
787
|
+
const resolved = resolveTemplateString(rawUrl, {
|
|
788
|
+
...context,
|
|
789
|
+
targetName: targetConfig.name,
|
|
790
|
+
workerId,
|
|
791
|
+
serviceName,
|
|
792
|
+
});
|
|
793
|
+
const actualPort = context.portMap.get(serviceName);
|
|
794
|
+
return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function finalizeString(value, context) {
|
|
798
|
+
let resolved = resolveTemplateString(value, context);
|
|
799
|
+
for (const [source, destination] of context.urlMappings || []) {
|
|
800
|
+
if (source && destination && source !== destination) {
|
|
801
|
+
resolved = resolved.split(source).join(destination);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return resolved;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function resolveTemplateString(value, context) {
|
|
808
|
+
if (typeof value !== "string") return value;
|
|
809
|
+
|
|
810
|
+
return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
|
|
811
|
+
switch (token) {
|
|
812
|
+
case "worker":
|
|
813
|
+
return String(context.workerId);
|
|
814
|
+
case "target":
|
|
815
|
+
return context.targetName;
|
|
816
|
+
case "service":
|
|
817
|
+
return context.serviceName;
|
|
818
|
+
case "stateDir":
|
|
819
|
+
return context.serviceStateDir;
|
|
820
|
+
case "port": {
|
|
821
|
+
const serviceName = arg || context.serviceName;
|
|
822
|
+
const port = context.portMap.get(serviceName);
|
|
823
|
+
if (!port) {
|
|
824
|
+
throw new Error(`Unknown port placeholder for service "${serviceName}"`);
|
|
825
|
+
}
|
|
826
|
+
return String(port);
|
|
827
|
+
}
|
|
828
|
+
case "baseUrl": {
|
|
829
|
+
const serviceName = arg || context.serviceName;
|
|
830
|
+
const baseUrl = context.baseUrlByService.get(serviceName);
|
|
831
|
+
if (!baseUrl) {
|
|
832
|
+
throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
|
|
833
|
+
}
|
|
834
|
+
return baseUrl;
|
|
835
|
+
}
|
|
836
|
+
case "readyUrl": {
|
|
837
|
+
const serviceName = arg || context.serviceName;
|
|
838
|
+
const readyUrl = context.readyUrlByService.get(serviceName);
|
|
839
|
+
if (!readyUrl) {
|
|
840
|
+
throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
|
|
841
|
+
}
|
|
842
|
+
return readyUrl;
|
|
843
|
+
}
|
|
844
|
+
default:
|
|
845
|
+
throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function rewriteUrlPort(rawUrl, port) {
|
|
851
|
+
try {
|
|
852
|
+
const original = new URL(rawUrl);
|
|
853
|
+
if (!original.port) return rawUrl;
|
|
854
|
+
|
|
855
|
+
const rewritten = new URL(rawUrl);
|
|
856
|
+
rewritten.port = String(port);
|
|
857
|
+
|
|
858
|
+
let next = rewritten.toString();
|
|
859
|
+
if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
|
|
860
|
+
next = next.slice(0, -1);
|
|
861
|
+
}
|
|
862
|
+
return next;
|
|
863
|
+
} catch {
|
|
864
|
+
return rawUrl;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function numericPortFromUrl(rawUrl) {
|
|
498
869
|
try {
|
|
499
870
|
const url = new URL(rawUrl);
|
|
500
|
-
|
|
871
|
+
const port = Number(url.port);
|
|
872
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
501
873
|
} catch {
|
|
502
874
|
return null;
|
|
503
875
|
}
|
|
@@ -517,7 +889,8 @@ async function assertLocalServicePortsAvailable(config) {
|
|
|
517
889
|
|
|
518
890
|
if (await isPortInUse(socket)) {
|
|
519
891
|
throw new Error(
|
|
520
|
-
`Cannot start "${config.name}" because ${key} is already in use.
|
|
892
|
+
`Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
|
|
893
|
+
`Stop the existing process and rerun testkit.`
|
|
521
894
|
);
|
|
522
895
|
}
|
|
523
896
|
}
|
|
@@ -572,3 +945,29 @@ async function isPortInUse({ host, port }) {
|
|
|
572
945
|
socket.connect(port, host);
|
|
573
946
|
});
|
|
574
947
|
}
|
|
948
|
+
|
|
949
|
+
function findRuntimeStateDirs(rootDir) {
|
|
950
|
+
const found = [];
|
|
951
|
+
|
|
952
|
+
const visit = (dir) => {
|
|
953
|
+
if (!fs.existsSync(dir)) return;
|
|
954
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
955
|
+
const hasRuntimeFiles = entries.some(
|
|
956
|
+
(entry) =>
|
|
957
|
+
entry.isFile() &&
|
|
958
|
+
(entry.name === "neon_project_id" || entry.name === "neon_branch_id")
|
|
959
|
+
);
|
|
960
|
+
if (hasRuntimeFiles) {
|
|
961
|
+
found.push(dir);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
for (const entry of entries) {
|
|
965
|
+
if (entry.isDirectory()) {
|
|
966
|
+
visit(path.join(dir, entry.name));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
visit(rootDir);
|
|
972
|
+
return found.sort((a, b) => b.length - a.length);
|
|
973
|
+
}
|