Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ee91c87
feat: add canyonjs-dev-report integration and local HTML coverage rep…
humengjing07 Aug 24, 2026
19e5ad5
chore(deps): bump canyonjs-dev-report to ^1.0.18
humengjing07 Aug 24, 2026
13f55d5
update
humengjing07 Aug 24, 2026
9eabbea
Merge branch 'main' into dev
travzhang Aug 26, 2026
f8376f5
chore: remove canyonjs-dev-report integration and related files
travzhang Aug 26, 2026
5a42a50
chore: introduce modern HTML coverage report
travzhang Aug 26, 2026
b25d485
Merge pull request #3 from travzhang/feat/istanbul-report-html-modern
travzhang Aug 26, 2026
319e561
refactor: rename options variable and clean up path handling functions
travzhang Sep 2, 2026
5d5d37d
chore: configure server port for playground environment
travzhang Sep 2, 2026
b6ce9e4
refactor: reorganize project structure and update imports for modern …
travzhang Sep 2, 2026
d74c4e0
refactor: remove unused CSS and HTML language registrations from Mona…
travzhang Sep 2, 2026
0a31ef5
Merge pull request #4 from travzhang/feat/html-modern
travzhang Sep 2, 2026
0e27a45
refactor: update typecheck script and exclude modern HTML report from…
travzhang Sep 3, 2026
640a61d
Merge pull request #5 from travzhang/feat/html-modern
travzhang Sep 3, 2026
3812aaa
Merge upstream vitest-dev/istanbuljs main into dev
travzhang Sep 14, 2026
f4dd6c5
feat(html-modern): add fileTags filtering and migrate UI to Preact/Co…
travzhang Sep 15, 2026
70a4f34
chore: add package fileTags for html-modern coverage report
travzhang Sep 15, 2026
5350965
chore(html-modern): update package author to Travis Zhang
travzhang Sep 15, 2026
ed5c9e1
chore(html-modern): point report footer credit to vitest
travzhang Sep 15, 2026
06b8175
fix: fmt
travzhang Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ dist
lerna-debug.log
packages/*/yarn.lock
packages/nyc/

.DS_Store
.pnpm-store
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"test": "vitest",
"fmt": "oxfmt",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit && pnpm -r --if-present run typecheck"
},
"devDependencies": {
"@e18e/eslint-plugin": "catalog:",
Expand Down
5 changes: 4 additions & 1 deletion packages/istanbul-lib-report/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@
"test": "pnpm --filter @vitest/istanbuljs-monorepo run test --project @vitest/istanbul-lib-report"
},
"dependencies": {
"@vitest/istanbul-lib-coverage": "workspace:*"
"@vitest/istanbul-lib-coverage": "workspace:*",
"picomatch": "catalog:"
},
"devDependencies": {
"@types/picomatch": "catalog:",
"@vitest/browser-playwright": "catalog:",
"@vitest/istanbul-report-html-modern": "workspace:*",
"playwright": "catalog:",
"preact": "catalog:",
"tsdown": "catalog:"
Expand Down
22 changes: 22 additions & 0 deletions packages/istanbul-lib-report/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import CloverReport from "./reports/clover/index";
import type { CloverOptions } from "./reports/clover/index";
import CoberturaReport from "./reports/cobertura/index";
import type { CoberturaOptions } from "./reports/cobertura/index";
import HtmlModernReport from "./reports/html-modern/index";
import type { HtmlModernOptions } from "./reports/html-modern/index";
import HtmlSpaReport from "./reports/html-spa/index";
import type { HtmlSpaOptions } from "./reports/html-spa/index";
import HtmlReport from "./reports/html/index";
Expand Down Expand Up @@ -49,6 +51,24 @@ export type { ReportBaseOptions } from "./report-base";
export type { CloverOptions } from "./reports/clover/index";
export type { CoberturaOptions } from "./reports/cobertura/index";
export type { HtmlSpaOptions } from "./reports/html-spa/index";
export type {
CoverageData,
GenerateOptions,
GenerateResult,
HtmlModernOptions,
IstanbulReportContext,
ReportData,
ReportStats,
SerializableHtmlModernOptions,
} from "./reports/html-modern/index";
export {
CoverageReport,
collectAvailableTags,
extractIstanbulContext,
inferProjectRoot,
resolveFileTags,
} from "./reports/html-modern/index";
export { default as HtmlModernReport } from "./reports/html-modern/index";
export type { HtmlOptions, LinkMapper } from "./reports/html/index";
export type { JsonSummaryOptions } from "./reports/json-summary/index";
export type { JsonOptions } from "./reports/json/index";
Expand Down Expand Up @@ -97,6 +117,7 @@ const reports = {
clover: CloverReport,
cobertura: CoberturaReport,
html: HtmlReport,
"html-modern": HtmlModernReport,
"html-spa": HtmlSpaReport,
json: JsonReport,
"json-summary": JsonSummaryReport,
Expand All @@ -114,6 +135,7 @@ export interface ReportOptions {
clover: CloverOptions;
cobertura: CoberturaOptions;
html: HtmlOptions;
"html-modern": HtmlModernOptions;
"html-spa": HtmlSpaOptions;
json: JsonOptions;
"json-summary": JsonSummaryOptions;
Expand Down
4 changes: 4 additions & 0 deletions packages/istanbul-lib-report/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ class Path {
return new Path(ret);
}

static findCommonParent(paths: Path[]): Path {
return paths.reduce((common, path) => common.commonPrefixPath(path), paths[0] ?? new Path([]));
}

static compare(a: Path, b: Path): number {
const al = a.length;
const bl = b.length;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { gzipSync } from "node:zlib";

export function compress(jsonstring: string): string {
const gzip = gzipSync(jsonstring, { level: 9 });
return gzip.toString("base64");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from "node:fs";
import { createRequire } from "node:module";

import { resolveProjectRoot } from "./infer-project-root";
import type { HtmlModernOptions } from "./options";
import { toRelativePath } from "./paths";
import { resolveFileTags } from "./resolve-file-tags";
import type {
CoverageData,
GenerateOptions,
GenerateResult,
IstanbulReportContext,
ReportData,
SerializableHtmlModernOptions,
} from "./types";
import { resolveReportHtmlDist, writeHtmlReport, writeReportDataJson } from "./write-report";

const require = createRequire(import.meta.url);
const { name: packageName, version: packageVersion } = require(
require.resolve("@vitest/istanbul-report-html-modern/package.json"),
) as {
name: string;
version: string;
};

function serializeHtmlOptions(options: HtmlModernOptions): SerializableHtmlModernOptions {
const html: SerializableHtmlModernOptions = {};

if (options.verbose !== undefined) {
html.verbose = options.verbose;
}
if (options.subdir !== undefined) {
html.subdir = options.subdir;
}
if (options.skipEmpty !== undefined) {
html.skipEmpty = options.skipEmpty;
}
if (options.metricsToShow !== undefined) {
html.metricsToShow = options.metricsToShow;
}
if (options.projectRoot !== undefined) {
html.projectRoot = options.projectRoot;
}
if (options.writeReportDataJson !== undefined) {
html.writeReportDataJson = options.writeReportDataJson;
}
if (options.fileTags !== undefined) {
html.fileTags = options.fileTags;
}

return html;
}

export class CoverageReport {
private htmlOptions: HtmlModernOptions;

constructor(htmlOptions: HtmlModernOptions = {}) {
this.htmlOptions = htmlOptions;
}

buildReportData(
coverage: CoverageData,
sourceFinder: (filePath: string) => string,
istanbul: IstanbulReportContext,
): ReportData {
const sources: Record<string, string> = {};

for (const filePath of Object.keys(coverage)) {
try {
sources[filePath] = sourceFinder(filePath);
} catch {
// skip files whose source cannot be resolved
}
}

const projectRoot = resolveProjectRoot(Object.keys(coverage), this.htmlOptions.projectRoot);
const relativePaths = Object.keys(coverage).map((filePath) =>
toRelativePath(filePath, projectRoot),
);
const fileTagsByPath = resolveFileTags(relativePaths, this.htmlOptions.fileTags, {
verbose: this.htmlOptions.verbose,
});

return {
html: serializeHtmlOptions(this.htmlOptions),
istanbul,
stats: {
coverageFileCount: Object.keys(coverage).length,
sourceFileCount: Object.keys(sources).length,
},
projectRoot,
coverage,
sources,
fileTagRules: this.htmlOptions.fileTags,
fileTagsByPath: Object.keys(fileTagsByPath).length > 0 ? fileTagsByPath : undefined,
generatedAt: new Date().toISOString(),
packageName,
packageVersion,
};
}

async generate({
coverage,
targetDir,
sourceFinder,
istanbul,
}: GenerateOptions): Promise<GenerateResult> {
const reportData = this.buildReportData(coverage, sourceFinder, istanbul);
fs.mkdirSync(targetDir, { recursive: true });

let htmlReportPath: string | undefined;
const reportHtmlDist = resolveReportHtmlDist();
if (reportHtmlDist) {
htmlReportPath = writeHtmlReport(reportData, targetDir, reportHtmlDist);
if (this.htmlOptions.verbose) {
console.log(`HTML report written to ${htmlReportPath}`);
}
} else if (this.htmlOptions.verbose) {
console.log(
"@vitest/istanbul-report-html-modern dist/page not found, skipping HTML report generation",
);
}

if (this.htmlOptions.writeReportDataJson) {
const reportDataJsonPath = writeReportDataJson(reportData, targetDir);
if (this.htmlOptions.verbose) {
console.log(`Report data written to ${reportDataJsonPath}`);
}
}

return htmlReportPath !== undefined ? { reportData, htmlReportPath } : { reportData };
}
}
25 changes: 25 additions & 0 deletions packages/istanbul-lib-report/src/reports/html-modern/file-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** glob rule that assigns a tag to matching project-relative file paths */
export interface FileTagRule {
/** glob relative to project root, e.g. `src/payments/**` */
glob: string;
/** display tag, e.g. `payments` or `P0` */
tag: string;
}

