@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
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
cleanupStaleLocalEnvironments,
|
|
13
|
+
createLocalEnvironmentLifecycle,
|
|
14
|
+
getEnvironmentDir,
|
|
15
|
+
isLocalEnvironmentActive,
|
|
16
|
+
listLocalEnvironmentManifests,
|
|
17
|
+
readLocalEnvironmentManifest,
|
|
18
|
+
stopLocalEnvironment,
|
|
19
|
+
} from "./lifecycle.mjs";
|
|
20
|
+
|
|
21
|
+
export async function localUp(options = {}) {
|
|
22
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
23
|
+
const environment = resolveEnvironment(context, options);
|
|
24
|
+
const existing = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
25
|
+
if (existing && isLocalEnvironmentActive(existing)) {
|
|
26
|
+
throw new Error(`Local environment "${environment.name}" is already running. Stop it with testkit local down ${environment.name}.`);
|
|
27
|
+
}
|
|
28
|
+
if (existing && environment.data === "rebuild") {
|
|
29
|
+
await stopLocalEnvironment(context.productDir, environment.name, { removeRuntimeState: true });
|
|
30
|
+
} else {
|
|
31
|
+
await cleanupStaleLocalEnvironments(context.productDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const runtimeDir = path.join(getEnvironmentDir(context.productDir, environment.name), "runtime");
|
|
35
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, runtimeDir);
|
|
38
|
+
const lifecycle = createLocalEnvironmentLifecycle(context.productDir, environment.name, {
|
|
39
|
+
target: environment.target,
|
|
40
|
+
runtimeDir,
|
|
41
|
+
data: environment.data,
|
|
42
|
+
portOffset: environment.portOffset,
|
|
43
|
+
});
|
|
44
|
+
lifecycle.setRuntimeState(runtimeConfigs);
|
|
45
|
+
|
|
46
|
+
const extraEnv = environment.env || {};
|
|
47
|
+
try {
|
|
48
|
+
for (const config of runtimeConfigs) {
|
|
49
|
+
await prepareDatabaseRuntime(config, { ...(options.setupOptions || {}) });
|
|
50
|
+
}
|
|
51
|
+
await prepareRuntimeServices(runtimeConfigs, { ...(options.setupOptions || {}), extraEnv });
|
|
52
|
+
await startLocalServices(runtimeConfigs, lifecycle, {
|
|
53
|
+
extraEnv,
|
|
54
|
+
reporter: options.reporter,
|
|
55
|
+
serviceLogFiles(config) {
|
|
56
|
+
const logsDir = path.join(getEnvironmentDir(context.productDir, environment.name), "logs");
|
|
57
|
+
return {
|
|
58
|
+
stdoutPath: path.join(logsDir, `${config.name}.stdout.log`),
|
|
59
|
+
stderrPath: path.join(logsDir, `${config.name}.stderr.log`),
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
lifecycle.markRunning({
|
|
64
|
+
target: environment.target,
|
|
65
|
+
runtimeDir,
|
|
66
|
+
runtimeServices: runtimeConfigs.map((config) => config.name),
|
|
67
|
+
endpoints: collectEndpoints(runtimeConfigs),
|
|
68
|
+
});
|
|
69
|
+
return buildLocalStatus(context.productDir, environment.name);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
lifecycle.requestStop("startup-failed");
|
|
72
|
+
await stopLocalEnvironment(context.productDir, environment.name, { removeRuntimeState: environment.data === "rebuild" });
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function localDown(options = {}) {
|
|
78
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
79
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
80
|
+
const manifest = await stopLocalEnvironment(context.productDir, name, {
|
|
81
|
+
removeRuntimeState: Boolean(options.destroyState),
|
|
82
|
+
});
|
|
83
|
+
return manifest
|
|
84
|
+
? buildLocalStatus(context.productDir, name)
|
|
85
|
+
: { name, exists: false, lines: [`Local Environment: ${name}`, " not found"] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function localStatus(options = {}) {
|
|
89
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
90
|
+
if (options.name) return buildLocalStatus(context.productDir, normalizeEnvironmentName(options.name));
|
|
91
|
+
return {
|
|
92
|
+
productDir: context.productDir,
|
|
93
|
+
environments: listLocalEnvironmentManifests(context.productDir).map((manifest) =>
|
|
94
|
+
buildLocalStatus(context.productDir, manifest.name)
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function localEnv(options = {}) {
|
|
100
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
101
|
+
const environment = resolveEnvironment(context, options);
|
|
102
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
103
|
+
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
104
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
105
|
+
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
106
|
+
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
107
|
+
return {
|
|
108
|
+
name: environment.name,
|
|
109
|
+
service: serviceConfig.name,
|
|
110
|
+
env,
|
|
111
|
+
lines: Object.entries(env)
|
|
112
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
113
|
+
.map(([key, value]) => `${key}=${shellQuote(String(value))}`),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function localLogs(options = {}) {
|
|
118
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
119
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
120
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
121
|
+
if (!manifest) throw new Error(`Local environment "${name}" has not been started.`);
|
|
122
|
+
const logsDir = path.join(getEnvironmentDir(context.productDir, name), "logs");
|
|
123
|
+
const serviceNames = options.service
|
|
124
|
+
? [options.service]
|
|
125
|
+
: [...new Set((manifest.services || []).map((service) => service.serviceName))].sort();
|
|
126
|
+
const lines = [];
|
|
127
|
+
for (const serviceName of serviceNames) {
|
|
128
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
129
|
+
const filePath = path.join(logsDir, `${serviceName}.${stream}.log`);
|
|
130
|
+
if (!fs.existsSync(filePath)) continue;
|
|
131
|
+
lines.push(`==> ${serviceName}.${stream} <==`);
|
|
132
|
+
lines.push(...tailLines(fs.readFileSync(filePath, "utf8"), options.lines || 200));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { name, lines };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function localShell(options = {}) {
|
|
139
|
+
const context = await loadConfigContext({ dir: options.dir });
|
|
140
|
+
const environment = resolveEnvironment(context, options);
|
|
141
|
+
const manifest = readLocalEnvironmentManifest(context.productDir, environment.name);
|
|
142
|
+
if (!manifest) throw new Error(`Local environment "${environment.name}" has not been started.`);
|
|
143
|
+
const runtimeConfigs = buildLocalRuntimeConfigs(context.configs, environment, manifest.runtimeDir);
|
|
144
|
+
const serviceConfig = resolveService(runtimeConfigs, options.service || environment.target);
|
|
145
|
+
const env = buildServiceEnv(serviceConfig, environment.env);
|
|
146
|
+
const command = options.command || [];
|
|
147
|
+
if (command.length === 0) throw new Error("testkit local shell requires a command after --");
|
|
148
|
+
return runShellCommand(command, {
|
|
149
|
+
cwd: serviceConfig.productDir,
|
|
150
|
+
env: { ...process.env, ...env },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildLocalRuntimeConfigs(allConfigs, environment, runtimeDir) {
|
|
155
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
156
|
+
const target = configMap.get(environment.target);
|
|
157
|
+
if (!target) {
|
|
158
|
+
const available = allConfigs.map((config) => config.name).join(", ");
|
|
159
|
+
throw new Error(`Service "${environment.target}" not found. Available: ${available}`);
|
|
160
|
+
}
|
|
161
|
+
const runtimeConfigs = resolveRuntimeConfigs(target, configMap);
|
|
162
|
+
return resolveRuntimeInstanceConfigs(runtimeConfigs, "local", runtimeDir, {
|
|
163
|
+
graphDirName: runtimeConfigs.map((config) => config.name).sort().join("__"),
|
|
164
|
+
portOffset: environment.portOffset || 0,
|
|
165
|
+
}).map((config) => applyLocalEnvironmentConfig(config, environment));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function buildLocalStatus(productDir, name) {
|
|
169
|
+
const manifest = readLocalEnvironmentManifest(productDir, name);
|
|
170
|
+
if (!manifest) return { name, exists: false, active: false, services: [], lines: [`Local Environment: ${name}`, " not found"] };
|
|
171
|
+
const active = isLocalEnvironmentActive(manifest);
|
|
172
|
+
const services = (manifest.services || []).map((service) => ({
|
|
173
|
+
serviceName: service.serviceName,
|
|
174
|
+
pid: service.pid,
|
|
175
|
+
active: serviceIsActive(service),
|
|
176
|
+
baseUrl: service.baseUrl,
|
|
177
|
+
readyUrl: service.readyUrl,
|
|
178
|
+
ports: service.ports || [],
|
|
179
|
+
}));
|
|
180
|
+
const lines = [
|
|
181
|
+
`Local Environment: ${name}`,
|
|
182
|
+
` status: ${active ? "running" : manifest.status || "stopped"}`,
|
|
183
|
+
` target: ${manifest.target || "unknown"}`,
|
|
184
|
+
` state: ${path.relative(productDir, getEnvironmentDir(productDir, name))}`,
|
|
185
|
+
];
|
|
186
|
+
if (services.length === 0) {
|
|
187
|
+
lines.push(" services: none");
|
|
188
|
+
} else {
|
|
189
|
+
lines.push(" services:");
|
|
190
|
+
for (const service of services) {
|
|
191
|
+
const url = service.baseUrl ? ` url=${service.baseUrl}` : "";
|
|
192
|
+
lines.push(` ${service.serviceName}: ${service.active ? "running" : "stale"} pid=${service.pid}${url}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
exists: true,
|
|
198
|
+
active,
|
|
199
|
+
manifest,
|
|
200
|
+
services,
|
|
201
|
+
lines,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolveEnvironment(context, options = {}) {
|
|
206
|
+
const name = normalizeEnvironmentName(options.name || "local");
|
|
207
|
+
const configured = context.environments[name] || null;
|
|
208
|
+
const target = options.service || configured?.target || inferDefaultTarget(context.configs);
|
|
209
|
+
return {
|
|
210
|
+
name,
|
|
211
|
+
kind: "local",
|
|
212
|
+
target,
|
|
213
|
+
data: options.rebuild ? "rebuild" : options.reset ? "reset" : configured?.data || "reuse",
|
|
214
|
+
portOffset: Number(options.portOffset ?? configured?.portOffset ?? 0),
|
|
215
|
+
env: { ...(configured?.env || {}) },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inferDefaultTarget(configs) {
|
|
220
|
+
if (configs.length === 1) return configs[0].name;
|
|
221
|
+
const runnable = configs.filter((config) => config.testkit.local);
|
|
222
|
+
if (runnable.length === 1) return runnable[0].name;
|
|
223
|
+
const available = configs.map((config) => config.name).join(", ");
|
|
224
|
+
throw new Error(`Multiple services available. Configure environments.local.target or pass --service. Available: ${available}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function applyLocalEnvironmentConfig(config, environment) {
|
|
228
|
+
const database = config.testkit.database
|
|
229
|
+
? {
|
|
230
|
+
...config.testkit.database,
|
|
231
|
+
reset: environment.data === "reuse" ? false : true,
|
|
232
|
+
}
|
|
233
|
+
: config.testkit.database;
|
|
234
|
+
return {
|
|
235
|
+
...config,
|
|
236
|
+
testkit: {
|
|
237
|
+
...config.testkit,
|
|
238
|
+
mode: "local",
|
|
239
|
+
localEnvironmentName: environment.name,
|
|
240
|
+
database,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resolveService(runtimeConfigs, serviceName) {
|
|
246
|
+
const serviceConfig = runtimeConfigs.find((config) => config.name === serviceName);
|
|
247
|
+
if (!serviceConfig) {
|
|
248
|
+
const available = runtimeConfigs.map((config) => config.name).join(", ");
|
|
249
|
+
throw new Error(`Service "${serviceName}" is not in this local environment. Available: ${available}`);
|
|
250
|
+
}
|
|
251
|
+
return serviceConfig;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildServiceEnv(config, extraEnv = {}) {
|
|
255
|
+
const env = buildExecutionEnv(config, { ...(config.testkit.local?.env || {}), ...(extraEnv || {}) }, process.env);
|
|
256
|
+
const databaseUrl = readDatabaseUrl(config.stateDir);
|
|
257
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
258
|
+
return env;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function collectEndpoints(runtimeConfigs) {
|
|
262
|
+
return Object.fromEntries(
|
|
263
|
+
runtimeConfigs
|
|
264
|
+
.filter((config) => config.testkit.local?.baseUrl)
|
|
265
|
+
.map((config) => [config.name, config.testkit.local.baseUrl])
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeEnvironmentName(name) {
|
|
270
|
+
const normalized = String(name || "").trim();
|
|
271
|
+
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
272
|
+
throw new Error(`Environment name "${name}" must contain only letters, numbers, underscores, or dashes`);
|
|
273
|
+
}
|
|
274
|
+
return normalized;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function serviceIsActive(service) {
|
|
278
|
+
try {
|
|
279
|
+
process.kill(Number(service.processGroupId || service.pid), 0);
|
|
280
|
+
return true;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return error?.code === "EPERM";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function tailLines(text, count) {
|
|
287
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-count);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function shellQuote(value) {
|
|
291
|
+
if (/^[A-Za-z0-9_/:.,@%+=-]*$/.test(value)) return value;
|
|
292
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function runShellCommand(command, options) {
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const child = spawn(command[0], command.slice(1), {
|
|
298
|
+
cwd: options.cwd,
|
|
299
|
+
env: options.env,
|
|
300
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
301
|
+
});
|
|
302
|
+
let stdout = "";
|
|
303
|
+
let stderr = "";
|
|
304
|
+
child.stdout.on("data", (chunk) => {
|
|
305
|
+
stdout += chunk.toString();
|
|
306
|
+
});
|
|
307
|
+
child.stderr.on("data", (chunk) => {
|
|
308
|
+
stderr += chunk.toString();
|
|
309
|
+
});
|
|
310
|
+
child.on("close", (code) => {
|
|
311
|
+
resolve({ code: code ?? 0, stdout, stderr });
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const TESTKIT_OWNED_PATHS = [
|
|
5
|
+
".testkit",
|
|
6
|
+
".next-testkit",
|
|
7
|
+
"testkit.status.json",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function normalizeFingerprintPolicy(value = {}) {
|
|
11
|
+
if (value == null) {
|
|
12
|
+
return {
|
|
13
|
+
exclude: [],
|
|
14
|
+
include: [],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
18
|
+
throw new Error("testkit.config.ts fingerprints must be an object");
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
exclude: normalizePatternList(value.exclude, "fingerprints.exclude"),
|
|
22
|
+
include: normalizePatternList(value.include, "fingerprints.include"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shouldIgnoreFingerprintPath(productDir, absOrRelPath, policy = {}) {
|
|
27
|
+
const relativePath = normalizeRelativePath(productDir, absOrRelPath);
|
|
28
|
+
if (!relativePath) return false;
|
|
29
|
+
if (matchesTestkitOwnedPath(relativePath)) return true;
|
|
30
|
+
if (matchesAnyPattern(relativePath, policy.include || [])) return false;
|
|
31
|
+
return matchesAnyPattern(relativePath, policy.exclude || []);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function appendFingerprintPathToHash(hash, productDir, absPath, policy = {}) {
|
|
35
|
+
const relativePath = normalizeRelativePath(productDir, absPath);
|
|
36
|
+
const ignored = shouldIgnoreFingerprintPath(productDir, relativePath, policy);
|
|
37
|
+
|
|
38
|
+
let stat;
|
|
39
|
+
try {
|
|
40
|
+
stat = fs.lstatSync(absPath);
|
|
41
|
+
} catch {
|
|
42
|
+
if (ignored) return;
|
|
43
|
+
hash.update(`missing:${relativePath}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (ignored && (!stat.isDirectory() || !hasIncludedDescendant(relativePath, policy))) return;
|
|
47
|
+
if (stat.isSymbolicLink()) {
|
|
48
|
+
hash.update(`symlink:${relativePath}:${fs.readlinkSync(absPath)}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (stat.isDirectory()) {
|
|
52
|
+
if (!ignored) hash.update(`dir:${relativePath}`);
|
|
53
|
+
for (const entry of fs.readdirSync(absPath).sort()) {
|
|
54
|
+
appendFingerprintPathToHash(hash, productDir, path.join(absPath, entry), policy);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!stat.isFile()) return;
|
|
60
|
+
hash.update(`file:${relativePath}:${stat.size}:${stat.mtimeMs}`);
|
|
61
|
+
hash.update(fs.readFileSync(absPath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeRelativePath(productDir, absOrRelPath) {
|
|
65
|
+
const raw = String(absOrRelPath || "");
|
|
66
|
+
const relative = path.isAbsolute(raw) ? path.relative(productDir, raw) : raw;
|
|
67
|
+
return normalizePath(relative);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizePath(value) {
|
|
71
|
+
return String(value || "")
|
|
72
|
+
.split(path.sep)
|
|
73
|
+
.join("/")
|
|
74
|
+
.replace(/\\/g, "/")
|
|
75
|
+
.replace(/^\.\/+/, "")
|
|
76
|
+
.replace(/\/+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePatternList(value, label) {
|
|
80
|
+
if (value == null) return [];
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
throw new Error(`testkit.config.ts ${label} must be an array of paths`);
|
|
83
|
+
}
|
|
84
|
+
return value.map((entry, index) => {
|
|
85
|
+
const normalized = normalizePath(String(entry || "").trim());
|
|
86
|
+
if (!normalized) {
|
|
87
|
+
throw new Error(`testkit.config.ts ${label}[${index}] must be a non-empty path`);
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
path.isAbsolute(normalized) ||
|
|
91
|
+
path.win32.isAbsolute(normalized) ||
|
|
92
|
+
normalized.startsWith("../") ||
|
|
93
|
+
normalized === ".."
|
|
94
|
+
) {
|
|
95
|
+
throw new Error(`testkit.config.ts ${label}[${index}] must be relative to the product directory`);
|
|
96
|
+
}
|
|
97
|
+
return normalized;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matchesAnyPattern(relativePath, patterns) {
|
|
102
|
+
return (patterns || []).some((pattern) => matchesPattern(relativePath, pattern));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function matchesTestkitOwnedPath(relativePath) {
|
|
106
|
+
return matchesAnyPattern(relativePath, TESTKIT_OWNED_PATHS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hasIncludedDescendant(relativePath, policy = {}) {
|
|
110
|
+
if (!relativePath || matchesTestkitOwnedPath(relativePath)) return false;
|
|
111
|
+
const prefix = `${relativePath}/`;
|
|
112
|
+
return (policy.include || []).some((pattern) => normalizePath(pattern).startsWith(prefix));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function matchesPattern(relativePath, pattern) {
|
|
116
|
+
const normalizedPath = normalizePath(relativePath);
|
|
117
|
+
const normalizedPattern = normalizePath(pattern);
|
|
118
|
+
if (!normalizedPattern) return false;
|
|
119
|
+
if (normalizedPath === normalizedPattern) return true;
|
|
120
|
+
if (normalizedPath.startsWith(`${normalizedPattern}/`)) return true;
|
|
121
|
+
if (!normalizedPattern.includes("*")) return false;
|
|
122
|
+
return globToRegExp(normalizedPattern).test(normalizedPath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function globToRegExp(pattern) {
|
|
126
|
+
let source = "^";
|
|
127
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
128
|
+
const char = pattern[index];
|
|
129
|
+
const next = pattern[index + 1];
|
|
130
|
+
if (char === "*" && next === "*") {
|
|
131
|
+
source += ".*";
|
|
132
|
+
index += 1;
|
|
133
|
+
} else if (char === "*") {
|
|
134
|
+
source += "[^/]*";
|
|
135
|
+
} else {
|
|
136
|
+
source += escapeRegExp(char);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
source += "$";
|
|
140
|
+
return new RegExp(source);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function escapeRegExp(value) {
|
|
144
|
+
return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
|
|
145
|
+
}
|
package/lib/repo/state.mjs
CHANGED
|
@@ -3,13 +3,18 @@ import fs from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { execFileSync } from "child_process";
|
|
5
5
|
import { parseGitHubRepoSlug } from "../regressions/github.mjs";
|
|
6
|
+
import {
|
|
7
|
+
appendFingerprintPathToHash,
|
|
8
|
+
normalizeFingerprintPolicy,
|
|
9
|
+
normalizePath,
|
|
10
|
+
shouldIgnoreFingerprintPath,
|
|
11
|
+
} from "./fingerprint-policy.mjs";
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export function collectRepoState(productDir) {
|
|
13
|
+
export function collectRepoState(productDir, options = {}) {
|
|
14
|
+
const fingerprints = normalizeFingerprintPolicy(options.fingerprints);
|
|
10
15
|
const repoRoot = readGit(productDir, ["rev-parse", "--show-toplevel"]);
|
|
11
16
|
if (!repoRoot) {
|
|
12
|
-
const fingerprint = fingerprintDirectory(productDir);
|
|
17
|
+
const fingerprint = fingerprintDirectory(productDir, fingerprints);
|
|
13
18
|
return {
|
|
14
19
|
kind: "nogit",
|
|
15
20
|
repoRoot: null,
|
|
@@ -30,7 +35,7 @@ export function collectRepoState(productDir) {
|
|
|
30
35
|
const branchName = readGit(productDir, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
31
36
|
const remoteUrl = readGit(productDir, ["remote", "get-url", "origin"]);
|
|
32
37
|
const detached = branchName === "HEAD";
|
|
33
|
-
const dirtyFingerprint = fingerprintGitDirtyState(productDir);
|
|
38
|
+
const dirtyFingerprint = fingerprintGitDirtyState(productDir, fingerprints);
|
|
34
39
|
const dirty = Boolean(dirtyFingerprint);
|
|
35
40
|
const baseCommit = commitSha || "unborn";
|
|
36
41
|
|
|
@@ -70,24 +75,30 @@ export function summarizeRepoStateForMetadata(repoState) {
|
|
|
70
75
|
};
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
function fingerprintGitDirtyState(productDir) {
|
|
78
|
+
function fingerprintGitDirtyState(productDir, fingerprints) {
|
|
74
79
|
const hash = crypto.createHash("sha256");
|
|
75
80
|
let hasChanges = false;
|
|
76
81
|
|
|
77
|
-
const trackedStatus =
|
|
78
|
-
|
|
82
|
+
const trackedStatus = readGitRaw(productDir, ["status", "--porcelain=v1", "-z", "-uno"]) || "";
|
|
83
|
+
const trackedFiles = parsePorcelainStatusPaths(trackedStatus)
|
|
84
|
+
.filter((entry) => !shouldIgnoreFingerprintPath(productDir, entry, fingerprints))
|
|
85
|
+
.sort();
|
|
86
|
+
if (trackedFiles.length > 0) {
|
|
79
87
|
hasChanges = true;
|
|
80
88
|
hash.update("tracked-status\0");
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
for (const relativePath of trackedFiles) {
|
|
90
|
+
hash.update(`${relativePath}\0`);
|
|
91
|
+
}
|
|
92
|
+
appendGitOutput(hash, productDir, ["diff", "--binary", "--no-ext-diff", "--", ...trackedFiles]);
|
|
93
|
+
appendGitOutput(hash, productDir, ["diff", "--binary", "--cached", "--no-ext-diff", "--", ...trackedFiles]);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
const untracked =
|
|
96
|
+
const untracked = readGitRaw(productDir, ["ls-files", "--others", "--exclude-standard", "-z"]) || "";
|
|
87
97
|
const untrackedFiles = untracked
|
|
88
98
|
.split("\0")
|
|
89
99
|
.filter(Boolean)
|
|
90
|
-
.
|
|
100
|
+
.map(normalizePath)
|
|
101
|
+
.filter((entry) => !shouldIgnoreFingerprintPath(productDir, entry, fingerprints))
|
|
91
102
|
.sort();
|
|
92
103
|
if (untrackedFiles.length > 0) {
|
|
93
104
|
hasChanges = true;
|
|
@@ -106,58 +117,49 @@ function fingerprintGitDirtyState(productDir) {
|
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
function appendGitOutput(hash, cwd, args) {
|
|
109
|
-
const output =
|
|
120
|
+
const output = readGitRaw(cwd, args) || "";
|
|
110
121
|
hash.update(args.join(" "));
|
|
111
122
|
hash.update("\0");
|
|
112
123
|
hash.update(output);
|
|
113
124
|
hash.update("\0");
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
function fingerprintDirectory(rootDir) {
|
|
127
|
+
function fingerprintDirectory(rootDir, fingerprints) {
|
|
117
128
|
const hash = crypto.createHash("sha256");
|
|
118
|
-
|
|
129
|
+
appendFingerprintPathToHash(hash, rootDir, rootDir, fingerprints);
|
|
119
130
|
return hash.digest("hex").slice(0, 24);
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
133
|
+
function parsePorcelainStatusPaths(output) {
|
|
134
|
+
const entries = output.split("\0").filter(Boolean);
|
|
135
|
+
const paths = [];
|
|
136
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
137
|
+
const entry = entries[index];
|
|
138
|
+
if (entry.length < 4) continue;
|
|
139
|
+
const status = entry.slice(0, 2);
|
|
140
|
+
const relativePath = entry.slice(3);
|
|
141
|
+
if (relativePath) paths.push(normalizePath(relativePath));
|
|
142
|
+
if (status.includes("R") || status.includes("C")) {
|
|
143
|
+
const previousPath = entries[index + 1];
|
|
144
|
+
if (previousPath) paths.push(normalizePath(previousPath));
|
|
145
|
+
index += 1;
|
|
135
146
|
}
|
|
136
|
-
return;
|
|
137
147
|
}
|
|
138
|
-
|
|
139
|
-
const relative = normalizePath(path.relative(rootDir, absPath));
|
|
140
|
-
hash.update(`file:${relative}:${stat.size}:${stat.mtimeMs}`);
|
|
141
|
-
hash.update(fs.readFileSync(absPath));
|
|
148
|
+
return [...new Set(paths)];
|
|
142
149
|
}
|
|
143
150
|
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.some((segment) => IGNORED_DIRS.has(segment));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function normalizePath(value) {
|
|
151
|
-
return String(value).split(path.sep).join("/");
|
|
151
|
+
function readGit(cwd, args) {
|
|
152
|
+
const output = readGitRaw(cwd, args);
|
|
153
|
+
return output?.trim() || null;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
function
|
|
156
|
+
function readGitRaw(cwd, args) {
|
|
155
157
|
try {
|
|
156
158
|
return execFileSync("git", args, {
|
|
157
159
|
cwd,
|
|
158
160
|
encoding: "utf8",
|
|
159
161
|
stdio: ["ignore", "pipe", "ignore"],
|
|
160
|
-
})
|
|
162
|
+
}) || null;
|
|
161
163
|
} catch {
|
|
162
164
|
return null;
|
|
163
165
|
}
|
|
@@ -9,9 +9,18 @@ import {
|
|
|
9
9
|
import { cleanupRuns, formatRunSummary, isPidRunning, listRunManifests } from "./lifecycle.mjs";
|
|
10
10
|
import { findGraphDirsForService, findRuntimeStateDirs } from "./state.mjs";
|
|
11
11
|
import { collectCleanupTargets, collectStatusModel } from "./status-model.mjs";
|
|
12
|
+
import {
|
|
13
|
+
cleanupStaleLocalEnvironments,
|
|
14
|
+
formatLocalEnvironmentSummary,
|
|
15
|
+
listLocalEnvironmentManifests,
|
|
16
|
+
stopLocalEnvironment,
|
|
17
|
+
} from "../local/lifecycle.mjs";
|
|
12
18
|
|
|
13
19
|
export async function destroy(config) {
|
|
14
20
|
await cleanupRuns(config.productDir, { includeActive: true });
|
|
21
|
+
for (const manifest of listLocalEnvironmentManifests(config.productDir)) {
|
|
22
|
+
await stopLocalEnvironment(config.productDir, manifest.name, { removeRuntimeState: true });
|
|
23
|
+
}
|
|
15
24
|
const roots = new Set([
|
|
16
25
|
config.stateDir,
|
|
17
26
|
...findGraphDirsForService(config.productDir, config.name),
|
|
@@ -45,6 +54,9 @@ export async function cleanup(productDir, options = {}) {
|
|
|
45
54
|
const summary = dryRun
|
|
46
55
|
? collectRunCleanupPreview(productDir)
|
|
47
56
|
: await cleanupRuns(productDir, { includeActive: false });
|
|
57
|
+
const localCleaned = dryRun
|
|
58
|
+
? collectLocalEnvironmentCleanupPreview(productDir)
|
|
59
|
+
: await cleanupStaleLocalEnvironments(productDir);
|
|
48
60
|
const targets = collectCleanupTargets(productDir, {
|
|
49
61
|
allConfigs,
|
|
50
62
|
serviceName,
|
|
@@ -78,6 +90,9 @@ export async function cleanup(productDir, options = {}) {
|
|
|
78
90
|
for (const manifest of summary.skippedActive) {
|
|
79
91
|
lines.push(`Active run still present: ${formatRunSummary(manifest)}`);
|
|
80
92
|
}
|
|
93
|
+
for (const manifest of localCleaned) {
|
|
94
|
+
lines.push(`${dryRun ? "Would mark stopped" : "Marked stopped"} stale local environment ${formatLocalEnvironmentSummary(manifest)}`);
|
|
95
|
+
}
|
|
81
96
|
for (const target of targets.runtime) {
|
|
82
97
|
lines.push(`${dryRun ? "Would remove" : "Removed"} stale runtime ${target.graph}/${target.runtimeId}`);
|
|
83
98
|
}
|
|
@@ -102,6 +117,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
102
117
|
runtimeCleaned,
|
|
103
118
|
bundleCleaned,
|
|
104
119
|
assistantCleaned,
|
|
120
|
+
localCleaned,
|
|
105
121
|
lines: ["No stale runs to clean."],
|
|
106
122
|
};
|
|
107
123
|
}
|
|
@@ -113,6 +129,7 @@ export async function cleanup(productDir, options = {}) {
|
|
|
113
129
|
runtimeCleaned,
|
|
114
130
|
bundleCleaned,
|
|
115
131
|
assistantCleaned,
|
|
132
|
+
localCleaned,
|
|
116
133
|
lines,
|
|
117
134
|
};
|
|
118
135
|
}
|
|
@@ -182,3 +199,9 @@ function collectRunCleanupPreview(productDir) {
|
|
|
182
199
|
}
|
|
183
200
|
return summary;
|
|
184
201
|
}
|
|
202
|
+
|
|
203
|
+
function collectLocalEnvironmentCleanupPreview(productDir) {
|
|
204
|
+
return listLocalEnvironmentManifests(productDir).filter((manifest) =>
|
|
205
|
+
(manifest.services || []).some((service) => !isPidRunning(Number(service.processGroupId || service.pid)))
|
|
206
|
+
);
|
|
207
|
+
}
|