# Monocart

**Monocart** ([`monocart-coverage-reports`](https://github.com/cenfun/monocart-coverage-reports))
collects raw V8 coverage from more than one test runner and **merges it into one
report**. Without it, running [Vitest](https://vitest.dev/) in-process and
[Playwright](https://quality.stereobooster.com/playwright.md) 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](https://quality.stereobooster.com/coverage.md) for why coverage is a signal,
not a target.

## Install

```bash
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.

```javascript
// 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:

```typescript
// vite.config.ts
test: {
  coverage: {
    provider: "custom",
    customProviderModule: "vitest-monocart-coverage",
  },
},
```

## Playwright

The Playwright config overrides the report name and the output directory:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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();
}
```

```typescript
// 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();
}
```

```typescript
// 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.

```typescript
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`](https://github.com/linux-test-project/lcov) for CI upload, `v8` for the browsable view:

```javascript
// .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](https://quality.stereobooster.com/coverage.md) · Methods
- [Playwright](https://quality.stereobooster.com/playwright.md) · Recipes
- [Recipes](https://quality.stereobooster.com/recipes.md) · Recipes
