Skip to content

Software Quality

Monocart

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

npm i -D monocart-coverage-reports monocart-reporter vitest-monocart-coverage

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