@elench/testkit 0.1.16 → 0.1.18

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.
Files changed (39) hide show
  1. package/README.md +44 -19
  2. package/bin/testkit.mjs +1 -1
  3. package/lib/cli/args.mjs +57 -0
  4. package/lib/cli/args.test.mjs +62 -0
  5. package/lib/cli/index.mjs +88 -0
  6. package/lib/config/index.mjs +294 -0
  7. package/lib/config/index.test.mjs +12 -0
  8. package/lib/config/model.mjs +422 -0
  9. package/lib/config/model.test.mjs +193 -0
  10. package/lib/database/fingerprint.mjs +61 -0
  11. package/lib/database/fingerprint.test.mjs +93 -0
  12. package/lib/{database.mjs → database/index.mjs} +45 -160
  13. package/lib/database/naming.mjs +47 -0
  14. package/lib/database/naming.test.mjs +39 -0
  15. package/lib/database/state.mjs +52 -0
  16. package/lib/database/state.test.mjs +66 -0
  17. package/lib/reporters/playwright.mjs +125 -0
  18. package/lib/reporters/playwright.test.mjs +73 -0
  19. package/lib/runner/index.mjs +1221 -0
  20. package/lib/runner/metadata.mjs +55 -0
  21. package/lib/runner/metadata.test.mjs +52 -0
  22. package/lib/runner/planning.mjs +270 -0
  23. package/lib/runner/planning.test.mjs +127 -0
  24. package/lib/runner/results.mjs +285 -0
  25. package/lib/runner/results.test.mjs +144 -0
  26. package/lib/runner/state.mjs +71 -0
  27. package/lib/runner/state.test.mjs +64 -0
  28. package/lib/runner/template.mjs +320 -0
  29. package/lib/runner/template.test.mjs +150 -0
  30. package/lib/telemetry/index.mjs +43 -0
  31. package/lib/timing/index.mjs +73 -0
  32. package/lib/timing/index.test.mjs +64 -0
  33. package/package.json +11 -3
  34. package/infra/neon-down.sh +0 -18
  35. package/infra/neon-up.sh +0 -124
  36. package/lib/cli.mjs +0 -132
  37. package/lib/config.mjs +0 -666
  38. package/lib/exec.mjs +0 -20
  39. package/lib/runner.mjs +0 -1165
