diff --git a/packages/istanbul-lib-coverage/src/coverage-map.ts b/packages/istanbul-lib-coverage/src/coverage-map.ts index 4b4bd8b..c15871f 100644 --- a/packages/istanbul-lib-coverage/src/coverage-map.ts +++ b/packages/istanbul-lib-coverage/src/coverage-map.ts @@ -16,12 +16,18 @@ export interface CoverageMapData { [path: string]: FileCoverage | FileCoverageData; } -function maybeConstruct(obj: A | T, klass: new (obj: A) => T): T { - if (obj instanceof klass) { - return obj; +export function isCoverageMap(obj: unknown): obj is CoverageMap { + if (obj instanceof CoverageMap) { + return true; } - return new klass(obj as A); + return ( + typeof obj === "object" && + obj !== null && + typeof (obj as CoverageMap).data === "object" && + typeof (obj as CoverageMap).fileCoverageFor === "function" && + typeof (obj as CoverageMap).addFileCoverage === "function" + ); } function loadMap(source?: CoverageMapData): Record { @@ -31,7 +37,7 @@ function loadMap(source?: CoverageMapData): Record { } Object.entries(source).forEach(([k, cov]) => { - data[k] = maybeConstruct(cov, FileCoverage); + data[k] = cov instanceof FileCoverage ? cov : new FileCoverage(cov); }); return data; @@ -49,6 +55,8 @@ class CoverageMap { constructor(obj?: CoverageMap | CoverageMapData) { if (obj instanceof CoverageMap) { this.data = obj.data; + } else if (isCoverageMap(obj)) { + this.data = loadMap(obj.data); } else { this.data = loadMap(obj); } @@ -61,7 +69,7 @@ class CoverageMap { * as needed. */ merge(obj: CoverageMap | CoverageMapData): void { - const other = maybeConstruct(obj, CoverageMap); + const other = obj instanceof CoverageMap ? obj : new CoverageMap(obj); Object.values(other.data).forEach((fc) => { this.addFileCoverage(fc); }); diff --git a/packages/istanbul-lib-coverage/src/coverage-summary.ts b/packages/istanbul-lib-coverage/src/coverage-summary.ts index 05060d3..00c5f98 100644 --- a/packages/istanbul-lib-coverage/src/coverage-summary.ts +++ b/packages/istanbul-lib-coverage/src/coverage-summary.ts @@ -79,7 +79,7 @@ class CoverageSummary { constructor(obj?: CoverageSummary | CoverageSummaryData) { if (!obj) { this.data = blankSummary(); - } else if (obj instanceof CoverageSummary) { + } else if (isCoverageSummary(obj)) { this.data = obj.data; } else { this.data = obj; @@ -125,4 +125,18 @@ class CoverageSummary { dataProperties(CoverageSummary, ["lines", "statements", "functions", "branches", "branchesTrue"]); +export function isCoverageSummary(obj: unknown): obj is CoverageSummary { + if (obj instanceof CoverageSummary) { + return true; + } + + return ( + typeof obj === "object" && + obj !== null && + typeof (obj as CoverageSummary).data === "object" && + typeof (obj as CoverageSummary).isEmpty === "function" && + typeof (obj as CoverageSummary).merge === "function" + ); +} + export { CoverageSummary }; diff --git a/packages/istanbul-lib-coverage/src/file-coverage.ts b/packages/istanbul-lib-coverage/src/file-coverage.ts index 308d4e8..5283d74 100644 --- a/packages/istanbul-lib-coverage/src/file-coverage.ts +++ b/packages/istanbul-lib-coverage/src/file-coverage.ts @@ -274,7 +274,7 @@ class FileCoverage { } if (typeof pathOrObj === "string") { this.data = emptyCoverage(pathOrObj, reportLogic); - } else if (pathOrObj instanceof FileCoverage) { + } else if (isFileCoverage(pathOrObj)) { this.data = pathOrObj.data; } else if (typeof pathOrObj === "object") { this.data = pathOrObj; @@ -496,6 +496,20 @@ dataProperties(FileCoverage, [ "all", ]); +export function isFileCoverage(obj: unknown): obj is FileCoverage { + if (obj instanceof FileCoverage) { + return true; + } + + return ( + typeof obj === "object" && + obj !== null && + typeof (obj as FileCoverage).data === "object" && + typeof (obj as FileCoverage).toSummary === "function" && + typeof (obj as FileCoverage).getLineCoverage === "function" + ); +} + export { FileCoverage, // exported for testing diff --git a/packages/istanbul-lib-coverage/test/foreign-instance.test.ts b/packages/istanbul-lib-coverage/test/foreign-instance.test.ts new file mode 100644 index 0000000..accef2d --- /dev/null +++ b/packages/istanbul-lib-coverage/test/foreign-instance.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; + +import { CoverageMap } from "../src/coverage-map"; +import { CoverageSummary } from "../src/coverage-summary"; +import { FileCoverage } from "../src/file-coverage"; +import * as index from "../src/index"; + +// Load second, independent copies of the modules. This simulates a duplicate +// install of `@vitest/istanbul-lib-coverage`, where objects created by one copy +// are passed to the other and `instanceof` checks fail. +async function loadForeign(path: string): Promise { + return (await import(/* @vite-ignore */ `${path}?foreign`)) as T; +} + +const foreign = { + CoverageMap: (await loadForeign("../src/coverage-map")) + .CoverageMap, + CoverageSummary: ( + await loadForeign("../src/coverage-summary") + ).CoverageSummary, + FileCoverage: (await loadForeign("../src/file-coverage")) + .FileCoverage, +}; + +describe("instances from a duplicate copy of the package", () => { + it("test setup produces distinct classes", () => { + expect(foreign.CoverageMap).not.toBe(CoverageMap); + expect(foreign.CoverageSummary).not.toBe(CoverageSummary); + expect(foreign.FileCoverage).not.toBe(FileCoverage); + expect(new foreign.CoverageMap()).not.toBeInstanceOf(CoverageMap); + }); + + it("merges a foreign coverage map", () => { + const local = new CoverageMap({ "foo.js": new FileCoverage("foo.js") }); + const other = new foreign.CoverageMap({ + "foo.js": new foreign.FileCoverage("foo.js"), + "bar.js": new foreign.FileCoverage("bar.js"), + }); + + expect(() => local.merge(other)).not.toThrow(); + expect(local.files().sort()).toEqual(["bar.js", "foo.js"]); + expect(local.fileCoverageFor("bar.js")).toBeInstanceOf(FileCoverage); + }); + + it("constructs from a foreign coverage map", () => { + const other = new foreign.CoverageMap({ "foo.js": new foreign.FileCoverage("foo.js") }); + + const local = new CoverageMap(other); + expect(local.files()).toEqual(["foo.js"]); + expect(local.fileCoverageFor("foo.js")).toBeInstanceOf(FileCoverage); + expect(index.createCoverageMap(other)).toBeInstanceOf(CoverageMap); + }); + + it("constructs from and merges a foreign file coverage", () => { + const other = new foreign.FileCoverage("foo.js"); + other.data.statementMap["0"] = { + start: { line: 1, column: 0 }, + end: { line: 1, column: 10 }, + }; + other.data.s["0"] = 3; + + const local = new FileCoverage(other); + expect(local).toBeInstanceOf(FileCoverage); + expect(local.data).toBe(other.data); + expect(index.createFileCoverage(other)).toBeInstanceOf(FileCoverage); + + const map = new CoverageMap(); + map.addFileCoverage(other); + expect(map.fileCoverageFor("foo.js").s).toEqual({ "0": 3 }); + map.addFileCoverage(other); + expect(map.fileCoverageFor("foo.js").s).toEqual({ "0": 6 }); + }); + + it("constructs from and merges a foreign coverage summary", () => { + const other = new foreign.CoverageSummary(); + other.lines.total = 10; + other.lines.covered = 5; + + const local = new CoverageSummary(other); + expect(local).toBeInstanceOf(CoverageSummary); + expect(local.data).toBe(other.data); + expect(index.createCoverageSummary(other)).toBeInstanceOf(CoverageSummary); + + const merged = new CoverageSummary().merge(other); + expect(merged.lines.total).toBe(10); + expect(merged.lines.pct).toBe(50); + }); + + it("still treats raw data objects as data", () => { + const map = new CoverageMap({ "foo.js": new FileCoverage("foo.js").data }); + expect(map.fileCoverageFor("foo.js")).toBeInstanceOf(FileCoverage); + expect(new CoverageSummary(new CoverageSummary().data).isEmpty()).toBe(true); + }); +});