@vitest-agent/plugin 5.6.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/reporter.js CHANGED
@@ -5,17 +5,22 @@ import { ReporterLive } from "./layers/ReporterLive.js";
5
5
  import { buildReporterKit, normalizeReporters } from "./utils/build-reporter-kit.js";
6
6
  import { captureEnvVars } from "./utils/capture-env.js";
7
7
  import { captureSettings, hashSettings } from "./utils/capture-settings.js";
8
+ import { collectPerformanceHints } from "./utils/collect-performance-hints.js";
9
+ import { formatFailureSummary, shouldWriteFailureSummary } from "./utils/failure-summary.js";
8
10
  import { isPartialRun } from "./utils/is-partial-run.js";
9
11
  import { processFailure } from "./utils/process-failure.js";
10
12
  import { assertReportCapable, createReportWriter } from "./utils/report-writer.js";
11
13
  import { resolveNoColor } from "./utils/resolve-no-color.js";
12
14
  import { routeRenderedOutput } from "./utils/route-rendered-output.js";
15
+ import { publishRunDiagnostics } from "./utils/run-diagnostics.js";
16
+ import { runsInCi } from "./utils/runs-in-ci.js";
13
17
  import { strayOutputCaptureOf } from "./utils/stray-output-capture.js";
14
18
  import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
15
19
  import { mkdirSync, statSync } from "node:fs";
16
20
  import { dirname } from "node:path";
17
- import { ConfigLive, DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, HistoryTracker, OutputPipelineLive, PathResolutionLive, VitestAgentConfigFile, ensureMigrated, historyKey, resolveDataPath, resolveLogFile, resolveLogLevel, withPersistTransaction } from "@vitest-agent/engine";
18
- import { RunRetentionPolicy, VitestAgentConfig, buildAgentReport, coerceErrorField, computeTrend, formatFatalError, isTimeoutError, probeHostMetadataFromEnv } from "@vitest-agent/sdk";
21
+ import { RuntimeEnv } from "@effected/env";
22
+ import { ConfigLive, DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, HistoryTracker, OutputPipelineLive, PathResolutionLive, VitestAgentConfigFile, ensureMigrated, formatFatalError, historyKey, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel, withPersistTransaction } from "@vitest-agent/engine";
23
+ import { RunRetentionPolicy, VitestAgentConfig, buildAgentReport, coerceErrorField, computeTrend, isTimeoutError, suggestedActionsFromRunDiagnostics } from "@vitest-agent/sdk";
19
24
  import { Effect, Option, PubSub } from "effect";
20
25
  import { randomUUID } from "node:crypto";
