@elench/testkit 0.1.6 → 0.1.7

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
@@ -178,6 +178,14 @@ function validateService(name, svc, manifestPath) {
178
178
  optionalStringArray(errors, tk.fly, `${ctx}: testkit.fly.secrets`, "secrets");
179
179
  }
180
180
 
181
+ if (tk.migrate !== undefined) {
182
+ if (!isObject(tk.migrate)) {
183
+ errors.push(`${ctx}: testkit.migrate must be an object`);
184
+ } else {
185
+ requireString(errors, tk.migrate, `${ctx}: testkit.migrate.cmd`, "cmd");
186
+ }
187
+ }
188
+
181
189
  if (tk.k6 !== undefined && !isObject(tk.k6)) {
182
190
  errors.push(`${ctx}: testkit.k6 must be an object`);
183
191
  }
@@ -201,6 +209,9 @@ function validateService(name, svc, manifestPath) {
201
209
  const prefix = `${ctx}: suites.${type}[${i}]`;
202
210
  requireString(errors, suite, `${prefix}.name`, "name");
203
211
  requireNonEmptyStringArray(errors, suite, `${prefix}.files`, "files");
212
+ if (suite.pre !== undefined && (typeof suite.pre !== "string" || suite.pre.length === 0)) {
213
+ errors.push(`${prefix}.pre must be a non-empty string (shell command)`);
214
+ }
204
215
  }
205
216
  }
206
217
  }
package/lib/runner.mjs CHANGED
@@ -181,6 +181,66 @@ export async function runDalTests(config, files) {
181
181
  return { failed };
182
182
  }
183
183
 
184
+ /**
185
+ * Delete the Neon branch (so neonUp creates a fresh fork from prod).
186
+ */
187
+ async function neonReset(config) {
188
+ const { stateDir, manifest } = config;
189
+ const tk = manifest.testkit;
190
+ console.log("Deleting Neon branch for fresh fork...");
191
+ await runScript("neon-down.sh", {
192
+ NEON_PROJECT_ID: tk.neon.projectId,
193
+ STATE_DIR: stateDir,
194
+ });
195
+ // Clear branch state so neonUp creates a new one
196
+ const branchFile = path.join(stateDir, "neon_branch_id");
197
+ if (fs.existsSync(branchFile)) fs.unlinkSync(branchFile);
198
+ const dbUrlFile = path.join(stateDir, "database_url");
199
+ if (fs.existsSync(dbUrlFile)) fs.unlinkSync(dbUrlFile);
200
+ }
201
+
202
+ /**
203
+ * Run database migrations from local source against the test branch.
204
+ * If migrations fail, nukes the Neon branch and retries on a fresh fork.
205
+ * A second failure means a real migration bug — throws.
206
+ */
207
+ async function migrate(config) {
208
+ const { productDir, stateDir, manifest } = config;
209
+ const migrateCmd = manifest.testkit.migrate?.cmd;
210
+ if (!migrateCmd) return;
211
+
212
+ const dbUrlPath = path.join(stateDir, "database_url");
213
+ const env = { ...process.env };
214
+ if (fs.existsSync(dbUrlPath)) {
215
+ env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
216
+ }
217
+
218
+ const execOpts = { stdio: "inherit", cwd: productDir, env, shell: true };
219
+
220
+ console.log("\n── migrate ──");
221
+ try {
222
+ await execaCommand(migrateCmd, execOpts);
223
+ return;
224
+ } catch (err) {
225
+ console.log("\nMigration failed — resetting Neon branch and retrying...\n");
226
+ }
227
+
228
+ // Nuke and retry: fresh fork from prod, clean goose state
229
+ await neonReset(config);
230
+ await neonUp(config);
231
+
232
+ // Re-read DATABASE_URL from fresh branch
233
+ if (fs.existsSync(dbUrlPath)) {
234
+ env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
235
+ }
236
+
237
+ try {
238
+ await execaCommand(migrateCmd, execOpts);
239
+ } catch (retryErr) {
240
+ throw new Error(`Migration failed on fresh branch — likely a bug in your migration SQL:\n${retryErr.message}`);
241
+ }
242
+ }
243
+
184
244
  /**
185
245
  * Show state directory contents.
186
246
  */
