@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 +14 -2
- package/lib/cli.mjs +2 -1
- package/lib/config.mjs +237 -33
- package/lib/database.mjs +656 -0
- package/lib/runner.mjs +275 -98
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
315
|
-
const cwdPath = resolveServiceCwd(productDir,
|
|
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: ${
|
|
353
|
+
`Service "${name}" migrate.cwd does not exist: ${resolvedMigrate.cwd}`
|
|
319
354
|
);
|
|
320
355
|
}
|
|
321
356
|
}
|
|
322
357
|
|
|
323
|
-
if (
|
|
324
|
-
const cwdPath = resolveServiceCwd(productDir,
|
|
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: ${
|
|
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
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|