@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.
- package/infra/fly-build.sh +3 -2
- package/infra/neon-up.sh +1 -0
- package/lib/config.mjs +11 -0
- package/lib/runner.mjs +100 -10
- package/package.json +1 -1
package/infra/fly-build.sh
CHANGED
|
@@ -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
|
|
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 "$
|
|
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
|
|
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
|
|
214
|
-
let
|
|
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
|
-
|
|
297
|
+
dalSuites.push(suite);
|
|
221
298
|
} else {
|
|
222
|
-
|
|
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
|
|
336
|
+
// DAL flow: neon → migrate → run suite pre hooks → run DAL tests
|
|
253
337
|
if (dalFiles.length) {
|
|
254
|
-
if (!neonReady)
|
|
255
|
-
|
|
256
|
-
|
|
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;
|