@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,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
|
+
}
|
package/lib/runner/processes.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
1
3
|
import { execFileSync, spawn } from "child_process";
|
|
2
4
|
|
|
3
5
|
export function normalizeServiceStartCommand(command) {
|
|
@@ -10,28 +12,65 @@ export function normalizeServiceStartCommand(command) {
|
|
|
10
12
|
throw new Error("Service start command must not be empty");
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
return trimmed
|
|
15
|
+
return trimmed;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export function startDetachedCommand(command, cwd, env) {
|
|
18
|
+
export function startDetachedCommand(command, cwd, env, options = {}) {
|
|
17
19
|
const normalizedCommand = normalizeServiceStartCommand(command);
|
|
20
|
+
const { stdio, close } = resolveDetachedStdio(options);
|
|
21
|
+
let child;
|
|
18
22
|
if (process.platform === "win32") {
|
|
19
|
-
|
|
23
|
+
child = spawn(normalizedCommand, {
|
|
20
24
|
cwd,
|
|
21
25
|
env,
|
|
22
26
|
detached: true,
|
|
23
27
|
shell: true,
|
|
24
|
-
stdio
|
|
28
|
+
stdio,
|
|
25
29
|
});
|
|
30
|
+
close();
|
|
31
|
+
if (options.unref) child.unref();
|
|
32
|
+
return child;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
const shell = process.env.SHELL || "/bin/sh";
|
|
29
|
-
|
|
36
|
+
child = spawn(shell, ["-lc", `exec ${normalizedCommand}`], {
|
|
30
37
|
cwd,
|
|
31
38
|
env,
|
|
32
39
|
detached: true,
|
|
33
|
-
stdio
|
|
40
|
+
stdio,
|
|
34
41
|
});
|
|
42
|
+
close();
|
|
43
|
+
if (options.unref) child.unref();
|
|
44
|
+
return child;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveDetachedStdio(options = {}) {
|
|
48
|
+
if (!options.stdoutPath && !options.stderrPath) {
|
|
49
|
+
return { stdio: ["ignore", "pipe", "pipe"], close() {} };
|
|
50
|
+
}
|
|
51
|
+
const fds = [];
|
|
52
|
+
const openLog = (filePath) => {
|
|
53
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
54
|
+
const fd = fs.openSync(filePath, "a");
|
|
55
|
+
fds.push(fd);
|
|
56
|
+
return fd;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
stdio: [
|
|
60
|
+
"ignore",
|
|
61
|
+
options.stdoutPath ? openLog(options.stdoutPath) : "ignore",
|
|
62
|
+
options.stderrPath ? openLog(options.stderrPath) : "ignore",
|
|
63
|
+
],
|
|
64
|
+
close() {
|
|
65
|
+
for (const fd of fds) {
|
|
66
|
+
try {
|
|
67
|
+
fs.closeSync(fd);
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort descriptor cleanup.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
35
74
|
}
|
|
36
75
|
|
|
37
76
|
export function killChildProcess(child, signal) {
|
package/lib/runner/readiness.mjs
CHANGED
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
isPidRunning,
|
|
7
7
|
listRunManifests,
|
|
8
8
|
} from "./lifecycle.mjs";
|
|
9
|
+
import {
|
|
10
|
+
cleanupStaleLocalEnvironments,
|
|
11
|
+
findLocalPortOwner,
|
|
12
|
+
formatLocalEnvironmentSummary,
|
|
13
|
+
} from "../local/lifecycle.mjs";
|
|
9
14
|
import { socketFromUrl } from "./template.mjs";
|
|
10
15
|
|
|
11
16
|
export const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
@@ -48,15 +53,21 @@ export async function assertLocalServicePortsAvailable(config, isPortInUse) {
|
|
|
48
53
|
|
|
49
54
|
if (await isPortInUse(socket)) {
|
|
50
55
|
await cleanupStaleRuns(config.productDir);
|
|
56
|
+
await cleanupStaleLocalEnvironments(config.productDir);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
if (await isPortInUse(socket)) {
|
|
54
60
|
const owner = findPortOwner(config.productDir, socket);
|
|
61
|
+
const localOwner = owner ? null : findLocalPortOwner(config.productDir, socket);
|
|
55
62
|
const ownerDetail = owner
|
|
56
63
|
? owner.active
|
|
57
64
|
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.runtimeLabel}:${owner.service.serviceName}.`
|
|
58
65
|
: ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
|
|
59
|
-
:
|
|
66
|
+
: localOwner
|
|
67
|
+
? localOwner.active
|
|
68
|
+
? ` Active testkit local environment ${formatLocalEnvironmentSummary(localOwner.manifest)} owns ${key} via ${localOwner.service.serviceName}.`
|
|
69
|
+
: ` Stale testkit local environment ${formatLocalEnvironmentSummary(localOwner.manifest)} owns ${key}.`
|
|
70
|
+
: "";
|
|
60
71
|
throw new Error(
|
|
61
72
|
`Cannot start "${config.runtimeLabel}:${config.name}" because ${key} is already in use. ` +
|
|
62
73
|
`Stop the existing process and rerun testkit.${ownerDetail}`
|
|
@@ -41,7 +41,11 @@ export async function prepareRuntimeService(config, options = {}) {
|
|
|
41
41
|
fs.mkdirSync(prepareDir, { recursive: true });
|
|
42
42
|
|
|
43
43
|
const env = {
|
|
44
|
-
...buildExecutionEnv(
|
|
44
|
+
...buildExecutionEnv(
|
|
45
|
+
config,
|
|
46
|
+
{ ...(config.testkit.local?.env || {}), ...(options.extraEnv || {}) },
|
|
47
|
+
process.env
|
|
48
|
+
),
|
|
45
49
|
};
|
|
46
50
|
const databaseUrl = readDatabaseUrl(config.stateDir);
|
|
47
51
|
if (databaseUrl) {
|
|
@@ -128,12 +132,13 @@ export async function computeRuntimePrepareFingerprint(config) {
|
|
|
128
132
|
})
|
|
129
133
|
);
|
|
130
134
|
hash.update(JSON.stringify(collectRuntimeDatabaseFingerprintInputs(config)));
|
|
135
|
+
const fingerprints = config.testkit.fingerprints || {};
|
|
131
136
|
|
|
132
137
|
for (const envFile of config.testkit.envFiles || []) {
|
|
133
|
-
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile));
|
|
138
|
+
appendFileToHash(hash, config.productDir, resolveServiceCwd(config.productDir, envFile), fingerprints);
|
|
134
139
|
}
|
|
135
140
|
for (const input of collectConfiguredInputs(config.productDir, config.testkit.runtime.prepare)) {
|
|
136
|
-
appendResolvedInputToHash(hash, config.productDir, input);
|
|
141
|
+
appendResolvedInputToHash(hash, config.productDir, input, fingerprints);
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
return hash.digest("hex");
|
|
@@ -174,9 +179,9 @@ function collectDatabasePlaceholderServices(value, out, defaultServiceName) {
|
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
|
|
177
|
-
function appendResolvedInputToHash(hash, productDir, absPath) {
|
|
182
|
+
function appendResolvedInputToHash(hash, productDir, absPath, fingerprints) {
|
|
178
183
|
const relative = path.relative(productDir, absPath);
|
|
179
|
-
appendInputToHash(hash, productDir, relative);
|
|
184
|
+
appendInputToHash(hash, productDir, relative, fingerprints);
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
function readPrepareManifest(manifestPath) {
|
package/lib/runner/services.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export async function startLocalService(config, lifecycle, options = {}) {
|
|
|
32
32
|
const resolvedToolchain = await resolveConfiguredToolchain(config);
|
|
33
33
|
await announceResolvedToolchain(config, resolvedToolchain, options.reporter);
|
|
34
34
|
const env = applyToolchainEnv(
|
|
35
|
-
buildExecutionEnv(config, config.testkit.local.env, process.env),
|
|
35
|
+
buildExecutionEnv(config, { ...(config.testkit.local.env || {}), ...(options.extraEnv || {}) }, process.env),
|
|
36
36
|
resolvedToolchain
|
|
37
37
|
);
|
|
38
38
|
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
@@ -48,29 +48,35 @@ export async function startLocalService(config, lifecycle, options = {}) {
|
|
|
48
48
|
await assertLocalServicePortsAvailable(config, isPortInUse);
|
|
49
49
|
|
|
50
50
|
options.reporter?.localServiceStarting?.(config, config.testkit.local.start);
|
|
51
|
-
const
|
|
51
|
+
const serviceLogFiles = options.serviceLogFiles?.(config) || null;
|
|
52
|
+
const child = startDetachedCommand(config.testkit.local.start, cwd, env, {
|
|
53
|
+
...(serviceLogFiles || {}),
|
|
54
|
+
unref: Boolean(serviceLogFiles),
|
|
55
|
+
});
|
|
52
56
|
const logRecord = options.logRegistry?.ensureServiceLogRecord(config);
|
|
53
57
|
const liveWriter =
|
|
54
58
|
options.reporter?.outputMode === "debug"
|
|
55
59
|
? (line) => options.reporter.writeDebugLine?.(line)
|
|
56
60
|
: null;
|
|
57
61
|
|
|
58
|
-
const outputDrains =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
62
|
+
const outputDrains = serviceLogFiles
|
|
63
|
+
? []
|
|
64
|
+
: [
|
|
65
|
+
captureOutput(child.stdout, {
|
|
66
|
+
livePrefix: `[${config.runtimeLabel}:${config.name}]`,
|
|
67
|
+
liveWriter,
|
|
68
|
+
onLine(line) {
|
|
69
|
+
if (logRecord) options.logRegistry.append(logRecord, "stdout", line);
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
captureOutput(child.stderr, {
|
|
73
|
+
livePrefix: `[${config.runtimeLabel}:${config.name}]`,
|
|
74
|
+
liveWriter,
|
|
75
|
+
onLine(line) {
|
|
76
|
+
if (logRecord) options.logRegistry.append(logRecord, "stderr", line);
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
];
|
|
74
80
|
registerManagedService(lifecycle, config, child, cwd, "SIGTERM");
|
|
75
81
|
|
|
76
82
|
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { buildRuntimeIds } from "./execution-config.mjs";
|
|
4
4
|
import { buildGraphDirName, resolveRuntimeConfigs } from "./planning.mjs";
|
|
5
5
|
import { isPidRunning, listRunManifests } from "./lifecycle.mjs";
|
|
6
|
+
import { isLocalEnvironmentActive, listLocalEnvironmentManifests } from "../local/lifecycle.mjs";
|
|
6
7
|
import { readGraphMetadata } from "./state.mjs";
|
|
7
8
|
|
|
8
9
|
const BUNDLE_MANIFEST = "manifest.json";
|
|
@@ -18,6 +19,7 @@ export function collectStatusModel(config, { allConfigs = [config] } = {}) {
|
|
|
18
19
|
});
|
|
19
20
|
const serviceState = collectDirectorySummary(config.stateDir);
|
|
20
21
|
const runs = collectRunStatus(productDir);
|
|
22
|
+
const localEnvironments = collectLocalEnvironmentStatus(productDir);
|
|
21
23
|
const bundles = collectBundleCacheStatus(productDir, config.name);
|
|
22
24
|
const assistant = collectAssistantResultStatus(productDir);
|
|
23
25
|
const warnings = collectWarnings({ runtimeGraphs, bundles, assistant });
|
|
@@ -25,6 +27,7 @@ export function collectStatusModel(config, { allConfigs = [config] } = {}) {
|
|
|
25
27
|
serviceState.exists ||
|
|
26
28
|
runtimeGraphs.some((graph) => graph.exists) ||
|
|
27
29
|
runs.total > 0 ||
|
|
30
|
+
localEnvironments.total > 0 ||
|
|
28
31
|
bundles.exists ||
|
|
29
32
|
assistant.exists;
|
|
30
33
|
|
|
@@ -40,6 +43,7 @@ export function collectStatusModel(config, { allConfigs = [config] } = {}) {
|
|
|
40
43
|
},
|
|
41
44
|
hasState,
|
|
42
45
|
runs,
|
|
46
|
+
localEnvironments,
|
|
43
47
|
serviceState,
|
|
44
48
|
runtimeGraphs,
|
|
45
49
|
caches: {
|
|
@@ -50,6 +54,29 @@ export function collectStatusModel(config, { allConfigs = [config] } = {}) {
|
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
function collectLocalEnvironmentStatus(productDir) {
|
|
58
|
+
const environments = listLocalEnvironmentManifests(productDir).map((manifest) => {
|
|
59
|
+
const active = isLocalEnvironmentActive(manifest);
|
|
60
|
+
return {
|
|
61
|
+
name: manifest.name,
|
|
62
|
+
status: active ? "running" : manifest.status || "stopped",
|
|
63
|
+
target: manifest.target || null,
|
|
64
|
+
ports: [
|
|
65
|
+
...new Set(
|
|
66
|
+
(manifest.services || []).flatMap((service) =>
|
|
67
|
+
(service.ports || []).map((socket) => `${socket.host}:${socket.port}`)
|
|
68
|
+
)
|
|
69
|
+
),
|
|
70
|
+
].sort(),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
total: environments.length,
|
|
75
|
+
active: environments.filter((environment) => environment.status === "running").length,
|
|
76
|
+
environments,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
export function collectCleanupTargets(productDir, { allConfigs = [], serviceName = null, cache = [] } = {}) {
|
|
54
81
|
const desiredGraphs = collectDesiredRuntimeGraphs(allConfigs);
|
|
55
82
|
const runtimeGraphs = collectRuntimeGraphStatus(productDir, { desiredGraphs, serviceName });
|