@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 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 test files from manifest, partitioned by flow
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 httpFiles = [];
215
- let dalFiles = [];
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
- dalFiles.push(...suite.files);
387
+ dalSuites.push(suite);
222
388
  } else {
223
- httpFiles.push(...suite.files);
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
- if (opts.build) await build(config);
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
- await flyUp(config);
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
- await flyDown(config);
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 (idempotent) → run DAL tests
446
+ // DAL flow: neon migrate → run suite pre hooks → run DAL tests
254
447
  if (dalFiles.length) {
255
- if (!neonReady) await neonUp(config);
256
- const result = await runDalTests(config, dalFiles);
257
- if (result?.failed) failed = true;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {