Skip to content

Commit e59c94b

Browse files
authored
feat(coverage): support ignore start/stop ignore hints (#9204)
1 parent 2bea549 commit e59c94b

File tree

11 files changed

+326
-149
lines changed

11 files changed

+326
-149
lines changed

docs/guide/coverage.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,10 @@ Comments which are considered as [legal comments](https://esbuild.github.io/api/
350350
You can include a `@preserve` keyword in the ignore hint.
351351
Beware that these ignore hints may now be included in final production build as well.
352352

353+
::: tip
354+
Follow https://github.com/vitest-dev/vitest/issues/2021 for updates about `@preserve` usage.
355+
:::
356+
353357
```diff
354358
-/* istanbul ignore if */
355359
+/* istanbul ignore if -- @preserve */
@@ -364,6 +368,30 @@ if (condition) {
364368

365369
::: code-group
366370

371+
```ts [lines: start/stop]
372+
/* istanbul ignore start -- @preserve */
373+
if (parameter) { // [!code error]
374+
console.log('Ignored') // [!code error]
375+
} // [!code error]
376+
else { // [!code error]
377+
console.log('Ignored') // [!code error]
378+
} // [!code error]
379+
/* istanbul ignore stop -- @preserve */
380+
381+
console.log('Included')
382+
383+
/* v8 ignore start -- @preserve */
384+
if (parameter) { // [!code error]
385+
console.log('Ignored') // [!code error]
386+
} // [!code error]
387+
else { // [!code error]
388+
console.log('Ignored') // [!code error]
389+
} // [!code error]
390+
/* v8 ignore stop -- @preserve */
391+
392+
console.log('Included')
393+
```
394+
367395
```ts [if else]
368396
/* v8 ignore if -- @preserve */
369397
if (parameter) { // [!code error]

packages/coverage-istanbul/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@
4444
"vitest": "workspace:*"
4545
},
4646
"dependencies": {
47+
"@babel/core": "^7.23.9",
4748
"@istanbuljs/schema": "^0.1.3",
4849
"@jridgewell/gen-mapping": "^0.3.13",
4950
"@jridgewell/trace-mapping": "catalog:",
5051
"istanbul-lib-coverage": "catalog:",
51-
"istanbul-lib-instrument": "^6.0.3",
5252
"istanbul-lib-report": "catalog:",
5353
"istanbul-reports": "catalog:",
5454
"magicast": "catalog:",
@@ -61,6 +61,7 @@
6161
"@types/istanbul-lib-report": "catalog:",
6262
"@types/istanbul-lib-source-maps": "catalog:",
6363
"@types/istanbul-reports": "catalog:",
64+
"istanbul-lib-instrument": "^6.0.3",
6465
"istanbul-lib-source-maps": "catalog:",
6566
"pathe": "catalog:",
6667
"vitest": "workspace:*"

packages/coverage-istanbul/rollup.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const external = [
1919
...Object.keys(pkg.dependencies || {}),
2020
...Object.keys(pkg.peerDependencies || {}),
2121
/^@?vitest(\/|$)/,
22+
23+
// We bundle istanbul-lib-instrument but don't want to bundle its babel dependency
24+
'@babel/core',
2225
]
2326

2427
const dtsUtils = createDtsUtils()
@@ -29,6 +32,7 @@ const plugins = [
2932
json(),
3033
commonjs({
3134
// "istanbul-lib-source-maps > @jridgewell/trace-mapping" is not CJS
35+
// "istanbul-lib-instrument > @jridgewell/trace-mapping" is not CJS
3236
esmExternals: ['@jridgewell/trace-mapping'],
3337
}),
3438
oxc({

packages/coverage-istanbul/src/provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
4848
// @ts-expect-error missing type
4949
importAttributesKeyword: 'with',
5050
},
51+
52+
// Custom option from the patched istanbul-lib-instrument: https://github.com/istanbuljs/istanbuljs/pull/835
53+
ignoreLines: true,
5154
})
5255
}
5356

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
diff --git a/CHANGELOG.md b/CHANGELOG.md
2+
deleted file mode 100644
3+
index 3c2c978099ce42ef26b9cd86d5772cff79e329af..0000000000000000000000000000000000000000
4+
diff --git a/package.json b/package.json
5+
index 5e3c704a13d642e07c6d8eb655cb052b69a99e45..743037fa385ac381b193ac7b975e5f548e064727 100644
6+
--- a/package.json
7+
+++ b/package.json
8+
@@ -13,6 +13,7 @@
9+
"dependencies": {
10+
"@babel/core": "^7.23.9",
11+
"@babel/parser": "^7.23.9",
12+
+ "@jridgewell/trace-mapping": "^0.3.23",
13+
"@istanbuljs/schema": "^0.1.3",
14+
"istanbul-lib-coverage": "^3.2.0",
15+
"semver": "^7.5.4"
16+
diff --git a/src/ignored-lines.js b/src/ignored-lines.js
17+
new file mode 100644
18+
index 0000000000000000000000000000000000000000..5392b2b17165b3bbd52871bb931be1306f7fabe1
19+
--- /dev/null
20+
+++ b/src/ignored-lines.js
21+
@@ -0,0 +1,61 @@
22+
+const IGNORE_LINES_PATTERN = /\s*istanbul\s+ignore\s+(start|stop)/;
23+
+const EOL_PATTERN = /\r?\n/g;
24+
+
25+
+/**
26+
+ * Parse ignore start/stop hints from **text file** based on regular expressions
27+
+ * - Does not understand what a comment is in Javascript (or JSX, Vue, Svelte)
28+
+ * - Parses source code (JS, TS, Vue, Svelte, anything) based on text search by
29+
+ * matching for `/* istanbul ignore start *\/` pattern - not by looking for real comments
30+
+ *
31+
+ * ```js
32+
+ * /* istanbul ignore start *\/
33+
+ * <!-- /* istanbul ignore start *\/ -->
34+
+ * <SomeFrameworkComment content="/* istanbul ignore start *\/">
35+
+ * ```
36+
+ */
37+
+function getIgnoredLines(text) {
38+
+ if (!text) {
39+
+ return new Set();
40+
+ }
41+
+
42+
+ const ranges = [];
43+
+ let lineNumber = 0;
44+
+
45+
+ for (const line of text.split(EOL_PATTERN)) {
46+
+ lineNumber++;
47+
+
48+
+ const match = line.match(IGNORE_LINES_PATTERN);
49+
+ if (match) {
50+
+ const type = match[1];
51+
+
52+
+ if (type === 'stop') {
53+
+ const previous = ranges.at(-1);
54+
+
55+
+ // Ignore whole "ignore stop" if no previous start was found
56+
+ if (previous && previous.stop === Infinity) {
57+
+ previous.stop = lineNumber;
58+
+ }
59+
+
60+
+ continue;
61+
+ }
62+
+
63+
+ ranges.push({ start: lineNumber, stop: Infinity });
64+
+ }
65+
+ }
66+
+
67+
+ const ignoredLines = new Set();
68+
+
69+
+ for (const range of ranges) {
70+
+ for (let line = range.start; line <= range.stop; line++) {
71+
+ ignoredLines.add(line);
72+
+
73+
+ if (line >= lineNumber) {
74+
+ break;
75+
+ }
76+
+ }
77+
+ }
78+
+
79+
+ return ignoredLines;
80+
+}
81+
+
82+
+module.exports = { getIgnoredLines };
83+
diff --git a/src/instrumenter.js b/src/instrumenter.js
84+
index ffc4387b9ba9477bdce3823760400fddb021637d..39f5ee8fede4a6d724b28050e1dd1e1942bd665a 100644
85+
--- a/src/instrumenter.js
86+
+++ b/src/instrumenter.js
87+
@@ -21,6 +21,7 @@ const readInitialCoverage = require('./read-coverage');
88+
* @param {boolean} [opts.autoWrap=false] set to true to allow `return` statements outside of functions.
89+
* @param {boolean} [opts.produceSourceMap=false] set to true to produce a source map for the instrumented code.
90+
* @param {Array} [opts.ignoreClassMethods=[]] set to array of class method names to ignore for coverage.
91+
+ * @param {Array} [opts.ignoreLines=false] enable ignore hints for lines (start, end).
92+
* @param {Function} [opts.sourceMapUrlCallback=null] a callback function that is called when a source map URL
93+
* is found in the original code. This function is called with the source file name and the source map URL.
94+
* @param {boolean} [opts.debug=false] - turn debugging on.
95+
@@ -83,6 +84,7 @@ class Instrumenter {
96+
coverageGlobalScopeFunc:
97+
opts.coverageGlobalScopeFunc,
98+
ignoreClassMethods: opts.ignoreClassMethods,
99+
+ ignoreLines: opts.ignoreLines,
100+
inputSourceMap
101+
});
102+
103+
diff --git a/src/visitor.js b/src/visitor.js
104+
index 04e3115f832799fad6d141e8b0aeaa61ac5c98f9..88f8d2420daabecef2ad2def18c8be245b60e253 100644
105+
--- a/src/visitor.js
106+
+++ b/src/visitor.js
107+
@@ -1,8 +1,17 @@
108+
+const { readFileSync } = require('fs');
109+
const { createHash } = require('crypto');
110+
const { template } = require('@babel/core');
111+
+const {
112+
+ originalPositionFor,
113+
+ TraceMap,
114+
+ GREATEST_LOWER_BOUND,
115+
+ LEAST_UPPER_BOUND,
116+
+ sourceContentFor
117+
+} = require('@jridgewell/trace-mapping');
118+
const { defaults } = require('@istanbuljs/schema');
119+
const { SourceCoverage } = require('./source-coverage');
120+
const { SHA, MAGIC_KEY, MAGIC_VALUE } = require('./constants');
121+
+const { getIgnoredLines } = require('./ignored-lines');
122+
123+
// pattern for istanbul to ignore a section
124+
const COMMENT_RE = /^\s*istanbul\s+ignore\s+(if|else|next)(?=\W|$)/;
125+
@@ -26,7 +35,8 @@ class VisitState {
126+
sourceFilePath,
127+
inputSourceMap,
128+
ignoreClassMethods = [],
129+
- reportLogic = false
130+
+ reportLogic = false,
131+
+ ignoreLines = false
132+
) {
133+
this.varName = genVar(sourceFilePath);
134+
this.attrs = {};
135+
@@ -35,8 +45,13 @@ class VisitState {
136+
137+
if (typeof inputSourceMap !== 'undefined') {
138+
this.cov.inputSourceMap(inputSourceMap);
139+
+
140+
+ if (ignoreLines) {
141+
+ this.traceMap = new TraceMap(inputSourceMap);
142+
+ }
143+
}
144+
this.ignoreClassMethods = ignoreClassMethods;
145+
+ this.ignoredLines = new Map();
146+
this.types = types;
147+
this.sourceMappingURL = null;
148+
this.reportLogic = reportLogic;
149+
@@ -45,7 +60,42 @@ class VisitState {
150+
// should we ignore the node? Yes, if specifically ignoring
151+
// or if the node is generated.
152+
shouldIgnore(path) {
153+
- return this.nextIgnore || !path.node.loc;
154+
+ if (this.nextIgnore || !path.node.loc) {
155+
+ return true;
156+
+ }
157+
+
158+
+ if (!this.traceMap) {
159+
+ return false;
160+
+ }
161+
+
162+
+ // Anything that starts between the line ignore hints is ignored
163+
+ const start = originalPositionTryBoth(
164+
+ this.traceMap,
165+
+ path.node.loc.start
166+
+ );
167+
+
168+
+ // Generated code
169+
+ if (start.line == null) {
170+
+ return false;
171+
+ }
172+
+
173+
+ const filename = start.source;
174+
+ let ignoredLines = this.ignoredLines.get(filename);
175+
+
176+
+ if (!ignoredLines) {
177+
+ const sources = sourceContentFor(this.traceMap, filename);
178+
+ ignoredLines = getIgnoredLines(
179+
+ sources || tryReadFileSync(filename)
180+
+ );
181+
+
182+
+ this.ignoredLines.set(filename, ignoredLines);
183+
+ }
184+
+
185+
+ if (ignoredLines.has(start.line)) {
186+
+ return true;
187+
+ }
188+
+
189+
+ return false;
190+
}
191+
192+
// extract the ignore comment hint (next|if|else) or null
193+
@@ -742,6 +792,7 @@ function shouldIgnoreFile(programNode) {
194+
* @param {string} [opts.coverageGlobalScope=this] the global coverage variable scope.
195+
* @param {boolean} [opts.coverageGlobalScopeFunc=true] use an evaluated function to find coverageGlobalScope.
196+
* @param {Array} [opts.ignoreClassMethods=[]] names of methods to ignore by default on classes.
197+
+ * @param {Array} [opts.ignoreLines=false] enable ignore hints for lines (start, end).
198+
* @param {object} [opts.inputSourceMap=undefined] the input source map, that maps the uninstrumented code back to the
199+
* original code.
200+
*/
201+
@@ -756,7 +807,8 @@ function programVisitor(types, sourceFilePath = 'unknown.js', opts = {}) {
202+
sourceFilePath,
203+
opts.inputSourceMap,
204+
opts.ignoreClassMethods,
205+
- opts.reportLogic
206+
+ opts.reportLogic,
207+
+ opts.ignoreLines
208+
);
209+
return {
210+
enter(path) {
211+
@@ -840,4 +892,29 @@ function programVisitor(types, sourceFilePath = 'unknown.js', opts = {}) {
212+
};
213+
}
214+
215+
+function originalPositionTryBoth(sourceMap, { line, column }) {
216+
+ const mapping = originalPositionFor(sourceMap, {
217+
+ line,
218+
+ column,
219+
+ bias: GREATEST_LOWER_BOUND
220+
+ });
221+
+ if (mapping.source === null) {
222+
+ return originalPositionFor(sourceMap, {
223+
+ line,
224+
+ column,
225+
+ bias: LEAST_UPPER_BOUND
226+
+ });
227+
+ } else {
228+
+ return mapping;
229+
+ }
230+
+}
231+
+
232+
+function tryReadFileSync(filename) {
233+
+ try {
234+
+ return readFileSync(filename, 'utf8');
235+
+ } catch (_) {
236+
+ return undefined;
237+
+ }
238+
+}
239+
+
240+
module.exports = programVisitor;

0 commit comments

Comments
 (0)