@elench/testkit 0.1.10 → 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 +62 -30
- package/lib/cli.mjs +55 -3
- package/lib/config.mjs +295 -176
- package/lib/runner.mjs +874 -362
- package/package.json +4 -3
- package/infra/fly-app-ensure.sh +0 -23
- package/infra/fly-build.sh +0 -55
- package/infra/fly-destroy.sh +0 -21
- package/infra/fly-down.sh +0 -19
- package/infra/fly-up.sh +0 -142
package/lib/runner.mjs
CHANGED
|
@@ -1,461 +1,973 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import net from "net";
|
|
5
|
+
import { execa, execaCommand } from "execa";
|
|
3
6
|
import { runScript } from "./exec.mjs";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
import {
|
|
8
|
+
requireNeonApiKey,
|
|
9
|
+
resolveDalBinary,
|
|
10
|
+
resolveServiceCwd,
|
|
11
|
+
} from "./config.mjs";
|
|
12
|
+
|
|
13
|
+
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
14
|
+
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
15
|
+
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
16
|
+
const PORT_STRIDE = 100;
|
|
17
|
+
|
|
18
|
+
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
19
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
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
|
+
);
|
|
30
|
+
|
|
31
|
+
if (results.some(Boolean)) process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function destroy(config) {
|
|
35
|
+
if (!fs.existsSync(config.stateDir)) return;
|
|
36
|
+
|
|
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
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fs.rmSync(config.stateDir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function showStatus(config) {
|
|
52
|
+
if (!fs.existsSync(config.stateDir)) {
|
|
53
|
+
console.log("No state — run tests first.");
|
|
17
54
|
return;
|
|
18
55
|
}
|
|
19
56
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
57
|
+
printStateDir(config.stateDir, " ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
|
|
61
|
+
const suites = applyShard(
|
|
62
|
+
collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
|
|
63
|
+
opts.shard
|
|
26
64
|
);
|
|
65
|
+
if (suites.length === 0) {
|
|
66
|
+
console.log(
|
|
67
|
+
`No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
68
|
+
);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
27
71
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const r = results[i];
|
|
31
|
-
const ok = r.status === "fulfilled" && !r.value;
|
|
32
|
-
return ` ${ok ? "✓" : "✗"} ${c.name}`;
|
|
33
|
-
});
|
|
34
|
-
console.log(`\n── Summary ──\n${summary.join("\n")}`);
|
|
72
|
+
const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
|
|
73
|
+
fs.mkdirSync(targetConfig.stateDir, { recursive: true });
|
|
35
74
|
|
|
36
|
-
const
|
|
37
|
-
|
|
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
|
|
38
82
|
);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
STATE_DIR: stateDir,
|
|
54
|
-
});
|
|
83
|
+
|
|
84
|
+
const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
|
|
85
|
+
let failed = false;
|
|
86
|
+
|
|
87
|
+
for (const result of results) {
|
|
88
|
+
if (result.status === "rejected") {
|
|
89
|
+
failed = true;
|
|
90
|
+
console.error(result.reason);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (result.value) failed = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return failed;
|
|
55
97
|
}
|
|
56
98
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
99
|
+
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
100
|
+
const types =
|
|
101
|
+
suiteType === "all"
|
|
102
|
+
? orderedTypes(Object.keys(config.suites))
|
|
103
|
+
: [suiteType === "int" ? "integration" : suiteType];
|
|
104
|
+
|
|
105
|
+
const selectedNames = new Set(suiteNames);
|
|
106
|
+
const suites = [];
|
|
107
|
+
let orderIndex = 0;
|
|
108
|
+
|
|
109
|
+
for (const type of types) {
|
|
110
|
+
for (const suite of config.suites[type] || []) {
|
|
111
|
+
const framework = suite.framework || "k6";
|
|
112
|
+
if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
|
|
113
|
+
if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
|
|
114
|
+
|
|
115
|
+
suites.push({
|
|
116
|
+
...suite,
|
|
117
|
+
framework,
|
|
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,
|
|
128
|
+
});
|
|
129
|
+
orderIndex += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return suites;
|
|
70
134
|
}
|
|
71
135
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const { productDir, stateDir, manifest } = config;
|
|
77
|
-
const tk = manifest.testkit;
|
|
136
|
+
function applyShard(suites, shard) {
|
|
137
|
+
if (!shard) return suites;
|
|
138
|
+
return suites.filter((_, index) => index % shard.total === shard.index - 1);
|
|
139
|
+
}
|
|
78
140
|
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
for (const
|
|
82
|
-
|
|
141
|
+
function orderedTypes(types) {
|
|
142
|
+
const ordered = [];
|
|
143
|
+
for (const known of TYPE_ORDER) {
|
|
144
|
+
if (types.includes(known)) ordered.push(known);
|
|
83
145
|
}
|
|
84
|
-
for (const
|
|
85
|
-
|
|
146
|
+
for (const type of types) {
|
|
147
|
+
if (!ordered.includes(type)) ordered.push(type);
|
|
86
148
|
}
|
|
87
|
-
|
|
88
|
-
|
|
149
|
+
return ordered;
|
|
150
|
+
}
|
|
89
151
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
152
|
+
function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
153
|
+
const ordered = [];
|
|
154
|
+
const visiting = new Set();
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
|
|
157
|
+
const visit = (config) => {
|
|
158
|
+
if (seen.has(config.name)) return;
|
|
159
|
+
if (visiting.has(config.name)) {
|
|
160
|
+
throw new Error(`Dependency cycle detected involving "${config.name}"`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
visiting.add(config.name);
|
|
164
|
+
for (const depName of config.testkit.dependsOn || []) {
|
|
165
|
+
const dep = configMap.get(depName);
|
|
166
|
+
if (!dep) {
|
|
167
|
+
throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
|
|
168
|
+
}
|
|
169
|
+
visit(dep);
|
|
170
|
+
}
|
|
171
|
+
visiting.delete(config.name);
|
|
172
|
+
seen.add(config.name);
|
|
173
|
+
ordered.push(config);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
visit(targetConfig);
|
|
177
|
+
return ordered;
|
|
98
178
|
}
|
|
99
179
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await runScript("fly-build.sh", {
|
|
108
|
-
FLY_APP: dep.fly.app,
|
|
109
|
-
FLY_ORG: dep.fly.org,
|
|
110
|
-
API_DIR: productDir,
|
|
111
|
-
DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
|
|
112
|
-
STATE_DIR: depStateDir,
|
|
113
|
-
});
|
|
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);
|
|
114
187
|
}
|
|
115
188
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
124
216
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (fs.existsSync(primaryDbUrl)) {
|
|
128
|
-
fs.copyFileSync(primaryDbUrl, path.join(depStateDir, "database_url"));
|
|
217
|
+
for (const bucket of buckets) {
|
|
218
|
+
bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
|
|
129
219
|
}
|
|
130
220
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
);
|
|
135
278
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
291
|
}
|
|
139
|
-
const flyEnvPath = path.join(depStateDir, "fly-env.sh");
|
|
140
|
-
fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
|
|
141
292
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
);
|
|
150
305
|
}
|
|
151
306
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
331
|
}
|
|
162
332
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
};
|
|
172
421
|
}
|
|
173
422
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
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);
|
|
188
449
|
}
|
|
189
450
|
|
|
190
|
-
|
|
191
|
-
await runScript("neon-down.sh", {
|
|
192
|
-
NEON_PROJECT_ID: tk.neon.projectId,
|
|
193
|
-
STATE_DIR: stateDir,
|
|
194
|
-
});
|
|
195
|
-
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
451
|
+
return failed;
|
|
196
452
|
}
|
|
197
453
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
454
|
+
async function prepareDatabases(runtimeConfigs) {
|
|
455
|
+
for (const config of runtimeConfigs) {
|
|
456
|
+
const db = config.testkit.database;
|
|
457
|
+
if (!db) continue;
|
|
458
|
+
|
|
459
|
+
requireNeonApiKey();
|
|
460
|
+
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
204
461
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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,
|
|
468
|
+
});
|
|
208
469
|
|
|
209
|
-
|
|
210
|
-
const envFlags = [`-e BASE_URL=${baseUrl}`, `-e MACHINE_ID=${machineId}`];
|
|
211
|
-
for (const key of tk.k6?.secrets || []) {
|
|
212
|
-
envFlags.push(`-e ${key}=${process.env[key]}`);
|
|
470
|
+
fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
|
|
213
471
|
}
|
|
214
|
-
|
|
472
|
+
}
|
|
215
473
|
|
|
216
|
-
|
|
217
|
-
for (const
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
474
|
+
async function runMigrations(runtimeConfigs) {
|
|
475
|
+
for (const config of runtimeConfigs) {
|
|
476
|
+
const migrate = config.testkit.migrate;
|
|
477
|
+
if (!migrate) continue;
|
|
478
|
+
|
|
479
|
+
const env = { ...process.env };
|
|
480
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
481
|
+
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
482
|
+
|
|
483
|
+
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
484
|
+
await execaCommand(migrate.cmd, {
|
|
485
|
+
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
486
|
+
env,
|
|
487
|
+
stdio: "inherit",
|
|
488
|
+
shell: true,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function runSeeds(runtimeConfigs) {
|
|
494
|
+
for (const config of runtimeConfigs) {
|
|
495
|
+
const seed = config.testkit.seed;
|
|
496
|
+
if (!seed) continue;
|
|
497
|
+
|
|
498
|
+
const env = { ...process.env };
|
|
499
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
500
|
+
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
501
|
+
|
|
502
|
+
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
503
|
+
await execaCommand(seed.cmd, {
|
|
504
|
+
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
505
|
+
env,
|
|
506
|
+
stdio: "inherit",
|
|
507
|
+
shell: true,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function startLocalServices(runtimeConfigs) {
|
|
513
|
+
const started = [];
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
for (const config of runtimeConfigs) {
|
|
517
|
+
if (!config.testkit.local) continue;
|
|
518
|
+
const proc = await startLocalService(config);
|
|
519
|
+
started.push(proc);
|
|
223
520
|
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
await stopLocalServices(started);
|
|
523
|
+
throw error;
|
|
224
524
|
}
|
|
225
|
-
|
|
525
|
+
|
|
526
|
+
return started;
|
|
226
527
|
}
|
|
227
528
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
529
|
+
async function startLocalService(config) {
|
|
530
|
+
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
531
|
+
const env = {
|
|
532
|
+
...process.env,
|
|
533
|
+
...config.testkit.local.env,
|
|
534
|
+
};
|
|
535
|
+
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
536
|
+
if (port) {
|
|
537
|
+
env.PORT = String(port);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
541
|
+
if (dbUrl) {
|
|
542
|
+
env.DATABASE_URL = dbUrl;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await assertLocalServicePortsAvailable(config);
|
|
235
546
|
|
|
236
|
-
|
|
237
|
-
const
|
|
547
|
+
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
548
|
+
const child = spawn(config.testkit.local.start, {
|
|
549
|
+
cwd,
|
|
550
|
+
env,
|
|
551
|
+
shell: true,
|
|
552
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`);
|
|
556
|
+
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`);
|
|
557
|
+
|
|
558
|
+
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
await waitForReady({
|
|
562
|
+
name: `${config.workerLabel}:${config.name}`,
|
|
563
|
+
url: config.testkit.local.readyUrl,
|
|
564
|
+
timeoutMs: readyTimeoutMs,
|
|
565
|
+
process: child,
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
await stopChildProcess(child);
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { name: config.name, child };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function runSuite(targetConfig, suite) {
|
|
576
|
+
if (suite.type === "dal") {
|
|
577
|
+
return runDalSuite(targetConfig, suite);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (suite.framework === "playwright") {
|
|
581
|
+
return runPlaywrightSuite(targetConfig, suite);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
|
|
585
|
+
return runHttpK6Suite(targetConfig, suite);
|
|
586
|
+
}
|
|
238
587
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
588
|
+
throw new Error(
|
|
589
|
+
`Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function runHttpK6Suite(targetConfig, suite) {
|
|
594
|
+
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
595
|
+
if (!baseUrl) {
|
|
596
|
+
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
|
|
243
597
|
}
|
|
244
|
-
const envStr = envFlags.join(" ");
|
|
245
598
|
|
|
246
599
|
let failed = false;
|
|
247
|
-
|
|
248
|
-
const absFile = path.join(productDir, file);
|
|
600
|
+
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
601
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
602
|
+
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
249
603
|
try {
|
|
250
|
-
await
|
|
251
|
-
|
|
604
|
+
await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
605
|
+
cwd: targetConfig.productDir,
|
|
606
|
+
env: process.env,
|
|
607
|
+
stdio: "inherit",
|
|
608
|
+
});
|
|
609
|
+
} catch {
|
|
252
610
|
failed = true;
|
|
253
611
|
}
|
|
254
|
-
}
|
|
612
|
+
});
|
|
613
|
+
|
|
255
614
|
return { failed };
|
|
256
615
|
}
|
|
257
616
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
617
|
+
async function runDalSuite(targetConfig, suite) {
|
|
618
|
+
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
619
|
+
if (!databaseUrl) {
|
|
620
|
+
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const k6Binary = resolveDalBinary();
|
|
624
|
+
let failed = false;
|
|
625
|
+
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
626
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
627
|
+
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
628
|
+
try {
|
|
629
|
+
await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
|
|
630
|
+
cwd: targetConfig.productDir,
|
|
631
|
+
env: process.env,
|
|
632
|
+
stdio: "inherit",
|
|
633
|
+
});
|
|
634
|
+
} catch {
|
|
635
|
+
failed = true;
|
|
636
|
+
}
|
|
268
637
|
});
|
|
269
|
-
// Clear branch state so neonUp creates a new one
|
|
270
|
-
const branchFile = path.join(stateDir, "neon_branch_id");
|
|
271
|
-
if (fs.existsSync(branchFile)) fs.unlinkSync(branchFile);
|
|
272
|
-
const dbUrlFile = path.join(stateDir, "database_url");
|
|
273
|
-
if (fs.existsSync(dbUrlFile)) fs.unlinkSync(dbUrlFile);
|
|
274
|
-
}
|
|
275
638
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
* If migrations fail, nukes the Neon branch and retries on a fresh fork.
|
|
279
|
-
* A second failure means a real migration bug — throws.
|
|
280
|
-
*/
|
|
281
|
-
async function migrate(config) {
|
|
282
|
-
const { productDir, stateDir, manifest } = config;
|
|
283
|
-
const migrateCmd = manifest.testkit.migrate?.cmd;
|
|
284
|
-
if (!migrateCmd) return;
|
|
639
|
+
return { failed };
|
|
640
|
+
}
|
|
285
641
|
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
289
|
-
|
|
642
|
+
async function runPlaywrightSuite(targetConfig, suite) {
|
|
643
|
+
const local = targetConfig.testkit.local;
|
|
644
|
+
if (!local?.baseUrl) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
647
|
+
);
|
|
290
648
|
}
|
|
291
649
|
|
|
292
|
-
const
|
|
650
|
+
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
651
|
+
const files = suite.files.map((file) =>
|
|
652
|
+
path.relative(cwd, path.join(targetConfig.productDir, file))
|
|
653
|
+
);
|
|
293
654
|
|
|
294
|
-
console.log("\n── migrate ──");
|
|
295
655
|
try {
|
|
296
|
-
await
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
656
|
+
await execa("npx", ["playwright", "test", ...files], {
|
|
657
|
+
cwd,
|
|
658
|
+
env: {
|
|
659
|
+
...process.env,
|
|
660
|
+
BASE_URL: local.baseUrl,
|
|
661
|
+
PLAYWRIGHT_HTML_OPEN: "never",
|
|
662
|
+
TESTKIT_MANAGED_SERVERS: "1",
|
|
663
|
+
TESTKIT_WORKER_ID: String(targetConfig.workerId),
|
|
664
|
+
},
|
|
665
|
+
stdio: "inherit",
|
|
666
|
+
});
|
|
667
|
+
return { failed: false };
|
|
668
|
+
} catch {
|
|
669
|
+
return { failed: true };
|
|
300
670
|
}
|
|
671
|
+
}
|
|
301
672
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// Re-read DATABASE_URL from fresh branch
|
|
307
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
308
|
-
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
673
|
+
async function stopLocalServices(started) {
|
|
674
|
+
for (const service of [...started].reverse()) {
|
|
675
|
+
await stopChildProcess(service.child);
|
|
309
676
|
}
|
|
677
|
+
}
|
|
310
678
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
679
|
+
async function stopChildProcess(child) {
|
|
680
|
+
if (!child || child.exitCode !== null) return;
|
|
681
|
+
|
|
682
|
+
child.kill("SIGTERM");
|
|
683
|
+
const exited = await Promise.race([
|
|
684
|
+
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
685
|
+
sleep(5_000).then(() => false),
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
if (!exited && child.exitCode === null) {
|
|
689
|
+
child.kill("SIGKILL");
|
|
690
|
+
await new Promise((resolve) => child.once("exit", resolve));
|
|
315
691
|
}
|
|
316
692
|
}
|
|
317
693
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
console.log("No state — run tests first.");
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
for (const file of fs.readdirSync(stateDir)) {
|
|
328
|
-
if (file === "fly-env.sh") continue;
|
|
329
|
-
const filePath = path.join(stateDir, file);
|
|
330
|
-
if (fs.statSync(filePath).isDirectory()) continue;
|
|
331
|
-
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
332
|
-
console.log(` ${file}: ${val}`);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Show dependent state dirs
|
|
336
|
-
const depsDir = path.join(stateDir, "deps");
|
|
337
|
-
if (fs.existsSync(depsDir)) {
|
|
338
|
-
for (const depName of fs.readdirSync(depsDir)) {
|
|
339
|
-
const depDir = path.join(depsDir, depName);
|
|
340
|
-
if (!fs.statSync(depDir).isDirectory()) continue;
|
|
341
|
-
console.log(` ── dep: ${depName} ──`);
|
|
342
|
-
for (const file of fs.readdirSync(depDir)) {
|
|
343
|
-
if (file === "fly-env.sh") continue;
|
|
344
|
-
const filePath = path.join(depDir, file);
|
|
345
|
-
if (fs.statSync(filePath).isDirectory()) continue;
|
|
346
|
-
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
347
|
-
console.log(` ${file}: ${val}`);
|
|
348
|
-
}
|
|
694
|
+
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
695
|
+
const start = Date.now();
|
|
696
|
+
|
|
697
|
+
while (Date.now() - start < timeoutMs) {
|
|
698
|
+
if (process.exitCode !== null) {
|
|
699
|
+
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
349
700
|
}
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const response = await fetch(url);
|
|
704
|
+
if (response.ok) return;
|
|
705
|
+
} catch {
|
|
706
|
+
// Service still warming up.
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
await sleep(1_000);
|
|
350
710
|
}
|
|
711
|
+
|
|
712
|
+
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
351
713
|
}
|
|
352
714
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
const dbUrlPath = path.join(stateDir, "database_url");
|
|
361
|
-
const env = { ...process.env };
|
|
362
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
363
|
-
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
715
|
+
function needsLocalRuntime(suites) {
|
|
716
|
+
return suites.some((suite) => suite.type !== "dal");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
720
|
+
if (targetName === serviceName) {
|
|
721
|
+
return workerStateDir;
|
|
364
722
|
}
|
|
365
|
-
|
|
366
|
-
await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
|
|
723
|
+
return path.join(workerStateDir, "deps", serviceName);
|
|
367
724
|
}
|
|
368
725
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*/
|
|
373
|
-
async function runService(config, suiteType, suiteNames, opts) {
|
|
374
|
-
const { manifest, stateDir } = config;
|
|
726
|
+
function readDatabaseUrl(stateDir) {
|
|
727
|
+
return readStateValue(path.join(stateDir, "database_url"));
|
|
728
|
+
}
|
|
375
729
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
730
|
+
function readStateValue(filePath) {
|
|
731
|
+
if (!fs.existsSync(filePath)) return null;
|
|
732
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
733
|
+
}
|
|
380
734
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
dalSuites.push(suite);
|
|
389
|
-
} else {
|
|
390
|
-
httpSuites.push(suite);
|
|
391
|
-
}
|
|
735
|
+
function printStateDir(dir, indent) {
|
|
736
|
+
for (const file of fs.readdirSync(dir)) {
|
|
737
|
+
const filePath = path.join(dir, file);
|
|
738
|
+
if (fs.statSync(filePath).isDirectory()) {
|
|
739
|
+
console.log(`${indent}${file}/`);
|
|
740
|
+
printStateDir(filePath, `${indent} `);
|
|
741
|
+
continue;
|
|
392
742
|
}
|
|
743
|
+
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
744
|
+
console.log(`${indent}${file}: ${value}`);
|
|
393
745
|
}
|
|
746
|
+
}
|
|
394
747
|
|
|
395
|
-
|
|
396
|
-
|
|
748
|
+
function pipeOutput(stream, prefix) {
|
|
749
|
+
if (!stream) return;
|
|
397
750
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
751
|
+
let pending = "";
|
|
752
|
+
stream.on("data", (chunk) => {
|
|
753
|
+
pending += chunk.toString();
|
|
754
|
+
const lines = pending.split(/\r?\n/);
|
|
755
|
+
pending = lines.pop() || "";
|
|
756
|
+
for (const line of lines) {
|
|
757
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
stream.on("end", () => {
|
|
761
|
+
if (pending.length > 0) {
|
|
762
|
+
console.log(`${prefix} ${pending}`);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
402
766
|
|
|
403
|
-
|
|
767
|
+
async function runWithConcurrency(items, limit, handler) {
|
|
768
|
+
const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
|
|
769
|
+
let nextIndex = 0;
|
|
404
770
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
requireFlyToken(config.name);
|
|
411
|
-
const deps = manifest.testkit.depends || [];
|
|
412
|
-
|
|
413
|
-
// Phase 1: Build primary + deps in parallel
|
|
414
|
-
if (opts.build) {
|
|
415
|
-
await Promise.all([
|
|
416
|
-
build(config),
|
|
417
|
-
...deps.map(dep => buildDep(config, dep)),
|
|
418
|
-
]);
|
|
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);
|
|
419
776
|
}
|
|
777
|
+
});
|
|
420
778
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
await migrate(config);
|
|
424
|
-
neonReady = true;
|
|
779
|
+
await Promise.all(workers);
|
|
780
|
+
}
|
|
425
781
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
...deps.map(dep => flyUpDep(config, dep)),
|
|
430
|
-
]);
|
|
782
|
+
function sleep(ms) {
|
|
783
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
784
|
+
}
|
|
431
785
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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);
|
|
444
802
|
}
|
|
445
803
|
}
|
|
804
|
+
return resolved;
|
|
805
|
+
}
|
|
446
806
|
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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}` : ""}}"`);
|
|
452
846
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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);
|
|
457
861
|
}
|
|
862
|
+
return next;
|
|
863
|
+
} catch {
|
|
864
|
+
return rawUrl;
|
|
458
865
|
}
|
|
866
|
+
}
|
|
459
867
|
|
|
460
|
-
|
|
868
|
+
function numericPortFromUrl(rawUrl) {
|
|
869
|
+
try {
|
|
870
|
+
const url = new URL(rawUrl);
|
|
871
|
+
const port = Number(url.port);
|
|
872
|
+
return Number.isInteger(port) && port > 0 ? port : null;
|
|
873
|
+
} catch {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function assertLocalServicePortsAvailable(config) {
|
|
879
|
+
const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
|
|
880
|
+
const seen = new Set();
|
|
881
|
+
|
|
882
|
+
for (const endpoint of endpoints) {
|
|
883
|
+
const socket = socketFromUrl(endpoint);
|
|
884
|
+
if (!socket) continue;
|
|
885
|
+
|
|
886
|
+
const key = `${socket.host}:${socket.port}`;
|
|
887
|
+
if (seen.has(key)) continue;
|
|
888
|
+
seen.add(key);
|
|
889
|
+
|
|
890
|
+
if (await isPortInUse(socket)) {
|
|
891
|
+
throw new Error(
|
|
892
|
+
`Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
|
|
893
|
+
`Stop the existing process and rerun testkit.`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function socketFromUrl(rawUrl) {
|
|
900
|
+
try {
|
|
901
|
+
const url = new URL(rawUrl);
|
|
902
|
+
const port = Number(url.port);
|
|
903
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
904
|
+
|
|
905
|
+
const host = normalizeSocketHost(url.hostname);
|
|
906
|
+
return host ? { host, port } : null;
|
|
907
|
+
} catch {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function normalizeSocketHost(hostname) {
|
|
913
|
+
if (!hostname || hostname === "localhost") return "127.0.0.1";
|
|
914
|
+
if (hostname === "[::1]") return "::1";
|
|
915
|
+
return hostname;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function isPortInUse({ host, port }) {
|
|
919
|
+
return new Promise((resolve, reject) => {
|
|
920
|
+
const socket = new net.Socket();
|
|
921
|
+
let settled = false;
|
|
922
|
+
|
|
923
|
+
const finish = (value, error = null) => {
|
|
924
|
+
if (settled) return;
|
|
925
|
+
settled = true;
|
|
926
|
+
socket.destroy();
|
|
927
|
+
if (error) {
|
|
928
|
+
reject(error);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
resolve(value);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
socket.setTimeout(1_000);
|
|
935
|
+
socket.once("connect", () => finish(true));
|
|
936
|
+
socket.once("timeout", () => finish(false));
|
|
937
|
+
socket.once("error", (error) => {
|
|
938
|
+
if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
|
|
939
|
+
finish(false);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
finish(false, error);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
socket.connect(port, host);
|
|
946
|
+
});
|
|
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);
|
|
461
973
|
}
|