@vitest-agent/plugin 5.6.2 → 6.0.1

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.
@@ -0,0 +1,78 @@
1
+ import { stripAnsi } from "@vitest-agent/sdk";
2
+
3
+ // src/utils/failure-summary.ts
4
+ const firstLine = (message) => {
5
+ for (const line of stripAnsi(message).split(/\r?\n/)) {
6
+ const trimmed = line.trim();
7
+ if (trimmed.length > 0) return trimmed;
8
+ }
9
+ return "";
10
+ };
11
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
12
+ /**
13
+ * Whether the plugin owns stdout in this console mode, so failure detail
14
+ * never reaches stderr unless the reporter writes it there.
15
+ *
16
+ * @internal
17
+ */
18
+ function shouldWriteFailureSummary(mode) {
19
+ return mode === "agent" || mode === "stream" || mode === "ci-annotations";
20
+ }
21
+ /**
22
+ * Format a plain-text, ANSI-free failure summary for stderr: a count header
23
+ * plus one entry per failed test, failed module and unhandled error (each
24
+ * reduced to its first message line), capped at
25
+ * {@link FAILURE_SUMMARY_MAX_LINES} lines. Returns `undefined` when there is
26
+ * nothing to report.
27
+ *
28
+ * @internal
29
+ */
30
+ function formatFailureSummary(reports, reason) {
31
+ const entries = [];
32
+ const files = /* @__PURE__ */ new Set();
33
+ const seenUnhandled = /* @__PURE__ */ new Set();
34
+ const unhandled = [];
35
+ let failedTests = 0;
36
+ for (const report of reports) {
37
+ for (const mod of report.failed) {
38
+ files.add(mod.file);
39
+ const moduleError = mod.errors?.[0];
40
+ if (moduleError !== void 0) {
41
+ const msg = firstLine(moduleError.message);
42
+ entries.push([` FAIL ${mod.file}`, ...msg === "" ? [] : [` ${msg}`]]);
43
+ }
44
+ for (const test of mod.tests) {
45
+ if (test.state !== "failed") continue;
46
+ failedTests++;
47
+ const msg = firstLine(test.errors?.[0]?.message ?? "");
48
+ entries.push([` FAIL ${mod.file} > ${test.fullName}`, ...msg === "" ? [] : [` ${msg}`]]);
49
+ }
50
+ }
51
+ for (const err of report.unhandledErrors) {
52
+ const msg = firstLine(err.message);
53
+ if (seenUnhandled.has(msg)) continue;
54
+ seenUnhandled.add(msg);
55
+ unhandled.push([` ERROR ${msg}`]);
56
+ }
57
+ }
58
+ entries.push(...unhandled);
59
+ if (entries.length === 0 && reason !== "interrupted") return void 0;
60
+ const counts = [];
61
+ if (failedTests > 0) counts.push(`${plural(failedTests, "failed test")} in ${plural(files.size, "file")}`);
62
+ else if (files.size > 0) counts.push(plural(files.size, "failed file"));
63
+ if (unhandled.length > 0) counts.push(plural(unhandled.length, "unhandled error"));
64
+ const lines = [reason === "interrupted" ? `vitest-agent: run interrupted${counts.length > 0 ? `, ${counts.join(", ")}` : ""}` : `vitest-agent: ${counts.join(", ")}`];
65
+ let budget = 14;
66
+ if (entries.reduce((n, e) => n + e.length, 0) > budget) budget -= 1;
67
+ let shown = 0;
68
+ for (const entry of entries) {
69
+ if (entry.length > budget) break;
70
+ lines.push(...entry);
71
+ budget -= entry.length;
72
+ shown++;
73
+ }
74
+ if (shown < entries.length) lines.push(` ... ${entries.length - shown} more`);
75
+ return lines.join("\n");
76
+ }
77
+
78
+ export { formatFailureSummary, shouldWriteFailureSummary };
@@ -1,99 +1,82 @@
1
- import { toPosixPath } from "./to-posix-path.js";
2
- import { nodeWalkerFs } from "./walker-fs.js";
3
- import { join, relative } from "node:path";
1
+ import { join } from "node:path";
4
2
  import { NON_DISCOVERABLE_DIRS } from "@vitest-agent/sdk";
