@elench/testkit 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @elench/testkit
2
2
 
3
- CLI that reads `runner.manifest.json` plus `testkit.config.json` from a product repo, starts the required local services, provisions Neon branches when configured, and runs manifest-defined suites across `k6` and Playwright.
3
+ CLI that reads `runner.manifest.json` plus `testkit.config.json` from a product repo, starts the required local services, provisions the selected database backend, and runs manifest-defined suites across `k6` and Playwright.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -11,6 +11,8 @@ sudo apt-get install -y jq
11
11
 
12
12
  For DAL suites, `@elench/testkit` ships its own `k6` SQL binary. For suites using a Neon branch, set `NEON_API_KEY` in the product `.env`, shell, or `.envrc`.
13
13
 
14
+ Local database mode uses Docker-managed `postgres:16` containers. `testkit` creates and reuses template databases automatically, then clones per-worker databases from those templates for fast reruns.
15
+
14
16
  ## Usage
15
17
 
16
18
  ```bash
@@ -24,6 +26,10 @@ npx @elench/testkit int
24
26
  npx @elench/testkit dal
25
27
  npx @elench/testkit e2e
26
28
 
29
+ # Force a database backend
30
+ npx @elench/testkit --db-backend local
31
+ npx @elench/testkit --db-backend neon
32
+
27
33
  # Filter by framework
28
34
  npx @elench/testkit --framework playwright
29
35
  npx @elench/testkit --framework k6
@@ -49,7 +55,7 @@ npx @elench/testkit destroy
49
55
  1. **Discovery** — reads `runner.manifest.json` for services, suites, files, and frameworks
50
56
  2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
51
57
  Per-service `.env` files declared in config are loaded when present.
52
- 3. **Database** — provisions a Neon branch when a service declares one
58
+ 3. **Database** — provisions the selected backend (`neon` or Docker-managed `local`) when a service declares one
53
59
  4. **Seed** — runs optional product seed commands against the provisioned database
54
60
  5. **Runtime** — starts required local services, waits for readiness, and injects test env
55
61
  6. **Execution** — distributes suites across isolated worker stacks, runs `k6` suites file-by-file, and runs Playwright suites suite-by-suite
@@ -66,6 +72,9 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
66
72
 
67
73
  - `envFile` / `envFiles` for service-specific environment loading
68
74
  - `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
75
+ - `database.defaultBackend` / `database.backends` to switch between Neon and local Postgres
76
+ - `database.template.inputs` to define the local template cache invalidation inputs
77
+ - `migrate.backends` / `seed.backends` for backend-specific command overrides
69
78
 
70
79
  ## Parallel execution
71
80
 
@@ -73,6 +82,7 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
73
82
 
74
83
  Each worker gets its own:
75
84
  - Neon branch
85
+ - or cloned local Postgres database
76
86
  - `.testkit` state subtree
77
87
  - local service ports
78
88
 