@@ -0,0 +1,93 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import {
7
+ appendFileToHash,
8
+ appendInputToHash,
9
+ computeTemplateFingerprint,
10
+ } from "./fingerprint.mjs";
11
+
12
+ const tempDirs = [];
13
+
14
+ afterEach(() => {
15
+ for (const dir of tempDirs.splice(0)) {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ });
19
+
20
+ function mkTempDir() {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-db-fingerprint-"));
22
+ tempDirs.push(dir);
23
+ return dir;
24
+ }
25
+
26
+ describe("database-fingerprint", () => {
27
+ it("hashes files, directories, and missing paths deterministically", () => {
28
+ const productDir = mkTempDir();
29
+ fs.mkdirSync(path.join(productDir, "schema"), { recursive: true });
30
+ fs.writeFileSync(path.join(productDir, "schema", "001.sql"), "select 1;\n");
31
+ fs.mkdirSync(path.join(productDir, ".testkit", "ignored"), { recursive: true });
32
+ fs.writeFileSync(path.join(productDir, ".testkit", "ignored", "skip.sql"), "skip");
33
+
34
+ const hashA = crypto.createHash("sha256");
35
+ appendInputToHash(hashA, productDir, "schema");
36
+ appendInputToHash(hashA, productDir, "missing");
37
+ const digestA = hashA.digest("hex");
38
+
39
+ const hashB = crypto.createHash("sha256");
40
+ appendInputToHash(hashB, productDir, "schema");
41
+ appendInputToHash(hashB, productDir, "missing");
42
+ const digestB = hashB.digest("hex");
43
+
44
+ expect(digestA).toBe(digestB);
45
+ });
46
+
47
+ it("hashes file contents and missing files", () => {
48
+ const productDir = mkTempDir();
49
+ const filePath = path.join(productDir, "a.txt");
50
+ fs.writeFileSync(filePath, "alpha");
51
+
52
+ const present = crypto.createHash("sha256");
53
+ appendFileToHash(present, productDir, filePath);
54
+ const presentDigest = present.digest("hex");
55
+
56
+ const missing = crypto.createHash("sha256");
57
+ appendFileToHash(missing, productDir, path.join(productDir, "missing.txt"));
58
+ const missingDigest = missing.digest("hex");
59
+
60
+ expect(presentDigest).not.toBe(missingDigest);
61
+ });
62
+
63
+ it("computes template fingerprints from config inputs and env files", async () => {
64
+ const productDir = mkTempDir();
65
+ fs.mkdirSync(path.join(productDir, "schema"), { recursive: true });
66
+ fs.writeFileSync(path.join(productDir, "schema", "001.sql"), "select 1;\n");
67
+ fs.writeFileSync(path.join(productDir, ".env.testkit"), "TOKEN=alpha\n");
68
+
69
+ const config = {
70
+ productDir,
71
+ testkit: {
72
+ database: {
73
+ provider: "local",
74
+ selectedBackend: "local",
75
+ template: {
76
+ inputs: ["schema"],
77
+ },
78
+ },
79
+ envFiles: [".env.testkit"],
80
+ migrate: {
81
+ cmd: "npm run migrate",
82
+ },
83
+ seed: null,
84
+ },
85
+ };
86
+
87
+ const first = await computeTemplateFingerprint(config);
88
+ fs.writeFileSync(path.join(productDir, ".env.testkit"), "TOKEN=beta\n");
89
+ const second = await computeTemplateFingerprint(config);
90
+
91
+ expect(first).not.toBe(second);
92
+ });
93
+ });
@@ -1,11 +1,32 @@
1
- import crypto from "crypto";
2
1
  import fs from "fs";
3
2
  import path from "path";
4
3
  import { execa } from "execa";
5
- import { requireNeonApiKey, resolveServiceCwd } from "./config.mjs";
6
- import { runScript } from "./exec.mjs";
7
-
8
- const LOCAL_IMAGE = "postgres:16";
4
+ import {
5
+ appendFileToHash as appendFileToHashModel,
6
+ appendInputToHash as appendInputToHashModel,
7
+ computeTemplateFingerprint as computeTemplateFingerprintModel,
8
+ } from "./fingerprint.mjs";
9
+ import {
10
+ buildContainerName as buildContainerNameModel,
11
+ buildDatabaseUrl as buildDatabaseUrlModel,
12
+ buildTemplateDatabaseName as buildTemplateDatabaseNameModel,
13
+ buildWorkerDatabaseName as buildWorkerDatabaseNameModel,
14
+ escapeIdentifier as escapeIdentifierModel,
15
+ escapeSqlLiteral as escapeSqlLiteralModel,
16
+ hashString as hashStringModel,
17
+ limitIdentifier as limitIdentifierModel,
18
+ slugSegment as slugSegmentModel,
19
+ } from "./naming.mjs";
20
+ import {
21
+ getLocalInfraDir as getLocalInfraDirModel,
22
+ getLocalLocksDir as getLocalLocksDirModel,
23
+ getLocalServiceCacheDir as getLocalServiceCacheDirModel,
24
+ hasRemainingLocalArtifacts as hasRemainingLocalArtifactsModel,
25
+ readStateValue as readStateValueModel,
26
+ visitDirs as visitDirsModel,
27
+ } from "./state.mjs";
28
+
29
+ const LOCAL_IMAGE = "pgvector/pgvector:pg16";
9
30
  const LOCAL_USER = "testkit";
10
31
  const LOCAL_PASSWORD = "testkit";
11
32
  const LOCAL_ADMIN_DB = "postgres";
@@ -17,10 +38,6 @@ export async function prepareDatabaseRuntime(config, hooks = {}) {
17
38
  if (!db) return;
18
39
 
19
40
  fs.mkdirSync(config.stateDir, { recursive: true });
20
- if (db.provider === "neon") {
21
- await prepareNeonDatabase(config, hooks);
22
- return;
23
- }
24
41
  if (db.provider === "local") {
25
42
  await prepareLocalDatabase(config, hooks);
26
43
  return;
@@ -31,14 +48,8 @@ export async function prepareDatabaseRuntime(config, hooks = {}) {
31
48
 
32
49
  export async function destroyRuntimeDatabase({ productDir, stateDir }) {
33
50
  const backend = readStateValue(path.join(stateDir, "database_backend"));
34
- if (!backend || backend === "neon") {
35
- await destroyNeonDatabase(stateDir);
36
- return;
37
- }
38
-
39
51
  if (backend === "local") {
40
52
  await destroyLocalRuntimeDatabase(productDir, stateDir);
41
- return;
42
53
  }
43
54
  }
44
55
 
@@ -83,11 +94,7 @@ export async function cleanupOrphanedLocalInfrastructure(productDir) {
83
94
 
84
95
  export function isDatabaseStateDir(dir) {
85
96
  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
- );
97
+ return fs.existsSync(path.join(dir, "database_backend"));
91
98
  }
92
99
 
93
100
  export function showServiceDatabaseStatus(productDir, serviceName) {
@@ -108,42 +115,6 @@ export function showServiceDatabaseStatus(productDir, serviceName) {
108
115
  return true;
109
116
  }
110
117
 
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
118
  async function prepareLocalDatabase(config, hooks) {
148
119
  const db = config.testkit.database;
149
120
  const productDir = config.productDir;
@@ -227,7 +198,6 @@ async function ensureWorkerClone(config, infra, cacheDir, templateFingerprint) {
227
198
  }
228
199
 
229
200
  fs.mkdirSync(config.stateDir, { recursive: true });
230
- removeNeonRuntimeState(config.stateDir);
231
201
  fs.writeFileSync(path.join(config.stateDir, "database_backend"), "local");
232
202
  fs.writeFileSync(path.join(config.stateDir, "database_url"), buildDatabaseUrl(infra, desiredDbName));
233
203
  fs.writeFileSync(path.join(config.stateDir, "local_database_name"), desiredDbName);
@@ -427,84 +397,31 @@ async function runAdminQuery(infra, args) {
427
397
  }
428
398
 
429
399
  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");
400
+ return computeTemplateFingerprintModel(config);
451
401
  }
452
402
 
453
403
  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);
404
+ return appendInputToHashModel(hash, productDir, input);
471
405
  }
472
406
 
473
407
  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));
408
+ return appendFileToHashModel(hash, productDir, absPath);
483
409
  }
