monocart-coverage-reports 3.0.0 → 3.1.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'
@@ -1243,6 +1267,27 @@ const coverageOptions = {
1243
1267
  process.env.MCR_LOG_TIME = true;
1244
1268
  ```
1245
1269
 
1270
+ ### CSS coverage issues
1271
+
1272
+ CSS coverage from multiple bundles is merged as a union for each original source.
1273
+
1274
+ For preprocessor syntax that `all` cannot parse directly, use `all.transformer` to compile to CSS, set `entry.type = 'css'`, and preserve the sourcemap.
1275
+
1276
+ #### Sass mixins and `@include`
1277
+
1278
+ 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.
1279
+
1280
+ #### Line coverage of untested preprocessor sources
1281
+
1282
+ 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.
1283
+
1284
+ #### Source map can be inaccurate after minification
1285
+ CSS minification can produce imprecise sourcemaps and trigger fallback to generated CSS. If source mapping is inaccurate, try **disabling CSS minification**.
1286
+
1287
+ #### V8 covered the whole block
1288
+
1289
+ 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.
1290
+
1246
1291
  ## Integration with Any Testing Framework
1247
1292
  - API
1248
1293
  - 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'
@@ -1246,6 +1270,27 @@ const coverageOptions = {
1246
1270
  process.env.MCR_LOG_TIME = true;
1247
1271
  ```
1248
1272
 
1273
+ ### CSS 覆盖率与 sourcemap
1274
+
1275
+ 多个 bundle 映射到同一原始文件时,CSS 覆盖率取并集。
1276
+
1277
+ 对于 `all` 无法直接解析的预处理器语法,请使用 `all.transformer` 编译为 CSS,设置 `entry.type = 'css'`,并保留 sourcemap。
1278
+
1279
+ #### Sass mixin 与 `@include`
1280
+
1281
+ Sass `@include` 可能将一条生成规则映射到不连续的源码位置。规则无法可靠映射时,MCR 会将**整个 bundle 按生成的 CSS 展示**,而不是预处理器源文件;该 bundle 的覆盖率也不会合并到原始源码的覆盖率中。
1282
+
1283
+ #### 未测试预处理源文件的行覆盖率
1284
+
1285
+ 通过 `all` 加入的未测试文件,其字节覆盖率为 `0%`,包括 sourcemap 还原的原始文件。解析或映射规则之外的行(如变量、未使用的 mixin)可能显示为已覆盖;请以 **bytes** 覆盖率为准。
1286
+
1287
+ #### 压缩后 sourcemap 可能不准确
1288
+ CSS 压缩可能产生不准确的 sourcemap,导致报告回退到生成的 CSS。遇到映射不准确时,可尝试**禁用 CSS 压缩**。
1289
+
1290
+ #### V8 会将整个 block 视为已覆盖
1291
+
1292
+ Chromium 可能将整个 media query 或嵌套 CSS block 标记为已覆盖。可考虑使用 [postcss-atomic-selectors](https://www.npmjs.com/package/postcss-atomic-selectors) 作为临时解决方案。
1293
+
1249
1294
  ## Integration with Any Testing Framework
1250
1295
  通用集成方案
1251
1296
  - 通过API接口在程序集成
@@ -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
  }
@@ -11,16 +11,18 @@ import { Util } from '../utils/util.js';
11
11
 
12
12
  import { findOriginalRange } from './find-original-range.js';
13
13
  import { isUncoveredRange } from './source-range-merge.js';
14
+ import { mergeCssBytes } from './css-coverage.js';
14
15
  import { handleUncoveredInCovered } from './fake-source-coverage.js';
15
16
  import { getJsAstInfo, getCssAstInfo } from './ast.js';
16
17
  import { getIgnoredRanges } from './ignore.js';
17
18
 
18
19
  import { getUntestedList } from './untested.js';
19
-
20
20
  import {
21
21
  sortRanges, dedupeCountRanges, mergeRangesWith
22
22
  } from '../utils/dedupe.js';
23
- import { getSourceType, initSourceMapSourcesPath } from '../utils/source-path.js';
23
+ import {
24
+ getSourceType, initSourceMapSourcesPath, cssTypes
25
+ } from '../utils/source-path.js';
24
26
 
25
27
  import { decode } from '@jridgewell/sourcemap-codec';
26
28
 
@@ -831,16 +833,71 @@ const handleOriginalEmptyBytesCoverage = (state, originalStateMap) => {
831
833
  });
832
834
  };
833
835
 
