@elench/testkit 0.1.135 → 0.1.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +38 -0
  2. package/lib/cli/commands/local/down.mjs +37 -0
  3. package/lib/cli/commands/local/env.mjs +31 -0
  4. package/lib/cli/commands/local/logs.mjs +35 -0
  5. package/lib/cli/commands/local/shell.mjs +49 -0
  6. package/lib/cli/commands/local/status.mjs +34 -0
  7. package/lib/cli/commands/local/up.mjs +39 -0
  8. package/lib/cli/entrypoint.mjs +6 -0
  9. package/lib/cli/renderers/status/text.mjs +14 -0
  10. package/lib/config/index.mjs +117 -0
  11. package/lib/config/validation.mjs +9 -0
  12. package/lib/config-api/index.d.ts +22 -0
  13. package/lib/config-api/index.mjs +14 -0
  14. package/lib/database/fingerprint.mjs +13 -33
  15. package/lib/database/index.mjs +27 -12
  16. package/lib/database/schema-source.mjs +3 -1
  17. package/lib/env/index.d.ts +1 -0
  18. package/lib/env/index.mjs +5 -1
  19. package/lib/local/lifecycle.mjs +287 -0
  20. package/lib/local/orchestrator.mjs +314 -0
  21. package/lib/repo/fingerprint-policy.mjs +145 -0
  22. package/lib/repo/state.mjs +46 -44
  23. package/lib/runner/maintenance.mjs +23 -0
  24. package/lib/runner/processes.mjs +45 -6
  25. package/lib/runner/readiness.mjs +12 -1
  26. package/lib/runner/runtime-preparation.mjs +10 -5
  27. package/lib/runner/services.mjs +24 -18
  28. package/lib/runner/status-model.mjs +27 -0
  29. package/lib/runner/template.mjs +6 -1
  30. package/node_modules/@elench/next-analysis/package.json +1 -1
  31. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  32. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  33. package/node_modules/@elench/ts-analysis/package.json +1 -1
  34. package/package.json +6 -5
@@ -1,7 +1,9 @@
1
1
  import crypto from "crypto";
2
- import fs from "fs";
3
- import path from "path";
4
2
  import { resolveServiceCwd } from "../config/paths.mjs";
3
+ import {
4
+ appendFingerprintPathToHash,
5
+ normalizeFingerprintPolicy,
6
+ } from "../repo/fingerprint-policy.mjs";
5
7
  import { collectTemplateInputs } from "./template-steps.mjs";
6
8
  import { appendSourceSchemaCacheToHash } from "./schema-source.mjs";
7
9
 
@@ -11,6 +13,7 @@ const LOCAL_USER = "testkit";
11
13
  export async function computeTemplateFingerprint(config, options = {}) {
12
14
  const hash = crypto.createHash("sha256");
13
15
  const db = config.testkit.database;
16
+ const fingerprints = normalizeFingerprintPolicy(config.testkit.fingerprints);
14
17
  hash.update(JSON.stringify({
15
18
  provider: db.provider,
16
19
  selectedBackend: db.selectedBackend,
@@ -20,48 +23,25 @@ export async function computeTemplateFingerprint(config, options = {}) {
20
23
  }));
21
24
 
22
25
  for (const envFile of config.testkit.envFiles || []) {
23
- appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
26
+ appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile), fingerprints);
24
27
  }
25
28
  for (const input of collectTemplateInputs(config.productDir, db.template || {})) {
26
- appendResolvedInputToHash(hash, config.productDir, input);
29
+ appendResolvedInputToHash(hash, config.productDir, input, fingerprints);
27
30
  }
28
31
  appendSourceSchemaCacheToHash(hash, config, options.sourceSchemaState || null);
29
32
 
30
33
  return hash.digest("hex");
31
34
  }
32
35
 
