Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/test-coverage/fixtures/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
setupFiles: ['./rstest.setup.ts'],
coverage: {
reporters: ['text'],
Expand Down
2 changes: 2 additions & 0 deletions e2e/test-coverage/fixtures/rstest.enable.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
});
1 change: 1 addition & 0 deletions e2e/test-coverage/fixtures/rstest.globThresholds.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
coverage: {
enabled: true,
provider: 'istanbul',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
coverage: {
enabled: true,
provider: 'istanbul',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
coverage: {
enabled: true,
provider: 'istanbul',
Expand Down
1 change: 1 addition & 0 deletions e2e/test-coverage/fixtures/rstest.skipFull.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
coverage: {
enabled: true,
provider: 'istanbul',
Expand Down
12 changes: 12 additions & 0 deletions e2e/test-coverage/fixtures/rstest.sourcemap.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
});
1 change: 1 addition & 0 deletions e2e/test-coverage/fixtures/rstest.thresholds.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
exclude: ['test/sourcemapMapping.test.ts'],
coverage: {
enabled: true,
provider: 'istanbul',
Expand Down
18 changes: 18 additions & 0 deletions e2e/test-coverage/fixtures/src/sourcemap.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
9 changes: 9 additions & 0 deletions e2e/test-coverage/fixtures/test/sourcemapMapping.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
77 changes: 77 additions & 0 deletions e2e/test-coverage/sourcemap.test.ts
Original file line number Diff line number Diff line change
@@ -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|"`);
});
});
16 changes: 9 additions & 7 deletions packages/coverage-istanbul/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,6 +21,8 @@ declare global {

export class CoverageProvider implements RstestCoverageProvider {
private coverageMap: ReturnType<typeof createCoverageMap> | null = null;
// Cache to avoid redundant readFile calls in generateCoverageForUntestedFiles and generateReports.
private sourcemapUrlCache = new Map<string, string | undefined>();

constructor(private options: NormalizedCoverageOptions) {}

Expand Down Expand Up @@ -47,6 +53,7 @@ export class CoverageProvider implements RstestCoverageProvider {
content,
file,
);
registerSourceMapURL(file, code, this.sourcemapUrlCache);
return readInitialCoverage(code);
} catch (e) {
console.error(
Expand Down Expand Up @@ -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) {
Expand Down
94 changes: 93 additions & 1 deletion packages/coverage-istanbul/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, string | undefined>,
): 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<string, string | undefined>,
): Promise<CoverageMap> {
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;
}
Loading
Loading