Skip to content

Commit b8ad96f

Browse files
committed
feat: support ESM custom reporters
1 parent 7aea965 commit b8ad96f

8 files changed

Lines changed: 205 additions & 9 deletions

File tree

packages/istanbul-lib-report/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,27 @@ const report = libReport.create("json", {
3939
// call execute to synchronously create and write the report to disk
4040
report.execute(context);
4141
```
42+
43+
### Custom reporters
44+
45+
`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.
46+
The module's default export (ESM) or `module.exports` (CommonJS) must be the report class:
47+
48+
```js
49+
import { createAsync, ReportBase } from "@vitest/istanbul-lib-report";
50+
51+
// my-report.mjs
52+
export default class MyReport extends ReportBase {
53+
onStart(root, context) {
54+
this.writer = context.writer.writeFile("my-report.txt");
55+
}
56+
onEnd() {
57+
this.writer.close();
58+
}
59+
}
60+
61+
const report = await createAsync("/absolute/path/to/my-report.mjs", {
62+
summarizer: "nested",
63+
});
64+
report.execute(context);
65+
```

packages/istanbul-lib-report/src/index.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* @module Exports
88
*/
99

10-
import { createRequire } from "node:module";
10+
import { isAbsolute } from "node:path";
11+
import { pathToFileURL } from "node:url";
1112

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

132+
type ReportConstructor = new (cfg: object) => ReportBase;
133+
134+
function getBuiltinReport(name: string): ReportConstructor | undefined {
135+
return Object.hasOwn(reports, name)
136+
? (reports as Record<string, ReportConstructor>)[name]
137+
: undefined;
138+
}
139+
140+
function unwrapDefault(mod: unknown): unknown {
141+
return typeof mod === "object" && mod !== null && "default" in mod ? mod.default : mod;
142+
}
143+
144+
/**
145+
* creates an instance of a built-in report.
146+
* @param name the report name, e.g. `html`, `json`, `text`
147+
* @param cfg options for the report
148+
*/
131149
export function create<T extends ReportType>(
132150
name: T,
133151
cfg?: Partial<ReportOptions[T]>,
134-
): InstanceType<(typeof reports)[T]>;
135-
export function create(name: string, cfg?: object): ReportBase;
136-
export function create(name: string, cfg?: object): ReportBase {
137-
cfg = cfg || {};
138-
let Cons = (reports as Record<string, unknown>)[name] as new (cfg: object) => ReportBase;
152+
): InstanceType<(typeof reports)[T]> {
153+
const Cons = getBuiltinReport(name);
154+
if (!Cons) {
155+
throw new Error(`Unknown report "${name}". Use createAsync() to load custom reports.`);
156+
}
157+
return new Cons(cfg ?? {}) as InstanceType<(typeof reports)[T]>;
158+
}
159+
160+
/**
161+
* creates an instance of a built-in or custom report.
162+
*
163+
* Custom reports are loaded with `import()`, so `name` may be a package name,
164+
* an absolute path or a `file://` URL of an ES module or CommonJS module whose
165+
* default export (or `module.exports`) is the report class.
166+
* @param name the report name, package name or path
167+
* @param cfg options for the report
168+
*/
169+
export async function createAsync<T extends ReportType>(
170+
name: T,
171+
cfg?: Partial<ReportOptions[T]>,
172+
): Promise<InstanceType<(typeof reports)[T]>>;
173+
export async function createAsync(name: string, cfg?: object): Promise<ReportBase>;
174+
export async function createAsync(name: string, cfg?: object): Promise<ReportBase> {
175+
let Cons = getBuiltinReport(name);
139176

140177
if (!Cons) {
141-
// TODO Verify custom reporters load here fine
142-
Cons = createRequire(import.meta.url)(name);
178+
const specifier = isAbsolute(name) ? pathToFileURL(name).href : name;
179+
const mod: unknown = await import(specifier);
180+
181+
let Cons = unwrapDefault(mod);
182+
// CommonJS transpiled from ESM: `module.exports = { __esModule: true, default: … }`
183+
if (typeof Cons === "object" && Cons !== null && "__esModule" in Cons) {
184+
Cons = unwrapDefault(Cons);
185+
}
186+
187+
if (typeof Cons !== "function") {
188+
throw new TypeError(
189+
`Custom report "${name}" must export a report class as its default export (ESM) or module.exports (CommonJS)`,
190+
);
191+
}
143192
}
144193

145-
return new Cons(cfg);
194+
return new Cons!(cfg ?? {});
146195
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { fileURLToPath } from "node:url";
2+
3+
import { createCoverageMap } from "@vitest/istanbul-lib-coverage";
4+
import { describe, it, expect } from "vitest";
5+
6+
import { create, createAsync, createContext, ReportBase } from "../src/index";
7+
import type { PartialVisitor, ReportNode } from "../src/index";
8+
9+
const fixture = (name: string): string =>
10+
fileURLToPath(new URL(`./fixtures/custom-reports/${name}`, import.meta.url));
11+
12+
describe("create", () => {
13+
it("creates built-in reports", () => {
14+
expect(create("text")).toBeInstanceOf(ReportBase);
15+
expect(create("json", { file: "out.json" })).toBeInstanceOf(ReportBase);
16+
});
17+
18+
it("throws for unknown reports", () => {
19+
expect(() => create("does-not-exist" as "text")).toThrowErrorMatchingInlineSnapshot(
20+
`[Error: Unknown report "does-not-exist". Use createAsync() to load custom reports.]`,
21+
);
22+
});
23+
24+
it("does not resolve names via the Object prototype", () => {
25+
expect(() => create("constructor" as "text")).toThrow(/Unknown report/);
26+
});
27+
});
28+
29+
describe("createAsync", () => {
30+
it("creates built-in reports", async () => {
31+
const report = await createAsync("text");
32+
expect(report).toBeInstanceOf(ReportBase);
33+
});
34+
35+
it("does not resolve names via the Object prototype", async () => {
36+
await expect(createAsync("toString")).rejects.toThrow();
37+
});
38+
39+
it.each([
40+
["CommonJS", "cjs.cjs", "CjsReport"],
41+
["CommonJS transpiled from ESM", "cjs-transpiled.cjs", "TranspiledReport"],
42+
["ESM", "esm.mjs", "EsmReport"],
43+
["ESM with top-level await", "esm-tla.mjs", "EsmTlaReport"],
44+
])("loads a custom %s report from an absolute path", async (_, file, className) => {
45+
const cfg = { file: "out.md" };
46+
const report = (await createAsync(fixture(file), cfg)) as unknown as { opts: object };
47+
48+
expect(report.constructor.name).toBe(className);
49+
expect(report.opts).toBe(cfg);
50+
});
51+
52+
it("loads a custom report from a file:// URL", async () => {
53+
const url = new URL("./fixtures/custom-reports/esm.mjs", import.meta.url).href;
54+
const report = await createAsync(url);
55+
expect(report.constructor.name).toBe("EsmReport");
56+
});
57+
58+
it.each([
59+
["CommonJS", "cjs.cjs", ["start cjs", "end cjs"]],
60+
["ESM", "esm.mjs", ["start esm", "end esm"]],
61+
])("runs a custom %s report through the report tree", async (_, file, lines) => {
62+
const report = (await createAsync(fixture(file))) as unknown as PartialVisitor<ReportNode> & {
63+
lines: string[];
64+
};
65+
const context = createContext({ coverageMap: createCoverageMap({}) });
66+
67+
context.getTree().visit(report, context);
68+
69+
expect(report.lines).toEqual(lines);
70+
});
71+
72+
it("rejects modules without a report class as default export", async () => {
73+
await expect(createAsync(fixture("named-only.mjs"))).rejects.toThrow(
74+
/must export a report class as its default export/,
75+
);
76+
});
77+
78+
it("rejects unresolvable reports", async () => {
79+
await expect(createAsync("@vitest/this-report-does-not-exist")).rejects.toThrow();
80+
});
81+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"use strict";
2+
// CommonJS emitted by a transpiler from `export default class …`
3+
Object.defineProperty(exports, "__esModule", { value: true });
4+
exports.default = class TranspiledReport {
5+
constructor(opts) {
6+
this.opts = opts;
7+
}
8+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"use strict";
2+
// A classic CommonJS custom report, as written for istanbuljs for years.
3+
module.exports = class CjsReport {
4+
constructor(opts) {
5+
this.opts = opts;
6+
this.lines = [];
7+
}
8+
onStart() {
9+
this.lines.push("start cjs");
10+
}
11+
onEnd() {
12+
this.lines.push("end cjs");
13+
}
14+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
await Promise.resolve();
2+
3+
export default class EsmTlaReport {
4+
constructor(opts) {
5+
this.opts = opts;
6+
}
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default class EsmReport {
2+
constructor(opts) {
3+
this.opts = opts;
4+
this.lines = [];
5+
}
6+
onStart() {
7+
this.lines.push("start esm");
8+
}
9+
onEnd() {
10+
this.lines.push("end esm");
11+
}
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class NamedReport {}

0 commit comments

Comments
 (0)