@elench/testkit 0.1.5 → 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.
@@ -6,7 +6,8 @@ set -eo pipefail
6
6
  export PATH="$PATH:/snap/bin:$HOME/.fly/bin"
7
7
 
8
8
  STATE_DIR="${STATE_DIR:-.state}"
9
- API_DIR="$(cd "${API_DIR:?API_DIR required — set to the service directory containing the Dockerfile}" && pwd)"
9
+ API_DIR="$(cd "${API_DIR:?API_DIR required — set to the product directory}" && pwd)"
10
+ DOCKERFILE_DIR="$(cd "${DOCKERFILE_DIR:-$API_DIR}" && pwd)"
10
11
  FLY_APP="${FLY_APP:?FLY_APP required}"
11
12
 
12
13
  SHORT_SHA=$(git -C "$API_DIR" rev-parse --short HEAD)
@@ -44,7 +45,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
45
  bash "$SCRIPT_DIR/fly-app-ensure.sh"
45
46
 
46
47
  echo "Building image: $IMAGE"
47
- docker build -t "$IMAGE" -f "$API_DIR/Dockerfile" "$API_DIR"
48
+ docker build -t "$IMAGE" -f "$DOCKERFILE_DIR/Dockerfile" "$API_DIR"
48
49
 
49
50
  echo "Pushing to Fly registry..."
50
51
  fly auth docker
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
@@ -49,6 +49,7 @@ export async function build(config) {
49
49
  FLY_APP: tk.fly.app,
50
50
  FLY_ORG: tk.fly.org,
51
51
  API_DIR: productDir,
52
+ DOCKERFILE_DIR: tk.dockerfile ? path.join(productDir, tk.dockerfile) : productDir,
52
53
  STATE_DIR: stateDir,
53
54
  });
54
55
  }
@@ -180,6 +181,66 @@ export async function runDalTests(config, files) {
180
181
  return { failed };
181
182
  }
182
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
+
183
244
  /**
184
245
  * Show state directory contents.
185
246
  */
@@ -198,6 +259,22 @@ export function showStatus(config) {
198
259
  }
199
260
  }
200
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
+
201
278
  /**
202
279
  * Run a single service: orchestrate HTTP and/or DAL test flows.
203
280
  * Returns true if any tests failed.
@@ -205,25 +282,28 @@ export function showStatus(config) {
205
282
  async function runService(config, suiteType, suiteNames, opts) {
206
283
  const { manifest, stateDir } = config;
207
284
 
208
- // Collect test files from manifest, partitioned by flow
285
+ // Collect suites from manifest, partitioned by flow
209
286
  const types = suiteType === "all"
210
287
  ? Object.keys(manifest.suites)
211
288
  : [suiteType === "int" ? "integration" : suiteType];
212
289
 
213
- let httpFiles = [];
214
- let dalFiles = [];
290
+ let httpSuites = [];
291
+ let dalSuites = [];
215
292
  for (const type of types) {
216
293
  const suites = manifest.suites[type] || [];
217
294
  for (const suite of suites) {
218
295
  if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
219
296
  if (type === "dal") {
220
- dalFiles.push(...suite.files);
297
+ dalSuites.push(suite);
221
298
  } else {
222
- httpFiles.push(...suite.files);
299
+ httpSuites.push(suite);
223
300
  }
224
301
  }
225
302
  }
226
303
 
304
+ const httpFiles = httpSuites.flatMap((s) => s.files);
305
+ const dalFiles = dalSuites.flatMap((s) => s.files);
306
+
227
307
  if (!httpFiles.length && !dalFiles.length) {
228
308
  console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
229
309
  return false;
@@ -234,14 +314,18 @@ async function runService(config, suiteType, suiteNames, opts) {
234
314
  let failed = false;
235
315
  let neonReady = false;
236
316
 
237
- // HTTP flow: build → neon → fly → run → fly down
317
+ // HTTP flow: build → neon → migrate → fly → run → fly down
238
318
  if (httpFiles.length) {
239
319
  requireFlyToken(config.name);
240
320
  if (opts.build) await build(config);
241
321
  await neonUp(config);
322
+ await migrate(config);
242
323
  neonReady = true;
243
324
  await flyUp(config);
244
325
  try {
326
+ for (const suite of httpSuites) {
327
+ await runSuitePre(config, suite);
328
+ }
245
329
  const result = await runTests(config, httpFiles);
246
330
  if (result?.failed) failed = true;
247
331
  } finally {
@@ -249,11 +333,17 @@ async function runService(config, suiteType, suiteNames, opts) {
249
333
  }
250
334
  }
251
335
 
252
- // DAL flow: neon (idempotent) → run DAL tests
336
+ // DAL flow: neon migrate → run suite pre hooks → run DAL tests
253
337
  if (dalFiles.length) {
254
- if (!neonReady) await neonUp(config);
255
- const result = await runDalTests(config, dalFiles);
256
- 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
+ }
257
347
  }
258
348
 
259
349
  return failed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "CLI for running k6 tests against real, ephemeral infrastructure",
5
5
  "type": "module",
6
6
  "bin": {