monocart-coverage-reports 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@
13
13
  > A JavaScript code coverage tool to generate native [V8](https://v8.dev/blog/javascript-code-coverage) reports or [Istanbul](https://istanbul.js.org/) reports.
14
14
 
15
15
  * [Usage](#usage)
16
+ * [Best Practices](#best-practices)
16
17
  * [Options](#options)
17
18
  * [Available Reports](#available-reports)
18
19
  * [Compare Reports](#compare-reports)
@@ -103,8 +104,22 @@ mcr node my-app.js -r v8,console-details
103
104
  ```
104
105
  For more information, see [Command Line](#command-line)
105
106
 
107
+ ## Best Practices
108
+
109
+ For new test setups, prefer native coverage collection without an instrumentation step:
110
+
111
+ | Target | Recommended setup | Responsibility |
112
+ | --- | --- | --- |
113
+ | Node.js | **Node.js native test runner + `mcr test`** | `node:test` runs assertions; MCR automatically collects native V8 JavaScript coverage and generates reports. |
114
+ | Browser | **Playwright Test + MCR** | Playwright runs assertions; automatic fixtures collect Chromium JS/CSS coverage; MCR merges it and generates reports. |
115
+ | Browser with integrated test reporting | **Playwright Test + [monocart-reporter](https://github.com/cenfun/monocart-reporter#code-coverage-report)** | The reporter already integrates MCR; configure its coverage option and submit coverage from fixtures for unified test and coverage reporting. |
116
+
117
+ For detailed setup instructions and examples, see the [best-practices guide](skills/monocart-coverage-reports/references/best-practices.md).
118
+
106
119
  ## AI Agent Skill
107
120
 
121
+ For AI-assisted development in a source checkout, start with [AGENTS.md](https://github.com/cenfun/monocart-coverage-reports/blob/main/AGENTS.md). It describes the source layout, development commands, validation, and safety boundaries. `.github/copilot-instructions.md` provides a self-contained summary of the core rules for GitHub Copilot. These checkout-only guides are not included in the npm package and are separate from the reusable integration skill below.
122
+
108
123
  The reusable [Monocart Coverage Reports skill](skills/monocart-coverage-reports/SKILL.md) helps AI coding agents collect coverage, configure the CLI, debug sourcemaps and filters, merge raw reports, and enforce CI thresholds. It uses a main `SKILL.md` with task-specific references loaded on demand, targeting MCR 3.x only and checking its runtime requirements.
109
124
 
110
125
  The npm package includes `skills/monocart-coverage-reports/`. Copy this **entire directory**, including `references/`, into your agent's supported skills location, or ask the agent to read `SKILL.md` directly. Installing MCR does not automatically register the skill or modify agent settings. The instructions are in English; requests and responses can use any language.
@@ -354,11 +369,18 @@ const [jsCoverage, cssCoverage] = await Promise.all([
354
369
  const coverageData = [... jsCoverage, ... cssCoverage];
355
370
 
356
371
  ```
357
- Collect coverage with `@playwright/test` [`Automatic fixtures`](https://playwright.dev/docs/test-fixtures#automatic-fixtures), see example: [fixtures.ts](https://github.com/cenfun/playwright-coverage/blob/main/fixtures.ts)
372
+ For Playwright Test automatic fixtures, see [fixtures.js](https://github.com/cenfun/playwright-coverage/blob/main/fixtures.js) in the complete [playwright-coverage example](https://github.com/cenfun/playwright-coverage). The [best-practices guide](skills/monocart-coverage-reports/references/best-practices.md#complete-project-example) explains the integration.
358
373
  For more examples, see [./test/test-v8.js](./test/test-v8.js), [css](./test/test-css.js)
359
374
 
360
375
 
361
376
  ### Collecting Raw V8 Coverage Data with Puppeteer
377
+
378
+ > Depending on your package manager, Puppeteer may not download a browser during installation. If no browser is available, install one manually:
379
+ >
380
+ > ```sh
381
+ > npx puppeteer browsers install chrome
382
+ > ```
383
+
362
384
  ```js
363
385
  await Promise.all([
364
386
  page.coverage.startJSCoverage({
@@ -716,6 +738,8 @@ const coverageOptions = {
716
738
  }
717
739
  };
718
740
  ```
741
+ Built-in report path normalization is best-effort; it cannot fully reconcile every nonstandard, virtual, or foreign-platform path. Use `sourcePath` to correct these special cases rather than relying on automatic inference. The callback receives the normalized `filePath`; for V8 entries and sourcemap sources, `info.url` provides the original URL before report path normalization (including the sourcemap's `sourceRoot`, when present). This rewrites report paths, not the filesystem paths used to load missing source content. When `sourcesContent` is missing, local sources are loaded relative to the external map's URL (or the generated file for inline maps), with `sourceRoot` applied as a directory.
742
+
719
743
  It also supports simple key/value replacement:
720
744
  ```js
721
745
  const coverageOptions = {
@@ -741,7 +765,7 @@ const coverageOptions = {
741
765
  ```
742
766
 
743
767
  ## Adding Empty Coverage for Untested Files
744
- By default the untested files will not be included in the coverage report. Use `all` to include them with 0% coverage. This also works when no coverage data has been collected: a V8 report is generated from the matching untested files.
768
+ Untested files are excluded by default. Use `all` to include them with 0% coverage, even when no coverage data was collected. For the same original source, real V8 coverage takes precedence over empty entries.
745
769
  ```js
746
770
  const coverageOptions = {
747
771
  all: './src'
@@ -844,6 +868,53 @@ const coverageOptions = {
844
868
  };
845
869
  ```
846
870
 
871
+ ### `onTestEnd` — `mcr test` only
872
+
873
+ `onTestEnd(testResults)` runs once after the test runner exits and its JSON results are collected, before publishing the test results cache, `onReady`, and coverage generation. It is awaited even when assertions fail. Other commands, API calls, and runs skipped because no targets were supplied do not call it. The existing `onEnd(coverageResults)` is unchanged.
874
+
875
+ `mcr test` always collects structured test results using an internal JSON reporter, preserving terminal output and user-specified reporter/destination pairs. `onTestEnd` is optional; omitting it does not disable result collection. To retain results for later use, enable `raw` reports or debug logging; normal cache cleanup still applies.
876
+
877
+ ```js
878
+ // mcr.config.js
879
+ export default {
880
+ test: './test/**/*.test.js',
881
+ onTestEnd: (testResults) => {
882
+ console.log(testResults.summary);
883
+ for (const test of testResults.tests) {
884
+ console.log(test.name, test.status, test.error);
885
+ }
886
+ }
887
+ };
888
+ ```
889
+
890
+ - `id`: a collection ID generated with the same `Util.uid()` mechanism as coverage cache data.
891
+ - `framework`: `'node:test'`.
892
+ - `complete`: whether the final runner summary was received, not whether tests passed.
893
+ - `summary`: `{ tests, suites, passed, failed, cancelled, skipped, todo, duration, success }`; `null` if incomplete. Counts come from the runner's final summary; `tests` excludes suites. Durations are in milliseconds.
894
+ - `tests`: completed test and suite records, including nested tests: `{ name, type, nesting, testNumber, file?, line?, column?, duration, status, skip?, todo?, error? }`. Status is `passed`, `failed`, `cancelled`, `skipped`, or `todo`. Errors always include string `name` and `message` (defaults: `'Error'` and `''`). Other own enumerable data properties are copied only when their values are strings, booleans, finite numbers, or `null`; BigInts become decimal strings. Getters/setters, objects/arrays, functions, symbols, `undefined`, `NaN`, and infinities are omitted without recursive traversal. Apart from the two fixed fields, inherited/non-enumerable properties are not collected, so normal `stack` is omitted and fields inside `cause` are not flattened. Explicitly enumerable string `stack` is copied like any other string. Reserved keys `__proto__`, `constructor`, and `prototype` are excluded; the fixed fields cannot be overwritten by extra properties.
895
+
896
+ Test failures, incomplete/missing/invalid reports, and hook errors keep the command nonzero. An incomplete report is delivered with `complete: false`; a missing or invalid JSON report cannot be delivered and is logged as a collection error. A nonzero `process.exitCode` set by `onTestEnd` cannot be cleared by later coverage hooks. Hook/collection errors do not prevent MCR from attempting coverage generation. If the hook throws, test results are not published or exported, rather than falling back to possibly unredacted data. Test results are independent of coverage, so this hook can run even if coverage generation later fails.
897
+
898
+ Test results use the same ID and filename mechanism as coverage cache data: `outputDir/.cache/test-results-<id>.json`, with the same `id` stored in the JSON. The reporter streams records into `.json.tmp` with its collection ID already included. A small `.meta.tmp` completion record verifies the final byte count. Without a hook, MCR reads only this completion record and renames the JSON, without loading or rewriting the full report. With a hook, MCR reads the full result object and publishes its mutations after the hook completes. Temporary output and metadata are removed before coverage generation. Finalized JSON follows the coverage cache lifecycle: normally cleaned after generation, retained in debug mode, or exported by `raw` (including ZIP). It is not embedded in HTML/coverage JSON reports, and importing raw data does not yet merge test results. Save results yourself if needed; files written into `outputDir` by this hook may be removed by the subsequent coverage cleanup when `clean: true`.
899
+
900
+ **Sensitive data:** Primitive does not mean non-sensitive: a string `token`, `password`, or response body is also copied. Messages, test names, and paths may contain credentials or personal data; large strings can also increase report size. MCR does not redact them automatically. Keep the output directory private and review raw/debug artifacts before uploading them. Mutate results in `onTestEnd` to sanitize the persisted JSON; this does not redact Node's terminal output or data already written to temporary files. For example, adapt the following rule to your application's data:
901
+
902
+ ```js
903
+ export default {
904
+ onTestEnd: (results) => {
905
+ for (const test of results.tests) {
906
+ if (test.error) {
907
+ for (const [key, value] of Object.entries(test.error)) {
908
+ if (typeof value === 'string') {
909
+ test.error[key] = value.replace(/(Bearer\s+)\S+/gi, '$1[REDACTED]');
910
+ }
911
+ }
912
+ }
913
+ }
914
+ }
915
+ };
916
+ ```
917
+
847
918
  ### `onStart` / `onReady` — CLI-only
848
919
  Only available when using `mcr <command>`:
849
920
  - `onStart(coverageReport)` — runs before the child process is spawned.
@@ -961,6 +1032,7 @@ The options below are only available when using the CLI (`mcr <command>`):
961
1032
  | `--require` | `string` | `null` | Deprecated alias for `--import`; uses ESM loading, not CommonJS resolution |
962
1033
  | `--env` | `string` | `null` | Load environment variables from a dotenv file (defaults to `.env`). |
963
1034
  | `test` (config only) | `string \| string[] \| null` | `null` | Default test file paths or glob patterns accepted by `node --test`, passed unchanged when `mcr test` has no positional targets; ignored by other commands and the API. Requires Node.js `>= 22.15.0` |
1035
+ | `onTestEnd` (config only) | `(testResults) => void \| Promise<void>` | `null` | Only `mcr test`: runs after test result collection, before `onReady` and coverage generation; see [Hooks](#hooks) |
964
1036
  | `onStart` | `(coverageReport) => Promise<void>` | `null` | Runs before the child process is spawned; see [Hooks](#hooks) |
965
1037
  | `onReady` | `(coverageReport, nodeV8CoverageDir, subprocess) => Promise<void>` | `null` | Runs after the child exits, before MCR reads coverage; see [Hooks](#hooks) |
966
1038
 
@@ -1079,6 +1151,9 @@ The following usage scenarios may require merging coverage reports:
1079
1151
  ### Manual Merging
1080
1152
  If the reports cannot be merged automatically, then here is how to manually merge the reports.
1081
1153
  First, using the `raw` report to export the original coverage data to the specified directory.
1154
+
1155
+ **Before uploading raw/debug artifacts (3.2.0+):** `mcr test` also exports `test-results-<id>.json` in raw reports (including ZIP) and retains it in debug caches, even without a hook. Test names, paths and error strings may contain secrets or personal data. Review artifacts and use [`onTestEnd`](#ontestend--mcr-test-only) to redact persisted test results before publication; this does not redact terminal output or temporary files already written. Raw imports merge coverage only, not test results.
1156
+
1082
1157
  - For example, we have `raw` coverage data from `unit test`, which is output to `./coverage-reports/unit/raw`. Unit test examples:
1083
1158
  - `Jest` + [jest-monocart-coverage](https://github.com/cenfun/jest-monocart-coverage)
1084
1159
  - `Vitest` + [vitest-monocart-coverage](https://github.com/cenfun/vitest-monocart-coverage)
@@ -1243,6 +1318,27 @@ const coverageOptions = {
1243
1318
  process.env.MCR_LOG_TIME = true;
1244
1319
  ```
1245
1320
 
1321
+ ### CSS coverage issues
1322
+
1323
+ CSS coverage from multiple bundles is merged as a union for each original source.
1324
+
1325
+ For preprocessor syntax that `all` cannot parse directly, use `all.transformer` to compile to CSS, set `entry.type = 'css'`, and preserve the sourcemap.
1326
+
1327
+ #### Sass mixins and `@include`
1328
+
1329
+ Sass `@include` can map a generated rule to non-contiguous source locations. When a rule cannot be reliably mapped, MCR reports the **whole bundle as generated CSS** instead of the preprocessor sources. That bundle's coverage is not merged into the original-source coverage.
1330
+
1331
+ #### Line coverage of untested preprocessor sources
1332
+
1333
+ Untested files included through `all` have `0%` byte coverage, including sourcemap originals. Lines outside parsed or mapped rules (such as variables or unused mixins) may appear covered; use **bytes** coverage for these files.
1334
+
1335
+ #### Source map can be inaccurate after minification
1336
+ CSS minification can produce imprecise sourcemaps and trigger fallback to generated CSS. If source mapping is inaccurate, try **disabling CSS minification**.
1337
+
1338
+ #### V8 covered the whole block
1339
+
1340
+ Chromium may mark an entire media query or nested CSS block as covered. Consider [postcss-atomic-selectors](https://www.npmjs.com/package/postcss-atomic-selectors) as a potential workaround.
1341
+
1246
1342
  ## Integration with Any Testing Framework
1247
1343
  - API
1248
1344
  - Collecting coverage data when any stage of the test is completed, and adding the coverage data to the coverage reporter. `await mcr.add(coverageData)`
package/README.zh-Hans.md CHANGED
@@ -13,6 +13,7 @@
13
13
  > JS代码覆盖率工具,用来生成原生的[V8](https://v8.dev/blog/javascript-code-coverage)或者[Istanbul](https://istanbul.js.org/)代码覆盖率报告
14
14
 
15
15
  * [用法](#usage)
16
+ * [最佳实践](#best-practices)
16
17
  * [选项配置](#options)
17
18
  * [所有支持的报告类型](#available-reports)
18
19
  * [比较两种报告](#compare-reports)
@@ -103,8 +104,22 @@ mcr node my-app.js -r v8,console-details
103
104
  ```
104
105
  参见 [命令行](#command-line)
105
106
 
107
+ ## Best Practices
108
+
109
+ 为新项目建立测试方案时,优先使用无需插桩的原生覆盖率采集:
110
+
111
+ | 测试目标 | 推荐方案 | 职责划分 |
112
+ | --- | --- | --- |
113
+ | Node.js 端 | **Node.js 原生 test runner + `mcr test`** | `node:test` 执行测试断言;MCR 自动采集 JavaScript 原生 V8 覆盖率并生成报告。 |
114
+ | 浏览器端 | **Playwright Test + MCR** | Playwright 执行测试断言;自动 fixture 采集 Chromium JS/CSS 覆盖率;MCR 合并并生成报告。 |
115
+ | 浏览器端,需要统一测试报告 | **Playwright Test + [monocart-reporter](https://github.com/cenfun/monocart-reporter#code-coverage-report)** | reporter 已集成 MCR;配置其 coverage 选项,并由 fixture 提交覆盖率数据,统一管理测试报告和覆盖率报告。 |
116
+
117
+ 详细配置步骤和示例见[最佳实践指南](skills/monocart-coverage-reports/references/best-practices.md)。
118
+
106
119
  ## AI Agent Skill
107
120
 
121
+ 使用 AI 辅助开发本仓库源码时,请先阅读 [AGENTS.md](https://github.com/cenfun/monocart-coverage-reports/blob/main/AGENTS.md),了解源码结构、开发命令、验证流程和安全边界。`.github/copilot-instructions.md` 为 GitHub Copilot 提供可独立使用的核心规则摘要。这些指南仅用于源码仓库,不随 npm 包发布,与下面可复用的集成技能相互独立。
122
+
108
123
  项目提供可复用的 [Monocart Coverage Reports 技能](skills/monocart-coverage-reports/SKILL.md),帮助 AI coding agent 采集覆盖率、配置 CLI、排查 sourcemap 和过滤问题、合并 raw 报告及设置 CI 阈值。结构为主 `SKILL.md` 加按需加载的专题参考文档,仅面向 MCR 3.x,并检查其运行环境要求。
109
124
 
110
125
  npm 包包含 `skills/monocart-coverage-reports/`。请将**整个目录**(包括 `references/`)复制到 agent 支持的 skills 目录,或直接让 agent 读取 `SKILL.md`。安装 MCR 不会自动注册技能或修改 agent 配置。技能说明使用英文,但提问和回复可以使用中文或其他语言。
@@ -356,11 +371,18 @@ const [jsCoverage, cssCoverage] = await Promise.all([
356
371
  const coverageData = [... jsCoverage, ... cssCoverage];
357
372
 
358
373
  ```
359
- 使用 `@playwright/test` 的 [`Automatic fixtures`](https://playwright.dev/docs/test-fixtures#automatic-fixtures)收集覆盖率数据, 见例子: [fixtures.ts](https://github.com/cenfun/playwright-coverage/blob/main/fixtures.ts)
374
+ Playwright Test 自动 fixture 参见完整 [playwright-coverage 示例](https://github.com/cenfun/playwright-coverage)中的 [fixtures.js](https://github.com/cenfun/playwright-coverage/blob/main/fixtures.js),接入方式见[最佳实践指南](skills/monocart-coverage-reports/references/best-practices.md#complete-project-example)。
360
375
  参见例子 [./test/test-v8.js](./test/test-v8.js), [css](./test/test-css.js)
361
376
 
362
377
 
363
378
  ### Collecting Raw V8 Coverage Data with Puppeteer
379
+
380
+ > 取决于包管理器配置,Puppeteer 可能不会在安装依赖时下载浏览器。如果没有可用浏览器,请手动安装:
381
+ >
382
+ > ```sh
383
+ > npx puppeteer browsers install chrome
384
+ > ```
385
+
364
386
  使用Puppeteer的覆盖接口收集覆盖率数据,注意Puppeteer默认不会提供原生V8的覆盖率数据,需要设置`includeRawScriptCoverage`
365
387
  ```js
366
388
  await Promise.all([
@@ -719,6 +741,8 @@ const coverageOptions = {
719
741
  }
720
742
  };
721
743
  ```
744
+ 内置报告路径规范化采用尽力而为的原则,无法完全兼容所有非标准、虚拟或来自其他操作系统的路径。此类特殊情况应通过 `sourcePath` 自行修正,而不是依赖自动推断。回调接收规范化后的 `filePath`;对于 V8 入口和 sourcemap 源文件,`info.url` 提供报告路径规范化前的原始 URL(sourcemap 存在 `sourceRoot` 时包含该前缀)。它修改的是报告路径,不是读取缺失源码时使用的文件系统路径。缺少 `sourcesContent` 时,本地源码相对于外部 map 的 URL 加载(内联 map 则相对于生成文件),并将 `sourceRoot` 作为目录应用。
745
+
722
746
  它也支持简单key/value的替换:
723
747
  ```js
724
748
  const coverageOptions = {
@@ -744,7 +768,7 @@ const coverageOptions = {
744
768
  ```
745
769
 
746
770
  ## Adding Empty Coverage for Untested Files
747
- 默认,未测试的文件不会包含在覆盖率报告中。使用 `all` 选项可将它们以 0% 覆盖率加入报告。即使没有收集到任何覆盖率数据,也会为匹配的未测试文件生成 V8 报告。
771
+ 默认不包含未测试文件。使用 `all` 可将它们以 0% 覆盖率加入报告,即使没有采集到任何覆盖率数据也可生成报告。同一原始源码的真实 V8 覆盖率优先于空覆盖率条目。
748
772
  ```js
749
773
  const coverageOptions = {
750
774
  all: './src'
@@ -847,6 +871,53 @@ const coverageOptions = {
847
871
  };
848
872
  ```
849
873
 
874
+ ### `onTestEnd` — 仅 `mcr test` 可用
875
+
876
+ `onTestEnd(testResults)` 在测试运行器退出、JSON 测试结果采集完成后触发一次,早于测试结果缓存发布、`onReady` 和覆盖率报告生成。支持异步等待,测试断言失败时也会触发。其他命令、API 调用,以及因未提供目标而跳过的测试,不触发该 hook。现有 `onEnd(coverageResults)` 保持不变。
877
+
878
+ `mcr test` 始终通过内部 JSON reporter 采集结构化测试结果,同时保留终端输出和用户指定的 reporter/destination 配对。`onTestEnd` 为可选回调,不配置也不会关闭采集。如需留待后续使用,请启用 `raw` 报告或 debug 日志;默认的缓存清理规则仍然生效。
879
+
880
+ ```js
881
+ // mcr.config.js
882
+ export default {
883
+ test: './test/**/*.test.js',
884
+ onTestEnd: (testResults) => {
885
+ console.log(testResults.summary);
886
+ for (const test of testResults.tests) {
887
+ console.log(test.name, test.status, test.error);
888
+ }
889
+ }
890
+ };
891
+ ```
892
+
893
+ - `id`:采用与覆盖率缓存数据相同的 `Util.uid()` 机制生成的采集 ID。
894
+ - `framework`:`'node:test'`。
895
+ - `complete`:是否收到运行器最终汇总,不代表测试是否通过。
896
+ - `summary`:`{ tests, suites, passed, failed, cancelled, skipped, todo, duration, success }`,报告不完整时为 `null`。计数采用运行器最终汇总,`tests` 不包含 suite,耗时单位为毫秒。
897
+ - `tests`:已完成的测试及 suite 记录,包含嵌套测试:`{ name, type, nesting, testNumber, file?, line?, column?, duration, status, skip?, todo?, error? }`。状态为 `passed`、`failed`、`cancelled`、`skipped` 或 `todo`。错误固定包含字符串 `name`、`message`,不可用或非字符串时分别使用 `'Error'`、`''`。其余只复制自有、可枚举的数据属性,值仅限字符串、布尔值、有限数字和 `null`;BigInt 转为十进制字符串。不递归遍历,跳过 getter/setter、对象/数组、函数、Symbol、`undefined`、`NaN` 和无穷数。除两个固定字段外,不采集继承或不可枚举属性,因此通常不包含 `stack`,也不展开 `cause` 内的字段;明确设为可枚举的字符串 `stack` 仍按普通字符串复制。过滤 `__proto__`、`constructor`、`prototype` 等特殊键,额外属性不会覆盖固定字段。
898
+
899
+ 测试失败、报告不完整/缺失/损坏或 hook 抛错,均保持非零退出状态。不完整报告以 `complete: false` 交付;缺失或无效的 JSON 无法交付,会明确记录采集错误。`onTestEnd` 设置的非零 `process.exitCode` 不会被后续覆盖率 hook 清零。hook 或采集失败后仍尝试生成覆盖率报告。hook 抛错时不会发布或导出测试结果,避免回退导出可能尚未脱敏的数据。测试结果独立于覆盖率,因此即使后续覆盖率生成失败,此 hook 仍可执行。
900
+
901
+ 测试结果复用覆盖率缓存的 ID 和文件命名机制:`outputDir/.cache/test-results-<id>.json`,JSON 内容保存同一个 `id`。reporter 将记录逐条写入 `.json.tmp`,输出时即包含采集 ID,并通过很小的 `.meta.tmp` 完成记录校验最终字节数。没有 hook 时,MCR 只读取完成记录并重命名 JSON,不会加载或重写完整报告;配置 hook 时,读取完整结果对象,在 hook 完成后将修改后的数据发布为正式缓存。临时输出和元数据均在生成覆盖率前清理。正式 JSON 遵循覆盖率缓存生命周期:正常生成后清理、调试模式保留,或由 `raw` 报告导出(包括 ZIP)。它不会嵌入 HTML/覆盖率 JSON 报告,导入 raw 数据目前也不会合并测试结果。如需持久化,请自行保存;当 `clean: true` 时,此 hook 写入 `outputDir` 的文件可能被后续覆盖率清理流程删除。
902
+
903
+ **敏感信息提醒:** 基础类型不等于非敏感数据,字符串 `token`、`password` 或响应正文同样会被复制。错误消息、测试名称和路径也可能包含凭据或个人信息,大字符串仍可能增大报告体积,MCR 不会自动脱敏。请保护输出目录,并在上传 raw/debug 产物前审查内容。可在 `onTestEnd` 中修改结果,对正式保存的 JSON 脱敏;这不会处理 Node 的终端输出或已经写入临时文件的数据。以下规则仅为示例,请按业务数据调整:
904
+
905
+ ```js
906
+ export default {
907
+ onTestEnd: (results) => {
908
+ for (const test of results.tests) {
909
+ if (test.error) {
910
+ for (const [key, value] of Object.entries(test.error)) {
911
+ if (typeof value === 'string') {
912
+ test.error[key] = value.replace(/(Bearer\s+)\S+/gi, '$1[REDACTED]');
913
+ }
914
+ }
915
+ }
916
+ }
917
+ }
918
+ };
919
+ ```
920
+
850
921
  ### `onStart` / `onReady` — 仅CLI可用
851
922
  只在使用 `mcr <command>` 时有效:
852
923
  - `onStart(coverageReport)` — 子进程启动前触发
@@ -964,6 +1035,7 @@ npx mcr node ./test/specs/node.test.js -r v8,console-details --lcov
964
1035
  | `--require` | `string` | `null` | `--import` 的已弃用别名;使用 ESM 加载,不使用 CommonJS 解析 |
965
1036
  | `--env` | `string` | `null` | 从 dotenv 文件加载环境变量(默认 `.env`)。|
966
1037
  | `test`(仅配置文件) | `string \| string[] \| null` | `null` | 未指定命令行目标时,原样传给 `node --test` 的默认测试文件路径或 glob,**仅 `mcr test` 使用**;其他命令和 API 忽略。需要 Node.js `>= 22.15.0` |
1038
+ | `onTestEnd`(仅配置文件) | `(testResults) => void \| Promise<void>` | `null` | 仅 `mcr test`:测试结果采集完成后、`onReady` 和覆盖率生成前触发;参见 [Hooks](#hooks) |
967
1039
  | `onStart` | `(coverageReport) => Promise<void>` | `null` | 子进程启动前触发;参见 [Hooks](#hooks) |
968
1040
  | `onReady` | `(coverageReport, nodeV8CoverageDir, subprocess) => Promise<void>` | `null` | 子进程退出后、MCR 读取覆盖率前触发;参见 [Hooks](#hooks) |
969
1041
 
@@ -1081,6 +1153,9 @@ npx mcr -c ./mcr.config.js -- test "../tests/**/*.test.js" -- --test-reporter=sp
1081
1153
 
1082
1154
  ### Manual Merging
1083
1155
  手动合并覆盖率报告需要使用`raw`报告来导出原始的覆盖率数据到指定的目录
1156
+
1157
+ **上传 raw/debug 产物前请注意(3.2.0+):** 即使未配置 hook,`mcr test` 也会在 raw 报告(包括 ZIP)中导出 `test-results-<id>.json`,并在 debug 缓存中保留该文件。测试名称、路径及错误字符串可能包含凭据或个人信息。请先审查产物,并通过 [`onTestEnd`](#ontestend--仅-mcr-test-可用) 在发布前对正式保存的测试结果脱敏;这不会处理终端输出或已写入的临时文件。导入 raw 数据只合并覆盖率,不会合并测试结果。
1158
+
1084
1159
  - 比如,单元测试保存到`./coverage-reports/unit/raw`,见例子
1085
1160
  - `Jest` + [jest-monocart-coverage](https://github.com/cenfun/jest-monocart-coverage)
1086
1161
  - `Vitest` + [vitest-monocart-coverage](https://github.com/cenfun/vitest-monocart-coverage)
@@ -1246,6 +1321,27 @@ const coverageOptions = {
1246
1321
  process.env.MCR_LOG_TIME = true;
1247
1322
  ```
1248
1323
 
1324
+ ### CSS 覆盖率与 sourcemap
1325
+
1326
+ 多个 bundle 映射到同一原始文件时,CSS 覆盖率取并集。
1327
+
1328
+ 对于 `all` 无法直接解析的预处理器语法,请使用 `all.transformer` 编译为 CSS,设置 `entry.type = 'css'`,并保留 sourcemap。
1329
+
1330
+ #### Sass mixin 与 `@include`
1331
+
1332
+ Sass `@include` 可能将一条生成规则映射到不连续的源码位置。规则无法可靠映射时,MCR 会将**整个 bundle 按生成的 CSS 展示**,而不是预处理器源文件;该 bundle 的覆盖率也不会合并到原始源码的覆盖率中。
1333
+
1334
+ #### 未测试预处理源文件的行覆盖率
1335
+
1336
+ 通过 `all` 加入的未测试文件,其字节覆盖率为 `0%`,包括 sourcemap 还原的原始文件。解析或映射规则之外的行(如变量、未使用的 mixin)可能显示为已覆盖;请以 **bytes** 覆盖率为准。
1337
+
1338
+ #### 压缩后 sourcemap 可能不准确
1339
+ CSS 压缩可能产生不准确的 sourcemap,导致报告回退到生成的 CSS。遇到映射不准确时,可尝试**禁用 CSS 压缩**。
1340
+
1341
+ #### V8 会将整个 block 视为已覆盖
1342
+
1343
+ Chromium 可能将整个 media query 或嵌套 CSS block 标记为已覆盖。可考虑使用 [postcss-atomic-selectors](https://www.npmjs.com/package/postcss-atomic-selectors) 作为临时解决方案。
1344
+
1249
1345
  ## Integration with Any Testing Framework
1250
1346
  通用集成方案
1251
1347
  - 通过API接口在程序集成
package/lib/cli.js CHANGED
@@ -9,6 +9,7 @@ import { program } from 'commander';
9
9
  import { execa } from 'execa';
10
10
 
11
11
  import { MCR } from './index.js';
12
+ import { getTestCommand, createTestRunner } from './test/test-runner.js';
12
13
  import { Util } from './utils/util.js';
13
14
  import pkg from '../package.json' with { type: 'json' };
14
15
 
@@ -85,17 +86,6 @@ const mergeCoverage = async (cliOptions) => {
85
86
  await coverageReport.generate();
86
87
  };
87
88
 
88
- // Only check whether targets were supplied. Node owns all target parsing,
89
- // discovery and validation; preserve each argument exactly as provided.
90
- const getTestTargets = (test) => {
91
- if (!test || (Array.isArray(test) && !test.length)) {
92
- Util.logInfo('No test targets configured; skipping tests.');
93
- return;
94
- }
95
-
96
- return Array.isArray(test) ? test : [test];
97
- };
98
-
99
89
  // Preserve signals and failing commands even if a hook sets exitCode=0.
100
90
  const getExitStatus = (code, signal) => signal || code || process.exitCode || 0;
101
91
 
@@ -177,24 +167,19 @@ const executeCommand = async ([command, ... commandArgs], cliOptions, testArgs =
177
167
 
178
168
  // console.log(options);
179
169
  const coverageReport = MCR(cliOptions);
180
- const isTest = command === 'test';
181
170
  await coverageReport.loadConfig(cliOptions.config);
182
171
 
183
172
  const coverageOptions = coverageReport.options;
184
- let childArgs;
185
- if (isTest) {
186
- const targets = getTestTargets(coverageOptions.test);
187
- if (!targets) {
173
+ let testRunner;
174
+ if (command === 'test') {
175
+ testRunner = createTestRunner(coverageOptions, testArgs);
176
+ if (!testRunner) {
188
177
  return;
189
178
  }
190
- childArgs = ['--test', ... testArgs, ... targets];
191
179
  }
192
180
  coverageReport.cleanCache();
193
181
  const nodeV8CoverageDir = initNodeV8CoverageDir(coverageOptions);
194
- if (isTest) {
195
- // Tests may spawn children with a different working directory.
196
- process.env.NODE_V8_COVERAGE = path.resolve(nodeV8CoverageDir);
197
- }
182
+ testRunner?.initCoverageDir(nodeV8CoverageDir);
198
183
 
199
184
  // =========================================
200
185
  // onStart hook
@@ -204,22 +189,26 @@ const executeCommand = async ([command, ... commandArgs], cliOptions, testArgs =
204
189
  }
205
190
  // =========================================
206
191
 
207
- const onClose = async (code, signal, subprocess) => {
192
+ const generateCoverage = async (subprocess) => {
193
+ // onReady hook before adding coverage data. Some children need
194
+ // additional time to finish writing their coverage files.
195
+ const onReady = coverageOptions.onReady;
196
+ if (typeof onReady === 'function') {
197
+ await onReady(coverageReport, nodeV8CoverageDir, subprocess);
198
+ }
199
+ await coverageReport.addFromDir(nodeV8CoverageDir);
200
+ return coverageReport.generate();
201
+ };
208
202
 
209
- // generate coverage even it is failed. code != 0
203
+ const onClose = async (code, signal, subprocess) => {
210
204
 
205
+ // Generate coverage even when the child failed (code != 0).
211
206
  try {
212
- // onReady hook before adding coverage data. Some children need
213
- // additional time to finish writing their coverage files.
214
- const onReady = coverageOptions.onReady;
215
- if (typeof onReady === 'function') {
216
- await onReady(coverageReport, nodeV8CoverageDir, subprocess);
217
- }
218
-
219
- await coverageReport.addFromDir(nodeV8CoverageDir);
220
- const report = await coverageReport.generate();
221
- if (isTest && !report) {
222
- throw new Error('No coverage report was generated');
207
+ const generate = () => generateCoverage(subprocess);
208
+ if (testRunner) {
209
+ await testRunner.onClose(generate);
210
+ } else {
211
+ await generate();
223
212
  }
224
213
  } catch (error) {
225
214
  Util.logError(`Coverage report generation failed: ${error.message}`);
@@ -233,8 +222,8 @@ const executeCommand = async ([command, ... commandArgs], cliOptions, testArgs =
233
222
  return getExitStatus(code, signal);
234
223
  };
235
224
 
236
- if (isTest) {
237
- return executeSubprocess(process.execPath, childArgs, onClose);
225
+ if (testRunner) {
226
+ return testRunner.execute(executeSubprocess, onClose);
238
227
  }
239
228
  return executeSubprocess(command, commandArgs, onClose);
240
229
 
@@ -261,30 +250,6 @@ process.argv.forEach((it) => {
261
250
  }
262
251
  });
263
252
 
264
- // Also recognize `mcr -c config -- test targets -- runner-options` without
265
- // falling through to the shell's unrelated `test` command. Only MCR options
266
- // before the first separator are parsed in that form.
267
- const getTestCommand = (args, subArgs) => {
268
- if (args[0] === 'test') {
269
- return {
270
- targets: args.slice(1), runnerArgs: subArgs.slice(1)
271
- };
272
- }
273
- if (args.length || subArgs[1] !== 'test') {
274
- return;
275
- }
276
- const remaining = subArgs.slice(2);
277
- const index = remaining.indexOf('--');
278
- if (index === -1) {
279
- return {
280
- targets: remaining, runnerArgs: []
281
- };
282
- }
283
- return {
284
- targets: remaining.slice(0, index), runnerArgs: remaining.slice(index + 1)
285
- };
286
- };
287
-
288
253
  program
289
254
  .name('mcr')
290
255
  .description('CLI to generate coverage reports')
@@ -345,8 +345,29 @@ const getCssAstInfo = (item, coverageList) => {
345
345
  endOffset: it.end
346
346
  };
347
347
  });
348
-
349
- const ast = parseCss(source);
348
+ let ast;
349
+ try {
350
+ ast = parseCss(source);
351
+ } catch (error) {
352
+ // Untested files may use preprocessor syntax that postcss cannot parse.
353
+ // Mirror the JS empty-file fallback: report the whole file as uncovered
354
+ // instead of aborting report generation.
355
+ if (empty) {
356
+ coverageList.push({
357
+ start: 0,
358
+ end: source.length,
359
+ count: 0
360
+ });
361
+ return {
362
+ functions: [],
363
+ branches: [],
364
+ statements: []
365
+ };
366
+ }
367
+ throw new Error(`parse css error in ${item.sourcePath}`, {
368
+ cause: error
369
+ });
370
+ }
350
371
 
351
372
  addCssRules(ast.nodes, coverageList, coveredRanges, empty);
352
373
 
@@ -121,27 +121,41 @@ const resolveMapUrl = (filename, url) => {
121
121
 
122
122
  const resolveSourcesContent = (data, url) => {
123
123
 
124
- const { sources, sourcesContent } = data;
124
+ const {
125
+ sources, sourcesContent, sourceRoot
126
+ } = data;
127
+ // sourceRoot is a directory, even when its trailing slash is omitted.
128
+ let rootUrl;
129
+ if (typeof sourceRoot === 'string' && sourceRoot) {
130
+ rootUrl = Util.resolveUrl(sourceRoot.endsWith('/') ? sourceRoot : `${sourceRoot}/`, url);
131
+ }
125
132
 
126
133
  // sources [1,2,3]
127
134
  // sourcesContent could be [null, null, "content"]
128
135
  // some of contents could be missed
129
136
 
130
137
  let hasSourceContent = false;
138
+ const missingSources = [];
131
139
  sources.forEach((file, i) => {
132
140
  if (typeof sourcesContent[i] === 'string') {
133
141
  hasSourceContent = true;
134
142
  return;
135
143
  }
136
144
 
137
- const sourceUrl = Util.resolveUrl(file, url);
145
+ const sourceUrl = Util.resolveUrl(file, rootUrl?.href || url);
138
146
  if (sourceUrl) {
139
- let sourcePath = sourceUrl.toString();
140
- // could be no `file:`
141
- if (sourcePath.startsWith('file:')) {
142
- sourcePath = fileURLToPath(sourcePath);
147
+ let content;
148
+ try {
149
+ let sourcePath = sourceUrl.toString();
150
+ // Read actual local paths, not normalized virtual report paths.
151
+ if (sourceUrl.protocol === 'file:') {
152
+ sourcePath = fileURLToPath(sourceUrl);
153
+ }
154
+ content = Util.readFileSync(path.resolve(sourcePath));
155
+ } catch {
156
+ // A foreign or malformed file URL is just an unavailable source.
157
+ // Keep resolving the other sources instead of discarding the map.
143
158
  }
144
- const content = Util.readFileSync(path.resolve(sourcePath));
145
159
  if (typeof content === 'string') {
146
160
  sourcesContent[i] = content;
147
161
  hasSourceContent = true;
@@ -149,13 +163,18 @@ const resolveSourcesContent = (data, url) => {
149
163
  }
150
164
  }
151
165
 
152
- sourcesContent[i] = '';
166
+ missingSources.push(i);
153
167
  Util.logDebug(EC.red(`failed to load source content: ${file}`));
154
168
 
155
169
  });
156
170
 
157
171
 
158
172
  if (hasSourceContent) {
173
+ // Only commit placeholders for accepted maps. A rejected map may be
174
+ // cached by the resolver and must not look like valid empty sources later.
175
+ missingSources.forEach((i) => {
176
+ sourcesContent[i] = '';
177
+ });
159
178
  return data;
160
179
  }
161
180
  };
@@ -233,7 +252,7 @@ const collectSourceMaps = async (v8list, options) => {
233
252
  for (const item of v8list) {
234
253
 
235
254
  const {
236
- type, url, id, source, sourceMap
255
+ url, id, source, sourceMap
237
256
  } = item;
238
257
 
239
258
  // source and sourceMap will be saved as separated file (could be cached)
@@ -258,7 +277,7 @@ const collectSourceMaps = async (v8list, options) => {
258
277
 
259
278
  // An explicitly supplied, usable map takes precedence over sourceMappingURL.
260
279
  // Keep the original source so V8 offsets remain valid.
261
- if (type === 'js' && !isUsableSourceMap(sourceData.sourceMap)) {
280
+ if (!isUsableSourceMap(sourceData.sourceMap)) {
262
281
  delete sourceData.sourceMap;
263
282
  const candidates = getSourceMapCandidates(source);
264
283
  if (candidates.length) {
@@ -279,7 +298,7 @@ const collectSourceMaps = async (v8list, options) => {
279
298
  const { url } = sourceData;
280
299
  for (const candidate of candidates) {
281
300
  let data;
282
- let sourceMapUrl;
301
+ let sourceMapUrl = url;
283
302
  try {
284
303
  if (candidate.inline) {
285
304
  data = convertSourceMap.fromComment(candidate.comment).sourcemap;
@@ -293,7 +312,9 @@ const collectSourceMaps = async (v8list, options) => {
293
312
  if (!isUsableSourceMap(data)) {
294
313
  continue;
295
314
  }
296
- const sourceMap = resolveSourceMap(data, url);
315
+ // Inline maps inherit the generated file's URL. External maps
316
+ // resolve their sources relative to the map, not the bundle.
317
+ const sourceMap = resolveSourceMap(data, sourceMapUrl);
297
318
  if (!sourceMap) {
298
319
  continue;
299
320
  }