@elench/testkit 0.1.139 → 0.1.140
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/lib/app/configs.mjs +2 -2
- package/lib/cli/operations/db/schema/refresh/operation.mjs +1 -1
- package/lib/cli/operations/db/schema/verify/operation.mjs +1 -1
- package/lib/config/database.mjs +31 -16
- package/lib/config-api/index.d.ts +21 -1
- package/lib/config-api/index.mjs +2 -1
- package/lib/database/index.mjs +243 -28
- package/lib/database/naming.mjs +2 -1
- package/lib/database/state.mjs +4 -0
- package/lib/local/kiln-driver.mjs +14 -1
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
package/lib/app/configs.mjs
CHANGED
|
@@ -25,8 +25,8 @@ export function resolveTargetConfig(configs, serviceName = null) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
if (configs.length === 1) return configs[0];
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
28
|
+
const withDatabase = configs.filter((config) => config.testkit.database);
|
|
29
|
+
if (withDatabase.length === 1) return withDatabase[0];
|
|
30
30
|
|
|
31
31
|
const available = configs.map((config) => config.name).join(", ");
|
|
32
32
|
throw new Error(`Multiple services available. Pass --service. Available: ${available}`);
|
|
@@ -31,7 +31,7 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
|
|
|
31
31
|
try {
|
|
32
32
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
33
33
|
if (config.name === resolvedTarget.name) break;
|
|
34
|
-
if (config.testkit.database
|
|
34
|
+
if (config.testkit.database) {
|
|
35
35
|
await prepareDatabaseRuntime(config, { reporter, logRegistry, setupRegistry });
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -29,7 +29,7 @@ export async function executeDatabaseSchemaVerifyOperation(options = {}) {
|
|
|
29
29
|
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
30
30
|
try {
|
|
31
31
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
32
|
-
if (config.testkit.database
|
|
32
|
+
if (config.testkit.database) {
|
|
33
33
|
await prepareDatabaseRuntime(config, {
|
|
34
34
|
reporter,
|
|
35
35
|
logRegistry,
|
package/lib/config/database.mjs
CHANGED
|
@@ -12,26 +12,41 @@ export function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
|
12
12
|
if (explicitService.databaseFrom) return undefined;
|
|
13
13
|
if (!explicitService.database) return undefined;
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
const rawDatabase = explicitService.database;
|
|
16
|
+
const provider = rawDatabase.provider || (rawDatabase.resource ? "resource" : "local");
|
|
17
|
+
if (!["local", "resource"].includes(provider)) {
|
|
18
|
+
throw new Error(`Service "${serviceName}" database.provider must be "local" or "resource"`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
...rawDatabase,
|
|
23
|
+
provider,
|
|
24
|
+
binding: normalizeDatabaseBinding(rawDatabase.binding || "per-runtime", `Service "${serviceName}" database.binding`),
|
|
25
|
+
reset: rawDatabase.reset !== false,
|
|
26
|
+
sourceSchema: normalizeSourceSchemaConfig(rawDatabase.sourceSchema, serviceName),
|
|
27
|
+
template: normalizeDatabaseTemplateConfig(rawDatabase.template, serviceName),
|
|
28
|
+
serviceName,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (provider === "resource") {
|
|
32
|
+
const resource = String(rawDatabase.resource || "").trim();
|
|
33
|
+
if (!resource) {
|
|
34
|
+
throw new Error(`Service "${serviceName}" database.resource must be a non-empty string`);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
...base,
|
|
38
|
+
resource,
|
|
39
|
+
selectedBackend: "resource",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
22
42
|
|
|
23
43
|
return {
|
|
24
|
-
...
|
|
25
|
-
binding: normalizeDatabaseBinding(database.binding || "per-runtime", `Service "${serviceName}" database.binding`),
|
|
44
|
+
...base,
|
|
26
45
|
provider: "local",
|
|
27
46
|
selectedBackend: "local",
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
32
|
-
sourceSchema: normalizeSourceSchemaConfig(database.sourceSchema, serviceName),
|
|
33
|
-
template: normalizeDatabaseTemplateConfig(database.template, serviceName),
|
|
34
|
-
serviceName,
|
|
47
|
+
image: rawDatabase.image || DEFAULT_LOCAL_IMAGE,
|
|
48
|
+
user: rawDatabase.user || DEFAULT_LOCAL_USER,
|
|
49
|
+
password: rawDatabase.password || DEFAULT_LOCAL_PASSWORD,
|
|
35
50
|
};
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -96,6 +96,17 @@ export interface LocalDatabaseConfig {
|
|
|
96
96
|
user?: string;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export interface ResourceDatabaseConfig {
|
|
100
|
+
provider: "resource";
|
|
101
|
+
binding?: "shared" | "per-runtime";
|
|
102
|
+
reset?: boolean;
|
|
103
|
+
resource: string;
|
|
104
|
+
sourceSchema?: DatabaseSourceSchemaConfig | null;
|
|
105
|
+
template?: DatabaseTemplateConfig;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type PostgresDatabaseConfig = LocalDatabaseConfig | ResourceDatabaseConfig;
|
|
109
|
+
|
|
99
110
|
export interface SkipFileRule {
|
|
100
111
|
path: string;
|
|
101
112
|
reason: string;
|
|
@@ -250,7 +261,7 @@ export interface DiscoveryConfig {
|
|
|
250
261
|
}
|
|
251
262
|
|
|
252
263
|
export interface ServiceConfig {
|
|
253
|
-
database?:
|
|
264
|
+
database?: PostgresDatabaseConfig;
|
|
254
265
|
databaseFrom?: string;
|
|
255
266
|
dependsOn?: string[];
|
|
256
267
|
discovery?: DiscoveryConfig;
|
|
@@ -621,6 +632,15 @@ export declare const database: {
|
|
|
621
632
|
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
622
633
|
}
|
|
623
634
|
): LocalDatabaseConfig;
|
|
635
|
+
postgres(
|
|
636
|
+
options?: Omit<ResourceDatabaseConfig, "provider" | "template"> & {
|
|
637
|
+
inputs?: string[];
|
|
638
|
+
migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
639
|
+
seed?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
640
|
+
template?: DatabaseTemplateOptions;
|
|
641
|
+
verify?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[] | string | string[];
|
|
642
|
+
}
|
|
643
|
+
): ResourceDatabaseConfig;
|
|
624
644
|
fixture(
|
|
625
645
|
options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
|
|
626
646
|
inputs?: string[];
|
package/lib/config-api/index.mjs
CHANGED
package/lib/database/index.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getLocalInfraDir as getLocalInfraDirModel,
|
|
20
20
|
getLocalLocksDir as getLocalLocksDirModel,
|
|
21
21
|
getLocalServiceCacheDir as getLocalServiceCacheDirModel,
|
|
22
|
+
getResourceLocksDir as getResourceLocksDirModel,
|
|
22
23
|
hasRemainingLocalArtifacts as hasRemainingLocalArtifactsModel,
|
|
23
24
|
readStateValue as readStateValueModel,
|
|
24
25
|
visitDirs as visitDirsModel,
|
|
@@ -56,6 +57,10 @@ export async function prepareDatabaseRuntime(config, options = {}) {
|
|
|
56
57
|
await prepareLocalDatabase(config, options);
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
60
|
+
if (db.provider === "resource") {
|
|
61
|
+
await prepareResourceDatabase(config, options);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
59
64
|
|
|
60
65
|
throw new Error(`Unsupported database provider "${db.provider}"`);
|
|
61
66
|
}
|
|
@@ -64,6 +69,8 @@ export async function destroyRuntimeDatabase({ productDir, stateDir }) {
|
|
|
64
69
|
const backend = readStateValue(path.join(stateDir, "database_backend"));
|
|
65
70
|
if (backend === "local") {
|
|
66
71
|
await destroyLocalRuntimeDatabase(productDir, stateDir);
|
|
72
|
+
} else if (backend === "resource") {
|
|
73
|
+
await destroyResourceRuntimeDatabase(stateDir);
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -72,12 +79,26 @@ export async function destroyServiceDatabaseCache(productDir, serviceName) {
|
|
|
72
79
|
if (!fs.existsSync(cacheDir)) return;
|
|
73
80
|
|
|
74
81
|
const backend = readStateValue(path.join(cacheDir, "database_backend"));
|
|
82
|
+
const lockDir = getLocalLocksDir(productDir);
|
|
83
|
+
if (backend === "resource") {
|
|
84
|
+
const resourceName = readStateValue(path.join(cacheDir, "resource_name"));
|
|
85
|
+
const resourceLockDir = getResourceLocksDir(productDir, resourceName || "unknown");
|
|
86
|
+
fs.mkdirSync(resourceLockDir, { recursive: true });
|
|
87
|
+
await withLock(path.join(resourceLockDir, `template-${serviceName}.lock`), async () => {
|
|
88
|
+
const infra = resolveResourcePostgresInfraFromState(cacheDir);
|
|
89
|
+
const templateDbName = readStateValue(path.join(cacheDir, "template_database_name"));
|
|
90
|
+
if (infra && templateDbName) {
|
|
91
|
+
await dropDatabaseIfExists(infra, templateDbName);
|
|
92
|
+
}
|
|
93
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
75
97
|
if (backend !== "local") {
|
|
76
98
|
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
77
99
|
return;
|
|
78
100
|
}
|
|
79
101
|
|
|
80
|
-
const lockDir = getLocalLocksDir(productDir);
|
|
81
102
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
82
103
|
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
83
104
|
const infra = await loadExistingLocalContainer(productDir);
|
|
@@ -165,6 +186,40 @@ async function prepareLocalDatabase(config, options = {}) {
|
|
|
165
186
|
});
|
|
166
187
|
}
|
|
167
188
|
|
|
189
|
+
async function prepareResourceDatabase(config, options = {}) {
|
|
190
|
+
const db = config.testkit.database;
|
|
191
|
+
const productDir = config.productDir;
|
|
192
|
+
const serviceName = config.name;
|
|
193
|
+
const bindingKey = resolveDatabaseBindingKey(config);
|
|
194
|
+
const lockDir = getResourceLocksDir(productDir, db.resource);
|
|
195
|
+
const cacheDir = getLocalServiceCacheDir(productDir, serviceName);
|
|
196
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
197
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
198
|
+
|
|
199
|
+
const infra = resolveResourcePostgresInfra(config);
|
|
200
|
+
let templateFingerprint = null;
|
|
201
|
+
|
|
202
|
+
await waitForResourcePostgresReady(infra);
|
|
203
|
+
await withLock(path.join(lockDir, `template-${serviceName}.lock`), async () => {
|
|
204
|
+
const sourceSchemaState = await prepareSourceSchemaCache(config, options);
|
|
205
|
+
templateFingerprint = await computeTemplateFingerprint(config, { sourceSchemaState });
|
|
206
|
+
templateFingerprint = await ensureTemplateDatabase(
|
|
207
|
+
config,
|
|
208
|
+
infra,
|
|
209
|
+
cacheDir,
|
|
210
|
+
templateFingerprint,
|
|
211
|
+
{
|
|
212
|
+
...options,
|
|
213
|
+
sourceSchemaState,
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await withLock(path.join(lockDir, `runtime-${serviceName}-${hashString(bindingKey, 10)}.lock`), async () => {
|
|
219
|
+
await ensureRuntimeClone(config, infra, cacheDir, templateFingerprint, bindingKey);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerprint, options = {}) {
|
|
169
224
|
const serviceName = config.name;
|
|
170
225
|
let activeFingerprint = templateFingerprint;
|
|
@@ -187,7 +242,7 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
187
242
|
kind: "database-template",
|
|
188
243
|
summary: "template cache hit",
|
|
189
244
|
});
|
|
190
|
-
|
|
245
|
+
writeCacheState(cacheDir, config, infra, existingDbName, activeFingerprint);
|
|
191
246
|
return activeFingerprint;
|
|
192
247
|
}
|
|
193
248
|
|
|
@@ -213,7 +268,7 @@ async function ensureTemplateDatabase(config, infra, cacheDir, templateFingerpri
|
|
|
213
268
|
continue;
|
|
214
269
|
}
|
|
215
270
|
|
|
216
|
-
|
|
271
|
+
writeCacheState(cacheDir, config, infra, desiredDbName, activeFingerprint);
|
|
217
272
|
return activeFingerprint;
|
|
218
273
|
}
|
|
219
274
|
}
|
|
@@ -320,8 +375,13 @@ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint,
|
|
|
320
375
|
}
|
|
321
376
|
|
|
322
377
|
const desiredDbName = buildRuntimeDatabaseName(serviceName, bindingKey, templateFingerprint);
|
|
323
|
-
const
|
|
324
|
-
const
|
|
378
|
+
const backend = config.testkit.database.provider;
|
|
379
|
+
const existingDbName =
|
|
380
|
+
readStateValue(path.join(config.stateDir, `${backend}_database_name`)) ||
|
|
381
|
+
readStateValue(path.join(config.stateDir, "local_database_name"));
|
|
382
|
+
const existingFingerprint =
|
|
383
|
+
readStateValue(path.join(config.stateDir, `${backend}_template_fingerprint`)) ||
|
|
384
|
+
readStateValue(path.join(config.stateDir, "local_template_fingerprint"));
|
|
325
385
|
const needsReset =
|
|
326
386
|
config.testkit.database.reset !== false ||
|
|
327
387
|
existingFingerprint !== templateFingerprint ||
|
|
@@ -338,12 +398,17 @@ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint,
|
|
|
338
398
|
}
|
|
339
399
|
|
|
340
400
|
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
341
|
-
fs.writeFileSync(path.join(config.stateDir, "database_backend"),
|
|
401
|
+
fs.writeFileSync(path.join(config.stateDir, "database_backend"), backend);
|
|
342
402
|
fs.writeFileSync(path.join(config.stateDir, "database_url"), buildDatabaseUrl(infra, desiredDbName));
|
|
343
|
-
fs.writeFileSync(path.join(config.stateDir,
|
|
344
|
-
fs.writeFileSync(path.join(config.stateDir,
|
|
345
|
-
fs.writeFileSync(path.join(config.stateDir,
|
|
346
|
-
|
|
403
|
+
fs.writeFileSync(path.join(config.stateDir, `${backend}_database_name`), desiredDbName);
|
|
404
|
+
fs.writeFileSync(path.join(config.stateDir, `${backend}_template_fingerprint`), templateFingerprint);
|
|
405
|
+
fs.writeFileSync(path.join(config.stateDir, `${backend}_template_database_name`), templateDbName);
|
|
406
|
+
if (backend === "local") {
|
|
407
|
+
fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
|
|
408
|
+
} else {
|
|
409
|
+
fs.writeFileSync(path.join(config.stateDir, "resource_name"), infra.resourceName);
|
|
410
|
+
writeResourceConnectionState(config.stateDir, infra);
|
|
411
|
+
}
|
|
347
412
|
}
|
|
348
413
|
|
|
349
414
|
function resolveDatabaseBindingKey(config) {
|
|
@@ -366,6 +431,15 @@ async function destroyLocalRuntimeDatabase(productDir, stateDir) {
|
|
|
366
431
|
await dropDatabaseIfExists(infra, dbName);
|
|
367
432
|
}
|
|
368
433
|
|
|
434
|
+
async function destroyResourceRuntimeDatabase(stateDir) {
|
|
435
|
+
const dbName = readStateValue(path.join(stateDir, "resource_database_name"));
|
|
436
|
+
if (!dbName) return;
|
|
437
|
+
|
|
438
|
+
const infra = resolveResourcePostgresInfraFromState(stateDir);
|
|
439
|
+
if (!infra) return;
|
|
440
|
+
await dropDatabaseIfExists(infra, dbName);
|
|
441
|
+
}
|
|
442
|
+
|
|
369
443
|
async function ensureLocalContainer(productDir, database = {}) {
|
|
370
444
|
const infraDir = getLocalInfraDir(productDir);
|
|
371
445
|
fs.mkdirSync(infraDir, { recursive: true });
|
|
@@ -476,6 +550,21 @@ async function waitForLocalContainerReady(infra) {
|
|
|
476
550
|
throw new Error(`Timed out waiting for local database container ${infra.containerName}`);
|
|
477
551
|
}
|
|
478
552
|
|
|
553
|
+
async function waitForResourcePostgresReady(infra) {
|
|
554
|
+
const startedAt = Date.now();
|
|
555
|
+
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
556
|
+
try {
|
|
557
|
+
await runAdminQuery(infra, ["-tAc", "SELECT 1"]);
|
|
558
|
+
return;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (!isTransientAdminQueryConnectionError(error)) throw error;
|
|
561
|
+
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
throw new Error(`Timed out waiting for Postgres resource "${infra.resourceName}" at ${infra.host}:${infra.port}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
479
568
|
async function inspectContainer(containerName) {
|
|
480
569
|
try {
|
|
481
570
|
const { stdout } = await execa("docker", ["inspect", containerName]);
|
|
@@ -589,24 +678,48 @@ export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {
|
|
|
589
678
|
}
|
|
590
679
|
|
|
591
680
|
async function runAdminQuery(infra, args) {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
681
|
+
const command = infra.containerName ? "docker" : "psql";
|
|
682
|
+
const commandArgs = infra.containerName
|
|
683
|
+
? [
|
|
684
|
+
"exec",
|
|
685
|
+
"-e",
|
|
686
|
+
`PGPASSWORD=${infra.password}`,
|
|
687
|
+
infra.containerName,
|
|
688
|
+
"psql",
|
|
689
|
+
"-v",
|
|
690
|
+
"ON_ERROR_STOP=1",
|
|
691
|
+
"-U",
|
|
692
|
+
infra.user,
|
|
693
|
+
"-d",
|
|
694
|
+
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
695
|
+
...args,
|
|
696
|
+
]
|
|
697
|
+
: [
|
|
698
|
+
"-v",
|
|
699
|
+
"ON_ERROR_STOP=1",
|
|
700
|
+
"-h",
|
|
701
|
+
infra.host,
|
|
702
|
+
"-p",
|
|
703
|
+
String(infra.port),
|
|
704
|
+
"-U",
|
|
705
|
+
infra.user,
|
|
706
|
+
"-d",
|
|
707
|
+
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
708
|
+
...args,
|
|
709
|
+
];
|
|
710
|
+
const commandOptions = infra.containerName
|
|
711
|
+
? {}
|
|
712
|
+
: {
|
|
713
|
+
env: {
|
|
714
|
+
...process.env,
|
|
715
|
+
PGPASSWORD: infra.password,
|
|
716
|
+
...(infra.sslMode ? { PGSSLMODE: infra.sslMode } : {}),
|
|
717
|
+
},
|
|
718
|
+
};
|
|
606
719
|
const startedAt = Date.now();
|
|
607
720
|
while (true) {
|
|
608
721
|
try {
|
|
609
|
-
const { stdout } = await execa(
|
|
722
|
+
const { stdout } = await execa(command, commandArgs, commandOptions);
|
|
610
723
|
return stdout;
|
|
611
724
|
} catch (error) {
|
|
612
725
|
if (
|
|
@@ -669,6 +782,75 @@ function buildDatabaseUrl(infra, dbName) {
|
|
|
669
782
|
return buildDatabaseUrlModel(infra, dbName);
|
|
670
783
|
}
|
|
671
784
|
|
|
785
|
+
function resolveResourcePostgresInfra(config, processEnv = process.env) {
|
|
786
|
+
const resourceName = String(config.testkit?.database?.resource || "").trim();
|
|
787
|
+
if (!resourceName) {
|
|
788
|
+
throw new Error("Resource-backed Postgres database requires database.resource");
|
|
789
|
+
}
|
|
790
|
+
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
791
|
+
const connection = connections[resourceName];
|
|
792
|
+
if (!connection) {
|
|
793
|
+
const available = Object.keys(connections).sort().join(", ") || "none";
|
|
794
|
+
throw new Error(`Postgres resource "${resourceName}" is not available. Available resources: ${available}`);
|
|
795
|
+
}
|
|
796
|
+
return normalizeResourcePostgresConnection(resourceName, connection);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function resolveResourcePostgresInfraFromState(stateDir, processEnv = process.env) {
|
|
800
|
+
const resourceName = readStateValue(path.join(stateDir, "resource_name"));
|
|
801
|
+
if (!resourceName) return null;
|
|
802
|
+
const connections = parseResourceConnections(processEnv.TESTKIT_RESOURCE_CONNECTIONS_JSON);
|
|
803
|
+
const connection = connections[resourceName] || readResourceConnectionState(stateDir);
|
|
804
|
+
return connection ? normalizeResourcePostgresConnection(resourceName, connection) : null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function parseResourceConnections(raw) {
|
|
808
|
+
if (!raw) return {};
|
|
809
|
+
try {
|
|
810
|
+
const parsed = JSON.parse(raw);
|
|
811
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
812
|
+
} catch (error) {
|
|
813
|
+
throw new Error(`Invalid TESTKIT_RESOURCE_CONNECTIONS_JSON: ${error.message}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function normalizeResourcePostgresConnection(resourceName, connection) {
|
|
818
|
+
const fromUrl = connection.url ? parsePostgresConnectionUrl(connection.url) : {};
|
|
819
|
+
const host = connection.host || fromUrl.host;
|
|
820
|
+
const port = Number(connection.port || fromUrl.port || 5432);
|
|
821
|
+
const user = connection.user || fromUrl.user;
|
|
822
|
+
const password = connection.password || fromUrl.password || "";
|
|
823
|
+
const adminDatabase = connection.adminDatabase || connection.admin_database || fromUrl.database || LOCAL_ADMIN_DB;
|
|
824
|
+
const sslMode = connection.sslMode || connection.sslmode || fromUrl.sslMode || "disable";
|
|
825
|
+
if (!host) throw new Error(`Postgres resource "${resourceName}" connection is missing host`);
|
|
826
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
827
|
+
throw new Error(`Postgres resource "${resourceName}" connection has invalid port`);
|
|
828
|
+
}
|
|
829
|
+
if (!user) throw new Error(`Postgres resource "${resourceName}" connection is missing user`);
|
|
830
|
+
return {
|
|
831
|
+
backend: "resource",
|
|
832
|
+
resourceName,
|
|
833
|
+
host,
|
|
834
|
+
port,
|
|
835
|
+
user,
|
|
836
|
+
password,
|
|
837
|
+
adminDatabase,
|
|
838
|
+
sslMode,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function parsePostgresConnectionUrl(rawUrl) {
|
|
843
|
+
const parsed = new URL(rawUrl);
|
|
844
|
+
return {
|
|
845
|
+
host: parsed.hostname,
|
|
846
|
+
port: parsed.port ? Number(parsed.port) : 5432,
|
|
847
|
+
database: decodeURIComponent(parsed.pathname.replace(/^\//, "")) || LOCAL_ADMIN_DB,
|
|
848
|
+
user: decodeURIComponent(parsed.username || ""),
|
|
849
|
+
password: decodeURIComponent(parsed.password || ""),
|
|
850
|
+
sslMode: parsed.searchParams.get("sslmode") || undefined,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
672
854
|
function buildContainerName(productDir) {
|
|
673
855
|
return buildContainerNameModel(productDir);
|
|
674
856
|
}
|
|
@@ -692,11 +874,40 @@ function writeLocalInfraState(infraDir, infra) {
|
|
|
692
874
|
fs.writeFileSync(path.join(infraDir, "port"), String(infra.port));
|
|
693
875
|
}
|
|
694
876
|
|
|
695
|
-
function
|
|
696
|
-
|
|
877
|
+
function writeCacheState(cacheDir, config, infra, templateDbName, fingerprint) {
|
|
878
|
+
const backend = config.testkit.database.provider;
|
|
879
|
+
fs.writeFileSync(path.join(cacheDir, "database_backend"), backend);
|
|
697
880
|
fs.writeFileSync(path.join(cacheDir, "template_database_name"), templateDbName);
|
|
698
881
|
fs.writeFileSync(path.join(cacheDir, "template_fingerprint"), fingerprint);
|
|
699
|
-
|
|
882
|
+
if (backend === "local") {
|
|
883
|
+
fs.writeFileSync(path.join(cacheDir, "container_name"), infra.containerName);
|
|
884
|
+
} else {
|
|
885
|
+
fs.writeFileSync(path.join(cacheDir, "resource_name"), infra.resourceName);
|
|
886
|
+
writeResourceConnectionState(cacheDir, infra);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function writeResourceConnectionState(stateDir, infra) {
|
|
891
|
+
fs.writeFileSync(path.join(stateDir, "resource_host"), infra.host);
|
|
892
|
+
fs.writeFileSync(path.join(stateDir, "resource_port"), String(infra.port));
|
|
893
|
+
fs.writeFileSync(path.join(stateDir, "resource_user"), infra.user);
|
|
894
|
+
fs.writeFileSync(path.join(stateDir, "resource_password"), infra.password);
|
|
895
|
+
fs.writeFileSync(path.join(stateDir, "resource_admin_database"), infra.adminDatabase || LOCAL_ADMIN_DB);
|
|
896
|
+
fs.writeFileSync(path.join(stateDir, "resource_sslmode"), infra.sslMode || "disable");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function readResourceConnectionState(stateDir) {
|
|
900
|
+
const host = readStateValue(path.join(stateDir, "resource_host"));
|
|
901
|
+
const user = readStateValue(path.join(stateDir, "resource_user"));
|
|
902
|
+
if (!host || !user) return null;
|
|
903
|
+
return {
|
|
904
|
+
host,
|
|
905
|
+
port: Number(readStateValue(path.join(stateDir, "resource_port")) || 5432),
|
|
906
|
+
user,
|
|
907
|
+
password: readStateValue(path.join(stateDir, "resource_password")) || "",
|
|
908
|
+
adminDatabase: readStateValue(path.join(stateDir, "resource_admin_database")) || LOCAL_ADMIN_DB,
|
|
909
|
+
sslMode: readStateValue(path.join(stateDir, "resource_sslmode")) || "disable",
|
|
910
|
+
};
|
|
700
911
|
}
|
|
701
912
|
|
|
702
913
|
function getLocalInfraDir(productDir) {
|
|
@@ -711,6 +922,10 @@ function getLocalServiceCacheDir(productDir, serviceName) {
|
|
|
711
922
|
return getLocalServiceCacheDirModel(productDir, serviceName);
|
|
712
923
|
}
|
|
713
924
|
|
|
925
|
+
function getResourceLocksDir(productDir, resourceName) {
|
|
926
|
+
return getResourceLocksDirModel(productDir, resourceName);
|
|
927
|
+
}
|
|
928
|
+
|
|
714
929
|
function hasRemainingLocalArtifacts(productDir) {
|
|
715
930
|
return hasRemainingLocalArtifactsModel(productDir, readStateValue);
|
|
716
931
|
}
|
package/lib/database/naming.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import crypto from "crypto";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
|
|
4
4
|
export function buildDatabaseUrl(infra, dbName) {
|
|
5
|
-
|
|
5
|
+
const sslMode = infra.sslMode || "disable";
|
|
6
|
+
return `postgresql://${encodeURIComponent(infra.user)}:${encodeURIComponent(infra.password)}@${infra.host}:${infra.port}/${dbName}?sslmode=${encodeURIComponent(sslMode)}`;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function buildContainerName(productDir) {
|
package/lib/database/state.mjs
CHANGED
|
@@ -13,6 +13,10 @@ export function getLocalServiceCacheDir(productDir, serviceName) {
|
|
|
13
13
|
return path.join(productDir, ".testkit", "_dbcache", serviceName);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function getResourceLocksDir(productDir, resourceName) {
|
|
17
|
+
return path.join(productDir, ".testkit", "_infra", "resource-postgres", resourceName, "locks");
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
export function hasRemainingLocalArtifacts(productDir, readStateValue) {
|
|
17
21
|
const root = path.join(productDir, ".testkit");
|
|
18
22
|
if (!fs.existsSync(root)) return false;
|
|
@@ -123,7 +123,12 @@ export async function kilnLocalDown(context, name, options = {}) {
|
|
|
123
123
|
const ssh = await sshFromManifest(manifest);
|
|
124
124
|
const args = ["local", "down", name, ...(options.destroyState ? ["--destroy-state"] : [])];
|
|
125
125
|
try {
|
|
126
|
-
const
|
|
126
|
+
const resourceConnections = manifestResourceConnections(manifest);
|
|
127
|
+
const result = await remoteTestkit(ssh, manifest.remoteProductDir, args, {
|
|
128
|
+
...(Object.keys(resourceConnections).length > 0
|
|
129
|
+
? { TESTKIT_RESOURCE_CONNECTIONS_JSON: JSON.stringify(resourceConnections) }
|
|
130
|
+
: {}),
|
|
131
|
+
});
|
|
127
132
|
if (result.exitCode !== 0) {
|
|
128
133
|
throw new Error(`remote testkit local down failed\n${result.stdout}${result.stderr}`);
|
|
129
134
|
}
|
|
@@ -621,6 +626,14 @@ function pickAppliance(appliance) {
|
|
|
621
626
|
};
|
|
622
627
|
}
|
|
623
628
|
|
|
629
|
+
function manifestResourceConnections(manifest) {
|
|
630
|
+
return Object.fromEntries(
|
|
631
|
+
Object.entries(manifest.resources || {})
|
|
632
|
+
.map(([name, resource]) => [name, resource?.connection || null])
|
|
633
|
+
.filter(([_name, connection]) => connection && typeof connection === "object")
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
624
637
|
function pickAPIConfig(api) {
|
|
625
638
|
return {
|
|
626
639
|
...(api.apiUrl ? { apiUrl: api.apiUrl } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@elench/testkit-protocol": "0.1.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.140"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
},
|
|
99
99
|
"dependencies": {
|
|
100
100
|
"@babel/code-frame": "^7.29.0",
|
|
101
|
-
"@elench/next-analysis": "0.1.
|
|
102
|
-
"@elench/testkit-bridge": "0.1.
|
|
103
|
-
"@elench/testkit-protocol": "0.1.
|
|
104
|
-
"@elench/ts-analysis": "0.1.
|
|
101
|
+
"@elench/next-analysis": "0.1.140",
|
|
102
|
+
"@elench/testkit-bridge": "0.1.140",
|
|
103
|
+
"@elench/testkit-protocol": "0.1.140",
|
|
104
|
+
"@elench/ts-analysis": "0.1.140",
|
|
105
105
|
"@oclif/core": "^4.10.6",
|
|
106
106
|
"@playwright/test": "^1.52.0",
|
|
107
107
|
"esbuild": "^0.25.11",
|