package/lib/cli.mjs CHANGED
@@ -16,6 +16,7 @@ export function run() {
16
16
  .option("--jobs <n>", "Number of isolated worker stacks per service", {
17
17
  default: "1",
18
18
  })
19
+ .option("--db-backend <name>", "Database backend override (neon, local)")
19
20
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
20
21
  .option("--framework <name>", "Filter by framework (k6, playwright, all)", {
21
22
  default: "all",
@@ -63,7 +64,7 @@ export function run() {
63
64
  );
64
65
  }
65
66
 
66
- const allConfigs = loadConfigs({ dir: options.dir });
67
+ const allConfigs = loadConfigs({ dir: options.dir, dbBackend: options.dbBackend });
67
68
  const configs = service
68
69
  ? allConfigs.filter((config) => config.name === service)
69
70
  : allConfigs;
package/lib/config.mjs CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
5
5
  const RUNNER_MANIFEST = "runner.manifest.json";
6
6
  const TESTKIT_CONFIG = "testkit.config.json";
7
7
  const VALID_FRAMEWORKS = new Set(["k6", "playwright"]);
8
+ const VALID_DB_PROVIDERS = new Set(["neon", "local"]);
8
9
 
9
10
  /**
10
11
  * Parse a .env file into an object. Supports KEY=VALUE, KEY='VALUE', KEY="VALUE".
@@ -67,8 +68,20 @@ export function loadConfigs(opts = {}) {
67
68
  );
68
69
  }
69
70
 
70
- validateMergedService(name, runnerService, serviceConfig, productDir);
71
+ const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig, opts.dbBackend);
71
72
  const serviceEnv = loadServiceEnv(productDir, serviceConfig);
73
+ const selectedBackend = resolvedDatabase?.selectedBackend;
74
+ const resolvedMigrate = resolveLifecycleConfig(serviceConfig.migrate, selectedBackend);
75
+ const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
76
+ validateMergedService(
77
+ name,
78
+ runnerService,
79
+ serviceConfig,
80
+ resolvedDatabase,
81
+ resolvedMigrate,
82
+ resolvedSeed,
83
+ productDir
84
+ );
72
85
 
73
86
  return {
74
87
  name,
@@ -77,6 +90,10 @@ export function loadConfigs(opts = {}) {
77
90
  suites: runnerService.suites,
78
91
  testkit: {
79
92
  ...serviceConfig,
93
+ database: resolvedDatabase,
94
+ migrate: resolvedMigrate,
95
+ seed: resolvedSeed,
96
+ envFiles: getServiceEnvFiles(serviceConfig),
80
97
  serviceEnv,
81
98
  },
82
99
  };
@@ -279,7 +296,15 @@ export function isSiblingProduct(name) {
279
296
  );
280
297
  }
281
298
 
282
- function validateMergedService(name, runnerService, serviceConfig, productDir) {
299
+ function validateMergedService(
300
+ name,
301
+ runnerService,
302
+ serviceConfig,
303
+ resolvedDatabase,
304
+ resolvedMigrate,
305
+ resolvedSeed,
306
+ productDir
307
+ ) {
283
308
  const usesLocalExecution = Object.values(runnerService.suites).some((suites) =>
284
309
  suites.some(
285
310
  (suite) =>
@@ -302,6 +327,16 @@ function validateMergedService(name, runnerService, serviceConfig, productDir) {
302
327
  }
303
328
  }
304
329
 
330
+ if (
331
+ resolvedDatabase?.provider === "local" &&
332
+ (serviceConfig.migrate || serviceConfig.seed) &&
333
+ resolvedDatabase.template.inputs.length === 0
334
+ ) {
335
+ throw new Error(
336
+ `Service "${name}" uses local database provisioning with migrations or seeds, so database.template.inputs must list the files/directories that define the template cache`
337
+ );
338
+ }
339
+
305
340
  if (serviceConfig.local?.cwd) {
306
341
  const cwdPath = resolveServiceCwd(productDir, serviceConfig.local.cwd);
307
342
  if (!fs.existsSync(cwdPath)) {
@@ -311,20 +346,20 @@ function validateMergedService(name, runnerService, serviceConfig, productDir) {
311
346
  }
312
347
  }
313
348
 
314
- if (serviceConfig.migrate?.cwd) {
315
- const cwdPath = resolveServiceCwd(productDir, serviceConfig.migrate.cwd);
349
+ if (resolvedMigrate?.cwd) {
350
+ const cwdPath = resolveServiceCwd(productDir, resolvedMigrate.cwd);
316
351
  if (!fs.existsSync(cwdPath)) {
317
352
  throw new Error(
318
- `Service "${name}" migrate.cwd does not exist: ${serviceConfig.migrate.cwd}`
353
+ `Service "${name}" migrate.cwd does not exist: ${resolvedMigrate.cwd}`
319
354
  );
320
355
  }
321
356
  }
322
357
 
323
- if (serviceConfig.seed?.cwd) {
324
- const cwdPath = resolveServiceCwd(productDir, serviceConfig.seed.cwd);
358
+ if (resolvedSeed?.cwd) {
359
+ const cwdPath = resolveServiceCwd(productDir, resolvedSeed.cwd);
325
360
  if (!fs.existsSync(cwdPath)) {
326
361
  throw new Error(
327
- `Service "${name}" seed.cwd does not exist: ${serviceConfig.seed.cwd}`
362
+ `Service "${name}" seed.cwd does not exist: ${resolvedSeed.cwd}`
328
363
  );
329
364
  }
330
365
  }
@@ -367,38 +402,24 @@ function validateServiceConfig(name, service, configPath) {
367
402
  if (!isObject(service.database)) {
368
403
  throw new Error(`Service "${name}" database must be an object`);
369
404
  }
370
- const db = service.database;
371
- if (db.provider !== "neon") {
372
- throw new Error(`Service "${name}" database.provider must be "neon"`);
373
- }
374
- requireString(db, "projectId", `Service "${name}" database.projectId`);
375
- requireString(db, "dbName", `Service "${name}" database.dbName`);
376
- if (db.branchName !== undefined && typeof db.branchName !== "string") {
377
- throw new Error(`Service "${name}" database.branchName must be a string`);
378
- }
379
- if (db.reset !== undefined && typeof db.reset !== "boolean") {
380
- throw new Error(`Service "${name}" database.reset must be a boolean`);
405
+
406
+ if (service.database.provider !== undefined) {
407
+ validateDatabaseProviderConfig(name, service.database, `Service "${name}" database`);
408
+ } else if (service.database.backends !== undefined) {
409
+ validateMultiBackendDatabaseConfig(name, service.database);
410
+ } else {
411
+ throw new Error(
412
+ `Service "${name}" database must define either provider or backends`
413
+ );
381
414
  }
382
415
  }
383
416
 
384
417
  if (service.migrate !== undefined) {
385
- if (!isObject(service.migrate)) {
386
- throw new Error(`Service "${name}" migrate must be an object`);
387
- }
388
- requireString(service.migrate, "cmd", `Service "${name}" migrate.cmd`);
389
- if (service.migrate.cwd !== undefined && typeof service.migrate.cwd !== "string") {
390
- throw new Error(`Service "${name}" migrate.cwd must be a string`);
391
- }
418
+ validateLifecycleConfig(name, service.migrate, "migrate");
392
419
  }
393
420
 
394
421
  if (service.seed !== undefined) {
395
- if (!isObject(service.seed)) {
396
- throw new Error(`Service "${name}" seed must be an object`);
397
- }
398
- requireString(service.seed, "cmd", `Service "${name}" seed.cmd`);
399
- if (service.seed.cwd !== undefined && typeof service.seed.cwd !== "string") {
400
- throw new Error(`Service "${name}" seed.cwd must be a string`);
401
- }
422
+ validateLifecycleConfig(name, service.seed, "seed");
402
423
  }
403
424
 
404
425
  if (service.local !== undefined) {
@@ -446,6 +467,189 @@ function getServiceEnvFiles(serviceConfig) {
446
467
  return files;
447
468
  }
448
469
 
470
+ function resolveSelectedDatabase(name, serviceConfig, requestedBackend) {
471
+ if (!serviceConfig.database) return undefined;
472
+
473
+ const database = serviceConfig.database;
474
+ if (database.provider !== undefined) {
475
+ if (requestedBackend && requestedBackend !== database.provider) {
476
+ throw new Error(
477
+ `Service "${name}" does not define database backend "${requestedBackend}". Available: ${database.provider}`
478
+ );
479
+ }
480
+
481
+ return {
482
+ ...database,
483
+ provider: database.provider,
484
+ selectedBackend: database.provider,
485
+ reset: database.reset !== false,
486
+ template: normalizeTemplateConfig(database.template),
487
+ };
488
+ }
489
+
490
+ const backends = database.backends || {};
491
+ const selectedBackend =
492
+ requestedBackend ||
493
+ process.env.TESTKIT_DB_BACKEND ||
494
+ database.defaultBackend ||
495
+ Object.keys(backends)[0];
496
+
497
+ if (!selectedBackend || !backends[selectedBackend]) {
498
+ throw new Error(
499
+ `Service "${name}" does not define database backend "${selectedBackend || "undefined"}"`
500
+ );
501
+ }
502
+
503
+ const backend = backends[selectedBackend];
504
+ return {
505
+ ...backend,
506
+ provider: backend.provider,
507
+ selectedBackend,
508
+ reset: database.reset !== false,
509
+ template: normalizeTemplateConfig(database.template),
510
+ };
511
+ }
512
+
513
+ function validateMultiBackendDatabaseConfig(name, database) {
514
+ if (
515
+ database.defaultBackend !== undefined &&
516
+ (typeof database.defaultBackend !== "string" || !database.defaultBackend.length)
517
+ ) {
518
+ throw new Error(`Service "${name}" database.defaultBackend must be a non-empty string`);
519
+ }
520
+ if (database.reset !== undefined && typeof database.reset !== "boolean") {
521
+ throw new Error(`Service "${name}" database.reset must be a boolean`);
522
+ }
523
+ if (database.template !== undefined) {
524
+ validateTemplateConfig(name, database.template, `Service "${name}" database.template`);
525
+ }
526
+ if (!isObject(database.backends) || Object.keys(database.backends).length === 0) {
527
+ throw new Error(`Service "${name}" database.backends must be a non-empty object`);
528
+ }
529
+
530
+ for (const [backendName, backendConfig] of Object.entries(database.backends)) {
531
+ if (!isObject(backendConfig)) {
532
+ throw new Error(
533
+ `Service "${name}" database.backends.${backendName} must be an object`
534
+ );
535
+ }
536
+ validateDatabaseProviderConfig(
537
+ name,
538
+ backendConfig,
539
+ `Service "${name}" database.backends.${backendName}`
540
+ );
541
+ }
542
+
543
+ if (database.defaultBackend && !database.backends[database.defaultBackend]) {
544
+ throw new Error(
545
+ `Service "${name}" database.defaultBackend "${database.defaultBackend}" is missing from database.backends`
546
+ );
547
+ }
548
+ }
549
+
550
+ function validateDatabaseProviderConfig(name, db, label) {
551
+ if (!VALID_DB_PROVIDERS.has(db.provider)) {
552
+ throw new Error(
553
+ `${label}.provider must be one of: ${[...VALID_DB_PROVIDERS].join(", ")}`
554
+ );
555
+ }
556
+
557
+ if (db.reset !== undefined && typeof db.reset !== "boolean") {
558
+ throw new Error(`${label}.reset must be a boolean`);
559
+ }
560
+ if (db.template !== undefined) {
561
+ validateTemplateConfig(name, db.template, `${label}.template`);
562
+ }
563
+
564
+ if (db.provider === "neon") {
565
+ requireString(db, "projectId", `${label}.projectId`);
566
+ requireString(db, "dbName", `${label}.dbName`);
567
+ if (db.branchName !== undefined && typeof db.branchName !== "string") {
568
+ throw new Error(`${label}.branchName must be a string`);
569
+ }
570
+ return;
571
+ }
572
+
573
+ if (db.image !== undefined && typeof db.image !== "string") {
574
+ throw new Error(`${label}.image must be a string`);
575
+ }
576
+ if (db.user !== undefined && typeof db.user !== "string") {
577
+ throw new Error(`${label}.user must be a string`);
578
+ }
579
+ if (db.password !== undefined && typeof db.password !== "string") {
580
+ throw new Error(`${label}.password must be a string`);
581
+ }
582
+ }
583
+
584
+ function validateTemplateConfig(name, template, label) {
585
+ if (!isObject(template)) {
586
+ throw new Error(`${label} must be an object`);
587
+ }
588
+ if (
589
+ template.inputs !== undefined &&
590
+ (!Array.isArray(template.inputs) || template.inputs.some((value) => typeof value !== "string"))
591
+ ) {
592
+ throw new Error(`${label}.inputs must be an array of strings`);
593
+ }
594
+ }
595
+
596
+ function normalizeTemplateConfig(template) {
597
+ return {
598
+ inputs: Array.isArray(template?.inputs) ? [...template.inputs] : [],
599
+ };
600
+ }
601
+
602
+ function validateLifecycleConfig(name, value, label) {
603
+ if (!isObject(value)) {
604
+ throw new Error(`Service "${name}" ${label} must be an object`);
605
+ }
606
+
607
+ if (value.cmd !== undefined) {
608
+ requireString(value, "cmd", `Service "${name}" ${label}.cmd`);
609
+ }
610
+ if (value.cwd !== undefined && typeof value.cwd !== "string") {
611
+ throw new Error(`Service "${name}" ${label}.cwd must be a string`);
612
+ }
613
+
614
+ if (value.backends !== undefined) {
615
+ if (!isObject(value.backends)) {
616
+ throw new Error(`Service "${name}" ${label}.backends must be an object`);
617
+ }
618
+ for (const [backendName, override] of Object.entries(value.backends)) {
619
+ if (!isObject(override)) {
620
+ throw new Error(
621
+ `Service "${name}" ${label}.backends.${backendName} must be an object`
622
+ );
623
+ }
624
+ if (override.cmd !== undefined) {
625
+ requireString(override, "cmd", `Service "${name}" ${label}.backends.${backendName}.cmd`);
626
+ }
627
+ if (override.cwd !== undefined && typeof override.cwd !== "string") {
628
+ throw new Error(
629
+ `Service "${name}" ${label}.backends.${backendName}.cwd must be a string`
630
+ );
631
+ }
632
+ }
633
+ }
634
+
635
+ if (value.cmd === undefined && value.backends === undefined) {
636
+ throw new Error(`Service "${name}" ${label} must define cmd or backends`);
637
+ }
638
+ }
639
+
640
+ function resolveLifecycleConfig(value, selectedBackend) {
641
+ if (!value) return value;
642
+
643
+ const override =
644
+ selectedBackend && isObject(value.backends) ? value.backends[selectedBackend] || {} : {};
645
+ const resolved = {
646
+ ...value,
647
+ ...override,
648
+ };
649
+ delete resolved.backends;
650
+ return resolved;
651
+ }
652
+
449
653
  function requireString(obj, key, label) {
450
654
  if (typeof obj[key] !== "string" || obj[key].length === 0) {
451
655
  throw new Error(`${label} must be a non-empty string`);
@@ -0,0 +1,656 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { execa } from "execa";
5
+ import { requireNeonApiKey, resolveServiceCwd } from "./config.mjs";
6
+ import { runScript } from "./exec.mjs";
7
+
8
+ const LOCAL_IMAGE = "postgres:16";
9
+ const LOCAL_USER = "testkit";
10
+ const LOCAL_PASSWORD = "testkit";
11
+ const LOCAL_ADMIN_DB = "postgres";
12
+ const LOCAL_READY_TIMEOUT_MS = 60_000;
13
+ const LOCAL_POLL_INTERVAL_MS = 1_000;
14
+
15
+ export async function prepareDatabaseRuntime(config, hooks = {}) {
16
+ const db = config.testkit.database;
17
+ if (!db) return;
18
+
19
+ fs.mkdirSync(config.stateDir, { recursive: true });
20
+ if (db.provider === "neon") {
21
+ await prepareNeonDatabase(config, hooks);
22
+ return;
23
+ }
24
+ if (db.provider === "local") {
25
+ await prepareLocalDatabase(config, hooks);
26
+ return;
27
+ }
28
+
29
+ throw new Error(`Unsupported database provider "${db.provider}"`);
30
+ }
31
+
32
+ export async function destroyRuntimeDatabase({ productDir, stateDir }) {
33
+ const backend = readStateValue(path.join(stateDir, "database_backend"));
34
+ if (!backend || backend === "neon") {
35
+ await destroyNeonDatabase(stateDir);
36
+ return;
37
+ }
38
+
39
+ if (backend === "local") {
40
+ await destroyLocalRuntimeDatabase(productDir, stateDir);
41
+ return;
42
+ }
43
+ }
44
+
45
+ export async function destroyServiceDatabaseCache(productDir, serviceName) {
46
+ const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
47
+ if (!fs.existsSync(cacheDir)) return;
48
+
49
+ const backend = readStateValue(path.join(cacheDir, "database_backend"));
50
+ if (backend !== "local") {
51
+ fs.rmSync(cacheDir, { recursive: true, force: true });
52
+ return;
53
+ }
54
+
55
+ const lockDir = getLocalLocksDir(productDir);
56
+ fs.mkdirSync(lockDir, { recursive: true });
57
+ await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
58
+ const infra = await loadExistingLocalContainer(productDir);
59
+ if (!infra) {
60
+ fs.rmSync(cacheDir, { recursive: true, force: true });
61
+ return;
62
+ }
63
+ const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
64
+ if (templateDbName) {
65
+ await dropDatabaseIfExists(infra, templateDbName);
66
+ }
67
+ fs.rmSync(cacheDir, { recursive: true, force: true });
68
+ });
69
+ }
70
+
71
+ export async function cleanupOrphanedLocalInfrastructure(productDir) {
72
+ const infraDir = getLocalInfraDir(productDir);
73
+ if (!fs.existsSync(infraDir)) return;
74
+
75
+ if (hasRemainingLocalArtifacts(productDir)) return;
76
+
77
+ const containerName = readStateValue(path.join(infraDir, "container_name"));
78
+ if (containerName) {
79
+ await stopAndRemoveContainer(containerName);
80
+ }
81
+ fs.rmSync(infraDir, { recursive: true, force: true });
82
+ }
83
+
84
+ export function isDatabaseStateDir(dir) {
85
+ if (!fs.existsSync(dir)) return false;
86
+ return (
87
+ fs.existsSync(path.join(dir, "database_backend")) ||
88
+ fs.existsSync(path.join(dir, "neon_branch_id")) ||
89
+ fs.existsSync(path.join(dir, "neon_project_id"))
90
+ );
91
+ }
92
+
93
+ export function showServiceDatabaseStatus(productDir, serviceName) {
94
+ const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
95
+ const infraDir = getLocalInfraDir(productDir);
96
+ if (!fs.existsSync(cacheDir) && !fs.existsSync(infraDir)) return false;
97
+
98
+ if (fs.existsSync(cacheDir)) {
99
+ console.log(" database-cache/");
100
+ printStateDir(cacheDir, " ");
101
+ }
102
+
103
+ if (fs.existsSync(infraDir)) {
104
+ console.log(" database-infra/");
105
+ printStateDir(infraDir, " ");
106
+ }
107
+
108
+ return true;
109
+ }
110
+
111
+ async function prepareNeonDatabase(config, hooks) {
112
+ const db = config.testkit.database;
113
+ requireNeonApiKey();
114
+
115
+ removeLocalRuntimeState(config.stateDir);
116
+
117
+ await runScript("neon-up.sh", {
118
+ NEON_PROJECT_ID: db.projectId,
119
+ NEON_DB_NAME: db.dbName,
120
+ NEON_BRANCH_NAME: db.branchName,
121
+ NEON_RESET: db.reset === false ? "false" : "true",
122
+ STATE_DIR: config.stateDir,
123
+ });
124
+
125
+ fs.writeFileSync(path.join(config.stateDir, "database_backend"), "neon");
126
+ fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
127
+
128
+ const databaseUrl = readStateValue(path.join(config.stateDir, "database_url"));
129
+ if (hooks.runMigrate) {
130
+ await hooks.runMigrate(databaseUrl);
131
+ }
132
+ if (hooks.runSeed) {
133
+ await hooks.runSeed(databaseUrl);
134
+ }
135
+ }
136
+
137
+ async function destroyNeonDatabase(stateDir) {
138
+ const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
139
+ if (!projectId) return;
140
+
141
+ await runScript("neon-down.sh", {
142
+ NEON_PROJECT_ID: projectId,
143
+ STATE_DIR: stateDir,
144
+ });
145
+ }
146
+
147
+ async function prepareLocalDatabase(config, hooks) {
148
+ const db = config.testkit.database;
149
+ const productDir = config.productDir;
150
+ const serviceName = config.name;
151
+ const lockDir = getLocalLocksDir(productDir);
152
+ const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
153
+ fs.mkdirSync(lockDir, { recursive: true });
154
+ fs.mkdirSync(cacheDir, { recursive: true });
155
+
156
+ const templateFingerprint = await computeTemplateFingerprint(config);
157
+ const infra = await withLock(path.join(lockDir, "container.lock"), () =>
158
+ ensureLocalContainer(productDir, db)
159
+ );
160
+
161
+ await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
162
+ await ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks);
163
+ });
164
+
165
+ await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(config.stateDir, 10)}.lock`), async () => {
166
+ await ensureWorkerClone(config, infra, cacheDir, templateFingerprint);
167
+ });
168
+ }
169
+
170
+ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, hooks) {
171
+ const serviceName = config.name;
172
+ const existingFingerprint = readStateValue(path.join(cacheDir, "template_fingerprint"));
173
+ const existingDbName = readStateValue(path.join(cacheDir, "template_database_name"));
174
+ const desiredDbName = buildTemplateDatabaseName(serviceName, templateFingerprint);
175
+
176
+ if (
177
+ existingFingerprint === templateFingerprint &&
178
+ existingDbName &&
179
+ (await databaseExists(infra, existingDbName))
180
+ ) {
181
+ writeLocalCacheState(cacheDir, infra, existingDbName, templateFingerprint);
182
+ return;
183
+ }
184
+
185
+ if (existingDbName && existingDbName !== desiredDbName) {
186
+ await dropDatabaseIfExists(infra, existingDbName);
187
+ }
188
+ if (await databaseExists(infra, desiredDbName)) {
189
+ await dropDatabaseIfExists(infra, desiredDbName);
190
+ }
191
+
192
+ await createEmptyDatabase(infra, desiredDbName);
193
+ const templateUrl = buildDatabaseUrl(infra, desiredDbName);
194
+ if (hooks.runMigrate) {
195
+ await hooks.runMigrate(templateUrl);
196
+ }
197
+ if (hooks.runSeed) {
198
+ await hooks.runSeed(templateUrl);
199
+ }
200
+
201
+ writeLocalCacheState(cacheDir, infra, desiredDbName, templateFingerprint);
202
+ }
203
+
204
+ async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
205
+ const serviceName = config.name;
206
+ const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
207
+ if (!templateDbName) {
208
+ throw new Error(`Missing template database for service "${serviceName}"`);
209
+ }
210
+
211
+ const desiredDbName = buildWorkerDatabaseName(serviceName, config.stateDir, templateFingerprint);
212
+ const existingDbName = readStateValue(path.join(config.stateDir, "local_database_name"));
213
+ const existingFingerprint = readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
214
+ const needsReset =
215
+ config.testkit.database.reset !== false ||
216
+ existingFingerprint !== templateFingerprint ||
217
+ existingDbName !== desiredDbName ||
218
+ !(await databaseExists(infra, desiredDbName));
219
+
220
+ if (existingDbName && existingDbName !== desiredDbName) {
221
+ await dropDatabaseIfExists(infra, existingDbName);
222
+ }
223
+
224
+ if (needsReset) {
225
+ await dropDatabaseIfExists(infra, desiredDbName);
226
+ await cloneDatabaseFromTemplate(infra, desiredDbName, templateDbName);
227
+ }
228
+
229
+ fs.mkdirSync(config.stateDir, { recursive: true });
230
+ removeNeonRuntimeState(config.stateDir);
231
+ fs.writeFileSync(path.join(config.stateDir, "database_backend"), "local");
232
+ fs.writeFileSync(path.join(config.stateDir, "database_url"), buildDatabaseUrl(infra, desiredDbName));
233
+ fs.writeFileSync(path.join(config.stateDir, "local_database_name"), desiredDbName);
234
+ fs.writeFileSync(path.join(config.stateDir, "local_template_fingerprint"), templateFingerprint);
235
+ fs.writeFileSync(path.join(config.stateDir, "local_template_database_name"), templateDbName);
236
+ fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
237
+ }
238
+
239
+ async function destroyLocalRuntimeDatabase(productDir, stateDir) {
240
+ const dbName = readStateValue(path.join(stateDir, "local_database_name"));
241
+ if (!dbName) return;
242
+
243
+ const infra = await loadExistingLocalContainer(productDir);
244
+ if (!infra) return;
245
+ await dropDatabaseIfExists(infra, dbName);
246
+ }
247
+
248
+ async function ensureLocalContainer(productDir, database = {}) {
249
+ const infraDir = getLocalInfraDir(productDir);
250
+ fs.mkdirSync(infraDir, { recursive: true });
251
+
252
+ const containerName =
253
+ readStateValue(path.join(infraDir, "container_name")) || buildContainerName(productDir);
254
+ const image = database.image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE;
255
+ const user = database.user || readStateValue(path.join(infraDir, "user")) || LOCAL_USER;
256
+ const password =
257
+ database.password || readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD;
258
+
259
+ let inspect = await inspectContainer(containerName);
260
+ if (inspect && inspect.Config?.Image !== image) {
261
+ await stopAndRemoveContainer(containerName);
262
+ inspect = null;
263
+ }
264
+
265
+ if (!inspect) {
266
+ await execa("docker", [
267
+ "run",
268
+ "-d",
269
+ "--name",
270
+ containerName,
271
+ "-e",
272
+ `POSTGRES_USER=${user}`,
273
+ "-e",
274
+ `POSTGRES_PASSWORD=${password}`,
275
+ "-e",
276
+ `POSTGRES_DB=${LOCAL_ADMIN_DB}`,
277
+ "-p",
278
+ "127.0.0.1::5432",
279
+ image,
280
+ ]);
281
+ inspect = await inspectContainer(containerName);
282
+ } else if (!inspect.State?.Running) {
283
+ await execa("docker", ["start", containerName]);
284
+ inspect = await inspectContainer(containerName);
285
+ }
286
+
287
+ const hostPort = inspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
288
+ if (!hostPort) {
289
+ throw new Error(`Could not determine published port for local database container ${containerName}`);
290
+ }
291
+
292
+ const infra = {
293
+ containerName,
294
+ containerId: inspect.Id,
295
+ image,
296
+ user,
297
+ password,
298
+ host: "127.0.0.1",
299
+ port: Number(hostPort),
300
+ };
301
+
302
+ await waitForLocalContainerReady(infra);
303
+ writeLocalInfraState(infraDir, infra);
304
+ return infra;
305
+ }
306
+
307
+ async function loadExistingLocalContainer(productDir) {
308
+ const infraDir = getLocalInfraDir(productDir);
309
+ const containerName = readStateValue(path.join(infraDir, "container_name"));
310
+ if (!containerName) return null;
311
+
312
+ const inspect = await inspectContainer(containerName);
313
+ if (!inspect) return null;
314
+
315
+ if (!inspect.State?.Running) {
316
+ await execa("docker", ["start", containerName]);
317
+ }
318
+
319
+ const nextInspect = await inspectContainer(containerName);
320
+ const hostPort = nextInspect?.NetworkSettings?.Ports?.["5432/tcp"]?.[0]?.HostPort;
321
+ if (!hostPort) return null;
322
+
323
+ const infra = {
324
+ containerName,
325
+ containerId: nextInspect.Id,
326
+ image: nextInspect.Config?.Image || readStateValue(path.join(infraDir, "image")) || LOCAL_IMAGE,
327
+ user: readStateValue(path.join(infraDir, "user")) || LOCAL_USER,
328
+ password: readStateValue(path.join(infraDir, "password")) || LOCAL_PASSWORD,
329
+ host: "127.0.0.1",
330
+ port: Number(hostPort),
331
+ };
332
+ await waitForLocalContainerReady(infra);
333
+ return infra;
334
+ }
335
+
336
+ async function waitForLocalContainerReady(infra) {
337
+ const startedAt = Date.now();
338
+ while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
339
+ try {
340
+ await execa("docker", [
341
+ "exec",
342
+ infra.containerName,
343
+ "pg_isready",
344
+ "-U",
345
+ infra.user,
346
+ "-d",
347
+ LOCAL_ADMIN_DB,
348
+ ]);
349
+ return;
350
+ } catch {
351
+ await sleep(LOCAL_POLL_INTERVAL_MS);
352
+ }
353
+ }
354
+
355
+ throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
356
+ }
357
+
358
+ async function inspectContainer(containerName) {
359
+ try {
360
+ const { stdout } = await execa("docker", ["inspect", containerName]);
361
+ const parsed = JSON.parse(stdout);
362
+ return parsed[0] || null;
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ async function stopAndRemoveContainer(containerName) {
369
+ try {
370
+ await execa("docker", ["rm", "-f", containerName]);
371
+ } catch {
372
+ // Already gone.
373
+ }
374
+ }
375
+
376
+ async function databaseExists(infra, dbName) {
377
+ const result = await runAdminQuery(infra, [
378
+ "-tAc",
379
+ `SELECT 1 FROM pg_database WHERE datname = '${escapeSqlLiteral(dbName)}'`,
380
+ ]);
381
+ return result.trim() === "1";
382
+ }
383
+
384
+ async function createEmptyDatabase(infra, dbName) {
385
+ await runAdminQuery(infra, ["-c", `CREATE DATABASE "${escapeIdentifier(dbName)}"`]);
386
+ }
387
+
388
+ async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
389
+ await runAdminQuery(infra, [
390
+ "-c",
391
+ `CREATE DATABASE "${escapeIdentifier(dbName)}" TEMPLATE "${escapeIdentifier(templateDbName)}"`,
392
+ ]);
393
+ }
394
+
395
+ async function dropDatabaseIfExists(infra, dbName) {
396
+ await runAdminQuery(infra, [
397
+ "-c",
398
+ [
399
+ `SELECT pg_terminate_backend(pid)`,
400
+ `FROM pg_stat_activity`,
401
+ `WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
402
+ ].join(" "),
403
+ ]);
404
+ await runAdminQuery(infra, [
405
+ "-c",
406
+ `DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
407
+ ]);
408
+ }
409
+
410
+ async function runAdminQuery(infra, args) {
411
+ const commandArgs = [
412
+ "exec",
413
+ "-e",
414
+ `PGPASSWORD=${infra.password}`,
415
+ infra.containerName,
416
+ "psql",
417
+ "-v",
418
+ "ON_ERROR_STOP=1",
419
+ "-U",
420
+ infra.user,
421
+ "-d",
422
+ LOCAL_ADMIN_DB,
423
+ ...args,
424
+ ];
425
+ const { stdout } = await execa("docker", commandArgs);
426
+ return stdout;
427
+ }
428
+
429
+ async function computeTemplateFingerprint(config) {
430
+ const hash = crypto.createHash("sha256");
431
+ const db = config.testkit.database;
432
+ hash.update(JSON.stringify({
433
+ provider: db.provider,
434
+ selectedBackend: db.selectedBackend,
435
+ image: db.image || LOCAL_IMAGE,
436
+ user: db.user || LOCAL_USER,
437
+ migrate: config.testkit.migrate || null,
438
+ seed: config.testkit.seed || null,
439
+ }));
440
+
441
+ const rootEnvPath = path.join(config.productDir, ".env");
442
+ appendFileToHash(hash, config.productDir, rootEnvPath);
443
+ for (const envFile of config.testkit.envFiles || []) {
444
+ appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
445
+ }
446
+ for (const input of db.template.inputs || []) {
447
+ appendInputToHash(hash, config.productDir, input);
448
+ }
449
+
450
+ return hash.digest("hex");
451
+ }
452
+
453
+ function appendInputToHash(hash, productDir, input) {
454
+ const absPath = resolveServiceCwd(productDir, input);
455
+ if (!fs.existsSync(absPath)) {
456
+ hash.update(`missing:${path.relative(productDir, absPath)}`);
457
+ return;
458
+ }
459
+
460
+ const stat = fs.statSync(absPath);
461
+ if (stat.isDirectory()) {
462
+ hash.update(`dir:${path.relative(productDir, absPath)}`);
463
+ for (const entry of fs.readdirSync(absPath).sort()) {
464
+ if (entry === ".git" || entry === "node_modules" || entry === ".testkit") continue;
465
+ appendInputToHash(hash, productDir, path.join(input, entry));
466
+ }
467
+ return;
468
+ }
469
+
470
+ appendFileToHash(hash, productDir, absPath);
471
+ }
472
+
473
+ function appendFileToHash(hash, productDir, absPath) {
474
+ if (!fs.existsSync(absPath)) {
475
+ hash.update(`missing:${path.relative(productDir, absPath)}`);
476
+ return;
477
+ }
478
+ const stat = fs.statSync(absPath);
479
+ if (!stat.isFile()) return;
480
+
481
+ hash.update(`file:${path.relative(productDir, absPath)}:${stat.size}:${stat.mtimeMs}`);
482
+ hash.update(fs.readFileSync(absPath));
483
+ }
484
+
485
+ function buildDatabaseUrl(infra, dbName) {
486
+ return `postgresql://${encodeURIComponent(infra.user)}:${encodeURIComponent(infra.password)}@${infra.host}:${infra.port}/${dbName}?sslmode=disable`;
487
+ }
488
+
489
+ function buildContainerName(productDir) {
490
+ return limitIdentifier(
491
+ `testkit_pg_${slugSegment(path.basename(productDir))}_${hashString(productDir, 10)}`,
492
+ 63
493
+ );
494
+ }
495
+
496
+ function buildTemplateDatabaseName(serviceName, fingerprint) {
497
+ return limitIdentifier(
498
+ `tk_tpl_${slugSegment(serviceName)}_${fingerprint.slice(0, 16)}`,
499
+ 63
500
+ );
501
+ }
502
+
503
+ function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
504
+ return limitIdentifier(
505
+ `tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
506
+ 63
507
+ );
508
+ }
509
+
510
+ function writeLocalInfraState(infraDir, infra) {
511
+ fs.writeFileSync(path.join(infraDir, "database_backend"), "local");
512
+ fs.writeFileSync(path.join(infraDir, "container_name"), infra.containerName);
513
+ fs.writeFileSync(path.join(infraDir, "container_id"), infra.containerId);
514
+ fs.writeFileSync(path.join(infraDir, "image"), infra.image);
515
+ fs.writeFileSync(path.join(infraDir, "user"), infra.user);
516
+ fs.writeFileSync(path.join(infraDir, "password"), infra.password);
517
+ fs.writeFileSync(path.join(infraDir, "host"), infra.host);
518
+ fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
519
+ }
520
+
521
+ function writeLocalCacheState(cacheDir, infra, templateDbName, fingerprint) {
522
+ fs.writeFileSync(path.join(cacheDir, "database_backend"), "local");
523
+ fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
524
+ fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
525
+ fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
526
+ }
527
+
528
+ function getLocalInfraDir(productDir) {
529
+ return path.join(productDir, ".testkit", "_infra", "local-postgres");
530
+ }
531
+
532
+ function getLocalLocksDir(productDir) {
533
+ return path.join(getLocalInfraDir(productDir), "locks");
534
+ }
535
+
536
+ function getLocalServiceCacheDir(productDir, serviceName) {
537
+ return path.join(productDir, ".testkit", "_dbcache", serviceName);
538
+ }
539
+
540
+ function hasRemainingLocalArtifacts(productDir) {
541
+ const root = path.join(productDir, ".testkit");
542
+ if (!fs.existsSync(root)) return false;
543
+
544
+ let found = false;
545
+ visitDirs(root, (dir) => {
546
+ if (found) return;
547
+ if (dir.includes(`${path.sep}.testkit${path.sep}_infra`)) return;
548
+ if (readStateValue(path.join(dir, "database_backend")) === "local") {
549
+ found = true;
550
+ }
551
+ });
552
+
553
+ if (found) return true;
554
+
555
+ const cacheRoot = path.join(productDir, ".testkit", "_dbcache");
556
+ if (!fs.existsSync(cacheRoot)) return false;
557
+ return fs.readdirSync(cacheRoot).some((entry) => {
558
+ const entryPath = path.join(cacheRoot, entry);
559
+ return fs.statSync(entryPath).isDirectory();
560
+ });
561
+ }
562
+
563
+ function visitDirs(root, visitor) {
564
+ if (!fs.existsSync(root)) return;
565
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
566
+ if (!entry.isDirectory()) continue;
567
+ const dir = path.join(root, entry.name);
568
+ visitor(dir);
569
+ visitDirs(dir, visitor);
570
+ }
571
+ }
572
+
573
+ function printStateDir(dir, indent) {
574
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
575
+ const filePath = path.join(dir, entry.name);
576
+ if (entry.isDirectory()) {
577
+ console.log(`${indent}${entry.name}/`);
578
+ printStateDir(filePath, `${indent} `);
579
+ continue;
580
+ }
581
+ const value =
582
+ entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
583
+ console.log(`${indent}${entry.name}: ${value}`);
584
+ }
585
+ }
586
+
587
+ async function withLock(lockPath, fn) {
588
+ const lockDir = `${lockPath}.dir`;
589
+ const timeoutMs = 60_000;
590
+ const startedAt = Date.now();
591
+
592
+ while (true) {
593
+ try {
594
+ fs.mkdirSync(lockDir, { recursive: false });
595
+ break;
596
+ } catch (error) {
597
+ if (error.code !== "EEXIST") throw error;
598
+ if (Date.now() - startedAt > timeoutMs) {
599
+ throw new Error(`Timed out waiting for lock ${path.basename(lockPath)}`);
600
+ }
601
+ await sleep(200);
602
+ }
603
+ }
604
+
605
+ try {
606
+ return await fn();
607
+ } finally {
608
+ fs.rmSync(lockDir, { recursive: true, force: true });
609
+ }
610
+ }
611
+
612
+ function hashString(value, length = 12) {
613
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
614
+ }
615
+
616
+ function slugSegment(value) {
617
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "db";
618
+ }
619
+
620
+ function limitIdentifier(value, maxLength) {
621
+ return value.length <= maxLength ? value : value.slice(0, maxLength);
622
+ }
623
+
624
+ function escapeIdentifier(value) {
625
+ return String(value).replace(/"/g, "\"\"");
626
+ }
627
+
628
+ function escapeSqlLiteral(value) {
629
+ return String(value).replace(/'/g, "''");
630
+ }
631
+
632
+ function readStateValue(filePath) {
633
+ if (!fs.existsSync(filePath)) return null;
634
+ return fs.readFileSync(filePath, "utf8").trim();
635
+ }
636
+
637
+ function sleep(ms) {
638
+ return new Promise((resolve) => setTimeout(resolve, ms));
639
+ }
640
+
641
+ function removeLocalRuntimeState(stateDir) {
642
+ for (const file of [
643
+ "local_database_name",
644
+ "local_template_fingerprint",
645
+ "local_template_database_name",
646
+ "local_container_name",
647
+ ]) {
648
+ fs.rmSync(path.join(stateDir, file), { force: true });
649
+ }
650
+ }
651
+
652
+ function removeNeonRuntimeState(stateDir) {
653
+ for (const file of ["neon_project_id", "neon_branch_id"]) {
654
+ fs.rmSync(path.join(stateDir, file), { force: true });
655
+ }
656
+ }
package/lib/runner.mjs CHANGED
@@ -3,12 +3,15 @@ import path from "path";
3
3
  import { spawn } from "child_process";
4
4
  import net from "net";
5
5
  import { execa, execaCommand } from "execa";
6
- import { runScript } from "./exec.mjs";
6
+ import { resolveDalBinary, resolveServiceCwd } from "./config.mjs";
7
7
  import {
8
- requireNeonApiKey,
9
- resolveDalBinary,
10
- resolveServiceCwd,
11
- } from "./config.mjs";
8
+ cleanupOrphanedLocalInfrastructure,
9
+ destroyRuntimeDatabase,
10
+ destroyServiceDatabaseCache,
11
+ isDatabaseStateDir,
12
+ prepareDatabaseRuntime,
13
+ showServiceDatabaseStatus,
14
+ } from "./database.mjs";
12
15
 
13
16
  const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
14
17
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
@@ -38,25 +41,24 @@ export async function destroy(config) {
38
41
 
39
42
  const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
40
43
  for (const stateDir of runtimeStateDirs) {
41
- const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
42
- if (!projectId) continue;
43
-
44
- await runScript("neon-down.sh", {
45
- NEON_PROJECT_ID: projectId,
46
- STATE_DIR: stateDir,
44
+ await destroyRuntimeDatabase({
45
+ productDir: config.productDir,
46
+ stateDir,
47
47
  });
48
48
  }
49
49
 
50
+ await destroyServiceDatabaseCache(config.productDir, config.name);
50
51
  fs.rmSync(config.stateDir, { recursive: true, force: true });
52
+ await cleanupOrphanedLocalInfrastructure(config.productDir);
51
53
  }
52
54
 
53
55
  export function showStatus(config) {
54
56
  if (!fs.existsSync(config.stateDir)) {
55
57
  console.log("No state — run tests first.");
56
- return;
58
+ } else {
59
+ printStateDir(config.stateDir, " ");
57
60
  }
58
-
59
- printStateDir(config.stateDir, " ");
61
+ showServiceDatabaseStatus(config.productDir, config.name);
60
62
  }
61
63
 
62
64
  async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
@@ -387,9 +389,12 @@ function resolveWorkerConfig(
387
389
  ? {
388
390
  ...config.testkit.database,
389
391
  branchName:
392
+ config.testkit.database.provider === "neon" &&
390
393
  config.testkit.database.branchName !== undefined
391
394
  ? finalizeString(config.testkit.database.branchName, context)
392
- : `${targetConfig.name}-${config.name}-w${workerId}-testkit`,
395
+ : config.testkit.database.provider === "neon"
396
+ ? `${targetConfig.name}-${config.name}-w${workerId}-testkit`
397
+ : undefined,
393
398
  }
394
399
  : undefined;
395
400
 
@@ -464,8 +469,6 @@ async function runWorkerPlan(plan) {
464
469
 
465
470
  try {
466
471
  await prepareDatabases(plan.runtimeConfigs);
467
- await runMigrations(plan.runtimeConfigs);
468
- await runSeeds(plan.runtimeConfigs);
469
472
 
470
473
  if (needsLocalRuntime(plan.suites)) {
471
474
  startedServices = await startLocalServices(plan.runtimeConfigs);
@@ -501,60 +504,45 @@ async function runWorkerPlan(plan) {
501
504
 
502
505
  async function prepareDatabases(runtimeConfigs) {
503
506
  for (const config of runtimeConfigs) {
504
- const db = config.testkit.database;
505
- if (!db) continue;
506
-
507
- requireNeonApiKey();
508
- fs.mkdirSync(config.stateDir, { recursive: true });
509
-
510
- await runScript("neon-up.sh", {
511
- NEON_PROJECT_ID: db.projectId,
512
- NEON_DB_NAME: db.dbName,
513
- NEON_BRANCH_NAME: db.branchName,
514
- NEON_RESET: db.reset === false ? "false" : "true",
515
- STATE_DIR: config.stateDir,
507
+ await prepareDatabaseRuntime(config, {
508
+ runMigrate: config.testkit.migrate
509
+ ? (databaseUrl) => runMigrate(config, databaseUrl)
510
+ : null,
511
+ runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
516
512
  });
517
-
518
- fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
519
513
  }
520
514
  }
521
515
 
522
- async function runMigrations(runtimeConfigs) {
523
- for (const config of runtimeConfigs) {
524
- const migrate = config.testkit.migrate;
525
- if (!migrate) continue;
516
+ async function runMigrate(config, databaseUrl) {
517
+ const migrate = config.testkit.migrate;
518
+ if (!migrate) return;
526
519
 
527
- const env = buildExecutionEnv(config);
528
- const dbUrl = readDatabaseUrl(config.stateDir);
529
- if (dbUrl) env.DATABASE_URL = dbUrl;
520
+ const env = buildExecutionEnv(config);
521
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
530
522
 
531
- console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
532
- await execaCommand(migrate.cmd, {
533
- cwd: resolveServiceCwd(config.productDir, migrate.cwd),
534
- env,
535
- stdio: "inherit",
536
- shell: true,
537
- });
538
- }
523
+ console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
524
+ await execaCommand(migrate.cmd, {
525
+ cwd: resolveServiceCwd(config.productDir, migrate.cwd),
526
+ env,
527
+ stdio: "inherit",
528
+ shell: true,
529
+ });
539
530
  }
540
531
 
541
- async function runSeeds(runtimeConfigs) {
542
- for (const config of runtimeConfigs) {
543
- const seed = config.testkit.seed;
544
- if (!seed) continue;
532
+ async function runSeed(config, databaseUrl) {
533
+ const seed = config.testkit.seed;
534
+ if (!seed) return;
545
535
 
546
- const env = buildExecutionEnv(config);
547
- const dbUrl = readDatabaseUrl(config.stateDir);
548
- if (dbUrl) env.DATABASE_URL = dbUrl;
536
+ const env = buildExecutionEnv(config);
537
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
549
538
 
550
- console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
551
- await execaCommand(seed.cmd, {
552
- cwd: resolveServiceCwd(config.productDir, seed.cwd),
553
- env,
554
- stdio: "inherit",
555
- shell: true,
556
- });
557
- }
539
+ console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
540
+ await execaCommand(seed.cmd, {
541
+ cwd: resolveServiceCwd(config.productDir, seed.cwd),
542
+ env,
543
+ stdio: "inherit",
544
+ shell: true,
545
+ });
558
546
  }
559
547
 
560
548
  async function startLocalServices(runtimeConfigs) {
@@ -1180,12 +1168,7 @@ function findRuntimeStateDirs(rootDir) {
1180
1168
  const visit = (dir) => {
1181
1169
  if (!fs.existsSync(dir)) return;
1182
1170
  const entries = fs.readdirSync(dir, { withFileTypes: true });
1183
- const hasRuntimeFiles = entries.some(
1184
- (entry) =>
1185
- entry.isFile() &&
1186
- (entry.name === "neon_project_id" || entry.name === "neon_branch_id")
1187
- );
1188
- if (hasRuntimeFiles) {
1171
+ if (isDatabaseStateDir(dir)) {
1189
1172
  found.push(dir);
1190
1173
  }
1191
1174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "CLI for running manifest-defined local test suites across k6 and Playwright",
5
5
  "type": "module",
6
6
  "bin": {