@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.
@@ -0,0 +1,94 @@
1
+ import * as acorn from "acorn";
2
+ import { tsPlugin } from "acorn-typescript";
3
+ import MagicString from "magic-string";
4
+
5
+ //#region src/utils/inject-tags.ts
6
+ const Parser = acorn.Parser.extend(tsPlugin());
7
+ const TEST_NAMES = /* @__PURE__ */ new Set(["test", "it"]);
8
+ function rootIdentifier(n) {
9
+ if (n.type === "Identifier") return n;
10
+ if (n.type === "MemberExpression") return rootIdentifier(n.object);
11
+ if (n.type === "CallExpression") return rootIdentifier(n.callee);
12
+ return null;
13
+ }
14
+ function isTestCallee(callee) {
15
+ const root = rootIdentifier(callee);
16
+ return !!root && TEST_NAMES.has(root.name);
17
+ }
18
+ function hasTagsField(objectExpression) {
19
+ const props = objectExpression.properties ?? [];
20
+ for (const p of props) {
21
+ if (p.type !== "Property") continue;
22
+ const key = p.key;
23
+ if (key.type === "Identifier" && key.name === "tags") return true;
24
+ if (key.type === "Literal" && key.value === "tags") return true;
25
+ }
26
+ return false;
27
+ }
28
+ function tagsLiteral(tags) {
29
+ return `[${tags.map((t) => JSON.stringify(t)).join(", ")}]`;
30
+ }
31
+ function walk(node, visit) {
32
+ visit(node);
33
+ for (const key of Object.keys(node)) {
34
+ if (key === "type" || key === "start" || key === "end" || key === "loc") continue;
35
+ const v = node[key];
36
+ if (Array.isArray(v)) {
37
+ for (const item of v) if (item && typeof item === "object" && "type" in item) walk(item, visit);
38
+ } else if (v && typeof v === "object" && "type" in v) walk(v, visit);
39
+ }
40
+ }
41
+ function injectTags(source, tags) {
42
+ if (tags.length === 0) return null;
43
+ let ast;
44
+ try {
45
+ ast = Parser.parse(source, {
46
+ ecmaVersion: "latest",
47
+ sourceType: "module",
48
+ locations: true
49
+ });
50
+ } catch {
51
+ return null;
52
+ }
53
+ const ms = new MagicString(source);
54
+ let mutated = false;
55
+ walk(ast, (node) => {
56
+ if (node.type !== "CallExpression") return;
57
+ const callee = node.callee;
58
+ if (!isTestCallee(callee)) return;
59
+ const args = node.arguments ?? [];
60
+ if (args.length < 2) return;
61
+ const last = args[args.length - 1];
62
+ if (!(last.type === "FunctionExpression" || last.type === "ArrowFunctionExpression")) return;
63
+ const optsCandidate = args.length >= 3 ? args[args.length - 2] : null;
64
+ if (optsCandidate) {
65
+ if (optsCandidate.type === "ObjectExpression") {
66
+ if (hasTagsField(optsCandidate)) return;
67
+ const lastProp = (optsCandidate.properties ?? []).at(-1);
68
+ if (lastProp === void 0) {
69
+ const insertPoint = optsCandidate.end - 1;
70
+ ms.appendLeft(insertPoint, `tags: ${tagsLiteral(tags)} `);
71
+ } else ms.appendLeft(lastProp.end, `, tags: ${tagsLiteral(tags)}`);
72
+ mutated = true;
73
+ return;
74
+ }
75
+ const optsStart = optsCandidate.start;
76
+ const optsEnd = optsCandidate.end;
77
+ const origExpr = source.slice(optsStart, optsEnd);
78
+ ms.overwrite(optsStart, optsEnd, `{ ...(${origExpr}), tags: ${tagsLiteral(tags)} }`);
79
+ mutated = true;
80
+ return;
81
+ }
82
+ const nameArg = args[0];
83
+ ms.appendRight(nameArg.end, `, { tags: ${tagsLiteral(tags)} }`);
84
+ mutated = true;
85
+ });
86
+ if (!mutated) return null;
87
+ return {
88
+ code: ms.toString(),
89
+ map: ms.generateMap({ hires: true })
90
+ };
91
+ }
92
+
93
+ //#endregion
94
+ export { injectTags };
@@ -0,0 +1,89 @@
1
+ import { computeFailureSignature, findFunctionBoundary } from "@vitest-agent/sdk";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ //#region src/utils/process-failure.ts
5
+ const FRAME_LINE_REGEX = /^\s*at\s+(?:([\w$.<>[\] ]+?)\s+)?\(?([^\n)]+):(\d+):(\d+)\)?\s*$/;
6
+ const isFrameworkPath = (filePath) => filePath.includes("/node_modules/") || filePath.includes("vitest/dist") || filePath.includes("@vitest/") || filePath.startsWith("node:");
7
+ const parseFramesFromStackString = (stack) => {
8
+ const frames = [];
9
+ let ordinal = 0;
10
+ for (const line of stack.split("\n")) {
11
+ const m = FRAME_LINE_REGEX.exec(line);
12
+ if (m === null) continue;
13
+ frames.push({
14
+ ordinal: ordinal++,
15
+ method: m[1] ?? null,
16
+ filePath: m[2],
17
+ line: Number.parseInt(m[3], 10),
18
+ col: Number.parseInt(m[4], 10),
19
+ sourceMapped: false
20
+ });
21
+ }
22
+ return frames;
23
+ };
24
+ const readSourceSafe = (filePath) => {
25
+ try {
26
+ return readFileSync(filePath, "utf-8");
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+ /**
32
+ * Convert a Vitest error into structured frame inputs (with source-map and
33
+ * function-boundary annotations) plus a stable failure signature.
34
+ *
35
+ * Returns `null` for the signature when no usable top frame is found
36
+ * (error has no stack, or every frame is in framework code). Frames may
37
+ * still be populated even when the signature is null.
38
+ * @public
39
+ */
40
+ const processFailure = (error) => {
41
+ const rawFrames = error.stacks !== void 0 && error.stacks.length > 0 ? error.stacks.map((f, ordinal) => ({
42
+ ordinal,
43
+ method: f.method ?? null,
44
+ filePath: f.file ?? "<unknown>",
45
+ line: f.line ?? 0,
46
+ col: f.column ?? 0,
47
+ sourceMapped: true
48
+ })) : error.stack !== void 0 ? parseFramesFromStackString(error.stack) : [];
49
+ const topFrame = rawFrames.find((f) => f.filePath !== "<unknown>" && !isFrameworkPath(f.filePath));
50
+ let topBoundaryLine = null;
51
+ let topFunctionName = null;
52
+ if (topFrame !== void 0) {
53
+ const lineForBoundary = topFrame.line;
54
+ const source = readSourceSafe(topFrame.filePath);
55
+ const boundary = source !== null ? findFunctionBoundary(source, lineForBoundary) : null;
56
+ if (boundary !== null) {
57
+ topBoundaryLine = boundary.line;
58
+ topFunctionName = boundary.name;
59
+ }
60
+ }
61
+ const frames = rawFrames.map((f) => {
62
+ return {
63
+ ordinal: f.ordinal,
64
+ method: f.method,
65
+ filePath: f.filePath,
66
+ line: f.line,
67
+ col: f.col,
68
+ ...f.sourceMapped && { sourceMappedLine: f.line },
69
+ ...topFrame !== void 0 && f.ordinal === topFrame.ordinal && topBoundaryLine !== null && { functionBoundaryLine: topBoundaryLine }
70
+ };
71
+ });
72
+ if (topFrame === void 0) return {
73
+ frames,
74
+ signatureHash: null
75
+ };
76
+ return {
77
+ frames,
78
+ signatureHash: computeFailureSignature({
79
+ error_name: error.name ?? "Error",
80
+ assertion_message: error.message,
81
+ top_frame_function_name: topFunctionName ?? topFrame.method ?? "<anonymous>",
82
+ top_frame_function_boundary_line: topBoundaryLine,
83
+ top_frame_raw_line: topFrame.line
84
+ })
85
+ };
86
+ };
87
+
88
+ //#endregion
89
+ export { processFailure };
@@ -0,0 +1,65 @@
1
+ //#region src/utils/resolve-thresholds.ts
2
+ const METRIC_KEYS = /* @__PURE__ */ new Set([
3
+ "lines",
4
+ "functions",
5
+ "branches",
6
+ "statements"
7
+ ]);
8
+ const RESERVED_KEYS = /* @__PURE__ */ new Set([
9
+ ...METRIC_KEYS,
10
+ "100",
11
+ "perFile",
12
+ "autoUpdate"
13
+ ]);
14
+ /**
15
+ * Parse Vitest `coverage.thresholds` format into a normalized `ResolvedThresholds`.
16
+ * @param input - The raw `coverage.thresholds` object from Vitest config
17
+ * @returns Normalized thresholds with global, perFile, and pattern entries
18
+ * @public
19
+ */
20
+ function resolveThresholds(input) {
21
+ if (!input) return {
22
+ global: {},
23
+ perFile: false,
24
+ patterns: []
25
+ };
26
+ const global = {};
27
+ const patterns = [];
28
+ let perFile = false;
29
+ if (input["100"] === true) {
30
+ global.lines = 100;
31
+ global.functions = 100;
32
+ global.branches = 100;
33
+ global.statements = 100;
34
+ }
35
+ for (const key of METRIC_KEYS) {
36
+ const value = input[key];
37
+ if (typeof value === "number") global[key] = value;
38
+ }
39
+ if (input.perFile === true) perFile = true;
40
+ for (const [key, value] of Object.entries(input)) {
41
+ if (RESERVED_KEYS.has(key)) continue;
42
+ if (typeof value !== "object" || value === null) continue;
43
+ const patternMetrics = {};
44
+ const obj = value;
45
+ if (obj["100"] === true) {
46
+ patternMetrics.lines = 100;
47
+ patternMetrics.functions = 100;
48
+ patternMetrics.branches = 100;
49
+ patternMetrics.statements = 100;
50
+ }
51
+ for (const mk of METRIC_KEYS) {
52
+ const mv = obj[mk];
53
+ if (typeof mv === "number") patternMetrics[mk] = mv;
54
+ }
55
+ if (Object.keys(patternMetrics).length > 0) patterns.push([key, patternMetrics]);
56
+ }
57
+ return {
58
+ global,
59
+ perFile,
60
+ patterns
61
+ };
62
+ }
63
+
64
+ //#endregion
65
+ export { resolveThresholds };
@@ -0,0 +1,45 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ //#region src/utils/route-rendered-output.ts
5
+ /**
6
+ * Route a {@link RenderedOutput} entry to its declared target.
7
+ *
8
+ * Centralizes the side-effects that user-supplied reporters get to stay
9
+ * pure about: writing to stdout, appending to a github-summary file, or
10
+ * writing to an arbitrary file path. The plugin's internal Vitest reporter
11
+ * calls this once per `RenderedOutput` returned by the user's reporter(s).
12
+ *
13
+ * `github-summary` writes go to `kit.config.githubSummaryFile` if set,
14
+ * otherwise `process.env.GITHUB_STEP_SUMMARY`. When neither is set the
15
+ * write is silently dropped — the typical case is "we're not under
16
+ * GitHub Actions and the user-supplied reporter shouldn't have produced
17
+ * one anyway."
18
+ *
19
+ * `file` outputs require an explicit path embedded in the output (a
20
+ * convention reporters should adopt; this helper currently treats `file`
21
+ * as a no-op until we settle on a path field). The default reporter
22
+ * never produces `file` outputs today, so this gap is theoretical.
23
+ *
24
+ * @internal
25
+ */
26
+ const routeRenderedOutput = (output, options) => {
27
+ switch (output.target) {
28
+ case "stdout":
29
+ process.stdout.write(`${output.content.replace(/\n+$/, "")}\n`);
30
+ return;
31
+ case "github-summary": {
32
+ const path = options.githubSummaryFile ?? process.env.GITHUB_STEP_SUMMARY;
33
+ if (!path) return;
34
+ try {
35
+ mkdirSync(dirname(path), { recursive: true });
36
+ appendFileSync(path, output.content, "utf8");
37
+ } catch {}
38
+ return;
39
+ }
40
+ case "file": return;
41
+ }
42
+ };
43
+
44
+ //#endregion
45
+ export { routeRenderedOutput };
@@ -0,0 +1,36 @@
1
+ //#region src/utils/stringify-failure-value.ts
2
+ /**
3
+ * Converts a raw Vitest assertion value (`.expected` / `.actual`) into a
4
+ * one-line string suitable for display in the stream renderer.
5
+ *
6
+ * The value stays within the Vitest-side error object; this helper
7
+ * converts it to a string representation that crosses the ReportError
8
+ * schema boundary. Returns `undefined` when there is no value to show
9
+ * (i.e., the input is `undefined`).
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ /**
14
+ * Stringify a raw Vitest assertion `.expected` or `.actual` value into a
15
+ * single-line string.
16
+ *
17
+ * - `undefined` → `undefined` (signals "no value — omit the field")
18
+ * - `null` → `"null"`
19
+ * - primitives (string, number, boolean, bigint) → `String(value)`
20
+ * - objects / arrays → `JSON.stringify(value)`, falling back to
21
+ * `String(value)` for circular or otherwise un-serialisable values
22
+ */
23
+ const stringifyFailureValue = (value) => {
24
+ if (value === void 0) return void 0;
25
+ if (value === null) return "null";
26
+ if (typeof value === "string") return value;
27
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
28
+ try {
29
+ return JSON.stringify(value);
30
+ } catch {
31
+ return String(value);
32
+ }
33
+ };
34
+
35
+ //#endregion
36
+ export { stringifyFailureValue };
@@ -0,0 +1,45 @@
1
+ //#region src/utils/strip-console-reporters.ts
2
+ /**
3
+ * Built-in Vitest reporters that write to the console (stdout).
4
+ * These are the reporters suppressed when an agent takes over console output.
5
+ *
6
+ * @privateRemarks
7
+ * `"agent"` is the built-in Vitest reporter added in v4.1 that reduces
8
+ * console noise for AI agents. We strip it because our reporter replaces
9
+ * its functionality with structured markdown output.
10
+ *
11
+ * @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter docs}
12
+ * @internal
13
+ */
14
+ const CONSOLE_REPORTERS = /* @__PURE__ */ new Set([
15
+ "default",
16
+ "verbose",
17
+ "tree",
18
+ "dot",
19
+ "tap",
20
+ "tap-flat",
21
+ "hanging-process",
22
+ "agent"
23
+ ]);
24
+ /**
25
+ * Filter out built-in console reporters from a Vitest reporters array.
26
+ *
27
+ * Keeps custom reporters (class instances, file paths) and non-console
28
+ * built-in reporters (`json`, `junit`, `html`, `blob`, `github-actions`).
29
+ * Used by `AgentPlugin` in agent mode to suppress noisy console output.
30
+ *
31
+ * @param reporters - The Vitest `config.reporters` array
32
+ * @returns Filtered array with console reporters removed
33
+ *
34
+ * @internal
35
+ */
36
+ function stripConsoleReporters(reporters) {
37
+ return reporters.filter((entry) => {
38
+ if (typeof entry === "string") return !CONSOLE_REPORTERS.has(entry);
39
+ if (Array.isArray(entry) && typeof entry[0] === "string") return !CONSOLE_REPORTERS.has(entry[0]);
40
+ return true;
41
+ });
42
+ }
43
+
44
+ //#endregion
45
+ export { CONSOLE_REPORTERS, stripConsoleReporters };
package/utils/tag.js ADDED
@@ -0,0 +1,42 @@
1
+ //#region src/utils/tag.ts
2
+ const RESERVED = /* @__PURE__ */ new Set([
3
+ "and",
4
+ "or",
5
+ "not"
6
+ ]);
7
+ const FORBIDDEN_CHAR = /[()&|!*\s]/;
8
+ function validateTagName(name) {
9
+ if (!name) throw new Error("Tag name is empty");
10
+ if (RESERVED.has(name)) throw new Error(`Tag name "${name}" is reserved (and/or/not)`);
11
+ if (FORBIDDEN_CHAR.test(name)) throw new Error(`Tag name "${name}" contains an invalid character (no spaces, ()&|!*)`);
12
+ }
13
+ /**
14
+ * A validated Vitest tag with its `name` string and a `TestTagDefinition` for registration.
15
+ * @public
16
+ */
17
+ var Tag = class Tag {
18
+ /** The tag name string (validated on construction). */
19
+ name;
20
+ /** The full `TestTagDefinition` object to pass to Vitest's `test.tags` config. */
21
+ definition;
22
+ constructor(name, options) {
23
+ this.name = name;
24
+ this.definition = {
25
+ name,
26
+ ...options
27
+ };
28
+ }
29
+ /**
30
+ * Create a validated `Tag`.
31
+ * @param name - Tag identifier; must not be empty, reserved, or contain forbidden characters
32
+ * @param options - Optional timeout, retry, and other Vitest tag settings
33
+ * @returns A new `Tag` instance
34
+ */
35
+ static make(name, options = {}) {
36
+ validateTagName(name);
37
+ return new Tag(name, options);
38
+ }
39
+ };
40
+
41
+ //#endregion
42
+ export { Tag };
@@ -0,0 +1,35 @@
1
+ //#region src/utils/to-posix-path.ts
2
+ /**
3
+ * Normalize a filesystem path to forward-slash separators.
4
+ *
5
+ * Single source of truth for the POSIX-style path strings the plugin uses
6
+ * in three places where the path is compared against a slash-only pattern
7
+ * or surfaced to user code that documents slash semantics:
8
+ *
9
+ * - `buildModuleInfo.relativePath` — consumed by user-supplied `ClassifyFn`
10
+ * implementations and by the bundled `classifyByDirectory` helper.
11
+ * - `find-test-files` regex matching — `globToRegex` compiles patterns
12
+ * with `/` boundaries.
13
+ * - `discoverProjects` `addProject` relativePath — passed to
14
+ * `strategy.buildProject` and exposed via `DiscoverInput.relativePath`.
15
+ *
16
+ * Always folds backslashes to forward slashes regardless of the host
17
+ * platform. On POSIX the call is effectively a no-op for paths produced
18
+ * by `node:path` operations (they never contain backslashes there) but
19
+ * still defends against custom DiscoverStrategy implementations that
20
+ * pass through a Windows-style path string. On Windows, where `node:path`
21
+ * returns backslash separators, this folds them so glob matching and
22
+ * slash-bounded segment checks both work without platform-specific
23
+ * branching at every call site.
24
+ *
25
+ * Returns the input unchanged when no backslashes are present — only the
26
+ * separator characters are touched. Callers that need the platform-native
27
+ * form (e.g. for `readFile`) should pass the original path produced by
28
+ * `join` rather than the value returned here.
29
+ */
30
+ function toPosixPath(p) {
31
+ return p.indexOf("\\") === -1 ? p : p.split("\\").join("/");
32
+ }
33
+
34
+ //#endregion
35
+ export { toPosixPath };