@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/plugin.js ADDED
@@ -0,0 +1,329 @@
1
+ import { ConfigValidation } from "./services/ConfigValidation.js";
2
+ import { resolveThresholds } from "./utils/resolve-thresholds.js";
3
+ import { ConfigValidationLive } from "./layers/ConfigValidationLive.js";
4
+ import { AgentReporter } from "./reporter.js";
5
+ import { buildModuleInfo } from "./utils/build-module-info.js";
6
+ import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
7
+ import { discoverProjects } from "./utils/discover-projects.js";
8
+ import { injectTags } from "./utils/inject-tags.js";
9
+ import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
10
+ import { execSync } from "node:child_process";
11
+ import { CURRENT_REPORTER_VERSION } from "@vitest-agent/reporter";
12
+ import { CURRENT_SDK_VERSION, CoverageLevel, EnvironmentDetector, EnvironmentDetectorLive, formatFatalError, resolveLogLevel } from "@vitest-agent/sdk";
13
+ import { Effect } from "effect";
14
+
15
+ //#region src/plugin.ts
16
+ /**
17
+ * Resolve which {@link ConsoleMode} value applies to the active executor.
18
+ * Looks up `console.{executor}` in the user-supplied matrix, falls back to
19
+ * the per-slot default.
20
+ *
21
+ * Per-slot defaults:
22
+ * - `human` → `passthrough` (Vitest's own reporters do the visible work).
23
+ * Users opt into `stream` for the progressively-drawn, animated
24
+ * agent-shaped live renderer by setting the `human` slot to `"stream"`;
25
+ * the default reporter owns the live Ink mount end to end (T6 contract
26
+ * — users do not import or wire the live renderer themselves).
27
+ * - `agent` → `agent` (markdown-flavored final-frame string).
28
+ * - `ci` → `passthrough` (Vitest's reporters produce log-friendly output;
29
+ * the dedicated `ci-annotations` reporter is opt-in until the GHA
30
+ * annotations writer ships).
31
+ *
32
+ * @internal
33
+ */
34
+ function resolveConsoleMode(options, executor, _env) {
35
+ const console = options.console;
36
+ if (executor === "human") return console?.human ?? "passthrough";
37
+ if (executor === "agent") return console?.agent ?? "agent";
38
+ return console?.ci ?? "passthrough";
39
+ }
40
+ /**
41
+ * The plugin owns stdout when the resolved console mode produces visible
42
+ * output that would conflict with Vitest's own reporters. Passthrough lets
43
+ * Vitest emit progress normally; silent strips everything; the other modes
44
+ * (stream, agent, ci-annotations) need exclusive stdout access.
45
+ *
46
+ * @internal
47
+ */
48
+ function ownsStdout(mode) {
49
+ return mode !== "passthrough";
50
+ }
51
+ /**
52
+ * Map the resolved {@link ConsoleMode} to the legacy {@link OutputFormat}
53
+ * the existing reporter factories switch on. The new event-sourced renderer
54
+ * does not consult this — it dispatches directly on `kit.config.consoleMode`
55
+ * — but the bundled markdown/terminal/silent/ci-annotations reporters still
56
+ * need a format value to pick which formatter to invoke.
57
+ *
58
+ * @internal
59
+ */
60
+ function resolveFormat(mode) {
61
+ switch (mode) {
62
+ case "stream":
63
+ case "agent": return "terminal";
64
+ case "ci-annotations": return "ci-annotations";
65
+ case "silent": return "silent";
66
+ case "passthrough": return "vitest-bypass";
67
+ }
68
+ }
69
+ /**
70
+ * Set to the Vitest object reference once we've pushed an aggregating
71
+ * reporter for that Vitest instance. The flag is module-scoped (rather
72
+ * than closure-scoped on the plugin) because Vitest can construct the
73
+ * plugin more than once per `vitest run` invocation (e.g., once per
74
+ * project). Keying the guard on the Vitest reference itself ensures we
75
+ * push exactly one reporter per actual Vitest run, regardless of how
76
+ * many times the plugin or `configureVitest` fires.
77
+ *
78
+ * The terminal/markdown formatters render all projects in one block
79
+ * (Projects header, per-project rows, one Total at the bottom), so we
80
+ * want exactly ONE reporter instance handling the whole run rather than
81
+ * N reporters each rendering their own slice.
82
+ *
83
+ * @internal
84
+ */
85
+ const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
86
+ /**
87
+ * The version of this package, inlined at build time from
88
+ * `package.json#version` via rslib-builder's `__PACKAGE_VERSION__` substitution.
89
+ * Re-exported from the package barrel as the public symbol; defined here so
90
+ * the drift-check code path can read it without a circular import.
91
+ *
92
+ * @public
93
+ */
94
+ const CURRENT_PLUGIN_VERSION = "1.0.0";
95
+ /**
96
+ * Cross-package version drift warning state. Module-scoped so multi-project
97
+ * Vitest configs construct one plugin per project but only emit the
98
+ * warning once per Node process. See the root CLAUDE.md
99
+ * "Cross-package version drift" section.
100
+ *
101
+ * @internal
102
+ */
103
+ let _hasWarnedDrift = false;
104
+ /**
105
+ * Compare CURRENT_PLUGIN_VERSION against each runtime peer the plugin
106
+ * orchestrates and write one stderr line per mismatch. The check is
107
+ * observation-only — never throws, never exits. Suppressed after the
108
+ * first call in the same process so multi-project Vitest configs do
109
+ * not duplicate the warning.
110
+ *
111
+ * The `"0.0.0"` fallback (from process.env.__PACKAGE_VERSION__ ??
112
+ * "0.0.0" in the constant source) marks a dev build where rslib-builder
113
+ * has not substituted the literal — typically when this module is
114
+ * loaded directly from source rather than from dist. Skip the check
115
+ * in that case; the asymmetry between source-loaded plugin and
116
+ * dist-loaded peers would fire a spurious warning on every dev test
117
+ * run otherwise.
118
+ *
119
+ * @internal
120
+ */
121
+ function checkVersionDrift(pluginVersion) {
122
+ if (_hasWarnedDrift) return;
123
+ if (pluginVersion === "0.0.0") return;
124
+ const peers = [["@vitest-agent/sdk", CURRENT_SDK_VERSION], ["@vitest-agent/reporter", CURRENT_REPORTER_VERSION]];
125
+ let warned = false;
126
+ for (const [peerName, peerVersion] of peers) if (peerVersion !== pluginVersion) {
127
+ process.stderr.write(`[@vitest-agent/plugin] version drift: @vitest-agent/plugin@${pluginVersion} with ${peerName}@${peerVersion}. Reinstall @vitest-agent/* packages so versions match.\n`);
128
+ warned = true;
129
+ }
130
+ if (warned) _hasWarnedDrift = true;
131
+ }
132
+ const TEST_FILE_SUFFIX_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/;
133
+ const TEST_FILE_DIR_RE = /\/(?:src|__test__)\//;
134
+ const isTestFile = (id) => TEST_FILE_SUFFIX_RE.test(id) && TEST_FILE_DIR_RE.test(id);
135
+ /**
136
+ * Map a detected {@link Environment} to its {@link Executor}. Inline copy
137
+ * of the `ExecutorResolverLive` mapping so the plugin can compute it
138
+ * synchronously inside `configureVitest` without spinning up an Effect
139
+ * runtime.
140
+ *
141
+ * @internal
142
+ */
143
+ function envToExecutor(env) {
144
+ if (env === "agent-shell") return "agent";
145
+ if (env === "terminal") return "human";
146
+ return "ci";
147
+ }
148
+ /**
149
+ * Vitest plugin that injects `AgentReporter` into the reporter chain.
150
+ *
151
+ * @param options - Plugin configuration options
152
+ * @param _layer - Internal: override the EnvironmentDetector layer (for testing)
153
+ * @returns Vitest plugin object with `configureVitest` hook
154
+ *
155
+ * @public
156
+ */
157
+ function AgentPlugin(options = {}, _layer) {
158
+ checkVersionDrift(CURRENT_PLUGIN_VERSION);
159
+ const layer = _layer ?? EnvironmentDetectorLive;
160
+ const logLevel = resolveLogLevel();
161
+ const log = logLevel !== void 0 && logLevel._tag !== "None" ? (...args) => process.stderr.write(`[vitest-agent:plugin] ${args.map(String).join(" ")}\n`) : (..._args) => {};
162
+ const discoverStrategyResolved = options.discoverStrategy === false ? null : options.discoverStrategy ?? new DefaultDiscoverStrategy();
163
+ const pluginObj = {
164
+ name: "vitest-agent",
165
+ async configureVitest(ctx) {
166
+ try {
167
+ const { vitest, project } = ctx;
168
+ log("configureVitest called | project:", project?.name ?? "(root)");
169
+ const env = await Effect.runPromise(Effect.provide(Effect.flatMap(EnvironmentDetector, (d) => d.detect()), layer));
170
+ const executor = envToExecutor(env);
171
+ const consoleMode = resolveConsoleMode(options, executor, env);
172
+ const format = resolveFormat(consoleMode);
173
+ const mcp = executor === "agent";
174
+ log("env:", env, "| executor:", executor, "| consoleMode:", consoleMode, "| format:", format, "| mcp (auto):", mcp);
175
+ if (ownsStdout(consoleMode)) {
176
+ log("stripping console reporters (consoleMode owns stdout)");
177
+ const stripped = stripConsoleReporters(vitest.config.reporters);
178
+ vitest.config.reporters = stripped;
179
+ const coverageCfg = vitest.config.coverage;
180
+ if (coverageCfg) {
181
+ log("suppressing native coverage text reporter");
182
+ coverageCfg.reporter = [];
183
+ }
184
+ }
185
+ const githubActions = env === "ci-github" && consoleMode !== "silent";
186
+ log("githubActions (auto):", githubActions);
187
+ const validation = await Effect.runPromise(Effect.provide(Effect.flatMap(ConfigValidation, (cv) => cv.validate({
188
+ vitestConfig: vitest.config,
189
+ pluginOptions: options
190
+ })), ConfigValidationLive));
191
+ for (const w of validation.warnings) process.stderr.write(`[vitest-agent:plugin] warning ${w.code}: ${w.message}` + (w.remediation ? `\n ${w.remediation}` : "") + "\n");
192
+ for (const i of validation.info) process.stderr.write(`[vitest-agent:plugin] info ${i.code}: ${i.message}\n`);
193
+ if (validation.errors.length > 0) {
194
+ const body = validation.errors.map((e) => `${e.code}${e.path ? ` @ ${e.path}` : ""}: ${e.message}` + (e.remediation ? `\n ${e.remediation}` : "")).join("\n");
195
+ throw new Error(body);
196
+ }
197
+ const coverageConfig = vitest.config.coverage;
198
+ const coverageThresholds = coverageConfig?.thresholds ? resolveThresholds(coverageConfig.thresholds) : void 0;
199
+ const rawTargets = options.coverageTargets;
200
+ const coverageTargets = rawTargets ? resolveThresholds(rawTargets) : void 0;
201
+ const coverageMode = coverageConfig?.enabled === false ? "ui-only" : "full";
202
+ const transport = options.transport ?? { kind: "local" };
203
+ log("transport.kind:", transport.kind);
204
+ const passWithNoTestsRaw = vitest.config.passWithNoTests;
205
+ const passWithNoTests = typeof passWithNoTestsRaw === "boolean" ? passWithNoTestsRaw : void 0;
206
+ log("passWithNoTests (resolved):", passWithNoTests);
207
+ if (aggregatedReporterByVitest.has(vitest)) {
208
+ log("aggregate reporter already pushed for this Vitest run; skipping push for project:", project?.name ?? "(root)");
209
+ return;
210
+ }
211
+ const reporter = new AgentReporter({
212
+ ...coverageThresholds !== void 0 ? { coverageThresholds } : {},
213
+ ...coverageTargets !== void 0 ? { coverageTargets } : {},
214
+ coverageMode,
215
+ format,
216
+ consoleMode,
217
+ mcp,
218
+ githubActions,
219
+ transport,
220
+ ...passWithNoTests !== void 0 ? { passWithNoTests } : {},
221
+ ...options.reporter !== void 0 && { reporter: options.reporter },
222
+ ...options.onRunEvent !== void 0 ? { onRunEvent: options.onRunEvent } : {}
223
+ });
224
+ vitest.config.reporters.push(reporter);
225
+ aggregatedReporterByVitest.add(vitest);
226
+ log("reporters after push:", vitest.config.reporters.length);
227
+ } catch (err) {
228
+ process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
229
+ throw err;
230
+ }
231
+ }
232
+ };
233
+ if (discoverStrategyResolved) pluginObj.transform = (code, id) => {
234
+ const cleanId = id.split("?")[0] ?? id;
235
+ if (!isTestFile(cleanId)) return null;
236
+ const module = buildModuleInfo(cleanId);
237
+ const tags = discoverStrategyResolved.classify({ module });
238
+ if (tags.length === 0) return null;
239
+ const rewritten = injectTags(code, [...tags]);
240
+ if (rewritten === null) return null;
241
+ return rewritten;
242
+ };
243
+ return pluginObj;
244
+ }
245
+ const PRESET_METRICS = (level) => ({
246
+ lines: level.lines,
247
+ functions: level.functions,
248
+ branches: level.branches,
249
+ statements: level.statements
250
+ });
251
+ const buildPreset = (thresholdsLevel, targetsLevel, perFile) => Object.freeze({
252
+ thresholds: Object.freeze({
253
+ ...PRESET_METRICS(thresholdsLevel),
254
+ ...perFile ? { perFile: true } : {}
255
+ }),
256
+ coverageTargets: Object.freeze(PRESET_METRICS(targetsLevel))
257
+ });
258
+ /**
259
+ * Create a concrete {@link DiscoverBuilder} for the given strategy and
260
+ * accumulated additional entries. Calling `.then()` triggers `discoverProjects`
261
+ * with all accumulated options.
262
+ *
263
+ * Process-level cache rule (spec §3.3): caching fires only when no explicit
264
+ * `strategy` is provided AND `additionalEntries` is empty. Any `.addProject()`
265
+ * chain or explicit strategy bypasses the cache.
266
+ *
267
+ * @internal
268
+ */
269
+ function makeDiscoverBuilder(options) {
270
+ return {
271
+ addProject(input) {
272
+ return makeDiscoverBuilder({
273
+ ...options,
274
+ additionalEntries: [...options.additionalEntries ?? [], {
275
+ name: input.name,
276
+ path: input.path
277
+ }]
278
+ });
279
+ },
280
+ then(onFulfilled, onRejected) {
281
+ return discoverProjects(options).then(onFulfilled, onRejected);
282
+ }
283
+ };
284
+ }
285
+ (function(_AgentPlugin) {
286
+ _AgentPlugin.COVERAGE_LEVELS = Object.freeze({
287
+ none: buildPreset(CoverageLevel.none, CoverageLevel.basic, false),
288
+ basic: buildPreset(CoverageLevel.basic, CoverageLevel.standard, false),
289
+ standard: buildPreset(CoverageLevel.standard, CoverageLevel.strict, false),
290
+ strict: buildPreset(CoverageLevel.strict, CoverageLevel.full, false),
291
+ full: buildPreset(CoverageLevel.full, CoverageLevel.full, false)
292
+ });
293
+ _AgentPlugin.COVERAGE_LEVELS_PER_FILE = Object.freeze({
294
+ none: buildPreset(CoverageLevel.none, CoverageLevel.basic, true),
295
+ basic: buildPreset(CoverageLevel.basic, CoverageLevel.standard, true),
296
+ standard: buildPreset(CoverageLevel.standard, CoverageLevel.strict, true),
297
+ strict: buildPreset(CoverageLevel.strict, CoverageLevel.full, true),
298
+ full: buildPreset(CoverageLevel.full, CoverageLevel.full, true)
299
+ });
300
+ _AgentPlugin.COVERAGE_AUTOUPDATE = Object.freeze({
301
+ standard: (n) => Math.floor(n),
302
+ strict: (n) => Math.ceil(n),
303
+ lenient: (n) => Math.max(0, Math.floor(n - 2))
304
+ });
305
+ function discover(strategy) {
306
+ if (strategy === void 0) return makeDiscoverBuilder({});
307
+ if (typeof strategy.buildProject === "function") return makeDiscoverBuilder({ strategy });
308
+ const opts = strategy;
309
+ return makeDiscoverBuilder({
310
+ ...opts.strategy !== void 0 ? { strategy: opts.strategy } : {},
311
+ ...opts.cwd !== void 0 ? { cwd: opts.cwd } : {}
312
+ });
313
+ }
314
+ _AgentPlugin.discover = discover;
315
+ function runScript(command) {
316
+ try {
317
+ execSync(command, { stdio: "pipe" });
318
+ } catch (error) {
319
+ const execError = error;
320
+ if (execError.stderr?.length) process.stderr.write(execError.stderr);
321
+ if (execError.stdout?.length) process.stdout.write(execError.stdout);
322
+ throw error;
323
+ }
324
+ }
325
+ _AgentPlugin.runScript = runScript;
326
+ })(AgentPlugin || (AgentPlugin = {}));
327
+
328
+ //#endregion
329
+ export { AgentPlugin, CURRENT_PLUGIN_VERSION };