Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions packages/istanbul-lib-report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,27 @@ const report = libReport.create("json", {
// call execute to synchronously create and write the report to disk
report.execute(context);
```

### Custom reporters

`create()` only knows the built-in reporters. To load a custom reporter use`createAsync()`, which accepts a built-in name, a package name, an absolute path or a `file://` URL.
The module's default export (ESM) or `module.exports` (CommonJS) must be the report class:

```js
import { createAsync, ReportBase } from "@vitest/istanbul-lib-report";

// my-report.mjs
export default class MyReport extends ReportBase {
onStart(root, context) {
this.writer = context.writer.writeFile("my-report.txt");
}
onEnd() {
this.writer.close();
}
}

const report = await createAsync("/absolute/path/to/my-report.mjs", {
summarizer: "nested",
});
report.execute(context);
```
67 changes: 58 additions & 9 deletions packages/istanbul-lib-report/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* @module Exports
*/

import { createRequire } from "node:module";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";

import Context from "./context";
import type { ContextOptions } from "./context";
Expand Down Expand Up @@ -128,19 +129,67 @@ export interface ReportOptions {
/** names of the built-in reports */
export type ReportType = keyof ReportOptions;

type ReportConstructor = new (cfg: object) => ReportBase;

function getBuiltinReport(name: string): ReportConstructor | undefined {
return Object.hasOwn(reports, name)
? (reports as Record<string, ReportConstructor>)[name]
: undefined;
}

function unwrapDefault(mod: unknown): unknown {
return typeof mod === "object" && mod !== null && "default" in mod ? mod.default : mod;
}

/**
* creates an instance of a built-in report.
* @param name the report name, e.g. `html`, `json`, `text`
* @param cfg options for the report
*/
export function create<T extends ReportType>(
name: T,
cfg?: Partial<ReportOptions[T]>,
): InstanceType<(typeof reports)[T]>;
export function create(name: string, cfg?: object): ReportBase;
export function create(name: string, cfg?: object): ReportBase {
cfg = cfg || {};
let Cons = (reports as Record<string, unknown>)[name] as new (cfg: object) => ReportBase;
): InstanceType<(typeof reports)[T]> {
const Cons = getBuiltinReport(name);
if (!Cons) {
throw new Error(`Unknown report "${name}". Use createAsync() to load custom reports.`);
}
return new Cons(cfg ?? {}) as InstanceType<(typeof reports)[T]>;
}

/**
* creates an instance of a built-in or custom report.
*
* Custom reports are loaded with `import()`, so `name` may be a package name,
* an absolute path or a `file://` URL of an ES module or CommonJS module whose
* default export (or `module.exports`) is the report class.
* @param name the report name, package name or path
* @param cfg options for the report
*/
export async function createAsync<T extends ReportType>(
name: T,
cfg?: Partial<ReportOptions[T]>,
): Promise<InstanceType<(typeof reports)[T]>>;
export async function createAsync(name: string, cfg?: object): Promise<ReportBase>;
export async function createAsync(name: string, cfg?: object): Promise<ReportBase> {
let Cons = getBuiltinReport(name);

if (!Cons) {
// TODO Verify custom reporters load here fine
Cons = createRequire(import.meta.url)(name);
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name;
const mod: unknown = await import(specifier);

Cons = unwrapDefault(mod) as ReportConstructor;
// CommonJS transpiled from ESM: `module.exports = { __esModule: true, default: … }`
if (typeof Cons === "object" && Cons !== null && "__esModule" in Cons) {
Cons = unwrapDefault(Cons) as ReportConstructor;
}

if (typeof Cons !== "function") {
throw new TypeError(
`Custom report "${name}" must export a report class as its default export (ESM) or module.exports (CommonJS)`,
);
}
}

return new Cons(cfg);
return new Cons!(cfg ?? {});
}
81 changes: 81 additions & 0 deletions packages/istanbul-lib-report/test/custom-reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { fileURLToPath } from "node:url";

import { createCoverageMap } from "@vitest/istanbul-lib-coverage";
import { describe, it, expect } from "vitest";

import { create, createAsync, createContext, ReportBase } from "../src/index";
import type { PartialVisitor, ReportNode } from "../src/index";

const fixture = (name: string): string =>
fileURLToPath(new URL(`./fixtures/custom-reports/${name}`, import.meta.url));

describe("create", () => {
it("creates built-in reports", () => {
expect(create("text")).toBeInstanceOf(ReportBase);
expect(create("json", { file: "out.json" })).toBeInstanceOf(ReportBase);
});

it("throws for unknown reports", () => {
expect(() => create("does-not-exist" as "text")).toThrowErrorMatchingInlineSnapshot(
`[Error: Unknown report "does-not-exist". Use createAsync() to load custom reports.]`,
);
});

it("does not resolve names via the Object prototype", () => {
expect(() => create("constructor" as "text")).toThrow(/Unknown report/);
});
});

describe("createAsync", () => {
it("creates built-in reports", async () => {
const report = await createAsync("text");
expect(report).toBeInstanceOf(ReportBase);
});

it("does not resolve names via the Object prototype", async () => {
await expect(createAsync("toString")).rejects.toThrow();
});

it.each([
["CommonJS", "cjs.cjs", "CjsReport"],
["CommonJS transpiled from ESM", "cjs-transpiled.cjs", "TranspiledReport"],
["ESM", "esm.mjs", "EsmReport"],
["ESM with top-level await", "esm-tla.mjs", "EsmTlaReport"],
])("loads a custom %s report from an absolute path", async (_, file, className) => {
const cfg = { file: "out.md" };
const report = (await createAsync(fixture(file), cfg)) as unknown as { opts: object };

expect(report.constructor.name).toBe(className);
expect(report.opts).toBe(cfg);
});

it("loads a custom report from a file:// URL", async () => {
const url = new URL("./fixtures/custom-reports/esm.mjs", import.meta.url).href;
const report = await createAsync(url);
expect(report.constructor.name).toBe("EsmReport");
});

it.each([
["CommonJS", "cjs.cjs", ["start cjs", "end cjs"]],
["ESM", "esm.mjs", ["start esm", "end esm"]],
])("runs a custom %s report through the report tree", async (_, file, lines) => {
const report = (await createAsync(fixture(file))) as unknown as PartialVisitor<ReportNode> & {
lines: string[];
};
const context = createContext({ coverageMap: createCoverageMap({}) });

context.getTree().visit(report, context);

expect(report.lines).toEqual(lines);
});

it("rejects modules without a report class as default export", async () => {
await expect(createAsync(fixture("named-only.mjs"))).rejects.toThrow(
/must export a report class as its default export/,
);
});

it("rejects unresolvable reports", async () => {
await expect(createAsync("@vitest/this-report-does-not-exist")).rejects.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use strict";
// CommonJS emitted by a transpiler from `export default class …`
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = class TranspiledReport {
constructor(opts) {
this.opts = opts;
}
};
14 changes: 14 additions & 0 deletions packages/istanbul-lib-report/test/fixtures/custom-reports/cjs.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use strict";
// A classic CommonJS custom report, as written for istanbuljs for years.
module.exports = class CjsReport {
constructor(opts) {
this.opts = opts;
this.lines = [];
}
onStart() {
this.lines.push("start cjs");
}
onEnd() {
this.lines.push("end cjs");
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
await Promise.resolve();

export default class EsmTlaReport {
constructor(opts) {
this.opts = opts;
}
}
12 changes: 12 additions & 0 deletions packages/istanbul-lib-report/test/fixtures/custom-reports/esm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default class EsmReport {
constructor(opts) {
this.opts = opts;
this.lines = [];
}
onStart() {
this.lines.push("start esm");
}
onEnd() {
this.lines.push("end esm");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class NamedReport {}
Loading