Skip to content

Commit 2b90175

Browse files
committed
Add vitest coverage provider
1 parent e6ac94b commit 2b90175

File tree

4 files changed

+207
-4
lines changed

4 files changed

+207
-4
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"devDependencies": {
2929
"@stylistic/eslint-plugin": "*",
3030
"@types/istanbul-lib-coverage": "^2.0.6",
31+
"@types/istanbul-reports": "^3.0.4",
3132
"@types/moo": "^0.5.10",
3233
"@types/node": "*",
3334
"@types/unist": "^3.0.3",
@@ -42,6 +43,7 @@
4243
"@hyperjump/uri": "^1.3.1",
4344
"istanbul-lib-coverage": "^3.2.2",
4445
"moo": "^0.5.2",
46+
"pathe": "^2.0.3",
4547
"vfile": "^6.0.3"
4648
}
4749
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { CoverageProviderModule } from "vitest";
2+
3+
export default CoverageProviderModule;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { existsSync, readdirSync } from "node:fs";
2+
import * as fs from "node:fs/promises";
3+
import { createCoverageMap } from "istanbul-lib-coverage";
4+
import { resolve } from "pathe";
5+
import c from "tinyrainbow";
6+
import libReport from "istanbul-lib-report";
7+
import reports from "istanbul-reports";
8+
import { coverageConfigDefaults } from "vitest/config";
9+
10+
/**
11+
* @import { BaseCoverageOptions, CoverageProvider, CoverageProviderModule, ResolvedCoverageOptions, Vitest} from "vitest"
12+
* @import { CoverageMap, CoverageMapData } from "istanbul-lib-coverage"
13+
*/
14+
15+
/** @type CoverageProviderModule */
16+
const JsonSchemaCoverageProviderModule = {
17+
/** @type CoverageProviderModule["getProvider"] */
18+
getProvider() {
19+
return new JsonSchemaCoverageProvider();
20+
}
21+
};
22+
23+
/** @implements CoverageProvider */
24+
class JsonSchemaCoverageProvider {
25+
name = "../src/vitest/json-schema-coverage-provider.js";
26+
27+
ctx = /** @type Vitest */ ({});
28+
options = /** @type ResolvedCoverageOptions<"custom"> */ ({});
29+
coverageFilesDirectory = "";
30+
31+
/** @type CoverageProvider["initialize"] */
32+
initialize(ctx) {
33+
this.ctx = ctx;
34+
35+
const config = ctx.config.coverage;
36+
37+
/** @type ResolvedCoverageOptions<"custom"> */
38+
this.options = {
39+
...coverageConfigDefaults,
40+
41+
// User's options
42+
...config,
43+
44+
// Resolved fields
45+
provider: "custom",
46+
customProviderModule: this.name,
47+
reportsDirectory: resolve(
48+
ctx.config.root,
49+
config.reportsDirectory || coverageConfigDefaults.reportsDirectory
50+
),
51+
reporter: resolveCoverageReporters(config.reporter || coverageConfigDefaults.reporter),
52+
extension: ".schema.json"
53+
};
54+
55+
this.coverageFilesDirectory = "./.json-schema-coverage";
56+
}
57+
58+
/** @type CoverageProvider["resolveOptions"] */
59+
resolveOptions() {
60+
return /** @type NonNullable<any> */ (this.options);
61+
}
62+
63+
/** @type CoverageProvider["clean"] */
64+
async clean(clean = true) {
65+
if (clean && existsSync(this.options.reportsDirectory)) {
66+
await fs.rm(this.options.reportsDirectory, {
67+
recursive: true,
68+
force: true,
69+
maxRetries: 10
70+
});
71+
}
72+
73+
if (existsSync(this.coverageFilesDirectory)) {
74+
await fs.rm(this.coverageFilesDirectory, {
75+
recursive: true,
76+
force: true,
77+
maxRetries: 10
78+
});
79+
}
80+
81+
await fs.mkdir(this.coverageFilesDirectory, { recursive: true });
82+
}
83+
84+
/** @type () => Promise<void> */
85+
async cleanAfterRun() {
86+
await fs.rm(this.coverageFilesDirectory, { recursive: true });
87+
88+
// Remove empty reports directory, e.g. when only text-reporter is used
89+
if (readdirSync(this.options.reportsDirectory).length === 0) {
90+
await fs.rm(this.options.reportsDirectory, { recursive: true });
91+
}
92+
}
93+
94+
/** @type CoverageProvider["reportCoverage"] */
95+
async reportCoverage(coverageMap) {
96+
this.generateReports(/** @type CoverageMap */ (coverageMap) ?? this.createCoverageMap());
97+
98+
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
99+
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch;
100+
101+
if (!keepResults) {
102+
await this.cleanAfterRun();
103+
}
104+
}
105+
106+
/** @type (coverageMap: CoverageMap) => void */
107+
generateReports(coverageMap) {
108+
const context = libReport.createContext({
109+
dir: this.options.reportsDirectory,
110+
coverageMap
111+
});
112+
113+
if (this.hasTerminalReporter(this.options.reporter)) {
114+
this.ctx.logger.log(
115+
c.blue(" % ") + c.dim("Coverage report from ") + c.yellow(this.name)
116+
);
117+
}
118+
119+
for (const reporter of this.options.reporter) {
120+
// Type assertion required for custom reporters
121+
reports
122+
.create(/** @type Parameters<typeof reports.create>[0] */ (reporter[0]), {
123+
projectRoot: this.ctx.config.root,
124+
...reporter[1]
125+
})
126+
.execute(context);
127+
}
128+
}
129+
130+
/** @type (reporters: ResolvedCoverageOptions["reporter"])=> boolean */
131+
hasTerminalReporter(reporters) {
132+
return reporters.some(
133+
([reporter]) =>
134+
reporter === "text"
135+
|| reporter === "text-summary"
136+
|| reporter === "text-lcov"
137+
|| reporter === "teamcity"
138+
);
139+
}
140+
141+
/** @type () => CoverageMap */
142+
createCoverageMap() {
143+
return createCoverageMap({});
144+
}
145+
146+
/** @type CoverageProvider["onAfterSuiteRun"] */
147+
onAfterSuiteRun() {
148+
// The method is required by the interface, but doesn't seem to ever be called
149+
throw Error("Not Implemented");
150+
}
151+
152+
/** @type CoverageProvider["generateCoverage"] */
153+
async generateCoverage() {
154+
const coverageMap = this.createCoverageMap();
155+
156+
for (const file of await fs.readdir(this.coverageFilesDirectory, { recursive: true, withFileTypes: true })) {
157+
const path = resolve(file.parentPath, file.name);
158+
/** @type CoverageMapData */
159+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
160+
const coverage = JSON.parse(await fs.readFile(path, "utf-8"));
161+
coverageMap.merge(coverage);
162+
}
163+
164+
return coverageMap;
165+
}
166+
}
167+
168+
/** @type (configReporters: NonNullable<BaseCoverageOptions["reporter"]>) => [string, unknown][] */
169+
const resolveCoverageReporters = (configReporters) => {
170+
// E.g. { reporter: "html" }
171+
if (!Array.isArray(configReporters)) {
172+
return [[configReporters, {}]];
173+
}
174+
175+
/** @type [string, unknown][] */
176+
const resolvedReporters = [];
177+
178+
for (const reporter of configReporters) {
179+
if (Array.isArray(reporter)) {
180+
// E.g. { reporter: [ ["html", { skipEmpty: true }], ["lcov"], ["json", { file: "map.json" }] ]}
181+
resolvedReporters.push([reporter[0], /** @type Record<string, unknown> */ (reporter[1]) ?? {}]);
182+
} else {
183+
// E.g. { reporter: ["html", "json"]}
184+
resolvedReporters.push([reporter, {}]);
185+
}
186+
}
187+
188+
return resolvedReporters;
189+
};
190+
191+
export default JsonSchemaCoverageProviderModule;

