@vitest-agent/plugin 1.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 ADDED
@@ -0,0 +1,1349 @@
1
+ import { resolveThresholds } from "./utils/resolve-thresholds.js";
2
+ import { CoverageAnalyzer } from "./services/CoverageAnalyzer.js";
3
+ import { ReporterLive } from "./layers/ReporterLive.js";
4
+ import { buildReporterKit, normalizeReporters } from "./utils/build-reporter-kit.js";
5
+ import { captureEnvVars } from "./utils/capture-env.js";
6
+ import { captureSettings, hashSettings } from "./utils/capture-settings.js";
7
+ import { processFailure } from "./utils/process-failure.js";
8
+ import { routeRenderedOutput } from "./utils/route-rendered-output.js";
9
+ import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
10
+ import { DefaultVitestAgentReporter } from "@vitest-agent/reporter";
11
+ import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, computeTrend, ensureMigrated, formatFatalError, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
12
+ import { Effect, Option, PubSub } from "effect";
13
+ import { randomUUID } from "node:crypto";
14
+ import { mkdirSync } from "node:fs";
15
+ import { dirname } from "node:path";
16
+ import { NodeContext } from "@effect/platform-node";
17
+
18
+ //#region src/reporter.ts
19
+ /**
20
+ * Cache of in-flight `resolveDataPath` promises, keyed by `projectDir`.
21
+ *
22
+ * Multi-project Vitest configs construct one `AgentReporter` instance per
23
+ * project (typical: 4-5 reporters per repo). Each instance's `ensureDbPath`
24
+ * runs `resolveDataPath(projectDir)` under `PathResolutionLive`, which
25
+ * pulls in `WorkspacesLive` — a layer that eagerly scans lockfiles and
26
+ * walks the workspace package graph. Without this cache, that scan
27
+ * happens N times in serial during reporter init, adding seconds of
28
+ * overhead to every `vitest run`.
29
+ *
30
+ * The cache is module-local: one entry per `projectDir` (in practice,
31
+ * always `process.cwd()` for a single Vitest invocation), shared across
32
+ * all reporter instances within the same Node process. Sufficient
33
+ * because reporter instances are created in the main Vitest process,
34
+ * not in worker forks.
35
+ *
36
+ * @internal
37
+ */
38
+ const dbPathCache = /* @__PURE__ */ new Map();
39
+ function resolveDataPathCached(projectDir) {
40
+ const cached = dbPathCache.get(projectDir);
41
+ if (cached !== void 0) return cached;
42
+ const promise = Effect.runPromise(resolveDataPath(projectDir).pipe(Effect.provide(PathResolutionLive(projectDir)), Effect.provide(NodeContext.layer)));
43
+ dbPathCache.set(projectDir, promise);
44
+ promise.catch(() => void 0);
45
+ return promise;
46
+ }
47
+ /**
48
+ * Compute updated baselines using ratchet logic: take the max of actual vs previous,
49
+ * capped by targets if set.
50
+ *
51
+ * @internal
52
+ */
53
+ function computeUpdatedBaselines(existing, actual, targets) {
54
+ const prev = existing?.global ?? {};
55
+ const cap = targets?.global ?? {};
56
+ const ratchet = (metric) => {
57
+ const actualVal = actual[metric];
58
+ const prevVal = prev[metric] ?? 0;
59
+ const targetVal = cap[metric];
60
+ const newVal = Math.max(actualVal, prevVal);
61
+ if (targetVal !== void 0 && newVal > targetVal) return targetVal;
62
+ return newVal;
63
+ };
64
+ return {
65
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
66
+ global: {
67
+ lines: ratchet("lines"),
68
+ functions: ratchet("functions"),
69
+ branches: ratchet("branches"),
70
+ statements: ratchet("statements")
71
+ },
72
+ patterns: existing?.patterns ?? []
73
+ };
74
+ }
75
+ /**
76
+ * Vitest Reporter that produces structured output for LLM coding agents.
77
+ *
78
+ * @remarks
79
+ * `AgentReporter` implements three Vitest Reporter lifecycle hooks:
80
+ *
81
+ * - {@link AgentReporter.onInit | onInit} -- stores the Vitest instance
82
+ * for project enumeration (used in Phase 2 overview generation)
83
+ * - {@link AgentReporter.onCoverage | onCoverage} -- stashes the istanbul
84
+ * CoverageMap for merging into reports
85
+ * - {@link AgentReporter.onTestRunEnd | onTestRunEnd} -- groups test
86
+ * modules by project, builds reports, writes data to SQLite,
87
+ * updates the manifest, and emits console/GFM output
88
+ *
89
+ * The reporter handles both single-package repos and monorepos by grouping
90
+ * results via Vitest's native `TestProject` API. In single-project mode,
91
+ * results are written with project name "default".
92
+ *
93
+ * @privateRemarks
94
+ * The `onCoverage` hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
95
+ * Coverage data must be stashed as instance state and merged during
96
+ * `onTestRunEnd`. This ordering is a Vitest design constraint, not a bug.
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * import { AgentReporter } from "@vitest-agent/plugin";
101
+ * import { defineConfig } from "vitest/config";
102
+ *
103
+ * export default defineConfig({
104
+ * test: {
105
+ * reporters: [
106
+ * new AgentReporter({
107
+ * cacheDir: ".vitest-agent",
108
+ * consoleOutput: "failures",
109
+ * coverageThresholds: { global: { lines: 80 } },
110
+ * }),
111
+ * ],
112
+ * },
113
+ * });
114
+ * ```
115
+ *
116
+ * @see `AgentPlugin` for the convenience plugin wrapper
117
+ * @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter API}
118
+ * @public
119
+ */
120
+ var AgentReporter = class {
121
+ options;
122
+ dbPath = null;
123
+ /**
124
+ * Set to `true` after the first `onTestRunEnd` invocation completes.
125
+ *
126
+ * Vitest can fire `onTestRunEnd` more than once per `vitest run` in
127
+ * some multi-project configurations (e.g., once per project group, with
128
+ * the same `testModules` array each time). The plugin pushes only ONE
129
+ * AgentReporter per run, so a fresh instance always starts with this
130
+ * flag `false`; the first call does the work and the flag prevents
131
+ * subsequent invocations from re-rendering and re-writing to the DB.
132
+ */
133
+ rendered = false;
134
+ /**
135
+ * Stored Vitest instance from {@link AgentReporter.onInit | onInit}.
136
+ *
137
+ * @remarks
138
+ * Available for Phase 2 overview generation. Exposed as a public
139
+ * property (prefixed with `_`) for testing and extension purposes.
140
+ *
141
+ * @internal
142
+ */
143
+ _vitest = null;
144
+ coverage = null;
145
+ logLevel;
146
+ logFile;
147
+ onRunEvent;
148
+ /**
149
+ * Run-event channel. The plugin publishes one `RunEvent` per
150
+ * Vitest streaming callback onto this `PubSub`; the reporter factory
151
+ * receives it on the `ReporterKit` and a live-painting reporter
152
+ * (the default reporter in `stream` mode) subscribes to drive its mount.
153
+ * The plugin no longer owns any render mount itself.
154
+ *
155
+ * @internal
156
+ */
157
+ runEvents;
158
+ /**
159
+ * Reporters resolved from the factory at run start (`onInit`). Reused
160
+ * by `onTestRunEnd` for the `render` call. Undefined when `onInit` did
161
+ * not run (tests invoking `onTestRunEnd` directly) — `onTestRunEnd`
162
+ * then resolves the factory itself.
163
+ *
164
+ * @internal
165
+ */
166
+ reporters;
167
+ /**
168
+ * Whether the user supplied a custom `reporter` factory. A custom
169
+ * reporter may subscribe to the run-event channel in any console mode,
170
+ * so the streaming hooks publish events whenever this is true.
171
+ *
172
+ * @internal
173
+ */
174
+ hasCustomReporter;
175
+ currentRunId = null;
176
+ moduleStartedAt = /* @__PURE__ */ new Map();
177
+ /**
178
+ * Wall-clock start stamp (`Date.now()`) for an in-flight Vitest
179
+ * setup/teardown hook, keyed `modulePath::hookType::scopeName`.
180
+ * `onHookStart` writes it, `onHookEnd` reads and clears it to derive
181
+ * `HookFinished.durationMs` — `ReportedHookContext` carries no
182
+ * duration of its own.
183
+ *
184
+ * @internal
185
+ */
186
+ hookStartedAt = /* @__PURE__ */ new Map();
187
+ constructor(options = {}) {
188
+ this.logLevel = resolveLogLevel();
189
+ this.logFile = resolveLogFile();
190
+ this.onRunEvent = options.onRunEvent;
191
+ this.hasCustomReporter = options.reporter !== void 0;
192
+ this.runEvents = Effect.runSync(PubSub.unbounded());
193
+ const rawThresholds = options.coverageThresholds;
194
+ const resolvedThresholds = rawThresholds && "global" in rawThresholds ? rawThresholds : resolveThresholds(rawThresholds);
195
+ const rawTargets = options.coverageTargets;
196
+ const resolvedTargets = rawTargets ? "global" in rawTargets ? rawTargets : resolveThresholds(rawTargets) : void 0;
197
+ const consoleMode = options.consoleMode ?? "passthrough";
198
+ const consoleOutput = consoleMode === "silent" ? "silent" : "failures";
199
+ const derivedFormat = options.format ?? (consoleMode === "silent" ? "silent" : consoleMode === "stream" || consoleMode === "agent" ? "terminal" : consoleMode === "ci-annotations" ? "ci-annotations" : void 0);
200
+ const githubActions = options.githubActions ?? false;
201
+ const base = {
202
+ ...options.cacheDir !== void 0 ? { cacheDir: options.cacheDir } : {},
203
+ consoleOutput,
204
+ omitPassingTests: true,
205
+ coverageThresholds: resolvedThresholds,
206
+ autoUpdate: true,
207
+ coverageConsoleLimit: 10,
208
+ includeBareZero: false,
209
+ githubActions,
210
+ githubSummary: githubActions,
211
+ githubSummaryFile: void 0,
212
+ ...derivedFormat !== void 0 ? { format: derivedFormat } : {},
213
+ consoleMode,
214
+ ...options.mcp !== void 0 ? { mcp: options.mcp } : {},
215
+ ...options.projectFilter !== void 0 ? { projectFilter: options.projectFilter } : {},
216
+ reporter: options.reporter ?? DefaultVitestAgentReporter,
217
+ coverageMode: options.coverageMode ?? "full",
218
+ transport: options.transport ?? { kind: "local" },
219
+ ...options.passWithNoTests !== void 0 ? { passWithNoTests: options.passWithNoTests } : {}
220
+ };
221
+ this.options = resolvedTargets ? {
222
+ ...base,
223
+ coverageTargets: resolvedTargets
224
+ } : base;
225
+ }
226
+ /**
227
+ * The resolved reporter config built at construction time. Exposed for
228
+ * test inspection and Phase 5 short-circuit logic.
229
+ *
230
+ * This getter is @internal — do not reference in user-facing docs.
231
+ * @internal
232
+ */
233
+ get resolvedConfig() {
234
+ const opts = this.options;
235
+ return {
236
+ executor: "ci",
237
+ consoleMode: opts.consoleMode,
238
+ mcp: opts.mcp ?? false,
239
+ consoleOutput: opts.consoleOutput,
240
+ omitPassingTests: opts.omitPassingTests,
241
+ coverageConsoleLimit: opts.coverageConsoleLimit,
242
+ includeBareZero: opts.includeBareZero,
243
+ githubActions: opts.githubActions,
244
+ githubSummary: opts.githubSummary,
245
+ format: opts.format ?? "vitest-bypass",
246
+ detail: opts.detail ?? "standard",
247
+ noColor: false,
248
+ coverageMode: opts.coverageMode,
249
+ transport: opts.transport,
250
+ ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
251
+ ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
252
+ ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests }
253
+ };
254
+ }
255
+ /**
256
+ * Store the Vitest instance for project enumeration.
257
+ *
258
+ * @remarks
259
+ * Called once at the start of the test run. The instance is stored
260
+ * for Phase 2 overview generation via `vitest.projects`.
261
+ *
262
+ * @param vitest - The Vitest instance
263
+ */
264
+ async onInit(vitest) {
265
+ this._vitest = vitest;
266
+ await this.ensureDbPath();
267
+ await this.initReporters();
268
+ }
269
+ /**
270
+ * Resolve the reporter factory at run start.
271
+ *
272
+ * The factory is invoked here — before any streaming hook fires — so a
273
+ * reporter that paints live (the default reporter's Ink mount in `stream`
274
+ * mode) can subscribe to {@link AgentReporter.runEvents | the run-event
275
+ * channel} before the first `RunStarted` event. The kit built here
276
+ * carries neutral run health; `detail` is resolved health-aware again
277
+ * for the render kit at `onTestRunEnd`, and that kit is the one handed
278
+ * to `render`.
279
+ *
280
+ * A resolution failure degrades silently: `this.reporters` stays unset
281
+ * and `onTestRunEnd` resolves the factory itself from the render kit.
282
+ *
283
+ * @internal
284
+ */
285
+ async initReporters() {
286
+ const opts = this.options;
287
+ try {
288
+ const initProgram = Effect.gen(function* () {
289
+ const detector = yield* EnvironmentDetector;
290
+ const executorResolver = yield* ExecutorResolver;
291
+ const formatSelector = yield* FormatSelector;
292
+ const detailResolver = yield* DetailResolver;
293
+ const env = yield* detector.detect();
294
+ const executor = yield* executorResolver.resolve(env);
295
+ return {
296
+ env,
297
+ executor,
298
+ format: yield* formatSelector.select(executor, opts.format, env),
299
+ detail: yield* detailResolver.resolve(executor, {
300
+ hasFailures: false,
301
+ belowTargets: false,
302
+ hasTargets: !!opts.coverageTargets
303
+ }, opts.detail)
304
+ };
305
+ });
306
+ const { env, executor, format, detail } = await Effect.runPromise(initProgram.pipe(Effect.provide(OutputPipelineLive), Effect.provide(NodeContext.layer)));
307
+ const kit = buildReporterKit({
308
+ env,
309
+ executor,
310
+ format,
311
+ detail,
312
+ noColor: !!process.env.NO_COLOR,
313
+ consoleMode: opts.consoleMode ?? "passthrough",
314
+ mcp: opts.mcp ?? false,
315
+ githubActions: opts.githubActions,
316
+ transport: opts.transport,
317
+ runEvents: this.runEvents,
318
+ ...this.dbPath !== null && { dbPath: this.dbPath },
319
+ ...opts.projectFilter !== void 0 && { projectFilter: opts.projectFilter },
320
+ ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
321
+ ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
322
+ ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
323
+ coverageMode: opts.coverageMode
324
+ });
325
+ this.reporters = normalizeReporters(opts.reporter(kit));
326
+ } catch (err) {
327
+ process.stderr.write(`vitest-agent: reporter init failed; retrying at run end (${formatFatalError(err)})\n`);
328
+ }
329
+ }
330
+ /**
331
+ * Should the streaming Vitest hooks publish `RunEvent`s? The
332
+ * hooks short-circuit when this is false to skip constructing event
333
+ * objects nothing will read: the default reporter consumes the channel
334
+ * only in `stream` mode, a custom reporter may consume it in any mode,
335
+ * and the user `onRunEvent` tap is fed off the same stream.
336
+ *
337
+ * @internal
338
+ */
339
+ wantsRunEvents() {
340
+ return this.onRunEvent !== void 0 || this.options.consoleMode === "stream" || this.hasCustomReporter;
341
+ }
342
+ /**
343
+ * Publish a `RunEvent` onto the run-event channel and the
344
+ * user-supplied tap. A throwing publish or tap is caught and logged to
345
+ * stderr — a live-render bug must not break persistence.
346
+ *
347
+ * @internal
348
+ */
349
+ emit(event) {
350
+ try {
351
+ Effect.runSync(PubSub.publish(this.runEvents, event));
352
+ } catch (err) {
353
+ process.stderr.write(`vitest-agent: run-event publish threw: ${formatFatalError(err)}\n`);
354
+ }
355
+ const tap = this.onRunEvent;
356
+ if (tap !== void 0) try {
357
+ tap(event);
358
+ } catch (err) {
359
+ process.stderr.write(`vitest-agent: onRunEvent tap threw: ${formatFatalError(err)}\n`);
360
+ }
361
+ }
362
+ /**
363
+ * Walk the suite parent chain to produce the suite-path array used
364
+ * in `RunEvent.TestStarted` / `RunEvent.TestFinished`.
365
+ *
366
+ * @internal
367
+ */
368
+ collectSuitePath(testCase) {
369
+ const path = [];
370
+ let cursor = testCase.parent;
371
+ while (cursor !== void 0 && cursor.type === "suite") {
372
+ path.unshift(cursor.name);
373
+ cursor = cursor.parent;
374
+ }
375
+ return path;
376
+ }
377
+ /**
378
+ * Vitest streaming hook: a test run is about to start. Mints a
379
+ * synthetic `runId` for the duration of this run and emits a
380
+ * `RunStarted` event for live subscribers.
381
+ */
382
+ onTestRunStart(_specifications) {
383
+ if (!this.wantsRunEvents()) return;
384
+ this.currentRunId = randomUUID();
385
+ this.moduleStartedAt.clear();
386
+ this.emit({
387
+ _tag: "RunStarted",
388
+ runId: this.currentRunId,
389
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
390
+ configHash: "live"
391
+ });
392
+ }
393
+ /**
394
+ * Read the owning Vitest project name off a `TestModule`. Vitest 4.x
395
+ * attaches `project` to every module; an empty name (the unnamed
396
+ * default project) collapses to `undefined` so the renderer treats it
397
+ * as a single anonymous project.
398
+ *
399
+ * @internal
400
+ */
401
+ moduleProjectName(testModule) {
402
+ const name = testModule.project?.name;
403
+ return name !== void 0 && name.length > 0 ? name : void 0;
404
+ }
405
+ /**
406
+ * Vitest streaming hook: a test module has been queued.
407
+ */
408
+ onTestModuleQueued(testModule) {
409
+ if (!this.wantsRunEvents()) return;
410
+ const projectName = this.moduleProjectName(testModule);
411
+ this.emit({
412
+ _tag: "ModuleQueued",
413
+ modulePath: testModule.relativeModuleId,
414
+ ...projectName !== void 0 && { projectName }
415
+ });
416
+ }
417
+ /**
418
+ * Vitest streaming hook: a test module is starting execution.
419
+ */
420
+ onTestModuleStart(testModule) {
421
+ if (!this.wantsRunEvents()) return;
422
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
423
+ this.moduleStartedAt.set(testModule.relativeModuleId, startedAt);
424
+ const projectName = this.moduleProjectName(testModule);
425
+ this.emit({
426
+ _tag: "ModuleStarted",
427
+ modulePath: testModule.relativeModuleId,
428
+ startedAt,
429
+ ...projectName !== void 0 && { projectName }
430
+ });
431
+ }
432
+ /**
433
+ * Vitest streaming hook: a test module has finished collecting its
434
+ * tests and suites, before execution begins.
435
+ */
436
+ onTestModuleCollected(testModule) {
437
+ if (!this.wantsRunEvents()) return;
438
+ let testCount = 0;
439
+ for (const _ of testModule.children.allTests()) testCount++;
440
+ let suiteCount = 0;
441
+ for (const _ of testModule.children.allSuites()) suiteCount++;
442
+ this.emit({
443
+ _tag: "ModuleCollected",
444
+ modulePath: testModule.relativeModuleId,
445
+ testCount,
446
+ suiteCount
447
+ });
448
+ }
449
+ /**
450
+ * Vitest streaming hook: a test case is about to run.
451
+ *
452
+ * Emits a standalone `TestStarted` so the renderer gets a render
453
+ * frame for the transient "running" state. `onTestCaseReady` fires
454
+ * before `onTestCaseResult`; emitting here — rather than synthesizing
455
+ * `TestStarted` back-to-back with `TestFinished` inside
456
+ * `onTestCaseResult` — gives the running state a real lifetime in the
457
+ * event stream and is the prerequisite for any future per-test
458
+ * animation.
459
+ */
460
+ onTestCaseReady(testCase) {
461
+ if (!this.wantsRunEvents()) return;
462
+ const modulePath = testCase.module?.relativeModuleId ?? "";
463
+ if (modulePath === "") return;
464
+ this.emit({
465
+ _tag: "TestStarted",
466
+ modulePath,
467
+ testName: testCase.name,
468
+ suitePath: this.collectSuitePath(testCase)
469
+ });
470
+ }
471
+ /**
472
+ * Vitest streaming hook: a test case has produced a result.
473
+ *
474
+ * Emits only `TestFinished` — the matching `TestStarted` is emitted
475
+ * by `onTestCaseReady`, which fires earlier in the lifecycle.
476
+ */
477
+ onTestCaseResult(testCase) {
478
+ if (!this.wantsRunEvents()) return;
479
+ const modulePath = testCase.module?.relativeModuleId ?? "";
480
+ if (modulePath === "") return;
481
+ const suitePath = this.collectSuitePath(testCase);
482
+ const result = testCase.result();
483
+ const diag = testCase.diagnostic();
484
+ const status = result?.state === "passed" || result?.state === "failed" || result?.state === "skipped" ? result.state : "pending";
485
+ const firstError = result?.errors?.[0];
486
+ const expectedStr = stringifyFailureValue(firstError?.expected);
487
+ const receivedStr = stringifyFailureValue(firstError?.actual);
488
+ const error = firstError !== void 0 ? {
489
+ message: firstError.message,
490
+ ...firstError.diff !== void 0 && { diff: firstError.diff },
491
+ ...expectedStr !== void 0 && { expected: expectedStr },
492
+ ...receivedStr !== void 0 && { received: receivedStr }
493
+ } : void 0;
494
+ const timedOut = result?.state === "failed" && firstError !== void 0 && isTimeoutError(firstError);
495
+ this.emit({
496
+ _tag: "TestFinished",
497
+ modulePath,
498
+ testName: testCase.name,
499
+ suitePath,
500
+ status,
501
+ durationMs: diag?.duration ?? 0,
502
+ ...error !== void 0 && { error },
503
+ ...timedOut && { timedOut: true }
504
+ });
505
+ }
506
+ /**
507
+ * Vitest streaming hook: a test module has finished. Emits a
508
+ * `ModuleFinished` carrying the tallied counts plus a duration
509
+ * derived from the module's diagnostic.
510
+ */
511
+ onTestModuleEnd(testModule) {
512
+ if (!this.wantsRunEvents()) return;
513
+ let pass = 0;
514
+ let fail = 0;
515
+ let skip = 0;
516
+ let timeout = 0;
517
+ for (const test of testModule.children.allTests()) {
518
+ const result = test.result();
519
+ const state = result?.state;
520
+ if (state === "passed") pass++;
521
+ else if (state === "failed") {
522
+ const firstError = result?.errors?.[0];
523
+ if (firstError !== void 0 && isTimeoutError(firstError)) timeout++;
524
+ else fail++;
525
+ } else skip++;
526
+ }
527
+ const tagCounts = {};
528
+ for (const test of testModule.children.allTests()) for (const tag of test.tags ?? []) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
529
+ const projectName = this.moduleProjectName(testModule);
530
+ this.emit({
531
+ _tag: "ModuleFinished",
532
+ modulePath: testModule.relativeModuleId,
533
+ passCount: pass,
534
+ failCount: fail,
535
+ skipCount: skip,
536
+ timeoutCount: timeout,
537
+ durationMs: testModule.diagnostic()?.duration ?? 0,
538
+ ...projectName !== void 0 && { projectName },
539
+ ...Object.keys(tagCounts).length > 0 && { tagCounts }
540
+ });
541
+ }
542
+ /**
543
+ * Walk a suite/test entity's parent chain to its owning module path.
544
+ *
545
+ * @internal
546
+ */
547
+ entityModulePath(entity) {
548
+ return entity.relativeModuleId ?? entity.module?.relativeModuleId ?? "";
549
+ }
550
+ /**
551
+ * Vitest streaming hook: a test suite is about to run.
552
+ */
553
+ onTestSuiteReady(testSuite) {
554
+ if (!this.wantsRunEvents()) return;
555
+ const modulePath = this.entityModulePath(testSuite);
556
+ if (modulePath === "") return;
557
+ this.emit({
558
+ _tag: "SuiteStarted",
559
+ modulePath,
560
+ suitePath: this.collectSuitePath(testSuite),
561
+ suiteName: testSuite.name
562
+ });
563
+ }
564
+ /**
565
+ * Vitest streaming hook: a test suite has finished running.
566
+ */
567
+ onTestSuiteResult(testSuite) {
568
+ if (!this.wantsRunEvents()) return;
569
+ const modulePath = this.entityModulePath(testSuite);
570
+ if (modulePath === "") return;
571
+ let pass = 0;
572
+ let fail = 0;
573
+ let skip = 0;
574
+ for (const test of testSuite.children.allTests()) {
575
+ const state = test.result()?.state;
576
+ if (state === "passed") pass++;
577
+ else if (state === "failed") fail++;
578
+ else skip++;
579
+ }
580
+ this.emit({
581
+ _tag: "SuiteFinished",
582
+ modulePath,
583
+ suitePath: this.collectSuitePath(testSuite),
584
+ suiteName: testSuite.name,
585
+ passCount: pass,
586
+ failCount: fail,
587
+ skipCount: skip
588
+ });
589
+ }
590
+ /**
591
+ * Derive `modulePath` / `scopeName` / a stable key for a Vitest
592
+ * setup/teardown hook from its `ReportedHookContext` entity.
593
+ *
594
+ * @internal
595
+ */
596
+ hookScope(hook) {
597
+ const entity = hook.entity ?? {};
598
+ return {
599
+ modulePath: entity.relativeModuleId ?? entity.module?.relativeModuleId ?? "",
600
+ scopeName: entity.name ?? entity.relativeModuleId ?? ""
601
+ };
602
+ }
603
+ /**
604
+ * Vitest streaming hook: a `beforeAll` / `afterAll` / `beforeEach` /
605
+ * `afterEach` hook is starting.
606
+ */
607
+ onHookStart(hook) {
608
+ if (!this.wantsRunEvents()) return;
609
+ if (hook.name !== "beforeAll" && hook.name !== "afterAll" && hook.name !== "beforeEach" && hook.name !== "afterEach") return;
610
+ const { modulePath, scopeName } = this.hookScope(hook);
611
+ this.hookStartedAt.set(`${modulePath}::${hook.name}::${scopeName}`, Date.now());
612
+ this.emit({
613
+ _tag: "HookStarted",
614
+ modulePath,
615
+ hookType: hook.name,
616
+ scopeName
617
+ });
618
+ }
619
+ /**
620
+ * Vitest streaming hook: a setup/teardown hook has finished. Duration
621
+ * is derived from the stamp `onHookStart` recorded — `ReportedHookContext`
622
+ * carries none of its own. Status is a best-effort read of the
623
+ * entity's collected errors.
624
+ */
625
+ onHookEnd(hook) {
626
+ if (!this.wantsRunEvents()) return;
627
+ if (hook.name !== "beforeAll" && hook.name !== "afterAll" && hook.name !== "beforeEach" && hook.name !== "afterEach") return;
628
+ const { modulePath, scopeName } = this.hookScope(hook);
629
+ const key = `${modulePath}::${hook.name}::${scopeName}`;
630
+ const startedAt = this.hookStartedAt.get(key);
631
+ this.hookStartedAt.delete(key);
632
+ const durationMs = startedAt !== void 0 ? Date.now() - startedAt : 0;
633
+ const entityErrors = typeof hook.entity?.errors === "function" ? hook.entity.errors() : [];
634
+ const firstError = entityErrors[0];
635
+ this.emit({
636
+ _tag: "HookFinished",
637
+ modulePath,
638
+ hookType: hook.name,
639
+ scopeName,
640
+ durationMs,
641
+ status: entityErrors.length > 0 ? "failed" : "passed",
642
+ ...firstError !== void 0 && { error: {
643
+ message: firstError.message,
644
+ ...firstError.stack !== void 0 && { stack: firstError.stack },
645
+ ...firstError.diff !== void 0 && { diff: firstError.diff }
646
+ } }
647
+ });
648
+ }
649
+ /**
650
+ * Vitest streaming hook: a captured `console.log` / `console.error`
651
+ * from user test code. The Vitest log carries a `taskId` rather than
652
+ * a module/test path; resolving it back to one would need the task
653
+ * registry, so `modulePath` / `testName` are left unset.
654
+ */
655
+ onUserConsoleLog(log) {
656
+ if (!this.wantsRunEvents()) return;
657
+ this.emit({
658
+ _tag: "ConsoleLog",
659
+ level: log.type,
660
+ content: log.content,
661
+ time: log.time
662
+ });
663
+ }
664
+ /**
665
+ * Vitest streaming hook: the run exceeded its configured process
666
+ * timeout. Terminal — moves the renderer to a final frame.
667
+ */
668
+ onProcessTimeout() {
669
+ if (!this.wantsRunEvents()) return;
670
+ this.emit({
671
+ _tag: "RunTimedOut",
672
+ message: "Test run exceeded the configured process timeout."
673
+ });
674
+ }
675
+ /**
676
+ * Vitest streaming hook: a test case recorded an annotation.
677
+ */
678
+ onTestCaseAnnotate(testCase, annotation) {
679
+ if (!this.wantsRunEvents()) return;
680
+ const modulePath = testCase.module?.relativeModuleId ?? "";
681
+ if (modulePath === "") return;
682
+ this.emit({
683
+ _tag: "TestAnnotated",
684
+ modulePath,
685
+ testName: testCase.name,
686
+ suitePath: this.collectSuitePath(testCase),
687
+ annotation: annotation.message
688
+ });
689
+ }
690
+ /**
691
+ * Vitest streaming hook: a test case recorded an artifact.
692
+ */
693
+ onTestCaseArtifactRecord(testCase, artifact) {
694
+ if (!this.wantsRunEvents()) return;
695
+ const modulePath = testCase.module?.relativeModuleId ?? "";
696
+ if (modulePath === "") return;
697
+ this.emit({
698
+ _tag: "TestArtifactRecorded",
699
+ modulePath,
700
+ testName: testCase.name,
701
+ suitePath: this.collectSuitePath(testCase),
702
+ artifact: artifact.type ?? "artifact"
703
+ });
704
+ }
705
+ /**
706
+ * Vitest streaming hook: watch mode has finished its initial run and
707
+ * is waiting for file changes.
708
+ */
709
+ onWatcherStart() {
710
+ if (!this.wantsRunEvents()) return;
711
+ this.emit({ _tag: "WatcherReady" });
712
+ }
713
+ /**
714
+ * Vitest streaming hook: watch mode is re-running because tracked
715
+ * files changed.
716
+ */
717
+ onWatcherRerun(files, trigger) {
718
+ if (!this.wantsRunEvents()) return;
719
+ this.emit({
720
+ _tag: "WatcherRerun",
721
+ triggerFiles: [...files],
722
+ ...trigger !== void 0 && { reason: trigger }
723
+ });
724
+ }
725
+ async ensureDbPath() {
726
+ if (this.dbPath) return this.dbPath;
727
+ if (this.options.cacheDir) {
728
+ mkdirSync(this.options.cacheDir, { recursive: true });
729
+ this.dbPath = `${this.options.cacheDir}/data.db`;
730
+ return this.dbPath;
731
+ }
732
+ const projectDir = process.cwd();
733
+ this.dbPath = await resolveDataPathCached(projectDir);
734
+ return this.dbPath;
735
+ }
736
+ /**
737
+ * Stash coverage data for merging into reports.
738
+ *
739
+ * @privateRemarks
740
+ * This hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
741
+ * The coverage value is an istanbul CoverageMap that will be duck-typed
742
+ * and processed during `onTestRunEnd`.
743
+ *
744
+ * @param coverage - Istanbul CoverageMap (duck-typed at processing time)
745
+ *
746
+ * @see `processCoverage` for the duck-typing logic
747
+ */
748
+ onCoverage(coverage) {
749
+ this.coverage = coverage;
750
+ }
751
+ /**
752
+ * Process test results, write reports, and emit formatted output.
753
+ *
754
+ * @remarks
755
+ * This is the main lifecycle hook where all output is generated.
756
+ * Processing steps:
757
+ *
758
+ * 1. Group test modules by `testModule.project.name`
759
+ * 2. Process stashed coverage data (if available)
760
+ * 3. Build per-project `AgentReport` objects
761
+ * 4. Classify tests via HistoryTracker and attach classifications
762
+ * 5. Write settings, run, modules, test cases, and errors to SQLite
763
+ * 6. Write per-test history entries
764
+ * 7. Write baselines and trends
765
+ * 8. Emit console markdown (unless `"silent"`)
766
+ * 9. Write GFM summary to `GITHUB_STEP_SUMMARY` (if GitHub Actions)
767
+ *
768
+ * File write failures are logged to stderr but do not crash the test run.
769
+ *
770
+ * @param testModules - All test modules from the completed run
771
+ * @param unhandledErrors - Any unhandled errors during the run
772
+ * @param reason - Overall outcome: `"passed"`, `"failed"`, or `"interrupted"`
773
+ */
774
+ async onTestRunEnd(testModules, unhandledErrors, reason) {
775
+ if (this.rendered) return;
776
+ this.rendered = true;
777
+ if (this.wantsRunEvents() && this.currentRunId !== null) {
778
+ let pass = 0;
779
+ let fail = 0;
780
+ let skip = 0;
781
+ let timeout = 0;
782
+ let totalDuration = 0;
783
+ for (const mod of testModules) {
784
+ totalDuration += mod.diagnostic()?.duration ?? 0;
785
+ for (const test of mod.children.allTests()) {
786
+ const result = test.result();
787
+ const state = result?.state;
788
+ if (state === "passed") pass++;
789
+ else if (state === "failed") {
790
+ const firstError = result?.errors?.[0];
791
+ if (firstError !== void 0 && isTimeoutError(firstError)) timeout++;
792
+ else fail++;
793
+ } else skip++;
794
+ }
795
+ }
796
+ this.emit({
797
+ _tag: "RunFinished",
798
+ runId: this.currentRunId,
799
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
800
+ passCount: pass,
801
+ failCount: fail,
802
+ skipCount: skip,
803
+ timeoutCount: timeout,
804
+ durationMs: totalDuration
805
+ });
806
+ }
807
+ const modules = testModules;
808
+ const errors = unhandledErrors;
809
+ const opts = this.options;
810
+ const stashedCoverage = this.coverage;
811
+ const stashedVitest = this._vitest;
812
+ const logLevel = this.logLevel;
813
+ const logFile = this.logFile;
814
+ const runEvents = this.runEvents;
815
+ const preBuiltReporters = this.reporters;
816
+ const emitEvent = (event) => {
817
+ this.emit(event);
818
+ };
819
+ const wantsRunEvents = this.wantsRunEvents();
820
+ let dbPath;
821
+ try {
822
+ dbPath = await this.ensureDbPath();
823
+ } catch (err) {
824
+ process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
825
+ return;
826
+ }
827
+ const filteredModules = opts.projectFilter ? modules.filter((m) => (m.project.name || "default") === opts.projectFilter) : modules;
828
+ if (filteredModules.length === 0 && opts.projectFilter) return;
829
+ if (opts.coverageMode === "ui-only") {
830
+ const uiProjectGroups = /* @__PURE__ */ new Map();
831
+ for (const mod of filteredModules) {
832
+ const key = mod.project.name || "default";
833
+ const existing = uiProjectGroups.get(key);
834
+ if (existing) existing.push(mod);
835
+ else uiProjectGroups.set(key, [mod]);
836
+ }
837
+ const isMultiProject = uiProjectGroups.size > 1 || !!opts.projectFilter;
838
+ const uiReports = [];
839
+ for (const [projectName, projectModules] of uiProjectGroups) {
840
+ const report = buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProject ? projectName : void 0);
841
+ uiReports.push(report);
842
+ }
843
+ const uiProgram = Effect.gen(function* () {
844
+ const detector = yield* EnvironmentDetector;
845
+ const executorResolver = yield* ExecutorResolver;
846
+ const formatSelector = yield* FormatSelector;
847
+ const detailResolver = yield* DetailResolver;
848
+ const env = yield* detector.detect();
849
+ const executor = yield* executorResolver.resolve(env);
850
+ const format = yield* formatSelector.select(executor, opts.format, env);
851
+ const health = {
852
+ hasFailures: uiReports.some((r) => r.summary.failed > 0 || r.unhandledErrors.length > 0),
853
+ belowTargets: false,
854
+ hasTargets: !!opts.coverageTargets
855
+ };
856
+ const detail = yield* detailResolver.resolve(executor, health, opts.detail);
857
+ const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
858
+ const kit = buildReporterKit({
859
+ env,
860
+ executor,
861
+ format,
862
+ detail,
863
+ noColor: !!process.env.NO_COLOR,
864
+ consoleMode: opts.consoleMode ?? "passthrough",
865
+ mcp: opts.mcp ?? false,
866
+ githubActions: opts.githubActions,
867
+ transport: opts.transport,
868
+ runEvents,
869
+ ...dbPath !== void 0 && { dbPath },
870
+ ...opts.projectFilter !== void 0 && { projectFilter: opts.projectFilter },
871
+ ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
872
+ ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
873
+ ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
874
+ coverageMode: opts.coverageMode
875
+ });
876
+ const reporters = preBuiltReporters ?? normalizeReporters(opts.reporter(kit));
877
+ const renderInput = {
878
+ reports: uiReports,
879
+ classifications: /* @__PURE__ */ new Map()
880
+ };
881
+ const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
882
+ for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
883
+ });
884
+ await Effect.runPromise(uiProgram.pipe(Effect.provide(OutputPipelineLive), Effect.provide(NodeContext.layer))).catch((err) => {
885
+ process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
886
+ });
887
+ return;
888
+ }
889
+ mkdirSync(dirname(dbPath), { recursive: true });
890
+ try {
891
+ await ensureMigrated(dbPath, logLevel, logFile);
892
+ } catch (err) {
893
+ process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
894
+ return;
895
+ }
896
+ const program = Effect.gen(function* () {
897
+ const store = yield* DataStore;
898
+ const reader = yield* DataReader;
899
+ const analyzer = yield* CoverageAnalyzer;
900
+ const tracker = yield* HistoryTracker;
901
+ const invocationId = randomUUID();
902
+ const vitest = stashedVitest;
903
+ const settings = captureSettings(vitest?.config ?? {}, vitest?.version ?? "unknown");
904
+ const settingsHash = hashSettings(settings);
905
+ const envVars = captureEnvVars(process.env);
906
+ yield* store.writeSettings(settingsHash, settings, envVars);
907
+ const projectGroups = /* @__PURE__ */ new Map();
908
+ for (const mod of filteredModules) {
909
+ const key = mod.project.name || "default";
910
+ const existing = projectGroups.get(key);
911
+ if (existing) existing.push(mod);
912
+ else projectGroups.set(key, [mod]);
913
+ }
914
+ const baselinesOpt = yield* reader.getBaselines("__global__").pipe(Effect.catchAll(() => Effect.succeed(Option.none())));
915
+ const baselines = Option.getOrUndefined(baselinesOpt);
916
+ const projectModuleCounts = /* @__PURE__ */ new Map();
917
+ for (const m of modules) {
918
+ const key = m.project.name || "default";
919
+ projectModuleCounts.set(key, (projectModuleCounts.get(key) ?? 0) + 1);
920
+ }
921
+ const primaryProject = Array.from(projectModuleCounts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0];
922
+ const isFirstProject = !opts.projectFilter || opts.projectFilter === primaryProject;
923
+ const coverageOpts = {
924
+ thresholds: opts.coverageThresholds,
925
+ includeBareZero: opts.includeBareZero,
926
+ ...opts.coverageTargets ? { targets: opts.coverageTargets } : {},
927
+ ...baselines ? { baselines } : {}
928
+ };
929
+ const coverageResult = stashedCoverage && isFirstProject ? yield* analyzer.process(stashedCoverage, coverageOpts) : Option.none();
930
+ const coverageReport = Option.getOrUndefined(coverageResult);
931
+ if (wantsRunEvents && coverageReport !== void 0) {
932
+ const globalThresholds = coverageReport.thresholds.global;
933
+ emitEvent({
934
+ _tag: "CoverageReady",
935
+ metrics: coverageReport.totals,
936
+ thresholds: globalThresholds,
937
+ gaps: coverageReport.lowCoverage.map((fc) => ({
938
+ file: fc.file,
939
+ missing: fc.summary,
940
+ uncoveredLines: fc.uncoveredLines
941
+ }))
942
+ });
943
+ for (const metric of [
944
+ "lines",
945
+ "branches",
946
+ "functions",
947
+ "statements"
948
+ ]) {
949
+ const expected = globalThresholds[metric];
950
+ const actual = coverageReport.totals[metric];
951
+ if (expected !== void 0 && actual < expected) emitEvent({
952
+ _tag: "ThresholdViolation",
953
+ metric,
954
+ expected,
955
+ actual
956
+ });
957
+ }
958
+ }
959
+ const reports = [];
960
+ const isMultiProject = projectGroups.size > 1 || !!opts.projectFilter;
961
+ for (const [projectName, projectModules] of projectGroups) {
962
+ const project = projectName === "default" ? "default" : projectName;
963
+ const baseReport = buildAgentReport(projectModules, errors, reason, { omitPassingTests: opts.omitPassingTests }, isMultiProject ? projectName : void 0);
964
+ let totalDuration = 0;
965
+ for (const mod of projectModules) totalDuration += mod.diagnostic()?.duration ?? 0;
966
+ const envAgentId = process.env.VITEST_AGENT_AGENT_ID;
967
+ const envConversationId = process.env.VITEST_AGENT_CONVERSATION_ID;
968
+ const attribution = envAgentId !== void 0 && envAgentId.length > 0 ? {
969
+ actorType: "agent",
970
+ agentId: envAgentId,
971
+ conversationId: envConversationId ?? null
972
+ } : {
973
+ actorType: "system",
974
+ agentId: null,
975
+ conversationId: null
976
+ };
977
+ const hostProbe = probeHostMetadataFromEnv(process.env);
978
+ const runId = yield* store.writeRun({
979
+ invocationId,
980
+ project,
981
+ settingsHash,
982
+ timestamp: baseReport.timestamp,
983
+ commitSha: process.env.GITHUB_SHA ?? null,
984
+ branch: process.env.GITHUB_REF_NAME ?? null,
985
+ reason,
986
+ duration: totalDuration,
987
+ total: baseReport.summary.total,
988
+ passed: baseReport.summary.passed,
989
+ failed: baseReport.summary.failed,
990
+ skipped: baseReport.summary.skipped,
991
+ scoped: false,
992
+ actorType: attribution.actorType,
993
+ agentId: attribution.agentId,
994
+ conversationId: attribution.conversationId,
995
+ gitBranch: process.env.GITHUB_REF_NAME ?? null,
996
+ gitCommitSha: process.env.GITHUB_SHA ?? null,
997
+ hostSource: hostProbe.source,
998
+ hostValue: hostProbe.value,
999
+ hostMetadata: hostProbe.metadata
1000
+ });
1001
+ for (const mod of projectModules) {
1002
+ const fileId = yield* store.ensureFile(mod.relativeModuleId);
1003
+ const moduleId = (yield* store.writeModules(runId, [{
1004
+ fileId,
1005
+ relativeModuleId: mod.relativeModuleId,
1006
+ state: mod.state(),
1007
+ duration: mod.diagnostic()?.duration ?? 0
1008
+ }]))[0];
1009
+ const sourceFile = mod.relativeModuleId.replace(/\.test\.([^.]+)$/, ".$1").replace(/\.spec\.([^.]+)$/, ".$1");
1010
+ if (sourceFile !== mod.relativeModuleId) yield* store.writeSourceMap(sourceFile, moduleId, "convention");
1011
+ const suiteIdMap = /* @__PURE__ */ new Map();
1012
+ for (const suite of mod.children.allSuites()) {
1013
+ const parentSuiteId = suite.parent && suite.parent.type === "suite" ? suiteIdMap.get(suite.parent.fullName) : void 0;
1014
+ const suiteIds = yield* store.writeSuites(moduleId, [{
1015
+ name: suite.name,
1016
+ fullName: suite.fullName,
1017
+ state: suite.state(),
1018
+ ...parentSuiteId !== void 0 && { parentSuiteId },
1019
+ ...suite.options?.mode !== void 0 && { mode: suite.options.mode },
1020
+ ...suite.options?.concurrent !== void 0 && { concurrent: suite.options.concurrent },
1021
+ ...suite.options?.shuffle !== void 0 && { shuffle: suite.options.shuffle },
1022
+ ...suite.options?.retry !== void 0 && { retry: suite.options.retry },
1023
+ ...suite.options?.repeats !== void 0 && { repeats: suite.options.repeats },
1024
+ ...suite.location?.line !== void 0 && { locationLine: suite.location.line },
1025
+ ...suite.location?.column !== void 0 && { locationColumn: suite.location.column }
1026
+ }]);
1027
+ suiteIdMap.set(suite.fullName, suiteIds[0]);
1028
+ }
1029
+ const testCases = [];
1030
+ for (const testCase of mod.children.allTests()) {
1031
+ const result = testCase.result();
1032
+ const diag = testCase.diagnostic();
1033
+ const parent = testCase.parent;
1034
+ const parentSuiteId = parent && parent.type === "suite" ? suiteIdMap.get(parent.fullName) : void 0;
1035
+ testCases.push({
1036
+ name: testCase.name,
1037
+ fullName: testCase.fullName,
1038
+ state: result?.state ?? "pending",
1039
+ ...diag?.duration !== void 0 && { duration: diag.duration },
1040
+ ...diag?.flaky !== void 0 && { flaky: diag.flaky },
1041
+ ...diag?.slow !== void 0 && { slow: diag.slow },
1042
+ ...testCase.tags.length > 0 && { tags: testCase.tags },
1043
+ ...parentSuiteId !== void 0 && { suiteId: parentSuiteId }
1044
+ });
1045
+ }
1046
+ const testCaseIds = yield* store.writeTestCases(moduleId, testCases.map((tc) => ({
1047
+ name: tc.name,
1048
+ fullName: tc.fullName,
1049
+ state: tc.state,
1050
+ ...tc.duration !== void 0 && { duration: tc.duration },
1051
+ ...tc.flaky !== void 0 && { flaky: tc.flaky },
1052
+ ...tc.slow !== void 0 && { slow: tc.slow },
1053
+ ...tc.tags !== void 0 && { tags: tc.tags },
1054
+ ...tc.suiteId !== void 0 && { suiteId: tc.suiteId }
1055
+ })));
1056
+ let testIdx = 0;
1057
+ for (const testCase of mod.children.allTests()) {
1058
+ const result = testCase.result();
1059
+ if (result?.errors && result.errors.length > 0) {
1060
+ const testCaseId = testCaseIds[testIdx];
1061
+ const inputs = [];
1062
+ for (let ordinal = 0; ordinal < result.errors.length; ordinal++) {
1063
+ const e = result.errors[ordinal];
1064
+ const { frames, signatureHash } = processFailure(e);
1065
+ if (signatureHash !== null) yield* store.writeFailureSignature({
1066
+ signatureHash,
1067
+ runId,
1068
+ seenAt: baseReport.timestamp
1069
+ });
1070
+ inputs.push({
1071
+ testCaseId,
1072
+ scope: "test",
1073
+ message: e.message,
1074
+ ...e.name !== void 0 && { name: e.name },
1075
+ ...e.diff !== void 0 && { diff: e.diff },
1076
+ ...e.stack !== void 0 && { stack: e.stack },
1077
+ ...signatureHash !== null && { signatureHash },
1078
+ ...frames.length > 0 && { frames },
1079
+ ordinal
1080
+ });
1081
+ }
1082
+ yield* store.writeErrors(runId, inputs);
1083
+ }
1084
+ testIdx++;
1085
+ }
1086
+ const modErrors = mod.errors();
1087
+ if (modErrors.length > 0) {
1088
+ const inputs = [];
1089
+ for (let ordinal = 0; ordinal < modErrors.length; ordinal++) {
1090
+ const e = modErrors[ordinal];
1091
+ const { frames, signatureHash } = processFailure(e);
1092
+ if (signatureHash !== null) yield* store.writeFailureSignature({
1093
+ signatureHash,
1094
+ runId,
1095
+ seenAt: baseReport.timestamp
1096
+ });
1097
+ inputs.push({
1098
+ moduleId,
1099
+ scope: "module",
1100
+ message: e.message,
1101
+ ...e.name !== void 0 && { name: e.name },
1102
+ ...e.stack !== void 0 && { stack: e.stack },
1103
+ ...signatureHash !== null && { signatureHash },
1104
+ ...frames.length > 0 && { frames },
1105
+ ordinal
1106
+ });
1107
+ }
1108
+ yield* store.writeErrors(runId, inputs);
1109
+ }
1110
+ }
1111
+ if (errors.length > 0) {
1112
+ const inputs = [];
1113
+ for (let ordinal = 0; ordinal < errors.length; ordinal++) {
1114
+ const e = errors[ordinal];
1115
+ const { frames, signatureHash } = processFailure(e);
1116
+ if (signatureHash !== null) yield* store.writeFailureSignature({
1117
+ signatureHash,
1118
+ runId,
1119
+ seenAt: baseReport.timestamp
1120
+ });
1121
+ inputs.push({
1122
+ scope: "unhandled",
1123
+ message: e.message,
1124
+ ...e.name !== void 0 && { name: e.name },
1125
+ ...e.stack !== void 0 && { stack: e.stack },
1126
+ ...signatureHash !== null && { signatureHash },
1127
+ ...frames.length > 0 && { frames },
1128
+ ordinal
1129
+ });
1130
+ }
1131
+ yield* store.writeErrors(runId, inputs);
1132
+ }
1133
+ const testOutcomes = [];
1134
+ for (const mod of projectModules) for (const testCase of mod.children.allTests()) {
1135
+ const state = testCase.result()?.state;
1136
+ if (state === "passed" || state === "failed") testOutcomes.push({
1137
+ fullName: testCase.fullName,
1138
+ state
1139
+ });
1140
+ }
1141
+ const { classifications } = yield* tracker.classify(project, testOutcomes, baseReport.timestamp);
1142
+ const diagMap = /* @__PURE__ */ new Map();
1143
+ const errorMap = /* @__PURE__ */ new Map();
1144
+ for (const mod of projectModules) for (const tc of mod.children.allTests()) {
1145
+ diagMap.set(tc.fullName, tc.diagnostic() ?? {});
1146
+ const tcResult = tc.result();
1147
+ if (tcResult?.state === "failed") {
1148
+ const errors = tcResult.errors;
1149
+ errorMap.set(tc.fullName, errors?.[0]?.message ?? null);
1150
+ }
1151
+ }
1152
+ for (const outcome of testOutcomes) {
1153
+ const diag = diagMap.get(outcome.fullName);
1154
+ const errorMessage = outcome.state === "failed" ? errorMap.get(outcome.fullName) ?? null : null;
1155
+ yield* store.writeHistory(project, outcome.fullName, runId, baseReport.timestamp, outcome.state, diag?.duration ?? null, diag?.flaky ?? false, 0, errorMessage);
1156
+ }
1157
+ const failedWithClassifications = baseReport.failed.map((mod) => ({
1158
+ ...mod,
1159
+ tests: mod.tests.map((test) => {
1160
+ const cls = classifications.get(test.fullName);
1161
+ return cls ? {
1162
+ ...test,
1163
+ classification: cls
1164
+ } : test;
1165
+ })
1166
+ }));
1167
+ const classifiedReport = {
1168
+ ...baseReport,
1169
+ failed: failedWithClassifications
1170
+ };
1171
+ const tagCounts = {};
1172
+ for (const mod of projectModules) for (const tc of mod.children.allTests()) {
1173
+ const state = tc.result()?.state ?? "pending";
1174
+ const tags = tc.tags ?? [];
1175
+ for (const tag of tags) {
1176
+ tagCounts[tag] ??= {
1177
+ passed: 0,
1178
+ failed: 0,
1179
+ skipped: 0
1180
+ };
1181
+ if (state === "passed") tagCounts[tag].passed++;
1182
+ else if (state === "failed") tagCounts[tag].failed++;
1183
+ else if (state === "skipped") tagCounts[tag].skipped++;
1184
+ }
1185
+ }
1186
+ const reportWithTags = Object.keys(tagCounts).length > 0 ? {
1187
+ ...classifiedReport,
1188
+ tagCounts
1189
+ } : classifiedReport;
1190
+ const report = coverageReport ? {
1191
+ ...reportWithTags,
1192
+ coverage: coverageReport
1193
+ } : reportWithTags;
1194
+ reports.push(report);
1195
+ if (coverageReport) {
1196
+ const tierByFile = /* @__PURE__ */ new Map();
1197
+ const sourceByFile = /* @__PURE__ */ new Map();
1198
+ for (const fc of coverageReport.belowTarget ?? []) {
1199
+ tierByFile.set(fc.file, "below_target");
1200
+ sourceByFile.set(fc.file, fc);
1201
+ }
1202
+ for (const fc of coverageReport.lowCoverage) {
1203
+ tierByFile.set(fc.file, "below_threshold");
1204
+ sourceByFile.set(fc.file, fc);
1205
+ }
1206
+ if (tierByFile.size > 0) {
1207
+ const coverageInputs = [];
1208
+ for (const [file, tier] of tierByFile) {
1209
+ const fc = sourceByFile.get(file);
1210
+ if (!fc) continue;
1211
+ const fileId = yield* store.ensureFile(fc.file);
1212
+ coverageInputs.push({
1213
+ fileId,
1214
+ statements: fc.summary.statements,
1215
+ branches: fc.summary.branches,
1216
+ functions: fc.summary.functions,
1217
+ lines: fc.summary.lines,
1218
+ uncoveredLines: fc.uncoveredLines,
1219
+ tier
1220
+ });
1221
+ }
1222
+ yield* store.writeCoverage(runId, coverageInputs);
1223
+ }
1224
+ }
1225
+ if (coverageReport && !coverageReport.scoped) {
1226
+ const existingTrends = yield* reader.getTrends(project).pipe(Effect.map((opt) => Option.getOrUndefined(opt)), Effect.catchAll(() => Effect.succeed(void 0)));
1227
+ const updatedTrends = computeTrend(coverageReport.totals, existingTrends, opts.coverageTargets);
1228
+ const latestEntry = updatedTrends.entries[updatedTrends.entries.length - 1];
1229
+ if (latestEntry) yield* store.writeTrends(project, runId, latestEntry);
1230
+ }
1231
+ }
1232
+ if (opts.autoUpdate && coverageReport) {
1233
+ const newBaselines = computeUpdatedBaselines(baselines, coverageReport.totals, opts.coverageTargets);
1234
+ yield* store.writeBaselines(newBaselines);
1235
+ }
1236
+ let trendSummary;
1237
+ if (coverageReport && !coverageReport.scoped) {
1238
+ const firstProjectKey = Array.from(projectGroups.keys())[0];
1239
+ if (firstProjectKey) {
1240
+ const tp = firstProjectKey === "default" ? "default" : firstProjectKey;
1241
+ const trendsOpt = yield* reader.getTrends(tp).pipe(Effect.catchAll(() => Effect.succeed(Option.none())));
1242
+ if (Option.isSome(trendsOpt)) {
1243
+ const entries = trendsOpt.value.entries;
1244
+ if (entries.length >= 2) {
1245
+ const latest = entries[entries.length - 1];
1246
+ const prev = entries[entries.length - 2];
1247
+ const direction = latest.direction;
1248
+ const metrics = [
1249
+ "lines",
1250
+ "functions",
1251
+ "branches",
1252
+ "statements"
1253
+ ];
1254
+ let firstMetric;
1255
+ for (const m of metrics) {
1256
+ const from = prev.coverage[m];
1257
+ const to = latest.coverage[m];
1258
+ if (from !== to) {
1259
+ const target = opts.coverageTargets?.global?.[m];
1260
+ firstMetric = {
1261
+ name: m,
1262
+ from,
1263
+ to,
1264
+ ...target !== void 0 ? { target } : {}
1265
+ };
1266
+ break;
1267
+ }
1268
+ }
1269
+ trendSummary = {
1270
+ direction,
1271
+ runCount: entries.length,
1272
+ ...firstMetric ? { firstMetric } : {}
1273
+ };
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+ if (wantsRunEvents && trendSummary !== void 0) emitEvent({
1279
+ _tag: "TrendComputed",
1280
+ direction: trendSummary.direction,
1281
+ runCount: trendSummary.runCount
1282
+ });
1283
+ yield* Effect.logInfo("reports built").pipe(Effect.annotateLogs({
1284
+ count: reports.length,
1285
+ projects: Array.from(projectGroups.keys()).join(", ")
1286
+ }));
1287
+ const detector = yield* EnvironmentDetector;
1288
+ const executorResolver = yield* ExecutorResolver;
1289
+ const formatSelector = yield* FormatSelector;
1290
+ const detailResolver = yield* DetailResolver;
1291
+ const env = yield* detector.detect();
1292
+ const executor = yield* executorResolver.resolve(env);
1293
+ const format = yield* formatSelector.select(executor, opts.format, env);
1294
+ const health = {
1295
+ hasFailures: reports.some((r) => r.summary.failed > 0 || r.unhandledErrors.length > 0),
1296
+ belowTargets: reports.some((r) => {
1297
+ return (r.coverage?.belowTarget?.length ?? 0) > 0;
1298
+ }),
1299
+ hasTargets: !!opts.coverageTargets
1300
+ };
1301
+ const detail = yield* detailResolver.resolve(executor, health, opts.detail);
1302
+ yield* Effect.logDebug("pipeline resolved").pipe(Effect.annotateLogs({
1303
+ env,
1304
+ executor,
1305
+ format,
1306
+ detail
1307
+ }));
1308
+ const classifications = /* @__PURE__ */ new Map();
1309
+ 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);
1310
+ const githubSummaryFile = process.env.GITHUB_STEP_SUMMARY;
1311
+ const kit = buildReporterKit({
1312
+ env,
1313
+ executor,
1314
+ format,
1315
+ detail,
1316
+ noColor: !!process.env.NO_COLOR,
1317
+ consoleMode: opts.consoleMode ?? "passthrough",
1318
+ mcp: opts.mcp ?? false,
1319
+ githubActions: opts.githubActions,
1320
+ transport: opts.transport,
1321
+ runEvents,
1322
+ ...dbPath !== void 0 && { dbPath },
1323
+ ...opts.projectFilter !== void 0 && { projectFilter: opts.projectFilter },
1324
+ ...opts.coverageThresholds !== void 0 && { coverageThresholds: opts.coverageThresholds },
1325
+ ...opts.coverageTargets !== void 0 && { coverageTargets: opts.coverageTargets },
1326
+ ...opts.passWithNoTests !== void 0 && { passWithNoTests: opts.passWithNoTests },
1327
+ coverageMode: opts.coverageMode
1328
+ });
1329
+ const reporters = preBuiltReporters ?? normalizeReporters(opts.reporter(kit));
1330
+ const renderInput = {
1331
+ reports,
1332
+ classifications,
1333
+ ...trendSummary !== void 0 && { trendSummary }
1334
+ };
1335
+ const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
1336
+ yield* Effect.logDebug("reporters rendered").pipe(Effect.annotateLogs({
1337
+ reporterCount: reporters.length,
1338
+ outputs: allOutputs.length
1339
+ }));
1340
+ for (const output of allOutputs) routeRenderedOutput(output, { ...githubSummaryFile !== void 0 && { githubSummaryFile } });
1341
+ });
1342
+ await Effect.runPromise(program.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(ReporterLive(dbPath, logLevel, logFile)))).catch((err) => {
1343
+ process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
1344
+ });
1345
+ }
1346
+ };
1347
+
1348
+ //#endregion
1349
+ export { AgentReporter };