Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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);
Comment thread
claneo marked this conversation as resolved.
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
92 changes: 91 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,92 @@ 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 {SourceMappingURL} source mapping information
Comment thread
claneo marked this conversation as resolved.
Outdated
*/
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 async function registerSourceMapURL(
filename: string,
code: string,
sourcemapUrlCache: Map<string, string | undefined>,
): Promise<void> {
// process js/cjs/mjs file only
if (!filename.endsWith('js')) return;
Comment thread
claneo marked this conversation as resolved.

const url = getSourceMappingURL(code);
sourcemapUrlCache.set(filename, url);
}
Comment thread
claneo marked this conversation as resolved.
Outdated

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'))
Comment thread
claneo marked this conversation as resolved.
.map(async (filename) => {
let url = sourcemapUrlCache.get(filename);
if (!url) {
const { readFile } = await import('node:fs/promises');
const content = await readFile(filename, 'utf8');
url = getSourceMappingURL(content);
}
sourcemapUrlCache.set(filename, url);
}),
Comment thread
claneo marked this conversation as resolved.
Comment thread
claneo marked this conversation as resolved.
);

// 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);
}
}
Comment thread
claneo marked this conversation as resolved.
if (store) return store.transformCoverage(coverageMap);

return coverageMap;
}
Loading
Loading