Skip to content

[Bug] merge-coverage: path key duplication on Windows due to backslash vs forward-slash mismatch #202

@nev21

Description

@nev21

Bug Report

Summary

On Windows, running merge-coverage produces a merged coverage-final.json where every source file appears twice — once with a Windows-style path (backslashes) and once with a POSIX-style path (forward slashes). This results in doubled entry counts (e.g. 388 instead of the expected 194).

Root Cause

Two separate path-format issues combine to cause duplication:

1. Inconsistent path separators across coverage tools

  • nyc generates Windows-style absolute paths on Windows:
    D:\gith1\tripwire\core\src\assert\assertClass.ts
  • karma-typescript generates POSIX-style paths even on Windows:
    D:/gith1/tripwire/core/src/assert/assertClass.ts

istanbul-lib-coverage's merge() uses exact string key comparison, so it treats these as different files and includes both in the merged output.

2. The coverage/coverage-final.json exclusion filter fails on Windows

The merge-coverage glob returns backslash-separated paths on Windows (e.g. D:\...\coverage\coverage-final.json), but the exclusion filter uses a forward-slash substring match (indexOf("coverage/coverage-final.json")). The match fails, so the previously-generated merged output is re-included in the next merge run — adding another full copy of all entries.

Steps to Reproduce

  1. On Windows, run tests that produce coverage via both nyc (node) and karma-typescript (browser/worker)
  2. Run merge-coverage
  3. Count keys in the output coverage-final.json — it will be 2× the expected count

Workaround (applied in consumer project)

Added a pre-merge normalization script that:

  1. Converts all backslash path keys (and path properties) to forward slashes in every intermediate coverage-final.json
  2. Deletes the stale root-level coverage-final.json before merging (bypassing the broken exclusion filter)
// normalize-coverage.js
var fs = require("fs"), path = require("path");

function normalizePaths(filePath) {
    var data = JSON.parse(fs.readFileSync(filePath, "utf8"));
    var normalized = {}, changed = false;
    Object.keys(data).forEach(function(key) {
        var nk = key.split("\\").join("/");
        if (nk !== key) { changed = true; }
        normalized[nk] = data[key];
        if (normalized[nk].path) {
            normalized[nk].path = normalized[nk].path.split("\\").join("/");
        }
    });
    if (changed) { fs.writeFileSync(filePath, JSON.stringify(normalized)); }
}

function walk(dir, rootMerged) {
    fs.readdirSync(dir).forEach(function(entry) {
        var p = path.join(dir, entry);
        if (fs.statSync(p).isDirectory()) { walk(p, rootMerged); }
        else if (entry === "coverage-final.json" && p !== rootMerged) { normalizePaths(p); }
    });
}

var coverageDir = path.resolve(__dirname, "coverage");
var rootMerged = path.join(coverageDir, "coverage-final.json");
if (fs.existsSync(rootMerged)) { fs.unlinkSync(rootMerged); }
walk(coverageDir, rootMerged);

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions