Monocart (monocart-coverage-reports)
collects raw V8 coverage from more than one test runner and merges it into one
report. Without it, running Vitest in-process and
Playwright in a browser leaves two separate coverage reports:
a line exercised only in the browser looks uncovered to the in-process run, and
vice versa. See
coverage for why coverage is a signal,
not a target.
Install¶
Shared config¶
Both runners extend one base. The sourcePath remap is the part that's
easy to get wrong: Playwright's V8 coverage reports source locations as
served URLs (http://localhost:3000/src/…), not file paths, so they
must be mapped back onto src/ or the report attributes the coverage to
the wrong file — or to none.
// mcr.config.js
export default {
name: "Vitest Coverage Report",
reports: process.env.CI ? ["raw"] : ["raw", "console-details", "v8"],
entryFilter: {
"**/src/**/*.ts": true,
"**/src/**/*.tsx": true,
},
sourceFilter: {
"**/node_modules/**": false,
"**/mock/**": false,
"**/*.test.tsx": false,
"**/*.test.ts": false,
"**/**": true,
},
sourcePath: (filePath, info) => {
if (info.distFile?.startsWith("src/")) return info.distFile;
if (info.distFile?.startsWith("localhost:3000/"))
return info.distFile.replace("localhost:3000/", "");
if (info.url?.startsWith("http://localhost:3000/"))
return info.url.replace("http://localhost:3000/", "");
if (filePath.startsWith("localhost-3000/"))
return filePath.replace("localhost-3000/", "");
return filePath;
},
outputDir: "./coverage/vitest",
};
In CI it emits only raw (the merge step renders the human report);
locally it adds console-details + v8 for an immediate read.
Vitest¶
Wire Monocart in as a custom coverage provider:
// vite.config.ts
test: {
coverage: {
provider: "custom",
customProviderModule: "vitest-monocart-coverage",
},
},
Playwright¶
The Playwright config overrides the report name and the output directory:
// playwright/setup/mcr.config.ts
import { CoverageReportOptions } from "monocart-coverage-reports";
import coverageOptions from "../../mcr.config";
export default {
...coverageOptions,
name: "Playwright Coverage Report",
outputDir: "./coverage/playwright",
} as CoverageReportOptions;
An auto: true fixture wraps every test, starting and stopping V8
coverage. page.coverage is Chromium-only, so it guards on the
project name; resetOnNavigation: false keeps coverage accumulating
across navigations within a test:
// playwright/setup/base.ts
import { test as base } from "@playwright/test";
import MCR from "monocart-coverage-reports";
import coverageOptions from "./mcr.config";
export const test = base.extend<{ coverage: void }>({
coverage: [
async ({ page }, use) => {
const isChromium = test.info().project.name === "chromium";
if (isChromium) {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
}
await use();
if (isChromium) {
const jsCoverage = await page.coverage.stopJSCoverage();
await MCR(coverageOptions).add(jsCoverage);
}
},
{ scope: "test", auto: true },
],
});
Clean the cache once before the run and generate the per-runner report after it, via global hooks:
// playwright/setup/global-setup.ts
import MCR from "monocart-coverage-reports";
import coverageOptions from "./mcr.config";
export default async function globalSetup() {
await MCR(coverageOptions).cleanCache();
}
// playwright/setup/global-teardown.ts
import MCR from "monocart-coverage-reports";
import coverageOptions from "./mcr.config";
export default async function globalTeardown() {
await MCR(coverageOptions).generate();
}
// playwright.config.ts
globalSetup: require.resolve("./playwright/setup/global-setup"),
globalTeardown: require.resolve("./playwright/setup/global-teardown"),
V8 coverage is tied to the current page, so a test that navigates away
unloads the page and loses the coverage collected so far, whether it clicks
a link or follows a redirect. Stop the navigation to keep that coverage: a
beforeunload handler makes the browser try to confirm leaving, and
dismissing that prompt cancels the navigation, so the page and its
coverage survive.
page.on("dialog", (dialog) => dialog.dismiss());
await page.addInitScript(() =>
window.addEventListener("beforeunload", (e) => e.stopImmediatePropagation()),
);
Merge the two¶
Each runner writes raw data to its own directory; a final step combines
them into one report — lcov for CI upload, v8 for the browsable view:
// .github/merge.js
import { CoverageReport } from "monocart-coverage-reports";
import coverageOptionsBase from "../mcr.config.js";
await new CoverageReport({
...coverageOptionsBase,
name: "Merged Coverage Report",
inputDir: ["./coverage/vitest/raw", "./coverage/playwright/raw"],
outputDir: "./coverage/merged",
reports: process.env.CI ? ["lcov", "v8"] : ["console-details", "v8"],
}).generate();
Add /coverage to .gitignore.
Referenced by¶
- Coverage · Methods
- Playwright · Recipes
- Recipes · Recipes