33
- export function appendInputToHash(hash, productDir, input) {
36
+ export function appendInputToHash(hash, productDir, input, fingerprints = {}) {
34
37
  const absPath = resolveServiceCwd(productDir, input);
35
- appendResolvedInputToHash(hash, productDir, absPath);
38
+ appendResolvedInputToHash(hash, productDir, absPath, normalizeFingerprintPolicy(fingerprints));
36
39
  }
37
40
 
38
- function appendResolvedInputToHash(hash, productDir, absPath) {
39
- if (!fs.existsSync(absPath)) {
40
- hash.update(`missing:${path.relative(productDir, absPath)}`);
41
- return;
42
- }
43
-
44
- const stat = fs.statSync(absPath);
45
- if (stat.isDirectory()) {
46
- hash.update(`dir:${path.relative(productDir, absPath)}`);
47
- for (const entry of fs.readdirSync(absPath).sort()) {
48
- if (entry === ".git" || entry === "node_modules" || entry === ".testkit") continue;
49
- appendResolvedInputToHash(hash, productDir, path.join(absPath, entry));
50
- }
51
- return;
52
- }
53
-
54
- appendFileToHash(hash, productDir, absPath);
41
+ function appendResolvedInputToHash(hash, productDir, absPath, fingerprints) {
42
+ appendFingerprintPathToHash(hash, productDir, absPath, fingerprints);
55
43
  }
56
44
 
57
- export function appendFileToHash(hash, productDir, absPath) {
58
- if (!fs.existsSync(absPath)) {
59
- hash.update(`missing:${path.relative(productDir, absPath)}`);
60
- return;
61
- }
62
- const stat = fs.statSync(absPath);
63
- if (!stat.isFile()) return;
64
-
65
- hash.update(`file:${path.relative(productDir, absPath)}:${stat.size}:${stat.mtimeMs}`);
66
- hash.update(fs.readFileSync(absPath));
45
+ export function appendFileToHash(hash, productDir, absPath, fingerprints = {}) {
46
+ appendFingerprintPathToHash(hash, productDir, absPath, normalizeFingerprintPolicy(fingerprints));
67
47
  }
@@ -2,8 +2,6 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { execa } from "execa";
4
4
  import {
5
- appendFileToHash as appendFileToHashModel,
6
- appendInputToHash as appendInputToHashModel,
7
5
  computeTemplateFingerprint as computeTemplateFingerprintModel,
8
6
  } from "./fingerprint.mjs";
9
7
  import {
@@ -44,6 +42,8 @@ const LOCAL_PASSWORD = "testkit";
44
42
  const LOCAL_ADMIN_DB = "postgres";
45
43
  const LOCAL_READY_TIMEOUT_MS = 60_000;
46
44
  const LOCAL_POLL_INTERVAL_MS = 1_000;
45
+ const LOCAL_ADMIN_QUERY_RETRY_MS = 15_000;
46
+ const LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS = 250;
47
47
  const LOCAL_DROP_DATABASE_TIMEOUT_MS = 15_000;
48
48
  const LOCAL_DROP_DATABASE_POLL_INTERVAL_MS = 250;
49
49
 
@@ -603,8 +603,31 @@ async function runAdminQuery(infra, args) {
603
603
  LOCAL_ADMIN_DB,
604
604
  ...args,
605
605
  ];
606
- const { stdout } = await execa("docker", commandArgs);
607
- return stdout;
606
+ const startedAt = Date.now();
607
+ while (true) {
608
+ try {
609
+ const { stdout } = await execa("docker", commandArgs);
610
+ return stdout;
611
+ } catch (error) {
612
+ if (
613
+ Date.now() - startedAt >= LOCAL_ADMIN_QUERY_RETRY_MS ||
614
+ !isTransientAdminQueryConnectionError(error)
615
+ ) {
616
+ throw error;
617
+ }
618
+ await sleep(LOCAL_ADMIN_QUERY_RETRY_INTERVAL_MS);
619
+ }
620
+ }
621
+ }
622
+
623
+ export function isTransientAdminQueryConnectionError(error) {
624
+ const text = `${error?.shortMessage || ""}\n${error?.stderr || ""}\n${error?.stdout || ""}\n${error?.message || ""}`;
625
+ return (
626
+ text.includes('connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed') ||
627
+ text.includes("No such file or directory") ||
628
+ text.includes("the database system is starting up") ||
629
+ text.includes("could not connect to server")
630
+ );
608
631
  }
609
632
 
610
633
  async function terminateDatabaseConnections(infra, dbName, runAdminQueryFn) {
@@ -642,14 +665,6 @@ async function computeTemplateFingerprint(config, options = {}) {
642
665
  return computeTemplateFingerprintModel(config, options);
643
666
  }
644
667
 
645
- function appendInputToHash(hash, productDir, input) {
646
- return appendInputToHashModel(hash, productDir, input);
647
- }
648
-
649
- function appendFileToHash(hash, productDir, absPath) {
650
- return appendFileToHashModel(hash, productDir, absPath);
651
- }
652
-
653
668
  function buildDatabaseUrl(infra, dbName) {
654
669
  return buildDatabaseUrlModel(infra, dbName);
655
670
  }
@@ -29,7 +29,9 @@ export function getSourceSchemaMetadataPath(config, options = {}) {
29
29
  }
30
30
 
31
31
  export function resolveSourceSchemaCacheLocation(config, options = {}) {
32
- const repoState = options.repoState || collectRepoState(config.productDir);
32
+ const repoState = options.repoState || collectRepoState(config.productDir, {
33
+ fingerprints: config.testkit?.fingerprints,
34
+ });
33
35
  const rootDir = getSourceSchemaRootDir(config);
34
36
  const cacheDir = path.join(rootDir, ...repoState.cacheKey.split("/").map(sanitizePathSegment));
35
37
  return {
@@ -30,6 +30,7 @@ export interface PostgresConnectionEnvValue {
30
30
  }
31
31
 
32
32
  export declare function isManagedRuntime(env?: NodeJS.ProcessEnv): boolean;
33
+ export declare function managedRuntimeMode(env?: NodeJS.ProcessEnv): "test" | "local" | string | null;
33
34
  export declare function shouldLoadDotenv(env?: NodeJS.ProcessEnv): boolean;
34
35
  export declare function loadDotenvFiles(options?: LoadDotenvFilesOptions): {
35
36
  loaded: string[];
package/lib/env/index.mjs CHANGED
@@ -9,6 +9,10 @@ export function isManagedRuntime(env = process.env) {
9
9
  return env?.TESTKIT_ACTIVE === "1";
10
10
  }
11
11
 
12
+ export function managedRuntimeMode(env = process.env) {
13
+ return isManagedRuntime(env) ? env?.TESTKIT_MODE || "test" : null;
14
+ }
15
+
12
16
  export function shouldLoadDotenv(env = process.env) {
13
17
  return env?.NODE_ENV !== "production" && !isManagedRuntime(env);
14
18
  }
@@ -59,7 +63,7 @@ export function assertLocalDatabaseUrl(env = process.env, consumer = "This comma
59
63
  if (!LOCAL_DATABASE_PROTOCOLS.has(parsed.protocol) || !LOCAL_DATABASE_HOSTS.has(parsed.hostname)) {
60
64
  const location = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
61
65
  throw new Error(
62
- `${consumer} testkit runs require a local PostgreSQL DATABASE_URL. Refusing ${location}.`
66
+ `${consumer} managed Testkit runtime requires a local PostgreSQL DATABASE_URL. Refusing ${location}.`
63
67
  );
64
68
  }
65
69
  }
@@ -0,0 +1,287 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
4
+ import { killProcessTree } from "../runner/processes.mjs";
5
+ import { isPidRunning } from "../runner/lifecycle.mjs";
6
+
7
+ const SCHEMA_VERSION = 1;
8
+ const ENVIRONMENTS_DIRNAME = path.join(".testkit", "environments");
9
+ const TERMINATION_TIMEOUT_MS = 5_000;
10
+
11
+ export function getEnvironmentDir(productDir, name) {
12
+ return path.join(productDir, ENVIRONMENTS_DIRNAME, name);
13
+ }
14
+
15
+ export function createLocalEnvironmentLifecycle(productDir, name, options = {}) {
16
+ const environmentDir = getEnvironmentDir(productDir, name);
17
+ const manifestPath = path.join(environmentDir, "manifest.json");
18
+ const abortController = new AbortController();
19
+ const state = {
20
+ schemaVersion: SCHEMA_VERSION,
21
+ kind: "local",
22
+ name,
23
+ productDir,
24
+ pid: process.pid,
25
+ status: "starting",
26
+ startedAt: new Date().toISOString(),
27
+ target: options.target || null,
28
+ runtimeDir: options.runtimeDir || null,
29
+ runtimeStateDirs: [],
30
+ portOffset: options.portOffset || 0,
31
+ data: options.data || "reuse",
32
+ services: [],
33
+ };
34
+ const managedProcesses = new Set();
35
+
36
+ function persist() {
37
+ fs.mkdirSync(environmentDir, { recursive: true });
38
+ fs.writeFileSync(manifestPath, `${JSON.stringify(state, null, 2)}\n`);
39
+ }
40
+
41
+ function mutate(mutator) {
42
+ mutator(state);
43
+ persist();
44
+ }
45
+
46
+ const api = {
47
+ name,
48
+ environmentDir,
49
+ manifestPath,
50
+ signal: abortController.signal,
51
+ isStopRequested() {
52
+ return abortController.signal.aborted;
53
+ },
54
+ markRunning(extra = {}) {
55
+ mutate((draft) => {
56
+ draft.status = "running";
57
+ Object.assign(draft, extra);
58
+ });
59
+ },
60
+ requestStop(reason = "stopped") {
61
+ if (!abortController.signal.aborted) {
62
+ abortController.abort(new Error(`testkit local environment stopped (${reason})`));
63
+ }
64
+ for (const entry of managedProcesses) {
65
+ try {
66
+ entry.terminate?.();
67
+ } catch {
68
+ // Best-effort stop only.
69
+ }
70
+ }
71
+ mutate((draft) => {
72
+ draft.status = "stopping";
73
+ draft.stopReason = reason;
74
+ });
75
+ },
76
+ registerProcess(child, terminate) {
77
+ if (!child) return;
78
+ managedProcesses.add({ child, terminate });
79
+ },
80
+ unregisterProcess(childPid) {
81
+ for (const entry of managedProcesses) {
82
+ if (entry.child?.pid === childPid) managedProcesses.delete(entry);
83
+ }
84
+ },
85
+ registerService(config, child, cwd, terminate) {
86
+ api.registerProcess(child, terminate);
87
+ mutate((draft) => {
88
+ draft.services = draft.services.filter((service) => service.pid !== child.pid);
89
+ draft.services.push({
90
+ serviceName: config.name,
91
+ runtimeLabel: config.runtimeLabel,
92
+ command: config.testkit.local?.start || null,
93
+ cwd,
94
+ pid: child.pid,
95
+ processGroupId: child.pid,
96
+ baseUrl: config.testkit.local?.baseUrl || null,
97
+ readyUrl: config.testkit.local?.readyUrl || null,
98
+ ports: collectConfigPorts(config),
99
+ startedAt: new Date().toISOString(),
100
+ });
101
+ for (const runtimeConfig of config.testkit?.templateContext?.stateDirByService?.values?.() || []) {
102
+ pushUnique(draft.runtimeStateDirs, runtimeConfig);
103
+ }
104
+ });
105
+ },
106
+ unregisterService(childPid) {
107
+ api.unregisterProcess(childPid);
108
+ mutate((draft) => {
109
+ draft.services = draft.services.filter((service) => service.pid !== childPid);
110
+ });
111
+ },
112
+ setRuntimeState(runtimeConfigs) {
113
+ mutate((draft) => {
114
+ draft.runtimeStateDirs = [...new Set(runtimeConfigs.map((config) => config.stateDir).filter(Boolean))];
115
+ });
116
+ },
117
+ removeManifest() {
118
+ fs.rmSync(manifestPath, { force: true });
119
+ pruneEmptyDir(environmentDir);
120
+ pruneEmptyDir(path.dirname(environmentDir));
121
+ },
122
+ persist,
123
+ };
124
+
125
+ api.persist();
126
+ return api;
127
+ }
128
+
129
+ export function readLocalEnvironmentManifest(productDir, name) {
130
+ return readManifest(path.join(getEnvironmentDir(productDir, name), "manifest.json"));
131
+ }
132
+
133
+ export function listLocalEnvironmentManifests(productDir) {
134
+ const root = path.join(productDir, ENVIRONMENTS_DIRNAME);
135
+ if (!fs.existsSync(root)) return [];
136
+ return fs.readdirSync(root, { withFileTypes: true })
137
+ .filter((entry) => entry.isDirectory())
138
+ .map((entry) => readManifest(path.join(root, entry.name, "manifest.json")))
139
+ .filter(Boolean)
140
+ .sort((left, right) => left.name.localeCompare(right.name));
141
+ }
142
+
143
+ export async function stopLocalEnvironment(productDir, name, options = {}) {
144
+ const manifest = readLocalEnvironmentManifest(productDir, name);
145
+ if (!manifest) return null;
146
+ for (const service of [...(manifest.services || [])].reverse()) {
147
+ await terminateOwnedProcess(service);
148
+ }
149
+ if (options.removeRuntimeState) {
150
+ for (const stateDir of [...new Set(manifest.runtimeStateDirs || [])].sort((a, b) => b.length - a.length)) {
151
+ await destroyRuntimeDatabase({ productDir, stateDir });
152
+ }
153
+ if (manifest.runtimeDir) fs.rmSync(manifest.runtimeDir, { recursive: true, force: true });
154
+ const environmentDir = getEnvironmentDir(productDir, name);
155
+ fs.rmSync(environmentDir, { recursive: true, force: true });
156
+ pruneEmptyDir(path.dirname(environmentDir));
157
+ await cleanupOrphanedLocalInfrastructure(productDir);
158
+ return manifest;
159
+ }
160
+ const environmentDir = getEnvironmentDir(productDir, name);
161
+ fs.mkdirSync(environmentDir, { recursive: true });
162
+ fs.writeFileSync(
163
+ path.join(environmentDir, "manifest.json"),
164
+ `${JSON.stringify({
165
+ ...manifest,
166
+ status: "stopped",
167
+ stoppedAt: new Date().toISOString(),
168
+ services: [],
169
+ }, null, 2)}\n`
170
+ );
171
+ await cleanupOrphanedLocalInfrastructure(productDir);
172
+ return manifest;
173
+ }
174
+
175
+ export async function cleanupStaleLocalEnvironments(productDir) {
176
+ const cleaned = [];
177
+ for (const manifest of listLocalEnvironmentManifests(productDir)) {
178
+ if (isLocalEnvironmentActive(manifest)) continue;
179
+ await stopLocalEnvironment(productDir, manifest.name, { removeRuntimeState: false });
180
+ cleaned.push(manifest);
181
+ }
182
+ return cleaned;
183
+ }
184
+
185
+ export function findLocalPortOwner(productDir, { host, port }) {
186
+ for (const manifest of listLocalEnvironmentManifests(productDir)) {
187
+ for (const service of manifest.services || []) {
188
+ for (const socket of service.ports || []) {
189
+ if (normalizeHost(socket.host) === normalizeHost(host) && Number(socket.port) === Number(port)) {
190
+ return {
191
+ manifest,
192
+ service,
193
+ active: isLocalServiceActive(service),
194
+ };
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ export function isLocalEnvironmentActive(manifest) {
203
+ return (manifest.services || []).some(isLocalServiceActive);
204
+ }
205
+
206
+ export function formatLocalEnvironmentSummary(manifest) {
207
+ const ports = [
208
+ ...new Set(
209
+ (manifest.services || []).flatMap((service) =>
210
+ (service.ports || []).map((socket) => `${socket.host}:${socket.port}`)
211
+ )
212
+ ),
213
+ ];
214
+ return `${manifest.name}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`;
215
+ }
216
+
217
+ function isLocalServiceActive(service) {
218
+ return isPidRunning(Number(service.processGroupId || service.pid));
219
+ }
220
+
221
+ async function terminateOwnedProcess(service) {
222
+ const pid = Number(service.processGroupId || service.pid);
223
+ if (!Number.isInteger(pid) || pid <= 0) return;
224
+ if (!isPidRunning(pid)) return;
225
+ killProcessTree(pid, "SIGTERM");
226
+ const exited = await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
227
+ if (!exited) {
228
+ killProcessTree(pid, "SIGKILL");
229
+ await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
230
+ }
231
+ }
232
+
233
+ async function waitForPidExit(pid, timeoutMs) {
234
+ const startedAt = Date.now();
235
+ while (Date.now() - startedAt < timeoutMs) {
236
+ if (!isPidRunning(pid)) return true;
237
+ await new Promise((resolve) => setTimeout(resolve, 100));
238
+ }
239
+ return !isPidRunning(pid);
240
+ }
241
+
242
+ function readManifest(filePath) {
243
+ try {
244
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
245
+ return parsed?.kind === "local" && parsed?.name ? parsed : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function collectConfigPorts(config) {
252
+ const seen = new Set();
253
+ const ports = [];
254
+ for (const rawUrl of [config.testkit.local?.baseUrl, config.testkit.local?.readyUrl]) {
255
+ if (!rawUrl) continue;
256
+ try {
257
+ const parsed = new URL(rawUrl);
258
+ const host = normalizeHost(parsed.hostname);
259
+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
260
+ const key = `${host}:${port}`;
261
+ if (seen.has(key)) continue;
262
+ seen.add(key);
263
+ ports.push({ host, port });
264
+ } catch {
265
+ // Startup validation handles malformed URLs.
266
+ }
267
+ }
268
+ return ports;
269
+ }
270
+
271
+ function normalizeHost(host) {
272
+ if (!host || host === "localhost" || host === "::1") return "127.0.0.1";
273
+ return host;
274
+ }
275
+
276
+ function pushUnique(list, value) {
277
+ if (value && !list.includes(value)) list.push(value);
278
+ }
279
+
280
+ function pruneEmptyDir(dir) {
281
+ if (!dir || !fs.existsSync(dir)) return;
282
+ try {
283
+ if (fs.readdirSync(dir).length === 0) fs.rmSync(dir, { recursive: true, force: true });
284
+ } catch {
285
+ // Best-effort cleanup only.
286
+ }
287
+ }