Skip to content

Commit 0540529

Browse files
committed
feat: #1880 coverage configuration 'all' option adds coverage for untested files
1 parent fe1bfd0 commit 0540529

File tree

6 files changed

+165
-54
lines changed

6 files changed

+165
-54
lines changed

docs/docs/test-runner/cli-and-configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ interface CoverageConfig {
104104
report: boolean;
105105
reportDir: string;
106106
reporters?: ReportType[];
107+
// whether to measure coverage of untested files
108+
all?: boolean;
107109
}
108110

109111
type MimeTypeMappings = Record<string, string>;

packages/test-runner-core/src/config/TestRunnerCoreConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface CoverageConfig {
2222
report?: boolean;
2323
reportDir?: string;
2424
reporters?: ReportType[];
25+
all?: boolean;
2526
}
2627

2728
export interface TestRunnerCoreConfig {

packages/test-runner-core/src/coverage/getTestCoverage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,15 @@ function addingMissingCoverageItems(coverages: CoverageMapData[]) {
178178
export function getTestCoverage(
179179
sessions: Iterable<TestSession>,
180180
config?: CoverageConfig,
181+
allFilesCoverage?: CoverageMapData
181182
): TestCoverage {
182183
const coverageMap = createCoverageMap();
183184
let coverages = Array.from(sessions)
184185
.map(s => s.testCoverage)
185186
.filter(c => c) as CoverageMapData[];
187+
if (allFilesCoverage) {
188+
coverages.unshift(allFilesCoverage);
189+
}
186190
// istanbul mutates the coverage objects, which pollutes coverage in watch mode
187191
// cloning prevents this. JSON stringify -> parse is faster than a fancy library
188192
// because we're only working with objects and arrays

packages/test-runner-core/src/runner/TestRunner.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createDebugSessions } from './createDebugSessions.js';
1111
import { TestRunnerServer } from '../server/TestRunnerServer.js';
1212
import { BrowserLauncher } from '../browser-launcher/BrowserLauncher.js';
1313
import { TestRunnerGroupConfig } from '../config/TestRunnerGroupConfig.js';
14+
import { generateEmptyReportsForUntouchedFiles } from '@web/test-runner-coverage-v8';
1415

1516
interface EventMap {
1617
'test-run-started': { testRun: number };
@@ -205,7 +206,11 @@ export class TestRunner extends EventEmitter<EventMap> {
205206
let passedCoverage = true;
206207
let testCoverage: TestCoverage | undefined = undefined;
207208
if (this.config.coverage) {
208-
testCoverage = getTestCoverage(this.sessions.all(), this.config.coverageConfig);
209+
let allFilesCoverage;
210+
if (this.config.coverageConfig?.all) {
211+
allFilesCoverage = await generateEmptyReportsForUntouchedFiles(this.config, this.testFiles);
212+
}
213+
testCoverage = getTestCoverage(this.sessions.all(), this.config.coverageConfig, allFilesCoverage);
209214
passedCoverage = testCoverage.passed;
210215
}
211216

packages/test-runner-coverage-v8/src/index.ts

Lines changed: 141 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { extname, join, isAbsolute, sep, posix } from 'path';
1+
import { extname, join, isAbsolute, sep, posix } from 'node:path';
22
import { CoverageMapData } from 'istanbul-lib-coverage';
33
import v8toIstanbulLib from 'v8-to-istanbul';
44
import { TestRunnerCoreConfig, fetchSourceMap } from '@web/test-runner-core';
55
import { Profiler } from 'inspector';
66
import picoMatch from 'picomatch';
77
import LruCache from 'lru-cache';
8-
import { readFile } from 'node:fs/promises';
9-
10-
import { toFilePath } from './utils.js';
8+
import { readFile, readdir, stat } from 'node:fs/promises';
9+
import { Stats } from 'node:fs';
10+
import { toFilePath, toBrowserPath } from './utils.js';
1111

1212
type V8Coverage = Profiler.ScriptCoverage;
1313
type Matcher = (test: string) => boolean;
@@ -32,11 +32,10 @@ function hasOriginalSource(source: IstanbulSource): boolean {
3232
typeof source.sourceMap.sourcemap === 'object' &&
3333
source.sourceMap.sourcemap !== null &&
3434
Array.isArray(source.sourceMap.sourcemap.sourcesContent) &&
35-
source.sourceMap.sourcemap.sourcesContent.length > 0
36-
);
35+
source.sourceMap.sourcemap.sourcesContent.length > 0);
3736
}
3837

39-
function getMatcher(patterns?: string[]) {
38+
function getMatcher(patterns?: string[]): picoMatch.Matcher {
4039
if (!patterns || patterns.length === 0) {
4140
return () => true;
4241
}
@@ -60,63 +59,154 @@ export async function v8ToIstanbul(
6059
testFiles: string[],
6160
coverage: V8Coverage[],
6261
userAgent?: string,
63-
) {
62+
): Promise<CoverageMapData> {
6463
const included = getMatcher(config?.coverageConfig?.include);
6564
const excluded = getMatcher(config?.coverageConfig?.exclude);
6665
const istanbulCoverage: CoverageMapData = {};
6766

6867
for (const entry of coverage) {
69-
const url = new URL(entry.url);
70-
const path = url.pathname;
71-
if (
72-
// ignore non-http protocols (for exmaple webpack://)
73-
url.protocol.startsWith('http') &&
74-
// ignore external urls
75-
url.hostname === config.hostname &&
76-
url.port === `${config.port}` &&
77-
// ignore non-files
78-
!!extname(path) &&
79-
// ignore virtual files
80-
!path.startsWith('/__web-test-runner') &&
81-
!path.startsWith('/__web-dev-server')
82-
) {
83-
try {
68+
try {
69+
const url = new URL(entry.url);
70+
const path = url.pathname;
71+
if (
72+
// ignore non-http protocols (for exmaple webpack://)
73+
url.protocol.startsWith('http') &&
74+
// ignore external urls
75+
url.hostname === config.hostname &&
76+
url.port === `${config.port}` &&
77+
// ignore non-files
78+
!!extname(path) &&
79+
// ignore virtual files
80+
!path.startsWith('/__web-test-runner') &&
81+
!path.startsWith('/__web-dev-server')
82+
) {
8483
const filePath = join(config.rootDir, toFilePath(path));
85-
8684
if (!testFiles.includes(filePath) && included(filePath) && !excluded(filePath)) {
8785
const browserUrl = `${url.pathname}${url.search}${url.hash}`;
88-
const cachedSource = cachedSources.get(browserUrl);
89-
const sources =
90-
cachedSource ??
91-
((await fetchSourceMap({
92-
protocol: config.protocol,
93-
host: config.hostname,
94-
port: config.port,
95-
browserUrl,
96-
userAgent,
97-
})) as IstanbulSource);
98-
99-
if (!cachedSource) {
100-
if (!hasOriginalSource(sources)) {
101-
const contents = await readFile(filePath, 'utf8');
102-
(sources as IstanbulSource & { originalSource: string }).originalSource = contents;
103-
}
104-
cachedSources.set(browserUrl, sources);
105-
}
106-
107-
const converter = v8toIstanbulLib(filePath, 0, sources);
108-
await converter.load();
109-
110-
converter.applyCoverage(entry.functions);
111-
Object.assign(istanbulCoverage, converter.toIstanbul());
86+
const sources = await getIstanbulSource(config, filePath, browserUrl, userAgent);
87+
await addCoverageForFilePath(sources, filePath, entry, istanbulCoverage);
11288
}
113-
} catch (error) {
114-
console.error(`Error while generating code coverage for ${entry.url}.`);
115-
console.error(error);
89+
}
90+
} catch (error) {
91+
console.error(`Error while generating code coverage for ${entry.url}.`);
92+
console.error(error);
93+
}
94+
}
95+
96+
return istanbulCoverage;
97+
}
98+
99+
async function addCoverageForFilePath(
100+
sources: IstanbulSource,
101+
filePath: string,
102+
entry: V8Coverage,
103+
istanbulCoverage: CoverageMapData,
104+
): Promise<void> {
105+
const converter = v8toIstanbulLib(filePath, 0, sources);
106+
await converter.load();
107+
108+
converter.applyCoverage(entry.functions);
109+
Object.assign(istanbulCoverage, converter.toIstanbul());
110+
}
111+
112+
async function getIstanbulSource(
113+
config: TestRunnerCoreConfig,
114+
filePath: string,
115+
browserUrl: string,
116+
userAgent?: string,
117+
doNotAddToCache?: boolean,
118+
): Promise<IstanbulSource> {
119+
const cachedSource = cachedSources.get(browserUrl);
120+
const sources =
121+
cachedSource ??
122+
((await fetchSourceMap({
123+
protocol: config.protocol,
124+
host: config.hostname,
125+
port: config.port,
126+
browserUrl,
127+
userAgent,
128+
})) as IstanbulSource);
129+
130+
if (!cachedSource) {
131+
if (!hasOriginalSource(sources)) {
132+
const contents = await readFile(filePath, 'utf8');
133+
(sources as IstanbulSource & { originalSource: string }).originalSource = contents;
134+
}
135+
!doNotAddToCache && cachedSources.set(browserUrl, sources);
136+
}
137+
return sources;
138+
}
139+
140+
141+
async function recursivelyAddEmptyReports(
142+
config: TestRunnerCoreConfig,
143+
testFiles: string[],
144+
include: picoMatch.Matcher,
145+
exclude: picoMatch.Matcher,
146+
istanbulCoverage: CoverageMapData,
147+
dir = '',
148+
): Promise<void> {
149+
const contents = await readdir(join(coverageBaseDir, dir));
150+
for (const file of contents) {
151+
const filePath = join(coverageBaseDir, dir, file);
152+
if (!exclude(filePath)) {
153+
const stats = await stat(filePath);
154+
const relativePath = join(dir, file);
155+
if (stats.isDirectory()) {
156+
await recursivelyAddEmptyReports(config, testFiles, include, exclude, istanbulCoverage, relativePath);
157+
} else if (!testFiles.includes(filePath) && include(filePath)) {
158+
await addEmptyReportIfFileUntouched(config, istanbulCoverage, filePath, stats, relativePath);
116159
}
117160
}
118161
}
162+
}
119163

164+
async function addEmptyReportIfFileUntouched(
165+
config: TestRunnerCoreConfig,
166+
istanbulCoverage: CoverageMapData,
167+
filePath: string,
168+
stats: Stats,
169+
relativePath: string,
170+
): Promise<void> {
171+
try {
172+
const browserUrl = toBrowserPath(relativePath);
173+
const fileHasBeenTouched = cachedSources.find((_, key) => {
174+
return key === browserUrl || key.startsWith(browserUrl+'?') || key.startsWith(browserUrl+'#');
175+
});
176+
if (fileHasBeenTouched) {
177+
return;
178+
}
179+
const sources = await getIstanbulSource(config, filePath, browserUrl, undefined, true);
180+
const entry = {
181+
scriptId: browserUrl,
182+
url: browserUrl,
183+
functions: [{
184+
functionName: '(empty-report)',
185+
isBlockCoverage: true,
186+
ranges: [{
187+
startOffset: 0,
188+
endOffset: stats.size,
189+
count: 0
190+
}]
191+
}]
192+
} as V8Coverage;
193+
await addCoverageForFilePath(sources, filePath, entry, istanbulCoverage);
194+
} catch (error) {
195+
console.error(`Error while generating empty code coverage for ${filePath}.`);
196+
console.error(error);
197+
}
198+
}
199+
200+
export async function generateEmptyReportsForUntouchedFiles(
201+
config: TestRunnerCoreConfig,
202+
testFiles: string[],
203+
): Promise<CoverageMapData> {
204+
const istanbulCoverage: CoverageMapData = {};
205+
if (config?.coverageConfig) {
206+
const include = getMatcher(config.coverageConfig.include);
207+
const exclude = getMatcher(config.coverageConfig.exclude);
208+
await recursivelyAddEmptyReports(config, testFiles, include, exclude, istanbulCoverage);
209+
}
120210
return istanbulCoverage;
121211
}
122212

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import path from 'path';
1+
import path from 'node:path';
22

33
const REGEXP_TO_FILE_PATH = new RegExp('/', 'g');
4+
const REGEXP_TO_BROWSER_PATH = new RegExp('\\\\', 'g');
45

5-
export function toFilePath(browserPath: string) {
6+
export function toFilePath(browserPath: string): string {
67
return browserPath.replace(REGEXP_TO_FILE_PATH, path.sep);
78
}
9+
10+
export function toBrowserPath(filePath: string): string {
11+
const replaced = filePath.replace(REGEXP_TO_BROWSER_PATH, '/');
12+
if (replaced[0] !== '/') {
13+
return '/' + replaced;
14+
}
15+
return replaced;
16+
}

0 commit comments

Comments
 (0)