@elench/testkit 0.1.10 → 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/lib/cli.mjs +18 -3
- package/lib/config.mjs +264 -176
- package/lib/runner.mjs +488 -375
- 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,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
|
-
|
|
69
|
-
|
|
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;
|
|
70
112
|
}
|
|
71
113
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
FLY_ENV_FILE: flyEnvPath,
|
|
96
|
-
STATE_DIR: stateDir,
|
|
97
|
-
});
|
|
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;
|
|
98
137
|
}
|
|
99
138
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
FLY_ORG: dep.fly.org,
|
|
110
|
-
API_DIR: productDir,
|
|
111
|
-
DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
|
|
112
|
-
STATE_DIR: depStateDir,
|
|
113
|
-
});
|
|
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;
|
|
114
148
|
}
|
|
115
149
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
await runScript("fly-up.sh", {
|
|
143
|
-
FLY_APP: dep.fly.app,
|
|
144
|
-
FLY_ORG: dep.fly.org,
|
|
145
|
-
FLY_REGION: dep.fly.region || "lhr",
|
|
146
|
-
FLY_PORT: dep.fly.port,
|
|
147
|
-
FLY_ENV_FILE: flyEnvPath,
|
|
148
|
-
STATE_DIR: depStateDir,
|
|
149
|
-
});
|
|
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;
|
|
150
176
|
}
|
|
151
177
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
}
|
|
161
198
|
}
|
|
162
199
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
249
|
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await stopLocalServices(started);
|
|
252
|
+
throw error;
|
|
188
253
|
}
|
|
189
254
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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"],
|
|
194
282
|
});
|
|
195
|
-
|
|
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 };
|
|
196
303
|
}
|
|
197
304
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
317
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
208
322
|
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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`);
|
|
213
327
|
}
|
|
214
|
-
const envStr = envFlags.join(" ");
|
|
215
328
|
|
|
216
329
|
let failed = false;
|
|
217
|
-
for (const file of files) {
|
|
218
|
-
const absFile = path.join(productDir, file);
|
|
330
|
+
for (const file of suite.files) {
|
|
331
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
219
332
|
try {
|
|
220
|
-
await
|
|
221
|
-
|
|
333
|
+
await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
334
|
+
cwd: targetConfig.productDir,
|
|
335
|
+
env: process.env,
|
|
336
|
+
stdio: "inherit",
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
222
339
|
failed = true;
|
|
223
340
|
}
|
|
224
341
|
}
|
|
342
|
+
|
|
225
343
|
return { failed };
|
|
226
344
|
}
|
|
227
345
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const { productDir, stateDir, manifest } = config;
|
|
233
|
-
const tk = manifest.testkit;
|
|
234
|
-
const k6Binary = resolveDalBinary();
|
|
235
|
-
|
|
236
|
-
// Read DATABASE_URL from neon state
|
|
237
|
-
const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
|
|
238
|
-
|
|
239
|
-
// Build -e flags
|
|
240
|
-
const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
|
|
241
|
-
for (const key of tk.dal?.secrets || []) {
|
|
242
|
-
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`);
|
|
243
350
|
}
|
|
244
|
-
const envStr = envFlags.join(" ");
|
|
245
351
|
|
|
352
|
+
const k6Binary = resolveDalBinary();
|
|
246
353
|
let failed = false;
|
|
247
|
-
|
|
248
|
-
|
|
354
|
+
|
|
355
|
+
for (const file of suite.files) {
|
|
356
|
+
const absFile = path.join(targetConfig.productDir, file);
|
|
249
357
|
try {
|
|
250
|
-
await
|
|
251
|
-
|
|
358
|
+
await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
|
|
359
|
+
cwd: targetConfig.productDir,
|
|
360
|
+
env: process.env,
|
|
361
|
+
stdio: "inherit",
|
|
362
|
+
});
|
|
363
|
+
} catch {
|
|
252
364
|
failed = true;
|
|
253
365
|
}
|
|
254
366
|
}
|
|
255
|
-
return { failed };
|
|
256
|
-
}
|
|
257
367
|
|
|
258
|
-
|
|
259
|
-
* Delete the Neon branch (so neonUp creates a fresh fork from prod).
|
|
260
|
-
*/
|
|
261
|
-
async function neonReset(config) {
|
|
262
|
-
const { stateDir, manifest } = config;
|
|
263
|
-
const tk = manifest.testkit;
|
|
264
|
-
console.log("Deleting Neon branch for fresh fork...");
|
|
265
|
-
await runScript("neon-down.sh", {
|
|
266
|
-
NEON_PROJECT_ID: tk.neon.projectId,
|
|
267
|
-
STATE_DIR: stateDir,
|
|
268
|
-
});
|
|
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);
|
|
368
|
+
return { failed };
|
|
274
369
|
}
|
|
275
370
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const { productDir, stateDir, manifest } = config;
|
|
283
|
-
const migrateCmd = manifest.testkit.migrate?.cmd;
|
|
284
|
-
if (!migrateCmd) return;
|
|
285
|
-
|
|
286
|
-
const dbUrlPath = path.join(stateDir, "database_url");
|
|
287
|
-
const env = { ...process.env };
|
|
288
|
-
if (fs.existsSync(dbUrlPath)) {
|
|
289
|
-
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
|
+
);
|
|
290
377
|
}
|
|
291
378
|
|
|
292
|
-
const
|
|
379
|
+
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
380
|
+
const files = suite.files.map((file) => path.relative(cwd, path.join(targetConfig.productDir, file)));
|
|
293
381
|
|
|
294
|
-
console.log("\n── migrate ──");
|
|
295
382
|
try {
|
|
296
|
-
await
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 };
|
|
300
396
|
}
|
|
397
|
+
}
|
|
301
398
|
|
|
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();
|
|
399
|
+
async function stopLocalServices(started) {
|
|
400
|
+
for (const service of [...started].reverse()) {
|
|
401
|
+
await stopChildProcess(service.child);
|
|
309
402
|
}
|
|
403
|
+
}
|
|
310
404
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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));
|
|
315
417
|
}
|
|
316
418
|
}
|
|
317
419
|
|
|
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
|
-
}
|
|
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`);
|
|
349
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);
|
|
350
436
|
}
|
|
437
|
+
|
|
438
|
+
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
351
439
|
}
|
|
352
440
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
* Executes in the product directory with DATABASE_URL available.
|
|
356
|
-
*/
|
|
357
|
-
async function runSuitePre(config, suite) {
|
|
358
|
-
if (!suite.pre) return;
|
|
359
|
-
const { productDir, stateDir } = config;
|
|
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();
|
|
364
|
-
}
|
|
365
|
-
console.log(`\n── pre: ${suite.name} ──`);
|
|
366
|
-
await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
|
|
441
|
+
function needsLocalRuntime(suites) {
|
|
442
|
+
return suites.some((suite) => suite.type !== "dal");
|
|
367
443
|
}
|
|
368
444
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
445
|
+
function getServiceStateDir(targetConfig, serviceName) {
|
|
446
|
+
if (targetConfig.name === serviceName) {
|
|
447
|
+
return targetConfig.stateDir;
|
|
448
|
+
}
|
|
449
|
+
return path.join(targetConfig.stateDir, "deps", serviceName);
|
|
450
|
+
}
|
|
375
451
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
: [suiteType === "int" ? "integration" : suiteType];
|
|
452
|
+
function readDatabaseUrl(stateDir) {
|
|
453
|
+
return readStateValue(path.join(stateDir, "database_url"));
|
|
454
|
+
}
|
|
380
455
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}
|
|
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;
|
|
392
468
|
}
|
|
469
|
+
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
470
|
+
console.log(`${indent}${file}: ${value}`);
|
|
393
471
|
}
|
|
472
|
+
}
|
|
394
473
|
|
|
395
|
-
|
|
396
|
-
|
|
474
|
+
function pipeOutput(stream, prefix) {
|
|
475
|
+
if (!stream) return;
|
|
397
476
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
+
}
|
|
402
492
|
|
|
403
|
-
|
|
493
|
+
function sleep(ms) {
|
|
494
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
495
|
+
}
|
|
404
496
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
]);
|
|
419
|
-
}
|
|
497
|
+
function portFromUrl(rawUrl) {
|
|
498
|
+
try {
|
|
499
|
+
const url = new URL(rawUrl);
|
|
500
|
+
return url.port || null;
|
|
501
|
+
} catch {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
420
505
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
neonReady = true;
|
|
506
|
+
async function assertLocalServicePortsAvailable(config) {
|
|
507
|
+
const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
|
|
508
|
+
const seen = new Set();
|
|
425
509
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
...deps.map(dep => flyUpDep(config, dep)),
|
|
430
|
-
]);
|
|
510
|
+
for (const endpoint of endpoints) {
|
|
511
|
+
const socket = socketFromUrl(endpoint);
|
|
512
|
+
if (!socket) continue;
|
|
431
513
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
await Promise.allSettled([
|
|
441
|
-
flyDown(config),
|
|
442
|
-
...deps.map(dep => flyDownDep(config, dep)),
|
|
443
|
-
]);
|
|
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
|
+
);
|
|
444
522
|
}
|
|
445
523
|
}
|
|
524
|
+
}
|
|
446
525
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
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;
|
|
458
536
|
}
|
|
537
|
+
}
|
|
459
538
|
|
|
460
|
-
|
|
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
|
+
});
|
|
461
574
|
}
|