/** Unique tag names in config order. */
export function collectAvailableTags(rules: FileTagRule[] | undefined): string[] {
if (rules === undefined || rules.length === 0) {
return [];
}

const seen = new Set<string>();
const tags: string[] = [];
for (const rule of rules) {
if (seen.has(rule.tag)) {
continue;
}
seen.add(rule.tag);
tags.push(rule.tag);
}
return tags;
}
62 changes: 62 additions & 0 deletions packages/istanbul-lib-report/src/reports/html-modern/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
Copyright 2012-2015, Yahoo Inc.
Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
*/
import type { FileCoverageData } from "@vitest/istanbul-lib-coverage";

import type { Context, ReportBaseOptions, ReportNode, Summarizers } from "../../index";
import ReportBase from "../../report-base";
import { CoverageReport } from "./coverage-report";
import { extractIstanbulContext } from "./istanbul-context";
import type { HtmlModernOptions } from "./options";

export type { LinkMapper } from "../html/index";
export type { FileTagRule, HtmlModernOptions } from "./options";
export { collectAvailableTags, resolveFileTags } from "./resolve-file-tags";
export type {
CoverageData,
GenerateOptions,
GenerateResult,
IstanbulReportContext,
ReportData,
ReportStats,
SerializableHtmlModernOptions,
} from "./types";
export { CoverageReport } from "./coverage-report";
export { extractIstanbulContext } from "./istanbul-context";
export { inferProjectRoot, resolveProjectRoot } from "./infer-project-root";

