@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 +12 -2
- package/lib/cli.mjs +2 -1
- package/lib/config.mjs +237 -33
- package/lib/database.mjs +656 -0
- package/lib/runner.mjs +50 -67
- 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,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
|
|
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
|
-
|
|
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`);
|
package/lib/database.mjs
ADDED
|
@@ -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 {
|
|
6
|
+
import { resolveDalBinary, resolveServiceCwd } from "./config.mjs";
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (!migrate) continue;
|
|
516
|
+
async function runMigrate(config, databaseUrl) {
|
|
517
|
+
const migrate = config.testkit.migrate;
|
|
518
|
+
if (!migrate) return;
|
|
526
519
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
520
|
+
const env = buildExecutionEnv(config);
|
|
521
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
530
522
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if (!seed) continue;
|
|
532
|
+
async function runSeed(config, databaseUrl) {
|
|
533
|
+
const seed = config.testkit.seed;
|
|
534
|
+
if (!seed) return;
|
|
545
535
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (dbUrl) env.DATABASE_URL = dbUrl;
|
|
536
|
+
const env = buildExecutionEnv(config);
|
|
537
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
549
538
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
|