@vitest-agent/mcp 5.3.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/README.md +1 -1
- package/index.d.ts +3101 -450
- package/index.js +31 -28
- package/main.js +8 -44
- package/package.json +9 -8
- package/replay.js +93 -0
- package/server.js +1 -1
- package/toolkit.js +25 -9
- package/tools/_tdd-error-envelope.js +119 -70
- package/tools/acceptance-metrics.js +1 -1
- package/tools/cache-health.js +1 -1
- package/tools/commit-changes.js +1 -1
- package/tools/configure.js +1 -1
- package/tools/coverage.js +1 -1
- package/tools/errors.js +1 -1
- package/tools/failure-signature-get.js +1 -1
- package/tools/file-coverage.js +1 -1
- package/tools/history.js +1 -1
- package/tools/hypothesis.js +7 -2
- package/tools/inventory.js +1 -1
- package/tools/note.js +1 -1
- package/tools/overview.js +1 -1
- package/tools/register-agent.js +2 -2
- package/tools/run-tests.js +310 -252
- package/tools/settings-list.js +1 -1
- package/tools/status.js +1 -1
- package/tools/tdd-artifact.js +1 -1
- package/tools/tdd-behavior.js +28 -23
- package/tools/tdd-goal.js +27 -24
- package/tools/tdd-phase-transition-request.js +1 -1
- package/tools/tdd-progress-push.js +1 -1
- package/tools/tdd-task.js +7 -2
- package/tools/test.js +1 -1
- package/tools/trends.js +1 -1
- package/tools/triage-brief.js +1 -1
- package/tools/turn-search.js +1 -1
- package/tools/wrapup-prompt.js +1 -1
- package/utils/safe-format-fatal-error.js +1 -1
- package/version.js +1 -1
- package/idempotency.js +0 -123
package/tools/run-tests.js
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import { McpSession } from "../session.js";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { ToolOutputSchema } from "@effected/mcp";
|
|
4
|
-
import { Data, Effect, Layer, Option, Path, Result, Schema
|
|
5
|
-
import { DataReader, DataStore } from "@vitest-agent/engine";
|
|
3
|
+
import { Context, Data, Effect, FileSystem, Layer, Option, Path, Result, Schema } from "effect";
|
|
6
4
|
import { Tool } from "effect/ai";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
5
|
+
import { DataReader, DataStore } from "@vitest-agent/engine";
|
|
6
|
+
import { AgentReport, RunDiagnostics, SuggestedActionRecord, VITEST_CONFIG_FILES, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote, readStrayOutput, suggestedActionsFromRunDiagnostics } from "@vitest-agent/sdk";
|
|
7
|
+
import { Exclusive, ProcessScope } from "@effected/engine";
|
|
10
8
|
import { stat } from "node:fs/promises";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { Writable } from "node:stream";
|
|
14
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { isAbsolute, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
15
11
|
import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
|
|
16
12
|
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
|
|
17
13
|
import * as NodePath from "@effect/platform-node/NodePath";
|
|
18
|
-
import {
|
|
14
|
+
import { ModuleResolution } from "@effected/commands/node-sync";
|
|
15
|
+
import { Git, TreeIdentity } from "@effected/git";
|
|
16
|
+
import { StdioCapture } from "@effected/mcp/node";
|
|
19
17
|
import { Walker } from "@effected/walker";
|
|
20
18
|
|
|
21
19
|
// src/tools/run-tests.ts
|
|
@@ -44,7 +42,8 @@ const RunTestsOk = Schema.Struct({
|
|
|
44
42
|
classifications: Schema.Record(Schema.String, Schema.String).annotate({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." }),
|
|
45
43
|
discoveryLastScannedAt: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "ISO timestamp of the most recent real disk scan performed by discoverProjects() in this process (issue #100). `null`/absent means discovery has not scanned disk in this process yet (e.g. a config that doesn't call AgentPlugin.discover()). A stale-looking test count is self-explaining when compared against this value." }),
|
|
46
44
|
scopedNote: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Set (issue #160) when this call was scoped to a subset of the project's test files (files/project/tags filter supplied). Vitest enforces coverage.thresholds against the whole-project denominator regardless of how many files ran, so a scoped run's threshold check is unreliable; this note explains why no coverage verdict should be trusted for this call. `null`/absent means the run was unscoped." }),
|
|
47
|
-
suggestedActions: Schema.optionalKey(Schema.Array(SuggestedActionRecord)).annotate({ description: "Run-level suggested actions the plugin raised at config time (issue #544): one entry per coverage-config diagnostic the plugin's ConfigValidation reported, such as a coverageTargets metric with no matching coverage.thresholds floor (severity warn). The same entries print on the plugin's stderr for a human and in the rendered report's Actions trailer. Absent when the project's config validated clean or does not load the plugin." })
|
|
45
|
+
suggestedActions: Schema.optionalKey(Schema.Array(SuggestedActionRecord)).annotate({ description: "Run-level suggested actions the plugin raised at config time (issue #544): one entry per coverage-config diagnostic the plugin's ConfigValidation reported, such as a coverageTargets metric with no matching coverage.thresholds floor (severity warn). The same entries print on the plugin's stderr for a human and in the rendered report's Actions trailer. Absent when the project's config validated clean or does not load the plugin. Includes the actions derived from `runDiagnostics`, after the config ones." }),
|
|
46
|
+
runDiagnostics: Schema.optionalKey(RunDiagnostics).annotate({ description: "Run-level diagnostics the plugin reporter published for this run (issue #544): Vitest performance hints, post-run timing (persist, coverage scan, total) and whether results were persisted. Their suggested actions are merged into `suggestedActions`. Absent when the project does not load the plugin." })
|
|
48
47
|
}).annotate({ identifier: "RunTestsOk" });
|
|
49
48
|
const RunTestsTimeout = Schema.Struct({
|
|
50
49
|
kind: Schema.Literal("timeout"),
|
|
@@ -142,6 +141,20 @@ function readConfigSuggestedActions(vitest) {
|
|
|
142
141
|
const decoded = Schema.decodeUnknownResult(Schema.Array(SuggestedActionRecord))(value);
|
|
143
142
|
return Result.isSuccess(decoded) ? decoded.success : void 0;
|
|
144
143
|
}
|
|
144
|
+
const RUN_DIAGNOSTICS_SYMBOL = Symbol.for("vitest-agent:run-diagnostics");
|
|
145
|
+
/**
|
|
146
|
+
* Reads the run diagnostics the plugin published on a Vitest instance.
|
|
147
|
+
* `undefined` when none were published (no plugin, or a clean run) or the
|
|
148
|
+
* slot holds a foreign shape; the value is decoded against `RunDiagnostics`.
|
|
149
|
+
*
|
|
150
|
+
* @internal
|
|
151
|
+
*/
|
|
152
|
+
function readRunDiagnostics(vitest) {
|
|
153
|
+
const value = vitest[RUN_DIAGNOSTICS_SYMBOL];
|
|
154
|
+
if (value === void 0 || value === null) return void 0;
|
|
155
|
+
const decoded = Schema.decodeUnknownResult(RunDiagnostics)(value);
|
|
156
|
+
return Result.isSuccess(decoded) ? decoded.success : void 0;
|
|
157
|
+
}
|
|
145
158
|
/**
|
|
146
159
|
* Per-invocation coverage temp directory. Vitest's v8 provider `rm -rf`s the
|
|
147
160
|
* shared `coverage/` reports directory at run start (`clean: true` default),
|
|
@@ -154,60 +167,39 @@ function readConfigSuggestedActions(vitest) {
|
|
|
154
167
|
* coverage from SQLite (`CoverageAnalyzer` consumes the in-memory map via
|
|
155
168
|
* `onCoverage`), not from disk artifacts.
|
|
156
169
|
*
|
|
157
|
-
*
|
|
170
|
+
* Removed when the run's scope closes. The removal ignores a failure (a
|
|
171
|
+
* directory already gone, a tmpdir reaper racing it): core's
|
|
172
|
+
* `makeTempDirectoryScoped` dies on one, which would turn a finished run into
|
|
173
|
+
* a defect.
|
|
158
174
|
*/
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
coverage: { reportsDirectory: dir }
|
|
164
|
-
};
|
|
165
|
-
};
|
|
175
|
+
const coverageTempDirectory = Effect.flatMap(FileSystem.FileSystem, (fs) => Effect.acquireRelease(fs.makeTempDirectory({ prefix: "vitest-agent-cov-" }), (dir) => fs.remove(dir, {
|
|
176
|
+
recursive: true,
|
|
177
|
+
force: true
|
|
178
|
+
}).pipe(Effect.ignore)));
|
|
166
179
|
/**
|
|
167
|
-
* Typed wrapper for a rejected
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const stderrSinkStorage = new AsyncLocalStorage();
|
|
174
|
-
let _stdioPatched = false;
|
|
175
|
-
let _originalStdoutWrite;
|
|
176
|
-
let _originalStderrWrite;
|
|
177
|
-
function ensureStdioPatched() {
|
|
178
|
-
if (_stdioPatched) return;
|
|
179
|
-
_stdioPatched = true;
|
|
180
|
-
_originalStdoutWrite = process.stdout.write;
|
|
181
|
-
_originalStderrWrite = process.stderr.write;
|
|
182
|
-
process.stdout.write = function patchedStdoutWrite(...args) {
|
|
183
|
-
const sink = stdoutSinkStorage.getStore();
|
|
184
|
-
if (sink) return sink.write.apply(sink, args);
|
|
185
|
-
return _originalStdoutWrite.apply(this, args);
|
|
186
|
-
};
|
|
187
|
-
process.stderr.write = function patchedStderrWrite(...args) {
|
|
188
|
-
const sink = stderrSinkStorage.getStore();
|
|
189
|
-
if (sink) return sink.write.apply(sink, args);
|
|
190
|
-
return _originalStderrWrite.apply(this, args);
|
|
191
|
-
};
|
|
192
|
-
}
|
|
180
|
+
* Typed wrapper for a rejected step of the run (loading `vitest/node`,
|
|
181
|
+
* `createVitest`, `vitest.start`, the coverage temp dir). Keeps the run's
|
|
182
|
+
* error channel a tagged union so the timeout branches are recovered by tag
|
|
183
|
+
* (issue #320), and carries the raw cause for the `{ kind: "error" }` message.
|
|
184
|
+
*/
|
|
185
|
+
var RunStepFailure = class extends Data.TaggedError("RunStepFailure") {};
|
|
193
186
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* logging. User-registered reporters that call `console.log` directly
|
|
202
|
-
* bypass them; this helper captures those writes into the supplied
|
|
203
|
-
* sink so they don't corrupt the JSON-RPC protocol stream.
|
|
187
|
+
* The stdio capture `run_tests` diverts the nested Vitest run's
|
|
188
|
+
* `process.stdout` / `process.stderr` writes through, so a user reporter's
|
|
189
|
+
* `console.log` never reaches the JSON-RPC wire while every other tool
|
|
190
|
+
* handler (and the transport) keeps writing normally. `main.ts` installs it
|
|
191
|
+
* once at boot and provides it; the default installs it on first use for an
|
|
192
|
+
* embedder or a test that never ran `main.ts` (installing is idempotent per
|
|
193
|
+
* stream).
|
|
204
194
|
*
|
|
205
195
|
* @internal
|
|
206
196
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
197
|
+
const RunTestsStdio = Context.Reference("@vitest-agent/mcp/RunTestsStdio", { defaultValue: () => StdioCapture.install(process) });
|
|
198
|
+
/** Both streams discarded for the run's own async context. */
|
|
199
|
+
const DISCARD_STDIO = {
|
|
200
|
+
stdout: StdioCapture.discard,
|
|
201
|
+
stderr: StdioCapture.discard
|
|
202
|
+
};
|
|
211
203
|
function sanitizeTestArgs(args) {
|
|
212
204
|
const result = [];
|
|
213
205
|
for (const arg of args) {
|
|
@@ -227,15 +219,6 @@ function sanitizeTestArgs(args) {
|
|
|
227
219
|
* as a silent pass.
|
|
228
220
|
*/
|
|
229
221
|
const gitCommonDir = (dir) => Effect.flatMap(Git, (git) => git.commonDir(dir)).pipe(Effect.option, Effect.map(Option.getOrNull), Effect.catchDefect(() => Effect.succeed(null)));
|
|
230
|
-
const VITEST_CONFIG_PREFIXES = ["vitest.config.", "vite.config."];
|
|
231
|
-
const VITEST_CONFIG_EXTENSIONS = [
|
|
232
|
-
"ts",
|
|
233
|
-
"mts",
|
|
234
|
-
"cts",
|
|
235
|
-
"js",
|
|
236
|
-
"mjs",
|
|
237
|
-
"cjs"
|
|
238
|
-
];
|
|
239
222
|
/**
|
|
240
223
|
* The one walk both anchoring helpers share: step UP from `startDir`
|
|
241
224
|
* until a vitest/vite config file is found, returning its absolute path.
|
|
@@ -256,7 +239,7 @@ const walkUpToConfigFile = (startDir) => Effect.gen(function* () {
|
|
|
256
239
|
const start = path.resolve(startDir);
|
|
257
240
|
const git = yield* Git;
|
|
258
241
|
const dirs = yield* Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)));
|
|
259
|
-
const found = yield* Walker.findUpward(dirs, (dir) =>
|
|
242
|
+
const found = yield* Walker.findUpward(dirs, (dir) => VITEST_CONFIG_FILES.map((name) => path.join(dir, name)));
|
|
260
243
|
return Option.getOrNull(found);
|
|
261
244
|
}).pipe(Effect.catchDefect(() => Effect.succeed(null)));
|
|
262
245
|
/**
|
|
@@ -333,6 +316,46 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
|
|
|
333
316
|
};
|
|
334
317
|
}
|
|
335
318
|
/**
|
|
319
|
+
* The tree identity for `dir` (`Git.treeIdentity`: its physical toplevel and
|
|
320
|
+
* the shared common dir, from one `git rev-parse`), or `None` when git cannot
|
|
321
|
+
* answer (not a repository, a bare repository, git failure). Git answers
|
|
322
|
+
* physical paths, so identities are only ever compared with each other.
|
|
323
|
+
*/
|
|
324
|
+
const treeIdentityOf = (dir) => Effect.flatMap(Git, (git) => git.treeIdentity(dir)).pipe(Effect.orElseSucceed(() => Option.none()), Effect.catchDefect(() => Effect.succeed(Option.none())));
|
|
325
|
+
/**
|
|
326
|
+
* Issue #275: refuse a call whose hook-injected `_callerCwd` sits in a
|
|
327
|
+
* DIFFERENT working tree of the same repository than the root the run would
|
|
328
|
+
* use. Anything indeterminate (absent, empty, relative, nonexistent, not a
|
|
329
|
+
* directory, outside git, a different repository) proceeds: the server
|
|
330
|
+
* cannot tell, and refusing on absence would break every non-plugin
|
|
331
|
+
* consumer.
|
|
332
|
+
*
|
|
333
|
+
* @internal exported for tests
|
|
334
|
+
*/
|
|
335
|
+
async function checkCallerWorktree(callerCwd, resolvedRoot) {
|
|
336
|
+
if (callerCwd === void 0 || callerCwd === "" || !isAbsolute(callerCwd)) return {
|
|
337
|
+
ok: true,
|
|
338
|
+
root: resolvedRoot
|
|
339
|
+
};
|
|
340
|
+
if (!await stat(callerCwd).then((s) => s.isDirectory(), () => false)) return {
|
|
341
|
+
ok: true,
|
|
342
|
+
root: resolvedRoot
|
|
343
|
+
};
|
|
344
|
+
const [caller, root] = await runOnNodePlatform(Effect.all([treeIdentityOf(callerCwd), treeIdentityOf(resolvedRoot)], { concurrency: 2 }));
|
|
345
|
+
if (Option.isNone(caller) || Option.isNone(root)) return {
|
|
346
|
+
ok: true,
|
|
347
|
+
root: resolvedRoot
|
|
348
|
+
};
|
|
349
|
+
if (TreeIdentity.relation(caller.value, root.value) !== "sibling-worktree") return {
|
|
350
|
+
ok: true,
|
|
351
|
+
root: resolvedRoot
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
message: `This call came from the working tree "${caller.value.toplevel}" but the Vitest root resolved to "${root.value.toplevel}", a different working tree of the same repository, so the run would report results for the wrong tree. Pass projectRoot (the absolute path of your own working tree, "${caller.value.toplevel}") to run there.`
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
336
359
|
* Issue #303: resolve `vitest/node` anchored at the run's project root
|
|
337
360
|
* instead of the bare `"vitest/node"` specifier, which resolves relative to
|
|
338
361
|
* `@vitest-agent/mcp`'s OWN install location. `vitest` is a peerDependency
|
|
@@ -348,17 +371,16 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
|
|
|
348
371
|
* for '<file>' is not found. Did you call 'SnapshotClient.setup()'?" while
|
|
349
372
|
* every non-snapshot assertion still passes.
|
|
350
373
|
*
|
|
351
|
-
* `
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* `file://` URL, which is what dynamic `import()` needs.
|
|
374
|
+
* Resolution is `ModuleResolution.resolve` from `@effected/commands`,
|
|
375
|
+
* anchored at `root` (a directory works as-is). vitest's package.json
|
|
376
|
+
* `exports` map for `./node` carries a `default` condition, so CommonJS
|
|
377
|
+
* resolution finds it even though vitest itself is ESM; the answer is the
|
|
378
|
+
* real path (Node's resolver follows symlinks), which is the spelling that
|
|
379
|
+
* identifies the module instance.
|
|
358
380
|
*
|
|
359
381
|
* Falls back to the bare `"vitest/node"` specifier when root-anchored
|
|
360
|
-
* resolution
|
|
361
|
-
* that case keeps working exactly as it did before this fix.
|
|
382
|
+
* resolution finds nothing (e.g. a project root with no local vitest
|
|
383
|
+
* install) so that case keeps working exactly as it did before this fix.
|
|
362
384
|
*
|
|
363
385
|
* The property this buys — one module instance shared by the server and the
|
|
364
386
|
* project's test files — is pinned by `vitest-node-module-identity.test.ts`
|
|
@@ -369,12 +391,7 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
|
|
|
369
391
|
* @internal exported for tests
|
|
370
392
|
*/
|
|
371
393
|
function resolveVitestNodeEntry(root) {
|
|
372
|
-
|
|
373
|
-
const req = createRequire(join(root, "__vitest-agent-resolver__.js"));
|
|
374
|
-
return pathToFileURL(req.resolve("vitest/node")).href;
|
|
375
|
-
} catch {
|
|
376
|
-
return "vitest/node";
|
|
377
|
-
}
|
|
394
|
+
return Option.getOrElse(ModuleResolution.resolve("vitest/node", { from: root }), () => "vitest/node");
|
|
378
395
|
}
|
|
379
396
|
/**
|
|
380
397
|
* Issue #461: the MCP server is long-lived. When the lockfile is regenerated
|
|
@@ -382,36 +399,59 @@ function resolveVitestNodeEntry(root) {
|
|
|
382
399
|
* store directory (peer-hash change), so the entry resolved for this process
|
|
383
400
|
* no longer exists and every run would otherwise fail opaquely inside the fork
|
|
384
401
|
* pool ("Worker forks emitted error", total: 0). Returns an actionable message
|
|
385
|
-
* when `entry`
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
402
|
+
* when `entry` (an absolute path or a `file://` URL) is missing on disk
|
|
403
|
+
* (`ModuleResolution.isStale`, which checks the exact path that will be
|
|
404
|
+
* imported), else `null`. The bare `"vitest/node"` fallback is not a path and
|
|
405
|
+
* is never stale.
|
|
389
406
|
*
|
|
390
407
|
* @internal exported for tests
|
|
391
408
|
*/
|
|
392
409
|
function staleInstallMessage(entry) {
|
|
393
|
-
if (!
|
|
394
|
-
|
|
395
|
-
if (existsSync(path)) return null;
|
|
396
|
-
return `The resolved vitest entry "${path}" no longer exists on disk. The install changed since this vitest-agent MCP server loaded it (for example the lockfile was regenerated by a dependency install or branch switch). Restart the vitest-agent MCP server (in Claude Code: /mcp) or restart the session, then retry.`;
|
|
410
|
+
if (!ModuleResolution.isStale(entry)) return null;
|
|
411
|
+
return `The resolved vitest entry "${entry.startsWith("file:") ? fileURLToPath(entry) : entry}" no longer exists on disk. The install changed since this vitest-agent MCP server loaded it (for example the lockfile was regenerated by a dependency install or branch switch). Restart the vitest-agent MCP server (in Claude Code: /mcp) or restart the session, then retry.`;
|
|
397
412
|
}
|
|
398
413
|
/**
|
|
399
|
-
*
|
|
414
|
+
* The test seam around resolving and importing `vitest/node`. vitest's own
|
|
400
415
|
* vite-node externalizes "vitest"/"vitest/node" for every importer, and
|
|
401
|
-
* `vi.mock("vitest/node", ...)` only
|
|
402
|
-
* `import("vitest/node")` call sites
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* `.load`
|
|
406
|
-
*
|
|
416
|
+
* `vi.mock("vitest/node", ...)` only intercepts AST-literal
|
|
417
|
+
* `import("vitest/node")` call sites — a computed specifier (unavoidable
|
|
418
|
+
* here; see `resolveVitestNodeEntry`) silently bypasses it. So the import is
|
|
419
|
+
* `ModuleResolution.load` with an injectable `importer`: tests substitute
|
|
420
|
+
* `.load` (the importer, called with the entry as a `file:` URL or the bare
|
|
421
|
+
* fallback specifier) and `.resolveEntry` by mutating this shared object,
|
|
422
|
+
* no `vi.mock` required. The production importer is this module's own
|
|
423
|
+
* `import()`, never `ModuleResolution.load`'s default: the bare
|
|
424
|
+
* `"vitest/node"` fallback must resolve from this package, which declares
|
|
425
|
+
* the `vitest` peer, not from `@effected/commands`, which does not.
|
|
407
426
|
*
|
|
408
427
|
* @internal exported for tests
|
|
409
428
|
*/
|
|
410
429
|
const vitestLoader = {
|
|
411
430
|
resolveEntry: (root) => resolveVitestNodeEntry(root),
|
|
412
|
-
load: (
|
|
431
|
+
load: (url) => import(
|
|
432
|
+
/* @vite-ignore */
|
|
433
|
+
url
|
|
434
|
+
)
|
|
413
435
|
};
|
|
414
|
-
|
|
436
|
+
/**
|
|
437
|
+
* The one-permit lane every `run_tests` body runs in. The body assigns the
|
|
438
|
+
* active attribution UUIDs into `process.env.VITEST_AGENT_*`, enters the run's
|
|
439
|
+
* root with `chdir`, and then awaits `createVitest` / `vitest.start`, which
|
|
440
|
+
* spawns the worker pool that snapshots env (and cwd) at spawn time. Two
|
|
441
|
+
* interleaved tool calls would race: caller B's env assignment can land
|
|
442
|
+
* between A's assignment and A's worker spawn, attributing A's results to B's
|
|
443
|
+
* agent. `Exclusive` keeps the whole body atomic against every other
|
|
444
|
+
* `run_tests` call in this process; its scope restores env and cwd before the
|
|
445
|
+
* next call starts, and a client cancel (an interrupt of the request fiber)
|
|
446
|
+
* only drops the response — the permit is held until the body ends.
|
|
447
|
+
*/
|
|
448
|
+
const runTestsLane = Effect.runSync(Exclusive.make);
|
|
449
|
+
/**
|
|
450
|
+
* How long a run may hold the lane beyond its own test timeout: config
|
|
451
|
+
* loading (`createVitest`), the classification read, and teardown. The test
|
|
452
|
+
* timeout itself bounds only `vitest.start`, as it always has.
|
|
453
|
+
*/
|
|
454
|
+
const LANE_ALLOWANCE_MS = 12e4;
|
|
415
455
|
/**
|
|
416
456
|
* Coerce unknown Vitest unhandled errors into VitestModuleError shape.
|
|
417
457
|
*
|
|
@@ -442,6 +482,7 @@ const RunTestsInput = Schema.Struct({
|
|
|
442
482
|
tags: Schema.optionalKey(TagFilter).annotate({ description: "Structured tag filter; all/any/none AND together with each other and with project/files" }),
|
|
443
483
|
passWithNoTests: Schema.optionalKey(Schema.Boolean).annotate({ description: "Per-call override of Vitest's native test.passWithNoTests" }),
|
|
444
484
|
timeout: Schema.optionalKey(Schema.Finite).annotate({ description: "Timeout in seconds (default: 120)" }),
|
|
485
|
+
_callerCwd: Schema.optionalKey(Schema.String).annotate({ description: "Hook-injected caller working directory; do not pass manually." }),
|
|
445
486
|
_sessionContext: Schema.optionalKey(Schema.Struct({
|
|
446
487
|
chatId: Schema.String,
|
|
447
488
|
conversationId: Schema.String,
|
|
@@ -519,6 +560,7 @@ function toNoMatch(scope, projectRoot) {
|
|
|
519
560
|
*/
|
|
520
561
|
function toOkPayload(args) {
|
|
521
562
|
const { scope } = args;
|
|
563
|
+
const suggestedActions = [...args.suggestedActions ?? [], ...args.runDiagnostics !== void 0 ? suggestedActionsFromRunDiagnostics(args.runDiagnostics) : []];
|
|
522
564
|
return {
|
|
523
565
|
kind: "ok",
|
|
524
566
|
...scope.project !== void 0 && { project: scope.project },
|
|
@@ -532,7 +574,8 @@ function toOkPayload(args) {
|
|
|
532
574
|
classifications: args.classifications ? Object.fromEntries(args.classifications) : {},
|
|
533
575
|
discoveryLastScannedAt: args.discoveryLastScannedAt ?? null,
|
|
534
576
|
scopedNote: args.scopedNote,
|
|
535
|
-
...
|
|
577
|
+
...suggestedActions.length > 0 ? { suggestedActions } : {},
|
|
578
|
+
...args.runDiagnostics !== void 0 && { runDiagnostics: args.runDiagnostics }
|
|
536
579
|
};
|
|
537
580
|
}
|
|
538
581
|
/**
|
|
@@ -547,27 +590,138 @@ function toOkPayload(args) {
|
|
|
547
590
|
*/
|
|
548
591
|
const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provideContext(effect.pipe(Effect.ignore), services));
|
|
549
592
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* `{ kind: "error" }` envelope
|
|
593
|
+
* A rejected step's message. Exception-safe: a hostile thrown value (a
|
|
594
|
+
* throwing `message` getter or `toString`) still yields a string, so the
|
|
595
|
+
* `{ kind: "error" }` envelope always comes back.
|
|
596
|
+
*/
|
|
597
|
+
const errorMessage = (err) => {
|
|
598
|
+
try {
|
|
599
|
+
return err instanceof Error ? err.message : String(err);
|
|
600
|
+
} catch {
|
|
601
|
+
return coerceErrorField(err, "message") ?? "<unserializable error>";
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* A promise-returning Vitest step, run through the lane's promise. By default
|
|
606
|
+
* an interrupt aborts, then waits for the promise to settle; `abandon` resumes
|
|
607
|
+
* at once instead, for a promise that ignores its signal.
|
|
608
|
+
*/
|
|
609
|
+
const step = (evaluate, options) => Exclusive.promise({
|
|
610
|
+
try: evaluate,
|
|
611
|
+
catch: (cause) => new RunStepFailure({ cause }),
|
|
612
|
+
...options
|
|
613
|
+
});
|
|
614
|
+
/** The Node platform the run's root validation, config walk and git reads use. */
|
|
615
|
+
const onNodePlatform = (effect) => Effect.provide(effect, NodeWalkPlatform);
|
|
616
|
+
/**
|
|
617
|
+
* The run itself, once the root, config and `vitest/node` entry are settled:
|
|
618
|
+
* every step that can reject fails as `RunStepFailure`, which the body folds
|
|
619
|
+
* into `{ kind: "error" }` along with any throw, and every process-global change (cwd, the coverage temp dir, the Vitest instance) is
|
|
620
|
+
* released by the lane's scope on every exit path.
|
|
553
621
|
*/
|
|
554
|
-
const
|
|
622
|
+
const runVitest = (args) => Effect.gen(function* () {
|
|
623
|
+
const { input, ctx, scope, resolvedRoot, anchoredConfig, vitestEntry } = args;
|
|
624
|
+
const { files, project, resolvedExpression, hasFilter } = scope;
|
|
625
|
+
const timeoutSeconds = input.timeout ?? 120;
|
|
626
|
+
const { createVitest } = yield* step(() => ModuleResolution.load(vitestEntry, { importer: vitestLoader.load }));
|
|
627
|
+
yield* ProcessScope.chdir(process, resolvedRoot);
|
|
628
|
+
const coverageDir = yield* coverageTempDirectory.pipe(Effect.mapError((cause) => new RunStepFailure({ cause })));
|
|
629
|
+
const nullStream = yield* Effect.acquireRelease(Effect.sync(StdioCapture.nullWritable), (stream) => Effect.sync(() => stream.destroy()));
|
|
630
|
+
const vitest = yield* Effect.acquireRelease(step(() => createVitest({
|
|
631
|
+
root: resolvedRoot,
|
|
632
|
+
...anchoredConfig !== void 0 ? { config: anchoredConfig } : {},
|
|
633
|
+
run: true,
|
|
634
|
+
coverage: { reportsDirectory: coverageDir },
|
|
635
|
+
...project ? { project } : {},
|
|
636
|
+
...resolvedExpression !== null ? { tagsFilter: [resolvedExpression] } : {},
|
|
637
|
+
...input.passWithNoTests !== void 0 ? { passWithNoTests: input.passWithNoTests } : {}
|
|
638
|
+
}, {}, {
|
|
639
|
+
stdout: nullStream,
|
|
640
|
+
stderr: nullStream
|
|
641
|
+
})), (instance) => Effect.tryPromise(() => instance.close()).pipe(Effect.ignore));
|
|
642
|
+
const capture = yield* RunTestsStdio;
|
|
643
|
+
const started = yield* step(() => capture.run(DISCARD_STDIO, () => vitest.start(files.length > 0 ? files : void 0)), { abandon: true }).pipe(Effect.timeout(timeoutSeconds * 1e3), Effect.map((value) => ({
|
|
644
|
+
outcome: "ok",
|
|
645
|
+
value
|
|
646
|
+
})), Effect.catchTag("TimeoutError", () => Effect.succeed({ outcome: "timeout" })));
|
|
647
|
+
if (started.outcome === "timeout") return {
|
|
648
|
+
kind: "timeout",
|
|
649
|
+
timeoutSeconds
|
|
650
|
+
};
|
|
651
|
+
const result = started.value;
|
|
652
|
+
const testModules = result.testModules;
|
|
653
|
+
const unhandledErrors = coerceErrors(result.unhandledErrors);
|
|
654
|
+
if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
|
|
655
|
+
const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
|
|
656
|
+
const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
|
|
657
|
+
const leaks = buildConsoleLeaks(collectConsoleLeakEntries(vitest.state.getFiles()));
|
|
658
|
+
const strayOutput = readStrayOutput(vitest.logger);
|
|
659
|
+
const configSuggestedActions = readConfigSuggestedActions(vitest);
|
|
660
|
+
const runDiagnostics = readRunDiagnostics(vitest);
|
|
661
|
+
const report = {
|
|
662
|
+
...baseReport,
|
|
663
|
+
...leaks !== void 0 && { consoleLeaks: leaks },
|
|
664
|
+
...strayOutput !== void 0 && { strayOutput }
|
|
665
|
+
};
|
|
666
|
+
const classifications = yield* Effect.gen(function* () {
|
|
667
|
+
const reader = yield* DataReader;
|
|
668
|
+
const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
|
|
669
|
+
const entries = [];
|
|
670
|
+
for (const p of projects) {
|
|
671
|
+
const tests = yield* reader.listTests(p, {});
|
|
672
|
+
for (const t of tests) if (t.classification != null) entries.push([t.fullName, t.classification]);
|
|
673
|
+
}
|
|
674
|
+
return new Map(entries);
|
|
675
|
+
}).pipe(Effect.catchCause(() => Effect.succeed(void 0)));
|
|
676
|
+
const chatId = ctx.currentSessionId.get();
|
|
677
|
+
if (chatId !== null) makeBestEffortFork(yield* Effect.context())(Effect.gen(function* () {
|
|
678
|
+
yield* (yield* DataStore).associateLatestRunWithSession({
|
|
679
|
+
chatId,
|
|
680
|
+
invocationMethod: "mcp"
|
|
681
|
+
});
|
|
682
|
+
}));
|
|
683
|
+
let scopedNote = null;
|
|
684
|
+
if (hasFilter) {
|
|
685
|
+
const totalFileCount = yield* step(() => vitest.globTestSpecifications()).pipe(Effect.map((specs) => specs.length), Effect.orElseSucceed(() => void 0));
|
|
686
|
+
scopedNote = formatScopedCoverageNote(testModules.length, totalFileCount);
|
|
687
|
+
}
|
|
688
|
+
return toOkPayload({
|
|
689
|
+
scope,
|
|
690
|
+
projectRoot: resolvedRoot,
|
|
691
|
+
report,
|
|
692
|
+
classifications,
|
|
693
|
+
discoveryLastScannedAt: readDiscoveryLastScannedAt(),
|
|
694
|
+
scopedNote,
|
|
695
|
+
...configSuggestedActions !== void 0 && { suggestedActions: configSuggestedActions },
|
|
696
|
+
...runDiagnostics !== void 0 && { runDiagnostics }
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
/**
|
|
700
|
+
* The run body, inside the lane: input checks that return the tool's
|
|
701
|
+
* `{ kind: "error" }` result before anything process-global changes, then
|
|
702
|
+
* the attribution env and the run. Never fails.
|
|
703
|
+
*/
|
|
704
|
+
const runTestsBody = (input, ctx) => Effect.gen(function* () {
|
|
555
705
|
const derived = deriveRunScope(input);
|
|
556
706
|
if (Result.isFailure(derived)) return {
|
|
557
707
|
kind: "error",
|
|
558
708
|
message: derived.failure
|
|
559
709
|
};
|
|
560
710
|
const scope = derived.success;
|
|
561
|
-
const
|
|
562
|
-
const projectRootValidation = await validateProjectRoot(input.projectRoot, ctx.cwd);
|
|
711
|
+
const projectRootValidation = yield* Effect.promise(() => validateProjectRoot(input.projectRoot, ctx.cwd));
|
|
563
712
|
if (!projectRootValidation.ok) return {
|
|
564
713
|
kind: "error",
|
|
565
714
|
message: projectRootValidation.message
|
|
566
715
|
};
|
|
567
716
|
const resolvedRoot = projectRootValidation.root;
|
|
717
|
+
const callerCheck = yield* Effect.promise(() => checkCallerWorktree(input._callerCwd, resolvedRoot));
|
|
718
|
+
if (!callerCheck.ok) return {
|
|
719
|
+
kind: "error",
|
|
720
|
+
message: callerCheck.message
|
|
721
|
+
};
|
|
568
722
|
let anchoredConfig;
|
|
569
723
|
if (input.projectRoot !== void 0) {
|
|
570
|
-
const found =
|
|
724
|
+
const found = yield* onNodePlatform(resolveAnchoredConfigFile(resolvedRoot));
|
|
571
725
|
if (found === null) return {
|
|
572
726
|
kind: "error",
|
|
573
727
|
message: `No vitest.config.* or vite.config.* was found at or above projectRoot "${resolvedRoot}" within the repository. Vitest 5 does not search ancestor directories, so this run would collect no tests and load no plugins. Pass a projectRoot that contains a Vitest config, or omit projectRoot to anchor automatically.`
|
|
@@ -581,157 +735,61 @@ const runTestsBody = async (input, ctx) => {
|
|
|
581
735
|
reason: "stale-install",
|
|
582
736
|
message: staleMessage
|
|
583
737
|
};
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
previousCwd = process.cwd();
|
|
600
|
-
} catch {
|
|
601
|
-
previousCwd = void 0;
|
|
602
|
-
}
|
|
603
|
-
try {
|
|
604
|
-
process.chdir(resolvedRoot);
|
|
605
|
-
covOverride = makeCoverageDirOverride();
|
|
606
|
-
vitest = await createVitest({
|
|
607
|
-
root: resolvedRoot,
|
|
608
|
-
...anchoredConfig !== void 0 ? { config: anchoredConfig } : {},
|
|
609
|
-
run: true,
|
|
610
|
-
coverage: covOverride.coverage,
|
|
611
|
-
...project ? { project } : {},
|
|
612
|
-
...resolvedExpression !== null ? { tagsFilter: [resolvedExpression] } : {},
|
|
613
|
-
...input.passWithNoTests !== void 0 ? { passWithNoTests: input.passWithNoTests } : {}
|
|
614
|
-
}, {}, {
|
|
615
|
-
stdout: nullStream,
|
|
616
|
-
stderr: nullStream
|
|
617
|
-
});
|
|
618
|
-
const localVitest = vitest;
|
|
619
|
-
const startOutcome = await withStdioCaptured(nullStream, () => Effect.runPromise(Effect.tryPromise({
|
|
620
|
-
try: () => localVitest.start(files.length > 0 ? files : void 0),
|
|
621
|
-
catch: (cause) => new VitestStartFailure({ cause })
|
|
622
|
-
}).pipe(Effect.timeout(timeoutMs), Effect.map((value) => ({
|
|
623
|
-
outcome: "ok",
|
|
624
|
-
value
|
|
625
|
-
})), Effect.catchTag("TimeoutError", () => Effect.succeed({ outcome: "timeout" })), Effect.catchTag("VitestStartFailure", (e) => Effect.succeed({
|
|
626
|
-
outcome: "failed",
|
|
627
|
-
cause: e.cause
|
|
628
|
-
})))));
|
|
629
|
-
if (startOutcome.outcome === "timeout") return {
|
|
630
|
-
kind: "timeout",
|
|
631
|
-
timeoutSeconds: input.timeout ?? 120
|
|
632
|
-
};
|
|
633
|
-
if (startOutcome.outcome === "failed") throw startOutcome.cause;
|
|
634
|
-
const result = startOutcome.value;
|
|
635
|
-
const testModules = result.testModules;
|
|
636
|
-
const unhandledErrors = coerceErrors(result.unhandledErrors);
|
|
637
|
-
if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
|
|
638
|
-
const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
|
|
639
|
-
const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
|
|
640
|
-
const leaks = buildConsoleLeaks(collectConsoleLeakEntries(localVitest.state.getFiles()));
|
|
641
|
-
const strayOutput = readStrayOutput(localVitest.logger);
|
|
642
|
-
const configSuggestedActions = readConfigSuggestedActions(localVitest);
|
|
643
|
-
const report = {
|
|
644
|
-
...baseReport,
|
|
645
|
-
...leaks !== void 0 && { consoleLeaks: leaks },
|
|
646
|
-
...strayOutput !== void 0 && { strayOutput }
|
|
647
|
-
};
|
|
648
|
-
let classifications;
|
|
649
|
-
try {
|
|
650
|
-
classifications = await ctx.runPromise(Effect.gen(function* () {
|
|
651
|
-
const reader = yield* DataReader;
|
|
652
|
-
const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
|
|
653
|
-
const entries = [];
|
|
654
|
-
for (const p of projects) {
|
|
655
|
-
const tests = yield* reader.listTests(p, {});
|
|
656
|
-
for (const t of tests) if (t.classification != null) entries.push([t.fullName, t.classification]);
|
|
657
|
-
}
|
|
658
|
-
return new Map(entries);
|
|
659
|
-
}));
|
|
660
|
-
} catch {}
|
|
661
|
-
const chatId = ctx.currentSessionId.get();
|
|
662
|
-
if (chatId !== null) ctx.runFork(Effect.gen(function* () {
|
|
663
|
-
yield* (yield* DataStore).associateLatestRunWithSession({
|
|
664
|
-
chatId,
|
|
665
|
-
invocationMethod: "mcp"
|
|
666
|
-
});
|
|
667
|
-
}));
|
|
668
|
-
let scopedNote = null;
|
|
669
|
-
if (hasFilter) {
|
|
670
|
-
const testedFileCount = testModules.length;
|
|
671
|
-
let totalFileCount;
|
|
672
|
-
try {
|
|
673
|
-
totalFileCount = (await localVitest.globTestSpecifications()).length;
|
|
674
|
-
} catch {}
|
|
675
|
-
scopedNote = formatScopedCoverageNote(testedFileCount, totalFileCount);
|
|
676
|
-
}
|
|
677
|
-
return toOkPayload({
|
|
678
|
-
scope,
|
|
679
|
-
projectRoot: resolvedRoot,
|
|
680
|
-
report,
|
|
681
|
-
classifications,
|
|
682
|
-
discoveryLastScannedAt: readDiscoveryLastScannedAt(),
|
|
683
|
-
scopedNote,
|
|
684
|
-
...configSuggestedActions !== void 0 && { suggestedActions: configSuggestedActions }
|
|
685
|
-
});
|
|
686
|
-
} catch (err) {
|
|
687
|
-
let message;
|
|
688
|
-
try {
|
|
689
|
-
message = err instanceof Error ? err.message : String(err);
|
|
690
|
-
} catch {
|
|
691
|
-
message = coerceErrorField(err, "message") ?? "<unserializable error>";
|
|
692
|
-
}
|
|
693
|
-
return {
|
|
738
|
+
const recovered = input._sessionContext ?? ctx.sessionContext.get();
|
|
739
|
+
if (recovered !== null) yield* ProcessScope.env(process.env, {
|
|
740
|
+
VITEST_AGENT_CHAT_ID: recovered.chatId,
|
|
741
|
+
VITEST_AGENT_CONVERSATION_ID: recovered.conversationId,
|
|
742
|
+
VITEST_AGENT_AGENT_ID: recovered.mainAgentId
|
|
743
|
+
});
|
|
744
|
+
return yield* runVitest({
|
|
745
|
+
input,
|
|
746
|
+
ctx,
|
|
747
|
+
scope,
|
|
748
|
+
resolvedRoot,
|
|
749
|
+
anchoredConfig,
|
|
750
|
+
vitestEntry
|
|
751
|
+
}).pipe(Effect.catchTags({
|
|
752
|
+
RunStepFailure: (e) => Effect.succeed({
|
|
694
753
|
kind: "error",
|
|
695
|
-
message
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
rmSync(covOverride.dir, {
|
|
707
|
-
recursive: true,
|
|
708
|
-
force: true
|
|
709
|
-
});
|
|
710
|
-
} catch {}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
};
|
|
754
|
+
message: errorMessage(e.cause)
|
|
755
|
+
}),
|
|
756
|
+
ProcessScopeError: (e) => Effect.succeed({
|
|
757
|
+
kind: "error",
|
|
758
|
+
message: errorMessage(e.cause)
|
|
759
|
+
})
|
|
760
|
+
}), Effect.catchDefect((defect) => Effect.succeed({
|
|
761
|
+
kind: "error",
|
|
762
|
+
message: errorMessage(defect)
|
|
763
|
+
})));
|
|
764
|
+
});
|
|
714
765
|
/**
|
|
715
|
-
* Handler for {@link runTestsTool}.
|
|
716
|
-
*
|
|
717
|
-
* `process.env.VITEST_AGENT_
|
|
718
|
-
* `vitest.start`, which spawns the worker pool that
|
|
719
|
-
* spawn time — two interleaved calls would race,
|
|
720
|
-
* results to the other's agent.
|
|
766
|
+
* Handler for {@link runTestsTool}. The body runs in the one-permit
|
|
767
|
+
* `Exclusive` lane: it assigns the active attribution UUIDs into
|
|
768
|
+
* `process.env.VITEST_AGENT_*`, enters the run's root, and then awaits
|
|
769
|
+
* `createVitest` / `vitest.start`, which spawns the worker pool that
|
|
770
|
+
* snapshots env and cwd at spawn time — two interleaved calls would race,
|
|
771
|
+
* attributing one call's results to the other's agent. A client cancel
|
|
772
|
+
* drops the response; the next call waits for the real run to end.
|
|
721
773
|
*
|
|
722
774
|
* @public
|
|
723
775
|
*/
|
|
724
776
|
const handleRunTests = (input) => Effect.gen(function* () {
|
|
725
777
|
const session = yield* McpSession;
|
|
726
|
-
const services = yield* Effect.context();
|
|
727
778
|
const ctx = {
|
|
728
779
|
cwd: session.cwd,
|
|
729
780
|
currentSessionId: session.currentSessionId,
|
|
730
|
-
sessionContext: session.sessionContext
|
|
731
|
-
runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services)),
|
|
732
|
-
runFork: makeBestEffortFork(services)
|
|
781
|
+
sessionContext: session.sessionContext
|
|
733
782
|
};
|
|
734
|
-
|
|
783
|
+
const timeoutSeconds = input.timeout ?? 120;
|
|
784
|
+
const fs = yield* Effect.serviceOption(FileSystem.FileSystem);
|
|
785
|
+
const platformFs = Option.match(fs, {
|
|
786
|
+
onNone: () => NodeFileSystem.layer,
|
|
787
|
+
onSome: (service) => Layer.succeed(FileSystem.FileSystem, service)
|
|
788
|
+
});
|
|
789
|
+
return yield* runTestsLane.run(runTestsBody(input, ctx), { timeout: timeoutSeconds * 1e3 + LANE_ALLOWANCE_MS }).pipe(Effect.catchTag("ExclusiveTimeout", () => Effect.succeed({
|
|
790
|
+
kind: "timeout",
|
|
791
|
+
timeoutSeconds
|
|
792
|
+
})), Effect.provide(platformFs));
|
|
735
793
|
});
|
|
736
794
|
/**
|
|
737
795
|
* The Effect-native `run_tests` tool.
|
|
@@ -739,7 +797,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
|
|
|
739
797
|
* @public
|
|
740
798
|
*/
|
|
741
799
|
const runTestsTool = Tool.make("run_tests", {
|
|
742
|
-
description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root — so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --path-format=absolute --git-common-dir`, which needs git 2.31 or newer and is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. An error result with `reason: \"stale-install\"` means the vitest install was replaced under this long-lived server (the lockfile was regenerated) and the resolved vitest/node entry is gone; restart the MCP server (/mcp in Claude Code) rather than retrying. The legacy format=json arg is dropped — structuredContent supersedes it.",
|
|
800
|
+
description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root — so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --path-format=absolute --git-common-dir`, which needs git 2.31 or newer and is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. When the plugin hook supplies the caller working directory (the hook-injected _callerCwd), a call from a different working tree of the same repository than the resolved root is refused with `{ kind: \"error\" }` naming both trees; pass projectRoot (your own worktree path) to run there. An absent or indeterminate caller directory never refuses. An error result with `reason: \"stale-install\"` means the vitest install was replaced under this long-lived server (the lockfile was regenerated) and the resolved vitest/node entry is gone; restart the MCP server (/mcp in Claude Code) rather than retrying. The legacy format=json arg is dropped — structuredContent supersedes it.",
|
|
743
801
|
parameters: RunTestsInput,
|
|
744
802
|
success: RunTestsResult,
|
|
745
803
|
dependencies: [
|
|
@@ -749,4 +807,4 @@ const runTestsTool = Tool.make("run_tests", {
|
|
|
749
807
|
]
|
|
750
808
|
}).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
|
|
751
809
|
|
|
752
|
-
export { RUN_TESTS_ERROR_REASONS, RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork,
|
|
810
|
+
export { RUN_TESTS_ERROR_REASONS, RunTestsInput, RunTestsResult, RunTestsStdio, checkCallerWorktree, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork, readConfigSuggestedActions, readDiscoveryLastScannedAt, readRunDiagnostics, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, staleInstallMessage, toNoMatch, toOkPayload, validateProjectRoot, vitestLoader };
|