3
+ import { Result } from "effect";
4
+ import { GlobPattern } from "@effected/glob";
5
+ import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
6
+ import { descendEntriesSync, descendSync } from "@effected/walker";
5
7
 
6
8
  // src/utils/find-test-files.ts
7
- function globToRegex(pattern) {
8
- const alts = expandBraces(pattern).map(toRegexFragment);
9
- return new RegExp(`^(?:${alts.join("|")})$`);
10
- }
11
- /** Expands the FIRST brace group found in a pattern string. Recursive to handle nesting. */
12
- function expandBraces(pattern) {
13
- const open = pattern.indexOf("{");
14
- if (open === -1) return [pattern];
15
- const close = pattern.indexOf("}", open);
16
- if (close === -1) return [pattern];
17
- const prefix = pattern.slice(0, open);
18
- const suffix = pattern.slice(close + 1);
19
- const alternatives = pattern.slice(open + 1, close).split(",");
20
- const results = [];
21
- for (const alt of alternatives) for (const expanded of expandBraces(`${prefix}${alt}${suffix}`)) results.push(expanded);
22
- return results;
23
- }
24
- /** Converts a brace-free glob string into a regex fragment (no ^ or $). */
25
- function toRegexFragment(glob) {
26
- let result = "";
27
- let i = 0;
28
- while (i < glob.length) {
29
- const ch = glob[i];
30
- if (ch === "*") {
31
- if (glob[i + 1] === "*") {
32
- result += "(?:.+/|)";
33
- i += 2;
34
- if (glob[i] === "/") i++;
35
- } else {
36
- result += "[^/]*";
37
- i++;
38
- }
39
- } else if (ch === "?") {
40
- result += "[^/]";
41
- i++;
42
- } else if (/[.+^${}()|[\]\\]/.test(ch)) {
43
- result += `\\${ch}`;
44
- i++;
45
- } else {
46
- result += ch;
47
- i++;
48
- }
49
- }
50
- return result;
51
- }
9
+ const GLOB_OPTIONS = { dot: true };
10
+ /** Every file, dot entries included: the pattern the signature walk expands. */
11
+ const EVERY_FILE = Result.getOrThrow(GlobPattern.compileResult("**", GLOB_OPTIONS));
12
+ /**
13
+ * The walk rules every discovery walk shares, so the test-file walk, the
14
+ * cache-signature walk and the engine's `ProjectDiscoveryLive` see one tree.
15
+ * `NON_DISCOVERABLE_DIRS` is pruned before descending (a fixture
16
+ * `node_modules` must never drag in the pnpm store); symlinked directories
17
+ * are followed under the walker's ancestor-chain cycle guard; an unreadable
18
+ * directory contributes nothing, as the `node:fs` walks this replaced did.
19
+ */
20
+ const WALK_RULES = {
21
+ prune: [...NON_DISCOVERABLE_DIRS],
22
+ followSymlinks: true,
23
+ onUnreadable: "skip"
24
+ };
52
25
  /**
53
- * Async file walker that returns matched absolute paths.
26
+ * File walker that returns matched absolute paths.
54
27
  *
55
- * Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
56
- * and `dist` directories, and does not descend past a nested `package.json`
57
- * boundary (any directory other than `dir` itself that has its own
58
- * `package.json` is treated as an independent unit — its files belong to a
59
- * separate discovery pass, not this one). The package-boundary rule applies
28
+ * Expands each pattern under `dir` with `@effected/walker`'s `descendSync`
29
+ * over `ops` (default: `nodeWalkerSyncOps`). Skips `node_modules`, `.git`, and
30
+ * `dist` directories, follows symlinked directories (a link loop is cut by
31
+ * the walker's cycle guard), and does not descend past a nested
32
+ * `package.json` boundary (any directory other than `dir` itself that has its
33
+ * own `package.json` is treated as an independent unit — its files belong to
34
+ * a separate discovery pass, not this one). The package-boundary rule applies
60
35
  * to every supplied pattern, not just an unanchored `**\/` one — an anchored
61
36
  * pattern like `"src/**\/*.test.ts"` will not match a test file under a
62
37
  * nested package.json even though the pattern itself never reaches past
63
- * `src/`, because the boundary check runs once per directory, independent of
64
- * which pattern is being matched. Matches files against the supplied glob
65
- * patterns relative to `dir` (e.g. `"src/**\/*.test.ts"`).
38
+ * `src/`. Matches files against the supplied glob patterns relative to `dir`
39
+ * (e.g. `"src/**\/*.test.ts"`); a pattern that fails to compile matches
40
+ * nothing.
66
41
  *
67
42
  * Returns an empty array if `dir` does not exist or no files match.
68
43
  * @param dir - Absolute path to the directory to walk
69
44
  * @param patterns - Glob patterns to match against (relative to `dir`)
70
- * @returns Absolute paths of matched test files
45
+ * @param ops - The sync filesystem the walk reads through
46
+ * @returns Absolute paths of matched test files, each pattern's matches sorted, deduplicated across patterns
71
47
  * @public
72
48
  */