@@ -199,6 +259,22 @@ export function showStatus(config) {
199
259
  }
200
260
  }
201
261
 
262
+ /**
263
+ * Run a suite's `pre` command if defined.
264
+ * Executes in the product directory with DATABASE_URL available.
265
+ */
266
+ async function runSuitePre(config, suite) {
267
+ if (!suite.pre) return;
268
+ const { productDir, stateDir } = config;
269
+ const dbUrlPath = path.join(stateDir, "database_url");
270
+ const env = { ...process.env };
271
+ if (fs.existsSync(dbUrlPath)) {
272
+ env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
273
+ }
274
+ console.log(`\n── pre: ${suite.name} ──`);
275
+ await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
276
+ }
277
+
202
278
  /**
203
279
  * Run a single service: orchestrate HTTP and/or DAL test flows.
204
280
  * Returns true if any tests failed.
@@ -206,25 +282,28 @@ export function showStatus(config) {
206
282
  async function runService(config, suiteType, suiteNames, opts) {
207
283
  const { manifest, stateDir } = config;
208
284
 
209
- // Collect test files from manifest, partitioned by flow
285
+ // Collect suites from manifest, partitioned by flow
210
286
  const types = suiteType === "all"
211
287
  ? Object.keys(manifest.suites)
212
288
  : [suiteType === "int" ? "integration" : suiteType];
213
289
 
214
- let httpFiles = [];
215
- let dalFiles = [];
290
+ let httpSuites = [];
291
+ let dalSuites = [];
216
292
  for (const type of types) {
217
293
  const suites = manifest.suites[type] || [];
218
294
  for (const suite of suites) {
219
295
  if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
220
296
  if (type === "dal") {
221
- dalFiles.push(...suite.files);
297
+ dalSuites.push(suite);
222
298
  } else {
223
- httpFiles.push(...suite.files);
299
+ httpSuites.push(suite);
224
300
  }
225
301
  }
226
302
  }
227
303
 
304
+ const httpFiles = httpSuites.flatMap((s) => s.files);
305
+ const dalFiles = dalSuites.flatMap((s) => s.files);
306
+
228
307
  if (!httpFiles.length && !dalFiles.length) {
229
308
  console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
230
309
  return false;
@@ -235,14 +314,18 @@ async function runService(config, suiteType, suiteNames, opts) {
235
314
  let failed = false;
236
315
  let neonReady = false;
237
316
 
238
- // HTTP flow: build → neon → fly → run → fly down
317
+ // HTTP flow: build → neon → migrate → fly → run → fly down
239
318
  if (httpFiles.length) {
240
319
  requireFlyToken(config.name);
241
320
  if (opts.build) await build(config);
242
321
  await neonUp(config);
322
+ await migrate(config);
243
323
  neonReady = true;
244
324
  await flyUp(config);
245
325
  try {
326
+ for (const suite of httpSuites) {
327
+ await runSuitePre(config, suite);
328
+ }
246
329
  const result = await runTests(config, httpFiles);
247
330
  if (result?.failed) failed = true;
248
331
  } finally {
@@ -250,11 +333,17 @@ async function runService(config, suiteType, suiteNames, opts) {
250
333
  }
251
334
  }
252
335
 
253
- // DAL flow: neon (idempotent) → run DAL tests
336
+ // DAL flow: neon migrate → run suite pre hooks → run DAL tests
254
337
  if (dalFiles.length) {
255
- if (!neonReady) await neonUp(config);
256
- const result = await runDalTests(config, dalFiles);
257
- if (result?.failed) failed = true;
338
+ if (!neonReady) {
339
+ await neonUp(config);
340
+ await migrate(config);
341
+ }
342
+ for (const suite of dalSuites) {
343
+ await runSuitePre(config, suite);
344
+ const result = await runDalTests(config, suite.files);
345
+ if (result?.failed) failed = true;
346
+ }
258
347
  }
259
348
 
260
349
  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.7",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {