@elench/testkit 0.1.6 → 0.1.8
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/infra/neon-up.sh +1 -0
- package/lib/config.mjs +43 -0
- package/lib/runner.mjs +212 -13
- package/package.json +1 -1
package/infra/neon-up.sh
CHANGED
|
@@ -106,6 +106,7 @@ if command -v psql &>/dev/null; then
|
|
|
106
106
|
SELECT tablename FROM pg_tables
|
|
107
107
|
WHERE schemaname = 'public'
|
|
108
108
|
AND tablename NOT LIKE '%migration%'
|
|
109
|
+
AND tablename NOT LIKE 'goose_%'
|
|
109
110
|
AND tablename NOT LIKE 'drizzle_%'
|
|
110
111
|
) LOOP
|
|
111
112
|
EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE';
|
package/lib/config.mjs
CHANGED
|
@@ -87,6 +87,14 @@ export function loadConfigs(opts = {}) {
|
|
|
87
87
|
throw new Error(`Secret "${key}" (service "${name}") not found in env or .env`);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
// Check dependent secrets
|
|
91
|
+
for (const dep of tk.depends || []) {
|
|
92
|
+
for (const key of dep.fly?.secrets || []) {
|
|
93
|
+
if (!process.env[key]) {
|
|
94
|
+
throw new Error(`Secret "${key}" (service "${name}", dep "${dep.name}") not found in env or .env`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
90
98
|
|
|
91
99
|
// Each service gets its own state dir: .testkit/<service>/
|
|
92
100
|
const stateDir = entries.length === 1
|
|
@@ -178,6 +186,14 @@ function validateService(name, svc, manifestPath) {
|
|
|
178
186
|
optionalStringArray(errors, tk.fly, `${ctx}: testkit.fly.secrets`, "secrets");
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
if (tk.migrate !== undefined) {
|
|
190
|
+
if (!isObject(tk.migrate)) {
|
|
191
|
+
errors.push(`${ctx}: testkit.migrate must be an object`);
|
|
192
|
+
} else {
|
|
193
|
+
requireString(errors, tk.migrate, `${ctx}: testkit.migrate.cmd`, "cmd");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
if (tk.k6 !== undefined && !isObject(tk.k6)) {
|
|
182
198
|
errors.push(`${ctx}: testkit.k6 must be an object`);
|
|
183
199
|
}
|
|
@@ -185,6 +201,30 @@ function validateService(name, svc, manifestPath) {
|
|
|
185
201
|
if (tk.dal !== undefined && !isObject(tk.dal)) {
|
|
186
202
|
errors.push(`${ctx}: testkit.dal must be an object`);
|
|
187
203
|
}
|
|
204
|
+
|
|
205
|
+
if (tk.depends !== undefined) {
|
|
206
|
+
if (!Array.isArray(tk.depends)) {
|
|
207
|
+
errors.push(`${ctx}: testkit.depends must be an array`);
|
|
208
|
+
} else {
|
|
209
|
+
for (let i = 0; i < tk.depends.length; i++) {
|
|
210
|
+
const dep = tk.depends[i];
|
|
211
|
+
const dp = `${ctx}: testkit.depends[${i}]`;
|
|
212
|
+
if (!isObject(dep)) {
|
|
213
|
+
errors.push(`${dp} must be an object`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
requireString(errors, dep, `${dp}.name`, "name");
|
|
217
|
+
requireString(errors, dep, `${dp}.dockerfile`, "dockerfile");
|
|
218
|
+
if (!isObject(dep.fly)) {
|
|
219
|
+
errors.push(`${dp}.fly is required and must be an object`);
|
|
220
|
+
} else {
|
|
221
|
+
requireString(errors, dep.fly, `${dp}.fly.app`, "app");
|
|
222
|
+
requireString(errors, dep.fly, `${dp}.fly.org`, "org");
|
|
223
|
+
optionalStringArray(errors, dep.fly, `${dp}.fly.secrets`, "secrets");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
188
228
|
}
|
|
189
229
|
|
|
190
230
|
if (!isObject(svc.suites)) {
|
|
@@ -201,6 +241,9 @@ function validateService(name, svc, manifestPath) {
|
|
|
201
241
|
const prefix = `${ctx}: suites.${type}[${i}]`;
|
|
202
242
|
requireString(errors, suite, `${prefix}.name`, "name");
|
|
203
243
|
requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
|
|
244
|
+
if (suite.pre !== undefined && (typeof suite.pre !== "string" || suite.pre.length === 0)) {
|
|
245
|
+
errors.push(`${prefix}.pre must be a non-empty string (shell command)`);
|
|
246
|
+
}
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
249
|
}
|
package/lib/runner.mjs
CHANGED
|
@@ -96,6 +96,69 @@ export async function flyUp(config) {
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Build a dependent service's Docker image.
|
|
101
|
+
*/
|
|
102
|
+
async function buildDep(config, dep) {
|
|
103
|
+
const { productDir, stateDir } = config;
|
|
104
|
+
const depStateDir = path.join(stateDir, "deps", dep.name);
|
|
105
|
+
fs.mkdirSync(depStateDir, { recursive: true });
|
|
106
|
+
await runScript("fly-build.sh", {
|
|
107
|
+
FLY_APP: dep.fly.app,
|
|
108
|
+
FLY_ORG: dep.fly.org,
|
|
109
|
+
API_DIR: productDir,
|
|
110
|
+
DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
|
|
111
|
+
STATE_DIR: depStateDir,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Deploy a dependent service's Fly machine.
|
|
117
|
+
* Copies database_url from primary, generates fly-env.sh, calls fly-up.sh.
|
|
118
|
+
*/
|
|
119
|
+
async function flyUpDep(config, dep) {
|
|
120
|
+
const { stateDir } = config;
|
|
121
|
+
const depStateDir = path.join(stateDir, "deps", dep.name);
|
|
122
|
+
fs.mkdirSync(depStateDir, { recursive: true });
|
|
123
|
+
|
|
124
|
+
// Copy database_url from primary state dir
|
|
125
|
+
const primaryDbUrl = path.join(stateDir, "database_url");
|
|
126
|
+
if (fs.existsSync(primaryDbUrl)) {
|
|
127
|
+
fs.copyFileSync(primaryDbUrl, path.join(depStateDir, "database_url"));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generate fly-env.sh for dependent
|
|
131
|
+
const envLines = [];
|
|
132
|
+
for (const [k, v] of Object.entries(dep.fly.env || {})) {
|
|
133
|
+
envLines.push(` --env ${k}=${v}`);
|
|
134
|
+
}
|
|
135
|
+
for (const k of dep.fly.secrets || []) {
|
|
136
|
+
envLines.push(` --env ${k}=$${k}`); // shell substitution at source-time
|
|
137
|
+
}
|
|
138
|
+
const flyEnvPath = path.join(depStateDir, "fly-env.sh");
|
|
139
|
+
fs.writeFileSync(flyEnvPath, `FLY_ENV_FLAGS="\n${envLines.join("\n")}\n"\n`);
|
|
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
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stop a dependent service's Fly machine.
|
|
153
|
+
*/
|
|
154
|
+
async function flyDownDep(config, dep) {
|
|
155
|
+
const depStateDir = path.join(config.stateDir, "deps", dep.name);
|
|
156
|
+
await runScript("fly-down.sh", {
|
|
157
|
+
FLY_APP: dep.fly.app,
|
|
158
|
+
STATE_DIR: depStateDir,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
/**
|
|
100
163
|
* Stop the Fly machine (preserved for reuse).
|
|
101
164
|
*/
|
|
@@ -109,10 +172,20 @@ export async function flyDown(config) {
|
|
|
109
172
|
|
|
110
173
|
/**
|
|
111
174
|
* Destroy machine + neon branch + state.
|
|
175
|
+
* Destroys dependent machines first, then primary.
|
|
112
176
|
*/
|
|
113
177
|
export async function destroy(config) {
|
|
114
178
|
const { stateDir, manifest } = config;
|
|
115
179
|
const tk = manifest.testkit;
|
|
180
|
+
|
|
181
|
+
// Destroy dependents first
|
|
182
|
+
for (const dep of tk.depends || []) {
|
|
183
|
+
const depStateDir = path.join(stateDir, "deps", dep.name);
|
|
184
|
+
if (fs.existsSync(depStateDir)) {
|
|
185
|
+
await runScript("fly-destroy.sh", { FLY_APP: dep.fly.app, STATE_DIR: depStateDir });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
116
189
|
await runScript("fly-destroy.sh", { FLY_APP: tk.fly.app, STATE_DIR: stateDir });
|
|
117
190
|
await runScript("neon-down.sh", {
|
|
118
191
|
NEON_PROJECT_ID: tk.neon.projectId,
|
|
@@ -181,6 +254,66 @@ export async function runDalTests(config, files) {
|
|
|
181
254
|
return { failed };
|
|
182
255
|
}
|
|
183
256
|
|
|
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);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Run database migrations from local source against the test branch.
|
|
277
|
+
* If migrations fail, nukes the Neon branch and retries on a fresh fork.
|
|
278
|
+
* A second failure means a real migration bug — throws.
|
|
279
|
+
*/
|
|
280
|
+
async function migrate(config) {
|
|
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();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const execOpts = { stdio: "inherit", cwd: productDir, env, shell: true };
|
|
292
|
+
|
|
293
|
+
console.log("\n── migrate ──");
|
|
294
|
+
try {
|
|
295
|
+
await execaCommand(migrateCmd, execOpts);
|
|
296
|
+
return;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.log("\nMigration failed — resetting Neon branch and retrying...\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Nuke and retry: fresh fork from prod, clean goose state
|
|
302
|
+
await neonReset(config);
|
|
303
|
+
await neonUp(config);
|
|
304
|
+
|
|
305
|
+
// Re-read DATABASE_URL from fresh branch
|
|
306
|
+
if (fs.existsSync(dbUrlPath)) {
|
|
307
|
+
env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
await execaCommand(migrateCmd, execOpts);
|
|
312
|
+
} catch (retryErr) {
|
|
313
|
+
throw new Error(`Migration failed on fresh branch — likely a bug in your migration SQL:\n${retryErr.message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
184
317
|
/**
|
|
185
318
|
* Show state directory contents.
|
|
186
319
|
*/
|
|
@@ -197,6 +330,39 @@ export function showStatus(config) {
|
|
|
197
330
|
const val = fs.readFileSync(filePath, "utf8").trim();
|
|
198
331
|
console.log(` ${file}: ${val}`);
|
|
199
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
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Run a suite's `pre` command if defined.
|
|
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 });
|
|
200
366
|
}
|
|
201
367
|
|
|
202
368
|
/**
|
|
@@ -206,25 +372,28 @@ export function showStatus(config) {
|
|
|
206
372
|
async function runService(config, suiteType, suiteNames, opts) {
|
|
207
373
|
const { manifest, stateDir } = config;
|
|
208
374
|
|
|
209
|
-
// Collect
|
|
375
|
+
// Collect suites from manifest, partitioned by flow
|
|
210
376
|
const types = suiteType === "all"
|
|
211
377
|
? Object.keys(manifest.suites)
|
|
212
378
|
: [suiteType === "int" ? "integration" : suiteType];
|
|
213
379
|
|
|
214
|
-
let
|
|
215
|
-
let
|
|
380
|
+
let httpSuites = [];
|
|
381
|
+
let dalSuites = [];
|
|
216
382
|
for (const type of types) {
|
|
217
383
|
const suites = manifest.suites[type] || [];
|
|
218
384
|
for (const suite of suites) {
|
|
219
385
|
if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
|
|
220
386
|
if (type === "dal") {
|
|
221
|
-
|
|
387
|
+
dalSuites.push(suite);
|
|
222
388
|
} else {
|
|
223
|
-
|
|
389
|
+
httpSuites.push(suite);
|
|
224
390
|
}
|
|
225
391
|
}
|
|
226
392
|
}
|
|
227
393
|
|
|
394
|
+
const httpFiles = httpSuites.flatMap((s) => s.files);
|
|
395
|
+
const dalFiles = dalSuites.flatMap((s) => s.files);
|
|
396
|
+
|
|
228
397
|
if (!httpFiles.length && !dalFiles.length) {
|
|
229
398
|
console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
|
|
230
399
|
return false;
|
|
@@ -235,26 +404,56 @@ async function runService(config, suiteType, suiteNames, opts) {
|
|
|
235
404
|
let failed = false;
|
|
236
405
|
let neonReady = false;
|
|
237
406
|
|
|
238
|
-
// HTTP flow: build → neon → fly → run → fly down
|
|
407
|
+
// HTTP flow: build → neon → migrate → fly → run → fly down
|
|
239
408
|
if (httpFiles.length) {
|
|
240
409
|
requireFlyToken(config.name);
|
|
241
|
-
|
|
410
|
+
const deps = manifest.testkit.depends || [];
|
|
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
|
+
}
|
|
419
|
+
|
|
420
|
+
// Phase 2: Database (unchanged)
|
|
242
421
|
await neonUp(config);
|
|
422
|
+
await migrate(config);
|
|
243
423
|
neonReady = true;
|
|
244
|
-
|
|
424
|
+
|
|
425
|
+
// Phase 3: Deploy primary + deps in parallel
|
|
426
|
+
await Promise.all([
|
|
427
|
+
flyUp(config),
|
|
428
|
+
...deps.map(dep => flyUpDep(config, dep)),
|
|
429
|
+
]);
|
|
430
|
+
|
|
245
431
|
try {
|
|
432
|
+
for (const suite of httpSuites) {
|
|
433
|
+
await runSuitePre(config, suite);
|
|
434
|
+
}
|
|
246
435
|
const result = await runTests(config, httpFiles);
|
|
247
436
|
if (result?.failed) failed = true;
|
|
248
437
|
} finally {
|
|
249
|
-
|
|
438
|
+
// Phase 5: Teardown primary + deps in parallel
|
|
439
|
+
await Promise.allSettled([
|
|
440
|
+
flyDown(config),
|
|
441
|
+
...deps.map(dep => flyDownDep(config, dep)),
|
|
442
|
+
]);
|
|
250
443
|
}
|
|
251
444
|
}
|
|
252
445
|
|
|
253
|
-
// DAL flow: neon
|
|
446
|
+
// DAL flow: neon → migrate → run suite pre hooks → run DAL tests
|
|
254
447
|
if (dalFiles.length) {
|
|
255
|
-
if (!neonReady)
|
|
256
|
-
|
|
257
|
-
|
|
448
|
+
if (!neonReady) {
|
|
449
|
+
await neonUp(config);
|
|
450
|
+
await migrate(config);
|
|
451
|
+
}
|
|
452
|
+
for (const suite of dalSuites) {
|
|
453
|
+
await runSuitePre(config, suite);
|
|
454
|
+
const result = await runDalTests(config, suite.files);
|
|
455
|
+
if (result?.failed) failed = true;
|
|
456
|
+
}
|
|
258
457
|
}
|
|
259
458
|
|
|
260
459
|
return failed;
|