@vitest-agent/plugin 5.6.1 → 6.0.0
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/index.d.ts +58 -113
- package/index.js +1 -2
- package/layers/CoverageAnalyzerLive.js +3 -3
- package/package.json +20 -11
- package/plugin.js +13 -36
- package/reporter.js +123 -25
- package/utils/collect-performance-hints.js +205 -0
- package/utils/discover-projects.js +13 -47
- package/utils/discover-strategy.js +7 -15
- package/utils/failure-summary.js +78 -0
- package/utils/find-test-files.js +65 -82
- package/utils/is-test-shaped-package.js +4 -3
- package/utils/resolve-coverage-dir-isolation.js +1 -1
- package/utils/run-diagnostics.js +24 -0
- package/utils/run-script-lock.js +77 -233
- package/utils/runs-in-ci.js +15 -0
- package/utils/stray-output-capture.js +27 -127
- package/utils/vitest-diagnostics.js +173 -0
- package/utils/walker-fs.js +8 -23
- package/version.js +1 -1
|
@@ -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 };
|
package/utils/find-test-files.js
CHANGED
|
@@ -1,99 +1,82 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
*
|
|
26
|
+
* File walker that returns matched absolute paths.
|
|
54
27
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
* @
|
|
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,
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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 };
|
package/utils/run-script-lock.js
CHANGED
|
@@ -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
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
5
4
|
|
|
6
5
|
// src/utils/run-script-lock.ts
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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,
|
|
68
|
-
* for anything that is not a whole positive integer at or
|
|
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
|
-
*
|
|
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,
|
|
80
|
-
if (raw === void 0) return
|
|
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
|
|
31
|
+
if (!/^\d+$/.test(trimmed)) return void 0;
|
|
83
32
|
const parsed = Number.parseInt(trimmed, 10);
|
|
84
|
-
if (!Number.isSafeInteger(parsed) || parsed < minimum) return
|
|
33
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum) return void 0;
|
|
85
34
|
return parsed;
|
|
86
35
|
}
|
|
87
36
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
107
|
-
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
*
|
|
237
|
-
* `
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 {
|
|
111
|
+
export { parseLockTimingOverride, resolveRunScriptLockDir, runScriptLockKey, runScriptOptions, shellCommand };
|