UNPKG

9.36 kBJavaScriptView Raw
1/**
2 * Copyright 2017 Google Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17const {helper, debugError} = require('./helper');
18
19/**
20 * @typedef {Object} CoverageEntry
21 * @property {string} url
22 * @property {string} text
23 * @property {!Array<!{start: number, end: number}>} ranges
24 */
25
26class Coverage {
27 /**
28 * @param {!Puppeteer.CDPSession} client
29 */
30 constructor(client) {
31 this._jsCoverage = new JSCoverage(client);
32 this._cssCoverage = new CSSCoverage(client);
33 }
34
35 /**
36 * @param {!Object} options
37 */
38 async startJSCoverage(options) {
39 return await this._jsCoverage.start(options);
40 }
41
42 /**
43 * @return {!Promise<!Array<!CoverageEntry>>}
44 */
45 async stopJSCoverage() {
46 return await this._jsCoverage.stop();
47 }
48
49 /**
50 * @param {!Object} options
51 */
52 async startCSSCoverage(options) {
53 return await this._cssCoverage.start(options);
54 }
55
56 /**
57 * @return {!Promise<!Array<!CoverageEntry>>}
58 */
59 async stopCSSCoverage() {
60 return await this._cssCoverage.stop();
61 }
62}
63
64module.exports = {Coverage};
65helper.tracePublicAPI(Coverage);
66
67class JSCoverage {
68 /**
69 * @param {!Puppeteer.CDPSession} client
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 * @param {!Object} options
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 * @param {!Protocol.Debugger.scriptParsedPayload} event
110 */
111 async _onScriptParsed(event) {
112 // Ignore anonymous scripts
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 // This might happen if the page has already navigated away.
121 debugError(e);
122 }
123 }
124
125 /**
126 * @return {!Promise<!Array<!CoverageEntry>>}
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
155class CSSCoverage {
156 /**
157 * @param {!Puppeteer.CDPSession} client
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 * @param {!Object} options
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 * @param {!Protocol.CSS.styleSheetAddedPayload} event
197 */
198 async _onStyleSheet(event) {
199 const header = event.header;
200 // Ignore anonymous scripts
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 // This might happen if the page has already navigated away.
209 debugError(e);
210 }
211 }
212
213 /**
214 * @return {!Promise<!Array<!CoverageEntry>>}
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 // aggregate by styleSheetId
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 * @param {!Array<!{startOffset:number, endOffset:number, count:number}>} nestedRanges
255 * @return {!Array<!{start:number, end:number}>}
256 */
257function 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 // Sort points to form a valid parenthesis sequence.
264 points.sort((a, b) => {
265 // Sort with increasing offsets.
266 if (a.offset !== b.offset)
267 return a.offset - b.offset;
268 // All "end" points should go before "start" points.
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 // For two "start" points, the one with longer range goes first.
274 if (a.type === 0)
275 return bLength - aLength;
276 // For two "end" points, the one with shorter range goes first.
277 return aLength - bLength;
278 });
279
280 const hitCountStack = [];
281 const results = [];
282 let lastOffset = 0;
283 // Run scanning line to intersect all ranges.
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 // Filter out empty ranges.
299 return results.filter(range => range.end - range.start > 1);
300}
301