@elench/testkit 0.1.134 → 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.
- package/README.md +38 -0
- package/lib/cli/commands/local/down.mjs +37 -0
- package/lib/cli/commands/local/env.mjs +31 -0
- package/lib/cli/commands/local/logs.mjs +35 -0
- package/lib/cli/commands/local/shell.mjs +49 -0
- package/lib/cli/commands/local/status.mjs +34 -0
- package/lib/cli/commands/local/up.mjs +39 -0
- package/lib/cli/entrypoint.mjs +12 -4
- package/lib/cli/renderers/status/text.mjs +14 -0
- package/lib/config/index.mjs +117 -0
- package/lib/config/validation.mjs +9 -0
- package/lib/config-api/database-steps.mjs +1 -1
- package/lib/config-api/index.d.ts +22 -0
- package/lib/config-api/index.mjs +14 -0
- package/lib/database/fingerprint.mjs +13 -33
- package/lib/database/index.mjs +27 -12
- package/lib/database/schema-source.mjs +61 -6
- package/lib/env/index.d.ts +1 -0
- package/lib/env/index.mjs +5 -1
- package/lib/local/lifecycle.mjs +287 -0
- package/lib/local/orchestrator.mjs +314 -0
- package/lib/repo/fingerprint-policy.mjs +145 -0
- package/lib/repo/state.mjs +46 -44
- package/lib/runner/maintenance.mjs +23 -0
- package/lib/runner/processes.mjs +45 -6
- package/lib/runner/readiness.mjs +12 -1
- package/lib/runner/runtime-preparation.mjs +10 -5
- package/lib/runner/services.mjs +24 -18
- package/lib/runner/status-model.mjs +27 -0
- package/lib/runner/template.mjs +6 -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 +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/database/index.mjs
CHANGED
|
@@ -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
|
|
607
|
-
|
|
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 {
|
|
@@ -166,6 +168,7 @@ export async function verifyLocalSchemaMatchesSource(config, databaseUrl, state,
|
|
|
166
168
|
return { status: "skipped" };
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
clearSchemaMismatchDiagnosticsForService(config);
|
|
169
172
|
const sourceSchema = readSourceSchemaCacheText(state.cachePath);
|
|
170
173
|
const localSchema = await captureTemplateSnapshotText(config, databaseUrl, {
|
|
171
174
|
reporter: options.reporter || null,
|
|
@@ -174,7 +177,7 @@ export async function verifyLocalSchemaMatchesSource(config, databaseUrl, state,
|
|
|
174
177
|
return { status: "matched" };
|
|
175
178
|
}
|
|
176
179
|
|
|
177
|
-
const diagnostics = await writeSchemaMismatchDiagnostics(config, state
|
|
180
|
+
const diagnostics = await writeSchemaMismatchDiagnostics(config, state, sourceSchema, localSchema);
|
|
178
181
|
return {
|
|
179
182
|
status: "mismatch",
|
|
180
183
|
diagnostics,
|
|
@@ -396,21 +399,73 @@ function readJson(filePath) {
|
|
|
396
399
|
}
|
|
397
400
|
}
|
|
398
401
|
|
|
399
|
-
|
|
400
|
-
|
|
402
|
+
function schemaMismatchDiagnosticsDir(config) {
|
|
403
|
+
return path.join(config.productDir, ".testkit", "results", "schema");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function schemaMismatchDiagnosticsPrefix(config) {
|
|
407
|
+
return `${sanitizePathSegment(config.runtimeLabel || config.name)}__${sanitizePathSegment(config.name)}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function clearSchemaMismatchDiagnosticsForService(config) {
|
|
411
|
+
const dir = schemaMismatchDiagnosticsDir(config);
|
|
412
|
+
if (!fs.existsSync(dir)) return;
|
|
413
|
+
const serviceSegment = sanitizePathSegment(config.name);
|
|
414
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
415
|
+
if (!isSchemaMismatchDiagnosticForService(entry, serviceSegment)) continue;
|
|
416
|
+
fs.rmSync(path.join(dir, entry), { force: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isSchemaMismatchDiagnosticForService(entry, serviceSegment) {
|
|
421
|
+
for (const suffix of [
|
|
422
|
+
"__source-schema.sql",
|
|
423
|
+
"__local-replay-schema.sql",
|
|
424
|
+
"__schema.diff",
|
|
425
|
+
"__schema.meta.json",
|
|
426
|
+
]) {
|
|
427
|
+
if (!entry.endsWith(suffix)) continue;
|
|
428
|
+
return entry.slice(0, -suffix.length).endsWith(`__${serviceSegment}`);
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function writeSchemaMismatchDiagnostics(config, state, sourceSchema, localSchema) {
|
|
434
|
+
const dir = schemaMismatchDiagnosticsDir(config);
|
|
401
435
|
fs.mkdirSync(dir, { recursive: true });
|
|
402
|
-
const prefix =
|
|
436
|
+
const prefix = schemaMismatchDiagnosticsPrefix(config);
|
|
403
437
|
const sourcePath = path.join(dir, `${prefix}__source-schema.sql`);
|
|
404
438
|
const localPath = path.join(dir, `${prefix}__local-replay-schema.sql`);
|
|
405
439
|
const diffPath = path.join(dir, `${prefix}__schema.diff`);
|
|
440
|
+
const metaPath = path.join(dir, `${prefix}__schema.meta.json`);
|
|
406
441
|
fs.writeFileSync(sourcePath, sourceSchema);
|
|
407
442
|
fs.writeFileSync(localPath, localSchema);
|
|
408
443
|
fs.writeFileSync(diffPath, await buildUnifiedDiff(sourcePath, localPath));
|
|
444
|
+
writeJson(metaPath, buildSchemaMismatchMetadata(config, state));
|
|
409
445
|
return {
|
|
410
|
-
sourceCachePath,
|
|
446
|
+
sourceCachePath: state.cachePath,
|
|
411
447
|
sourcePath,
|
|
412
448
|
localPath,
|
|
413
449
|
diffPath,
|
|
450
|
+
metaPath,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function buildSchemaMismatchMetadata(config, state) {
|
|
455
|
+
const sourceMetadata = readJson(state.metadataPath) || {};
|
|
456
|
+
return {
|
|
457
|
+
version: 1,
|
|
458
|
+
createdAt: new Date().toISOString(),
|
|
459
|
+
serviceName: config.name,
|
|
460
|
+
runtimeLabel: config.runtimeLabel || config.name,
|
|
461
|
+
sourceSchema: {
|
|
462
|
+
envName: state.envName || null,
|
|
463
|
+
cacheKey: state.cacheKey || null,
|
|
464
|
+
cacheKind: state.cacheKind || null,
|
|
465
|
+
cachePath: path.relative(config.productDir, state.cachePath),
|
|
466
|
+
refreshedAt: sourceMetadata.refreshedAt || null,
|
|
467
|
+
},
|
|
468
|
+
repo: state.repoState ? summarizeRepoStateForMetadata(state.repoState) : null,
|
|
414
469
|
};
|
|
415
470
|
}
|
|
416
471
|
|
package/lib/env/index.d.ts
CHANGED
|
@@ -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}
|
|
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
|
+
}
|