@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.
Files changed (43) hide show
  1. package/README.md +38 -0
  2. package/lib/cli/commands/local/down.mjs +37 -0
  3. package/lib/cli/commands/local/env.mjs +31 -0
  4. package/lib/cli/commands/local/logs.mjs +35 -0
  5. package/lib/cli/commands/local/shell.mjs +49 -0
  6. package/lib/cli/commands/local/status.mjs +34 -0
  7. package/lib/cli/commands/local/up.mjs +39 -0
  8. package/lib/cli/entrypoint.mjs +6 -0
  9. package/lib/cli/renderers/status/text.mjs +14 -0
  10. package/lib/config/index.mjs +154 -0
  11. package/lib/config/validation.mjs +9 -0
  12. package/lib/config-api/index.d.ts +53 -0
  13. package/lib/config-api/index.mjs +14 -0
  14. package/lib/database/fingerprint.mjs +13 -33
  15. package/lib/database/index.mjs +27 -12
  16. package/lib/database/schema-source.mjs +3 -1
  17. package/lib/docker-compat/matrix.mjs +135 -0
  18. package/lib/env/index.d.ts +1 -0
  19. package/lib/env/index.mjs +5 -1
  20. package/lib/kiln/client.mjs +100 -0
  21. package/lib/local/kiln-driver.mjs +544 -0
  22. package/lib/local/lifecycle.mjs +289 -0
  23. package/lib/local/orchestrator.mjs +343 -0
  24. package/lib/repo/fingerprint-policy.mjs +145 -0
  25. package/lib/repo/state.mjs +46 -44
  26. package/lib/runner/maintenance.mjs +23 -0
  27. package/lib/runner/processes.mjs +45 -6
  28. package/lib/runner/readiness.mjs +12 -1
  29. package/lib/runner/runtime-preparation.mjs +10 -5
  30. package/lib/runner/services.mjs +24 -18
  31. package/lib/runner/status-model.mjs +27 -0
  32. package/lib/runner/template.mjs +39 -1
  33. package/node_modules/@elench/next-analysis/package.json +1 -1
  34. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  35. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  36. package/node_modules/@elench/ts-analysis/package.json +1 -1
  37. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  42. package/node_modules/esprima/ChangeLog +235 -0
  43. 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
+ }
@@ -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
- const IGNORED_DIRS = new Set([".git", ".testkit", "node_modules"]);
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 = readGit(productDir, ["status", "--porcelain=v1", "-uno"]) || "";
78
- if (trackedStatus.trim()) {
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
- hash.update(trackedStatus);
82
- appendGitOutput(hash, productDir, ["diff", "--binary", "--no-ext-diff"]);
83
- appendGitOutput(hash, productDir, ["diff", "--binary", "--cached", "--no-ext-diff"]);
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 = readGit(productDir, ["ls-files", "--others", "--exclude-standard", "-z"]) || "";
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
- .filter((entry) => !hasIgnoredPathSegment(entry))
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 = readGit(cwd, args) || "";
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
- appendDirectoryToHash(hash, rootDir, rootDir);
129
+ appendFingerprintPathToHash(hash, rootDir, rootDir, fingerprints);
119
130
  return hash.digest("hex").slice(0, 24);
120
131
  }
121
132
 
122
- function appendDirectoryToHash(hash, rootDir, absPath) {
123
- if (!fs.existsSync(absPath)) {
124
- hash.update(`missing:${normalizePath(path.relative(rootDir, absPath))}`);
125
- return;
126
- }
127
- const stat = fs.statSync(absPath);
128
- if (stat.isDirectory()) {
129
- const relative = path.relative(rootDir, absPath);
130
- if (relative && hasIgnoredPathSegment(relative)) return;
131
- hash.update(`dir:${normalizePath(relative)}`);
132
- for (const entry of fs.readdirSync(absPath).sort()) {
133
- if (IGNORED_DIRS.has(entry)) continue;
134
- appendDirectoryToHash(hash, rootDir, path.join(absPath, entry));
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
- if (!stat.isFile()) return;
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 hasIgnoredPathSegment(relativePath) {
145
- return normalizePath(relativePath)
146
- .split("/")
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 readGit(cwd, args) {
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
- }).trim() || null;
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
+ }
@@ -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.replace(/^exec\s+/u, "");
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
- return spawn(normalizedCommand, {
23
+ child = spawn(normalizedCommand, {
20
24
  cwd,
21
25
  env,
22
26
  detached: true,
23
27
  shell: true,
24
- stdio: ["ignore", "pipe", "pipe"],
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
- return spawn(shell, ["-lc", `exec ${normalizedCommand}`], {
36
+ child = spawn(shell, ["-lc", `exec ${normalizedCommand}`], {
30
37
  cwd,
31
38
  env,
32
39
  detached: true,
33
- stdio: ["ignore", "pipe", "pipe"],
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) {
@@ -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(config, config.testkit.local?.env || {}, process.env),
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) {
@@ -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 child = startDetachedCommand(config.testkit.local.start, cwd, env);
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
- captureOutput(child.stdout, {
60
- livePrefix: `[${config.runtimeLabel}:${config.name}]`,
61
- liveWriter,
62
- onLine(line) {
63
- if (logRecord) options.logRegistry.append(logRecord, "stdout", line);
64
- },
65
- }),
66
- captureOutput(child.stderr, {
67
- livePrefix: `[${config.runtimeLabel}:${config.name}]`,
68
- liveWriter,
69
- onLine(line) {
70
- if (logRecord) options.logRegistry.append(logRecord, "stderr", line);
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 });