73
- async function findTestFiles(dir, patterns, fs = nodeWalkerFs) {
74
- if (patterns.length === 0) return [];
75
- const matchers = patterns.map(globToRegex);
76
- const results = [];
77
- await walkDir(dir, dir, matchers, results, fs);
78
- return results;
79
- }
80
- async function walkDir(root, dir, matchers, results, fs) {
81
- let entries;
82
- try {
83
- entries = await fs.readDirectory(dir);
84
- } catch {
85
- return;
86
- }
87
- if (dir !== root && entries.some((ent) => ent.isFile() && ent.name === "package.json")) return;
88
- for (const ent of entries) {
89
- if (NON_DISCOVERABLE_DIRS.has(ent.name)) continue;
90
- const fullPath = join(dir, ent.name);
91
- if (ent.isDirectory()) await walkDir(root, fullPath, matchers, results, fs);
92
- else if (ent.isFile()) {
93
- const rel = toPosixPath(relative(root, fullPath));
94
- if (matchers.some((re) => re.test(rel))) results.push(fullPath);
95
- }
49
+ async function findTestFiles(dir, patterns, ops = nodeWalkerSyncOps) {
50
+ const results = /* @__PURE__ */ new Set();
51
+ for (const source of patterns) {
52
+ const compiled = GlobPattern.compileResult(source, GLOB_OPTIONS);
53
+ if (Result.isFailure(compiled)) continue;
54
+ const matches = descendSync(compiled.success, {
55
+ cwd: dir,
56
+ boundaries: ["package.json"],
57
+ ...WALK_RULES,
58
+ ...ops
59
+ });
60
+ if (Result.isFailure(matches)) throw matches.failure;
61
+ for (const rel of matches.success) results.add(join(dir, rel));
96
62
  }
63
+ return [...results];
64
+ }
65
+ /**
66
+ * A cheap signature (relative path + mtimeMs pairs, sorted) for every file
67
+ * under `dir`, read from the stats the walk already took — no content reads.
68
+ * Empty when `dir` does not exist, which is still a stable, comparable
69
+ * contribution. The same walk rules as {@link findTestFiles}, minus the
70
+ * package boundary, so a change anywhere under the directory invalidates.
71
+ */
72
+ function directorySignature(dir, ops) {
73
+ const entries = descendEntriesSync(EVERY_FILE, {
74
+ cwd: dir,
75
+ ...WALK_RULES,
76
+ ...ops
77
+ });
78
+ if (Result.isFailure(entries)) throw entries.failure;
79
+ return entries.success.map((entry) => `${entry.path}:${entry.info.mtimeMs}`).join("|");
97
80
  }
98
81
 
99
- export { findTestFiles };
82
+ export { directorySignature, findTestFiles };
@@ -1,7 +1,8 @@
1
- import { nodeWalkerFs } from "./walker-fs.js";
2
1
  import { findTestFiles } from "./find-test-files.js";
2
+ import { statOrNull } from "./walker-fs.js";
3
3
  import { join } from "node:path";
4
4
  import { BATS_FILE_GLOB_SUFFIX, SRC_DIR, TEST_DIR, TEST_FILE_GLOB_SUFFIX } from "@vitest-agent/sdk";
5
+ import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
5
6
 
6
7
  // src/utils/is-test-shaped-package.ts
7
8
  /**
@@ -41,13 +42,13 @@ import { BATS_FILE_GLOB_SUFFIX, SRC_DIR, TEST_DIR, TEST_FILE_GLOB_SUFFIX } from
41
42
  * `findTestFiles` walker, rather than re-deriving the shape independently.
42
43
  * @internal
43
44
  */
44
- async function isTestShapedPackage(pkgPath, fs = nodeWalkerFs) {
45
+ async function isTestShapedPackage(pkgPath, fs = nodeWalkerSyncOps) {
45
46
  if ((await findTestFiles(pkgPath, [
46
47
  `${SRC_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`,
47
48
  `${TEST_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`,
48
49
  `**/${BATS_FILE_GLOB_SUFFIX}`
49
50
  ], fs)).length > 0) return false;
50
- return (await fs.statEntry(join(pkgPath, TEST_DIR)))?.isDirectory === true;
51
+ return statOrNull(fs, join(pkgPath, TEST_DIR))?.isDirectory() === true;
51
52
  }
52
53
 
53
54
  export { isTestShapedPackage };
@@ -17,7 +17,7 @@ const OPT_OUT_VALUES = /* @__PURE__ */ new Set([
17
17
  * `coverage.reportsDirectory` by default; the v8 provider's `clean: true`
18
18
  * default `rm -rf`s that directory at run start, so one run can delete the
19
19
  * other's `.tmp` files mid-run (issue #194). The MCP `run_tests` path
20
- * already isolates via `makeCoverageDirOverride()`; this function drives
20
+ * already isolates via a per-call temp directory; this function drives
21
21
  * the equivalent decision for the plain-CLI (`AgentPlugin.configureVitest`)
22
22
  * path.
23
23
  *
@@ -0,0 +1,24 @@
1
+ // src/utils/run-diagnostics.ts
2
+ /**
3
+ * The `Symbol.for` key under which `onTestRunEnd` publishes the run's
4
+ * diagnostics on the Vitest instance. `Symbol.for` resolves to the identical
5
+ * symbol across module copies in one process, so the MCP `run_tests` tool
6
+ * reads the same slot without importing the plugin.
7
+ *
8
+ * @internal
9
+ */
10
+ const RUN_DIAGNOSTICS_SYMBOL = Symbol.for("vitest-agent:run-diagnostics");
11
+ /**
12
+ * Publish the run's diagnostics on the Vitest instance for readers in the
13
+ * same process (the MCP `run_tests` tool). Any defined diagnostics are
14
+ * published, so a clean run still carries its post-run timing and overwrites
15
+ * the previous run's value; only `undefined` clears the slot, so a watch-mode
16
+ * rerun never reports a stale diagnostic.
17
+ *
18
+ * @internal
19
+ */
20
+ const publishRunDiagnostics = (vitest, d) => {
21
+ vitest[RUN_DIAGNOSTICS_SYMBOL] = d;
22
+ };
23
+
24
+ export { RUN_DIAGNOSTICS_SYMBOL, publishRunDiagnostics };
@@ -1,267 +1,111 @@
1
- import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
- import { createHash, randomBytes } from "node:crypto";
3
+ import { createHash } from "node:crypto";
5
4
 
6
5
  // src/utils/run-script-lock.ts
7
6
  /**
8
- * File-based advisory lock backing `AgentPlugin.runScript` (issue #191,
9
- * sub-item B). Two concurrent `vitest` invocations in one checkout used
10
- * to both run the `globalSetup` build and race — this lock serializes
11
- * them: the first process to acquire the lock runs the command and
12
- * marks a "done" timestamp on success; a concurrent process polls,
13
- * and once the marker is fresh enough (`builtRecentlyMs`) it trusts
14
- * the winner's build and skips its own.
15
- *
16
- * A stale-lock takeover exists because a process that crashed or was
17
- * killed mid-build leaves the lock file behind forever otherwise —
18
- * every future `vitest` invocation would hang. Takeover is a two-tier
19
- * rule, never age alone:
20
- *
21
- * 1. The lock file records the owner's pid. Once the lock is older
22
- * than `staleMs`, that pid is probed with `process.kill(pid, 0)`.
23
- * A live owner (probe succeeds, or fails `EPERM` — the process
24
- * exists but belongs to another user) is NEVER taken over, no
25
- * matter how old the lock is: a slow-but-live build must not have
26
- * its lock stolen and the command double-run.
27
- * 2. Only a dead owner (`ESRCH`), or a lock file whose owner record is
28
- * missing/corrupt (in which case age is all we have to go on),
29
- * is taken over.
30
- *
31
- * Release is ownership-gated for the same reason: `acquireRunScriptLock`
32
- * stamps a random per-acquisition nonce into the lock file, and
33
- * `releaseRunScriptLock` deletes the file only when the nonce on disk is
34
- * still its own. Without that, an original owner that lost its lock to a
35
- * takeover would delete the *takeover* owner's lock in its `finally`,
36
- * letting a third process in while the second is still building.
37
- *
38
- * A generous `waitTimeoutMs` safety valve exists so a genuinely slow
39
- * (but still live) build doesn't hang the waiter forever either: past
40
- * that timeout the waiter gives up, `acquireRunScriptLock` returns
41
- * `{ acquired: false, recentlyBuilt: false }`, and the caller
42
- * (`AgentPlugin.runScript`) goes ahead and runs its own command
43
- * unserialized. That reproduces the original race for that one pair of
44
- * processes rather than blocking indefinitely — a deliberate,
45
- * documented escape valve, not an oversight.
7
+ * The `RunOnce` settings behind `AgentPlugin.runScript` (issue #191). The
8
+ * lock protocol itself — exclusive create, nonce-observed trust, pid-and-host
9
+ * probed takeover, owner-only release — is `@effected/commands`' `RunOnce`;
10
+ * this module only derives the key, the directory and the timings one
11
+ * `runScript` call runs under.
46
12
  *
47
13
  * @packageDocumentation
48
14
  */
49
- /**
50
- * A build whose owning process is *gone* and whose lock is older than
51
- * this is presumed abandoned (crashed process, killed job) and taken
52
- * over. Age alone never triggers a takeover — the recorded pid is
53
- * probed first, and a live owner keeps its lock past this age (see the
54
- * module doc comment). One minute comfortably covers the `globalSetup`
55
- * build steps this lock is built for; the pid probe is what protects
56
- * the longer ones.
57
- */
58
- const DEFAULT_LOCK_STALE_MS = 6e4;
59
- /** How long a waiter sleeps between polls of an active lock. */
60
- const DEFAULT_LOCK_POLL_MS = 200;
61
- /** How long a waiter blocks before giving up and running its own build independently. */
62
- const DEFAULT_LOCK_WAIT_TIMEOUT_MS = 6e5;
63
- /** A done-marker fresher than this is trusted without re-running the command. */
64
- const DEFAULT_BUILT_RECENTLY_MS = 3e4;
15
+ /** Floor for the poll interval, matching the floor `RunOnce` itself enforces. */
16
+ const MIN_LOCK_POLL_MS = 10;
65
17
  /**
66
18
  * Strictly parses a millisecond timing supplied through one of the
67
- * `VITEST_AGENT_RUNSCRIPT_*` env overrides, falling back to `fallback`
68
- * for anything that is not a whole positive integer at or above
69
- * `minimum`.
70
- *
71
- * `Number.parseInt` is deliberately not used on its own: it happily
72
- * accepts `"200ms"` (→ 200) and `"-1"` (→ a negative stale window,
73
- * which makes every lock instantly stale) and `"0"` for a poll interval
74
- * (a tight spin loop). An unusable override falls back to the default
75
- * rather than silently degrading the lock.
19
+ * `VITEST_AGENT_RUNSCRIPT_*` env overrides, answering `undefined` (the
20
+ * `RunOnce` default) for anything that is not a whole positive integer at or
21
+ * above `minimum`.
76
22
  *
77
- * @public
23
+ * `Number.parseInt` is deliberately not used on its own: it happily accepts
24
+ * `"200ms"` (→ 200), `"-1"` and `"0"`, and `RunOnce` treats a non-positive or
25
+ * sub-10ms timing as a wiring defect and throws. An unusable override falls
26
+ * back to the default instead.
78
27
  */
79
- function parseLockTimingOverride(raw, fallback, minimum = 1) {
80
- if (raw === void 0) return fallback;
28
+ function parseLockTimingOverride(raw, minimum = 1) {
29
+ if (raw === void 0) return void 0;
81
30
  const trimmed = raw.trim();
82
- if (!/^\d+$/.test(trimmed)) return fallback;
31
+ if (!/^\d+$/.test(trimmed)) return void 0;
83
32
  const parsed = Number.parseInt(trimmed, 10);
84
- if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback;
33
+ if (!Number.isSafeInteger(parsed) || parsed < minimum) return void 0;
85
34
  return parsed;
86
35
  }
87
36
  /**
88
- * Resolves the directory `runScript` locks and done-markers live in:
89
- * `XDG_DATA_HOME` when set (matching `@vitest-agent/sdk`'s `data.db`
90
- * resolution convention), else the same `~/.local/share` fallback that
91
- * convention documents.
37
+ * The directory `runScript` locks and done-markers live in:
38
+ * `$XDG_DATA_HOME/vitest-agent/runscript-locks`, else the `~/.local/share`
39
+ * fallback the `data.db` convention documents.
92
40
  *
93
- * @public
41
+ * @remarks
42
+ * `RunOnce` recommends a host-local runtime or state directory over a data
43
+ * directory. The plugin has no `@effected/xdg` dependency to resolve one, so
44
+ * the location stays where 2.x put it.
94
45
  */
95
- function resolveRunScriptLockDir(env = process.env, home = homedir()) {
46
+ function resolveRunScriptLockDir(env, home = homedir()) {
96
47
  const xdg = env.XDG_DATA_HOME;
97
48
  const base = xdg !== void 0 && xdg.length > 0 ? xdg : join(home, ".local", "share");
98
49
  return join(base, "vitest-agent", "runscript-locks");
99
50
  }
100
51
  /**
101
- * Deterministic, filesystem-safe key for one (workspace, command) pair
102
- * so different `globalSetup` commands in the same checkout don't share
103
- * a lock, while repeat invocations of the *same* command in the *same*
104
- * workspace do.
52
+ * The lock key for one command in one directory: a SHA-256 over the
53
+ * directory the command runs in and the command. Concurrent callers share a
54
+ * lock, and a waiter trusts the owner's run, only when they would execute
55
+ * the same command in the same place (the #191 race); a package's build and
56
+ * the root's are different runs. A string command and an argv command never
57
+ * share a key: the argv form hashes its JSON encoding.
105
58
  */
106
- function computeLockKey(cwd, command) {
107
- return createHash("sha256").update(`${cwd}\n${command}`).digest("hex").slice(0, 16);
108
- }
109
- /** Reads the owner record from a lock file. Returns `null` when the file is missing, unreadable, or not a well-formed owner record (e.g. observed mid-write by its creator). */
110
- function readLockOwner(lockPath) {
111
- let raw;
112
- try {
113
- raw = readFileSync(lockPath, "utf8");
114
- } catch {
115
- return null;
116
- }
117
- try {
118
- const parsed = JSON.parse(raw);
119
- if (typeof parsed?.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
120
- if (typeof parsed.nonce !== "string" || parsed.nonce.length === 0) return null;
121
- return {
122
- pid: parsed.pid,
123
- nonce: parsed.nonce,
124
- startedAt: String(parsed.startedAt ?? "")
125
- };
126
- } catch {
127
- return null;
128
- }
59
+ function runScriptLockKey(cwd, command) {
60
+ const identity = typeof command === "string" ? `sh\n${command}` : `argv\n${JSON.stringify(command)}`;
61
+ return `runscript-${createHash("sha256").update(`${cwd}\n${identity}`).digest("hex").slice(0, 32)}`;
129
62
  }
130
63
  /**
131
- * Signal-0 liveness probe. `EPERM` means the process exists but is
132
- * owned by another user — still alive, and emphatically not ours to
133
- * take over. Anything else (`ESRCH`) means it is gone.
134
- */
135
- function isProcessAlive(pid) {
136
- try {
137
- process.kill(pid, 0);
138
- return true;
139
- } catch (err) {
140
- return err.code === "EPERM";
141
- }
142
- }
143
- function defaultSleep(ms) {
144
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
145
- }
146
- function isRecentlyBuilt(doneMarkerPath, builtRecentlyMs) {
147
- try {
148
- return Date.now() - statSync(doneMarkerPath).mtimeMs < builtRecentlyMs;
149
- } catch {
150
- return false;
151
- }
152
- }
153
- function lockAgeMs(lockPath) {
154
- try {
155
- return Date.now() - statSync(lockPath).mtimeMs;
156
- } catch {
157
- return null;
158
- }
159
- }
160
- /**
161
- * Acquires (or observes) the advisory lock for one (cwd, command) pair.
162
- * Blocks the calling thread (via `sleep`) while another process holds
163
- * a non-stale lock, up to `waitTimeoutMs`.
64
+ * The `RunOnce` options one `runScript` call runs under, from the caller's
65
+ * cwd (where the command runs) and environment.
164
66
  *
165
- * @public
67
+ * @remarks
68
+ * `VITEST_AGENT_RUNSCRIPT_LOCK_STALE_MS`, `_LOCK_WAIT_TIMEOUT_MS` and
69
+ * `_LOCK_POLL_MS` tune the protocol (the concurrency e2e suite shortens
70
+ * them). `VITEST_AGENT_RUNSCRIPT_BUILT_RECENTLY_MS` is an opt-in: set, a done
71
+ * marker that young is trusted even from a previous sequential run; unset, a
72
+ * marker is trusted only from an owner this caller watched hold the lock.
166
73
  */
167
- function acquireRunScriptLock(options) {
168
- const { cwd, command, lockDir = resolveRunScriptLockDir(), staleMs = DEFAULT_LOCK_STALE_MS, waitTimeoutMs = DEFAULT_LOCK_WAIT_TIMEOUT_MS, pollMs = 200, builtRecentlyMs = DEFAULT_BUILT_RECENTLY_MS, sleep = defaultSleep } = options;
169
- mkdirSync(lockDir, { recursive: true });
170
- const key = computeLockKey(cwd, command);
171
- const lockPath = join(lockDir, `${key}.lock`);
172
- const doneMarkerPath = join(lockDir, `${key}.done`);
173
- const deadline = Date.now() + waitTimeoutMs;
174
- for (;;) {
175
- if (isRecentlyBuilt(doneMarkerPath, builtRecentlyMs)) return {
176
- lockPath,
177
- doneMarkerPath,
178
- acquired: false,
179
- recentlyBuilt: true,
180
- ownerNonce: null
181
- };
182
- let fd = null;
183
- try {
184
- fd = openSync(lockPath, "wx");
185
- } catch (err) {
186
- if (err.code !== "EEXIST") throw err;
187
- }
188
- if (fd !== null) {
189
- const ownerNonce = randomBytes(12).toString("hex");
190
- const record = {
191
- pid: process.pid,
192
- nonce: ownerNonce,
193
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
194
- };
195
- try {
196
- writeSync(fd, JSON.stringify(record));
197
- } catch (err) {
198
- try {
199
- rmSync(lockPath, { force: true });
200
- } catch {}
201
- throw err;
202
- } finally {
203
- try {
204
- closeSync(fd);
205
- } catch {}
206
- }
207
- return {
208
- lockPath,
209
- doneMarkerPath,
210
- acquired: true,
211
- recentlyBuilt: false,
212
- ownerNonce
213
- };
214
- }
215
- const age = lockAgeMs(lockPath);
216
- if (age !== null && age > staleMs) {
217
- const owner = readLockOwner(lockPath);
218
- if (owner === null || !isProcessAlive(owner.pid)) {
219
- try {
220
- rmSync(lockPath, { force: true });
221
- } catch {}
222
- continue;
223
- }
224
- }
225
- if (Date.now() > deadline) return {
226
- lockPath,
227
- doneMarkerPath,
228
- acquired: false,
229
- recentlyBuilt: false,
230
- ownerNonce: null
231
- };
232
- sleep(pollMs);
233
- }
74
+ function runScriptOptions(command, cwd, env) {
75
+ const staleMs = parseLockTimingOverride(env.VITEST_AGENT_RUNSCRIPT_LOCK_STALE_MS);
76
+ const waitTimeoutMs = parseLockTimingOverride(env.VITEST_AGENT_RUNSCRIPT_LOCK_WAIT_TIMEOUT_MS);
77
+ const pollMs = parseLockTimingOverride(env.VITEST_AGENT_RUNSCRIPT_LOCK_POLL_MS, MIN_LOCK_POLL_MS);
78
+ const trustSequentialMs = parseLockTimingOverride(env.VITEST_AGENT_RUNSCRIPT_BUILT_RECENTLY_MS);
79
+ return {
80
+ key: runScriptLockKey(cwd, command),
81
+ dir: resolveRunScriptLockDir(env),
82
+ ...staleMs !== void 0 ? { staleMs } : {},
83
+ ...waitTimeoutMs !== void 0 ? { waitTimeoutMs } : {},
84
+ ...pollMs !== void 0 ? { pollMs } : {},
85
+ ...trustSequentialMs !== void 0 ? { trustSequentialMs } : {}
86
+ };
234
87
  }
235
88
  /**
236
- * Releases a lock this process acquired. No-op when `lock.acquired` is
237
- * `false` (this process never owned it), and — critically — also a
238
- * no-op when the lock file on disk no longer carries this
239
- * acquisition's nonce: that means the lock was taken over and now
240
- * belongs to somebody else's in-flight build. Deleting it there would
241
- * admit a third process while the second is still running the command.
242
- *
243
- * @public
244
- */
245
- function releaseRunScriptLock(lock) {
246
- if (!lock.acquired || lock.ownerNonce === null) return;
247
- const owner = readLockOwner(lock.lockPath);
248
- if (owner === null || owner.nonce !== lock.ownerNonce) return;
249
- try {
250
- rmSync(lock.lockPath, { force: true });
251
- } catch {}
252
- }
253
- /**
254
- * Marks a successful build, so a waiter's next `isRecentlyBuilt` check
255
- * can skip re-running the command. Best-effort: a write failure just
256
- * means the next process won't short-circuit, not a correctness
257
- * problem.
258
- *
259
- * @public
89
+ * How a shell-string command runs: the platform shell `execSync` used, now
90
+ * spawned explicitly so `RunOnce` stays argv-only. On Windows it is exactly
91
+ * what Node's `shell` option spawns: `cmd.exe /d /s /c "<command>"` with
92
+ * verbatim arguments, so `cmd` (not Node's argv escaping) parses the quotes.
260
93
  */
261
- function markRunScriptDone(lock) {
262
- try {
263
- writeFileSync(lock.doneMarkerPath, String(Date.now()));
264
- } catch {}
94
+ function shellCommand(command, platform, env) {
95
+ return platform === "win32" ? {
96
+ argv: [
97
+ env.ComSpec ?? "cmd.exe",
98
+ "/d",
99
+ "/s",
100
+ "/c",
101
+ `"${command}"`
102
+ ],
103
+ windowsVerbatimArguments: true
104
+ } : { argv: [
105
+ "/bin/sh",
106
+ "-c",
107
+ command
108
+ ] };
265
109
  }
266
110
 
267
- export { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock, resolveRunScriptLockDir };
111
+ export { parseLockTimingOverride, resolveRunScriptLockDir, runScriptLockKey, runScriptOptions, shellCommand };