diff --git a/e2e/test-coverage/fixtures/rstest.config.ts b/e2e/test-coverage/fixtures/rstest.config.ts index 72b7a909b..996610fee 100644 --- a/e2e/test-coverage/fixtures/rstest.config.ts +++ b/e2e/test-coverage/fixtures/rstest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], setupFiles: ['./rstest.setup.ts'], coverage: { reporters: ['text'], diff --git a/e2e/test-coverage/fixtures/rstest.enable.config.ts b/e2e/test-coverage/fixtures/rstest.enable.config.ts index 56da50ad4..fb0d223cc 100644 --- a/e2e/test-coverage/fixtures/rstest.enable.config.ts +++ b/e2e/test-coverage/fixtures/rstest.enable.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', + exclude: ['src/sourcemap.ts'], }, setupFiles: ['./rstest.setup.ts'], }); diff --git a/e2e/test-coverage/fixtures/rstest.globThresholds.config.ts b/e2e/test-coverage/fixtures/rstest.globThresholds.config.ts index 75e01c1e6..80d7cce47 100644 --- a/e2e/test-coverage/fixtures/rstest.globThresholds.config.ts +++ b/e2e/test-coverage/fixtures/rstest.globThresholds.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', diff --git a/e2e/test-coverage/fixtures/rstest.perFileThresholds.config.ts b/e2e/test-coverage/fixtures/rstest.perFileThresholds.config.ts index 8394ab095..b37121915 100644 --- a/e2e/test-coverage/fixtures/rstest.perFileThresholds.config.ts +++ b/e2e/test-coverage/fixtures/rstest.perFileThresholds.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', diff --git a/e2e/test-coverage/fixtures/rstest.reportsDirectory.config.ts b/e2e/test-coverage/fixtures/rstest.reportsDirectory.config.ts index b259e9d26..4ee7eddbc 100644 --- a/e2e/test-coverage/fixtures/rstest.reportsDirectory.config.ts +++ b/e2e/test-coverage/fixtures/rstest.reportsDirectory.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', diff --git a/e2e/test-coverage/fixtures/rstest.skipFull.config.ts b/e2e/test-coverage/fixtures/rstest.skipFull.config.ts index 5636686b3..1c1a504f3 100644 --- a/e2e/test-coverage/fixtures/rstest.skipFull.config.ts +++ b/e2e/test-coverage/fixtures/rstest.skipFull.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', diff --git a/e2e/test-coverage/fixtures/rstest.sourcemap.config.ts b/e2e/test-coverage/fixtures/rstest.sourcemap.config.ts new file mode 100644 index 000000000..ffbb3f8a0 --- /dev/null +++ b/e2e/test-coverage/fixtures/rstest.sourcemap.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + include: ['test/sourcemapMapping.test.ts'], + coverage: { + enabled: true, + provider: 'istanbul', + include: ['test-temp-sourcemap-dist/sourcemap.js'], + clean: true, + reporters: ['text'], + }, +}); diff --git a/e2e/test-coverage/fixtures/rstest.thresholds.config.ts b/e2e/test-coverage/fixtures/rstest.thresholds.config.ts index 659428b41..60520ea79 100644 --- a/e2e/test-coverage/fixtures/rstest.thresholds.config.ts +++ b/e2e/test-coverage/fixtures/rstest.thresholds.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; export default defineConfig({ + exclude: ['test/sourcemapMapping.test.ts'], coverage: { enabled: true, provider: 'istanbul', diff --git a/e2e/test-coverage/fixtures/src/sourcemap.ts b/e2e/test-coverage/fixtures/src/sourcemap.ts new file mode 100644 index 000000000..e454d8954 --- /dev/null +++ b/e2e/test-coverage/fixtures/src/sourcemap.ts @@ -0,0 +1,18 @@ +// Use enum to ensure the compiled JS has different line mapping from the original TS source. +export enum Status { + Active = 'active', + Inactive = 'inactive', +} + +export class Calculator { + public base = 10; + + constructor(public factor: number) {} + + add(val: number) { + if (val > 0) { + return this.base + val * this.factor; + } + return this.base; + } +} diff --git a/e2e/test-coverage/fixtures/test/sourcemapMapping.test.ts b/e2e/test-coverage/fixtures/test/sourcemapMapping.test.ts new file mode 100644 index 000000000..e9f75c585 --- /dev/null +++ b/e2e/test-coverage/fixtures/test/sourcemapMapping.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@rstest/core'; +// @ts-expect-error +import { Calculator, Status } from '../test-temp-sourcemap-dist/sourcemap.js'; + +test('calculator', () => { + const calc = new Calculator(2); + expect(calc.add(5)).toBe(20); + expect(Status.Active).toBe('active'); +}); diff --git a/e2e/test-coverage/sourcemap.test.ts b/e2e/test-coverage/sourcemap.test.ts new file mode 100644 index 000000000..31cf6b54f --- /dev/null +++ b/e2e/test-coverage/sourcemap.test.ts @@ -0,0 +1,77 @@ +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { x } from 'tinyexec'; +import { runRstestCli } from '../scripts'; + +describe('test coverage-istanbul sourcemap', () => { + it('should map coverage back to source files using sourcemaps', async () => { + const fixturePath = join(__dirname, 'fixtures'); + + // 1. Execute tsc in the test case to generate JS files with sourcemaps + const tsc = x( + 'npx', + [ + 'tsc', + '--sourceMap', + '--module', + 'esnext', + '--target', + 'esnext', + '--moduleResolution', + 'node', + '--outDir', + 'test-temp-sourcemap-dist', + 'src/sourcemap.ts', + ], + { + nodeOptions: { + cwd: fixturePath, + }, + }, + ); + await tsc; + + if (tsc.process?.exitCode !== 0) { + throw new Error( + `tsc compilation failed with exit code: ${tsc.process?.exitCode}`, + ); + } + + // 2. Run rstest with configuration including the compiled JS file + const { expectExecSuccess, expectLog, cli } = await runRstestCli({ + command: 'rstest', + args: [ + 'run', + '-c', + 'rstest.sourcemap.config.ts', + 'test/sourcemapMapping.test.ts', + ], + options: { + nodeOptions: { + cwd: fixturePath, + }, + }, + }); + + await expectExecSuccess(); + + const logs = cli.stdout.split('\n').filter(Boolean); + + // 3. Verify that the coverage report shows the original .ts file instead of the compiled .js file + expectLog('sourcemap.ts', logs); + + const sourcemapLog = logs + .find((log) => log.includes('sourcemap.ts')) + ?.replaceAll(' ', ''); + + expect(sourcemapLog).toMatchInlineSnapshot( + `"sourcemap.ts|87.5|75|100|87.5|16"`, + ); + + const allFilesLog = logs + .find((log) => log.includes('All files')) + ?.replaceAll(' ', ''); + + expect(allFilesLog).toMatchInlineSnapshot(`"Allfiles|87.5|75|100|87.5|"`); + }); +}); diff --git a/packages/coverage-istanbul/package.json b/packages/coverage-istanbul/package.json index f7bbf2cde..737744114 100644 --- a/packages/coverage-istanbul/package.json +++ b/packages/coverage-istanbul/package.json @@ -24,20 +24,22 @@ "@rstest/core": "workspace:~" }, "dependencies": { - "swc-plugin-coverage-instrument": "0.0.32", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0" + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "swc-plugin-coverage-instrument": "0.0.32" }, "devDependencies": { - "@rstest/tsconfig": "workspace:*", - "@rstest/core": "workspace:*", "@rslib/core": "^0.19.0", - "@types/node": "^22.16.5", - "typescript": "^5.9.3", + "@rstest/core": "workspace:*", + "@rstest/tsconfig": "workspace:*", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-lib-report": "^3.0.3", - "@types/istanbul-reports": "^3.0.4" + "@types/istanbul-lib-source-maps": "^4.0.4", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "^22.16.5", + "typescript": "^5.9.3" }, "keywords": [ "rstest", diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 3a9e235f3..16c7b9d4f 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -6,7 +6,11 @@ import type { CoverageMap, FileCoverageData } from 'istanbul-lib-coverage'; import istanbulLibCoverage from 'istanbul-lib-coverage'; import { createContext } from 'istanbul-lib-report'; import reports from 'istanbul-reports'; -import { readInitialCoverage } from './utils'; +import { + readInitialCoverage, + registerSourceMapURL, + transformCoverage, +} from './utils'; const { createCoverageMap } = istanbulLibCoverage; @@ -17,6 +21,8 @@ declare global { export class CoverageProvider implements RstestCoverageProvider { private coverageMap: ReturnType | null = null; + // Cache to avoid redundant readFile calls in generateCoverageForUntestedFiles and generateReports. + private sourcemapUrlCache = new Map(); constructor(private options: NormalizedCoverageOptions) {} @@ -47,6 +53,7 @@ export class CoverageProvider implements RstestCoverageProvider { content, file, ); + registerSourceMapURL(file, code, this.sourcemapUrlCache); return readInitialCoverage(code); } catch (e) { console.error( @@ -87,7 +94,10 @@ export class CoverageProvider implements RstestCoverageProvider { try { const context = createContext({ dir: this.options.reportsDirectory, - coverageMap: createCoverageMap(coverageMap.toJSON()), + coverageMap: await transformCoverage( + coverageMap, + this.sourcemapUrlCache, + ), }); const reportersList = this.options.reporters; for (const reporter of reportersList) { diff --git a/packages/coverage-istanbul/src/utils.ts b/packages/coverage-istanbul/src/utils.ts index dbc184636..5114e42d0 100644 --- a/packages/coverage-istanbul/src/utils.ts +++ b/packages/coverage-istanbul/src/utils.ts @@ -1,5 +1,6 @@ import { runInNewContext } from 'node:vm'; -import type { FileCoverageData } from 'istanbul-lib-coverage'; +import type { CoverageMap, FileCoverageData } from 'istanbul-lib-coverage'; +import type { MapStore } from 'istanbul-lib-source-maps'; // ATTENTION: when swc-plugin-coverage-instrument version changed, magic value should be updated too // https://github.com/kwonoj/swc-plugin-coverage-instrument/blob/63e9d5e16dbe61073c62af4b7dfed3c1779cbafa/spec/util/constants.ts#L1-L2 @@ -55,3 +56,94 @@ export function readInitialCoverage( return coverageData; } + +// https://github.com/webpack/webpack/blob/99c36fab8e8b21885f02cca76c253f51b97997eb/lib/util/extractSourceMap.js#L53 + +// Matches only the last occurrence of sourceMappingURL +const innerRegex = /\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*/; + +const sourceMappingURLRegex = new RegExp( + '(?:' + + '/\\*' + + '(?:\\s*\r?\n(?://)?)?' + + `(?:${innerRegex.source})` + + '\\s*' + + '\\*/' + + '|' + + `//(?:${innerRegex.source})` + + ')' + + '\\s*', +); + +/** + * Extract source mapping URL from code comments + * @param {string} code source code content + * @returns {string | undefined} source mapping information + */ +function getSourceMappingURL(code: string): string | undefined { + const lines = code.split(/^/m); + let match: RegExpMatchArray | null | undefined = null; + + for (let i = lines.length - 1; i >= 0; i--) { + match = lines[i]?.match(sourceMappingURLRegex); + if (match) { + break; + } + } + + const sourceMappingURL = match ? match[1] || match[2] || '' : ''; + + return sourceMappingURL ? decodeURI(sourceMappingURL) : sourceMappingURL; +} + +export function registerSourceMapURL( + filename: string, + code: string, + sourcemapUrlCache: Map, +): void { + // process js/cjs/mjs file only + if (!filename.endsWith('js')) return; + + const url = getSourceMappingURL(code); + sourcemapUrlCache.set(filename, url); +} + +export async function transformCoverage( + coverageMap: CoverageMap, + sourcemapUrlCache: Map, +): Promise { + await Promise.all( + coverageMap + .files() + // process js/cjs/mjs file only + .filter((filename) => filename.endsWith('js')) + .map(async (filename) => { + let url = sourcemapUrlCache.get(filename); + if (!url) { + const { readFile } = await import('node:fs/promises'); + url = await readFile(filename, 'utf8').then( + (content) => getSourceMappingURL(content), + () => undefined, + ); + } + sourcemapUrlCache.set(filename, url); + }), + ); + + // Call createSourceMapStore as needed + let store: MapStore | undefined; + for (const [filename, url] of sourcemapUrlCache) { + if (url) { + if (!store) { + const { createSourceMapStore } = await import( + 'istanbul-lib-source-maps' + ); + store = createSourceMapStore(); + } + store.registerURL(filename, url); + } + } + if (store) return store.transformCoverage(coverageMap); + + return coverageMap; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 200a79afb..297365387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -779,6 +779,9 @@ importers: istanbul-lib-report: specifier: ^3.0.1 version: 3.0.1 + istanbul-lib-source-maps: + specifier: ^5.0.6 + version: 5.0.6 istanbul-reports: specifier: ^3.2.0 version: 3.2.0 @@ -801,6 +804,9 @@ importers: '@types/istanbul-lib-report': specifier: ^3.0.3 version: 3.0.3 + '@types/istanbul-lib-source-maps': + specifier: ^4.0.4 + version: 4.0.4 '@types/istanbul-reports': specifier: ^3.0.4 version: 3.0.4 @@ -2860,6 +2866,9 @@ packages: '@types/istanbul-lib-report@3.0.3': resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + '@types/istanbul-lib-source-maps@4.0.4': + resolution: {integrity: sha512-p+nSH0hBMLvuqgnT0rbBnDcfO3IuOZrLU+Yf4x0BhGVmXynB+gm9D35gAvWeMuk+riik5Rj12NBQm8rnzIPH3g==} + '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} @@ -4745,6 +4754,10 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -9696,6 +9709,11 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-lib-source-maps@4.0.4': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + source-map: 0.6.1 + '@types/istanbul-reports@3.0.4': dependencies: '@types/istanbul-lib-report': 3.0.3 @@ -11837,6 +11855,14 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2