@vitest-agent/mcp 5.1.1 → 5.1.2
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 +1 -0
- package/package.json +4 -2
- package/tools/run-tests.js +195 -111
- package/version.js +1 -1
package/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ToolRefusal } from "@effected/mcp";
|
|
|
2
2
|
import { Context, Effect, Layer, Schema, Stdio } from "effect";
|
|
3
3
|
import { DataReader, DataStore, ProjectDiscovery, SessionContext } from "@vitest-agent/engine";
|
|
4
4
|
import { Toolkit } from "effect/ai";
|
|
5
|
+
import "@effected/git";
|
|
5
6
|
import "@vitest-agent/sdk";
|
|
6
7
|
import { Remediation } from "@effected/engine";
|
|
7
8
|
//#region src/annotations.d.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/mcp",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Model Context Protocol server for vitest-agent. Exposes 30 tools for agent access to test data, TDD lifecycle, and session management.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,8 +46,10 @@
|
|
|
46
46
|
"@effect/platform-node": "4.0.0-rc.118",
|
|
47
47
|
"@effect/sql-sqlite-node": "4.0.0-rc.118",
|
|
48
48
|
"@effected/engine": "^0.2.0",
|
|
49
|
+
"@effected/git": "^0.19.0",
|
|
49
50
|
"@effected/mcp": "^0.3.0",
|
|
50
|
-
"@
|
|
51
|
+
"@effected/walker": "^0.14.0",
|
|
52
|
+
"@vitest-agent/engine": "0.2.11",
|
|
51
53
|
"@vitest-agent/sdk": "5.1.3",
|
|
52
54
|
"effect": "4.0.0-rc.118"
|
|
53
55
|
},
|
package/tools/run-tests.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { McpSession } from "../session.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { ToolOutputSchema } from "@effected/mcp";
|
|
4
|
-
import { Data, Effect, Schema, Semaphore } from "effect";
|
|
4
|
+
import { Data, Effect, Layer, Option, Path, Result, Schema, Semaphore } from "effect";
|
|
5
5
|
import { DataReader, DataStore } from "@vitest-agent/engine";
|
|
6
6
|
import { Tool } from "effect/ai";
|
|
7
7
|
import { AgentReport, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote } from "@vitest-agent/sdk";
|
|
8
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
10
9
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
11
|
-
import {
|
|
10
|
+
import { stat } from "node:fs/promises";
|
|
12
11
|
import { tmpdir } from "node:os";
|
|
13
|
-
import {
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
14
13
|
import { Writable } from "node:stream";
|
|
15
|
-
import { pathToFileURL } from "node:url";
|
|
16
|
-
import {
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node";
|
|
16
|
+
import { Git } from "@effected/git";
|
|
17
|
+
import { Walker } from "@effected/walker";
|
|
17
18
|
|
|
18
19
|
//#region src/tools/run-tests.ts
|
|
19
20
|
const TagFilter = Schema.Struct({
|
|
@@ -186,29 +187,18 @@ function sanitizeTestArgs(args) {
|
|
|
186
187
|
}
|
|
187
188
|
return result;
|
|
188
189
|
}
|
|
189
|
-
const execFileAsync = promisify(execFile);
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
192
|
-
* --git-common-dir`) — identical across a
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* @internal exported for tests
|
|
191
|
+
* The git common directory for `dir` (`Git.commonDir`: `git rev-parse
|
|
192
|
+
* --path-format=absolute --git-common-dir`) — identical across a
|
|
193
|
+
* repository, its subdirectories, every linked worktree and any symlinked
|
|
194
|
+
* path to them, which is what makes it the right "same repository"
|
|
195
|
+
* comparison (`--show-toplevel` differs per worktree). Yields `null` when
|
|
196
|
+
* `dir` is not inside a git repository or the command fails for any other
|
|
197
|
+
* reason; callers treat `null` as "cannot confirm same repository", never
|
|
198
|
+
* as a silent pass.
|
|
200
199
|
*/
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: dir });
|
|
204
|
-
const trimmed = stdout.trim();
|
|
205
|
-
if (trimmed.length === 0) return null;
|
|
206
|
-
const candidate = resolve(dir, trimmed);
|
|
207
|
-
return await realpath(candidate);
|
|
208
|
-
} catch {
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
200
|
+
const gitCommonDir = (dir) => Effect.flatMap(Git, (git) => git.commonDir(dir)).pipe(Effect.option, Effect.map(Option.getOrNull), Effect.catchDefect(() => Effect.succeed(null)));
|
|
201
|
+
const VITEST_CONFIG_PREFIXES = ["vitest.config.", "vite.config."];
|
|
212
202
|
const VITEST_CONFIG_EXTENSIONS = [
|
|
213
203
|
"ts",
|
|
214
204
|
"mts",
|
|
@@ -217,57 +207,54 @@ const VITEST_CONFIG_EXTENSIONS = [
|
|
|
217
207
|
"mjs",
|
|
218
208
|
"cjs"
|
|
219
209
|
];
|
|
220
|
-
function findConfigInDir(dir) {
|
|
221
|
-
for (const prefix of ["vitest.config.", "vite.config."]) for (const ext of VITEST_CONFIG_EXTENSIONS) {
|
|
222
|
-
const candidate = join(dir, `${prefix}${ext}`);
|
|
223
|
-
if (existsSync(candidate)) return candidate;
|
|
224
|
-
}
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
210
|
/**
|
|
228
211
|
* The one walk both anchoring helpers share: step UP from `startDir`
|
|
229
212
|
* until a vitest/vite config file is found, returning its absolute path.
|
|
230
|
-
* Bounded at the git root (inclusive
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* the
|
|
213
|
+
* Bounded at the git work-tree root (inclusive, so the root is still
|
|
214
|
+
* examined) and at the filesystem root. `Git.repoRoot` answers a PHYSICAL
|
|
215
|
+
* path (`/private/var/...` for a `/var/...` tmpdir on macOS) while the chain
|
|
216
|
+
* is lexical, so the bound is `Walker.ascendWithin`, which stops on a
|
|
217
|
+
* realpath match; a lexical `stopAt` would match nothing there and let the
|
|
218
|
+
* walk run past the repository. Any `repoRoot` failure (not a repository,
|
|
219
|
+
* git missing) means no git bound: the walk runs to the filesystem root, as
|
|
220
|
+
* it always has outside a repository. Yields `null` when no config is found in
|
|
221
|
+
* range. An unreadable entry reads as absent (the walker absorbs
|
|
222
|
+
* per-candidate failures), and a defect anywhere in the walk also yields
|
|
223
|
+
* `null`, so the helper never fails.
|
|
234
224
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (parent === dir) return null;
|
|
244
|
-
dir = parent;
|
|
245
|
-
}
|
|
246
|
-
} catch {
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
225
|
+
const walkUpToConfigFile = (startDir) => Effect.gen(function* () {
|
|
226
|
+
const path = yield* Path.Path;
|
|
227
|
+
const start = path.resolve(startDir);
|
|
228
|
+
const git = yield* Git;
|
|
229
|
+
const dirs = yield* Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)));
|
|
230
|
+
const found = yield* Walker.findUpward(dirs, (dir) => VITEST_CONFIG_PREFIXES.flatMap((prefix) => VITEST_CONFIG_EXTENSIONS.map((ext) => path.join(dir, `${prefix}${ext}`))));
|
|
231
|
+
return Option.getOrNull(found);
|
|
232
|
+
}).pipe(Effect.catchDefect(() => Effect.succeed(null)));
|
|
250
233
|
/**
|
|
251
|
-
* Walk UP from `startDir` looking for the vitest/vite config file,
|
|
234
|
+
* Walk UP from `startDir` looking for the vitest/vite config file, yielding
|
|
252
235
|
* its absolute path, or `null` when none is found in range.
|
|
253
236
|
*
|
|
254
237
|
* @internal exported for tests
|
|
255
238
|
*/
|
|
256
|
-
|
|
257
|
-
return walkUpToConfigFile(startDir);
|
|
258
|
-
}
|
|
239
|
+
const resolveAnchoredConfigFile = (startDir) => walkUpToConfigFile(startDir);
|
|
259
240
|
/**
|
|
260
241
|
* Walk UP from `startDir` looking for the vitest/vite config Vitest would
|
|
261
|
-
* load anyway,
|
|
242
|
+
* load anyway, yielding the directory that holds it. Yields `startDir`
|
|
262
243
|
* unchanged when no config is found in range. See the issue #259 comment
|
|
263
244
|
* above `validateProjectRoot` for the full rationale.
|
|
264
245
|
*
|
|
265
246
|
* @internal exported for tests
|
|
266
247
|
*/
|
|
267
|
-
|
|
268
|
-
const found = walkUpToConfigFile(startDir);
|
|
269
|
-
|
|
270
|
-
|
|
248
|
+
const resolveConfigAnchoredRoot = (startDir) => Effect.gen(function* () {
|
|
249
|
+
const found = yield* walkUpToConfigFile(startDir);
|
|
250
|
+
if (found === null) return startDir;
|
|
251
|
+
return (yield* Path.Path).dirname(found);
|
|
252
|
+
});
|
|
253
|
+
const NodeFsPath = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
|
|
254
|
+
/** The Node platform the promise-shaped run body walks the real disk with; bound once so it memoizes. */
|
|
255
|
+
const NodeWalkPlatform = Git.layer.pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provideMerge(NodeFsPath));
|
|
256
|
+
/** Run a never-failing platform walk against the real disk. */
|
|
257
|
+
const runOnNodePlatform = (effect) => Effect.runPromise(Effect.provide(effect, NodeWalkPlatform));
|
|
271
258
|
/**
|
|
272
259
|
* Validate an optional caller-supplied `projectRoot` against `ctxCwd`
|
|
273
260
|
* (the MCP server's boot-time root). `undefined` anchors at the directory
|
|
@@ -290,7 +277,7 @@ function resolveConfigAnchoredRoot(startDir) {
|
|
|
290
277
|
async function validateProjectRoot(projectRoot, ctxCwd) {
|
|
291
278
|
if (projectRoot === void 0) return {
|
|
292
279
|
ok: true,
|
|
293
|
-
root: resolveConfigAnchoredRoot(ctxCwd)
|
|
280
|
+
root: await runOnNodePlatform(resolveConfigAnchoredRoot(ctxCwd))
|
|
294
281
|
};
|
|
295
282
|
const resolvedRoot = resolve(ctxCwd, projectRoot);
|
|
296
283
|
let isDirectory;
|
|
@@ -306,7 +293,7 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
|
|
|
306
293
|
ok: false,
|
|
307
294
|
message: `projectRoot "${resolvedRoot}" is not a directory (ctx.cwd is "${ctxCwd}").`
|
|
308
295
|
};
|
|
309
|
-
const [rootCommonDir, cwdCommonDir] = await
|
|
296
|
+
const [rootCommonDir, cwdCommonDir] = await runOnNodePlatform(Effect.all([gitCommonDir(resolvedRoot), gitCommonDir(ctxCwd)], { concurrency: 2 }));
|
|
310
297
|
if (rootCommonDir === null || cwdCommonDir === null || rootCommonDir !== cwdCommonDir) return {
|
|
311
298
|
ok: false,
|
|
312
299
|
message: `projectRoot "${resolvedRoot}" does not belong to the same git repository as ctx.cwd "${ctxCwd}".`
|
|
@@ -355,6 +342,25 @@ function resolveVitestNodeEntry(root) {
|
|
|
355
342
|
}
|
|
356
343
|
}
|
|
357
344
|
/**
|
|
345
|
+
* Issue #461: the MCP server is long-lived. When the lockfile is regenerated
|
|
346
|
+
* mid-session (dep bump, branch switch), pnpm can rename vitest's `.pnpm`
|
|
347
|
+
* store directory (peer-hash change), so the entry resolved for this process
|
|
348
|
+
* no longer exists and every run would otherwise fail opaquely inside the fork
|
|
349
|
+
* pool ("Worker forks emitted error", total: 0). Returns an actionable message
|
|
350
|
+
* when `entry` is a `file://` URL whose path is missing on disk, else `null`.
|
|
351
|
+
* The bare `"vitest/node"` fallback is not a path and is never checked. The
|
|
352
|
+
* check is on the exact path that will be imported, because Node's require
|
|
353
|
+
* resolution caches can hand back a stale path in a long-lived process.
|
|
354
|
+
*
|
|
355
|
+
* @internal exported for tests
|
|
356
|
+
*/
|
|
357
|
+
function staleInstallMessage(entry) {
|
|
358
|
+
if (!entry.startsWith("file:")) return null;
|
|
359
|
+
const path = fileURLToPath(entry);
|
|
360
|
+
if (existsSync(path)) return null;
|
|
361
|
+
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.`;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
358
364
|
* Indirection seam around `import(<vitest/node entry>)`. vitest's own
|
|
359
365
|
* vite-node externalizes "vitest"/"vitest/node" for every importer, and
|
|
360
366
|
* `vi.mock("vitest/node", ...)` only special-cases AST-literal
|
|
@@ -366,7 +372,10 @@ function resolveVitestNodeEntry(root) {
|
|
|
366
372
|
*
|
|
367
373
|
* @internal exported for tests
|
|
368
374
|
*/
|
|
369
|
-
const vitestLoader = {
|
|
375
|
+
const vitestLoader = {
|
|
376
|
+
resolveEntry: (root) => resolveVitestNodeEntry(root),
|
|
377
|
+
load: (entry) => import(entry)
|
|
378
|
+
};
|
|
370
379
|
const runTestsSemaphore = Effect.runSync(Semaphore.make(1));
|
|
371
380
|
/**
|
|
372
381
|
* Coerce unknown Vitest unhandled errors into VitestModuleError shape.
|
|
@@ -394,7 +403,7 @@ function coerceErrors(errors) {
|
|
|
394
403
|
const RunTestsInput = Schema.Struct({
|
|
395
404
|
files: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Test file paths to run" }),
|
|
396
405
|
project: Schema.optionalKey(Schema.String).annotate({ description: "Project name to filter" }),
|
|
397
|
-
projectRoot: Schema.optionalKey(Schema.String).annotate({ description: "Explicit Vitest root for this call, used verbatim. Omit it to get the config-anchored default (walk up from the server's boot dir for a vitest/vite config, bounded at the git root). Prefer an absolute path; a relative path is resolved against ctx.cwd, not the server process's cwd. Validated: must be an existing directory in the same git repository as ctx.cwd (same git-common-dir, e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise." }),
|
|
406
|
+
projectRoot: Schema.optionalKey(Schema.String).annotate({ description: "Explicit Vitest root for this call, used verbatim. Omit it to get the config-anchored default (walk up from the server's boot dir for a vitest/vite config, bounded at the git root). Prefer an absolute path; a relative path is resolved against ctx.cwd, not the server process's cwd. Validated: must be an existing directory in the same git repository as ctx.cwd (same git-common-dir, compared via `git rev-parse --path-format=absolute --git-common-dir`, which requires git 2.31 or newer; e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise." }),
|
|
398
407
|
tags: Schema.optionalKey(TagFilter).annotate({ description: "Structured tag filter; all/any/none AND together with each other and with project/files" }),
|
|
399
408
|
passWithNoTests: Schema.optionalKey(Schema.Boolean).annotate({ description: "Per-call override of Vitest's native test.passWithNoTests" }),
|
|
400
409
|
timeout: Schema.optionalKey(Schema.Finite).annotate({ description: "Timeout in seconds (default: 120)" }),
|
|
@@ -405,6 +414,92 @@ const RunTestsInput = Schema.Struct({
|
|
|
405
414
|
})).annotate({ description: "Hook-injected session attribution UUIDs; do not pass manually." })
|
|
406
415
|
});
|
|
407
416
|
/**
|
|
417
|
+
* Derive the run's {@link RunScope} from its input. Every file, project and
|
|
418
|
+
* tag value passes through `sanitizeTestArgs` (tag values ride into Vitest's
|
|
419
|
+
* tag-expression compiler unmodified, so shell metacharacters are rejected
|
|
420
|
+
* the same way); a refused argument fails with the message the tool returns
|
|
421
|
+
* as its `{ kind: "error" }` result. An all-empty tag filter composes to no
|
|
422
|
+
* expression and so is not a filter.
|
|
423
|
+
*
|
|
424
|
+
* @internal exported for tests
|
|
425
|
+
*/
|
|
426
|
+
function deriveRunScope(input) {
|
|
427
|
+
const tags = input.tags;
|
|
428
|
+
try {
|
|
429
|
+
const files = input.files ? sanitizeTestArgs(input.files) : [];
|
|
430
|
+
const project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
|
|
431
|
+
if (tags) {
|
|
432
|
+
if (tags.all) sanitizeTestArgs(tags.all);
|
|
433
|
+
if (tags.any) sanitizeTestArgs(tags.any);
|
|
434
|
+
if (tags.none) sanitizeTestArgs(tags.none);
|
|
435
|
+
}
|
|
436
|
+
const resolvedExpression = composeTagExpression(tags ?? null);
|
|
437
|
+
const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
|
|
438
|
+
return Result.succeed({
|
|
439
|
+
files,
|
|
440
|
+
project,
|
|
441
|
+
tags,
|
|
442
|
+
resolvedExpression,
|
|
443
|
+
hasFilter
|
|
444
|
+
});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return Result.fail(err instanceof Error ? err.message : String(err));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Whether a finished run is `no-match`: a filter was supplied, no test
|
|
451
|
+
* module was collected, and no unhandled error was raised. Filter-driven,
|
|
452
|
+
* not result-driven — an unfiltered empty run is `ok`, and the
|
|
453
|
+
* `passWithNoTests` policy never reshapes the discriminator.
|
|
454
|
+
*
|
|
455
|
+
* @internal exported for tests
|
|
456
|
+
*/
|
|
457
|
+
function isNoMatch(scope, testModuleCount, unhandledErrorCount) {
|
|
458
|
+
return scope.hasFilter && testModuleCount === 0 && unhandledErrorCount === 0;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Shape the `no-match` result, echoing the resolved filter verbatim.
|
|
462
|
+
*
|
|
463
|
+
* @internal exported for tests
|
|
464
|
+
*/
|
|
465
|
+
function toNoMatch(scope, projectRoot) {
|
|
466
|
+
return {
|
|
467
|
+
kind: "no-match",
|
|
468
|
+
projectRoot,
|
|
469
|
+
filter: {
|
|
470
|
+
project: scope.project ?? null,
|
|
471
|
+
files: scope.files,
|
|
472
|
+
tags: scope.tags ?? null,
|
|
473
|
+
resolvedExpression: scope.resolvedExpression
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Shape the `ok` result from a finished run's plain values. `project` is
|
|
479
|
+
* echoed at the top level only when supplied; `scope` always echoes the
|
|
480
|
+
* resolved filter set; absent classifications and scan time become `{}` and
|
|
481
|
+
* `null`.
|
|
482
|
+
*
|
|
483
|
+
* @internal exported for tests
|
|
484
|
+
*/
|
|
485
|
+
function toOkPayload(args) {
|
|
486
|
+
const { scope } = args;
|
|
487
|
+
return {
|
|
488
|
+
kind: "ok",
|
|
489
|
+
...scope.project !== void 0 && { project: scope.project },
|
|
490
|
+
projectRoot: args.projectRoot,
|
|
491
|
+
scope: {
|
|
492
|
+
project: scope.project ?? null,
|
|
493
|
+
files: scope.files,
|
|
494
|
+
tags: scope.tags ?? null
|
|
495
|
+
},
|
|
496
|
+
report: args.report,
|
|
497
|
+
classifications: args.classifications ? Object.fromEntries(args.classifications) : {},
|
|
498
|
+
discoveryLastScannedAt: args.discoveryLastScannedAt ?? null,
|
|
499
|
+
scopedNote: args.scopedNote
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
408
503
|
* Build the best-effort runner for a `RunTestsContext`: forks the effect
|
|
409
504
|
* as a fiber on the provided services with its expected failure channel
|
|
410
505
|
* ignored (`Effect.ignore`), so a typed failure never touches the tool
|
|
@@ -421,25 +516,13 @@ const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provi
|
|
|
421
516
|
* `{ kind: "error" }` envelope, so it never rejects.
|
|
422
517
|
*/
|
|
423
518
|
const runTestsBody = async (input, ctx) => {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (tagsInput.all) sanitizeTestArgs(tagsInput.all);
|
|
432
|
-
if (tagsInput.any) sanitizeTestArgs(tagsInput.any);
|
|
433
|
-
if (tagsInput.none) sanitizeTestArgs(tagsInput.none);
|
|
434
|
-
}
|
|
435
|
-
} catch (err) {
|
|
436
|
-
return {
|
|
437
|
-
kind: "error",
|
|
438
|
-
message: err instanceof Error ? err.message : String(err)
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const resolvedExpression = composeTagExpression(tagsInput ?? null);
|
|
442
|
-
const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
|
|
519
|
+
const derived = deriveRunScope(input);
|
|
520
|
+
if (Result.isFailure(derived)) return {
|
|
521
|
+
kind: "error",
|
|
522
|
+
message: derived.failure
|
|
523
|
+
};
|
|
524
|
+
const scope = derived.success;
|
|
525
|
+
const { files, project, resolvedExpression, hasFilter } = scope;
|
|
443
526
|
const projectRootValidation = await validateProjectRoot(input.projectRoot, ctx.cwd);
|
|
444
527
|
if (!projectRootValidation.ok) return {
|
|
445
528
|
kind: "error",
|
|
@@ -448,13 +531,19 @@ const runTestsBody = async (input, ctx) => {
|
|
|
448
531
|
const resolvedRoot = projectRootValidation.root;
|
|
449
532
|
let anchoredConfig;
|
|
450
533
|
if (input.projectRoot !== void 0) {
|
|
451
|
-
const found = resolveAnchoredConfigFile(resolvedRoot);
|
|
534
|
+
const found = await runOnNodePlatform(resolveAnchoredConfigFile(resolvedRoot));
|
|
452
535
|
if (found === null) return {
|
|
453
536
|
kind: "error",
|
|
454
537
|
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.`
|
|
455
538
|
};
|
|
456
539
|
anchoredConfig = found;
|
|
457
540
|
}
|
|
541
|
+
const vitestEntry = vitestLoader.resolveEntry(resolvedRoot);
|
|
542
|
+
const staleMessage = staleInstallMessage(vitestEntry);
|
|
543
|
+
if (staleMessage !== null) return {
|
|
544
|
+
kind: "error",
|
|
545
|
+
message: staleMessage
|
|
546
|
+
};
|
|
458
547
|
const timeoutMs = (input.timeout ?? 120) * 1e3;
|
|
459
548
|
const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
|
|
460
549
|
if (recovered !== null) {
|
|
@@ -465,10 +554,17 @@ const runTestsBody = async (input, ctx) => {
|
|
|
465
554
|
const nullStream = new Writable({ write(_chunk, _encoding, cb) {
|
|
466
555
|
cb();
|
|
467
556
|
} });
|
|
468
|
-
const { createVitest } = await vitestLoader.load(
|
|
557
|
+
const { createVitest } = await vitestLoader.load(vitestEntry);
|
|
469
558
|
let vitest;
|
|
470
559
|
let covOverride;
|
|
560
|
+
let previousCwd;
|
|
561
|
+
try {
|
|
562
|
+
previousCwd = process.cwd();
|
|
563
|
+
} catch {
|
|
564
|
+
previousCwd = void 0;
|
|
565
|
+
}
|
|
471
566
|
try {
|
|
567
|
+
process.chdir(resolvedRoot);
|
|
472
568
|
covOverride = makeCoverageDirOverride();
|
|
473
569
|
vitest = await createVitest({
|
|
474
570
|
root: resolvedRoot,
|
|
@@ -501,16 +597,7 @@ const runTestsBody = async (input, ctx) => {
|
|
|
501
597
|
const result = startOutcome.value;
|
|
502
598
|
const testModules = result.testModules;
|
|
503
599
|
const unhandledErrors = coerceErrors(result.unhandledErrors);
|
|
504
|
-
if (
|
|
505
|
-
kind: "no-match",
|
|
506
|
-
projectRoot: resolvedRoot,
|
|
507
|
-
filter: {
|
|
508
|
-
project: project ?? null,
|
|
509
|
-
files,
|
|
510
|
-
tags: tagsInput ?? null,
|
|
511
|
-
resolvedExpression
|
|
512
|
-
}
|
|
513
|
-
};
|
|
600
|
+
if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
|
|
514
601
|
const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
|
|
515
602
|
const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
|
|
516
603
|
const leaks = buildConsoleLeaks(collectConsoleLeakEntries(localVitest.state.getFiles()));
|
|
@@ -547,20 +634,14 @@ const runTestsBody = async (input, ctx) => {
|
|
|
547
634
|
} catch {}
|
|
548
635
|
scopedNote = formatScopedCoverageNote(testedFileCount, totalFileCount);
|
|
549
636
|
}
|
|
550
|
-
return {
|
|
551
|
-
|
|
552
|
-
...project !== void 0 && { project },
|
|
637
|
+
return toOkPayload({
|
|
638
|
+
scope,
|
|
553
639
|
projectRoot: resolvedRoot,
|
|
554
|
-
scope: {
|
|
555
|
-
project: project ?? null,
|
|
556
|
-
files,
|
|
557
|
-
tags: tagsInput ?? null
|
|
558
|
-
},
|
|
559
640
|
report,
|
|
560
|
-
classifications
|
|
561
|
-
discoveryLastScannedAt: readDiscoveryLastScannedAt()
|
|
641
|
+
classifications,
|
|
642
|
+
discoveryLastScannedAt: readDiscoveryLastScannedAt(),
|
|
562
643
|
scopedNote
|
|
563
|
-
};
|
|
644
|
+
});
|
|
564
645
|
} catch (err) {
|
|
565
646
|
let message;
|
|
566
647
|
try {
|
|
@@ -576,6 +657,9 @@ const runTestsBody = async (input, ctx) => {
|
|
|
576
657
|
try {
|
|
577
658
|
await vitest?.close();
|
|
578
659
|
} finally {
|
|
660
|
+
try {
|
|
661
|
+
if (previousCwd !== void 0) process.chdir(previousCwd);
|
|
662
|
+
} catch {}
|
|
579
663
|
nullStream.destroy();
|
|
580
664
|
if (covOverride !== void 0) try {
|
|
581
665
|
rmSync(covOverride.dir, {
|
|
@@ -606,7 +690,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
|
|
|
606
690
|
runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services)),
|
|
607
691
|
runFork: makeBestEffortFork(services)
|
|
608
692
|
};
|
|
609
|
-
return yield* Semaphore.withPermit(runTestsSemaphore, Effect.promise(() => runTestsBody(input, ctx)));
|
|
693
|
+
return yield* Semaphore.withPermit(runTestsSemaphore, Effect.uninterruptible(Effect.promise(() => runTestsBody(input, ctx))));
|
|
610
694
|
});
|
|
611
695
|
/**
|
|
612
696
|
* The Effect-native `run_tests` tool.
|
|
@@ -614,7 +698,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
|
|
|
614
698
|
* @public
|
|
615
699
|
*/
|
|
616
700
|
const runTestsTool = Tool.make("run_tests", {
|
|
617
|
-
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 --git-common-dir`, which 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. The legacy format=json arg is dropped — structuredContent supersedes it.",
|
|
701
|
+
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. The legacy format=json arg is dropped — structuredContent supersedes it.",
|
|
618
702
|
parameters: RunTestsInput,
|
|
619
703
|
success: RunTestsResult,
|
|
620
704
|
dependencies: [
|
|
@@ -625,4 +709,4 @@ const runTestsTool = Tool.make("run_tests", {
|
|
|
625
709
|
}).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
|
|
626
710
|
|
|
627
711
|
//#endregion
|
|
628
|
-
export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, handleRunTests, makeBestEffortFork, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot,
|
|
712
|
+
export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveVitestNodeEntry, runTestsSemaphore, runTestsTool, sanitizeTestArgs, staleInstallMessage, toNoMatch, toOkPayload, validateProjectRoot, vitestLoader, withStdioCaptured };
|