1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | const {helper, debugError} = require('./helper');
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 | class Coverage {
|
27 | |
28 |
|
29 |
|
30 | constructor(client) {
|
31 | this._jsCoverage = new JSCoverage(client);
|
32 | this._cssCoverage = new CSSCoverage(client);
|
33 | }
|
34 |
|
35 | |
36 |
|
37 |
|
38 | async startJSCoverage(options) {
|
39 | return await this._jsCoverage.start(options);
|
40 | }
|
41 |
|
42 | |
43 |
|
44 |
|
45 | async stopJSCoverage() {
|
46 | return await this._jsCoverage.stop();
|
47 | }
|
48 |
|
49 | |
50 |
|
51 |
|
52 | async startCSSCoverage(options) {
|
53 | return await this._cssCoverage.start(options);
|
54 | }
|
55 |
|
56 | |
57 |
|
58 |
|
59 | async stopCSSCoverage() {
|
60 | return await this._cssCoverage.stop();
|
61 | }
|
62 | }
|
63 |
|
64 | module.exports = {Coverage};
|
65 | helper.tracePublicAPI(Coverage);
|
66 |
|
67 | class JSCoverage {
|
68 | |
69 |
|
70 |
|
71 | constructor(client) {
|
72 | this._client = client;
|
73 | this._enabled = false;
|
74 | this._scriptURLs = new Map();
|
75 | this._scriptSources = new Map();
|
76 | this._eventListeners = [];
|
77 | this._resetOnNavigation = false;
|
78 | }
|
79 |
|
80 | |
81 |
|
82 |
|
83 | async start(options = {}) {
|
84 | console.assert(!this._enabled, 'JSCoverage is already enabled');
|
85 | this._resetOnNavigation = options.resetOnNavigation === undefined ? true : !!options.resetOnNavigation;
|
86 | this._enabled = true;
|
87 | this._scriptURLs.clear();
|
88 | this._scriptSources.clear();
|
89 | this._eventListeners = [
|
90 | helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)),
|
91 | helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),
|
92 | ];
|
93 | await Promise.all([
|
94 | this._client.send('Profiler.enable'),
|
95 | this._client.send('Profiler.startPreciseCoverage', {callCount: false, detailed: true}),
|
96 | this._client.send('Debugger.enable'),
|
97 | this._client.send('Debugger.setSkipAllPauses', {skip: true})
|
98 | ]);
|
99 | }
|
100 |
|
101 | _onExecutionContextsCleared() {
|
102 | if (!this._resetOnNavigation)
|
103 | return;
|
104 | this._scriptURLs.clear();
|
105 | this._scriptSources.clear();
|
106 | }
|
107 |
|
108 | |
109 |
|
110 |
|
111 | async _onScriptParsed(event) {
|
112 |
|
113 | if (!event.url)
|
114 | return;
|
115 | try {
|
116 | const response = await this._client.send('Debugger.getScriptSource', {scriptId: event.scriptId});
|
117 | this._scriptURLs.set(event.scriptId, event.url);
|
118 | this._scriptSources.set(event.scriptId, response.scriptSource);
|
119 | } catch (e) {
|
120 |
|
121 | debugError(e);
|
122 | }
|
123 | }
|
124 |
|
125 | |
126 |
|
127 |
|
128 | async stop() {
|
129 | console.assert(this._enabled, 'JSCoverage is not enabled');
|
130 | this._enabled = false;
|
131 | const [profileResponse] = await Promise.all([
|
132 | this._client.send('Profiler.takePreciseCoverage'),
|
133 | this._client.send('Profiler.stopPreciseCoverage'),
|
134 | this._client.send('Profiler.disable'),
|
135 | this._client.send('Debugger.disable'),
|
136 | ]);
|
137 | helper.removeEventListeners(this._eventListeners);
|
138 |
|
139 | const coverage = [];
|
140 | for (const entry of profileResponse.result) {
|
141 | const url = this._scriptURLs.get(entry.scriptId);
|
142 | const text = this._scriptSources.get(entry.scriptId);
|
143 | if (text === undefined || url === undefined)
|
144 | continue;
|
145 | const flattenRanges = [];
|
146 | for (const func of entry.functions)
|
147 | flattenRanges.push(...func.ranges);
|
148 | const ranges = convertToDisjointRanges(flattenRanges);
|
149 | coverage.push({url, ranges, text});
|
150 | }
|
151 | return coverage;
|
152 | }
|
153 | }
|
154 |
|
155 | class CSSCoverage {
|
156 | |
157 |
|
158 |
|
159 | constructor(client) {
|
160 | this._client = client;
|
161 | this._enabled = false;
|
162 | this._stylesheetURLs = new Map();
|
163 | this._stylesheetSources = new Map();
|
164 | this._eventListeners = [];
|
165 | this._resetOnNavigation = false;
|
166 | }
|
167 |
|
168 | |
169 |
|
170 |
|
171 | async start(options = {}) {
|
172 | console.assert(!this._enabled, 'CSSCoverage is already enabled');
|
173 | this._resetOnNavigation = options.resetOnNavigation === undefined ? true : !!options.resetOnNavigation;
|
174 | this._enabled = true;
|
175 | this._stylesheetURLs.clear();
|
176 | this._stylesheetSources.clear();
|
177 | this._eventListeners = [
|
178 | helper.addEventListener(this._client, 'CSS.styleSheetAdded', this._onStyleSheet.bind(this)),
|
179 | helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),
|
180 | ];
|
181 | await Promise.all([
|
182 | this._client.send('DOM.enable'),
|
183 | this._client.send('CSS.enable'),
|
184 | this._client.send('CSS.startRuleUsageTracking'),
|
185 | ]);
|
186 | }
|
187 |
|
188 | _onExecutionContextsCleared() {
|
189 | if (!this._resetOnNavigation)
|
190 | return;
|
191 | this._stylesheetURLs.clear();
|
192 | this._stylesheetSources.clear();
|
193 | }
|
194 |
|
195 | |
196 |
|
197 |
|
198 | async _onStyleSheet(event) {
|
199 | const header = event.header;
|
200 |
|
201 | if (!header.sourceURL)
|
202 | return;
|
203 | try {
|
204 | const response = await this._client.send('CSS.getStyleSheetText', {styleSheetId: header.styleSheetId});
|
205 | this._stylesheetURLs.set(header.styleSheetId, header.sourceURL);
|
206 | this._stylesheetSources.set(header.styleSheetId, response.text);
|
207 | } catch (e) {
|
208 |
|
209 | debugError(e);
|
210 | }
|
211 | }
|
212 |
|
213 | |
214 |
|
215 |
|
216 | async stop() {
|
217 | console.assert(this._enabled, 'CSSCoverage is not enabled');
|
218 | this._enabled = false;
|
219 | const [ruleTrackingResponse] = await Promise.all([
|
220 | this._client.send('CSS.stopRuleUsageTracking'),
|
221 | this._client.send('CSS.disable'),
|
222 | this._client.send('DOM.disable'),
|
223 | ]);
|
224 | helper.removeEventListeners(this._eventListeners);
|
225 |
|
226 |
|
227 | const styleSheetIdToCoverage = new Map();
|
228 | for (const entry of ruleTrackingResponse.ruleUsage) {
|
229 | let ranges = styleSheetIdToCoverage.get(entry.styleSheetId);
|
230 | if (!ranges) {
|
231 | ranges = [];
|
232 | styleSheetIdToCoverage.set(entry.styleSheetId, ranges);
|
233 | }
|
234 | ranges.push({
|
235 | startOffset: entry.startOffset,
|
236 | endOffset: entry.endOffset,
|
237 | count: entry.used ? 1 : 0,
|
238 | });
|
239 | }
|
240 |
|
241 | const coverage = [];
|
242 | for (const styleSheetId of this._stylesheetURLs.keys()) {
|
243 | const url = this._stylesheetURLs.get(styleSheetId);
|
244 | const text = this._stylesheetSources.get(styleSheetId);
|
245 | const ranges = convertToDisjointRanges(styleSheetIdToCoverage.get(styleSheetId) || []);
|
246 | coverage.push({url, ranges, text});
|
247 | }
|
248 |
|
249 | return coverage;
|
250 | }
|
251 | }
|
252 |
|
253 |
|
254 |
|
255 |
|
256 |
|
257 | function convertToDisjointRanges(nestedRanges) {
|
258 | const points = [];
|
259 | for (const range of nestedRanges) {
|
260 | points.push({ offset: range.startOffset, type: 0, range });
|
261 | points.push({ offset: range.endOffset, type: 1, range });
|
262 | }
|
263 |
|
264 | points.sort((a, b) => {
|
265 |
|
266 | if (a.offset !== b.offset)
|
267 | return a.offset - b.offset;
|
268 |
|
269 | if (a.type !== b.type)
|
270 | return b.type - a.type;
|
271 | const aLength = a.range.endOffset - a.range.startOffset;
|
272 | const bLength = b.range.endOffset - b.range.startOffset;
|
273 |
|
274 | if (a.type === 0)
|
275 | return bLength - aLength;
|
276 |
|
277 | return aLength - bLength;
|
278 | });
|
279 |
|
280 | const hitCountStack = [];
|
281 | const results = [];
|
282 | let lastOffset = 0;
|
283 |
|
284 | for (const point of points) {
|
285 | if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) {
|
286 | const lastResult = results.length ? results[results.length - 1] : null;
|
287 | if (lastResult && lastResult.end === lastOffset)
|
288 | lastResult.end = point.offset;
|
289 | else
|
290 | results.push({start: lastOffset, end: point.offset});
|
291 | }
|
292 | lastOffset = point.offset;
|
293 | if (point.type === 0)
|
294 | hitCountStack.push(point.range.count);
|
295 | else
|
296 | hitCountStack.pop();
|
297 | }
|
298 |
|
299 | return results.filter(range => range.end - range.start > 1);
|
300 | }
|
301 |
|