@elench/testkit 0.1.145 → 0.1.147
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 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +11 -1
- package/lib/cli/operations/db/schema/verify/operation.mjs +11 -1
- package/lib/config-api/auth-fixtures.mjs +41 -10
- package/lib/database/admin.mjs +227 -0
- package/lib/database/cleanup.mjs +201 -0
- package/lib/database/constants.mjs +10 -0
- package/lib/database/index.mjs +46 -720
- package/lib/database/local-postgres.mjs +158 -0
- package/lib/database/locks.mjs +31 -0
- package/lib/database/resource-postgres.mjs +72 -0
- package/lib/database/state-files.mjs +53 -0
- package/lib/ownership/docker.mjs +9 -0
- package/lib/runner/default-runtime-runner.mjs +2 -0
- package/lib/runner/lifecycle.mjs +1 -1
- package/lib/runner/maintenance.mjs +3 -0
- package/lib/runner/playwright-runner.mjs +10 -2
- package/lib/runner/scheduler/index.mjs +3 -0
- package/lib/runtime/index.d.ts +8 -0
- package/lib/runtime-src/k6/http.js +9 -2
- 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/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +6 -6
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +1 -1
package/README.md
CHANGED
|
@@ -203,6 +203,10 @@ export default defineConfig({
|
|
|
203
203
|
});
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
+
Managed UI suites should import Playwright APIs from `@elench/testkit/ui`.
|
|
207
|
+
Testkit runs those suites with its own Playwright runtime so product-local
|
|
208
|
+
Playwright installs cannot split the test registry.
|
|
209
|
+
|
|
206
210
|
For scripts and app-runtime code, `@elench/testkit/env` provides shared helpers
|
|
207
211
|
for managed runtime detection, dotenv loading, and local-database safety:
|
|
208
212
|
|
|
@@ -563,12 +567,17 @@ export default defineConfig({
|
|
|
563
567
|
|
|
564
568
|
const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, actors, req }) => {
|
|
565
569
|
req.get("/api/auth/session");
|
|
570
|
+
req("GET", "/api/auth/session");
|
|
566
571
|
actor?.req.get("/api/auth/session");
|
|
567
572
|
req.as("outsider").get("/api/auth/session");
|
|
568
573
|
actors.get("reviewer").rawReq.get("/api/auth/session");
|
|
569
574
|
});
|
|
570
575
|
```
|
|
571
576
|
|
|
577
|
+
Generated `auth.fixture(...)` identities are isolated per managed Testkit file run,
|
|
578
|
+
so parallel suites do not reuse the same login credentials unless an actor explicitly
|
|
579
|
+
sets `email`.
|
|
580
|
+
|
|
572
581
|
DAL suites:
|
|
573
582
|
|
|
574
583
|
```ts
|
|
@@ -596,6 +605,9 @@ const suite = defineDalSuite({ fixtures }, ({ db, fixtureScope, fixtures }) => {
|
|
|
596
605
|
export default suite;
|
|
597
606
|
```
|
|
598
607
|
|
|
608
|
+
DAL files for the same service database are serialized by default to avoid
|
|
609
|
+
exhausting Postgres connection limits during highly parallel runs.
|
|
610
|
+
|
|
599
611
|
`defineDalFixtures(...)` is the package-owned DAL seeding model. It gives every
|
|
600
612
|
suite a deterministic `fixtureScope` with:
|
|
601
613
|
|
|
@@ -3,7 +3,11 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
|
|
5
5
|
import { resolveProductDir } from "../../../../../config/index.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
cleanupOrphanedLocalInfrastructure,
|
|
8
|
+
destroyRuntimeDatabase,
|
|
9
|
+
prepareDatabaseRuntime,
|
|
10
|
+
} from "../../../../../database/index.mjs";
|
|
7
11
|
import { forceRefreshSourceSchemaCache } from "../../../../../database/schema-source.mjs";
|
|
8
12
|
import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
|
|
9
13
|
import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
|
|
@@ -28,10 +32,12 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
|
|
|
28
32
|
const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
|
|
29
33
|
const logRegistry = createRunLogRegistry(productDir);
|
|
30
34
|
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
35
|
+
const preparedStateDirs = [];
|
|
31
36
|
try {
|
|
32
37
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
33
38
|
if (config.name === resolvedTarget.name) break;
|
|
34
39
|
if (config.testkit.database) {
|
|
40
|
+
preparedStateDirs.push(config.stateDir);
|
|
35
41
|
await prepareDatabaseRuntime(config, { reporter, logRegistry, setupRegistry });
|
|
36
42
|
}
|
|
37
43
|
}
|
|
@@ -54,6 +60,10 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
|
|
|
54
60
|
reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
|
|
55
61
|
};
|
|
56
62
|
} finally {
|
|
63
|
+
for (const stateDir of preparedStateDirs.reverse()) {
|
|
64
|
+
await destroyRuntimeDatabase({ productDir, stateDir }).catch(() => {});
|
|
65
|
+
}
|
|
66
|
+
await cleanupOrphanedLocalInfrastructure(productDir).catch(() => {});
|
|
57
67
|
logRegistry.closeAll();
|
|
58
68
|
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
59
69
|
}
|
|
@@ -3,7 +3,11 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
|
|
5
5
|
import { resolveProductDir } from "../../../../../config/index.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
cleanupOrphanedLocalInfrastructure,
|
|
8
|
+
destroyRuntimeDatabase,
|
|
9
|
+
prepareDatabaseRuntime,
|
|
10
|
+
} from "../../../../../database/index.mjs";
|
|
7
11
|
import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
|
|
8
12
|
import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
|
|
9
13
|
import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
|
|
@@ -27,9 +31,11 @@ export async function executeDatabaseSchemaVerifyOperation(options = {}) {
|
|
|
27
31
|
const reporter = createRunReporter({ outputMode: options.debug ? "debug" : "compact" });
|
|
28
32
|
const logRegistry = createRunLogRegistry(productDir);
|
|
29
33
|
const setupRegistry = createSetupOperationRegistry({ logRegistry });
|
|
34
|
+
const preparedStateDirs = [];
|
|
30
35
|
try {
|
|
31
36
|
for (const config of topologicallySortConfigs(resolvedConfigs)) {
|
|
32
37
|
if (config.testkit.database) {
|
|
38
|
+
preparedStateDirs.push(config.stateDir);
|
|
33
39
|
await prepareDatabaseRuntime(config, {
|
|
34
40
|
reporter,
|
|
35
41
|
logRegistry,
|
|
@@ -44,6 +50,10 @@ export async function executeDatabaseSchemaVerifyOperation(options = {}) {
|
|
|
44
50
|
service: target.name,
|
|
45
51
|
};
|
|
46
52
|
} finally {
|
|
53
|
+
for (const stateDir of preparedStateDirs.reverse()) {
|
|
54
|
+
await destroyRuntimeDatabase({ productDir, stateDir }).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
await cleanupOrphanedLocalInfrastructure(productDir).catch(() => {});
|
|
47
57
|
logRegistry.closeAll();
|
|
48
58
|
fs.rmSync(runtimeRoot, { recursive: true, force: true });
|
|
49
59
|
}
|
|
@@ -243,8 +243,9 @@ function buildSessionBundle({ actorNames, contract, env, primaryActor, topology
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
function resolveActorSession({ actorDefinition, actorIndex, contract, env }) {
|
|
246
|
+
const resolvedActorDefinition = materializeActorDefinition(actorDefinition, env);
|
|
246
247
|
const context = {
|
|
247
|
-
actor:
|
|
248
|
+
actor: resolvedActorDefinition.actorName,
|
|
248
249
|
actorIndex,
|
|
249
250
|
env,
|
|
250
251
|
};
|
|
@@ -253,13 +254,13 @@ function resolveActorSession({ actorDefinition, actorIndex, contract, env }) {
|
|
|
253
254
|
try {
|
|
254
255
|
runProfileRequest({
|
|
255
256
|
requestConfig: {
|
|
256
|
-
body: () => buildSignupBody(
|
|
257
|
+
body: () => buildSignupBody(resolvedActorDefinition),
|
|
257
258
|
expect: contract.signup.expect,
|
|
258
259
|
method: "POST",
|
|
259
260
|
path: contract.signup.path,
|
|
260
261
|
},
|
|
261
262
|
context: { ...context, phase: "signup" },
|
|
262
|
-
label: `auth.fixture signup for actor "${
|
|
263
|
+
label: `auth.fixture signup for actor "${resolvedActorDefinition.actorName}"`,
|
|
263
264
|
});
|
|
264
265
|
} catch {
|
|
265
266
|
// Provisioning is best-effort. Some apps report duplicate-account races as 500s
|
|
@@ -269,28 +270,57 @@ function resolveActorSession({ actorDefinition, actorIndex, contract, env }) {
|
|
|
269
270
|
|
|
270
271
|
const response = runProfileRequest({
|
|
271
272
|
requestConfig: {
|
|
272
|
-
body: () => buildLoginBody(
|
|
273
|
+
body: () => buildLoginBody(resolvedActorDefinition),
|
|
273
274
|
expect: contract.login.expect,
|
|
274
275
|
method: "POST",
|
|
275
276
|
path: contract.login.path,
|
|
276
277
|
},
|
|
277
278
|
context: { ...context, phase: "login" },
|
|
278
|
-
label: `auth.fixture login for actor "${
|
|
279
|
+
label: `auth.fixture login for actor "${resolvedActorDefinition.actorName}"`,
|
|
279
280
|
});
|
|
280
281
|
const session = extractSessionData(response, contract.session);
|
|
281
282
|
|
|
282
283
|
return {
|
|
283
284
|
actorIndex,
|
|
284
|
-
actorName:
|
|
285
|
-
email:
|
|
286
|
-
name:
|
|
287
|
-
organizationKey:
|
|
288
|
-
organizationName:
|
|
285
|
+
actorName: resolvedActorDefinition.actorName,
|
|
286
|
+
email: resolvedActorDefinition.email,
|
|
287
|
+
name: resolvedActorDefinition.name,
|
|
288
|
+
organizationKey: resolvedActorDefinition.organizationKey,
|
|
289
|
+
organizationName: resolvedActorDefinition.organizationName,
|
|
289
290
|
session,
|
|
290
291
|
...session,
|
|
291
292
|
};
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
function materializeActorDefinition(actorDefinition, env = {}) {
|
|
296
|
+
if (!actorDefinition.emailGenerated) return actorDefinition;
|
|
297
|
+
const suffix = buildRuntimeEmailSuffix(env.rawEnv || {});
|
|
298
|
+
if (!suffix) return actorDefinition;
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
...actorDefinition,
|
|
302
|
+
email: appendEmailSuffix(actorDefinition.email, suffix),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildRuntimeEmailSuffix(rawEnv = {}) {
|
|
307
|
+
if (rawEnv.TESTKIT_ACTIVE !== "1") return "";
|
|
308
|
+
return [
|
|
309
|
+
rawEnv.TESTKIT_LEASE_ID,
|
|
310
|
+
rawEnv.TESTKIT_TEST_FILE,
|
|
311
|
+
rawEnv.TESTKIT_SCENARIO_SEED,
|
|
312
|
+
]
|
|
313
|
+
.map(slugifyToken)
|
|
314
|
+
.filter(Boolean)
|
|
315
|
+
.join(".");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function appendEmailSuffix(email, suffix) {
|
|
319
|
+
const [local, domain] = String(email).split("@");
|
|
320
|
+
if (!local || !domain || !suffix) return email;
|
|
321
|
+
return `${local}.${suffix}@${domain}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
294
324
|
function buildSignupBody(actorDefinition) {
|
|
295
325
|
return {
|
|
296
326
|
email: actorDefinition.email,
|
|
@@ -421,6 +451,7 @@ function finalizeActorEntries({ namespace, actorEntries, orgs, defaultOrgKey, de
|
|
|
421
451
|
email:
|
|
422
452
|
normalizeOptionalString(config.email) ||
|
|
423
453
|
buildGeneratedEmail(namespace, actorName),
|
|
454
|
+
emailGenerated: !normalizeOptionalString(config.email),
|
|
424
455
|
loginBody: normalizePlainObject(config.loginBody, `${label} actor "${actorName}" loginBody`),
|
|
425
456
|
name:
|
|
426
457
|
normalizeOptionalString(config.name) ||
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import {
|
|
3
|
+
LOCAL_ADMIN_DB,
|
|
4
|
+
LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS,
|
|
5
|
+
LOCAL_ADMIN_QUERY_RETRY_MS,
|
|
6
|
+
LOCAL_DROP_DATABASE_POLL_INTERVAL_MS,
|
|
7
|
+
LOCAL_DROP_DATABASE_TIMEOUT_MS,
|
|
8
|
+
LOCAL_POLL_INTERVAL_MS,
|
|
9
|
+
LOCAL_READY_TIMEOUT_MS,
|
|
10
|
+
} from "./constants.mjs";
|
|
11
|
+
import {
|
|
12
|
+
buildDatabaseUrl,
|
|
13
|
+
escapeIdentifier,
|
|
14
|
+
escapeSqlLiteral,
|
|
15
|
+
} from "./naming.mjs";
|
|
16
|
+
|
|
17
|
+
export { buildDatabaseUrl };
|
|
18
|
+
|
|
19
|
+
export async function waitForResourcePostgresReady(infra) {
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
while (Date.now() - startedAt < LOCAL_READY_TIMEOUT_MS) {
|
|
22
|
+
try {
|
|
23
|
+
await runAdminQuery(infra, ["-tAc", "SELECT 1"]);
|
|
24
|
+
return;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (!isTransientAdminQueryConnectionError(error)) throw error;
|
|
27
|
+
await sleep(LOCAL_POLL_INTERVAL_MS);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw new Error(`Timed out waiting for Postgres resource "${infra.resourceName}" at ${infra.host}:${infra.port}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function databaseExists(infra, dbName) {
|
|
35
|
+
const result = await runAdminQuery(infra, [
|
|
36
|
+
"-tAc",
|
|
37
|
+
`SELECT 1 FROM pg_database WHERE datname = '${escapeSqlLiteral(dbName)}'`,
|
|
38
|
+
]);
|
|
39
|
+
return result.trim() === "1";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createEmptyDatabase(infra, dbName) {
|
|
43
|
+
await runAdminQuery(infra, ["-c", `CREATE DATABASE "${escapeIdentifier(dbName)}"`]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function cloneDatabaseFromTemplate(infra, dbName, templateDbName) {
|
|
47
|
+
await runAdminQuery(infra, [
|
|
48
|
+
"-c",
|
|
49
|
+
`CREATE DATABASE "${escapeIdentifier(dbName)}" TEMPLATE "${escapeIdentifier(templateDbName)}"`,
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function dropDatabaseIfExists(infra, dbName) {
|
|
54
|
+
await dropDatabaseWithForceOrDrain(infra, dbName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function dropDatabaseWithForceOrDrain(infra, dbName, hooks = {}) {
|
|
58
|
+
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
59
|
+
const databaseExistsFn = hooks.databaseExists || databaseExists;
|
|
60
|
+
const sleepFn = hooks.sleep || sleep;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await runAdminQueryFn(infra, [
|
|
64
|
+
"-c",
|
|
65
|
+
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}" WITH (FORCE)`,
|
|
66
|
+
]);
|
|
67
|
+
return;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!isUnsupportedForceDropError(error)) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!(await databaseExistsFn(infra, dbName))) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let restoreConnections = false;
|
|
79
|
+
try {
|
|
80
|
+
await runAdminQueryFn(infra, [
|
|
81
|
+
"-c",
|
|
82
|
+
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS false`,
|
|
83
|
+
]);
|
|
84
|
+
restoreConnections = true;
|
|
85
|
+
await waitForDatabaseConnectionsToDrain(infra, dbName, {
|
|
86
|
+
runAdminQuery: runAdminQueryFn,
|
|
87
|
+
sleep: sleepFn,
|
|
88
|
+
});
|
|
89
|
+
await runAdminQueryFn(infra, [
|
|
90
|
+
"-c",
|
|
91
|
+
`DROP DATABASE IF EXISTS "${escapeIdentifier(dbName)}"`,
|
|
92
|
+
]);
|
|
93
|
+
restoreConnections = false;
|
|
94
|
+
} finally {
|
|
95
|
+
if (restoreConnections && (await databaseExistsFn(infra, dbName))) {
|
|
96
|
+
await runAdminQueryFn(infra, [
|
|
97
|
+
"-c",
|
|
98
|
+
`ALTER DATABASE "${escapeIdentifier(dbName)}" WITH ALLOW_CONNECTIONS true`,
|
|
99
|
+
]).catch(() => {
|
|
100
|
+
// Best-effort restoration for failed fallback drops.
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function waitForDatabaseConnectionsToDrain(infra, dbName, hooks = {}) {
|
|
107
|
+
const runAdminQueryFn = hooks.runAdminQuery || runAdminQuery;
|
|
108
|
+
const sleepFn = hooks.sleep || sleep;
|
|
109
|
+
const now = hooks.now || Date.now;
|
|
110
|
+
const timeoutMs = hooks.timeoutMs ?? LOCAL_DROP_DATABASE_TIMEOUT_MS;
|
|
111
|
+
const deadline = now() + timeoutMs;
|
|
112
|
+
|
|
113
|
+
while (true) {
|
|
114
|
+
await terminateDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
115
|
+
const remainingConnections = await countDatabaseConnections(infra, dbName, runAdminQueryFn);
|
|
116
|
+
if (remainingConnections === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (now() >= deadline) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Timed out waiting for database "${dbName}" connections to close (${remainingConnections} remaining)`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
await sleepFn(LOCAL_DROP_DATABASE_POLL_INTERVAL_MS);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function runAdminQuery(infra, args) {
|
|
129
|
+
const command = infra.containerName ? "docker" : "psql";
|
|
130
|
+
const commandArgs = infra.containerName
|
|
131
|
+
? [
|
|
132
|
+
"exec",
|
|
133
|
+
"-e",
|
|
134
|
+
`PGPASSWORD=${infra.password}`,
|
|
135
|
+
infra.containerName,
|
|
136
|
+
"psql",
|
|
137
|
+
"-v",
|
|
138
|
+
"ON_ERROR_STOP=1",
|
|
139
|
+
"-U",
|
|
140
|
+
infra.user,
|
|
141
|
+
"-d",
|
|
142
|
+
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
143
|
+
...args,
|
|
144
|
+
]
|
|
145
|
+
: [
|
|
146
|
+
"-v",
|
|
147
|
+
"ON_ERROR_STOP=1",
|
|
148
|
+
"-h",
|
|
149
|
+
infra.host,
|
|
150
|
+
"-p",
|
|
151
|
+
String(infra.port),
|
|
152
|
+
"-U",
|
|
153
|
+
infra.user,
|
|
154
|
+
"-d",
|
|
155
|
+
infra.adminDatabase || LOCAL_ADMIN_DB,
|
|
156
|
+
...args,
|
|
157
|
+
];
|
|
158
|
+
const commandOptions = infra.containerName
|
|
159
|
+
? {}
|
|
160
|
+
: {
|
|
161
|
+
env: {
|
|
162
|
+
...process.env,
|
|
163
|
+
PGPASSWORD: infra.password,
|
|
164
|
+
...(infra.sslMode ? { PGSSLMODE: infra.sslMode } : {}),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
const startedAt = Date.now();
|
|
168
|
+
while (true) {
|
|
169
|
+
try {
|
|
170
|
+
const { stdout } = await execa(command, commandArgs, commandOptions);
|
|
171
|
+
return stdout;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (
|
|
174
|
+
Date.now() - startedAt >= LOCAL_ADMIN_QUERY_RETRY_MS ||
|
|
175
|
+
!isTransientAdminQueryConnectionError(error)
|
|
176
|
+
) {
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
await sleep(LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function isTransientAdminQueryConnectionError(error) {
|
|
185
|
+
const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
|
|
186
|
+
return (
|
|
187
|
+
text.includes('connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed') ||
|
|
188
|
+
text.includes("No such file or directory") ||
|
|
189
|
+
text.includes("the database system is starting up") ||
|
|
190
|
+
text.includes("could not connect to server")
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
195
|
+
await runAdminQueryFn(infra, [
|
|
196
|
+
"-c",
|
|
197
|
+
[
|
|
198
|
+
"SELECT pg_terminate_backend(pid)",
|
|
199
|
+
"FROM pg_stat_activity",
|
|
200
|
+
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
201
|
+
].join(" "),
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function countDatabaseConnections(infra, dbName, runAdminQueryFn) {
|
|
206
|
+
const result = await runAdminQueryFn(infra, [
|
|
207
|
+
"-tAc",
|
|
208
|
+
[
|
|
209
|
+
"SELECT COUNT(*)",
|
|
210
|
+
"FROM pg_stat_activity",
|
|
211
|
+
`WHERE datname = '${escapeSqlLiteral(dbName)}' AND pid <> pg_backend_pid();`,
|
|
212
|
+
].join(" "),
|
|
213
|
+
]);
|
|
214
|
+
return Number.parseInt(result.trim(), 10) || 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isUnsupportedForceDropError(error) {
|
|
218
|
+
const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
|
|
219
|
+
return (
|
|
220
|
+
text.includes('syntax error at or near "WITH"') ||
|
|
221
|
+
text.includes('option "force"')
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sleep(ms) {
|
|
226
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
227
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
TESTKIT_PRODUCT_DIR_LABEL,
|
|
5
|
+
TESTKIT_PRODUCT_ID_LABEL,
|
|
6
|
+
TESTKIT_RESOURCE_KIND_LABEL,
|
|
7
|
+
TESTKIT_SCOPE_LABEL,
|
|
8
|
+
buildProductIdentity,
|
|
9
|
+
dockerContainerSummary,
|
|
10
|
+
listLegacyTestkitPostgresContainers,
|
|
11
|
+
listManagedDockerContainers,
|
|
12
|
+
removeDockerContainer,
|
|
13
|
+
stopDockerContainer,
|
|
14
|
+
} from "../ownership/docker.mjs";
|
|
15
|
+
import { buildContainerName } from "./naming.mjs";
|
|
16
|
+
import {
|
|
17
|
+
hasRemainingLocalArtifacts,
|
|
18
|
+
readStateValue,
|
|
19
|
+
visitDirs,
|
|
20
|
+
} from "./state.mjs";
|
|
21
|
+
|
|
22
|
+
export async function cleanupLocalPostgresDockerResources(options = {}) {
|
|
23
|
+
const product = buildProductIdentity(options.productDir || process.cwd());
|
|
24
|
+
const managed = await listManagedDockerContainers();
|
|
25
|
+
const targets = [];
|
|
26
|
+
const kept = [];
|
|
27
|
+
const stopped = [];
|
|
28
|
+
|
|
29
|
+
for (const container of managed) {
|
|
30
|
+
if (!isManagedLocalPostgresContainer(container)) continue;
|
|
31
|
+
const classification = classifyManagedLocalPostgresContainer(container, product, options);
|
|
32
|
+
if (classification.action === "remove") {
|
|
33
|
+
targets.push({ ...container, action: "remove", reason: classification.reason, legacy: false });
|
|
34
|
+
} else if (classification.action === "stop") {
|
|
35
|
+
targets.push({ ...container, action: "stop", reason: classification.reason, legacy: false });
|
|
36
|
+
} else if (classification.reason) {
|
|
37
|
+
kept.push({ ...container, reason: classification.reason, legacy: false });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.includeLegacy) {
|
|
42
|
+
const currentLegacyName = buildContainerName(product.dir);
|
|
43
|
+
for (const container of await listLegacyTestkitPostgresContainers()) {
|
|
44
|
+
if (!options.global && container.name !== currentLegacyName) continue;
|
|
45
|
+
targets.push({ ...container, action: "remove", reason: "legacy-unlabelled", legacy: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!options.dryRun) {
|
|
50
|
+
for (const container of targets) {
|
|
51
|
+
if (container.action === "stop") {
|
|
52
|
+
await stopDockerContainer(container.name || container.id);
|
|
53
|
+
stopped.push(container);
|
|
54
|
+
} else {
|
|
55
|
+
await removeDockerContainer(container.name || container.id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
removed: options.dryRun ? [] : targets.filter((container) => container.action !== "stop"),
|
|
62
|
+
stopped: options.dryRun ? [] : stopped,
|
|
63
|
+
targets,
|
|
64
|
+
kept,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
|
|
69
|
+
const isStop = entry.action === "stop";
|
|
70
|
+
const action = dryRun
|
|
71
|
+
? isStop ? "Would stop" : "Would remove"
|
|
72
|
+
: isStop ? "Stopped" : "Removed";
|
|
73
|
+
const legacy = entry.legacy ? " legacy" : "";
|
|
74
|
+
return `${action}${legacy} database resource ${dockerContainerSummary(entry)} reason=${entry.reason}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function classifyManagedLocalPostgresContainer(container, product, options) {
|
|
78
|
+
const labels = container.labels || {};
|
|
79
|
+
const containerProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
|
|
80
|
+
const containerProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
|
|
81
|
+
const currentProduct = containerProductId === product.id;
|
|
82
|
+
|
|
83
|
+
if (options.force && currentProduct) {
|
|
84
|
+
return { action: "remove", reason: "destroy-current-product" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!options.global && !currentProduct) {
|
|
88
|
+
return { action: "keep", reason: "different-product" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (currentProduct) {
|
|
92
|
+
if (!hasRemainingLocalArtifacts(product.dir, readStateValue)) {
|
|
93
|
+
return { action: "remove", reason: "current-product-no-local-artifacts" };
|
|
94
|
+
}
|
|
95
|
+
if (!localArtifactsReferenceContainer(product.dir, container.name)) {
|
|
96
|
+
return { action: "remove", reason: "current-product-unreferenced" };
|
|
97
|
+
}
|
|
98
|
+
if (shouldStopIdleLocalPostgresContainer(container, product.dir, options)) {
|
|
99
|
+
return { action: "stop", reason: "current-product-idle" };
|
|
100
|
+
}
|
|
101
|
+
return { action: "keep", reason: "current-product-referenced" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.global && containerProductDir && !fs.existsSync(containerProductDir)) {
|
|
105
|
+
return { action: "remove", reason: "product-dir-missing" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (options.global && containerProductDir && !hasRemainingLocalArtifacts(containerProductDir, readStateValue)) {
|
|
109
|
+
return { action: "remove", reason: "product-has-no-local-artifacts" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shouldStopIdleLocalPostgresContainer(container, productDir, options) {
|
|
116
|
+
return Boolean(
|
|
117
|
+
options.stopIdle &&
|
|
118
|
+
container.running &&
|
|
119
|
+
productDir &&
|
|
120
|
+
fs.existsSync(productDir) &&
|
|
121
|
+
!hasActiveProductRuntime(productDir)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isManagedLocalPostgresContainer(container) {
|
|
126
|
+
const labels = container.labels || {};
|
|
127
|
+
return (
|
|
128
|
+
labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-container" &&
|
|
129
|
+
labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function localArtifactsReferenceContainer(productDir, containerName) {
|
|
134
|
+
if (!containerName) return false;
|
|
135
|
+
const root = path.join(productDir, ".testkit");
|
|
136
|
+
let referenced = false;
|
|
137
|
+
visitDirs(root, (dir) => {
|
|
138
|
+
if (referenced) return;
|
|
139
|
+
for (const fileName of ["container_name", "local_container_name"]) {
|
|
140
|
+
if (readStateValue(path.join(dir, fileName)) === containerName) {
|
|
141
|
+
referenced = true;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return referenced;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasActiveProductRuntime(productDir) {
|
|
150
|
+
return hasActiveRunManifest(productDir) || hasActiveLocalEnvironmentManifest(productDir);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hasActiveRunManifest(productDir) {
|
|
154
|
+
const runsDir = path.join(productDir, ".testkit", "_runs");
|
|
155
|
+
for (const manifest of readManifestFiles(runsDir)) {
|
|
156
|
+
if (isPidRunning(manifest.pid)) return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hasActiveLocalEnvironmentManifest(productDir) {
|
|
162
|
+
const environmentsDir = path.join(productDir, ".testkit", "environments");
|
|
163
|
+
if (!fs.existsSync(environmentsDir)) return false;
|
|
164
|
+
for (const entry of fs.readdirSync(environmentsDir, { withFileTypes: true })) {
|
|
165
|
+
if (!entry.isDirectory()) continue;
|
|
166
|
+
const manifest = readJsonFile(path.join(environmentsDir, entry.name, "manifest.json"));
|
|
167
|
+
if (!manifest) continue;
|
|
168
|
+
if (manifest.driver === "kiln" && manifest.status === "running") return true;
|
|
169
|
+
if ((manifest.services || []).some((service) => isPidRunning(service.processGroupId || service.pid))) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readManifestFiles(dir) {
|
|
177
|
+
if (!fs.existsSync(dir)) return [];
|
|
178
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
179
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
180
|
+
.map((entry) => readJsonFile(path.join(dir, entry.name)))
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readJsonFile(filePath) {
|
|
185
|
+
try {
|
|
186
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isPidRunning(pid) {
|
|
193
|
+
const numericPid = Number(pid);
|
|
194
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
|
|
195
|
+
try {
|
|
196
|
+
process.kill(numericPid, 0);
|
|
197
|
+
return true;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return error?.code === "EPERM";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
2
|
+
export const LOCAL_USER = "testkit";
|
|
3
|
+
export const LOCAL_PASSWORD = "testkit";
|
|
4
|
+
export const LOCAL_ADMIN_DB = "postgres";
|
|
5
|
+
export const LOCAL_READY_TIMEOUT_MS = 60_000;
|
|
6
|
+
export const LOCAL_POLL_INTERVAL_MS = 1_000;
|
|
7
|
+
export const LOCAL_ADMIN_QUERY_RETRY_MS = 15_000;
|
|
8
|
+
export const LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS = 250;
|
|
9
|
+
export const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
|
|
10
|
+
export const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
|