@elench/testkit 0.1.9 → 0.1.11
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 +24 -30
- package/infra/neon-up.sh +4 -1
- package/lib/cli.mjs +18 -3
- package/lib/config.mjs +265 -174
- package/lib/runner.mjs +488 -374
- 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,460 +1,574 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
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
|
+
|
|
17
|
+
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
18
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
19
|
+
let failed = false;
|
|
20
|
+
|
|
21
|
+
for (const config of configs) {
|
|
22
|
+
console.log(`\n══ ${config.name} ══`);
|
|
23
|
+
const serviceFailed = await runService(config, configMap, suiteType, suiteNames, opts);
|
|
24
|
+
if (serviceFailed) failed = true;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
configs.map(async (config) => {
|
|
23
|
-
console.log(`\n══ ${config.name} ══`);
|
|
24
|
-
return runService(config, suiteType, suiteNames, opts);
|
|
25
|
-
})
|
|
26
|
-
);
|
|
27
|
+
if (failed) process.exit(1);
|
|
28
|
+
}
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
30
|
+
export async function destroy(config) {
|
|
31
|
+
const runtimeServices = [];
|
|
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,
|
|
33
45
|
});
|
|
34
|
-
console.log(`\n── Summary ──\n${summary.join("\n")}`);
|
|
35
46
|
|
|
36
|
-
const
|
|
37
|
-
(
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
for (const runtime of runtimeServices) {
|
|
48
|
+
if (!fs.existsSync(runtime.stateDir)) continue;
|
|
49
|
+
const dbFile = path.join(runtime.stateDir, "neon_branch_id");
|
|
50
|
+
if (fs.existsSync(dbFile)) {
|
|
51
|
+
const serviceConfig =
|
|
52
|
+
runtime.name === config.name
|
|
53
|
+
? config.testkit
|
|
54
|
+
: { database: { projectId: readStateValue(path.join(runtime.stateDir, "neon_project_id")) } };
|
|
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
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fs.rmSync(config.stateDir, { recursive: true, force: true });
|
|
40
67
|
}
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
FLY_APP: tk.fly.app,
|
|
50
|
-
FLY_ORG: tk.fly.org,
|
|
51
|
-
API_DIR: productDir,
|
|
52
|
-
DOCKERFILE_DIR: tk.dockerfile ? path.join(productDir, tk.dockerfile) : productDir,
|
|
53
|
-
STATE_DIR: stateDir,
|
|
54
|
-
});
|
|
69
|
+
export function showStatus(config) {
|
|
70
|
+
if (!fs.existsSync(config.stateDir)) {
|
|
71
|
+
console.log("No state — run tests first.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
printStateDir(config.stateDir, " ");
|
|
55
76
|
}
|
|
56
77
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
async function runService(targetConfig, configMap, suiteType, suiteNames, opts) {
|
|
79
|
+
const suites = collectSuites(targetConfig, suiteType, suiteNames, opts.framework);
|
|
80
|
+
if (suites.length === 0) {
|
|
81
|
+
console.log(
|
|
82
|
+
`No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
83
|
+
);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
|
|
88
|
+
fs.mkdirSync(targetConfig.stateDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
let startedServices = [];
|
|
91
|
+
let failed = false;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await prepareDatabases(runtimeConfigs, targetConfig);
|
|
95
|
+
await runMigrations(runtimeConfigs, targetConfig);
|
|
96
|
+
await runSeeds(runtimeConfigs, targetConfig);
|
|
97
|
+
|
|
98
|
+
if (needsLocalRuntime(suites)) {
|
|
99
|
+
startedServices = await startLocalServices(runtimeConfigs, targetConfig);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const suite of suites) {
|
|
103
|
+
console.log(`\n── ${suite.type}:${suite.name} (${suite.framework}) ──`);
|
|
104
|
+
const result = await runSuite(targetConfig, suite, targetConfig.stateDir);
|
|
105
|
+
if (result.failed) failed = true;
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
await stopLocalServices(startedServices);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return failed;
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
for (const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
FLY_ENV_FILE: flyEnvPath,
|
|
95
|
-
STATE_DIR: stateDir,
|
|
96
|
-
});
|
|
114
|
+
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
115
|
+
const types =
|
|
116
|
+
suiteType === "all"
|
|
117
|
+
? orderedTypes(Object.keys(config.suites))
|
|
118
|
+
: [suiteType === "int" ? "integration" : suiteType];
|
|
119
|
+
|
|
120
|
+
const selectedNames = new Set(suiteNames);
|
|
121
|
+
const suites = [];
|
|
122
|
+
|
|
123
|
+
for (const type of types) {
|
|
124
|
+
for (const suite of config.suites[type] || []) {
|
|
125
|
+
const framework = suite.framework || "k6";
|
|
126
|
+
if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
|
|
127
|
+
if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
|
|
128
|
+
suites.push({
|
|
129
|
+
...suite,
|
|
130
|
+
framework,
|
|
131
|
+
type,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return suites;
|
|
97
137
|
}
|
|
98
138
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
FLY_ORG: dep.fly.org,
|
|
109
|
-
API_DIR: productDir,
|
|
110
|
-
DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
|
|
111
|
-
STATE_DIR: depStateDir,
|
|
112
|
-
});
|
|
139
|
+
function orderedTypes(types) {
|
|
140
|
+
const ordered = [];
|
|
141
|
+
for (const known of TYPE_ORDER) {
|
|
142
|
+
if (types.includes(known)) ordered.push(known);
|
|
143
|
+
}
|
|
144
|
+
for (const type of types) {
|
|
145
|
+
if (!ordered.includes(type)) ordered.push(type);
|
|
146
|
+
}
|
|
147
|
+
return ordered;
|
|
113
148
|
}
|
|
114
149
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await runScript("fly-up.sh", {
|
|
142
|
-
FLY_APP: dep.fly.app,
|
|
143
|
-
FLY_ORG: dep.fly.org,
|
|
144
|
-
FLY_REGION: dep.fly.region || "lhr",
|
|
145
|
-
FLY_PORT: dep.fly.port,
|
|
146
|
-
FLY_ENV_FILE: flyEnvPath,
|
|
147
|
-
STATE_DIR: depStateDir,
|
|
148
|
-
});
|
|
150
|
+
function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
151
|
+
const ordered = [];
|
|
152
|
+
const visiting = new Set();
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
|
|
155
|
+
const visit = (config) => {
|
|
156
|
+
if (seen.has(config.name)) return;
|
|
157
|
+
if (visiting.has(config.name)) {
|
|
158
|
+
throw new Error(`Dependency cycle detected involving "${config.name}"`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
visiting.add(config.name);
|
|
162
|
+
for (const depName of config.testkit.dependsOn || []) {
|
|
163
|
+
const dep = configMap.get(depName);
|
|
164
|
+
if (!dep) {
|
|
165
|
+
throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
|
|
166
|
+
}
|
|
167
|
+
visit(dep);
|
|
168
|
+
}
|
|
169
|
+
visiting.delete(config.name);
|
|
170
|
+
seen.add(config.name);
|
|
171
|
+
ordered.push(config);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
visit(targetConfig);
|
|
175
|
+
return ordered;
|
|
149
176
|
}
|
|
150
177
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
178
|
+
async function prepareDatabases(runtimeConfigs, targetConfig) {
|
|
179
|
+
for (const config of runtimeConfigs) {
|
|
180
|
+
const db = config.testkit.database;
|
|
181
|
+
if (!db) continue;
|
|
182
|
+
|
|
183
|
+
requireNeonApiKey();
|
|
184
|
+
|
|
185
|
+
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
186
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
await runScript("neon-up.sh", {
|
|
189
|
+
NEON_PROJECT_ID: db.projectId,
|
|
190
|
+
NEON_DB_NAME: db.dbName,
|
|
191
|
+
NEON_BRANCH_NAME: db.branchName || `${targetConfig.name}-${config.name}-testkit`,
|
|
192
|
+
NEON_RESET: db.reset === false ? "false" : "true",
|
|
193
|
+
STATE_DIR: stateDir,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
fs.writeFileSync(path.join(stateDir, "neon_project_id"), db.projectId);
|
|
197
|
+
}
|
|
160
198
|
}
|
|
161
199
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
200
|
+
async function runMigrations(runtimeConfigs, targetConfig) {
|
|
201
|
+
for (const config of runtimeConfigs) {
|
|
202
|
+
const migrate = config.testkit.migrate;
|
|
203
|
+
if (!migrate) continue;
|
|
204
|
+
|
|
205
|
+
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
206
|
+
const env = { ...process.env };
|
|
207
|
+
const dbUrl = readDatabaseUrl(stateDir);
|
|
208
|
+
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
209
|
+
|
|
210
|
+
console.log(`\n── migrate:${config.name} ──`);
|
|
211
|
+
await execaCommand(migrate.cmd, {
|
|
212
|
+
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
213
|
+
env,
|
|
214
|
+
stdio: "inherit",
|
|
215
|
+
shell: true,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
171
218
|
}
|
|
172
219
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
220
|
+
async function runSeeds(runtimeConfigs, targetConfig) {
|
|
221
|
+
for (const config of runtimeConfigs) {
|
|
222
|
+
const seed = config.testkit.seed;
|
|
223
|
+
if (!seed) continue;
|
|
224
|
+
|
|
225
|
+
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
226
|
+
const env = { ...process.env };
|
|
227
|
+
const dbUrl = readDatabaseUrl(stateDir);
|
|
228
|
+
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
229
|
+
|
|
230
|
+
console.log(`\n── seed:${config.name} ──`);
|
|
231
|
+
await execaCommand(seed.cmd, {
|
|
232
|
+
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
233
|
+
env,
|
|
234
|
+
stdio: "inherit",
|
|
235
|
+
shell: true,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function startLocalServices(runtimeConfigs, targetConfig) {
|
|
241
|
+
const started = [];
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
for (const config of runtimeConfigs) {
|
|
245
|
+
if (!config.testkit.local) continue;
|
|
246
|
+
const stateDir = getServiceStateDir(targetConfig, config.name);
|
|
247
|
+
const proc = await startLocalService(config, stateDir);
|
|
248
|
+
started.push(proc);
|
|
186
249
|
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await stopLocalServices(started);
|
|
252
|
+
throw error;
|
|
187
253
|
}
|
|
188
254
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
255
|
+
return started;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function startLocalService(config, stateDir) {
|
|
259
|
+
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
260
|
+
const env = {
|
|
261
|
+
...process.env,
|
|
262
|
+
...config.testkit.local.env,
|
|
263
|
+
};
|
|
264
|
+
const port = portFromUrl(config.testkit.local.baseUrl);
|
|
265
|
+
if (port) {
|
|
266
|
+
env.PORT = port;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const dbUrl = readDatabaseUrl(stateDir);
|
|
270
|
+
if (dbUrl) {
|
|
271
|
+
env.DATABASE_URL = dbUrl;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await assertLocalServicePortsAvailable(config);
|
|
275
|
+
|
|
276
|
+
console.log(`Starting ${config.name}: ${config.testkit.local.start}`);
|
|
277
|
+
const child = spawn(config.testkit.local.start, {
|
|
278
|
+
cwd,
|
|
279
|
+
env,
|
|
280
|
+
shell: true,
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
193
282
|
});
|
|
194
|
-
|
|
283
|
+
|
|
284
|
+
pipeOutput(child.stdout, `[${config.name}]`);
|
|
285
|
+
pipeOutput(child.stderr, `[${config.name}]`);
|
|
286
|
+
|
|
287
|
+
const readyTimeoutMs =
|
|
288
|
+
config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await waitForReady({
|
|
292
|
+
name: config.name,
|
|
293
|
+
url: config.testkit.local.readyUrl,
|
|
294
|
+
timeoutMs: readyTimeoutMs,
|
|
295
|
+
process: child,
|
|
296
|
+
});
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await stopChildProcess(child);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { name: config.name, child };
|
|
195
303
|
}
|
|
196
304
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
305
|
+
async function runSuite(targetConfig, suite, stateDir) {
|
|
306
|
+
if (suite.type === "dal") {
|
|
307
|
+
return runDalSuite(targetConfig, suite, stateDir);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (suite.framework === "playwright") {
|
|
311
|
+
return runPlaywrightSuite(targetConfig, suite);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
|
|
315
|
+
return runHttpK6Suite(targetConfig, suite);
|
|
316
|
+
}
|
|
203
317
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
207
322
|
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
323
|
+
async function runHttpK6Suite(targetConfig, suite) {
|
|
324
|
+
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
325
|
+
if (!baseUrl) {
|
|
326
|
+
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
|
|
212
327
|
}
|
|
213
|
-
const envStr = envFlags.join(" ");
|
|
214
328
|
|
|
215
329
|
let failed = false;
|
|
216
|
-
for (const file of files) {
|
|
217
|
-
const absFile = path.join(productDir, file);
|
|
330
|
+
for (const file of suite.files) {
|
|
331
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
218
332
|
try {
|
|
219
|
-
await
|
|
220
|
-
|
|
333
|
+
await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
334
|
+
cwd: targetConfig.productDir,
|
|
335
|
+
env: process.env,
|
|
336
|
+
stdio: "inherit",
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
221
339
|
failed = true;
|
|
222
340
|
}
|
|
223
341
|
}
|
|
342
|
+
|
|
224
343
|
return { failed };
|
|
225
344
|
}
|
|
226
345
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const { productDir, stateDir, manifest } = config;
|
|
232
|
-
const tk = manifest.testkit;
|
|
233
|
-
const k6Binary = resolveDalBinary();
|
|
234
|
-
|
|
235
|
-
// Read DATABASE_URL from neon state
|
|
236
|
-
const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
|
|
237
|
-
|
|
238
|
-
// Build -e flags
|
|
239
|
-
const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
|
|
240
|
-
for (const key of tk.dal?.secrets || []) {
|
|
241
|
-
envFlags.push(`-e ${key}=${process.env[key]}`);
|
|
346
|
+
async function runDalSuite(targetConfig, suite, stateDir) {
|
|
347
|
+
const databaseUrl = readDatabaseUrl(stateDir);
|
|
348
|
+
if (!databaseUrl) {
|
|
349
|
+
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
242
350
|
}
|
|
243
|
-
const envStr = envFlags.join(" ");
|
|
244
351
|
|
|
352
|
+
const k6Binary = resolveDalBinary();
|
|
245
353
|
let failed = false;
|
|
246
|
-
|
|
247
|
-
|
|
354
|
+
|
|
355
|
+
for (const file of suite.files) {
|
|
356
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
248
357
|
try {
|
|
249
|
-
await
|
|
250
|
-
|
|
358
|
+
await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
|
|
359
|
+
cwd: targetConfig.productDir,
|
|
360
|
+
env: process.env,
|
|
361
|
+
stdio: "inherit",
|
|
362
|
+
});
|
|
363
|
+
} catch {
|
|
251
364
|
failed = true;
|
|
252
365
|
}
|
|
253
366
|
}
|
|
254
|
-
return { failed };
|
|
255
|
-
}
|
|
256
367
|
|
|
257
|
-
|
|
258
|
-
* Delete the Neon branch (so neonUp creates a fresh fork from prod).
|
|
259
|
-
*/
|
|
260
|
-
async function neonReset(config) {
|
|
261
|
-
const { stateDir, manifest } = config;
|
|
262
|
-
const tk = manifest.testkit;
|
|
263
|
-
console.log("Deleting Neon branch for fresh fork...");
|
|
264
|
-
await runScript("neon-down.sh", {
|
|
265
|
-
NEON_PROJECT_ID: tk.neon.projectId,
|
|
266
|
-
STATE_DIR: stateDir,
|
|
267
|
-
});
|
|
268
|
-
// Clear branch state so neonUp creates a new one
|
|
269
|
-
const branchFile = path.join(stateDir, "neon_branch_id");
|
|
270
|
-
if (fs.existsSync(branchFile)) fs.unlinkSync(branchFile);
|
|
271
|
-
const dbUrlFile = path.join(stateDir, "database_url");
|
|
272
|
-
if (fs.existsSync(dbUrlFile)) fs.unlinkSync(dbUrlFile);
|
|
368
|
+
return { failed };
|
|
273
369
|
}
|
|
274
370
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const { productDir, stateDir, manifest } = config;
|
|
282
|
-
const migrateCmd = manifest.testkit.migrate?.cmd;
|
|
283
|
-
if (!migrateCmd) return;
|
|
284
|
-
|
|
285
|
-
const dbUrlPath = path.join(stateDir, "database_url");
|
|
286
|
-
const env = { ...process.env };
|
|
287
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
288
|
-
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
371
|
+
async function runPlaywrightSuite(targetConfig, suite) {
|
|
372
|
+
const local = targetConfig.testkit.local;
|
|
373
|
+
if (!local?.baseUrl) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
376
|
+
);
|
|
289
377
|
}
|
|
290
378
|
|
|
291
|
-
const
|
|
379
|
+
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
380
|
+
const files = suite.files.map((file) => path.relative(cwd, path.join(targetConfig.productDir, file)));
|
|
292
381
|
|
|
293
|
-
console.log("\n── migrate ──");
|
|
294
382
|
try {
|
|
295
|
-
await
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
383
|
+
await execa("npx", ["playwright", "test", ...files], {
|
|
384
|
+
cwd,
|
|
385
|
+
env: {
|
|
386
|
+
...process.env,
|
|
387
|
+
BASE_URL: local.baseUrl,
|
|
388
|
+
PLAYWRIGHT_HTML_OPEN: "never",
|
|
389
|
+
TESTKIT_MANAGED_SERVERS: "1",
|
|
390
|
+
},
|
|
391
|
+
stdio: "inherit",
|
|
392
|
+
});
|
|
393
|
+
return { failed: false };
|
|
394
|
+
} catch {
|
|
395
|
+
return { failed: true };
|
|
299
396
|
}
|
|
397
|
+
}
|
|
300
398
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// Re-read DATABASE_URL from fresh branch
|
|
306
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
307
|
-
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
399
|
+
async function stopLocalServices(started) {
|
|
400
|
+
for (const service of [...started].reverse()) {
|
|
401
|
+
await stopChildProcess(service.child);
|
|
308
402
|
}
|
|
403
|
+
}
|
|
309
404
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
405
|
+
async function stopChildProcess(child) {
|
|
406
|
+
if (!child || child.exitCode !== null) return;
|
|
407
|
+
|
|
408
|
+
child.kill("SIGTERM");
|
|
409
|
+
const exited = await Promise.race([
|
|
410
|
+
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
411
|
+
sleep(5_000).then(() => false),
|
|
412
|
+
]);
|
|
413
|
+
|
|
414
|
+
if (!exited && child.exitCode === null) {
|
|
415
|
+
child.kill("SIGKILL");
|
|
416
|
+
await new Promise((resolve) => child.once("exit", resolve));
|
|
314
417
|
}
|
|
315
418
|
}
|
|
316
419
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
console.log("No state — run tests first.");
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
for (const file of fs.readdirSync(stateDir)) {
|
|
327
|
-
if (file === "fly-env.sh") continue;
|
|
328
|
-
const filePath = path.join(stateDir, file);
|
|
329
|
-
if (fs.statSync(filePath).isDirectory()) continue;
|
|
330
|
-
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
331
|
-
console.log(` ${file}: ${val}`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Show dependent state dirs
|
|
335
|
-
const depsDir = path.join(stateDir, "deps");
|
|
336
|
-
if (fs.existsSync(depsDir)) {
|
|
337
|
-
for (const depName of fs.readdirSync(depsDir)) {
|
|
338
|
-
const depDir = path.join(depsDir, depName);
|
|
339
|
-
if (!fs.statSync(depDir).isDirectory()) continue;
|
|
340
|
-
console.log(` ── dep: ${depName} ──`);
|
|
341
|
-
for (const file of fs.readdirSync(depDir)) {
|
|
342
|
-
if (file === "fly-env.sh") continue;
|
|
343
|
-
const filePath = path.join(depDir, file);
|
|
344
|
-
if (fs.statSync(filePath).isDirectory()) continue;
|
|
345
|
-
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
346
|
-
console.log(` ${file}: ${val}`);
|
|
347
|
-
}
|
|
420
|
+
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
421
|
+
const start = Date.now();
|
|
422
|
+
|
|
423
|
+
while (Date.now() - start < timeoutMs) {
|
|
424
|
+
if (process.exitCode !== null) {
|
|
425
|
+
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
348
426
|
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const response = await fetch(url);
|
|
430
|
+
if (response.ok) return;
|
|
431
|
+
} catch {
|
|
432
|
+
// Service still warming up.
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await sleep(1_000);
|
|
349
436
|
}
|
|
437
|
+
|
|
438
|
+
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
350
439
|
}
|
|
351
440
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
* Executes in the product directory with DATABASE_URL available.
|
|
355
|
-
*/
|
|
356
|
-
async function runSuitePre(config, suite) {
|
|
357
|
-
if (!suite.pre) return;
|
|
358
|
-
const { productDir, stateDir } = config;
|
|
359
|
-
const dbUrlPath = path.join(stateDir, "database_url");
|
|
360
|
-
const env = { ...process.env };
|
|
361
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
362
|
-
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
363
|
-
}
|
|
364
|
-
console.log(`\n── pre: ${suite.name} ──`);
|
|
365
|
-
await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
|
|
441
|
+
function needsLocalRuntime(suites) {
|
|
442
|
+
return suites.some((suite) => suite.type !== "dal");
|
|
366
443
|
}
|
|
367
444
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
445
|
+
function getServiceStateDir(targetConfig, serviceName) {
|
|
446
|
+
if (targetConfig.name === serviceName) {
|
|
447
|
+
return targetConfig.stateDir;
|
|
448
|
+
}
|
|
449
|
+
return path.join(targetConfig.stateDir, "deps", serviceName);
|
|
450
|
+
}
|
|
374
451
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
: [suiteType === "int" ? "integration" : suiteType];
|
|
452
|
+
function readDatabaseUrl(stateDir) {
|
|
453
|
+
return readStateValue(path.join(stateDir, "database_url"));
|
|
454
|
+
}
|
|
379
455
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
456
|
+
function readStateValue(filePath) {
|
|
457
|
+
if (!fs.existsSync(filePath)) return null;
|
|
458
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function printStateDir(dir, indent) {
|
|
462
|
+
for (const file of fs.readdirSync(dir)) {
|
|
463
|
+
const filePath = path.join(dir, file);
|
|
464
|
+
if (fs.statSync(filePath).isDirectory()) {
|
|
465
|
+
console.log(`${indent}${file}/`);
|
|
466
|
+
printStateDir(filePath, `${indent} `);
|
|
467
|
+
continue;
|
|
391
468
|
}
|
|
469
|
+
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
470
|
+
console.log(`${indent}${file}: ${value}`);
|
|
392
471
|
}
|
|
472
|
+
}
|
|
393
473
|
|
|
394
|
-
|
|
395
|
-
|
|
474
|
+
function pipeOutput(stream, prefix) {
|
|
475
|
+
if (!stream) return;
|
|
396
476
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
477
|
+
let pending = "";
|
|
478
|
+
stream.on("data", (chunk) => {
|
|
479
|
+
pending += chunk.toString();
|
|
480
|
+
const lines = pending.split(/\r?\n/);
|
|
481
|
+
pending = lines.pop() || "";
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
stream.on("end", () => {
|
|
487
|
+
if (pending.length > 0) {
|
|
488
|
+
console.log(`${prefix} ${pending}`);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
401
492
|
|
|
402
|
-
|
|
493
|
+
function sleep(ms) {
|
|
494
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
495
|
+
}
|
|
403
496
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// Phase 1: Build primary + deps in parallel
|
|
413
|
-
if (opts.build) {
|
|
414
|
-
await Promise.all([
|
|
415
|
-
build(config),
|
|
416
|
-
...deps.map(dep => buildDep(config, dep)),
|
|
417
|
-
]);
|
|
418
|
-
}
|
|
497
|
+
function portFromUrl(rawUrl) {
|
|
498
|
+
try {
|
|
499
|
+
const url = new URL(rawUrl);
|
|
500
|
+
return url.port || null;
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
419
505
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
neonReady = true;
|
|
506
|
+
async function assertLocalServicePortsAvailable(config) {
|
|
507
|
+
const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
|
|
508
|
+
const seen = new Set();
|
|
424
509
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
...deps.map(dep => flyUpDep(config, dep)),
|
|
429
|
-
]);
|
|
510
|
+
for (const endpoint of endpoints) {
|
|
511
|
+
const socket = socketFromUrl(endpoint);
|
|
512
|
+
if (!socket) continue;
|
|
430
513
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
await Promise.allSettled([
|
|
440
|
-
flyDown(config),
|
|
441
|
-
...deps.map(dep => flyDownDep(config, dep)),
|
|
442
|
-
]);
|
|
514
|
+
const key = `${socket.host}:${socket.port}`;
|
|
515
|
+
if (seen.has(key)) continue;
|
|
516
|
+
seen.add(key);
|
|
517
|
+
|
|
518
|
+
if (await isPortInUse(socket)) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
`Cannot start "${config.name}" because ${key} is already in use. Stop the existing process and rerun testkit.`
|
|
521
|
+
);
|
|
443
522
|
}
|
|
444
523
|
}
|
|
524
|
+
}
|
|
445
525
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
526
|
+
function socketFromUrl(rawUrl) {
|
|
527
|
+
try {
|
|
528
|
+
const url = new URL(rawUrl);
|
|
529
|
+
const port = Number(url.port);
|
|
530
|
+
if (!Number.isInteger(port) || port <= 0) return null;
|
|
531
|
+
|
|
532
|
+
const host = normalizeSocketHost(url.hostname);
|
|
533
|
+
return host ? { host, port } : null;
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
457
536
|
}
|
|
537
|
+
}
|
|
458
538
|
|
|
459
|
-
|
|
539
|
+
function normalizeSocketHost(hostname) {
|
|
540
|
+
if (!hostname || hostname === "localhost") return "127.0.0.1";
|
|
541
|
+
if (hostname === "[::1]") return "::1";
|
|
542
|
+
return hostname;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function isPortInUse({ host, port }) {
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
const socket = new net.Socket();
|
|
548
|
+
let settled = false;
|
|
549
|
+
|
|
550
|
+
const finish = (value, error = null) => {
|
|
551
|
+
if (settled) return;
|
|
552
|
+
settled = true;
|
|
553
|
+
socket.destroy();
|
|
554
|
+
if (error) {
|
|
555
|
+
reject(error);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
resolve(value);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
socket.setTimeout(1_000);
|
|
562
|
+
socket.once("connect", () => finish(true));
|
|
563
|
+
socket.once("timeout", () => finish(false));
|
|
564
|
+
socket.once("error", (error) => {
|
|
565
|
+
if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
|
|
566
|
+
finish(false);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
finish(false, error);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
socket.connect(port, host);
|
|
573
|
+
});
|
|
460
574
|
}
|