@vitest-agent/plugin 2.0.15 → 2.1.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 CHANGED
@@ -976,8 +976,16 @@ declare function combineClassifiers(...fns: ReadonlyArray<ClassifyFn>): Classify
976
976
  * Async file walker that returns matched absolute paths.
977
977
  *
978
978
  * Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
979
- * and `dist` directories. Matches files against the supplied glob patterns
980
- * relative to `dir` (e.g. `"src/**\/*.test.ts"`).
979
+ * and `dist` directories, and does not descend past a nested `package.json`
980
+ * boundary (any directory other than `dir` itself that has its own
981
+ * `package.json` is treated as an independent unit — its files belong to a
982
+ * separate discovery pass, not this one). The package-boundary rule applies
983
+ * to every supplied pattern, not just an unanchored `**\/` one — an anchored
984
+ * pattern like `"src/**\/*.test.ts"` will not match a test file under a
985
+ * nested package.json even though the pattern itself never reaches past
986
+ * `src/`, because the boundary check runs once per directory, independent of
987
+ * which pattern is being matched. Matches files against the supplied glob
988
+ * patterns relative to `dir` (e.g. `"src/**\/*.test.ts"`).
981
989
  *
982
990
  * Returns an empty array if `dir` does not exist or no files match.
983
991
  * @param dir - Absolute path to the directory to walk
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/plugin",
3
- "version": "2.0.15",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
6
6
  "keywords": [
@@ -41,11 +41,11 @@
41
41
  "dependencies": {
42
42
  "@effect/platform-node": "4.0.0-beta.107",
43
43
  "@effect/sql-sqlite-node": "4.0.0-beta.107",
44
- "@effected/workspaces": "^0.11.0",
45
- "@vitest-agent/cli": "2.0.15",
46
- "@vitest-agent/mcp": "2.1.0",
47
- "@vitest-agent/reporter": "2.0.15",
48
- "@vitest-agent/sdk": "2.0.15",
44
+ "@effected/workspaces": "^0.11.1",
45
+ "@vitest-agent/cli": "2.0.17",
46
+ "@vitest-agent/mcp": "2.1.2",
47
+ "@vitest-agent/reporter": "2.0.17",
48
+ "@vitest-agent/sdk": "2.1.0",
49
49
  "effect": "4.0.0-beta.107",
50
50
  "magic-string": "^1.1.0"
51
51
  },
package/plugin.js CHANGED
@@ -98,7 +98,7 @@ const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
98
98
  *
99
99
  * @public
100
100
  */
101
- const CURRENT_PLUGIN_VERSION = "2.0.15";
101
+ const CURRENT_PLUGIN_VERSION = "2.1.0";
102
102
  const TEST_FILE_SUFFIX_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/;
103
103
  const TEST_FILE_DIR_RE = /\/(?:src|__test__)\//;
104
104
  const isTestFile = (id) => TEST_FILE_SUFFIX_RE.test(id) && TEST_FILE_DIR_RE.test(id);
package/reporter.js CHANGED
@@ -7,7 +7,7 @@ import { captureSettings, hashSettings } from "./utils/capture-settings.js";
7
7
  import { processFailure } from "./utils/process-failure.js";
8
8
  import { routeRenderedOutput } from "./utils/route-rendered-output.js";
9
9
  import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
10
- import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
10
+ import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, coerceErrorField, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
11
11
  import { Effect, Option, PubSub } from "effect";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { mkdirSync } from "node:fs";
@@ -36,6 +36,21 @@ import { DefaultVitestAgentReporter } from "@vitest-agent/reporter";
36
36
  * @internal
37
37
  */
38
38
  const dbPathCache = /* @__PURE__ */ new Map();
39
+ /**
40
+ * Safely read a raw Vitest error object's `stacks` array. The property
41
+ * access itself can throw on hostile shapes (live getters — the same
42
+ * ConfigError class of failure `coerceErrorField` guards); frames are
43
+ * dropped rather than crashing the persistence loop.
44
+ */
45
+ function readErrorStacks(source) {
46
+ if (source === null || typeof source !== "object") return void 0;
47
+ try {
48
+ const value = source.stacks;
49
+ return Array.isArray(value) ? value : void 0;
50
+ } catch {
51
+ return;
52
+ }
53
+ }
39
54
  function resolveDataPathCached(projectDir) {
40
55
  const cached = dbPathCache.get(projectDir);
41
56
  if (cached !== void 0) return cached;
@@ -263,7 +278,9 @@ var AgentReporter = class {
263
278
  */
264
279
  async onInit(vitest) {
265
280
  this._vitest = vitest;
266
- await this.ensureDbPath();
281
+ try {
282
+ await this.ensureDbPath();
283
+ } catch {}
267
284
  await this.initReporters();
268
285
  }
269
286
  /**
@@ -801,7 +818,9 @@ var AgentReporter = class {
801
818
  failCount: fail,
802
819
  skipCount: skip,
803
820
  timeoutCount: timeout,
804
- durationMs: totalDuration
821
+ durationMs: totalDuration,
822
+ // @vitest-agent/ui, which both populate `collectedModules` on their
823
+ collectedModules: testModules.length
805
824
  });
806
825
  }
807
826
  const modules = testModules;
@@ -818,11 +837,11 @@ var AgentReporter = class {
818
837
  };
819
838
  const wantsRunEvents = this.wantsRunEvents();
820
839
  let dbPath;
840
+ let persistDisabled;
821
841
  try {
822
842
  dbPath = await this.ensureDbPath();
823
843
  } catch (err) {
824
- process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
825
- return;
844
+ persistDisabled = formatFatalError(err);
826
845
  }
827
846
  const filteredModules = opts.projectFilter ? modules.filter((m) => (m.project.name || "default") === opts.projectFilter) : modules;
828
847
  if (filteredModules.length === 0 && opts.projectFilter) return;
@@ -886,14 +905,33 @@ var AgentReporter = class {
886
905
  });
887
906
  return;
888
907
  }
889
- mkdirSync(dirname(dbPath), { recursive: true });
908
+ if (dbPath !== void 0 && persistDisabled === void 0) try {
909
+ mkdirSync(dirname(dbPath), { recursive: true });
910
+ } catch (err) {
911
+ persistDisabled = formatFatalError(err);
912
+ }
913
+ let fallbackReports;
890
914
  try {
891
- await ensureMigrated(dbPath, logLevel, logFile);
915
+ const fallbackProjectGroups = /* @__PURE__ */ new Map();
916
+ for (const mod of filteredModules) {
917
+ const key = mod.project.name || "default";
918
+ const existing = fallbackProjectGroups.get(key);
919
+ if (existing) existing.push(mod);
920
+ else fallbackProjectGroups.set(key, [mod]);
921
+ }
922
+ const isMultiProjectFallback = fallbackProjectGroups.size > 1 || !!opts.projectFilter;
923
+ fallbackReports = [];
924
+ for (const [projectName, projectModules] of fallbackProjectGroups) fallbackReports.push(buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProjectFallback ? projectName : void 0));
892
925
  } catch (err) {
893
926
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
894
927
  return;
895
928
  }
896
- const program = Effect.gen(function* () {
929
+ if (dbPath !== void 0 && persistDisabled === void 0) try {
930
+ await ensureMigrated(dbPath, logLevel, logFile);
931
+ } catch (err) {
932
+ persistDisabled = formatFatalError(err);
933
+ }
934
+ const persistProgram = Effect.gen(function* () {
897
935
  const store = yield* DataStore;
898
936
  const reader = yield* DataReader;
899
937
  const analyzer = yield* CoverageAnalyzer;
@@ -977,6 +1015,7 @@ var AgentReporter = class {
977
1015
  conversationId: null
978
1016
  };
979
1017
  const hostProbe = probeHostMetadataFromEnv(process.env);
1018
+ const projectReason = reason === "interrupted" ? "interrupted" : baseReport.summary.failed > 0 || baseReport.failedFiles.length > 0 ? "failed" : "passed";
980
1019
  const runId = yield* store.writeRun({
981
1020
  invocationId,
982
1021
  project,
@@ -984,7 +1023,7 @@ var AgentReporter = class {
984
1023
  timestamp: baseReport.timestamp,
985
1024
  commitSha: process.env.GITHUB_SHA ?? null,
986
1025
  branch: process.env.GITHUB_REF_NAME ?? null,
987
- reason,
1026
+ reason: projectReason,
988
1027
  duration: totalDuration,
989
1028
  total: baseReport.summary.total,
990
1029
  passed: baseReport.summary.passed,
@@ -1063,7 +1102,17 @@ var AgentReporter = class {
1063
1102
  const inputs = [];
1064
1103
  for (let ordinal = 0; ordinal < result.errors.length; ordinal++) {
1065
1104
  const e = result.errors[ordinal];
1066
- const { frames, signatureHash } = processFailure(e);
1105
+ const messageText = coerceErrorField(e, "message") ?? "<missing message>";
1106
+ const nameText = coerceErrorField(e, "name");
1107
+ const diffText = coerceErrorField(e, "diff");
1108
+ const stackText = coerceErrorField(e, "stack");
1109
+ const stacksValue = readErrorStacks(e);
1110
+ const { frames, signatureHash } = processFailure({
1111
+ message: messageText,
1112
+ ...nameText !== void 0 && { name: nameText },
1113
+ ...stackText !== void 0 && { stack: stackText },
1114
+ ...stacksValue !== void 0 && { stacks: stacksValue }
1115
+ });
1067
1116
  if (signatureHash !== null) yield* store.writeFailureSignature({
1068
1117
  signatureHash,
1069
1118
  runId,
@@ -1072,10 +1121,10 @@ var AgentReporter = class {
1072
1121
  inputs.push({
1073
1122
  testCaseId,
1074
1123
  scope: "test",
1075
- message: e.message,
1076
- ...e.name !== void 0 && { name: e.name },
1077
- ...e.diff !== void 0 && { diff: e.diff },
1078
- ...e.stack !== void 0 && { stack: e.stack },
1124
+ message: messageText,
1125
+ ...nameText !== void 0 && { name: nameText },
1126
+ ...diffText !== void 0 && { diff: diffText },
1127
+ ...stackText !== void 0 && { stack: stackText },
1079
1128
  ...signatureHash !== null && { signatureHash },
1080
1129
  ...frames.length > 0 && { frames },
1081
1130
  ordinal
@@ -1090,7 +1139,16 @@ var AgentReporter = class {
1090
1139
  const inputs = [];
1091
1140
  for (let ordinal = 0; ordinal < modErrors.length; ordinal++) {
1092
1141
  const e = modErrors[ordinal];
1093
- const { frames, signatureHash } = processFailure(e);
1142
+ const messageText = coerceErrorField(e, "message") ?? "<missing message>";
1143
+ const nameText = coerceErrorField(e, "name");
1144
+ const stackText = coerceErrorField(e, "stack");
1145
+ const stacksValue = readErrorStacks(e);
1146
+ const { frames, signatureHash } = processFailure({
1147
+ message: messageText,
1148
+ ...nameText !== void 0 && { name: nameText },
1149
+ ...stackText !== void 0 && { stack: stackText },
1150
+ ...stacksValue !== void 0 && { stacks: stacksValue }
1151
+ });
1094
1152
  if (signatureHash !== null) yield* store.writeFailureSignature({
1095
1153
  signatureHash,
1096
1154
  runId,
@@ -1099,9 +1157,9 @@ var AgentReporter = class {
1099
1157
  inputs.push({
1100
1158
  moduleId,
1101
1159
  scope: "module",
1102
- message: e.message,
1103
- ...e.name !== void 0 && { name: e.name },
1104
- ...e.stack !== void 0 && { stack: e.stack },
1160
+ message: messageText,
1161
+ ...nameText !== void 0 && { name: nameText },
1162
+ ...stackText !== void 0 && { stack: stackText },
1105
1163
  ...signatureHash !== null && { signatureHash },
1106
1164
  ...frames.length > 0 && { frames },
1107
1165
  ordinal
@@ -1114,7 +1172,16 @@ var AgentReporter = class {
1114
1172
  const inputs = [];
1115
1173
  for (let ordinal = 0; ordinal < errors.length; ordinal++) {
1116
1174
  const e = errors[ordinal];
1117
- const { frames, signatureHash } = processFailure(e);
1175
+ const messageText = coerceErrorField(e, "message") ?? "<missing message>";
1176
+ const nameText = coerceErrorField(e, "name");
1177
+ const stackText = coerceErrorField(e, "stack");
1178
+ const stacksValue = readErrorStacks(e);
1179
+ const { frames, signatureHash } = processFailure({
1180
+ message: messageText,
1181
+ ...nameText !== void 0 && { name: nameText },
1182
+ ...stackText !== void 0 && { stack: stackText },
1183
+ ...stacksValue !== void 0 && { stacks: stacksValue }
1184
+ });
1118
1185
  if (signatureHash !== null) yield* store.writeFailureSignature({
1119
1186
  signatureHash,
1120
1187
  runId,
@@ -1122,9 +1189,9 @@ var AgentReporter = class {
1122
1189
  });
1123
1190
  inputs.push({
1124
1191
  scope: "unhandled",
1125
- message: e.message,
1126
- ...e.name !== void 0 && { name: e.name },
1127
- ...e.stack !== void 0 && { stack: e.stack },
1192
+ message: messageText,
1193
+ ...nameText !== void 0 && { name: nameText },
1194
+ ...stackText !== void 0 && { stack: stackText },
1128
1195
  ...signatureHash !== null && { signatureHash },
1129
1196
  ...frames.length > 0 && { frames },
1130
1197
  ordinal
@@ -1155,7 +1222,7 @@ var AgentReporter = class {
1155
1222
  const tcResult = tc.result();
1156
1223
  if (tcResult?.state === "failed") {
1157
1224
  const errors = tcResult.errors;
1158
- errorMap.set(key, errors?.[0]?.message ?? null);
1225
+ errorMap.set(key, coerceErrorField(errors?.[0], "message") ?? null);
1159
1226
  }
1160
1227
  }
1161
1228
  }
@@ -1291,10 +1358,29 @@ var AgentReporter = class {
1291
1358
  direction: trendSummary.direction,
1292
1359
  runCount: trendSummary.runCount
1293
1360
  });
1361
+ const classifications = /* @__PURE__ */ new Map();
1362
+ for (const report of reports) for (const mod of report.failed) for (const test of mod.tests) if (test.classification) classifications.set(test.fullName, test.classification);
1294
1363
  yield* Effect.logInfo("reports built").pipe(Effect.annotateLogs({
1295
1364
  count: reports.length,
1296
1365
  projects: Array.from(projectGroups.keys()).join(", ")
1297
1366
  }));
1367
+ return {
1368
+ reports,
1369
+ classifications,
1370
+ ...trendSummary !== void 0 && { trendSummary }
1371
+ };
1372
+ });
1373
+ let persistFailure;
1374
+ let persistResult;
1375
+ if (persistDisabled === void 0 && dbPath !== void 0) persistResult = await Effect.runPromise(persistProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive(dbPath, logLevel, logFile)))).catch((err) => {
1376
+ persistFailure = formatFatalError(err);
1377
+ });
1378
+ const renderInputData = persistResult ?? {
1379
+ reports: fallbackReports,
1380
+ classifications: /* @__PURE__ */ new Map()
1381
+ };
1382
+ const renderProgram = Effect.gen(function* () {
1383
+ const { reports, classifications, trendSummary } = renderInputData;
1298
1384
  const detector = yield* EnvironmentDetector;
1299
1385
  const executorResolver = yield* ExecutorResolver;
1300
1386
  const formatSelector = yield* FormatSelector;
@@ -1316,8 +1402,6 @@ var AgentReporter = class {
1316
1402
  format,
1317
1403
  detail
1318
1404
  }));
1319
- const classifications = /* @__PURE__ */ new Map();
1320
- for (const report of reports) for (const mod of report.failed) for (const test of mod.tests) if (test.classification) classifications.set(test.fullName, test.classification);
1321
1405
  const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
1322
1406
  const kit = buildReporterKit({
1323
1407
  env,
@@ -1350,9 +1434,11 @@ var AgentReporter = class {
1350
1434
  }));
1351
1435
  for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
1352
1436
  });
1353
- await Effect.runPromise(program.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive(dbPath, logLevel, logFile)))).catch((err) => {
1437
+ await Effect.runPromise(renderProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
1354
1438
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1355
1439
  });
1440
+ const persistError = persistDisabled ?? persistFailure;
1441
+ if (persistError !== void 0) process.stderr.write(`vitest-agent: persistence failed — results above were rendered but NOT recorded: ${persistError}\n`);
1356
1442
  }
1357
1443
  };
1358
1444
 
@@ -11,53 +11,113 @@ function recordDiscoveryScanTimestamp() {
11
11
  globalThis[DISCOVERY_LAST_SCAN_SYMBOL] = (/* @__PURE__ */ new Date()).toISOString();
12
12
  }
13
13
  const _cache = /* @__PURE__ */ new Map();
14
+ const SIGNATURE_SKIP_DIRS = /* @__PURE__ */ new Set([
15
+ "node_modules",
16
+ ".git",
17
+ "dist"
18
+ ]);
19
+ /**
20
+ * Recursively finds every directory named `__test__` under `root` (issue
21
+ * #184: nested `__test__` dirs like `lib/scripts/__test__/` must invalidate
22
+ * the cache too, not just a package-root `__test__/`) and records every
23
+ * nested `package.json` (path + mtime) as a traversal-boundary marker. Skips
24
+ * `node_modules`, `.git`, and `dist` — see `SIGNATURE_SKIP_DIRS`. Stat-only:
25
+ * entries and mtimes are read, never file content. The root's own
26
+ * `package.json` is excluded — it is not a boundary for its own walk, and
27
+ * version bumps would otherwise churn the signature.
28
+ */
29
+ async function findNestedTestDirs(root) {
30
+ const testDirs = [];
31
+ const boundaries = [];
32
+ await walkForTestDirs(root, root, testDirs, boundaries);
33
+ return {
34
+ testDirs: testDirs.sort(),
35
+ boundaries: boundaries.sort()
36
+ };
37
+ }
38
+ async function walkForTestDirs(root, dir, testDirs, boundaries) {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir, { withFileTypes: true });
42
+ } catch {
43
+ return;
44
+ }
45
+ for (const ent of entries) {
46
+ const fullPath = join(dir, ent.name);
47
+ if (ent.isDirectory()) {
48
+ if (SIGNATURE_SKIP_DIRS.has(ent.name)) continue;
49
+ if (ent.name === "__test__") testDirs.push(fullPath);
50
+ await walkForTestDirs(root, fullPath, testDirs, boundaries);
51
+ } else if (ent.isFile() && ent.name === "package.json" && dir !== root) try {
52
+ const mtimeMs = (await stat(fullPath)).mtimeMs;
53
+ boundaries.push(`${toPosixPath(relative(root, fullPath))}:${mtimeMs}`);
54
+ } catch {}
55
+ }
56
+ }
14
57
  /**
15
58
  * Computes a cheap signature (relative path + mtimeMs pairs, sorted) for every
16
59
  * file nested under `dirPath`. Used to detect added/removed/moved/renamed test
17
60
  * files between `discoverProjects` calls without re-walking test-file globs.
18
61
  * Returns an empty string when `dirPath` does not exist — this still produces
19
62
  * a stable, comparable signature contribution.
63
+ *
64
+ * Manual per-directory walk (not a single `readdir({ recursive: true })`
65
+ * call) pruning `SIGNATURE_SKIP_DIRS` *before* recursing — same reasoning as
66
+ * `findNestedTestDirs`: a found `__test__` dir can itself contain a fixture
67
+ * `node_modules` (e.g. a subprocess-e2e fixture that installs deps), and
68
+ * Node's recursive `readdir` follows symlinked directories, so an unguarded
69
+ * call would walk into it.
20
70
  */
21
71
  async function computeDirSignature(dirPath) {
72
+ const parts = [];
73
+ await walkDirSignature(dirPath, dirPath, parts);
74
+ parts.sort();
75
+ return parts.join("|");
76
+ }
77
+ async function walkDirSignature(root, dir, parts) {
22
78
  let entries;
23
79
  try {
24
- entries = await readdir(dirPath, {
25
- withFileTypes: true,
26
- recursive: true
27
- });
80
+ entries = await readdir(dir, { withFileTypes: true });
28
81
  } catch {
29
- return "";
82
+ return;
30
83
  }
31
- const parts = [];
32
84
  for (const ent of entries) {
33
- if (!ent.isFile()) continue;
34
- const parentPath = ent.parentPath ?? dirPath;
35
- const fullPath = join(parentPath, ent.name);
36
- let mtimeMs;
37
- try {
38
- mtimeMs = (await stat(fullPath)).mtimeMs;
39
- } catch {
40
- continue;
85
+ const fullPath = join(dir, ent.name);
86
+ if (ent.isDirectory()) {
87
+ if (SIGNATURE_SKIP_DIRS.has(ent.name)) continue;
88
+ await walkDirSignature(root, fullPath, parts);
89
+ } else if (ent.isFile()) {
90
+ let mtimeMs;
91
+ try {
92
+ mtimeMs = (await stat(fullPath)).mtimeMs;
93
+ } catch {
94
+ continue;
95
+ }
96
+ const relPath = toPosixPath(relative(root, fullPath));
97
+ parts.push(`${relPath}:${mtimeMs}`);
41
98
  }
42
- const relPath = toPosixPath(relative(dirPath, fullPath));
43
- parts.push(`${relPath}:${mtimeMs}`);
44
99
  }
45
- parts.sort();
46
- return parts.join("|");
47
100
  }
48
101
  /**
49
102
  * Computes a cheap whole-workspace directory signature by combining each
50
- * package's `src/` and `__test__/` signatures (issue #100). Only entries +
51
- * mtimes are read no file content so this stays fast even for large
52
- * monorepos. A changed signature means a test file was added, removed,
53
- * moved, or renamed since the cached result was computed.
103
+ * package's `src/` signature with every nested `__test__/` dir's signature
104
+ * (issue #100, extended by issue #184 to see `__test__` dirs at any depth,
105
+ * not just a package-root `__test__/`). Only entries + mtimes are read — no
106
+ * file content so this stays fast even for large monorepos. A changed
107
+ * signature means a test file was added, removed, moved, or renamed since
108
+ * the cached result was computed.
54
109
  */
55
110
  async function computeWorkspaceSignature(packages) {
56
111
  const parts = [];
57
112
  for (const pkg of packages) {
58
113
  const srcSig = await computeDirSignature(join(pkg.path, "src"));
59
- const testDirSig = await computeDirSignature(join(pkg.path, "__test__"));
60
- parts.push(`${pkg.path}::src=${srcSig}::__test__=${testDirSig}`);
114
+ const { testDirs, boundaries } = await findNestedTestDirs(pkg.path);
115
+ const testDirParts = [];
116
+ for (const dir of testDirs) {
117
+ const sig = await computeDirSignature(dir);
118
+ testDirParts.push(`${toPosixPath(relative(pkg.path, dir))}=${sig}`);
119
+ }
120
+ parts.push(`${pkg.path}::src=${srcSig}::__test__=${testDirParts.join(";")}::boundaries=${boundaries.join(";")}`);
61
121
  }
62
122
  return parts.join("\n");
63
123
  }
@@ -16,13 +16,6 @@ const TEST_DIR_HELPER_DIRS = [
16
16
  "fixtures",
17
17
  "snapshots"
18
18
  ];
19
- async function isDir(p) {
20
- try {
21
- return (await stat(p)).isDirectory();
22
- } catch {
23
- return false;
24
- }
25
- }
26
19
  async function isFile(p) {
27
20
  try {
28
21
  return (await stat(p)).isFile();
@@ -123,17 +116,19 @@ var DefaultDiscoverStrategy = class extends DiscoverStrategy {
123
116
  return ["unit"];
124
117
  }
125
118
  async buildProject(input) {
126
- const hasTestDir = await isDir(join(input.path, "__test__"));
127
119
  const srcPrefix = join(input.path, "src");
128
- const testPrefix = join(input.path, "__test__");
129
- const allFiles = await findTestFiles(input.path, ["src/**/*.{test,spec}.{ts,tsx,js,jsx}", "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"]);
120
+ const allFiles = await findTestFiles(input.path, ["src/**/*.{test,spec}.{ts,tsx,js,jsx}", "**/__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"]);
130
121
  if (allFiles.length === 0) return null;
131
122
  const hasSrcTests = allFiles.some((f) => f.startsWith(`${srcPrefix}${sep}`) || f === srcPrefix);
132
- const hasTestDirTests = allFiles.some((f) => f.startsWith(`${testPrefix}${sep}`) || f === testPrefix);
123
+ const hasTestDirTests = allFiles.some((f) => !(f.startsWith(`${srcPrefix}${sep}`) || f === srcPrefix));
133
124
  const include = [];
134
125
  if (hasSrcTests) include.push(join(input.path, "src/**/*.{test,spec}.{ts,tsx,js,jsx}"));
135
- if (hasTestDirTests) include.push(join(input.path, "__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"));
136
- const exclude = hasTestDir ? [...configDefaults.exclude, ...TEST_DIR_HELPER_DIRS.map((d) => join(input.path, `__test__/${d}/**`))] : void 0;
126
+ if (hasTestDirTests) include.push(join(input.path, "**/__test__/**/*.{test,spec}.{ts,tsx,js,jsx}"));
127
+ const exclude = hasTestDirTests ? [
128
+ ...configDefaults.exclude,
129
+ join(input.path, "**/dist/**"),
130
+ ...TEST_DIR_HELPER_DIRS.map((d) => join(input.path, `**/__test__/${d}/**`))
131
+ ] : void 0;
137
132
  const setupFile = await detectSetupFile(input.path);
138
133
  return {
139
134
  extends: true,
@@ -57,8 +57,16 @@ function toRegexFragment(glob) {
57
57
  * Async file walker that returns matched absolute paths.
58
58
  *
59
59
  * Walks `dir` recursively via `node:fs/promises`. Skips `node_modules`, `.git`,
60
- * and `dist` directories. Matches files against the supplied glob patterns
61
- * relative to `dir` (e.g. `"src/**\/*.test.ts"`).
60
+ * and `dist` directories, and does not descend past a nested `package.json`
61
+ * boundary (any directory other than `dir` itself that has its own
62
+ * `package.json` is treated as an independent unit — its files belong to a
63
+ * separate discovery pass, not this one). The package-boundary rule applies
64
+ * to every supplied pattern, not just an unanchored `**\/` one — an anchored
65
+ * pattern like `"src/**\/*.test.ts"` will not match a test file under a
66
+ * nested package.json even though the pattern itself never reaches past
67
+ * `src/`, because the boundary check runs once per directory, independent of
68
+ * which pattern is being matched. Matches files against the supplied glob
69
+ * patterns relative to `dir` (e.g. `"src/**\/*.test.ts"`).
62
70
  *
63
71
  * Returns an empty array if `dir` does not exist or no files match.
64
72
  * @param dir - Absolute path to the directory to walk
@@ -80,6 +88,7 @@ async function walkDir(root, dir, matchers, results) {
80
88
  } catch {
81
89
  return;
82
90
  }
91
+ if (dir !== root && entries.some((ent) => ent.isFile() && ent.name === "package.json")) return;
83
92
  for (const ent of entries) {
84
93
  if (SKIP_DIRS.has(ent.name)) continue;
85
94
  const fullPath = join(dir, ent.name);
@@ -28,7 +28,11 @@ const stringifyFailureValue = (value) => {
28
28
  try {
29
29
  return JSON.stringify(value);
30
30
  } catch {
31
- return String(value);
31
+ try {
32
+ return String(value);
33
+ } catch {
34
+ return "<unserializable value>";
35
+ }
32
36
  }
33
37
  };
34
38