src/vitest/json-schema-matcher.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from "node:crypto";
2+
import { existsSync } from "node:fs";
23
import { writeFile } from "node:fs/promises";
34
import { expect } from "vitest";
45
import { registerSchema, unregisterSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
@@ -18,15 +19,18 @@ expect.extend({
1819
/** @type OutputUnit */
1920
let output;
2021

21-
const testCoveragePlugin = new TestCoverageEvaluationPlugin();
22+
const isCoverageEnabled = existsSync(".json-schema-coverage");
23+
const plugins = isCoverageEnabled
24+
? [new TestCoverageEvaluationPlugin()]
25+
: [];
2226

2327
if (typeof uriOrSchema === "string") {
2428
const uri = uriOrSchema;
2529

2630
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
2731
output = await validate(uri, instance, {
2832
outputFormat: BASIC,
29-
plugins: [testCoveragePlugin]
33+
plugins: plugins
3034
});
3135
} else {
3236
const schema = uriOrSchema;
@@ -36,14 +40,17 @@ expect.extend({
3640
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
3741
output = await validate(uri, instance, {
3842
outputFormat: BASIC,
39-
plugins: [testCoveragePlugin]
43+
plugins: plugins
4044
});
4145
} finally {
4246
unregisterSchema(uri);
4347
}
4448
}
4549

46-
await writeFile(`.nyc_output/${randomUUID()}.json`, JSON.stringify(testCoveragePlugin.coverageMap, null, " "));
50+
if (isCoverageEnabled) {
51+
const testCoveragePlugin = plugins[0];
52+
await writeFile(`.json-schema-coverage/${randomUUID()}.json`, JSON.stringify(testCoveragePlugin.coverageMap, null, " "));
53+
}
4754

4855
return {
4956
pass: output.valid,

0 commit comments

Comments
 (0)