@elench/testkit 0.1.135 → 0.1.137
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 +6 -0
- package/lib/cli/renderers/status/text.mjs +14 -0
- package/lib/config/index.mjs +154 -0
- package/lib/config/validation.mjs +9 -0
- package/lib/config-api/index.d.ts +53 -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 +3 -1
- package/lib/docker-compat/matrix.mjs +135 -0
- package/lib/env/index.d.ts +1 -0
- package/lib/env/index.mjs +5 -1
- package/lib/kiln/client.mjs +100 -0
- package/lib/local/kiln-driver.mjs +544 -0
- package/lib/local/lifecycle.mjs +289 -0
- package/lib/local/orchestrator.mjs +343 -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 +39 -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/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 +8 -5
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
driver: options.driver || "host",
|
|
23
|
+
name,
|
|
24
|
+
productDir,
|
|
25
|
+
pid: process.pid,
|
|
26
|
+
status: "starting",
|
|
27
|
+
startedAt: new Date().toISOString(),
|
|
28
|
+
target: options.target || null,
|
|
29
|
+
runtimeDir: options.runtimeDir || null,
|
|
30
|
+
runtimeStateDirs: [],
|
|
31
|
+
portOffset: options.portOffset || 0,
|
|
32
|
+
data: options.data || "reuse",
|
|
33
|
+
services: [],
|
|
34
|
+
};
|
|
35
|
+
const managedProcesses = new Set();
|
|
36
|
+
|
|
37
|
+
function persist() {
|
|
38
|
+
fs.mkdirSync(environmentDir, { recursive: true });
|
|
39
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(state, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mutate(mutator) {
|
|
43
|
+
mutator(state);
|
|
44
|
+
persist();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const api = {
|
|
48
|
+
name,
|
|
49
|
+
environmentDir,
|
|
50
|
+
manifestPath,
|
|
51
|
+
signal: abortController.signal,
|
|
52
|
+
isStopRequested() {
|
|
53
|
+
return abortController.signal.aborted;
|
|
54
|
+
},
|
|
55
|
+
markRunning(extra = {}) {
|
|
56
|
+
mutate((draft) => {
|
|
57
|
+
draft.status = "running";
|
|
58
|
+
Object.assign(draft, extra);
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
requestStop(reason = "stopped") {
|
|
62
|
+
if (!abortController.signal.aborted) {
|
|
63
|
+
abortController.abort(new Error(`testkit local environment stopped (${reason})`));
|
|
64
|
+
}
|
|
65
|
+
for (const entry of managedProcesses) {
|
|
66
|
+
try {
|
|
67
|
+
entry.terminate?.();
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort stop only.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
mutate((draft) => {
|
|
73
|
+
draft.status = "stopping";
|
|
74
|
+
draft.stopReason = reason;
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
registerProcess(child, terminate) {
|
|
78
|
+
if (!child) return;
|
|
79
|
+
managedProcesses.add({ child, terminate });
|
|
80
|
+
},
|
|
81
|
+
unregisterProcess(childPid) {
|
|
82
|
+
for (const entry of managedProcesses) {
|
|
83
|
+
if (entry.child?.pid === childPid) managedProcesses.delete(entry);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
registerService(config, child, cwd, terminate) {
|
|
87
|
+
api.registerProcess(child, terminate);
|
|
88
|
+
mutate((draft) => {
|
|
89
|
+
draft.services = draft.services.filter((service) => service.pid !== child.pid);
|
|
90
|
+
draft.services.push({
|
|
91
|
+
serviceName: config.name,
|
|
92
|
+
runtimeLabel: config.runtimeLabel,
|
|
93
|
+
command: config.testkit.local?.start || null,
|
|
94
|
+
cwd,
|
|
95
|
+
pid: child.pid,
|
|
96
|
+
processGroupId: child.pid,
|
|
97
|
+
baseUrl: config.testkit.local?.baseUrl || null,
|
|
98
|
+
readyUrl: config.testkit.local?.readyUrl || null,
|
|
99
|
+
ports: collectConfigPorts(config),
|
|
100
|
+
startedAt: new Date().toISOString(),
|
|
101
|
+
});
|
|
102
|
+
for (const runtimeConfig of config.testkit?.templateContext?.stateDirByService?.values?.() || []) {
|
|
103
|
+
pushUnique(draft.runtimeStateDirs, runtimeConfig);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
unregisterService(childPid) {
|
|
108
|
+
api.unregisterProcess(childPid);
|
|
109
|
+
mutate((draft) => {
|
|
110
|
+
draft.services = draft.services.filter((service) => service.pid !== childPid);
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
setRuntimeState(runtimeConfigs) {
|
|
114
|
+
mutate((draft) => {
|
|
115
|
+
draft.runtimeStateDirs = [...new Set(runtimeConfigs.map((config) => config.stateDir).filter(Boolean))];
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
removeManifest() {
|
|
119
|
+
fs.rmSync(manifestPath, { force: true });
|
|
120
|
+
pruneEmptyDir(environmentDir);
|
|
121
|
+
pruneEmptyDir(path.dirname(environmentDir));
|
|
122
|
+
},
|
|
123
|
+
persist,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
api.persist();
|
|
127
|
+
return api;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readLocalEnvironmentManifest(productDir, name) {
|
|
131
|
+
return readManifest(path.join(getEnvironmentDir(productDir, name), "manifest.json"));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function listLocalEnvironmentManifests(productDir) {
|
|
135
|
+
const root = path.join(productDir, ENVIRONMENTS_DIRNAME);
|
|
136
|
+
if (!fs.existsSync(root)) return [];
|
|
137
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
138
|
+
.filter((entry) => entry.isDirectory())
|
|
139
|
+
.map((entry) => readManifest(path.join(root, entry.name, "manifest.json")))
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function stopLocalEnvironment(productDir, name, options = {}) {
|
|
145
|
+
const manifest = readLocalEnvironmentManifest(productDir, name);
|
|
146
|
+
if (!manifest) return null;
|
|
147
|
+
for (const service of [...(manifest.services || [])].reverse()) {
|
|
148
|
+
await terminateOwnedProcess(service);
|
|
149
|
+
}
|
|
150
|
+
if (options.removeRuntimeState) {
|
|
151
|
+
for (const stateDir of [...new Set(manifest.runtimeStateDirs || [])].sort((a, b) => b.length - a.length)) {
|
|
152
|
+
await destroyRuntimeDatabase({ productDir, stateDir });
|
|
153
|
+
}
|
|
154
|
+
if (manifest.runtimeDir) fs.rmSync(manifest.runtimeDir, { recursive: true, force: true });
|
|
155
|
+
const environmentDir = getEnvironmentDir(productDir, name);
|
|
156
|
+
fs.rmSync(environmentDir, { recursive: true, force: true });
|
|
157
|
+
pruneEmptyDir(path.dirname(environmentDir));
|
|
158
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
159
|
+
return manifest;
|
|
160
|
+
}
|
|
161
|
+
const environmentDir = getEnvironmentDir(productDir, name);
|
|
162
|
+
fs.mkdirSync(environmentDir, { recursive: true });
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
path.join(environmentDir, "manifest.json"),
|
|
165
|
+
`${JSON.stringify({
|
|
166
|
+
...manifest,
|
|
167
|
+
status: "stopped",
|
|
168
|
+
stoppedAt: new Date().toISOString(),
|
|
169
|
+
services: [],
|
|
170
|
+
}, null, 2)}\n`
|
|
171
|
+
);
|
|
172
|
+
await cleanupOrphanedLocalInfrastructure(productDir);
|
|
173
|
+
return manifest;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function cleanupStaleLocalEnvironments(productDir) {
|
|
177
|
+
const cleaned = [];
|
|
178
|
+
for (const manifest of listLocalEnvironmentManifests(productDir)) {
|
|
179
|
+
if (isLocalEnvironmentActive(manifest)) continue;
|
|
180
|
+
await stopLocalEnvironment(productDir, manifest.name, { removeRuntimeState: false });
|
|
181
|
+
cleaned.push(manifest);
|
|
182
|
+
}
|
|
183
|
+
return cleaned;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function findLocalPortOwner(productDir, { host, port }) {
|
|
187
|
+
for (const manifest of listLocalEnvironmentManifests(productDir)) {
|
|
188
|
+
for (const service of manifest.services || []) {
|
|
189
|
+
for (const socket of service.ports || []) {
|
|
190
|
+
if (normalizeHost(socket.host) === normalizeHost(host) && Number(socket.port) === Number(port)) {
|
|
191
|
+
return {
|
|
192
|
+
manifest,
|
|
193
|
+
service,
|
|
194
|
+
active: isLocalServiceActive(service),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isLocalEnvironmentActive(manifest) {
|
|
204
|
+
if (manifest.driver === "kiln") return manifest.status === "running";
|
|
205
|
+
return (manifest.services || []).some(isLocalServiceActive);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatLocalEnvironmentSummary(manifest) {
|
|
209
|
+
const ports = [
|
|
210
|
+
...new Set(
|
|
211
|
+
(manifest.services || []).flatMap((service) =>
|
|
212
|
+
(service.ports || []).map((socket) => `${socket.host}:${socket.port}`)
|
|
213
|
+
)
|
|
214
|
+
),
|
|
215
|
+
];
|
|
216
|
+
return `${manifest.name}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isLocalServiceActive(service) {
|
|
220
|
+
return isPidRunning(Number(service.processGroupId || service.pid));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function terminateOwnedProcess(service) {
|
|
224
|
+
const pid = Number(service.processGroupId || service.pid);
|
|
225
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
226
|
+
if (!isPidRunning(pid)) return;
|
|
227
|
+
killProcessTree(pid, "SIGTERM");
|
|
228
|
+
const exited = await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
229
|
+
if (!exited) {
|
|
230
|
+
killProcessTree(pid, "SIGKILL");
|
|
231
|
+
await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
236
|
+
const startedAt = Date.now();
|
|
237
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
238
|
+
if (!isPidRunning(pid)) return true;
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
240
|
+
}
|
|
241
|
+
return !isPidRunning(pid);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readManifest(filePath) {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
247
|
+
return parsed?.kind === "local" && parsed?.name ? parsed : null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectConfigPorts(config) {
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
const ports = [];
|
|
256
|
+
for (const rawUrl of [config.testkit.local?.baseUrl, config.testkit.local?.readyUrl]) {
|
|
257
|
+
if (!rawUrl) continue;
|
|
258
|
+
try {
|
|
259
|
+
const parsed = new URL(rawUrl);
|
|
260
|
+
const host = normalizeHost(parsed.hostname);
|
|
261
|
+
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
262
|
+
const key = `${host}:${port}`;
|
|
263
|
+
if (seen.has(key)) continue;
|
|
264
|
+
seen.add(key);
|
|
265
|
+
ports.push({ host, port });
|
|
266
|
+
} catch {
|
|
267
|
+
// Startup validation handles malformed URLs.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return ports;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeHost(host) {
|
|
274
|
+
if (!host || host === "localhost" || host === "::1") return "127.0.0.1";
|
|
275
|
+
return host;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function pushUnique(list, value) {
|
|
279
|
+
if (value && !list.includes(value)) list.push(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pruneEmptyDir(dir) {
|
|
283
|
+
if (!dir || !fs.existsSync(dir)) return;
|
|
284
|
+
try {
|
|
285
|
+
if (fs.readdirSync(dir).length === 0) fs.rmSync(dir, { recursive: true, force: true });
|
|
286
|
+
} catch {
|
|
287
|
+
// Best-effort cleanup only.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { loadConfigContext } from "../config/index.mjs";
|
|
5
|
+
import { prepareDatabaseRuntime } from "../database/index.mjs";
|
|
6
|
+
import { prepareRuntimeServices } from "../runner/runtime-preparation.mjs";
|
|
7
|
+
import { resolveRuntimeConfigs } from "../runner/planning.mjs";
|
|
8
|
+
import { startLocalServices } from "../runner/services.mjs";
|
|
9
|
+
import { buildExecutionEnv, resolveRuntimeInstanceConfigs } from "../runner/template.mjs";
|
|
10
|
+
import { readDatabaseUrl } from "../runner/state-io.mjs";
|
|
11
|
+
import {
|
|
12
|
+
buildKilnLocalStatus,
|
|
13
|
+
kilnLocalDown,
|
|
14
|
+
kilnLocalEnv,
|
|
15
|
+
kilnLocalLogs,
|
|
16
|
+
kilnLocalShell,
|
|
17
|
+
kilnLocalUp,
|
|
18
|
+
} from "./kiln-driver.mjs";
|
|
19
|
+
import {
|
|
20
|
+
cleanupStaleLocalEnvironments,
|
|
21
|
+
createLocalEnvironmentLifecycle,
|
|
22
|
+
getEnvironmentDir,
|
|
23
|
+
isLocalEnvironmentActive,
|
|
24
|
+
listLocalEnvironmentManifests,
|
|
25
|
+
readLocalEnvironmentManifest,
|
|
26
|
+
stopLocalEnvironment,
|
|
27
|
+
} from "./lifecycle.mjs";
|
|
28
|
+
|
|
29
|
+
export async function localUp(options = {}) {
|
|
30
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
31
|
+
const environment = resolveEnvironment(context, options);
|
|
32
|
+
if (environment.driver === "kiln") {
|
|
33
|
+
return kilnLocalUp(context, environment, options);
|
|
34
|
+
}
|
|
35
|
+
const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
36
|
+
if (existing && isLocalEnvironmentActive(existing)) {
|
|
37
|
+
throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
|
|
38
|
+
}
|
|
39
|
+
if (existing && environment.data === "rebuild") {
|
|
40
|
+
await stopLocalEnvironment(context.productDir, environment.name, { removeRuntimeState: true });
|
|
41
|
+
} else {
|
|
42
|
+
await cleanupStaleLocalEnvironments(context.productDir);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runtimeDir = path.join(getEnvironmentDir(context.productDir, environment.name), "runtime");
|
|
46
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, runtimeDir);
|
|
49
|
+
const lifecycle = createLocalEnvironmentLifecycle(context.productDir, environment.name, {
|
|
50
|
+
target: environment.target,
|
|
51
|
+
runtimeDir,
|
|
52
|
+
data: environment.data,
|
|
53
|
+
portOffset: environment.portOffset,
|
|
54
|
+
});
|
|
55
|
+
lifecycle.setRuntimeState(runtimeConfigs);
|
|
56
|
+
|
|
57
|
+
const extraEnv = environment.env || {};
|
|
58
|
+
try {
|
|
59
|
+
for (const config of runtimeConfigs) {
|
|
60
|
+
await prepareDatabaseRuntime(config, { ...(options.setupOptions || {}) });
|
|
61
|
+
}
|
|
62
|
+
await prepareRuntimeServices(runtimeConfigs, { ...(options.setupOptions || {}), extraEnv });
|
|
63
|
+
await startLocalServices(runtimeConfigs, lifecycle, {
|
|
64
|
+
extraEnv,
|
|
65
|
+
reporter: options.reporter,
|
|
66
|
+
serviceLogFiles(config) {
|
|
67
|
+
const logsDir = path.join(getEnvironmentDir(context.productDir, environment.name), "logs");
|
|
68
|
+
return {
|
|
69
|
+
stdoutPath: path.join(logsDir, `${config.name}.stdout.log`),
|
|
70
|
+
stderrPath: path.join(logsDir, `${config.name}.stderr.log`),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
lifecycle.markRunning({
|
|
75
|
+
target: environment.target,
|
|
76
|
+
runtimeDir,
|
|
77
|
+
runtimeServices: runtimeConfigs.map((config) => config.name),
|
|
78
|
+
endpoints: collectEndpoints(runtimeConfigs),
|
|
79
|
+
});
|
|
80
|
+
return buildLocalStatus(context.productDir, environment.name);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
lifecycle.requestStop("startup-failed");
|
|
83
|
+
await stopLocalEnvironment(context.productDir, environment.name, { removeRuntimeState: environment.data === "rebuild" });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function localDown(options = {}) {
|
|
89
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
90
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
91
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
92
|
+
if (manifest?.driver === "kiln") {
|
|
93
|
+
await kilnLocalDown(context, name, { destroyState: Boolean(options.destroyState) });
|
|
94
|
+
return buildKilnLocalStatus(context.productDir, name);
|
|
95
|
+
}
|
|
96
|
+
const stopped = await stopLocalEnvironment(context.productDir, name, {
|
|
97
|
+
removeRuntimeState: Boolean(options.destroyState),
|
|
98
|
+
});
|
|
99
|
+
return stopped
|
|
100
|
+
? buildLocalStatus(context.productDir, name)
|
|
101
|
+
: { name, exists: false, lines: [`Local Environment: ${name}`, " not found"] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function localStatus(options = {}) {
|
|
105
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
106
|
+
if (options.name) {
|
|
107
|
+
const name = normalizeEnvironmentName(options.name);
|
|
108
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
109
|
+
return manifest?.driver === "kiln" ? buildKilnLocalStatus(context.productDir, name) : buildLocalStatus(context.productDir, name);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
productDir: context.productDir,
|
|
113
|
+
environments: listLocalEnvironmentManifests(context.productDir).map((manifest) =>
|
|
114
|
+
manifest.driver === "kiln" ? buildKilnLocalStatus(context.productDir, manifest.name) : buildLocalStatus(context.productDir, manifest.name)
|
|
115
|
+
),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function localEnv(options = {}) {
|
|
120
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
121
|
+
const environment = resolveEnvironment(context, options);
|
|
122
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
123
|
+
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
124
|
+
if (manifest.driver === "kiln") return kilnLocalEnv(context, environment.name, options);
|
|
125
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
126
|
+
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
127
|
+
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
128
|
+
return {
|
|
129
|
+
name: environment.name,
|
|
130
|
+
service: serviceConfig.name,
|
|
131
|
+
env,
|
|
132
|
+
lines: Object.entries(env)
|
|
133
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
134
|
+
.map(([key, value]) => `${key}=${shellQuote(String(value))}`),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function localLogs(options = {}) {
|
|
139
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
140
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
141
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
142
|
+
if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
|
|
143
|
+
if (manifest.driver === "kiln") return kilnLocalLogs(context, name, options);
|
|
144
|
+
const logsDir = path.join(getEnvironmentDir(context.productDir, name), "logs");
|
|
145
|
+
const serviceNames = options.service
|
|
146
|
+
? [options.service]
|
|
147
|
+
: [...new Set((manifest.services || []).map((service) => service.serviceName))].sort();
|
|
148
|
+
const lines = [];
|
|
149
|
+
for (const serviceName of serviceNames) {
|
|
150
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
151
|
+
const filePath = path.join(logsDir, `${serviceName}.${stream}.log`);
|
|
152
|
+
if (!fs.existsSync(filePath)) continue;
|
|
153
|
+
lines.push(`==> ${serviceName}.${stream} <==`);
|
|
154
|
+
lines.push(...tailLines(fs.readFileSync(filePath, "utf8"), options.lines || 200));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { name, lines };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function localShell(options = {}) {
|
|
161
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
162
|
+
const environment = resolveEnvironment(context, options);
|
|
163
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
164
|
+
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
165
|
+
if (manifest.driver === "kiln") return kilnLocalShell(context, environment.name, options);
|
|
166
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
167
|
+
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
168
|
+
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
169
|
+
const command = options.command || [];
|
|
170
|
+
if (command.length === 0) throw new Error("testkit local shell requires a command after --");
|
|
171
|
+
return runShellCommand(command, {
|
|
172
|
+
cwd: serviceConfig.productDir,
|
|
173
|
+
env: { ...process.env, ...env },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
|
|
178
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
179
|
+
const target = configMap.get(environment.target);
|
|
180
|
+
if (!target) {
|
|
181
|
+
const available = allConfigs.map((config) => config.name).join(", ");
|
|
182
|
+
throw new Error(`Service "${environment.target}" not found. Available: ${available}`);
|
|
183
|
+
}
|
|
184
|
+
const runtimeConfigs = resolveRuntimeConfigs(target, configMap);
|
|
185
|
+
return resolveRuntimeInstanceConfigs(runtimeConfigs, "local", runtimeDir, {
|
|
186
|
+
graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
|
|
187
|
+
portOffset: environment.portOffset || 0,
|
|
188
|
+
publicHost: environment.publicHost || null,
|
|
189
|
+
}).map((config) => applyLocalEnvironmentConfig(config, environment));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildLocalStatus(productDir, name) {
|
|
193
|
+
const manifest = readLocalEnvironmentManifest(productDir, name);
|
|
194
|
+
if (!manifest) return { name, exists: false, active: false, services: [], lines: [`Local Environment: ${name}`, " not found"] };
|
|
195
|
+
const active = isLocalEnvironmentActive(manifest);
|
|
196
|
+
const services = (manifest.services || []).map((service) => ({
|
|
197
|
+
serviceName: service.serviceName,
|
|
198
|
+
pid: service.pid,
|
|
199
|
+
active: serviceIsActive(service),
|
|
200
|
+
baseUrl: service.baseUrl,
|
|
201
|
+
readyUrl: service.readyUrl,
|
|
202
|
+
ports: service.ports || [],
|
|
203
|
+
}));
|
|
204
|
+
const lines = [
|
|
205
|
+
`Local Environment: ${name}`,
|
|
206
|
+
` status: ${active ? "running" : manifest.status || "stopped"}`,
|
|
207
|
+
` target: ${manifest.target || "unknown"}`,
|
|
208
|
+
` state: ${path.relative(productDir, getEnvironmentDir(productDir, name))}`,
|
|
209
|
+
];
|
|
210
|
+
if (services.length === 0) {
|
|
211
|
+
lines.push(" services: none");
|
|
212
|
+
} else {
|
|
213
|
+
lines.push(" services:");
|
|
214
|
+
for (const service of services) {
|
|
215
|
+
const url = service.baseUrl ? ` url=${service.baseUrl}` : "";
|
|
216
|
+
lines.push(` ${service.serviceName}: ${service.active ? "running" : "stale"} pid=${service.pid}${url}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
name,
|
|
221
|
+
exists: true,
|
|
222
|
+
active,
|
|
223
|
+
manifest,
|
|
224
|
+
services,
|
|
225
|
+
lines,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function resolveEnvironment(context, options = {}) {
|
|
230
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
231
|
+
const configured = context.environments[name] || null;
|
|
232
|
+
const target = options.service || configured?.target || inferDefaultTarget(context.configs);
|
|
233
|
+
const requestedDriver = process.env.TESTKIT_LOCAL_DRIVER || options.driver || configured?.driver || "host";
|
|
234
|
+
const driver = process.env.TESTKIT_KILN_INNER === "1" ? "host" : requestedDriver;
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
kind: "local",
|
|
238
|
+
driver,
|
|
239
|
+
target,
|
|
240
|
+
data: options.rebuild ? "rebuild" : options.reset ? "reset" : configured?.data || "reuse",
|
|
241
|
+
portOffset: Number(options.portOffset ?? configured?.portOffset ?? 0),
|
|
242
|
+
env: { ...(configured?.env || {}) },
|
|
243
|
+
kiln: configured?.kiln || null,
|
|
244
|
+
publicHost: process.env.TESTKIT_PUBLIC_HOST || configured?.publicHost || null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function inferDefaultTarget(configs) {
|
|
249
|
+
if (configs.length === 1) return configs[0].name;
|
|
250
|
+
const runnable = configs.filter((config) => config.testkit.local);
|
|
251
|
+
if (runnable.length === 1) return runnable[0].name;
|
|
252
|
+
const available = configs.map((config) => config.name).join(", ");
|
|
253
|
+
throw new Error(`Multiple services available. Configure environments.local.target or pass --service. Available: ${available}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function applyLocalEnvironmentConfig(config, environment) {
|
|
257
|
+
const database = config.testkit.database
|
|
258
|
+
? {
|
|
259
|
+
...config.testkit.database,
|
|
260
|
+
reset: environment.data === "reuse" ? false : true,
|
|
261
|
+
}
|
|
262
|
+
: config.testkit.database;
|
|
263
|
+
return {
|
|
264
|
+
...config,
|
|
265
|
+
testkit: {
|
|
266
|
+
...config.testkit,
|
|
267
|
+
mode: "local",
|
|
268
|
+
localEnvironmentName: environment.name,
|
|
269
|
+
database,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resolveService(runtimeConfigs, serviceName) {
|
|
275
|
+
const serviceConfig = runtimeConfigs.find((config) => config.name === serviceName);
|
|
276
|
+
if (!serviceConfig) {
|
|
277
|
+
const available = runtimeConfigs.map((config) => config.name).join(", ");
|
|
278
|
+
throw new Error(`Service "${serviceName}" is not in this local environment. Available: ${available}`);
|
|
279
|
+
}
|
|
280
|
+
return serviceConfig;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildServiceEnv(config, extraEnv = {}) {
|
|
284
|
+
const env = buildExecutionEnv(config, { ...(config.testkit.local?.env || {}), ...(extraEnv || {}) }, process.env);
|
|
285
|
+
const databaseUrl = readDatabaseUrl(config.stateDir);
|
|
286
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
287
|
+
return env;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function collectEndpoints(runtimeConfigs) {
|
|
291
|
+
return Object.fromEntries(
|
|
292
|
+
runtimeConfigs
|
|
293
|
+
.filter((config) => config.testkit.local?.baseUrl)
|
|
294
|
+
.map((config) => [config.name, config.testkit.local.baseUrl])
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeEnvironmentName(name) {
|
|
299
|
+
const normalized = String(name || "").trim();
|
|
300
|
+
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
301
|
+
throw new Error(`Environment name "${name}" must contain only letters, numbers, underscores, or dashes`);
|
|
302
|
+
}
|
|
303
|
+
return normalized;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function serviceIsActive(service) {
|
|
307
|
+
try {
|
|
308
|
+
process.kill(Number(service.processGroupId || service.pid), 0);
|
|
309
|
+
return true;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return error?.code === "EPERM";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function tailLines(text, count) {
|
|
316
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-count);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function shellQuote(value) {
|
|
320
|
+
if (/^[A-Za-z0-9_/:.,@%+=-]*$/.test(value)) return value;
|
|
321
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function runShellCommand(command, options) {
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
const child = spawn(command[0], command.slice(1), {
|
|
327
|
+
cwd: options.cwd,
|
|
328
|
+
env: options.env,
|
|
329
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
330
|
+
});
|
|
331
|
+
let stdout = "";
|
|
332
|
+
let stderr = "";
|
|
333
|
+
child.stdout.on("data", (chunk) => {
|
|
334
|
+
stdout += chunk.toString();
|
|
335
|
+
});
|
|
336
|
+
child.stderr.on("data", (chunk) => {
|
|
337
|
+
stderr += chunk.toString();
|
|
338
|
+
});
|
|
339
|
+
child.on("close", (code) => {
|
|
340
|
+
resolve({ code: code ?? 0, stdout, stderr });
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|