@elench/testkit 0.1.13 → 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,12 +55,14 @@ 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
56
62
  7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
57
63
 
64
+ Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
65
+
58
66
  ## File roles
59
67
 
60
68
  - `runner.manifest.json`: canonical test inventory
@@ -64,6 +72,9 @@ npx @elench/testkit destroy
64
72
 
65
73
  - `envFile` / `envFiles` for service-specific environment loading
66
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
67
78
 
68
79
  ## Parallel execution
69
80
 
@@ -71,6 +82,7 @@ npx @elench/testkit destroy
71
82
 
72
83
  Each worker gets its own:
73
84
  - Neon branch
85
+ - or cloned local Postgres database
74
86
  - `.testkit` state subtree
75
87
  - local service ports
76
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`);