21
26
  import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -345,6 +350,12 @@ var AgentReporter = class {
345
350
  */
346
351
  rendered = false;
347
352
  /**
353
+ * `performance.now()` at this run's `onTestRunStart`, for the run's
354
+ * execution time (issue #544). `undefined` when `onTestRunStart` never
355
+ * fired (tests invoking `onTestRunEnd` directly).
356
+ */
357
+ runStartedAt = void 0;
358
+ /**
348
359
  * Stored Vitest instance from {@link AgentReporter.onInit | onInit}.
349
360
  *
350
361
  * @remarks
@@ -719,6 +730,7 @@ var AgentReporter = class {
719
730
  onTestRunStart(_specifications) {
720
731
  this.rendered = false;
721
732
  this.coverage = null;
733
+ this.runStartedAt = performance.now();
722
734
  this.startedSpecCount = _specifications.length;
723
735
  this.strayCapture?.reset();
724
736
  if (this.neutralizedThresholdSnapshot.size > 0) {
@@ -1131,6 +1143,7 @@ var AgentReporter = class {
1131
1143
  async onTestRunEnd(testModules, unhandledErrors, reason) {
1132
1144
  if (this.rendered) return;
1133
1145
  this.rendered = true;
1146
+ const executionTimeMs = this.runStartedAt !== void 0 ? performance.now() - this.runStartedAt : void 0;
1134
1147
  const errors = unhandledErrors;
1135
1148
  this.strayCapture?.flush();
1136
1149
  const strayOutput = this.strayCapture?.snapshot();
@@ -1169,6 +1182,26 @@ var AgentReporter = class {
1169
1182
  });
1170
1183
  }
1171
1184
  const modules = testModules;
1185
+ const writeFailureSummary = (summaryReports) => {
1186
+ const consoleMode = this.options.consoleMode ?? "passthrough";
1187
+ if (!shouldWriteFailureSummary(consoleMode)) return;
1188
+ try {
1189
+ const summary = formatFailureSummary(summaryReports, reason);
1190
+ if (summary === void 0) return;
1191
+ const printers = consoleMode === "stream" ? (this.reporters ?? []).flatMap((reporter) => reporter.printStrayLine !== void 0 ? [reporter.printStrayLine] : []) : [];
1192
+ if (printers.length === 0) {
1193
+ process.stderr.write(`${summary}\n`);
1194
+ return;
1195
+ }
1196
+ for (const line of summary.split("\n")) if (!printers.some((print) => {
1197
+ try {
1198
+ return print("stderr", line);
1199
+ } catch {
1200
+ return false;
1201
+ }
1202
+ })) process.stderr.write(`${line}\n`);
1203
+ } catch {}
1204
+ };
1172
1205
  const opts = this.options;
1173
1206
  const stashedCoverage = this.coverage;
1174
1207
  const stashedVitest = this._vitest;
@@ -1214,6 +1247,7 @@ var AgentReporter = class {
1214
1247
  const wantsRunEvents = this.wantsRunEvents();
1215
1248
  let dbPath;
1216
1249
  let persistDisabled;
1250
+ let migrationFailed = false;
1217
1251
  if (opts.persist) try {
1218
1252
  dbPath = await this.ensureDbPath();
1219
1253
  } catch (err) {
@@ -1222,6 +1256,42 @@ var AgentReporter = class {
1222
1256
  const retention = dbPath === void 0 || persistDisabled !== void 0 ? Option.none() : opts.retention === false ? Option.none() : opts.retention !== void 0 ? Option.some(opts.retention) : await resolveRetentionPolicyCached(process.cwd());
1223
1257
  const filteredModules = opts.projectFilter ? modules.filter((m) => (m.project.name || "default") === opts.projectFilter) : modules;
1224
1258
  if (filteredModules.length === 0 && opts.projectFilter) return;
1259
+ const projectModuleCounts = /* @__PURE__ */ new Map();
1260
+ for (const m of modules) {
1261
+ const key = m.project.name || "default";
1262
+ projectModuleCounts.set(key, (projectModuleCounts.get(key) ?? 0) + 1);
1263
+ }
1264
+ const primaryProject = Array.from(projectModuleCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
1265
+ const isFirstProject = !opts.projectFilter || opts.projectFilter === primaryProject;
1266
+ const vitestInstance = typeof stashedVitest === "object" && stashedVitest !== null ? stashedVitest : void 0;
1267
+ const performanceHints = isFirstProject && vitestInstance !== void 0 ? collectPerformanceHints(vitestInstance, executionTimeMs ?? 0, { isCI: runsInCi() }) : [];
1268
+ let coverageScanMs = 0;
1269
+ const buildRunDiagnostics = (persistMs, persistence) => isFirstProject ? {
1270
+ performanceHints,
1271
+ postRun: {
1272
+ persistMs,
1273
+ coverageScanMs,
1274
+ totalMs: persistMs + coverageScanMs,
1275
+ ...executionTimeMs !== void 0 && { vitestDurationMs: executionTimeMs }
1276
+ },
1277
+ ...persistence !== void 0 && { persistence }
1278
+ } : void 0;
1279
+ const suggestedActionsFor = (diagnostics) => {
1280
+ const actions = [...opts.suggestedActions ?? [], ...diagnostics !== void 0 ? suggestedActionsFromRunDiagnostics(diagnostics) : []];
1281
+ return actions.length > 0 ? actions : void 0;
1282
+ };
1283
+ const publishDiagnostics = (diagnostics, renderMs) => {
1284
+ if (!isFirstProject || vitestInstance === void 0) return;
1285
+ publishRunDiagnostics(vitestInstance, diagnostics !== void 0 && renderMs !== void 0 ? {
1286
+ ...diagnostics,
1287
+ postRun: {
1288
+ ...diagnostics.postRun,
1289
+ renderMs
1290
+ }
1291
+ } : diagnostics);
1292
+ };
1293
+ let runDiagnostics;
1294
+ let renderMs;
1225
1295
  const coverageRoot = (() => {
1226
1296
  const root = stashedVitest?.config?.root;
1227
1297
  return typeof root === "string" ? root : void 0;
@@ -1237,15 +1307,8 @@ var AgentReporter = class {
1237
1307
  ...exclude !== void 0 ? { exclude } : {}
1238
1308
  };
1239
1309
  })();
1240
- const analyzeCoverage = (baselines) => Effect.gen(function* () {
1310
+ const analyzeCoverageUntimed = (baselines) => Effect.gen(function* () {
1241
1311
  const analyzer = yield* CoverageAnalyzer;
1242
- const projectModuleCounts = /* @__PURE__ */ new Map();
1243
- for (const m of modules) {
1244
- const key = m.project.name || "default";
1245
- projectModuleCounts.set(key, (projectModuleCounts.get(key) ?? 0) + 1);
1246
- }
1247
- const primaryProject = Array.from(projectModuleCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
1248
- const isFirstProject = !opts.projectFilter || opts.projectFilter === primaryProject;
1249
1312
  const coverageOpts = {
1250
1313
  thresholds: opts.coverageThresholds,
1251
1314
  includeBareZero: opts.includeBareZero,
@@ -1291,6 +1354,12 @@ var AgentReporter = class {
1291
1354
  }
1292
1355
  return coverageReport;
1293
1356
  });
1357
+ const analyzeCoverage = (baselines) => Effect.suspend(() => {
1358
+ const startedAt = performance.now();
1359
+ return analyzeCoverageUntimed(baselines).pipe(Effect.ensuring(Effect.sync(() => {
1360
+ coverageScanMs += performance.now() - startedAt;
1361
+ })));
1362
+ });
1294
1363
  if (opts.coverageMode === "ui-only" || !opts.persist) {
1295
1364
  const uiProjectGroups = /* @__PURE__ */ new Map();
1296
1365
  for (const mod of filteredModules) {
@@ -1301,6 +1370,7 @@ var AgentReporter = class {
1301
1370
  }
1302
1371
  const isMultiProject = uiProjectGroups.size > 1 || !!opts.projectFilter;
1303
1372
  const uiReports = [];
1373
+ const summaryReports = uiReports;
1304
1374
  for (const [projectName, projectModules] of uiProjectGroups) {
1305
1375
  const report = buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProject ? projectName : void 0);
1306
1376
  uiReports.push(report);
@@ -1342,21 +1412,28 @@ var AgentReporter = class {
1342
1412
  persist: opts.persist
1343
1413
  });
1344
1414
  const reporters = preBuiltReporters ?? normalizeReporters(opts.reporter(kit));
1415
+ runDiagnostics = buildRunDiagnostics(0, void 0);
1416
+ const suggestedActions = suggestedActionsFor(runDiagnostics);
1345
1417
  const renderInput = {
1346
1418
  reports: withStrayOutput(reports, strayOutput),
1347
1419
  classifications: /* @__PURE__ */ new Map(),
1348
- ...opts.suggestedActions !== void 0 && { suggestedActions: opts.suggestedActions }
1420
+ ...suggestedActions !== void 0 && { suggestedActions },
1421
+ ...runDiagnostics !== void 0 && { runDiagnostics }
1349
1422
  };
1423
+ const renderStartedAt = performance.now();
1350
1424
  const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
1351
1425
  for (const output of allOutputs) routeRenderedOutput(output, {
1352
1426
  ...githubSummaryFile !== void 0 && { githubSummaryFile },
1353
1427
  ...reportWriter !== null && { writeReport: reportWriter.write }
1354
1428
  });
1429
+ renderMs = performance.now() - renderStartedAt;
1355
1430
  });
1356
1431
  await Effect.runPromise(uiProgram.pipe(Effect.provide(CoverageAnalyzerLive), Effect.provide(OutputPipelineLive(process.env)), Effect.provide(NodeServices.layer))).catch((err) => {
1357
1432
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1358
1433
  });
1359
1434
  await reportWriter?.flush();
1435
+ publishDiagnostics(runDiagnostics, renderMs);
1436
+ writeFailureSummary(summaryReports);
1360
1437
  return;
1361
1438
  }
1362
1439
  if (dbPath !== void 0 && persistDisabled === void 0) try {
@@ -1384,6 +1461,7 @@ var AgentReporter = class {
1384
1461
  await ensureMigrated(dbPath, logLevel, logFile, process.env);
1385
1462
  } catch (err) {
1386
1463
  persistDisabled = formatFatalError(err);
1464
+ migrationFailed = true;
1387
1465
  }
1388
1466
  const persistProgram = Effect.gen(function* () {
1389
1467
  const store = yield* DataStore;
@@ -1396,6 +1474,10 @@ var AgentReporter = class {
1396
1474
  const settings = captureSettings(vitestConfig, vitestVersion);
1397
1475
  const settingsHash = hashSettings(settings);
1398
1476
  const envVars = captureEnvVars(process.env);
1477
+ const runtimeEnv = RuntimeEnv.fromRecord(process.env);
1478
+ const ciRun = Option.isSome(runtimeEnv.ci) && runtimeEnv.ci.value === "github-actions" ? Option.getOrUndefined(runtimeEnv.ciRun) : void 0;
1479
+ const ciCommitSha = ciRun === void 0 ? null : Option.getOrNull(ciRun.sha);
1480
+ const ciRefName = ciRun === void 0 ? null : Option.getOrNull(ciRun.refName);
1399
1481
  const projectGroups = /* @__PURE__ */ new Map();
1400
1482
  for (const mod of filteredModules) {
1401
1483
  const key = mod.project.name || "default";
@@ -1434,8 +1516,8 @@ var AgentReporter = class {
1434
1516
  project,
1435
1517
  settingsHash,
1436
1518
  timestamp: baseReport.timestamp,
1437
- commitSha: process.env.GITHUB_SHA ?? null,
1438
- branch: process.env.GITHUB_REF_NAME ?? null,
1519
+ commitSha: ciCommitSha,
1520
+ branch: ciRefName,
1439
1521
  reason: projectReason,
1440
1522
  duration: totalDuration,
1441
1523
  total: baseReport.summary.total,
@@ -1446,8 +1528,8 @@ var AgentReporter = class {
1446
1528
  actorType: attribution.actorType,
1447
1529
  agentId: attribution.agentId,
1448
1530
  conversationId: attribution.conversationId,
1449
- gitBranch: process.env.GITHUB_REF_NAME ?? null,
1450
- gitCommitSha: process.env.GITHUB_SHA ?? null,
1531
+ gitBranch: ciRefName,
1532
+ gitCommitSha: ciCommitSha,
1451
1533
  hostSource: hostProbe.source,
1452
1534
  hostValue: hostProbe.value,
1453
1535
  hostMetadata: hostProbe.metadata,
@@ -1800,14 +1882,26 @@ var AgentReporter = class {
1800
1882
  });
1801
1883
  let persistFailure;
1802
1884
  let persistResult;
1803
- if (persistDisabled === void 0 && dbPath !== void 0) persistResult = await Effect.runPromise(persistProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive({
1804
- dbPath,
1805
- env: process.env,
1806
- logLevel,
1807
- logFile
1808
- })))).catch((err) => {
1809
- persistFailure = formatFatalError(err);
1885
+ let persistMs = 0;
1886
+ if (persistDisabled === void 0 && dbPath !== void 0) {
1887
+ const persistStartedAt = performance.now();
1888
+ persistResult = await Effect.runPromise(persistProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive({
1889
+ dbPath,
1890
+ env: process.env,
1891
+ logLevel,
1892
+ logFile
1893
+ })))).catch((err) => {
1894
+ persistFailure = formatFatalError(err);
1895
+ });
1896
+ persistMs = Math.max(0, performance.now() - persistStartedAt - coverageScanMs);
1897
+ }
1898
+ const persistError = persistDisabled ?? persistFailure;
1899
+ runDiagnostics = buildRunDiagnostics(persistMs, persistError === void 0 ? { ok: true } : {
1900
+ ok: false,
1901
+ reason: persistError,
1902
+ ...persistDisabled !== void 0 && migrationFailed && { migrationFailed: true }
1810
1903
  });
1904
+ const suggestedActions = suggestedActionsFor(runDiagnostics);
1811
1905
  const renderInputData = persistResult ?? {
1812
1906
  reports: fallbackReports,
1813
1907
  classifications: /* @__PURE__ */ new Map()
@@ -1855,9 +1949,11 @@ var AgentReporter = class {
1855
1949
  const renderInput = {
1856
1950
  reports: withStrayOutput(reports, strayOutput),
1857
1951
  classifications,
1858
- ...opts.suggestedActions !== void 0 && { suggestedActions: opts.suggestedActions },
1859
- ...trendSummary !== void 0 && { trendSummary }
1952
+ ...suggestedActions !== void 0 && { suggestedActions },
1953
+ ...trendSummary !== void 0 && { trendSummary },
1954
+ ...runDiagnostics !== void 0 && { runDiagnostics }
1860
1955
  };
1956
+ const renderStartedAt = performance.now();
1861
1957
  const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
1862
1958
  yield* Effect.logDebug("reporters rendered").pipe(Effect.annotateLogs({
1863
1959
  reporterCount: reporters.length,
@@ -1867,13 +1963,15 @@ var AgentReporter = class {
1867
1963
  ...githubSummaryFile !== void 0 && { githubSummaryFile },
1868
1964
  ...reportWriter !== null && { writeReport: reportWriter.write }
1869
1965
  });
1966
+ renderMs = performance.now() - renderStartedAt;
1870
1967
  });
1871
1968
  await Effect.runPromise(renderProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(OutputPipelineLive(process.env)), Effect.provide(NodeServices.layer))).catch((err) => {
1872
1969
  process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1873
1970
  });
1874
1971
  await reportWriter?.flush();
1875
- const persistError = persistDisabled ?? persistFailure;
1972
+ publishDiagnostics(runDiagnostics, renderMs);
1876
1973
  if (persistError !== void 0) process.stderr.write(`vitest-agent: persistence failed — results above were rendered but NOT recorded: ${persistError}\n`);
1974
+ writeFailureSummary(renderInputData.reports);
1877
1975
  }
1878
1976
  };
1879
1977
 
@@ -0,0 +1,205 @@
1
+ import { formatTime, getEnvironmentDiagnostics, getImportDiagnostics, getIsolateDiagnostic, getTransformDiagnostics } from "./vitest-diagnostics.js";
2
+
3
+ // src/utils/collect-performance-hints.ts
4
+ const LEARN_MORE = {
5
+ environment: "https://vitest.dev/guide/improving-performance#test-environments",
6
+ import: "https://vitest.dev/guide/improving-performance#test-isolation",
7
+ transform: "https://vitest.dev/guide/improving-performance#caching-between-reruns",
8
+ isolate: "https://vitest.dev/guide/improving-performance#test-isolation"
9
+ };
10
+ /**
11
+ * Summed time of all tracked phases of a file (base.ts `trackedFileTime`).
12
+ * The transform wait is part of `setupDuration`/`collectDuration`, so it is
13
+ * not added separately.
14
+ */
15
+ const trackedFileTime = (file) => (file.environmentLoad || 0) + (file.setupDuration || 0) + (file.collectDuration || 0) + (file.prepareDuration || 0) + (file.result?.duration || 0);
16
+ const pct = (share) => Math.round(share * 100);
17
+ const projectFilesOf = (files, name) => files.filter((file) => (file.projectName || "") === name);
18
+ const nameOf = (project) => project.name ?? "";
19
+ const withProject = (name) => name ? { project: name } : {};
20
+ function environmentHints(vitest, projects, files, executionTime) {
21
+ const maxWorkers = vitest.config?.maxWorkers ?? 1;
22
+ const inputs = projects.map((project) => {
23
+ const name = nameOf(project);
24
+ const config = project.config ?? {};
25
+ let environmentTime = 0;
26
+ let environmentCount = 0;
27
+ let trackedTime = 0;
28
+ for (const file of projectFilesOf(files, name)) {
29
+ if (file.environmentLoad) {
30
+ environmentTime += file.environmentLoad;
31
+ environmentCount++;
32
+ }
33
+ trackedTime += trackedFileTime(file);
34
+ }
35
+ return {
36
+ name,
37
+ environment: config.environment ?? "",
38
+ pool: config.pool ?? "",
39
+ isolate: config.isolate === true,
40
+ browser: config.browser?.enabled === true,
41
+ poolProvided: Boolean(config.providedOptions?.pool),
42
+ isolateProvided: Boolean(config.providedOptions?.isolate),
43
+ environmentTime,
44
+ environmentCount,
45
+ trackedTime,
46
+ parallelism: Math.max(1, Math.min(environmentCount, maxWorkers)),
47
+ executionTime
48
+ };
49
+ });
50
+ return getEnvironmentDiagnostics(inputs).map((d) => ({
51
+ kind: "environment",
52
+ ...withProject(d.name),
53
+ message: `${d.environment} was created ${d.environmentCount} times · ${formatTime(d.environmentTime)} total, ${pct(d.share)}% of tracked time`,
54
+ remedy: "pool: 'vmThreads'",
55
+ detail: d.suggestIsolate ? "create it once per worker (keeps per-file isolation) or isolate: false (shares it across files)" : "create it once per worker (keeps per-file isolation)",
56
+ learnMoreUrl: LEARN_MORE.environment,
57
+ share: d.share
58
+ }));
59
+ }
60
+ function importHints(vitest, projects, files, executionTime) {
61
+ const maxWorkers = vitest.config?.maxWorkers ?? 1;
62
+ const metadata = vitest.state?.metadata ?? {};
63
+ const inputs = projects.map((project) => {
64
+ const name = nameOf(project);
65
+ const config = project.config ?? {};
66
+ const projectFiles = projectFilesOf(files, name);
67
+ let importTime = 0;
68
+ let trackedTime = 0;
69
+ for (const file of projectFiles) {
70
+ importTime += Math.max((file.collectDuration || 0) - (file.collectFetchDuration || 0), 0);
71
+ trackedTime += trackedFileTime(file);
72
+ }
73
+ const durations = metadata[name]?.duration;
74
+ return {
75
+ name,
76
+ pool: config.pool ?? "",
77
+ isolate: config.isolate === true,
78
+ browser: config.browser?.enabled === true,
79
+ isolateProvided: Boolean(config.providedOptions?.isolate),
80
+ importTime,
81
+ trackedTime,
82
+ fetchCounts: durations ? Object.values(durations).map((times) => times.length) : [],
83
+ fileCount: projectFiles.length,
84
+ parallelism: Math.max(1, Math.min(projectFiles.length, maxWorkers)),
85
+ executionTime
86
+ };
87
+ });
88
+ return getImportDiagnostics(inputs).map((d) => ({
89
+ kind: "import",
90
+ ...withProject(d.name),
91
+ message: `${d.uniqueModules} modules were evaluated ${d.totalFetches} times · ${formatTime(d.importTime)} total, ${pct(d.share)}% of tracked time`,
92
+ remedy: "isolate: false",
93
+ detail: `~${formatTime(d.estimatedSaving)} faster — shared modules are evaluated once per worker instead of once per file`,
94
+ learnMoreUrl: LEARN_MORE.import,
95
+ estimatedSavingMs: d.estimatedSaving,
96
+ share: d.share
97
+ }));
98
+ }
99
+ function transformHints(projects, files, executionTime, isCI) {
100
+ const inputs = projects.map((project) => {
101
+ const name = nameOf(project);
102
+ const config = project.config ?? {};
103
+ let transformTime = 0;
104
+ let trackedTime = 0;
105
+ for (const file of projectFilesOf(files, name)) {
106
+ transformTime += (file.setupFetchDuration || 0) + (file.collectFetchDuration || 0);
107
+ trackedTime += trackedFileTime(file);
108
+ }
109
+ return {
110
+ name,
111
+ transformTime,
112
+ trackedTime,
113
+ fsModuleCache: config.fsModuleCache === true,
114
+ fsModuleCacheProvided: Boolean(config.providedOptions?.fsModuleCache),
115
+ executionTime
116
+ };
117
+ });
118
+ return getTransformDiagnostics(inputs).map((d) => ({
119
+ kind: "transform",
120
+ ...withProject(d.name),
121
+ message: `transforming modules took ${formatTime(d.transformTime)} · ${pct(d.share)}% of tracked time, re-done on every run`,
122
+ remedy: "fsModuleCache: true",
123
+ detail: isCI ? "persist transforms across runs; on CI this only helps when the cache directory is persisted between runs" : "persist transforms across runs",
124
+ learnMoreUrl: LEARN_MORE.transform,
125
+ share: d.share
126
+ }));
127
+ }
128
+ function isolateHint(vitest, projects, files, executionTime) {
129
+ const config = vitest.config ?? {};
130
+ const isEligible = (project) => project.config?.isolate === true && project.config.browser?.enabled !== true && !project.config.providedOptions?.isolate;
131
+ const measuresModules = (config.experimental?.importDurations?.limit ?? 0) > 0;
132
+ const moduleSelfTimes = /* @__PURE__ */ new Map();
133
+ if (measuresModules) {
134
+ const eligibleProjects = new Set(projects.filter((project) => (project.config?.pool === "forks" || project.config?.pool === "threads") && isEligible(project)).map(nameOf));
135
+ for (const file of files) {
136
+ if (!eligibleProjects.has(file.projectName || "") || !file.importDurations) continue;
137
+ for (const moduleId in file.importDurations) {
138
+ let times = moduleSelfTimes.get(moduleId);
139
+ if (!times) {
140
+ times = [];
141
+ moduleSelfTimes.set(moduleId, times);
142
+ }
143
+ times.push(file.importDurations[moduleId]?.selfTime ?? 0);
144
+ }
145
+ }
146
+ }
147
+ const d = getIsolateDiagnostic({
148
+ numWorkers: vitest.state?.workersSpawned ?? 0,
149
+ isolates: projects.some(isEligible),
150
+ numFiles: files.length,
151
+ startupTime: vitest.state?.startupTime ?? 0,
152
+ maxWorkers: config.maxWorkers ?? 1,
153
+ measuresModules,
154
+ moduleSelfTimes: moduleSelfTimes.values(),
155
+ executionTime
156
+ });
157
+ if (d === void 0) return void 0;
158
+ return {
159
+ kind: "isolate",
160
+ message: `${d.numWorkers} workers spawned · ~${formatTime(d.avgStartup)} startup each (spawn + environment, per file)`,
161
+ remedy: "isolate: false",
162
+ detail: `${d.measuresModules ? "" : "at least "}~${formatTime(d.estimatedSaving)} faster — ${d.measuresModules ? "reuses workers across files and evaluates shared modules once per worker" : "reuses workers across files instead of one per file"}`,
163
+ learnMoreUrl: LEARN_MORE.isolate,
164
+ estimatedSavingMs: d.estimatedSaving
165
+ };
166
+ }
167
+ /**
168
+ * Compute the performance hints Vitest's `BaseReporter` would print at the
169
+ * end of a run, for runs where `AgentPlugin` stripped that reporter. Applies
170
+ * Vitest's gating (watch mode, merged blob reports, and each kind's
171
+ * `experimental.diagnostics` flag) and its one-hint-per-family order:
172
+ * environment, else import, else transform; isolate only when none of those
173
+ * produced a hint. Never throws; an unrecognized `vitest` shape yields `[]`.
174
+ *
175
+ * @param vitest - The Vitest instance (read structurally)
176
+ * @param executionTimeMs - Wall time of the run, in milliseconds
177
+ * @param options - See {@link CollectPerformanceHintsOptions}
178
+ * @internal
179
+ */
180
+ function collectPerformanceHints(vitest, executionTimeMs, options = {}) {
181
+ try {
182
+ const v = vitest;
183
+ const config = v.config;
184
+ if (config === void 0 || config === null || typeof config !== "object" || config.watch) return [];
185
+ const projects = Array.isArray(v.projects) ? v.projects : [];
186
+ const getFiles = v.state?.getFiles;
187
+ const files = typeof getFiles === "function" ? getFiles.call(v.state) ?? [] : [];
188
+ const diagnostics = config.experimental?.diagnostics ?? {};
189
+ if (!v.state?.blobs) {
190
+ const familyHints = diagnostics.environment ? environmentHints(v, projects, files, executionTimeMs) : [];
191
+ if (familyHints.length > 0) return familyHints;
192
+ const imports = diagnostics.import ? importHints(v, projects, files, executionTimeMs) : [];
193
+ if (imports.length > 0) return imports;
194
+ const transforms = diagnostics.transform ? transformHints(projects, files, executionTimeMs, options.isCI === true) : [];
195
+ if (transforms.length > 0) return transforms;
196
+ }
197
+ if (!diagnostics.isolate) return [];
198
+ const isolate = isolateHint(v, projects, files, executionTimeMs);
199
+ return isolate === void 0 ? [] : [isolate];
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ export { collectPerformanceHints };
@@ -1,9 +1,10 @@
1
1
  import { toPosixPath } from "./to-posix-path.js";
2
- import { nodeWalkerFs } from "./walker-fs.js";
2
+ import { directorySignature } from "./find-test-files.js";
3
3
  import { DefaultDiscoverStrategy } from "./discover-strategy.js";
4
4
  import { isTestShapedPackage } from "./is-test-shaped-package.js";
5
5
  import { isAbsolute, join, normalize, relative } from "node:path";
6
- import { NON_DISCOVERABLE_DIRS, SRC_DIR, TEST_DIR } from "@vitest-agent/sdk";
6
+ import { SRC_DIR, TEST_DIR } from "@vitest-agent/sdk";
7
+ import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
7
8
  import { findWorkspaceRootSync, getWorkspacePackagesSync } from "@effected/workspaces";
8
9
  import { nodeSyncOps } from "@effected/workspaces/node-sync";
9
10
 
@@ -68,61 +69,26 @@ function recordDiscoveryScanTimestamp() {
68
69
  }
69
70
  const _cache = /* @__PURE__ */ new Map();
70
71
  /**
71
- * Computes a cheap signature (relative path + mtimeMs pairs, sorted) for every
72
- * file nested under `dirPath`. Used to detect added/removed/moved/renamed test
73
- * files between `discoverProjects` calls without re-walking test-file globs.
74
- * Returns an empty string when `dirPath` does not exist — this still produces
75
- * a stable, comparable signature contribution.
76
- *
77
- * Manual per-directory walk (not a single `readdir({ recursive: true })`
78
- * call) pruning `NON_DISCOVERABLE_DIRS` *before* recursing: a `src/` or
79
- * `__test__/` dir can itself contain a fixture `node_modules` (e.g. a
80
- * subprocess-e2e fixture that installs deps), and Node's recursive `readdir`
81
- * follows symlinked directories, so an unguarded call would walk into it.
82
- */
83
- async function computeDirSignature(dirPath, fs) {
84
- const parts = [];
85
- await walkDirSignature(dirPath, dirPath, parts, fs);
86
- parts.sort();
87
- return parts.join("|");
88
- }
89
- async function walkDirSignature(root, dir, parts, fs) {
90
- let entries;
91
- try {
92
- entries = await fs.readDirectory(dir);
93
- } catch {
94
- return;
95
- }
96
- for (const ent of entries) {
97
- const fullPath = join(dir, ent.name);
98
- if (ent.isDirectory()) {
99
- if (NON_DISCOVERABLE_DIRS.has(ent.name)) continue;
100
- await walkDirSignature(root, fullPath, parts, fs);
101
- } else if (ent.isFile()) {
102
- const info = await fs.statEntry(fullPath);
103
- if (info === null) continue;
104
- const mtimeMs = info.mtimeMs;
105
- const relPath = toPosixPath(relative(root, fullPath));
106
- parts.push(`${relPath}:${mtimeMs}`);
107
- }
108
- }
109
- }
110
- /**
111
72
  * Computes a cheap whole-workspace directory signature from each package's
112
73
  * `src/` and `__test__/` directories (issue #100). Only entries + mtimes are
113
74
  * read — no file content — so this stays fast for large monorepos. A changed
114
75
  * signature means a test file was added, removed, moved, or renamed since
115
76
  * the cached result was computed.
116
77
  *
78
+ * Each directory's signature is composed from the stats
79
+ * `@effected/walker`'s `descendEntriesSync` already read (relative path plus
80
+ * mtimeMs per file), with the same prune and symlink rules as the test-file
81
+ * walk.
82
+ *
117
83
  * Only the two anchored directories are fingerprinted. Discovery cannot see a
118
84
  * test file anywhere else, so nothing else can change the emitted project list
119
85
  * (issue #227).
120
86
  */
121
- async function computeWorkspaceSignature(packages, fs) {
87
+ function computeWorkspaceSignature(packages, fs) {
122
88
  const parts = [];
123
89
  for (const pkg of packages) {
124
- const srcSig = await computeDirSignature(join(pkg.path, SRC_DIR), fs);
125
- const testSig = await computeDirSignature(join(pkg.path, TEST_DIR), fs);
90
+ const srcSig = directorySignature(join(pkg.path, SRC_DIR), fs);
91
+ const testSig = directorySignature(join(pkg.path, TEST_DIR), fs);
126
92
  parts.push(`${pkg.path}::src=${srcSig}::__test__=${testSig}`);
127
93
  }
128
94
  return parts.join("\n");
@@ -138,7 +104,7 @@ async function discoverProjects(options) {
138
104
  const cwd = options?.cwd;
139
105
  const additionalEntries = options?.additionalEntries ?? [];
140
106
  const configure = options?.configure;
141
- const fs = options?.fs ?? nodeWalkerFs;
107
+ const fs = options?.fs ?? nodeWalkerSyncOps;
142
108
  const syncOps = options?.syncOps ?? nodeSyncOps;
143
109
  const packageEnumerationOptions = {
144
110
  ...syncOps,
@@ -151,7 +117,7 @@ async function discoverProjects(options) {
151
117
  const packages = getWorkspacePackagesSync(root, packageEnumerationOptions);
152
118
  let signature;
153
119
  if (useCache) {
154
- signature = await computeWorkspaceSignature(packages, fs);
120
+ signature = computeWorkspaceSignature(packages, fs);
155
121
  const cached = _cache.get(root);
156
122
  if (cached && cached.signature === signature) return cached.result;
157
123
  }
@@ -1,10 +1,10 @@
1
- import { nodeWalkerFs } from "./walker-fs.js";
1
+ import { runsInCi } from "./runs-in-ci.js";
2
2
  import { findTestFiles } from "./find-test-files.js";
3
3
  import { Tag } from "./tag.js";
4
+ import { statOrNull } from "./walker-fs.js";
4
5
  import { join, sep } from "node:path";
5
- import { RuntimeEnv } from "@effected/env";
6
6
  import { NON_DISCOVERABLE_DIRS, SRC_DIR, TEST_DIR, TEST_FILE_GLOB_SUFFIX, TEST_HELPER_DIRS } from "@vitest-agent/sdk";
7
- import { Option } from "effect";
7
+ import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
8
8
  import { configDefaults } from "vitest/config";
9
9
 
10
10
  // src/utils/discover-strategy.ts
@@ -14,10 +14,10 @@ const SETUP_EXTS = [
14
14
  "js",
15
15
  "jsx"
16
16
  ];
17
- async function detectSetupFile(pkgPath, fs) {
17
+ function detectSetupFile(pkgPath, fs) {
18
18
  for (const ext of SETUP_EXTS) {
19
19
  const candidate = join(pkgPath, `vitest.setup.${ext}`);
20
- if ((await fs.statEntry(candidate))?.isFile === true) return `vitest.setup.${ext}`;
20
+ if (statOrNull(fs, candidate)?.isFile() === true) return `vitest.setup.${ext}`;
21
21
  }
22
22
  return null;
23
23
  }
@@ -82,14 +82,6 @@ var ConcreteDiscoverStrategy = class ConcreteDiscoverStrategy extends DiscoverSt
82
82
  return new ConcreteDiscoverStrategy(newTags, newClassifyLayers, newBuildProjectLayers);
83
83
  }
84
84
  };
85
- /**
86
- * Whether the process runs in CI, by `@effected/env`'s `RuntimeEnv` rule: a
87
- * truthy `GITHUB_ACTIONS`, or a `CI` / `CONTINUOUS_INTEGRATION` that is set,
88
- * non-empty, and not `false` / `0` (is-in-ci semantics). Read when a strategy is
89
- * constructed rather than at module load, so the environment in force when
90
- * `AgentPlugin.discover()` runs is the one that decides.
91
- */
92
- const runsInCi = () => Option.isSome(RuntimeEnv.fromRecord(process.env).ci);
93
85
  const defaultTags = (inCi) => [
94
86
  Tag.make("unit"),
95
87
  Tag.make("int", { timeout: 6e4 }),
@@ -118,7 +110,7 @@ var DefaultDiscoverStrategy = class extends DiscoverStrategy {
118
110
  return ["unit"];
119
111
  }
120
112
  async buildProject(input) {
121
- const fs = input.fs ?? nodeWalkerFs;
113
+ const fs = input.fs ?? nodeWalkerSyncOps;
122
114
  const srcPrefix = join(input.path, SRC_DIR);
123
115
  const allFiles = await findTestFiles(input.path, [`${SRC_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`, `${TEST_DIR}/**/${TEST_FILE_GLOB_SUFFIX}`], fs);
124
116
  if (allFiles.length === 0) return null;
@@ -132,7 +124,7 @@ var DefaultDiscoverStrategy = class extends DiscoverStrategy {
132
124
  ...[SRC_DIR, TEST_DIR].flatMap((root) => [...NON_DISCOVERABLE_DIRS].map((d) => join(input.path, root, "**", d, "**"))),
133
125
  ...hasTestDirTests ? TEST_HELPER_DIRS.map((d) => join(input.path, TEST_DIR, d, "**")) : []
134
126
  ];
135
- const setupFile = await detectSetupFile(input.path, fs);
127
+ const setupFile = detectSetupFile(input.path, fs);
136
128
  return {
137
129
  extends: true,
138
130
  test: {