834
-
835
836
  const handleAllOriginalBytesCoverage = (state, originalStateMap) => {
836
837
 
837
- const { js, coverageList } = state;
838
+ const {
839
+ js, coverageList, type
840
+ } = state;
841
+
842
+ if (cssTypes.has(type)) {
843
+ // An intentional exclusion is not a broken source map, even when all
844
+ // sources were excluded and there is nothing left to map.
845
+ if (state.filteredSourceIndexes.size > 0 && state.filteredSourceIndexes.size === state.sourceMap.sources.length) {
846
+ return;
847
+ }
848
+ const positionMap = new Map();
849
+ const couldMapAllRanges = coverageList.every((range) => {
850
+ const {
851
+ start, end, originalState, error, filtered
852
+ } = findOriginalRange(range.start, range.end, state, originalStateMap, {
853
+ fixOriginalRange: true
854
+ });
855
+
856
+ if (filtered) {
857
+ return true;
858
+ }
859
+ if (error) {
860
+ return false;
861
+ }
862
+
863
+ // Offsets are local to each source, not unique across the bundle.
864
+ let positions = positionMap.get(originalState);
865
+ if (!positions) {
866
+ positions = new Set();
867
+ positionMap.set(originalState, positions);
868
+ }
869
+ const positionKey = `${start}-${end}`;
870
+ if (positions.has(positionKey)) {
871
+ return false;
872
+ }
873
+ positions.add(positionKey);
874
+ originalState.bytes.push({
875
+ start, end, count: range.count
876
+ });
877
+ return true;
878
+ });
879
+
880
+ // make sure every range mapped correctly,
881
+ // otherwise discard changes and fall back to dist file to show at least some meaningful coverage.
882
+ // Sass `@include` is a common case: one generated rule maps to both the
883
+ // call site and the mixin body, which cannot be one contiguous original
884
+ // range. See "Sass mixins and @include" in README.
885
+ if (!couldMapAllRanges) {
886
+ Util.logDebug(`Could not map all ranges, fallback to dist file for ${EC.yellow(state.sourcePath)}`);
887
+ state.v8Data.dedupe = false;
888
+ // The fallback is now a real report file, not a debug-only copy.
889
+ delete state.v8Data.debug;
890
+ state.originalList = [];
891
+ originalStateMap.clear();
892
+ generateCoverageForDist(state);
893
+ }
838
894
 
839
- // only for js, no sourcemap for css for now
840
- if (!js) {
841
895
  return;
842
896
  }
843
897
 
898
+ if (!js) {
899
+ return;
900
+ }
844
901
  // v8 coverage
845
902
  coverageList.forEach((block) => {
846
903
  block.ranges.forEach((range) => {
@@ -1046,7 +1103,6 @@ const getOriginalDecodedMappings = (originalDecodedMap, sourceIndex, locator) =>
1046
1103
  };
1047
1104
 
1048
1105
  // ========================================================================================================
1049
-
1050
1106
  const initOriginalList = (state, originalDecodedMap, options) => {
1051
1107
 
1052
1108
  // source filter
@@ -1054,6 +1110,9 @@ const initOriginalList = (state, originalDecodedMap, options) => {
1054
1110
 
1055
1111
  // create original content mappings
1056
1112
  const originalStateMap = new Map();
1113
+ if (!state.js) {
1114
+ state.filteredSourceIndexes = new Set();
1115
+ }
1057
1116
 
1058
1117
  const { sources, sourcesContent } = state.sourceMap;
1059
1118
 
@@ -1065,7 +1124,7 @@ const initOriginalList = (state, originalDecodedMap, options) => {
1065
1124
  // filter
1066
1125
  // do not change for sourceIndex
1067
1126
  if (!sourceFilter(sourcePath)) {
1068
- // console.log('-', sourcePath);
1127
+ state.filteredSourceIndexes?.add(sourceIndex);
1069
1128
  return;
1070
1129
  }
1071
1130
  // console.log(sourcePath);
@@ -1082,14 +1141,12 @@ const initOriginalList = (state, originalDecodedMap, options) => {
1082
1141
 
1083
1142
  const decodedMappings = getOriginalDecodedMappings(originalDecodedMap, sourceIndex, locator);
1084
1143
 
1085
- // unpacked file always is js
1086
-
1087
1144
  const type = getSourceType(sourcePath);
1088
1145
 
1089
1146
  const originalState = {
1090
1147
  original: true,
1091
- // original file is js
1092
- js: true,
1148
+ // Only known style types are CSS; preserve JS coverage for all other sources.
1149
+ js: !cssTypes.has(type),
1093
1150
  type,
1094
1151
  source: sourceContent,
1095
1152
  sourcePath,
@@ -1149,6 +1206,12 @@ const collectOriginalList = (state, originalStateMap) => {
1149
1206
  source
1150
1207
  };
1151
1208
 
1209
+ // Sourcemap gaps must not count as covered bytes for untested files.
1210
+ // Keep the marker so real coverage can replace this entry during merge.
1211
+ if (state.v8Data.empty) {
1212
+ sourceItem.empty = true;
1213
+ }
1214
+
1152
1215
  // save v8 data and add to originalList
1153
1216
  originalState.v8Data = sourceItem;
1154
1217
  state.originalList.push(originalState);
@@ -1695,8 +1758,14 @@ const generateV8DataList = (stateList, options, needsIstanbul) => {
1695
1758
  // exists item
1696
1759
  const prevState = stateMap.get(id);
1697
1760
  if (prevState) {
1698
- // ignore empty item, just override it
1699
- if (!prevState.v8Data.empty) {
1761
+ // Empty entries and debug copies must not replace real original
1762
+ // coverage, nor contribute a duplicate execution to its merge.
1763
+ if (!prevState.v8Data.empty && !prevState.v8Data.debug) {
1764
+ // An untested build may contain different mapped AST nodes
1765
+ // (for example ES5 helpers). It must not alter real metrics.
1766
+ if (v8Data.empty) {
1767
+ return;
1768
+ }
1700
1769
  if (mergeMap.has(id)) {
1701
1770
  mergeMap.get(id).push(originalState);
1702
1771
  } else {
@@ -1715,7 +1784,7 @@ const generateV8DataList = (stateList, options, needsIstanbul) => {
1715
1784
  if (state.js) {
1716
1785
  mergeV8Data(state, mergeMap.get(id));
1717
1786
  } else {
1718
- // should no css here, css can not be in sources
1787
+ state.bytes = mergeCssBytes(mergeMap.get(id).map((it) => it.bytes));
1719
1788
  }
1720
1789
  }
1721
1790
 
@@ -0,0 +1,76 @@
1
+ const getCssRangePoints = (groups) => {
2
+ const points = [];
3
+ groups.forEach((ranges) => {
4
+ ranges.forEach(({
5
+ start, end, count
6
+ }) => {
7
+ if (start >= end) {
8
+ return;
9
+ }
10
+ const covered = count > 0 ? 1 : 0;
11
+ const uncovered = count > 0 ? 0 : 1;
12
+ points.push({
13
+ offset: start, covered, uncovered
14
+ }, {
15
+ offset: end, covered: -covered, uncovered: -uncovered
16
+ });
17
+ });
18
+ });
19
+ return points.sort((a, b) => a.offset - b.offset);
20
+ };
21
+
22
+ const appendCssRange = (merged, start, end, count) => {
23
+ const last = merged[merged.length - 1];
24
+ if (last && last.end === start && last.count === count) {
25
+ last.end = end;
26
+ } else {
27
+ merged.push({
28
+ start, end, count
29
+ });
30
+ }
31
+ };
32
+
33
+ const sweepCssBytes = (groups, uncoveredWins) => {
34
+ const points = getCssRangePoints(groups);
35
+ const merged = [];
36
+ let start = 0;
37
+ let covered = 0;
38
+ let uncovered = 0;
39
+ for (const point of points) {
40
+ const end = point.offset;
41
+ if (start < end && (covered > 0 || uncovered > 0)) {
42
+ const count = uncoveredWins ? Number(uncovered === 0) : Number(covered > 0);
43
+ appendCssRange(merged, start, end, count);
44
+ }
45
+ covered += point.covered;
46
+ uncovered += point.uncovered;
47
+ start = end;
48
+ }
49
+ return merged;
50
+ };
51
+
52
+ const isSortedDisjoint = (ranges) => {
53
+ let end = -Infinity;
54
+ for (const range of ranges) {
55
+ if (!(range.start >= end && range.start < range.end)) {
56
+ return false;
57
+ }
58
+ end = range.end;
59
+ }
60
+ return true;
61
+ };
62
+
63
+ // Within one bundle an uncovered child overrides its covered wrapper. Only
64
+ // after that interpretation may coverage from separate bundles be unioned.
65
+ // Unmapped gaps stay absent from this union; this function does not turn them
66
+ // into covered ranges. Byte summaries still derive covered bytes from total
67
+ // length minus explicit uncovered ranges, so gaps without an uncovered range
68
+ // are reported as covered. Use the line/code views when gaps matter.
69
+ const mergeCssBytes = (groups) => {
70
+ // Ordered, disjoint ranges already have unambiguous per-bundle coverage.
71
+ // Reuse them without allocating intermediate events and normalized ranges.
72
+ const normalized = groups.map((ranges) => (isSortedDisjoint(ranges) ? ranges : sweepCssBytes([ranges], true)));
73
+ return sweepCssBytes(normalized, false);
74
+ };
75
+
76
+ export { mergeCssBytes };