@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/LICENSE +21 -0
- package/README.md +50 -0
- package/index.d.ts +1216 -0
- package/index.js +31 -0
- package/layers/ConfigValidationLive.js +148 -0
- package/layers/ConfigValidationTest.js +16 -0
- package/layers/CoverageAnalyzerLive.js +138 -0
- package/layers/CoverageAnalyzerTest.js +15 -0
- package/layers/ReporterLive.js +21 -0
- package/package.json +65 -0
- package/plugin.js +329 -0
- package/reporter.js +1349 -0
- package/services/ConfigValidation.js +11 -0
- package/services/CoverageAnalyzer.js +11 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/build-module-info.js +62 -0
- package/utils/build-reporter-kit.js +49 -0
- package/utils/capture-env.js +23 -0
- package/utils/capture-settings.js +54 -0
- package/utils/classify-helpers.js +72 -0
- package/utils/discover-projects.js +68 -0
- package/utils/discover-strategy.js +158 -0
- package/utils/find-test-files.js +94 -0
- package/utils/inject-tags.js +94 -0
- package/utils/process-failure.js +89 -0
- package/utils/resolve-thresholds.js +65 -0
- package/utils/route-rendered-output.js +45 -0
- package/utils/stringify-failure-value.js +36 -0
- package/utils/strip-console-reporters.js +45 -0
- package/utils/tag.js +42 -0
- package/utils/to-posix-path.js +35 -0
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 };
|