class HtmlModernReport extends ReportBase {
private htmlOptions: HtmlModernOptions;
private coverage: Record<string, FileCoverageData> = {};
private summarizer?: Summarizers;

constructor(opts: HtmlModernOptions & Partial<ReportBaseOptions> = {}) {
super(opts);
this.htmlOptions = opts;
if (opts.summarizer !== undefined) {
this.summarizer = opts.summarizer;
}
}

/** Called for each file node during tree traversal; stores a deep clone keyed by path. */
onDetail(node: ReportNode): void {
const fileCoverage: FileCoverageData = JSON.parse(
JSON.stringify(node.getFileCoverage().toJSON()),
);
this.coverage[fileCoverage.path] = fileCoverage;
}

/** Called after traversal completes; generates the HTML report via {@link CoverageReport}. */
async onEnd(_rootNode: ReportNode, context: Context): Promise<void> {
const coverageReport = new CoverageReport(this.htmlOptions);
await coverageReport.generate({
coverage: this.coverage,
targetDir: context.dir,
sourceFinder: context.sourceFinder,
istanbul: extractIstanbulContext(context, this.summarizer),
});
}
}

export default HtmlModernReport;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Path from "../../path";

/** Map a coverage file path to {@link Path}, including Windows drive paths on any platform. */
function filePathToPath(filePath: string): Path {
const normalized = filePath.replaceAll("\\", "/");
if (/^[A-Za-z]:(?:\/|$)/.test(normalized)) {
const withoutDrive = normalized.replace(/^[A-Za-z]:\/?/, "");
const segments = withoutDrive ? withoutDrive.split("/").filter(Boolean) : [];
return new Path(segments);
}

return new Path(filePath);
}

/** Restore a filesystem path prefix dropped by {@link Path} (POSIX `/` or Windows drive). */
function formatProjectRoot(common: Path, filePaths: string[]): string {
const relative = common.toString();
const sample = filePaths[0]!.replaceAll("\\", "/");

const driveMatch = sample.match(/^([A-Za-z]:)(?:\/|$)/);
if (driveMatch) {
return relative ? `${driveMatch[1]}/${relative}` : driveMatch[1];
}

if (sample.startsWith("/")) {
return relative ? `/${relative}` : "/";
}

return relative;
}

/**
* Infer project root from coverage file paths: the common parent directory
* of all file paths (same algorithm as SummarizerFactory `_commonParent`).
* Returns `undefined` when there is no useful shared prefix.
*/
export function inferProjectRoot(filePaths: string[]): string | undefined {
if (filePaths.length === 0) {
return undefined;
}

const parentPaths = filePaths.map((filePath) => filePathToPath(filePath).parent());
const common = Path.findCommonParent(parentPaths);
if (common.length === 0) {
return undefined;
}

return formatProjectRoot(common, filePaths);
}

export function resolveProjectRoot(filePaths: string[], explicit?: string): string {
if (explicit !== undefined && explicit !== "") {
return explicit;
}

return inferProjectRoot(filePaths) || process.cwd();
}
Loading
Loading