@elench/testkit 0.1.144 → 0.1.146

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 CHANGED
@@ -61,6 +61,7 @@ npx @elench/testkit --type int --write-status
61
61
  npx @elench/testkit status
62
62
  npx @elench/testkit destroy
63
63
  npx @elench/testkit cleanup
64
+ npx @elench/testkit cleanup --resources --global --dry-run
64
65
 
65
66
  # Local production environment
66
67
  npx @elench/testkit local up
@@ -367,6 +368,15 @@ environment state. Testkit only supports local environments here; it does not
367
368
  copy production data and it refuses managed runtime database URLs that are not
368
369
  loopback PostgreSQL URLs.
369
370
 
371
+ Testkit-owned Docker resources are labelled at creation time so they can be
372
+ found again even if product-local `.testkit` state is deleted. `testkit destroy`
373
+ removes labelled resources for the current product after service state is
374
+ destroyed. `testkit cleanup --resources` cleans stale resources for the current
375
+ product; add `--global` to clean labelled resources whose product directory no
376
+ longer exists, and add `--include-legacy` only when you intentionally want to
377
+ remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
378
+ versions. Use `--dry-run` first when cleaning globally.
379
+
370
380
  `database.template` is the database-side equivalent for reusable template DB
371
381
  state. When `database.sourceSchema` is configured, Testkit treats the configured
372
382
  source database as the schema source of truth. A normal `testkit run` resolves a
@@ -19,6 +19,18 @@ export default class CleanupCommand extends Command {
19
19
  multiple: true,
20
20
  options: ["runtime", "bundles", "assistant", "all"],
21
21
  }),
22
+ resources: Flags.boolean({
23
+ description: "Clean Testkit-owned Docker resources that are stale or orphaned",
24
+ default: false,
25
+ }),
26
+ global: Flags.boolean({
27
+ description: "Find orphaned Testkit-owned resources across all products",
28
+ default: false,
29
+ }),
30
+ "include-legacy": Flags.boolean({
31
+ description: "Include old unlabelled testkit_pg_* Docker containers in resource cleanup",
32
+ default: false,
33
+ }),
22
34
  };
23
35
 
24
36
  async run() {
@@ -9,5 +9,8 @@ export async function executeCleanupOperation(flags = {}) {
9
9
  serviceName: flags.service || null,
10
10
  dryRun: flags["dry-run"],
11
11
  cache: flags.cache || [],
12
+ resources: flags.resources,
13
+ globalResources: flags.global,
14
+ includeLegacyResources: flags["include-legacy"],
12
15
  });
13
16
  }
@@ -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 { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
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 { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
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
  }
@@ -1,12 +1,17 @@
1
1
  import * as runner from "../../../runner/index.mjs";
2
2
  import { loadManagedConfigs } from "../../../app/configs.mjs";
3
+ import { destroyOwnedLocalDatabaseResources } from "../../../database/index.mjs";
3
4
 
4
5
  export async function executeDestroyOperation(flags = {}) {
5
- const { configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
6
+ const { allConfigs, configs } = await loadManagedConfigs({ dir: flags.dir, service: flags.service });
6
7
  const results = [];
7
8
  for (const config of configs) {
8
9
  await runner.destroy(config);
9
10
  results.push({ name: config.name, destroyed: true });
10
11
  }
12
+ const productDir = allConfigs[0]?.productDir || process.cwd();
13
+ if (!flags.service) {
14
+ await destroyOwnedLocalDatabaseResources(productDir);
15
+ }
11
16
  return results;
12
17
  }
@@ -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;