@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.
- package/README.md +44 -19
- package/bin/testkit.mjs +1 -1
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +88 -0
- package/lib/config/index.mjs +294 -0
- package/lib/config/index.test.mjs +12 -0
- package/lib/config/model.mjs +422 -0
- package/lib/config/model.test.mjs +193 -0
- package/lib/database/fingerprint.mjs +61 -0
- package/lib/database/fingerprint.test.mjs +93 -0
- package/lib/{database.mjs → database/index.mjs} +45 -160
- package/lib/database/naming.mjs +47 -0
- package/lib/database/naming.test.mjs +39 -0
- package/lib/database/state.mjs +52 -0
- package/lib/database/state.test.mjs +66 -0
- package/lib/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/runner/index.mjs +1221 -0
- package/lib/runner/metadata.mjs +55 -0
- package/lib/runner/metadata.test.mjs +52 -0
- package/lib/runner/planning.mjs +270 -0
- package/lib/runner/planning.test.mjs +127 -0
- package/lib/runner/results.mjs +285 -0
- package/lib/runner/results.test.mjs +144 -0
- package/lib/runner/state.mjs +71 -0
- package/lib/runner/state.test.mjs +64 -0
- package/lib/runner/template.mjs +320 -0
- package/lib/runner/template.test.mjs +150 -0
- package/lib/telemetry/index.mjs +43 -0
- package/lib/timing/index.mjs +73 -0
- package/lib/timing/index.test.mjs +64 -0
- package/package.json +11 -3
- package/infra/neon-down.sh +0 -18
- package/infra/neon-up.sh +0 -124
- package/lib/cli.mjs +0 -132
- package/lib/config.mjs +0 -666
- package/lib/exec.mjs +0 -20
- 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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
412
|
+
return buildDatabaseUrlModel(infra, dbName);
|
|
487
413
|
}
|
|
488
414
|
|
|
489
415
|
function buildContainerName(productDir) {
|
|
490
|
-
return
|
|
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
|
|
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
|
|
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
|
|
446
|
+
return getLocalInfraDirModel(productDir);
|
|
530
447
|
}
|
|
531
448
|
|
|
532
449
|
function getLocalLocksDir(productDir) {
|
|
533
|
-
return
|
|
450
|
+
return getLocalLocksDirModel(productDir);
|
|
534
451
|
}
|
|
535
452
|
|
|
536
453
|
function getLocalServiceCacheDir(productDir, serviceName) {
|
|
537
|
-
return
|
|
454
|
+
return getLocalServiceCacheDirModel(productDir, serviceName);
|
|
538
455
|
}
|
|
539
456
|
|
|
540
457
|
function hasRemainingLocalArtifacts(productDir) {
|
|
541
|
-
|
|
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
|
-
|
|
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
|
|
505
|
+
return hashStringModel(value, length);
|
|
614
506
|
}
|
|
615
507
|
|
|
616
508
|
function slugSegment(value) {
|
|
617
|
-
return value
|
|
509
|
+
return slugSegmentModel(value);
|
|
618
510
|
}
|
|
619
511
|
|
|
620
512
|
function limitIdentifier(value, maxLength) {
|
|
621
|
-
return value
|
|
513
|
+
return limitIdentifierModel(value, maxLength);
|
|
622
514
|
}
|
|
623
515
|
|
|
624
516
|
function escapeIdentifier(value) {
|
|
625
|
-
return
|
|
517
|
+
return escapeIdentifierModel(value);
|
|
626
518
|
}
|
|
627
519
|
|
|
628
520
|
function escapeSqlLiteral(value) {
|
|
629
|
-
return
|
|
521
|
+
return escapeSqlLiteralModel(value);
|
|
630
522
|
}
|
|
631
523
|
|
|
632
524
|
function readStateValue(filePath) {
|
|
633
|
-
|
|
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
|
+
});
|