@vitest-agent/plugin 2.5.7 → 3.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/index.d.ts +81 -16
- package/index.js +2 -1
- package/layers/ConfigValidationLive.js +29 -2
- package/layers/CoverageAnalyzerLive.js +28 -1
- package/layers/ReporterLive.js +2 -2
- package/package.json +9 -9
- package/plugin.js +42 -13
- package/reporter.js +189 -7
- package/utils/configuration-error.js +19 -0
- package/utils/ensure-github-reporter.js +39 -12
- package/utils/report-writer.js +83 -0
- package/utils/resolve-thresholds.js +23 -11
- package/utils/route-rendered-output.js +12 -0
- package/utils/strip-console-reporters.js +8 -4
- package/utils/tag-cache-key.js +30 -0
package/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { TestTagDefinition } from "@vitest/runner";
|
|
2
1
|
import { AgentPluginOptions, AgentReporterOptions, ConsoleMode, CoverageBaselines, CoverageInput, CoverageLevel, CoverageLevelName, CoverageLevelName as CoverageLevelName$1, CoverageReport, EnvironmentDetector, OutputFormat, ResolvedReporterConfig, ResolvedThresholds, RunEvent, SettingsInput, StackFrameInput, Transport, VitestAgentReporterFactory, resolveCoverageInput, validateCoverageConfig } from "@vitest-agent/sdk";
|
|
3
2
|
import { Context, Effect, Layer, LogLevel, Option } from "effect";
|
|
4
|
-
import { TestProjectInlineConfiguration } from "vitest/config";
|
|
3
|
+
import { TestProjectInlineConfiguration, TestTagDefinition } from "vitest/config";
|
|
5
4
|
import { ResolvedConfig, VitestPluginContext } from "vitest/node";
|
|
6
5
|
import { SourceMap } from "magic-string";
|
|
7
6
|
import { WorkspacesSyncOptions } from "@effected/workspaces/node-sync";
|
|
@@ -323,8 +322,10 @@ export declare function AgentPlugin(options?: AgentPluginConstructorOptions, _la
|
|
|
323
322
|
* passed to Vitest's native `coverage.thresholds`; the `coverageTargets`
|
|
324
323
|
* half is passed to `AgentPlugin({ coverageTargets })`.
|
|
325
324
|
*
|
|
326
|
-
* `thresholds` carries the optional `perFile` flag
|
|
327
|
-
*
|
|
325
|
+
* `thresholds` carries the optional top-level `perFile` flag. Under
|
|
326
|
+
* Vitest 5 a glob-pattern entry may carry its own `perFile`, and a file a
|
|
327
|
+
* pattern matches does NOT inherit the top-level one — the top-level
|
|
328
|
+
* setting applies only to files no pattern matches.
|
|
328
329
|
* @public
|
|
329
330
|
*/
|
|
330
331
|
interface CoverageLevelPreset {
|
|
@@ -384,9 +385,14 @@ export declare namespace AgentPlugin {
|
|
|
384
385
|
/**
|
|
385
386
|
* Tolerance functions for Vitest's `coverage.thresholds.autoUpdate` field.
|
|
386
387
|
*
|
|
387
|
-
* Vitest's contract
|
|
388
|
-
*
|
|
389
|
-
*
|
|
388
|
+
* Vitest's contract is
|
|
389
|
+
* `autoUpdate?: boolean | ((newThreshold: number, previousThreshold: number) => number)`.
|
|
390
|
+
* Pass one of these functions directly. `standard` floors the new value;
|
|
391
|
+
* `strict` ceils it; `lenient` floors and subtracts 2 (clamped to 0) to
|
|
392
|
+
* leave a slack buffer, and never returns a value below
|
|
393
|
+
* `previousThreshold` — so a temporary coverage dip cannot ratchet the
|
|
394
|
+
* configured floor downward. `standard` and `strict` ignore
|
|
395
|
+
* `previousThreshold`.
|
|
390
396
|
*
|
|
391
397
|
* ```ts
|
|
392
398
|
* defineConfig({
|
|
@@ -398,9 +404,9 @@ export declare namespace AgentPlugin {
|
|
|
398
404
|
* ```
|
|
399
405
|
*/
|
|
400
406
|
const COVERAGE_AUTOUPDATE: Readonly<{
|
|
401
|
-
standard: (
|
|
402
|
-
strict: (
|
|
403
|
-
lenient: (
|
|
407
|
+
standard: (next: number, previous: number) => number;
|
|
408
|
+
strict: (next: number, previous: number) => number;
|
|
409
|
+
lenient: (next: number, previous: number) => number;
|
|
404
410
|
}>;
|
|
405
411
|
/**
|
|
406
412
|
* Discover Vitest project configs and tag definitions from the workspace layout.
|
|
@@ -511,6 +517,13 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
|
|
|
511
517
|
format?: OutputFormat;
|
|
512
518
|
mcp?: boolean;
|
|
513
519
|
githubActions?: boolean;
|
|
520
|
+
/**
|
|
521
|
+
* The `.vitest/<scope>` directory name for `report`-targeted rendered
|
|
522
|
+
* output. Undefined disables report files.
|
|
523
|
+
*
|
|
524
|
+
* @internal
|
|
525
|
+
*/
|
|
526
|
+
reportScope?: string;
|
|
514
527
|
transport?: Transport;
|
|
515
528
|
/**
|
|
516
529
|
* Optional `test.passWithNoTests` value the plugin captured from the
|
|
@@ -549,6 +562,10 @@ interface AgentReporterConstructorOptions extends AgentReporterOptions {
|
|
|
549
562
|
* results via Vitest's native `TestProject` API. In single-project mode,
|
|
550
563
|
* results are written with project name "default".
|
|
551
564
|
*
|
|
565
|
+
* Not to be confused with Vitest 5's own `AgentReporter` export from
|
|
566
|
+
* `vitest/node` (an alias of `MinimalReporter`) — this is
|
|
567
|
+
* `@vitest-agent/plugin`'s `AgentReporter`, an unrelated class.
|
|
568
|
+
*
|
|
552
569
|
* @privateRemarks
|
|
553
570
|
* The `onCoverage` hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
|
|
554
571
|
* Coverage data must be stashed as instance state and merged during
|
|
@@ -600,6 +617,11 @@ export declare class AgentReporter {
|
|
|
600
617
|
* @internal
|
|
601
618
|
*/
|
|
602
619
|
_vitest: unknown;
|
|
620
|
+
/**
|
|
621
|
+
* Lazily-created writer for `report`-targeted output. Null when
|
|
622
|
+
* `reportScope` is unset (report files disabled) or before `onInit`.
|
|
623
|
+
*/
|
|
624
|
+
private reportWriter;
|
|
603
625
|
private coverage;
|
|
604
626
|
private logLevel;
|
|
605
627
|
private logFile;
|
|
@@ -969,6 +991,18 @@ export declare class AgentReporter {
|
|
|
969
991
|
};
|
|
970
992
|
}, annotation: {
|
|
971
993
|
message: string;
|
|
994
|
+
type?: string;
|
|
995
|
+
location?: {
|
|
996
|
+
file: string;
|
|
997
|
+
line: number;
|
|
998
|
+
column: number;
|
|
999
|
+
};
|
|
1000
|
+
attachment?: {
|
|
1001
|
+
contentType?: string;
|
|
1002
|
+
path?: string;
|
|
1003
|
+
body?: string | Uint8Array;
|
|
1004
|
+
bodyEncoding?: "base64" | "utf-8";
|
|
1005
|
+
};
|
|
972
1006
|
}): void;
|
|
973
1007
|
/**
|
|
974
1008
|
* Vitest streaming hook: a test case recorded an artifact.
|
|
@@ -985,6 +1019,17 @@ export declare class AgentReporter {
|
|
|
985
1019
|
};
|
|
986
1020
|
}, artifact: {
|
|
987
1021
|
type?: string;
|
|
1022
|
+
location?: {
|
|
1023
|
+
file: string;
|
|
1024
|
+
line: number;
|
|
1025
|
+
column: number;
|
|
1026
|
+
};
|
|
1027
|
+
attachments?: ReadonlyArray<{
|
|
1028
|
+
contentType?: string;
|
|
1029
|
+
path?: string;
|
|
1030
|
+
body?: string | Uint8Array;
|
|
1031
|
+
bodyEncoding?: "base64" | "utf-8";
|
|
1032
|
+
}>;
|
|
988
1033
|
}): void;
|
|
989
1034
|
/**
|
|
990
1035
|
* Vitest streaming hook: watch mode has finished its initial run and
|
|
@@ -1036,6 +1081,23 @@ export declare class AgentReporter {
|
|
|
1036
1081
|
onTestRunEnd(testModules: ReadonlyArray<unknown>, unhandledErrors: ReadonlyArray<unknown>, reason: "passed" | "failed" | "interrupted"): Promise<void>;
|
|
1037
1082
|
}
|
|
1038
1083
|
//#endregion
|
|
1084
|
+
//#region src/utils/configuration-error.d.ts
|
|
1085
|
+
/**
|
|
1086
|
+
* A mistake in the user's own `AgentPlugin` configuration — a bad report
|
|
1087
|
+
* scope, an unsafe report filename — as opposed to an internal failure of
|
|
1088
|
+
* the plugin.
|
|
1089
|
+
*
|
|
1090
|
+
* `configureVitest` reports these as a single `vitest-agent: <message>`
|
|
1091
|
+
* line on stderr, with no stack trace and no "please report an issue"
|
|
1092
|
+
* banner: there is nothing to report, the user simply needs to fix the
|
|
1093
|
+
* config. Every other throw keeps the `formatFatalError` treatment.
|
|
1094
|
+
*
|
|
1095
|
+
* @public
|
|
1096
|
+
*/
|
|
1097
|
+
export declare class ConfigurationError extends Error {
|
|
1098
|
+
readonly name = "ConfigurationError";
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1039
1101
|
//#region src/utils/discover-projects.d.ts
|
|
1040
1102
|
/**
|
|
1041
1103
|
* The resolved output of `discoverProjects` — projects and tag definitions ready for `defineConfig`.
|
|
@@ -1353,9 +1415,12 @@ export declare function resolveThresholds(input: VitestThresholdsInput | undefin
|
|
|
1353
1415
|
* These are the reporters suppressed when an agent takes over console output.
|
|
1354
1416
|
*
|
|
1355
1417
|
* @privateRemarks
|
|
1356
|
-
* `"agent"`
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1418
|
+
* `"agent"` and `"minimal"` both resolve to Vitest's `MinimalReporter`
|
|
1419
|
+
* class. `"agent"` is the v4.1 spelling; `"minimal"` is the v5 spelling
|
|
1420
|
+
* and is what `configDefaults.reporters` selects whenever `std-env`'s
|
|
1421
|
+
* `isAgent` is true — which is the vitest-agent primary use case. Both
|
|
1422
|
+
* are stripped because our reporter replaces their functionality with
|
|
1423
|
+
* structured markdown output.
|
|
1359
1424
|
*
|
|
1360
1425
|
* @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter docs}
|
|
1361
1426
|
* @internal
|
|
@@ -1382,9 +1447,9 @@ export declare const COVERAGE_LEVELS: Readonly<Record<import("@vitest-agent/sdk"
|
|
|
1382
1447
|
export declare const COVERAGE_LEVELS_PER_FILE: Readonly<Record<import("@vitest-agent/sdk").CoverageLevelName, CoverageLevelPreset>>;
|
|
1383
1448
|
/** Auto-update tolerance functions for `coverage.thresholds.autoUpdate`. Mirrors `AgentPlugin.COVERAGE_AUTOUPDATE`. @public */
|
|
1384
1449
|
export declare const COVERAGE_AUTOUPDATE: Readonly<{
|
|
1385
|
-
standard: (
|
|
1386
|
-
strict: (
|
|
1387
|
-
lenient: (
|
|
1450
|
+
standard: (next: number, previous: number) => number;
|
|
1451
|
+
strict: (next: number, previous: number) => number;
|
|
1452
|
+
lenient: (next: number, previous: number) => number;
|
|
1388
1453
|
}>;
|
|
1389
1454
|
//#endregion
|
|
1390
1455
|
export { type AddProjectInput, type AgentPluginConstructorOptions, type AgentReporterConstructorOptions, type ClassifyContext, type ClassifyFn, type CoverageInput, CoverageLevel, type CoverageLevelName, type CoverageLevelPreset, type CoverageOptions, type DiscoverBuilder, type DiscoverInput, type PackageJson as DiscoverPackageJson, type DiscoverProjectsOptions, type DiscoverProjectsResult, type DiscoverResult, type DiscoverStrategyCreateOptions, type DiscoverStrategyExtendOptions, type InjectTagsResult, type ModuleInfo, type TagOptions, type ValidationError, type ValidationInfo, type ValidationInput, type ValidationResult, type ValidationWarning, type VitestErrorLike, type VitestStackFrameLike, type VitestThresholdsInput, type WalkerEntry, type WalkerEntryStat, type WalkerFileSystem, resolveCoverageInput, validateCoverageConfig };
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ReporterLive } from "./layers/ReporterLive.js";
|
|
|
7
7
|
import { captureEnvVars } from "./utils/capture-env.js";
|
|
8
8
|
import { captureSettings, hashSettings } from "./utils/capture-settings.js";
|
|
9
9
|
import { processFailure } from "./utils/process-failure.js";
|
|
10
|
+
import { ConfigurationError } from "./utils/configuration-error.js";
|
|
10
11
|
import { AgentReporter } from "./reporter.js";
|
|
11
12
|
import { nodeWalkerFs } from "./utils/walker-fs.js";
|
|
12
13
|
import { findTestFiles } from "./utils/find-test-files.js";
|
|
@@ -29,4 +30,4 @@ const COVERAGE_LEVELS_PER_FILE = AgentPlugin.COVERAGE_LEVELS_PER_FILE;
|
|
|
29
30
|
const COVERAGE_AUTOUPDATE = AgentPlugin.COVERAGE_AUTOUPDATE;
|
|
30
31
|
|
|
31
32
|
//#endregion
|
|
32
|
-
export { AgentPlugin, AgentReporter, CONSOLE_REPORTERS, COVERAGE_AUTOUPDATE, COVERAGE_LEVELS, COVERAGE_LEVELS_PER_FILE, CURRENT_PLUGIN_VERSION, ConfigValidation, ConfigValidationLive, ConfigValidationTest, CoverageAnalyzer, CoverageAnalyzerLive, CoverageAnalyzerTest, CoverageLevel, DefaultDiscoverStrategy, DiscoverStrategy, ReporterLive, Tag, captureEnvVars, captureSettings, classifyByDirectory, classifyByFilename, combineClassifiers, discoverProjects, findTestFiles, hashSettings, nodeWalkerFs, processFailure, resolveCoverageInput, resolveThresholds, stripConsoleReporters, validateCoverageConfig };
|
|
33
|
+
export { AgentPlugin, AgentReporter, CONSOLE_REPORTERS, COVERAGE_AUTOUPDATE, COVERAGE_LEVELS, COVERAGE_LEVELS_PER_FILE, CURRENT_PLUGIN_VERSION, ConfigValidation, ConfigValidationLive, ConfigValidationTest, ConfigurationError, CoverageAnalyzer, CoverageAnalyzerLive, CoverageAnalyzerTest, CoverageLevel, DefaultDiscoverStrategy, DiscoverStrategy, ReporterLive, Tag, captureEnvVars, captureSettings, classifyByDirectory, classifyByFilename, combineClassifiers, discoverProjects, findTestFiles, hashSettings, nodeWalkerFs, processFailure, resolveCoverageInput, resolveThresholds, stripConsoleReporters, validateCoverageConfig };
|
|
@@ -8,7 +8,7 @@ import { Effect, Layer } from "effect";
|
|
|
8
8
|
/**
|
|
9
9
|
* Live implementation of the ConfigValidation service.
|
|
10
10
|
*
|
|
11
|
-
* Implements
|
|
11
|
+
* Implements the eight built-in validation rules.
|
|
12
12
|
* Rules run inside `validate(...)` — no I/O at construction time.
|
|
13
13
|
*
|
|
14
14
|
* @packageDocumentation
|
|
@@ -90,7 +90,7 @@ function runInvalidTargetValueRule(input, errors, warnings) {
|
|
|
90
90
|
});
|
|
91
91
|
for (const warn of result.warnings) if (warn.code === "PERFILE_ON_TARGETS") warnings.push({
|
|
92
92
|
code: "PERFILE_ON_TARGETS",
|
|
93
|
-
message: `
|
|
93
|
+
message: `A top-level "perFile" key should not be set inside coverageTargets. Set coverage.thresholds.perFile instead, or move it inside a glob-pattern entry — under Vitest 5 a glob-pattern entry carries its own perFile and no longer inherits the top-level one.`
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
@@ -119,6 +119,32 @@ function runMissingProviderPackageRule(input, errors) {
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
|
+
* Run the GITHUB_JOB_SUMMARY_COLLISION rule.
|
|
123
|
+
*
|
|
124
|
+
* Vitest 5's `github-actions` reporter writes a markdown job summary by
|
|
125
|
+
* default, and Vitest seeds the reporter from `configDefaults` under
|
|
126
|
+
* `GITHUB_ACTIONS=true`. The plugin normalizes every such entry to
|
|
127
|
+
* `jobSummary.enabled = false` in `configureVitest`, so a bare string or a
|
|
128
|
+
* tuple with no `jobSummary` key is NOT a collision. Only an explicit
|
|
129
|
+
* `jobSummary: { enabled: true }` survives normalization, and that entry
|
|
130
|
+
* collides with the plugin's own step summary under `ci-github`. Runs in
|
|
131
|
+
* both operating modes — it is a reporter concern, not a coverage concern.
|
|
132
|
+
*/
|
|
133
|
+
function runGithubJobSummaryCollisionRule(input, warnings) {
|
|
134
|
+
const reporters = input.vitestConfig.reporters;
|
|
135
|
+
if (!Array.isArray(reporters)) return;
|
|
136
|
+
if (!reporters.some((entry) => {
|
|
137
|
+
if (!Array.isArray(entry) || entry[0] !== "github-actions") return false;
|
|
138
|
+
return entry[1]?.jobSummary?.enabled === true;
|
|
139
|
+
})) return;
|
|
140
|
+
warnings.push({
|
|
141
|
+
code: "GITHUB_JOB_SUMMARY_COLLISION",
|
|
142
|
+
path: "reporters",
|
|
143
|
+
message: "You enabled the Vitest job summary explicitly on the \"github-actions\" reporter, and vitest-agent also writes a GitHub Actions step summary. Both summaries will appear in the same job.",
|
|
144
|
+
remediation: "Drop the explicit jobSummary option so the plugin can set { jobSummary: { enabled: false } } for you, or set console.ci to \"silent\" to suppress the plugin's own summary instead."
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
122
148
|
* Core validation logic. Runs all rules and accumulates results.
|
|
123
149
|
*/
|
|
124
150
|
function runAllRules(input) {
|
|
@@ -128,6 +154,7 @@ function runAllRules(input) {
|
|
|
128
154
|
const mode = resolveMode(input.vitestConfig);
|
|
129
155
|
runTargetThresholdRules(input, errors, warnings);
|
|
130
156
|
runInvalidTargetValueRule(input, errors, warnings);
|
|
157
|
+
runGithubJobSummaryCollisionRule(input, warnings);
|
|
131
158
|
if (mode === "full") {
|
|
132
159
|
runUnsupportedProviderRule(input, errors);
|
|
133
160
|
runMissingProviderPackageRule(input, errors);
|
|
@@ -39,6 +39,33 @@ function resolveEffectiveThresholds(filePath, resolved) {
|
|
|
39
39
|
return resolved.global;
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
+
* Resolve the per-file threshold override for a file path.
|
|
43
|
+
*
|
|
44
|
+
* Vitest 5 widened `perFile` to `boolean | MetricThresholds` and stopped
|
|
45
|
+
* letting a glob-pattern entry inherit the top-level setting. A matched
|
|
46
|
+
* pattern's own `perFile` is the ONLY per-file setting for that file — a
|
|
47
|
+
* pattern that declares none does not fall back to the top-level one. The
|
|
48
|
+
* top-level `perFile` applies only to files no pattern matches, mirroring
|
|
49
|
+
* Vitest 5.
|
|
50
|
+
*
|
|
51
|
+
* Only an OBJECT-valued setting changes anything here — it replaces the
|
|
52
|
+
* metric numbers used for the per-file check. `true` / `false` / absent
|
|
53
|
+
* all return `null`, which leaves the existing per-file reporting behavior
|
|
54
|
+
* (check against the effective metric thresholds) exactly as it was.
|
|
55
|
+
*/
|
|
56
|
+
function resolveEffectivePerFileThresholds(filePath, resolved) {
|
|
57
|
+
let setting;
|
|
58
|
+
let matched = false;
|
|
59
|
+
for (const [pattern, metrics] of resolved.patterns ?? []) if (matchGlob(filePath, pattern)) {
|
|
60
|
+
matched = true;
|
|
61
|
+
setting = metrics.perFile;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (!matched) setting = resolved.perFile;
|
|
65
|
+
if (setting === void 0 || typeof setting === "boolean") return null;
|
|
66
|
+
return setting;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
42
69
|
* Internal coverage processing logic. Shared by both `process` and `processScoped`.
|
|
43
70
|
*
|
|
44
71
|
* @param coverageMap - The value received by `onCoverage`; duck-typed at runtime
|
|
@@ -73,7 +100,7 @@ function processCoverageInternal(coverageMap, options, testedFiles) {
|
|
|
73
100
|
const isBareZero = fileStats.statements === 0 && fileStats.branches === 0 && fileStats.functions === 0 && fileStats.lines === 0;
|
|
74
101
|
if (isBareZero && !includeBareZero) continue;
|
|
75
102
|
if (scoped && !testedFileSet?.has(filePath)) continue;
|
|
76
|
-
const isBelowThreshold = isBelowMetricThresholds(fileStats, resolveEffectiveThresholds(filePath, options.thresholds));
|
|
103
|
+
const isBelowThreshold = isBelowMetricThresholds(fileStats, resolveEffectivePerFileThresholds(filePath, options.thresholds) ?? resolveEffectiveThresholds(filePath, options.thresholds));
|
|
77
104
|
if (isBareZero || isBelowThreshold) {
|
|
78
105
|
const uncoveredLines = compressLines(fileCoverage.getUncoveredLines());
|
|
79
106
|
lowCoverage.push({
|
package/layers/ReporterLive.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CoverageAnalyzerLive } from "./CoverageAnalyzerLive.js";
|
|
2
|
-
import { DataReaderLive, DataStoreLive, HistoryTrackerLive, LoggerLive, OutputPipelineLive,
|
|
2
|
+
import { DataReaderLive, DataStoreLive, HistoryTrackerLive, LoggerLive, OutputPipelineLive, PROJECT_MIGRATIONS } from "@vitest-agent/sdk";
|
|
3
3
|
import { Layer } from "effect";
|
|
4
4
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
5
5
|
import { layer } from "@effect/sql-sqlite-node/SqliteClient";
|
|
@@ -13,7 +13,7 @@ import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
|
|
|
13
13
|
const ReporterLive = (dbPath, logLevel, logFile) => {
|
|
14
14
|
const SqliteLayer = layer({ filename: dbPath });
|
|
15
15
|
const PlatformLayer = NodeServices.layer;
|
|
16
|
-
const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(
|
|
16
|
+
const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(PROJECT_MIGRATIONS) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
|
|
17
17
|
return Layer.mergeAll(DataStoreLive, CoverageAnalyzerLive, HistoryTrackerLive, OutputPipelineLive).pipe(Layer.provideMerge(DataReaderLive), Layer.provideMerge(MigratorLayer), Layer.provideMerge(SqliteLayer), Layer.provideMerge(PlatformLayer), Layer.provideMerge(LoggerLive(logLevel, logFile)));
|
|
18
18
|
};
|
|
19
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Vitest plugin for the vitest-agent ecosystem: owns persistence, classification, baselines, trends, and dispatches rendering to a configurable reporter.",
|
|
6
6
|
"keywords": [
|
|
@@ -41,18 +41,18 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@effect/platform-node": "4.0.0-rc.112",
|
|
43
43
|
"@effect/sql-sqlite-node": "4.0.0-rc.112",
|
|
44
|
-
"@effected/workspaces": "^0.20.
|
|
45
|
-
"@vitest-agent/cli": "2.2.
|
|
46
|
-
"@vitest-agent/mcp": "
|
|
47
|
-
"@vitest-agent/reporter": "
|
|
48
|
-
"@vitest-agent/sdk": "
|
|
44
|
+
"@effected/workspaces": "^0.20.1",
|
|
45
|
+
"@vitest-agent/cli": "2.2.16",
|
|
46
|
+
"@vitest-agent/mcp": "3.0.0",
|
|
47
|
+
"@vitest-agent/reporter": "3.0.0",
|
|
48
|
+
"@vitest-agent/sdk": "3.0.0",
|
|
49
49
|
"effect": "4.0.0-rc.112",
|
|
50
50
|
"magic-string": "^1.2.3"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@vitest/coverage-istanbul": "^
|
|
54
|
-
"@vitest/coverage-v8": "^
|
|
55
|
-
"vitest": "^
|
|
53
|
+
"@vitest/coverage-istanbul": "^5.0.0",
|
|
54
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
55
|
+
"vitest": "^5.0.0"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
58
|
"node": ">=24.11.0"
|
package/plugin.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ConfigValidation } from "./services/ConfigValidation.js";
|
|
2
2
|
import { resolveThresholds } from "./utils/resolve-thresholds.js";
|
|
3
3
|
import { ConfigValidationLive } from "./layers/ConfigValidationLive.js";
|
|
4
|
+
import { ConfigurationError } from "./utils/configuration-error.js";
|
|
5
|
+
import { assertFlatScope } from "./utils/report-writer.js";
|
|
4
6
|
import { AgentReporter } from "./reporter.js";
|
|
5
7
|
import { buildModuleInfo } from "./utils/build-module-info.js";
|
|
6
8
|
import { DefaultDiscoverStrategy } from "./utils/discover-strategy.js";
|
|
@@ -11,6 +13,7 @@ import { isBenignViteSourceMapWarning } from "./utils/is-benign-vite-source-map-
|
|
|
11
13
|
import { resolveCoverageDirIsolation } from "./utils/resolve-coverage-dir-isolation.js";
|
|
12
14
|
import { DEFAULT_BUILT_RECENTLY_MS, DEFAULT_LOCK_STALE_MS, DEFAULT_LOCK_WAIT_TIMEOUT_MS, acquireRunScriptLock, markRunScriptDone, parseLockTimingOverride, releaseRunScriptLock } from "./utils/run-script-lock.js";
|
|
13
15
|
import { stripConsoleReporters } from "./utils/strip-console-reporters.js";
|
|
16
|
+
import { makeTagCacheKeyGenerator } from "./utils/tag-cache-key.js";
|
|
14
17
|
import { execSync } from "node:child_process";
|
|
15
18
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
19
|
import { tmpdir } from "node:os";
|
|
@@ -98,6 +101,14 @@ function resolveFormat(mode) {
|
|
|
98
101
|
*/
|
|
99
102
|
const aggregatedReporterByVitest = /* @__PURE__ */ new WeakSet();
|
|
100
103
|
/**
|
|
104
|
+
* Per-Vitest-instance guard for the `fsModuleCache` cache-key generator.
|
|
105
|
+
* `configureVitest` fires once per project, but the generator is global to
|
|
106
|
+
* the Vitest instance — register it exactly once per run.
|
|
107
|
+
*
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
const cacheKeyGeneratorByVitest = /* @__PURE__ */ new WeakSet();
|
|
111
|
+
/**
|
|
101
112
|
* Guards the coverage.reportsDirectory isolation decision (issue #194) to
|
|
102
113
|
* run at most once per Vitest run — `configureVitest` fires once per
|
|
103
114
|
* project, but `coverage.reportsDirectory` is root-level config shared by
|
|
@@ -114,7 +125,7 @@ const coverageDirDecidedByVitest = /* @__PURE__ */ new WeakSet();
|
|
|
114
125
|
*
|
|
115
126
|
* @public
|
|
116
127
|
*/
|
|
117
|
-
const CURRENT_PLUGIN_VERSION = "
|
|
128
|
+
const CURRENT_PLUGIN_VERSION = "3.0.0";
|
|
118
129
|
const TEST_FILE_DIR_RE = new RegExp(`/(?:${SRC_DIR}|${TEST_DIR})/`);
|
|
119
130
|
const isTestFile = (id) => isTestFileName(id) && TEST_FILE_DIR_RE.test(id);
|
|
120
131
|
/**
|
|
@@ -169,6 +180,13 @@ function AgentPlugin(options = {}, _layer) {
|
|
|
169
180
|
const logLevel = resolveLogLevel();
|
|
170
181
|
const log = logLevel !== void 0 && logLevel !== "None" ? (...args) => process.stderr.write(`[vitest-agent:plugin] ${args.map(String).join(" ")}\n`) : (..._args) => {};
|
|
171
182
|
const discoverStrategyResolved = options.discoverStrategy === false ? null : options.discoverStrategy ?? new DefaultDiscoverStrategy();
|
|
183
|
+
const classifyForCache = (id) => {
|
|
184
|
+
if (!discoverStrategyResolved) return void 0;
|
|
185
|
+
const cleanId = id.split("?")[0] ?? id;
|
|
186
|
+
if (!isTestFile(cleanId)) return void 0;
|
|
187
|
+
const tags = discoverStrategyResolved.classify({ module: buildModuleInfo(cleanId) });
|
|
188
|
+
return tags.length === 0 ? void 0 : tags;
|
|
189
|
+
};
|
|
172
190
|
const pluginObj = {
|
|
173
191
|
name: "vitest-agent",
|
|
174
192
|
configResolved(resolvedConfig) {
|
|
@@ -178,8 +196,16 @@ function AgentPlugin(options = {}, _layer) {
|
|
|
178
196
|
try {
|
|
179
197
|
const { vitest, project } = ctx;
|
|
180
198
|
log("configureVitest called | project:", project?.name ?? "(root)");
|
|
199
|
+
if (discoverStrategyResolved && !cacheKeyGeneratorByVitest.has(vitest)) {
|
|
200
|
+
cacheKeyGeneratorByVitest.add(vitest);
|
|
201
|
+
ctx.defineCacheKeyGenerator(makeTagCacheKeyGenerator(classifyForCache));
|
|
202
|
+
log("registered fsModuleCache tag cache-key generator");
|
|
203
|
+
}
|
|
181
204
|
const env = await Effect.runPromise(Effect.provide(Effect.flatMap(EnvironmentDetector, (d) => d.detect()), layer));
|
|
182
205
|
const executor = envToExecutor(env);
|
|
206
|
+
const reportOption = options.report;
|
|
207
|
+
const reportScope = reportOption === false ? void 0 : executor === "human" && reportOption === void 0 ? void 0 : reportOption?.scope ?? "vitest-agent";
|
|
208
|
+
if (reportScope !== void 0) assertFlatScope(reportScope);
|
|
183
209
|
const consoleMode = resolveConsoleMode(options, executor, env);
|
|
184
210
|
const format = resolveFormat(consoleMode);
|
|
185
211
|
const mcp = executor === "agent";
|
|
@@ -195,7 +221,7 @@ function AgentPlugin(options = {}, _layer) {
|
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
223
|
if (env === "ci-github" && consoleMode !== "silent") {
|
|
198
|
-
log("
|
|
224
|
+
log("normalizing the github-actions reporter entry");
|
|
199
225
|
const withGithubActions = ensureGithubActionsReporter(vitest.config.reporters);
|
|
200
226
|
vitest.config.reporters = withGithubActions;
|
|
201
227
|
}
|
|
@@ -258,6 +284,7 @@ function AgentPlugin(options = {}, _layer) {
|
|
|
258
284
|
consoleMode,
|
|
259
285
|
mcp,
|
|
260
286
|
githubActions,
|
|
287
|
+
...reportScope !== void 0 && { reportScope },
|
|
261
288
|
transport,
|
|
262
289
|
...passWithNoTests !== void 0 ? { passWithNoTests } : {},
|
|
263
290
|
...options.reporter !== void 0 && { reporter: options.reporter },
|
|
@@ -267,20 +294,19 @@ function AgentPlugin(options = {}, _layer) {
|
|
|
267
294
|
aggregatedReporterByVitest.add(vitest);
|
|
268
295
|
log("reporters after push:", vitest.config.reporters.length);
|
|
269
296
|
} catch (err) {
|
|
297
|
+
if (err instanceof ConfigurationError) {
|
|
298
|
+
process.stderr.write(`${err.message}\n`);
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
270
301
|
process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
|
|
271
302
|
throw err;
|
|
272
303
|
}
|
|
273
304
|
}
|
|
274
305
|
};
|
|
275
306
|
if (discoverStrategyResolved) pluginObj.transform = (code, id) => {
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
const tags = discoverStrategyResolved.classify({ module });
|
|
280
|
-
if (tags.length === 0) return null;
|
|
281
|
-
const rewritten = injectTags(code, [...tags]);
|
|
282
|
-
if (rewritten === null) return null;
|
|
283
|
-
return rewritten;
|
|
307
|
+
const tags = classifyForCache(id);
|
|
308
|
+
if (tags === void 0) return null;
|
|
309
|
+
return injectTags(code, [...tags]);
|
|
284
310
|
};
|
|
285
311
|
return pluginObj;
|
|
286
312
|
}
|
|
@@ -340,9 +366,12 @@ function makeDiscoverBuilder(options) {
|
|
|
340
366
|
full: buildPreset(CoverageLevel.full, CoverageLevel.full, true)
|
|
341
367
|
});
|
|
342
368
|
_AgentPlugin.COVERAGE_AUTOUPDATE = Object.freeze({
|
|
343
|
-
standard: (
|
|
344
|
-
strict: (
|
|
345
|
-
lenient: (
|
|
369
|
+
standard: (next, _previous) => Math.floor(next),
|
|
370
|
+
strict: (next, _previous) => Math.ceil(next),
|
|
371
|
+
lenient: (next, previous) => {
|
|
372
|
+
const slack = Math.max(0, Math.floor(next - 2));
|
|
373
|
+
return typeof previous === "number" ? Math.max(previous, slack) : slack;
|
|
374
|
+
}
|
|
346
375
|
});
|
|
347
376
|
function discover(strategy) {
|
|
348
377
|
if (strategy === void 0) return makeDiscoverBuilder({});
|
package/reporter.js
CHANGED
|
@@ -6,9 +6,10 @@ import { captureEnvVars } from "./utils/capture-env.js";
|
|
|
6
6
|
import { captureSettings, hashSettings } from "./utils/capture-settings.js";
|
|
7
7
|
import { isPartialRun } from "./utils/is-partial-run.js";
|
|
8
8
|
import { processFailure } from "./utils/process-failure.js";
|
|
9
|
+
import { assertReportCapable, createReportWriter } from "./utils/report-writer.js";
|
|
9
10
|
import { routeRenderedOutput } from "./utils/route-rendered-output.js";
|
|
10
11
|
import { stringifyFailureValue } from "./utils/stringify-failure-value.js";
|
|
11
|
-
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
12
13
|
import { dirname } from "node:path";
|
|
13
14
|
import { DataReader, DataStore, DetailResolver, EnvironmentDetector, ExecutorResolver, FormatSelector, HistoryTracker, OutputPipelineLive, PathResolutionLive, buildAgentReport, coerceErrorField, computeTrend, ensureMigrated, formatFatalError, historyKey, isTimeoutError, probeHostMetadataFromEnv, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
|
|
14
15
|
import { Effect, Option, PubSub } from "effect";
|
|
@@ -89,6 +90,153 @@ function computeUpdatedBaselines(existing, actual, targets) {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
/**
|
|
93
|
+
* Size of a path-only attachment. Vitest has already rewritten `path`
|
|
94
|
+
* to its `.vitest/attachments/` location (or left an external URL), so
|
|
95
|
+
* a miss here is a dangling or remote descriptor, not an error.
|
|
96
|
+
*/
|
|
97
|
+
const attachmentPathByteSize = (path) => {
|
|
98
|
+
try {
|
|
99
|
+
return statSync(path).size;
|
|
100
|
+
} catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Read one property off a user-authored artifact or attachment object.
|
|
106
|
+
* Both carry arbitrary user data and may expose live getters that throw,
|
|
107
|
+
* so every read is guarded -- the same discipline `coerceErrorField`
|
|
108
|
+
* applies to error objects.
|
|
109
|
+
*/
|
|
110
|
+
const readField = (raw, key) => {
|
|
111
|
+
try {
|
|
112
|
+
return raw[key];
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Normalize one Vitest attachment onto a `TestAttachmentInput`.
|
|
119
|
+
*
|
|
120
|
+
* A `Uint8Array` body is base64-encoded and declared as such, matching
|
|
121
|
+
* what Vitest does for its own consumers. A string body is passed
|
|
122
|
+
* through with whatever encoding the producer declared. `byteSize` is
|
|
123
|
+
* always the size of the decoded payload: the raw array length, the
|
|
124
|
+
* base64-decoded length, the UTF-8 length of a text body, or the
|
|
125
|
+
* on-disk size of a path-only attachment.
|
|
126
|
+
*/
|
|
127
|
+
const toAttachmentInput = (att) => {
|
|
128
|
+
const raw = att;
|
|
129
|
+
const contentType = readField(raw, "contentType");
|
|
130
|
+
const path = readField(raw, "path");
|
|
131
|
+
const descriptor = {
|
|
132
|
+
...typeof contentType === "string" && { contentType },
|
|
133
|
+
...typeof path === "string" && { path }
|
|
134
|
+
};
|
|
135
|
+
const body = readField(raw, "body");
|
|
136
|
+
if (body instanceof Uint8Array) return {
|
|
137
|
+
...descriptor,
|
|
138
|
+
body: Buffer.from(body).toString("base64"),
|
|
139
|
+
bodyEncoding: "base64",
|
|
140
|
+
byteSize: body.byteLength
|
|
141
|
+
};
|
|
142
|
+
if (typeof body === "string") {
|
|
143
|
+
const bodyEncoding = readField(raw, "bodyEncoding") === "base64" ? "base64" : "utf-8";
|
|
144
|
+
return {
|
|
145
|
+
...descriptor,
|
|
146
|
+
body,
|
|
147
|
+
bodyEncoding,
|
|
148
|
+
byteSize: Buffer.byteLength(body, bodyEncoding === "base64" ? "base64" : "utf8")
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
...descriptor,
|
|
153
|
+
byteSize: typeof path === "string" ? attachmentPathByteSize(path) : 0
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const toAttachmentInputs = (attachments) => attachments.map(toAttachmentInput);
|
|
157
|
+
/**
|
|
158
|
+
* Strip the inline body off attachment inputs, leaving only the
|
|
159
|
+
* descriptor (`contentType`, `path`, `byteSize`).
|
|
160
|
+
*
|
|
161
|
+
* The run-event stream is a live channel a subscriber may buffer, log or
|
|
162
|
+
* forward; an inline body can be up to the 64 KiB cap per attachment, so
|
|
163
|
+
* events carry the descriptor and leave the bytes to the database. The
|
|
164
|
+
* persistence path keeps using `toAttachmentInputs`.
|
|
165
|
+
*/
|
|
166
|
+
const toAttachmentDescriptors = (attachments) => toAttachmentInputs(attachments).map(({ body: _body, bodyEncoding: _bodyEncoding, ...descriptor }) => descriptor);
|
|
167
|
+
/**
|
|
168
|
+
* Map Vitest annotations onto `DataStore.writeAnnotations` inputs.
|
|
169
|
+
* @internal
|
|
170
|
+
*/
|
|
171
|
+
const toAnnotationInputs = (testCaseId, annotations) => annotations.map((anno) => ({
|
|
172
|
+
testCaseId,
|
|
173
|
+
type: anno.type ?? "notice",
|
|
174
|
+
message: anno.message,
|
|
175
|
+
...anno.location !== void 0 && {
|
|
176
|
+
locationFile: anno.location.file,
|
|
177
|
+
locationLine: anno.location.line,
|
|
178
|
+
locationColumn: anno.location.column
|
|
179
|
+
},
|
|
180
|
+
attachments: toAttachmentInputs(anno.attachment !== void 0 ? [anno.attachment] : [])
|
|
181
|
+
}));
|
|
182
|
+
/**
|
|
183
|
+
* JSON-encode an artifact's custom fields, minus the ones modelled as
|
|
184
|
+
* their own columns. Returns `undefined` when there is nothing to store
|
|
185
|
+
* or when the object resists encoding -- a throwing getter surfaced by
|
|
186
|
+
* `Object.entries`, or a circular structure `JSON.stringify` rejects.
|
|
187
|
+
* Losing the `data` blob is acceptable; aborting the whole run's
|
|
188
|
+
* persistence over one malformed user artifact is not.
|
|
189
|
+
*/
|
|
190
|
+
const collectArtifactData = (raw, skipMessage) => {
|
|
191
|
+
try {
|
|
192
|
+
const custom = {};
|
|
193
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
194
|
+
if (key === "type" || key === "location" || key === "attachments") continue;
|
|
195
|
+
if (key === "message" && skipMessage) continue;
|
|
196
|
+
custom[key] = value;
|
|
197
|
+
}
|
|
198
|
+
if (Object.keys(custom).length === 0) return void 0;
|
|
199
|
+
return JSON.stringify(custom);
|
|
200
|
+
} catch {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Map Vitest test artifacts onto `DataStore.writeArtifacts` inputs.
|
|
206
|
+
*
|
|
207
|
+
* `internal:` is a Vitest-reserved type prefix; `internal:annotation`
|
|
208
|
+
* never reaches `artifacts()` anyway, but the guard keeps any future
|
|
209
|
+
* internal type out of `test_artifacts`.
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
const toArtifactInputs = (testCaseId, artifacts) => {
|
|
213
|
+
const out = [];
|
|
214
|
+
for (const raw of artifacts) {
|
|
215
|
+
const rawType = readField(raw, "type");
|
|
216
|
+
const type = typeof rawType === "string" ? rawType : "";
|
|
217
|
+
if (type === "" || type.startsWith("internal:")) continue;
|
|
218
|
+
const location = readField(raw, "location");
|
|
219
|
+
const rawAttachments = readField(raw, "attachments");
|
|
220
|
+
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
|
|
221
|
+
const message = readField(raw, "message");
|
|
222
|
+
const messageIsString = typeof message === "string";
|
|
223
|
+
const data = collectArtifactData(raw, messageIsString);
|
|
224
|
+
out.push({
|
|
225
|
+
testCaseId,
|
|
226
|
+
type,
|
|
227
|
+
...messageIsString && { message },
|
|
228
|
+
...data !== void 0 && { data },
|
|
229
|
+
...location !== void 0 && {
|
|
230
|
+
locationFile: location.file,
|
|
231
|
+
locationLine: location.line,
|
|
232
|
+
locationColumn: location.column
|
|
233
|
+
},
|
|
234
|
+
attachments: toAttachmentInputs(attachments)
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
92
240
|
* Vitest Reporter that produces structured output for LLM coding agents.
|
|
93
241
|
*
|
|
94
242
|
* @remarks
|
|
@@ -106,6 +254,10 @@ function computeUpdatedBaselines(existing, actual, targets) {
|
|
|
106
254
|
* results via Vitest's native `TestProject` API. In single-project mode,
|
|
107
255
|
* results are written with project name "default".
|
|
108
256
|
*
|
|
257
|
+
* Not to be confused with Vitest 5's own `AgentReporter` export from
|
|
258
|
+
* `vitest/node` (an alias of `MinimalReporter`) — this is
|
|
259
|
+
* `@vitest-agent/plugin`'s `AgentReporter`, an unrelated class.
|
|
260
|
+
*
|
|
109
261
|
* @privateRemarks
|
|
110
262
|
* The `onCoverage` hook fires **before** `onTestRunEnd` in Vitest's lifecycle.
|
|
111
263
|
* Coverage data must be stashed as instance state and merged during
|
|
@@ -157,6 +309,11 @@ var AgentReporter = class {
|
|
|
157
309
|
* @internal
|
|
158
310
|
*/
|
|
159
311
|
_vitest = null;
|
|
312
|
+
/**
|
|
313
|
+
* Lazily-created writer for `report`-targeted output. Null when
|
|
314
|
+
* `reportScope` is unset (report files disabled) or before `onInit`.
|
|
315
|
+
*/
|
|
316
|
+
reportWriter = null;
|
|
160
317
|
coverage = null;
|
|
161
318
|
logLevel;
|
|
162
319
|
logFile;
|
|
@@ -254,6 +411,7 @@ var AgentReporter = class {
|
|
|
254
411
|
githubActions,
|
|
255
412
|
githubSummary: githubActions,
|
|
256
413
|
githubSummaryFile: void 0,
|
|
414
|
+
...options.reportScope !== void 0 ? { reportScope: options.reportScope } : {},
|
|
257
415
|
...derivedFormat !== void 0 ? { format: derivedFormat } : {},
|
|
258
416
|
consoleMode,
|
|
259
417
|
...options.mcp !== void 0 ? { mcp: options.mcp } : {},
|
|
@@ -308,6 +466,10 @@ var AgentReporter = class {
|
|
|
308
466
|
*/
|
|
309
467
|
async onInit(vitest) {
|
|
310
468
|
this._vitest = vitest;
|
|
469
|
+
if (this.options.reportScope !== void 0) {
|
|
470
|
+
assertReportCapable(vitest);
|
|
471
|
+
this.reportWriter = createReportWriter(vitest, this.options.reportScope);
|
|
472
|
+
}
|
|
311
473
|
try {
|
|
312
474
|
await this.ensureDbPath();
|
|
313
475
|
} catch {}
|
|
@@ -741,7 +903,10 @@ var AgentReporter = class {
|
|
|
741
903
|
modulePath,
|
|
742
904
|
testName: testCase.name,
|
|
743
905
|
suitePath: this.collectSuitePath(testCase),
|
|
744
|
-
annotation: annotation.message
|
|
906
|
+
annotation: annotation.message,
|
|
907
|
+
annotationType: annotation.type ?? "notice",
|
|
908
|
+
...annotation.location !== void 0 && { location: annotation.location },
|
|
909
|
+
attachments: toAttachmentDescriptors(annotation.attachment !== void 0 ? [annotation.attachment] : [])
|
|
745
910
|
});
|
|
746
911
|
}
|
|
747
912
|
/**
|
|
@@ -751,12 +916,16 @@ var AgentReporter = class {
|
|
|
751
916
|
if (!this.wantsRunEvents()) return;
|
|
752
917
|
const modulePath = testCase.module?.relativeModuleId ?? "";
|
|
753
918
|
if (modulePath === "") return;
|
|
919
|
+
const type = artifact.type ?? "";
|
|
920
|
+
if (type === "" || type.startsWith("internal:")) return;
|
|
754
921
|
this.emit({
|
|
755
922
|
_tag: "TestArtifactRecorded",
|
|
756
923
|
modulePath,
|
|
757
924
|
testName: testCase.name,
|
|
758
925
|
suitePath: this.collectSuitePath(testCase),
|
|
759
|
-
artifact:
|
|
926
|
+
artifact: type,
|
|
927
|
+
...artifact.location !== void 0 && { location: artifact.location },
|
|
928
|
+
attachments: toAttachmentDescriptors(artifact.attachments ?? [])
|
|
760
929
|
});
|
|
761
930
|
}
|
|
762
931
|
/**
|
|
@@ -869,6 +1038,7 @@ var AgentReporter = class {
|
|
|
869
1038
|
const opts = this.options;
|
|
870
1039
|
const stashedCoverage = this.coverage;
|
|
871
1040
|
const stashedVitest = this._vitest;
|
|
1041
|
+
const reportWriter = this.reportWriter;
|
|
872
1042
|
const logLevel = this.logLevel;
|
|
873
1043
|
const logFile = this.logFile;
|
|
874
1044
|
const runEvents = this.runEvents;
|
|
@@ -964,11 +1134,15 @@ var AgentReporter = class {
|
|
|
964
1134
|
classifications: /* @__PURE__ */ new Map()
|
|
965
1135
|
};
|
|
966
1136
|
const allOutputs = reporters.flatMap((r) => r.render(renderInput, kit));
|
|
967
|
-
for (const output of allOutputs) routeRenderedOutput(output, {
|
|
1137
|
+
for (const output of allOutputs) routeRenderedOutput(output, {
|
|
1138
|
+
...githubSummaryFile !== void 0 && { githubSummaryFile },
|
|
1139
|
+
...reportWriter !== null && { writeReport: reportWriter.write }
|
|
1140
|
+
});
|
|
968
1141
|
});
|
|
969
1142
|
await Effect.runPromise(uiProgram.pipe(Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
|
|
970
1143
|
process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
|
|
971
1144
|
});
|
|
1145
|
+
await reportWriter?.flush();
|
|
972
1146
|
return;
|
|
973
1147
|
}
|
|
974
1148
|
if (dbPath !== void 0 && persistDisabled === void 0) try {
|
|
@@ -1167,8 +1341,8 @@ var AgentReporter = class {
|
|
|
1167
1341
|
let testIdx = 0;
|
|
1168
1342
|
for (const testCase of mod.children.allTests()) {
|
|
1169
1343
|
const result = testCase.result();
|
|
1344
|
+
const testCaseId = testCaseIds[testIdx];
|
|
1170
1345
|
if (result?.errors && result.errors.length > 0) {
|
|
1171
|
-
const testCaseId = testCaseIds[testIdx];
|
|
1172
1346
|
const inputs = [];
|
|
1173
1347
|
for (let ordinal = 0; ordinal < result.errors.length; ordinal++) {
|
|
1174
1348
|
const e = result.errors[ordinal];
|
|
@@ -1202,6 +1376,10 @@ var AgentReporter = class {
|
|
|
1202
1376
|
}
|
|
1203
1377
|
yield* store.writeErrors(runId, inputs);
|
|
1204
1378
|
}
|
|
1379
|
+
const annotationInputs = toAnnotationInputs(testCaseId, testCase.annotations?.() ?? []);
|
|
1380
|
+
if (annotationInputs.length > 0) yield* store.writeAnnotations(runId, annotationInputs);
|
|
1381
|
+
const artifactInputs = toArtifactInputs(testCaseId, testCase.artifacts?.() ?? []);
|
|
1382
|
+
if (artifactInputs.length > 0) yield* store.writeArtifacts(runId, artifactInputs);
|
|
1205
1383
|
testIdx++;
|
|
1206
1384
|
}
|
|
1207
1385
|
const modErrors = mod.errors();
|
|
@@ -1506,15 +1684,19 @@ var AgentReporter = class {
|
|
|
1506
1684
|
reporterCount: reporters.length,
|
|
1507
1685
|
outputs: allOutputs.length
|
|
1508
1686
|
}));
|
|
1509
|
-
for (const output of allOutputs) routeRenderedOutput(output, {
|
|
1687
|
+
for (const output of allOutputs) routeRenderedOutput(output, {
|
|
1688
|
+
...githubSummaryFile !== void 0 && { githubSummaryFile },
|
|
1689
|
+
...reportWriter !== null && { writeReport: reportWriter.write }
|
|
1690
|
+
});
|
|
1510
1691
|
});
|
|
1511
1692
|
await Effect.runPromise(renderProgram.pipe(Effect.annotateLogs("service", "reporter"), Effect.provide(OutputPipelineLive), Effect.provide(NodeServices.layer))).catch((err) => {
|
|
1512
1693
|
process.stderr.write(`vitest-agent: ${formatFatalError(err)}\n`);
|
|
1513
1694
|
});
|
|
1695
|
+
await reportWriter?.flush();
|
|
1514
1696
|
const persistError = persistDisabled ?? persistFailure;
|
|
1515
1697
|
if (persistError !== void 0) process.stderr.write(`vitest-agent: persistence failed — results above were rendered but NOT recorded: ${persistError}\n`);
|
|
1516
1698
|
}
|
|
1517
1699
|
};
|
|
1518
1700
|
|
|
1519
1701
|
//#endregion
|
|
1520
|
-
export { AgentReporter };
|
|
1702
|
+
export { AgentReporter, toAnnotationInputs, toArtifactInputs };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/utils/configuration-error.ts
|
|
2
|
+
/**
|
|
3
|
+
* A mistake in the user's own `AgentPlugin` configuration — a bad report
|
|
4
|
+
* scope, an unsafe report filename — as opposed to an internal failure of
|
|
5
|
+
* the plugin.
|
|
6
|
+
*
|
|
7
|
+
* `configureVitest` reports these as a single `vitest-agent: <message>`
|
|
8
|
+
* line on stderr, with no stack trace and no "please report an issue"
|
|
9
|
+
* banner: there is nothing to report, the user simply needs to fix the
|
|
10
|
+
* config. Every other throw keeps the `formatFatalError` treatment.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var ConfigurationError = class extends Error {
|
|
15
|
+
name = "ConfigurationError";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { ConfigurationError };
|
|
@@ -1,23 +1,50 @@
|
|
|
1
1
|
//#region src/utils/ensure-github-reporter.ts
|
|
2
2
|
/**
|
|
3
|
-
* Ensure a Vitest `reporters` array contains a `github-actions` entry
|
|
3
|
+
* Ensure a Vitest `reporters` array contains a `github-actions` entry with
|
|
4
|
+
* its markdown job summary disabled.
|
|
4
5
|
*
|
|
5
6
|
* @privateRemarks
|
|
6
|
-
* Vitest
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Under Vitest 5 `github-actions` is seeded into `configDefaults.reporters`
|
|
8
|
+
* whenever `GITHUB_ACTIONS=true`, and `resolveConfig` normalizes every bare
|
|
9
|
+
* reporter name to a `[name, {}]` tuple. So by the time `configureVitest`
|
|
10
|
+
* runs, the common CI case already holds `["github-actions", {}]` — an entry
|
|
11
|
+
* that still writes a markdown job summary, because the reporter's own
|
|
12
|
+
* default is `jobSummary.enabled = true`. The plugin writes its own step
|
|
13
|
+
* summary to `$GITHUB_STEP_SUMMARY` under `env === "ci-github"`, so leaving
|
|
14
|
+
* that entry alone produces two reports in the same job.
|
|
15
|
+
*
|
|
16
|
+
* This function therefore NORMALIZES an existing entry rather than only
|
|
17
|
+
* appending a missing one: a bare string or a tuple whose options leave
|
|
18
|
+
* `jobSummary.enabled` unset becomes
|
|
19
|
+
* `["github-actions", { jobSummary: { enabled: false } }]`, with every other
|
|
20
|
+
* option preserved. The plugin owns the summary, the reporter owns the
|
|
21
|
+
* `::error::` annotations. An explicit `jobSummary.enabled === true` is a
|
|
22
|
+
* deliberate opt-in and is left untouched; the `GITHUB_JOB_SUMMARY_COLLISION`
|
|
23
|
+
* ConfigValidation rule warns about that case instead.
|
|
11
24
|
*
|
|
12
25
|
* @internal
|
|
13
26
|
*/
|
|
14
27
|
function ensureGithubActionsReporter(reporters) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
let found = false;
|
|
29
|
+
const normalized = reporters.map((entry) => {
|
|
30
|
+
if (entry === "github-actions") {
|
|
31
|
+
found = true;
|
|
32
|
+
return ["github-actions", { jobSummary: { enabled: false } }];
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(entry) || entry[0] !== "github-actions") return entry;
|
|
35
|
+
found = true;
|
|
36
|
+
const options = entry[1] ?? {};
|
|
37
|
+
if (options.jobSummary?.enabled === true) return entry;
|
|
38
|
+
return ["github-actions", {
|
|
39
|
+
...options,
|
|
40
|
+
jobSummary: {
|
|
41
|
+
...options.jobSummary,
|
|
42
|
+
enabled: false
|
|
43
|
+
}
|
|
44
|
+
}];
|
|
45
|
+
});
|
|
46
|
+
if (found) return normalized;
|
|
47
|
+
return [...reporters, ["github-actions", { jobSummary: { enabled: false } }]];
|
|
21
48
|
}
|
|
22
49
|
|
|
23
50
|
//#endregion
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ConfigurationError } from "./configuration-error.js";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/report-writer.ts
|
|
4
|
+
/**
|
|
5
|
+
* Lazily-created writer over Vitest 5's `createReport` scope directory.
|
|
6
|
+
*
|
|
7
|
+
* `vitest.createReport(scope)` mkdirs `<config.root>/.vitest/<scope>`
|
|
8
|
+
* eagerly and synchronously at call time, so the handle is created on
|
|
9
|
+
* the first write rather than at reporter construction — a run that
|
|
10
|
+
* emits no report-targeted output leaves no directory behind.
|
|
11
|
+
*
|
|
12
|
+
* `clean()` is never called: it would wipe a prior shard's output, and
|
|
13
|
+
* it is a no-op under `--merge-reports` anyway.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
const VITEST_5_REQUIRED = "vitest-agent: writing report files requires Vitest 5's `createReport` API. Upgrade vitest to ^5.0.0, or set `AgentPlugin({ report: false })`.";
|
|
18
|
+
/**
|
|
19
|
+
* Narrow a Vitest instance to one carrying `createReport`, throwing the
|
|
20
|
+
* upgrade message when it does not.
|
|
21
|
+
*
|
|
22
|
+
* Called eagerly from `onInit` so an unsupported Vitest fails before any
|
|
23
|
+
* rendering happens, rather than mid-way through a routing loop. It only
|
|
24
|
+
* reads the property — `createReport` itself stays uncalled, preserving
|
|
25
|
+
* the lazy directory creation.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
const assertReportCapable = (vitest) => {
|
|
30
|
+
if (typeof vitest?.createReport !== "function") throw new Error(VITEST_5_REQUIRED);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Vitest's `Report.writeFile` resolves `filename` against the scope
|
|
34
|
+
* directory with no `mkdir` and no containment check, so a nested path
|
|
35
|
+
* rejects at write time and a `..` segment escapes the directory
|
|
36
|
+
* entirely. Reject both here, where the message can name the culprit.
|
|
37
|
+
*/
|
|
38
|
+
const assertFlatFilename = (filename) => {
|
|
39
|
+
if (filename.includes("/") || filename.includes("\\") || filename === ".." || filename === ".") throw new ConfigurationError(`vitest-agent: report filename ${filename} must be a flat name — Vitest writes it directly into the report scope directory and creates no intermediate directories.`);
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The report scope names a single directory under `<config.root>/.vitest`.
|
|
43
|
+
* Vitest joins it without a containment check, so a `..` segment or a
|
|
44
|
+
* path separator escapes `.vitest` entirely, and an empty scope resolves
|
|
45
|
+
* to `.vitest` itself. Reject all of those here, where the message can
|
|
46
|
+
* name the offending scope.
|
|
47
|
+
*
|
|
48
|
+
* A dot inside the name (`vitest.agent`) is fine — only the bare `.` and
|
|
49
|
+
* `..` traversal names are rejected.
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
const assertFlatScope = (scope) => {
|
|
54
|
+
if (scope === "" || scope.includes("/") || scope.includes("\\") || scope === "." || scope === "..") throw new ConfigurationError(`vitest-agent: report scope ${JSON.stringify(scope)} must be a single flat directory name — it is created directly under <root>/.vitest and must not traverse or nest.`);
|
|
55
|
+
};
|
|
56
|
+
/** @internal */
|
|
57
|
+
const createReportWriter = (vitest, scope) => {
|
|
58
|
+
let handle = null;
|
|
59
|
+
const pending = [];
|
|
60
|
+
const resolveHandle = () => {
|
|
61
|
+
if (handle !== null) return handle;
|
|
62
|
+
const create = vitest?.createReport;
|
|
63
|
+
if (typeof create !== "function") throw new Error(VITEST_5_REQUIRED);
|
|
64
|
+
handle = create.call(vitest, scope);
|
|
65
|
+
return handle;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
write: (filename, content) => {
|
|
69
|
+
assertFlatFilename(filename);
|
|
70
|
+
const report = resolveHandle();
|
|
71
|
+
pending.push(report.writeFile(filename, content).catch((err) => {
|
|
72
|
+
process.stderr.write(`vitest-agent: report file ${filename} not written: ${String(err)}\n`);
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
flush: async () => {
|
|
76
|
+
await Promise.all(pending);
|
|
77
|
+
pending.length = 0;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//#endregion
|
|
83
|
+
export { assertFlatScope, assertReportCapable, createReportWriter };
|
|
@@ -12,6 +12,25 @@ const RESERVED_KEYS = /* @__PURE__ */ new Set([
|
|
|
12
12
|
"autoUpdate"
|
|
13
13
|
]);
|
|
14
14
|
/**
|
|
15
|
+
* Extract the four metric numbers (plus the `100: true` shorthand) from a
|
|
16
|
+
* raw threshold-shaped record. Shared by the global, glob-pattern, and
|
|
17
|
+
* object-valued `perFile` paths.
|
|
18
|
+
*/
|
|
19
|
+
function extractMetrics(obj) {
|
|
20
|
+
const metrics = {};
|
|
21
|
+
if (obj["100"] === true) {
|
|
22
|
+
metrics.lines = 100;
|
|
23
|
+
metrics.functions = 100;
|
|
24
|
+
metrics.branches = 100;
|
|
25
|
+
metrics.statements = 100;
|
|
26
|
+
}
|
|
27
|
+
for (const mk of METRIC_KEYS) {
|
|
28
|
+
const mv = obj[mk];
|
|
29
|
+
if (typeof mv === "number") metrics[mk] = mv;
|
|
30
|
+
}
|
|
31
|
+
return metrics;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
15
34
|
* Parse Vitest `coverage.thresholds` format into a normalized `ResolvedThresholds`.
|
|
16
35
|
* @param input - The raw `coverage.thresholds` object from Vitest config
|
|
17
36
|
* @returns Normalized thresholds with global, perFile, and pattern entries
|
|
@@ -37,21 +56,14 @@ function resolveThresholds(input) {
|
|
|
37
56
|
if (typeof value === "number") global[key] = value;
|
|
38
57
|
}
|
|
39
58
|
if (input.perFile === true) perFile = true;
|
|
59
|
+
else if (typeof input.perFile === "object" && input.perFile !== null && !Array.isArray(input.perFile)) perFile = extractMetrics(input.perFile);
|
|
40
60
|
for (const [key, value] of Object.entries(input)) {
|
|
41
61
|
if (RESERVED_KEYS.has(key)) continue;
|
|
42
62
|
if (typeof value !== "object" || value === null) continue;
|
|
43
|
-
const patternMetrics = {};
|
|
44
63
|
const obj = value;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
64
|
+
const patternMetrics = extractMetrics(obj);
|
|
65
|
+
if (obj.perFile === true || obj.perFile === false) patternMetrics.perFile = obj.perFile;
|
|
66
|
+
else if (typeof obj.perFile === "object" && obj.perFile !== null && !Array.isArray(obj.perFile)) patternMetrics.perFile = extractMetrics(obj.perFile);
|
|
55
67
|
if (Object.keys(patternMetrics).length > 0) patterns.push([key, patternMetrics]);
|
|
56
68
|
}
|
|
57
69
|
return {
|
|
@@ -16,6 +16,11 @@ import { dirname } from "node:path";
|
|
|
16
16
|
* GitHub Actions and the user-supplied reporter shouldn't have produced
|
|
17
17
|
* one anyway."
|
|
18
18
|
*
|
|
19
|
+
* `report` outputs carry their own `filename` and go to the plugin's
|
|
20
|
+
* `writeReport` sink — Vitest 5's `createReport` scope directory. When
|
|
21
|
+
* report files are disabled the sink is absent and the output is
|
|
22
|
+
* dropped, mirroring the `github-summary` contract.
|
|
23
|
+
*
|
|
19
24
|
* `file` outputs require an explicit path embedded in the output (a
|
|
20
25
|
* convention reporters should adopt; this helper currently treats `file`
|
|
21
26
|
* as a no-op until we settle on a path field). The default reporter
|
|
@@ -37,6 +42,13 @@ const routeRenderedOutput = (output, options) => {
|
|
|
37
42
|
} catch {}
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
45
|
+
case "report":
|
|
46
|
+
try {
|
|
47
|
+
options.writeReport?.(output.filename, output.content);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
process.stderr.write(`vitest-agent: report file ${output.filename} not written: ${String(err)}\n`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
40
52
|
case "file": return;
|
|
41
53
|
}
|
|
42
54
|
};
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* These are the reporters suppressed when an agent takes over console output.
|
|
5
5
|
*
|
|
6
6
|
* @privateRemarks
|
|
7
|
-
* `"agent"`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `"agent"` and `"minimal"` both resolve to Vitest's `MinimalReporter`
|
|
8
|
+
* class. `"agent"` is the v4.1 spelling; `"minimal"` is the v5 spelling
|
|
9
|
+
* and is what `configDefaults.reporters` selects whenever `std-env`'s
|
|
10
|
+
* `isAgent` is true — which is the vitest-agent primary use case. Both
|
|
11
|
+
* are stripped because our reporter replaces their functionality with
|
|
12
|
+
* structured markdown output.
|
|
10
13
|
*
|
|
11
14
|
* @see {@link https://vitest.dev/api/advanced/reporters.html | Vitest Reporter docs}
|
|
12
15
|
* @internal
|
|
@@ -19,7 +22,8 @@ const CONSOLE_REPORTERS = /* @__PURE__ */ new Set([
|
|
|
19
22
|
"tap",
|
|
20
23
|
"tap-flat",
|
|
21
24
|
"hanging-process",
|
|
22
|
-
"agent"
|
|
25
|
+
"agent",
|
|
26
|
+
"minimal"
|
|
23
27
|
]);
|
|
24
28
|
/**
|
|
25
29
|
* Filter out built-in console reporters from a Vitest reporters array.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/utils/tag-cache-key.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cache-key material for Vitest 5's `fsModuleCache`.
|
|
4
|
+
*
|
|
5
|
+
* The tag-injection transform's output depends on the classification tag
|
|
6
|
+
* set, which Vitest cannot see. Returning this string from a
|
|
7
|
+
* `defineCacheKeyGenerator` callback folds the tag set into the module
|
|
8
|
+
* cache key, so a changed tag set invalidates the cached prelude.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
function tagCacheKey(tags) {
|
|
13
|
+
return `vitest-agent:tags:${[...tags].sort().join(",")}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Builds the `defineCacheKeyGenerator` callback from the same per-id
|
|
17
|
+
* classification the `transform` hook uses. Ids the transform would not
|
|
18
|
+
* rewrite contribute nothing to the cache key.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
function makeTagCacheKeyGenerator(classify) {
|
|
23
|
+
return (context) => {
|
|
24
|
+
const tags = classify(context.id);
|
|
25
|
+
return tags === void 0 ? void 0 : tagCacheKey(tags);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { makeTagCacheKeyGenerator, tagCacheKey };
|