@vitest-agent/sdk 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/DataReader-2kJ9I9Hh.d.ts +1809 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dispatch.d.ts +48 -0
- package/dispatch.js +5 -0
- package/errors/AgentErrors.js +65 -0
- package/errors/DataStoreError.js +44 -0
- package/errors/DiscoveryError.js +21 -0
- package/errors/PathResolutionError.js +27 -0
- package/errors/ProjectIdentityError.js +29 -0
- package/errors/RunContextError.js +35 -0
- package/errors/TddErrors.js +66 -0
- package/exit-code-for-tag.js +18 -0
- package/formatters/ci-annotations.js +61 -0
- package/formatters/gfm.js +20 -0
- package/formatters/json.js +18 -0
- package/formatters/markdown.js +39 -0
- package/formatters/silent.js +9 -0
- package/formatters/terminal.js +56 -0
- package/index.d.ts +3334 -0
- package/index.js +120 -0
- package/internal-inject-env.js +42 -0
- package/layers/ConfigLive.js +47 -0
- package/layers/DataReaderLive.js +1666 -0
- package/layers/DataStoreLive.js +1185 -0
- package/layers/DetailResolverLive.js +9 -0
- package/layers/DiscoveryRegistryLive.js +70 -0
- package/layers/EnvironmentDetectorLive.js +21 -0
- package/layers/EnvironmentDetectorTest.js +13 -0
- package/layers/ExecutorResolverLive.js +9 -0
- package/layers/FormatSelectorLive.js +20 -0
- package/layers/HistoryTrackerLive.js +47 -0
- package/layers/HistoryTrackerTest.js +25 -0
- package/layers/LoggerLive.js +64 -0
- package/layers/OutputPipelineLive.js +13 -0
- package/layers/OutputRendererLive.js +28 -0
- package/layers/PathResolutionLive.js +36 -0
- package/layers/PerClientSessionMapLive.js +145 -0
- package/layers/ProjectDiscoveryLive.js +70 -0
- package/layers/ProjectDiscoveryTest.js +12 -0
- package/layers/ProjectIdentityLive.js +92 -0
- package/layers/RunContextLive.js +81 -0
- package/lib/format-triage.js +103 -0
- package/lib/format-wrapup.js +67 -0
- package/migrations/0001_initial.js +849 -0
- package/migrations/registry_0001_initial.js +39 -0
- package/migrations/session_map_0001_initial.js +61 -0
- package/package.json +64 -0
- package/schemas/Agent.js +42 -0
- package/schemas/AgentReport.js +71 -0
- package/schemas/Baselines.js +16 -0
- package/schemas/CacheManifest.js +27 -0
- package/schemas/ChannelEvent.js +138 -0
- package/schemas/Common.js +105 -0
- package/schemas/Config.js +28 -0
- package/schemas/Coverage.js +51 -0
- package/schemas/CoverageLevel.js +117 -0
- package/schemas/CoverageTargets.js +39 -0
- package/schemas/History.js +31 -0
- package/schemas/Identity.js +64 -0
- package/schemas/Options.js +57 -0
- package/schemas/RenderState.js +161 -0
- package/schemas/RunEvent.js +163 -0
- package/schemas/Tdd.js +44 -0
- package/schemas/Thresholds.js +30 -0
- package/schemas/Transport.js +16 -0
- package/schemas/Trends.js +23 -0
- package/schemas/turns/FileEditPayload.js +15 -0
- package/schemas/turns/HookFirePayload.js +13 -0
- package/schemas/turns/HypothesisPayload.js +13 -0
- package/schemas/turns/NotePayload.js +13 -0
- package/schemas/turns/ToolCallPayload.js +13 -0
- package/schemas/turns/ToolResultPayload.js +15 -0
- package/schemas/turns/UserPromptPayload.js +12 -0
- package/schemas/turns/index.js +15 -0
- package/services/Config.js +15 -0
- package/services/DataReader.js +8 -0
- package/services/DataStore.js +8 -0
- package/services/DetailResolver.js +8 -0
- package/services/DiscoveryRegistry.js +8 -0
- package/services/EnvironmentDetector.js +8 -0
- package/services/ExecutorResolver.js +8 -0
- package/services/FormatSelector.js +8 -0
- package/services/HistoryTracker.js +8 -0
- package/services/OutputRenderer.js +8 -0
- package/services/PerClientSessionMap.js +22 -0
- package/services/ProjectDiscovery.js +8 -0
- package/services/ProjectIdentity.js +63 -0
- package/services/RunContext.js +53 -0
- package/services/idempotency.js +57 -0
- package/sidecar-dispatch.js +84 -0
- package/sql/assemblers.js +59 -0
- package/testing/layers.js +21 -0
- package/testing.d.ts +25 -0
- package/testing.js +243 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/ansi.js +49 -0
- package/utils/build-report.js +130 -0
- package/utils/canonicalize-git-url.js +66 -0
- package/utils/classify-test.js +22 -0
- package/utils/compress-lines.js +45 -0
- package/utils/compute-trend.js +88 -0
- package/utils/detect-pm.js +58 -0
- package/utils/detect-timeout.js +17 -0
- package/utils/ensure-migrated.js +52 -0
- package/utils/failure-signature.js +33 -0
- package/utils/format-console.js +219 -0
- package/utils/format-fatal-error.js +35 -0
- package/utils/format-gfm.js +161 -0
- package/utils/format-terminal.js +480 -0
- package/utils/function-boundary.js +75 -0
- package/utils/hyperlink.js +11 -0
- package/utils/match-vitest-command.js +88 -0
- package/utils/normalize-workspace-key.js +34 -0
- package/utils/probe-host-metadata.js +99 -0
- package/utils/resolve-data-path.js +66 -0
- package/utils/resolve-project-key-from-cwd.js +52 -0
- package/utils/resolve-workspace-key.js +33 -0
- package/utils/safe-filename.js +34 -0
- package/utils/validate-coverage-targets-shape.js +84 -0
- package/utils/validate-phase-transition.js +113 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/utils/compute-trend.ts
|
|
2
|
+
const STABLE_THRESHOLD = .1;
|
|
3
|
+
const MAX_ENTRIES = 50;
|
|
4
|
+
/**
|
|
5
|
+
* Hash resolved targets to detect configuration changes.
|
|
6
|
+
*
|
|
7
|
+
* Note: JSON.stringify is order-sensitive, but resolveThresholds()
|
|
8
|
+
* always produces keys in METRIC_KEYS iteration order, so the hash
|
|
9
|
+
* is stable for the same logical configuration.
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
function hashTargets(targets) {
|
|
13
|
+
if (!targets) return void 0;
|
|
14
|
+
return JSON.stringify(targets);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compute direction from aggregate delta.
|
|
18
|
+
*
|
|
19
|
+
* Threshold is compared against the sum of all four metric deltas, not
|
|
20
|
+
* per-metric. This means the sensitivity is higher when multiple metrics
|
|
21
|
+
* move together vs. a single metric. This is intentional -- aggregate
|
|
22
|
+
* movement is a better signal of overall trajectory.
|
|
23
|
+
*/
|
|
24
|
+
function computeDirection(delta) {
|
|
25
|
+
const sum = delta.lines + delta.functions + delta.branches + delta.statements;
|
|
26
|
+
if (sum > STABLE_THRESHOLD) return "improving";
|
|
27
|
+
if (sum < -.1) return "regressing";
|
|
28
|
+
return "stable";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compute a new trend entry and return the updated trend record.
|
|
32
|
+
*
|
|
33
|
+
* If targets have changed (different hash), the trend history is
|
|
34
|
+
* cleared and the new entry becomes the first data point.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
function computeTrend(current, existing, targets) {
|
|
38
|
+
const currentHash = hashTargets(targets);
|
|
39
|
+
const entries = existing?.entries ?? [];
|
|
40
|
+
const baseEntries = entries[entries.length - 1]?.targetsHash !== currentHash ? [] : entries;
|
|
41
|
+
const previous = baseEntries[baseEntries.length - 1];
|
|
42
|
+
const delta = previous ? {
|
|
43
|
+
lines: current.lines - previous.coverage.lines,
|
|
44
|
+
functions: current.functions - previous.coverage.functions,
|
|
45
|
+
branches: current.branches - previous.coverage.branches,
|
|
46
|
+
statements: current.statements - previous.coverage.statements
|
|
47
|
+
} : {
|
|
48
|
+
lines: 0,
|
|
49
|
+
functions: 0,
|
|
50
|
+
branches: 0,
|
|
51
|
+
statements: 0
|
|
52
|
+
};
|
|
53
|
+
const entry = {
|
|
54
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
55
|
+
coverage: { ...current },
|
|
56
|
+
delta,
|
|
57
|
+
direction: computeDirection(delta),
|
|
58
|
+
...currentHash ? { targetsHash: currentHash } : {}
|
|
59
|
+
};
|
|
60
|
+
const newEntries = [...baseEntries, entry];
|
|
61
|
+
if (newEntries.length > MAX_ENTRIES) newEntries.splice(0, newEntries.length - MAX_ENTRIES);
|
|
62
|
+
return { entries: newEntries };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get the overall direction from recent entries.
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
function getRecentDirection(record, count = 5) {
|
|
69
|
+
const entries = record.entries;
|
|
70
|
+
if (entries.length === 0) return {
|
|
71
|
+
direction: "stable",
|
|
72
|
+
runCount: 0
|
|
73
|
+
};
|
|
74
|
+
const recent = entries.slice(-count);
|
|
75
|
+
let improving = 0;
|
|
76
|
+
let regressing = 0;
|
|
77
|
+
for (const e of recent) {
|
|
78
|
+
if (e.direction === "improving") improving++;
|
|
79
|
+
if (e.direction === "regressing") regressing++;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
direction: improving > regressing ? "improving" : regressing > improving ? "regressing" : "stable",
|
|
83
|
+
runCount: recent.length
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
export { computeTrend, getRecentDirection, hashTargets };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/utils/detect-pm.ts
|
|
2
|
+
const LOCKFILE_MAP = [
|
|
3
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
4
|
+
["package-lock.json", "npm"],
|
|
5
|
+
["yarn.lock", "yarn"],
|
|
6
|
+
["bun.lock", "bun"]
|
|
7
|
+
];
|
|
8
|
+
const RUN_COMMANDS = {
|
|
9
|
+
pnpm: "pnpm vitest run",
|
|
10
|
+
npm: "npx vitest run",
|
|
11
|
+
yarn: "yarn vitest run",
|
|
12
|
+
bun: "bun vitest run"
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Detect the package manager used in a project by inspecting the root directory.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* Detection order:
|
|
19
|
+
* 1. The `packageManager` field in `package.json` (e.g., `"pnpm\@10.32.1"`)
|
|
20
|
+
* 2. Lockfile presence: `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock`, `bun.lock`
|
|
21
|
+
*
|
|
22
|
+
* Returns `null` when no package manager can be determined.
|
|
23
|
+
*
|
|
24
|
+
* @param root - Absolute path to the project root directory
|
|
25
|
+
* @param fs - Filesystem adapter for reading files and checking existence
|
|
26
|
+
* @returns The detected package manager, or `null`
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
async function detectPackageManager(root, fs) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await fs.readFile(`${root}/package.json`);
|
|
33
|
+
const pkg = JSON.parse(raw);
|
|
34
|
+
if (pkg.packageManager) {
|
|
35
|
+
const name = pkg.packageManager.split("@")[0];
|
|
36
|
+
if (name in RUN_COMMANDS) return name;
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
for (const [lockfile, pm] of LOCKFILE_MAP) if (await fs.exists(`${root}/${lockfile}`)) return pm;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Return the vitest run command for the given package manager.
|
|
44
|
+
*
|
|
45
|
+
* Falls back to `"npx vitest run"` when `pm` is `null`.
|
|
46
|
+
*
|
|
47
|
+
* @param pm - Detected package manager, or `null` for unknown
|
|
48
|
+
* @returns Shell command string for running vitest
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
function getRunCommand(pm) {
|
|
53
|
+
if (!pm) return "npx vitest run";
|
|
54
|
+
return RUN_COMMANDS[pm];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { detectPackageManager, getRunCommand };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/utils/detect-timeout.ts
|
|
2
|
+
const TIMEOUT_MESSAGE = /\btimed out in \d+\s*ms/i;
|
|
3
|
+
const TIMEOUT_NAME = /timeout/i;
|
|
4
|
+
/**
|
|
5
|
+
* True when the error looks like a Vitest test or hook timeout. Either
|
|
6
|
+
* the message matches "timed out in Nms" or the error name contains
|
|
7
|
+
* "timeout".
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
const isTimeoutError = (error) => {
|
|
11
|
+
if (error.message !== void 0 && TIMEOUT_MESSAGE.test(error.message)) return true;
|
|
12
|
+
if (error.name !== void 0 && TIMEOUT_NAME.test(error.name)) return true;
|
|
13
|
+
return false;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { isTimeoutError };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LoggerLive } from "../layers/LoggerLive.js";
|
|
2
|
+
import migration from "../migrations/0001_initial.js";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { SqlClient } from "@effect/sql/SqlClient";
|
|
5
|
+
import * as NodeContext from "@effect/platform-node/NodeContext";
|
|
6
|
+
import { layer } from "@effect/sql-sqlite-node/SqliteClient";
|
|
7
|
+
import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
|
|
8
|
+
|
|
9
|
+
//#region src/utils/ensure-migrated.ts
|
|
10
|
+
const GLOBAL_KEY = Symbol.for("vitest-agent/migration-promises");
|
|
11
|
+
const getCache = () => {
|
|
12
|
+
const g = globalThis;
|
|
13
|
+
let cache = g[GLOBAL_KEY];
|
|
14
|
+
if (!cache) {
|
|
15
|
+
cache = /* @__PURE__ */ new Map();
|
|
16
|
+
g[GLOBAL_KEY] = cache;
|
|
17
|
+
}
|
|
18
|
+
return cache;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the SQLite database at `dbPath` is migrated. Runs migrations at
|
|
22
|
+
* most once per dbPath in the current process; concurrent calls share the
|
|
23
|
+
* same in-flight promise. Subsequent calls (after the first resolves) are
|
|
24
|
+
* no-ops.
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
function ensureMigrated(dbPath, logLevel, logFile) {
|
|
28
|
+
const cache = getCache();
|
|
29
|
+
const cached = cache.get(dbPath);
|
|
30
|
+
if (cached) return cached;
|
|
31
|
+
const SqliteLayer = layer({ filename: dbPath });
|
|
32
|
+
const PlatformLayer = NodeContext.layer;
|
|
33
|
+
const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord({ "0001_initial": migration }) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
|
|
34
|
+
const program = Effect.gen(function* () {
|
|
35
|
+
yield* SqlClient;
|
|
36
|
+
}).pipe(Effect.provide(MigratorLayer), Effect.provide(Layer.merge(SqliteLayer, PlatformLayer)), Effect.provide(LoggerLive(logLevel, logFile)));
|
|
37
|
+
const promise = Effect.runPromise(program);
|
|
38
|
+
cache.set(dbPath, promise);
|
|
39
|
+
promise.catch(() => {});
|
|
40
|
+
return promise;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reset the migration cache. Test-only.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
function _resetMigrationCacheForTesting() {
|
|
48
|
+
getCache().clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { _resetMigrationCacheForTesting, ensureMigrated };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/failure-signature.ts
|
|
4
|
+
const ASSERTION_REGEX = /(toBe|toEqual|toContain|toMatch|toThrow|toBeNull|toBeUndefined|toBeTruthy|toBeFalsy|toBeGreaterThan|toBeLessThan|toBeInstanceOf|toBeCloseTo|toHaveBeenCalled|toHaveBeenCalledWith|toHaveProperty|toHaveLength)\s*\(([^)]*)\)/;
|
|
5
|
+
const typeTagOf = (literal) => {
|
|
6
|
+
const trimmed = literal.trim();
|
|
7
|
+
if (trimmed === "" || trimmed === ")") return "";
|
|
8
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) return "<number>";
|
|
9
|
+
if (/^["'`]/.test(trimmed)) return "<string>";
|
|
10
|
+
if (/^(true|false)$/.test(trimmed)) return "<boolean>";
|
|
11
|
+
if (/^null$/.test(trimmed)) return "<null>";
|
|
12
|
+
if (/^undefined$/.test(trimmed)) return "<undefined>";
|
|
13
|
+
if (/^[{[]/.test(trimmed)) return "<object>";
|
|
14
|
+
return "<expr>";
|
|
15
|
+
};
|
|
16
|
+
/** @public */
|
|
17
|
+
const normalizeAssertionShape = (message) => {
|
|
18
|
+
const match = message.match(ASSERTION_REGEX);
|
|
19
|
+
if (match === null) return null;
|
|
20
|
+
const matcher = match[1];
|
|
21
|
+
const arg = typeTagOf(match[2]);
|
|
22
|
+
return arg === "" ? matcher : `${matcher}(${arg})`;
|
|
23
|
+
};
|
|
24
|
+
/** @public */
|
|
25
|
+
const computeFailureSignature = (input) => {
|
|
26
|
+
const shape = normalizeAssertionShape(input.assertion_message) ?? "<unknown>";
|
|
27
|
+
const lineCoord = input.top_frame_function_boundary_line !== null ? `fb:${input.top_frame_function_boundary_line}` : input.top_frame_raw_line !== void 0 ? `raw:${Math.floor(input.top_frame_raw_line / 10) * 10}` : "raw:?";
|
|
28
|
+
const key = `${input.error_name}|${shape}|${input.top_frame_function_name}|${lineCoord}`;
|
|
29
|
+
return createHash("sha256").update(key).digest("hex").substring(0, 16);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { computeFailureSignature, normalizeAssertionShape };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { ansi } from "./ansi.js";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/format-console.ts
|
|
4
|
+
/**
|
|
5
|
+
* Return the metric with the lowest percentage from a `FileCoverageReport`.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Used to surface the single worst coverage metric per file in console output,
|
|
9
|
+
* keeping the display compact. Labels are abbreviated: `"Stmts"`, `"Branch"`,
|
|
10
|
+
* `"Funcs"`, `"Lines"`.
|
|
11
|
+
*
|
|
12
|
+
* @param f - File coverage report to analyze
|
|
13
|
+
* @returns Object with `label` and `pct` of the worst metric
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
function getWorstMetric(f) {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
label: "Stmts",
|
|
21
|
+
pct: f.summary.statements
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: "Branch",
|
|
25
|
+
pct: f.summary.branches
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: "Funcs",
|
|
29
|
+
pct: f.summary.functions
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: "Lines",
|
|
33
|
+
pct: f.summary.lines
|
|
34
|
+
}
|
|
35
|
+
].reduce((worst, m) => m.pct < worst.pct ? m : worst);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Strip the working directory prefix from a file path to produce a
|
|
39
|
+
* project-relative path for display.
|
|
40
|
+
*
|
|
41
|
+
* @param filePath - Absolute or relative file path
|
|
42
|
+
* @param cwd - Working directory to strip; defaults to `process.cwd()`
|
|
43
|
+
* @returns Project-relative path string
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
function relativePath(filePath, cwd) {
|
|
48
|
+
const root = cwd ?? process.cwd();
|
|
49
|
+
if (filePath.startsWith(root)) {
|
|
50
|
+
const rel = filePath.slice(root.length);
|
|
51
|
+
return rel.startsWith("/") ? rel.slice(1) : rel;
|
|
52
|
+
}
|
|
53
|
+
return filePath;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Determine the output tier based on test results and coverage state.
|
|
57
|
+
*
|
|
58
|
+
* - `"red"` -- failures, unhandled errors, or files below threshold
|
|
59
|
+
* - `"yellow"` -- all tests pass but coverage targets exist (room to improve)
|
|
60
|
+
* - `"green"` -- all tests pass, all coverage targets met
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
function determineTier(report) {
|
|
65
|
+
if (report.summary.failed > 0 || report.unhandledErrors.length > 0) return "red";
|
|
66
|
+
if (report.coverage) {
|
|
67
|
+
if (report.coverage.lowCoverage.length > 0) return "red";
|
|
68
|
+
if (report.coverage.targets && report.coverage.belowTarget && report.coverage.belowTarget.length > 0) return "yellow";
|
|
69
|
+
}
|
|
70
|
+
return "green";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Format an {@link (AgentReport:variable)} as compact, actionable console markdown.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* The output is structured for LLM agent consumption with four sections:
|
|
77
|
+
*
|
|
78
|
+
* 1. **Compact header** with pass/fail counts and duration
|
|
79
|
+
* 2. **Failed test details** with error messages and diffs (the core value)
|
|
80
|
+
* 3. **Coverage gaps** showing only files below threshold, worst metric first
|
|
81
|
+
* 4. **Next steps** with re-run commands and cache file pointer
|
|
82
|
+
*
|
|
83
|
+
* When all tests pass, the output collapses to a single success line
|
|
84
|
+
* with an optional cache file reference.
|
|
85
|
+
*
|
|
86
|
+
* Returns an empty string when `consoleOutput` is `"silent"`.
|
|
87
|
+
*
|
|
88
|
+
* @param report - The report to format
|
|
89
|
+
* @param options - Formatting options controlling verbosity and color
|
|
90
|
+
* @returns Formatted markdown string, or empty string for silent mode
|
|
91
|
+
*
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
function formatConsoleMarkdown(report, options) {
|
|
95
|
+
const { consoleOutput, coverageConsoleLimit, noColor } = options;
|
|
96
|
+
if (consoleOutput === "silent") return "";
|
|
97
|
+
const ao = { noColor };
|
|
98
|
+
const lines = [];
|
|
99
|
+
const hasFailures = report.summary.failed > 0;
|
|
100
|
+
const duration = Math.round(report.summary.duration);
|
|
101
|
+
const projectLabel = report.project ? ` (${report.project})` : "";
|
|
102
|
+
if (hasFailures) lines.push(`## ${ansi("✗", "red", ao)} Vitest${projectLabel} \u2014 ${report.summary.failed} failed, ${report.summary.passed} passed (${duration}ms)`);
|
|
103
|
+
else lines.push(`## ${ansi("✓", "green", ao)} Vitest${projectLabel} \u2014 ${report.summary.passed} passed (${duration}ms)`);
|
|
104
|
+
lines.push("");
|
|
105
|
+
if (options.trendSummary && report.coverage) {
|
|
106
|
+
const { direction, runCount, firstMetric } = options.trendSummary;
|
|
107
|
+
const tier = determineTier(report);
|
|
108
|
+
if (tier === "green") {
|
|
109
|
+
const trendNote = `Coverage: all targets met | trending ${direction} over ${runCount} runs`;
|
|
110
|
+
lines[0] = `${lines[0]} — ${trendNote}`;
|
|
111
|
+
} else if (tier === "yellow") {
|
|
112
|
+
const belowTargetCount = report.coverage.belowTarget?.length ?? 0;
|
|
113
|
+
let summary = `Coverage: ${belowTargetCount} file${belowTargetCount !== 1 ? "s" : ""} below target`;
|
|
114
|
+
summary += ` | trending ${direction} over ${runCount} runs`;
|
|
115
|
+
if (firstMetric) {
|
|
116
|
+
summary += ` (${firstMetric.name}: ${firstMetric.from}% -> ${firstMetric.to}%`;
|
|
117
|
+
if (firstMetric.target) summary += `, target: ${firstMetric.target}%`;
|
|
118
|
+
summary += ")";
|
|
119
|
+
}
|
|
120
|
+
lines.push(summary);
|
|
121
|
+
lines.push("");
|
|
122
|
+
} else {
|
|
123
|
+
let summary = "Coverage:";
|
|
124
|
+
if (report.coverage.lowCoverage.length > 0) summary += ` ${report.coverage.lowCoverage.length} file${report.coverage.lowCoverage.length !== 1 ? "s" : ""} below threshold`;
|
|
125
|
+
summary += ` | trending ${direction} over ${runCount} runs`;
|
|
126
|
+
if (firstMetric) summary += ` (${firstMetric.name}: ${firstMetric.from}% -> ${firstMetric.to}%)`;
|
|
127
|
+
lines.push(summary);
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (hasFailures) for (const mod of report.failed) {
|
|
132
|
+
const file = relativePath(mod.file);
|
|
133
|
+
lines.push(`### ${ansi("✗", "red", ao)} \`${file}\``);
|
|
134
|
+
lines.push("");
|
|
135
|
+
if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
|
|
136
|
+
lines.push(ansi(err.message, "red", ao));
|
|
137
|
+
if (err.diff) {
|
|
138
|
+
lines.push("");
|
|
139
|
+
lines.push("```diff");
|
|
140
|
+
lines.push(err.diff);
|
|
141
|
+
lines.push("```");
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
}
|
|
145
|
+
const failedTests = mod.tests.filter((t) => t.state === "failed");
|
|
146
|
+
for (const test of failedTests) {
|
|
147
|
+
const label = test.classification ? ` [${test.classification}]` : "";
|
|
148
|
+
lines.push(`- ${ansi("✗", "red", ao)} **${test.fullName}**${label}`);
|
|
149
|
+
if (test.errors && test.errors.length > 0) for (const err of test.errors) {
|
|
150
|
+
lines.push(` ${ansi(err.message, "dim", ao)}`);
|
|
151
|
+
if (err.diff) {
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(" ```diff");
|
|
154
|
+
for (const diffLine of err.diff.split("\n")) lines.push(` ${diffLine}`);
|
|
155
|
+
lines.push(" ```");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push("");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (report.coverage) {
|
|
162
|
+
const cov = report.coverage;
|
|
163
|
+
const globalThresholds = cov.thresholds.global;
|
|
164
|
+
const filesToShow = cov.lowCoverage.slice(0, coverageConsoleLimit);
|
|
165
|
+
if (filesToShow.length > 0) {
|
|
166
|
+
lines.push(`### Coverage gaps`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
for (const f of filesToShow) {
|
|
169
|
+
const file = relativePath(f.file);
|
|
170
|
+
const worst = getWorstMetric(f);
|
|
171
|
+
const thresholdValue = globalThresholds[worst.label === "Stmts" ? "statements" : worst.label === "Branch" ? "branches" : worst.label === "Funcs" ? "functions" : "lines"];
|
|
172
|
+
const thresholdSuffix = thresholdValue != null ? ` (threshold: ${thresholdValue}%)` : "";
|
|
173
|
+
lines.push(`- \`${file}\` \u2014 ${worst.label}: ${worst.pct}%${thresholdSuffix} \u2014 uncovered: ${f.uncoveredLines}`);
|
|
174
|
+
}
|
|
175
|
+
lines.push("");
|
|
176
|
+
}
|
|
177
|
+
if (cov.targets && cov.belowTarget && cov.belowTarget.length > 0) {
|
|
178
|
+
lines.push(`### Coverage improvements needed`);
|
|
179
|
+
lines.push("");
|
|
180
|
+
for (const f of cov.belowTarget.slice(0, coverageConsoleLimit)) {
|
|
181
|
+
const file = relativePath(f.file);
|
|
182
|
+
const worst = getWorstMetric(f);
|
|
183
|
+
const metricKey = worst.label === "Stmts" ? "statements" : worst.label === "Branch" ? "branches" : worst.label === "Funcs" ? "functions" : "lines";
|
|
184
|
+
const targetValue = cov.targets.global[metricKey];
|
|
185
|
+
const targetSuffix = targetValue != null ? ` (target: ${targetValue}%)` : "";
|
|
186
|
+
lines.push(`- \`${file}\` \u2014 ${worst.label}: ${worst.pct}%${targetSuffix} \u2014 uncovered: ${f.uncoveredLines}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push("");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (hasFailures || report.coverage && report.coverage.lowCoverage.length > 0) {
|
|
192
|
+
lines.push("### Next steps");
|
|
193
|
+
lines.push("");
|
|
194
|
+
if (hasFailures) {
|
|
195
|
+
const allFailedTests = report.failed.flatMap((m) => m.tests.filter((t) => t.state === "failed"));
|
|
196
|
+
const newFailures = allFailedTests.filter((t) => t.classification === "new-failure");
|
|
197
|
+
const persistent = allFailedTests.filter((t) => t.classification === "persistent");
|
|
198
|
+
const flaky = allFailedTests.filter((t) => t.classification === "flaky");
|
|
199
|
+
if (newFailures.length > 0) {
|
|
200
|
+
const files = [...new Set(report.failed.filter((m) => m.tests.some((t) => t.classification === "new-failure")).map((m) => relativePath(m.file)))];
|
|
201
|
+
lines.push(`- Fix ${newFailures.length} new failure${newFailures.length > 1 ? "s" : ""} in ${files.map((f) => `\`${f}\``).join(", ")} (likely caused by recent changes)`);
|
|
202
|
+
}
|
|
203
|
+
if (persistent.length > 0) lines.push(`- ${persistent.length} persistent failure${persistent.length > 1 ? "s" : ""} (pre-existing, may not be yours)`);
|
|
204
|
+
if (flaky.length > 0) lines.push(`- ${flaky.length} flaky test${flaky.length > 1 ? "s" : ""} (may pass on retry)`);
|
|
205
|
+
for (const file of report.failedFiles) lines.push(`- Re-run: \`vitest run ${relativePath(file)}\``);
|
|
206
|
+
if (options.mcp) {
|
|
207
|
+
lines.push("- Use `test_history` for failure trends");
|
|
208
|
+
lines.push("- Use `test_errors` to search errors by type");
|
|
209
|
+
lines.push("- Use `test_coverage` for coverage gap analysis");
|
|
210
|
+
lines.push("- Use `note({ action: \"create\" })` to record debugging findings");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
lines.push("");
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
export { determineTier, formatConsoleMarkdown, getWorstMetric, relativePath };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Cause } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/format-fatal-error.ts
|
|
4
|
+
const ISSUE_URL = "https://github.com/spencerbeggs/vitest-agent/issues";
|
|
5
|
+
const FIBER_FAILURE_CAUSE_KEY = Symbol.for("effect/Runtime/FiberFailure/Cause");
|
|
6
|
+
/**
|
|
7
|
+
* Format an unknown error into a human-readable string with issue URL.
|
|
8
|
+
*
|
|
9
|
+
* Extraction order:
|
|
10
|
+
* 1. Effect Cause — use `Cause.pretty()` for full formatting
|
|
11
|
+
* 2. Effect FiberFailure — extract Cause and use `Cause.pretty()`
|
|
12
|
+
* 3. Plain Error — extract `.message` and `.stack`
|
|
13
|
+
* 4. Unknown — `String(err)`
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
function formatFatalError(err) {
|
|
18
|
+
let detail;
|
|
19
|
+
if (Cause.isCause(err)) detail = Cause.pretty(err);
|
|
20
|
+
else if (err != null && typeof err === "object" && FIBER_FAILURE_CAUSE_KEY in err) {
|
|
21
|
+
const cause = err[FIBER_FAILURE_CAUSE_KEY];
|
|
22
|
+
if (Cause.isCause(cause)) detail = Cause.pretty(cause);
|
|
23
|
+
else detail = String(err);
|
|
24
|
+
} else if (err instanceof Error) detail = err.stack ?? err.message;
|
|
25
|
+
else if (err !== null && typeof err === "object") try {
|
|
26
|
+
detail = JSON.stringify(err, null, 2);
|
|
27
|
+
} catch {
|
|
28
|
+
detail = String(err);
|
|
29
|
+
}
|
|
30
|
+
else detail = String(err);
|
|
31
|
+
return `${detail}\nPlease report at ${ISSUE_URL}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { formatFatalError };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
//#region src/utils/format-gfm.ts
|
|
2
|
+
/**
|
|
3
|
+
* Format a number as a percentage string.
|
|
4
|
+
*
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
function pct(n) {
|
|
8
|
+
return `${n}%`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Find the worst (lowest) coverage metric for a file.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
function worstCoverage(f) {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
label: "Stmts",
|
|
19
|
+
pct: f.summary.statements
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
label: "Branch",
|
|
23
|
+
pct: f.summary.branches
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
label: "Funcs",
|
|
27
|
+
pct: f.summary.functions
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: "Lines",
|
|
31
|
+
pct: f.summary.lines
|
|
32
|
+
}
|
|
33
|
+
].reduce((worst, m) => m.pct < worst.pct ? m : worst);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Format the body of a single project's GFM output.
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
function formatProjectBody(report) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
if (report.failed.length > 0) {
|
|
43
|
+
lines.push("## Failed Tests");
|
|
44
|
+
lines.push("");
|
|
45
|
+
for (const mod of report.failed) {
|
|
46
|
+
lines.push(`### \`${mod.file}\``);
|
|
47
|
+
lines.push("");
|
|
48
|
+
if (mod.errors && mod.errors.length > 0) for (const err of mod.errors) {
|
|
49
|
+
lines.push(`> ${err.message}`);
|
|
50
|
+
if (err.diff) {
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("```diff");
|
|
53
|
+
lines.push(err.diff);
|
|
54
|
+
lines.push("```");
|
|
55
|
+
}
|
|
56
|
+
lines.push("");
|
|
57
|
+
}
|
|
58
|
+
const failedTests = mod.tests.filter((t) => t.state === "failed");
|
|
59
|
+
for (const test of failedTests) {
|
|
60
|
+
lines.push(`**${test.fullName}**`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (test.errors && test.errors.length > 0) for (const err of test.errors) {
|
|
63
|
+
lines.push(`> ${err.message}`);
|
|
64
|
+
if (err.diff) {
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push("```diff");
|
|
67
|
+
lines.push(err.diff);
|
|
68
|
+
lines.push("```");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (report.coverage) {
|
|
76
|
+
const cov = report.coverage;
|
|
77
|
+
lines.push("## Coverage");
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push("| Metric | Coverage |");
|
|
80
|
+
lines.push("| --- | --- |");
|
|
81
|
+
lines.push(`| Statements | ${pct(cov.totals.statements)} |`);
|
|
82
|
+
lines.push(`| Branches | ${pct(cov.totals.branches)} |`);
|
|
83
|
+
lines.push(`| Functions | ${pct(cov.totals.functions)} |`);
|
|
84
|
+
lines.push(`| Lines | ${pct(cov.totals.lines)} |`);
|
|
85
|
+
lines.push("");
|
|
86
|
+
if (cov.lowCoverage.length > 0) {
|
|
87
|
+
const thresholdParts = [];
|
|
88
|
+
const g = cov.thresholds.global;
|
|
89
|
+
if (g.lines !== void 0) thresholdParts.push(`lines: ${g.lines}%`);
|
|
90
|
+
if (g.functions !== void 0) thresholdParts.push(`functions: ${g.functions}%`);
|
|
91
|
+
if (g.branches !== void 0) thresholdParts.push(`branches: ${g.branches}%`);
|
|
92
|
+
if (g.statements !== void 0) thresholdParts.push(`statements: ${g.statements}%`);
|
|
93
|
+
const thresholdDisplay = thresholdParts.length > 0 ? thresholdParts.join(", ") : "configured thresholds";
|
|
94
|
+
lines.push("> [!WARNING]");
|
|
95
|
+
lines.push(`> ${cov.lowCoverage.length} file(s) below coverage threshold (${thresholdDisplay}):`);
|
|
96
|
+
lines.push(">");
|
|
97
|
+
for (const f of cov.lowCoverage) {
|
|
98
|
+
const worst = worstCoverage(f);
|
|
99
|
+
lines.push(`> - \`${f.file}\` — ${worst.label} ${pct(worst.pct)}`);
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Format one or more {@link (AgentReport:variable) | AgentReports} as a GitHub-Flavored
|
|
108
|
+
* Markdown string for writing to `GITHUB_STEP_SUMMARY`.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* GFM-specific features used in the output:
|
|
112
|
+
*
|
|
113
|
+
* - **Summary table** with pass/fail counts across all projects
|
|
114
|
+
* - **Collapsible `<details>` blocks** for per-project results in monorepo setups
|
|
115
|
+
* - **Tables** for coverage metrics
|
|
116
|
+
* - **Diff-fenced code blocks** for expected/received comparison output
|
|
117
|
+
* - **GitHub Alert callouts** (`[!WARNING]`) for coverage threshold violations
|
|
118
|
+
*
|
|
119
|
+
* For a single report, the project body is rendered directly without
|
|
120
|
+
* a `<details>` wrapper. For multiple reports (monorepo), each project
|
|
121
|
+
* gets its own collapsible section with a status icon in the summary line.
|
|
122
|
+
*
|
|
123
|
+
* @param reports - One or more agent reports to format
|
|
124
|
+
* @returns GFM-formatted string ready for appending to `GITHUB_STEP_SUMMARY`
|
|
125
|
+
*
|
|
126
|
+
* @public
|
|
127
|
+
*/
|
|
128
|
+
function formatGfm(reports) {
|
|
129
|
+
const lines = [];
|
|
130
|
+
const headerIcon = reports.some((r) => r.summary.failed > 0) ? "❌" : "✅";
|
|
131
|
+
lines.push(`# ${headerIcon} Vitest Results`);
|
|
132
|
+
lines.push("");
|
|
133
|
+
const totalPassed = reports.reduce((acc, r) => acc + r.summary.passed, 0);
|
|
134
|
+
const totalFailed = reports.reduce((acc, r) => acc + r.summary.failed, 0);
|
|
135
|
+
const totalTests = reports.reduce((acc, r) => acc + r.summary.total, 0);
|
|
136
|
+
lines.push("| | Count |");
|
|
137
|
+
lines.push("| --- | --- |");
|
|
138
|
+
lines.push(`| Passed | **${totalPassed}** |`);
|
|
139
|
+
lines.push(`| Failed | **${totalFailed}** |`);
|
|
140
|
+
lines.push(`| Total | **${totalTests}** |`);
|
|
141
|
+
lines.push("");
|
|
142
|
+
if (reports.length > 1) for (const report of reports) {
|
|
143
|
+
const projectLabel = report.project ?? "default";
|
|
144
|
+
const statusIcon = report.summary.failed > 0 ? "❌" : "✅";
|
|
145
|
+
lines.push(`<details>`);
|
|
146
|
+
lines.push(`<summary>${statusIcon} ${projectLabel}</summary>`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
const bodyLines = formatProjectBody(report);
|
|
149
|
+
lines.push(...bodyLines);
|
|
150
|
+
lines.push("</details>");
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
else if (reports.length === 1) {
|
|
154
|
+
const bodyLines = formatProjectBody(reports[0]);
|
|
155
|
+
lines.push(...bodyLines);
|
|
156
|
+
}
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
//#endregion
|
|
161
|
+
export { formatGfm };
|