484
410
 
485
411
  function buildDatabaseUrl(infra, dbName) {
486
- return `postgresql://${encodeURIComponent(infra.user)}:${encodeURIComponent(infra.password)}@${infra.host}:${infra.port}/${dbName}?sslmode=disable`;
412
+ return buildDatabaseUrlModel(infra, dbName);
487
413
  }
488
414
 
489
415
  function buildContainerName(productDir) {
490
- return limitIdentifier(
491
- `testkit_pg_${slugSegment(path.basename(productDir))}_${hashString(productDir, 10)}`,
492
- 63
493
- );
416
+ return buildContainerNameModel(productDir);
494
417
  }
495
418
 
496
419
  function buildTemplateDatabaseName(serviceName, fingerprint) {
497
- return limitIdentifier(
498
- `tk_tpl_${slugSegment(serviceName)}_${fingerprint.slice(0, 16)}`,
499
- 63
500
- );
420
+ return buildTemplateDatabaseNameModel(serviceName, fingerprint);
501
421
  }
502
422
 
503
423
  function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
504
- return limitIdentifier(
505
- `tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
506
- 63
507
- );
424
+ return buildWorkerDatabaseNameModel(serviceName, stateDir, fingerprint);
508
425
  }
509
426
 
510
427
  function writeLocalInfraState(infraDir, infra) {
@@ -526,48 +443,23 @@ function writeLocalCacheState(cacheDir, infra, templateDbName, fingerprint) {
526
443
  }
527
444
 
528
445
  function getLocalInfraDir(productDir) {
529
- return path.join(productDir, ".testkit", "_infra", "local-postgres");
446
+ return getLocalInfraDirModel(productDir);
530
447
  }
531
448
 
532
449
  function getLocalLocksDir(productDir) {
533
- return path.join(getLocalInfraDir(productDir), "locks");
450
+ return getLocalLocksDirModel(productDir);
534
451
  }
535
452
 
536
453
  function getLocalServiceCacheDir(productDir, serviceName) {
537
- return path.join(productDir, ".testkit", "_dbcache", serviceName);
454
+ return getLocalServiceCacheDirModel(productDir, serviceName);
538
455
  }
539
456
 
540
457
  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
- });
458
+ return hasRemainingLocalArtifactsModel(productDir, readStateValue);
561
459
  }
562
460
 
563
461
  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
- }
462
+ return visitDirsModel(root, visitor);
571
463
  }
572
464
 
573
465
  function printStateDir(dir, indent) {
@@ -610,28 +502,27 @@ async function withLock(lockPath, fn) {
610
502
  }
611
503
 
612
504
  function hashString(value, length = 12) {
613
- return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
505
+ return hashStringModel(value, length);
614
506
  }
615
507
 
616
508
  function slugSegment(value) {
617
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "db";
509
+ return slugSegmentModel(value);
618
510
  }
619
511
 
620
512
  function limitIdentifier(value, maxLength) {
621
- return value.length <= maxLength ? value : value.slice(0, maxLength);
513
+ return limitIdentifierModel(value, maxLength);
622
514
  }
623
515
 
624
516
  function escapeIdentifier(value) {
625
- return String(value).replace(/"/g, "\"\"");
517
+ return escapeIdentifierModel(value);
626
518
  }
627
519
 
628
520
  function escapeSqlLiteral(value) {
629
- return String(value).replace(/'/g, "''");
521
+ return escapeSqlLiteralModel(value);
630
522
  }
631
523
 
632
524
  function readStateValue(filePath) {
633
- if (!fs.existsSync(filePath)) return null;
634
- return fs.readFileSync(filePath, "utf8").trim();
525
+ return readStateValueModel(filePath);
635
526
  }
636
527
 
637
528
  function sleep(ms) {
@@ -648,9 +539,3 @@ function removeLocalRuntimeState(stateDir) {
648
539
  fs.rmSync(path.join(stateDir, file), { force: true });
649
540
  }
650
541
  }
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
- }
@@ -0,0 +1,47 @@
1
+ import crypto from "crypto";
2
+ import path from "path";
3
+
4
+ export function buildDatabaseUrl(infra, dbName) {
5
+ return `postgresql://${encodeURIComponent(infra.user)}:${encodeURIComponent(infra.password)}@${infra.host}:${infra.port}/${dbName}?sslmode=disable`;
6
+ }
7
+
8
+ export function buildContainerName(productDir) {
9
+ return limitIdentifier(
10
+ `testkit_pg_${slugSegment(path.basename(productDir))}_${hashString(productDir, 10)}`,
11
+ 63
12
+ );
13
+ }
14
+
15
+ export function buildTemplateDatabaseName(serviceName, fingerprint) {
16
+ return limitIdentifier(
17
+ `tk_tpl_${slugSegment(serviceName)}_${fingerprint.slice(0, 16)}`,
18
+ 63
19
+ );
20
+ }
21
+
22
+ export function buildWorkerDatabaseName(serviceName, stateDir, fingerprint) {
23
+ return limitIdentifier(
24
+ `tk_${slugSegment(serviceName)}_${hashString(stateDir, 10)}_${fingerprint.slice(0, 12)}`,
25
+ 63
26
+ );
27
+ }
28
+
29
+ export function hashString(value, length = 12) {
30
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
31
+ }
32
+
33
+ export function slugSegment(value) {
34
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "db";
35
+ }
36
+
37
+ export function limitIdentifier(value, maxLength) {
38
+ return value.length <= maxLength ? value : value.slice(0, maxLength);
39
+ }
40
+
41
+ export function escapeIdentifier(value) {
42
+ return String(value).replace(/"/g, "\"\"");
43
+ }
44
+
45
+ export function escapeSqlLiteral(value) {
46
+ return String(value).replace(/'/g, "''");
47
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildContainerName,
4
+ buildDatabaseUrl,
5
+ buildTemplateDatabaseName,
6
+ buildWorkerDatabaseName,
7
+ escapeIdentifier,
8
+ escapeSqlLiteral,
9
+ slugSegment,
10
+ } from "./naming.mjs";
11
+
12
+ describe("database-naming", () => {
13
+ it("builds deterministic names and URLs", () => {
14
+ expect(buildContainerName("/tmp/My Product")).toMatch(/^testkit_pg_my_product_/);
15
+ expect(buildTemplateDatabaseName("api", "1234567890abcdef1234")).toBe(
16
+ "tk_tpl_api_1234567890abcdef"
17
+ );
18
+ expect(buildWorkerDatabaseName("api", "/tmp/state", "abcdef1234567890")).toMatch(
19
+ /^tk_api_[a-f0-9]{10}_abcdef123456$/
20
+ );
21
+ expect(
22
+ buildDatabaseUrl(
23
+ {
24
+ user: "a user",
25
+ password: "p@ss word",
26
+ host: "127.0.0.1",
27
+ port: 5432,
28
+ },
29
+ "dbname"
30
+ )
31
+ ).toContain("postgresql://a%20user:p%40ss%20word@127.0.0.1:5432/dbname");
32
+ });
33
+
34
+ it("escapes identifiers and literals", () => {
35
+ expect(slugSegment("My API")).toBe("my_api");
36
+ expect(escapeIdentifier('bad"name')).toBe('bad""name');
37
+ expect(escapeSqlLiteral("it's")).toBe("it''s");
38
+ });
39
+ });
@@ -0,0 +1,52 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export function getLocalInfraDir(productDir) {
5
+ return path.join(productDir, ".testkit", "_infra", "local-postgres");
6
+ }
7
+
8
+ export function getLocalLocksDir(productDir) {
9
+ return path.join(getLocalInfraDir(productDir), "locks");
10
+ }
11
+
12
+ export function getLocalServiceCacheDir(productDir, serviceName) {
13
+ return path.join(productDir, ".testkit", "_dbcache", serviceName);
14
+ }
15
+
16
+ export function hasRemainingLocalArtifacts(productDir, readStateValue) {
17
+ const root = path.join(productDir, ".testkit");
18
+ if (!fs.existsSync(root)) return false;
19
+
20
+ let found = false;
21
+ visitDirs(root, (dir) => {
22
+ if (found) return;
23
+ if (dir.includes(`${path.sep}.testkit${path.sep}_infra`)) return;
24
+ if (readStateValue(path.join(dir, "database_backend")) === "local") {
25
+ found = true;
26
+ }
27
+ });
28
+
29
+ if (found) return true;
30
+
31
+ const cacheRoot = path.join(productDir, ".testkit", "_dbcache");
32
+ if (!fs.existsSync(cacheRoot)) return false;
33
+ return fs.readdirSync(cacheRoot).some((entry) => {
34
+ const entryPath = path.join(cacheRoot, entry);
35
+ return fs.statSync(entryPath).isDirectory();
36
+ });
37
+ }
38
+
39
+ export function visitDirs(root, visitor) {
40
+ if (!fs.existsSync(root)) return;
41
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
42
+ if (!entry.isDirectory()) continue;
43
+ const dir = path.join(root, entry.name);
44
+ visitor(dir);
45
+ visitDirs(dir, visitor);
46
+ }
47
+ }
48
+
49
+ export function readStateValue(filePath) {
50
+ if (!fs.existsSync(filePath)) return null;
51
+ return fs.readFileSync(filePath, "utf8").trim();
52
+ }
@@ -0,0 +1,66 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ getLocalInfraDir,
7
+ getLocalLocksDir,
8
+ getLocalServiceCacheDir,
9
+ hasRemainingLocalArtifacts,
10
+ readStateValue,
11
+ visitDirs,
12
+ } from "./state.mjs";
13
+
14
+ const tempDirs = [];
15
+
16
+ afterEach(() => {
17
+ for (const dir of tempDirs.splice(0)) {
18
+ fs.rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ function mkTempDir() {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-db-state-"));
24
+ tempDirs.push(dir);
25
+ return dir;
26
+ }
27
+
28
+ describe("database-state", () => {
29
+ it("builds local state paths", () => {
30
+ expect(getLocalInfraDir("/tmp/product")).toBe("/tmp/product/.testkit/_infra/local-postgres");
31
+ expect(getLocalLocksDir("/tmp/product")).toBe("/tmp/product/.testkit/_infra/local-postgres/locks");
32
+ expect(getLocalServiceCacheDir("/tmp/product", "api")).toBe("/tmp/product/.testkit/_dbcache/api");
33
+ });
34
+
35
+ it("reads state values and visits directories", () => {
36
+ const productDir = mkTempDir();
37
+ const aDir = path.join(productDir, "a");
38
+ const bDir = path.join(aDir, "b");
39
+ fs.mkdirSync(bDir, { recursive: true });
40
+ fs.writeFileSync(path.join(aDir, "value"), " hello \n");
41
+
42
+ const visited = [];
43
+ visitDirs(productDir, (dir) => visited.push(path.relative(productDir, dir)));
44
+
45
+ expect(visited).toEqual(["a", path.join("a", "b")]);
46
+ expect(readStateValue(path.join(aDir, "value"))).toBe("hello");
47
+ expect(readStateValue(path.join(aDir, "missing"))).toBeNull();
48
+ });
49
+
50
+ it("detects remaining local artifacts", () => {
51
+ const productDir = mkTempDir();
52
+ const runtimeDir = path.join(productDir, ".testkit", "api");
53
+ fs.mkdirSync(runtimeDir, { recursive: true });
54
+ fs.writeFileSync(path.join(runtimeDir, "database_backend"), "local");
55
+
56
+ expect(hasRemainingLocalArtifacts(productDir, readStateValue)).toBe(true);
57
+
58
+ fs.rmSync(path.join(productDir, ".testkit"), { recursive: true, force: true });
59
+ fs.mkdirSync(path.join(productDir, ".testkit", "_dbcache", "api"), { recursive: true });
60
+
61
+ expect(hasRemainingLocalArtifacts(productDir, readStateValue)).toBe(true);
62
+
63
+ fs.rmSync(path.join(productDir, ".testkit"), { recursive: true, force: true });
64
+ expect(hasRemainingLocalArtifacts(productDir, readStateValue)).toBe(false);
65
+ });
66
+ });