UNPKG

15.8 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, assert} = require('./helper');
18
19const {EVALUATION_SCRIPT_URL} = require('./ExecutionContext');
20
21/**
22 * @typedef {Object} CoverageEntry
23 * @property {string} url
24 * @property {string} text
25 * @property {!Array<!{start: number, end: number}>} ranges
26 */
27
28class Coverage {
29 /**
30 * @param {!Puppeteer.CDPSession} client
31 */
32 constructor(client) {
33 this._jsCoverage = new JSCoverage(client);
34 this._cssCoverage = new CSSCoverage(client);
35 }
36
37 /**
38 * @param {!Object} options
39 */
40 /* async */ startJSCoverage(options) {return (fn => {
41 const gen = fn.call(this);
42 return new Promise((resolve, reject) => {
43 function step(key, arg) {
44 let info, value;
45 try {
46 info = gen[key](arg);
47 value = info.value;
48 } catch (error) {
49 reject(error);
50 return;
51 }
52 if (info.done) {
53 resolve(value);
54 } else {
55 return Promise.resolve(value).then(
56 value => {
57 step('next', value);
58 },
59 err => {
60 step('throw', err);
61 });
62 }
63 }
64 return step('next');
65 });
66})(function*(){
67 return (yield this._jsCoverage.start(options));
68 });}
69
70 /**
71 * @return {!Promise<!Array<!CoverageEntry>>}
72 */
73 /* async */ stopJSCoverage() {return (fn => {
74 const gen = fn.call(this);
75 return new Promise((resolve, reject) => {
76 function step(key, arg) {
77 let info, value;
78 try {
79 info = gen[key](arg);
80 value = info.value;
81 } catch (error) {
82 reject(error);
83 return;
84 }
85 if (info.done) {
86 resolve(value);
87 } else {
88 return Promise.resolve(value).then(
89 value => {
90 step('next', value);
91 },
92 err => {
93 step('throw', err);
94 });
95 }
96 }
97 return step('next');
98 });
99})(function*(){
100 return (yield this._jsCoverage.stop());
101 });}
102
103 /**
104 * @param {!Object} options
105 */
106 /* async */ startCSSCoverage(options) {return (fn => {
107 const gen = fn.call(this);
108 return new Promise((resolve, reject) => {
109 function step(key, arg) {
110 let info, value;
111 try {
112 info = gen[key](arg);
113 value = info.value;
114 } catch (error) {
115 reject(error);
116 return;
117 }
118 if (info.done) {
119 resolve(value);
120 } else {
121 return Promise.resolve(value).then(
122 value => {
123 step('next', value);
124 },
125 err => {
126 step('throw', err);
127 });
128 }
129 }
130 return step('next');
131 });
132})(function*(){
133 return (yield this._cssCoverage.start(options));
134 });}
135
136 /**
137 * @return {!Promise<!Array<!CoverageEntry>>}
138 */
139 /* async */ stopCSSCoverage() {return (fn => {
140 const gen = fn.call(this);
141 return new Promise((resolve, reject) => {
142 function step(key, arg) {
143 let info, value;
144 try {
145 info = gen[key](arg);
146 value = info.value;
147 } catch (error) {
148 reject(error);
149 return;
150 }
151 if (info.done) {
152 resolve(value);
153 } else {
154 return Promise.resolve(value).then(
155 value => {
156 step('next', value);
157 },
158 err => {
159 step('throw', err);
160 });
161 }
162 }
163 return step('next');
164 });
165})(function*(){
166 return (yield this._cssCoverage.stop());
167 });}
168}
169
170module.exports = {Coverage};
171helper.tracePublicAPI(Coverage);
172
173class JSCoverage {
174 /**
175 * @param {!Puppeteer.CDPSession} client
176 */
177 constructor(client) {
178 this._client = client;
179 this._enabled = false;
180 this._scriptURLs = new Map();
181 this._scriptSources = new Map();
182 this._eventListeners = [];
183 this._resetOnNavigation = false;
184 }
185
186 /**
187 * @param {!Object} options
188 */
189 /* async */ start(options = {}) {return (fn => {
190 const gen = fn.call(this);
191 return new Promise((resolve, reject) => {
192 function step(key, arg) {
193 let info, value;
194 try {
195 info = gen[key](arg);
196 value = info.value;
197 } catch (error) {
198 reject(error);
199 return;
200 }
201 if (info.done) {
202 resolve(value);
203 } else {
204 return Promise.resolve(value).then(
205 value => {
206 step('next', value);
207 },
208 err => {
209 step('throw', err);
210 });
211 }
212 }
213 return step('next');
214 });
215})(function*(){
216 assert(!this._enabled, 'JSCoverage is already enabled');
217 this._resetOnNavigation = options.resetOnNavigation === undefined ? true : !!options.resetOnNavigation;
218 this._reportAnonymousScripts = !!options.reportAnonymousScripts;
219 this._enabled = true;
220 this._scriptURLs.clear();
221 this._scriptSources.clear();
222 this._eventListeners = [
223 helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)),
224 helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),
225 ];
226 (yield Promise.all([
227 this._client.send('Profiler.enable'),
228 this._client.send('Profiler.startPreciseCoverage', {callCount: false, detailed: true}),
229 this._client.send('Debugger.enable'),
230 this._client.send('Debugger.setSkipAllPauses', {skip: true})
231 ]));
232 });}
233
234 _onExecutionContextsCleared() {
235 if (!this._resetOnNavigation)
236 return;
237 this._scriptURLs.clear();
238 this._scriptSources.clear();
239 }
240
241 /**
242 * @param {!Protocol.Debugger.scriptParsedPayload} event
243 */
244 /* async */ _onScriptParsed(event) {return (fn => {
245 const gen = fn.call(this);
246 return new Promise((resolve, reject) => {
247 function step(key, arg) {
248 let info, value;
249 try {
250 info = gen[key](arg);
251 value = info.value;
252 } catch (error) {
253 reject(error);
254 return;
255 }
256 if (info.done) {
257 resolve(value);
258 } else {
259 return Promise.resolve(value).then(
260 value => {
261 step('next', value);
262 },
263 err => {
264 step('throw', err);
265 });
266 }
267 }
268 return step('next');
269 });
270})(function*(){
271 // Ignore puppeteer-injected scripts
272 if (event.url === EVALUATION_SCRIPT_URL)
273 return;
274 // Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
275 if (!event.url && !this._reportAnonymousScripts)
276 return;
277 try {
278 const response = (yield this._client.send('Debugger.getScriptSource', {scriptId: event.scriptId}));
279 this._scriptURLs.set(event.scriptId, event.url);
280 this._scriptSources.set(event.scriptId, response.scriptSource);
281 } catch (e) {
282 // This might happen if the page has already navigated away.
283 debugError(e);
284 }
285 });}
286
287 /**
288 * @return {!Promise<!Array<!CoverageEntry>>}
289 */
290 /* async */ stop() {return (fn => {
291 const gen = fn.call(this);
292 return new Promise((resolve, reject) => {
293 function step(key, arg) {
294 let info, value;
295 try {
296 info = gen[key](arg);
297 value = info.value;
298 } catch (error) {
299 reject(error);
300 return;
301 }
302 if (info.done) {
303 resolve(value);
304 } else {
305 return Promise.resolve(value).then(
306 value => {
307 step('next', value);
308 },
309 err => {
310 step('throw', err);
311 });
312 }
313 }
314 return step('next');
315 });
316})(function*(){
317 assert(this._enabled, 'JSCoverage is not enabled');
318 this._enabled = false;
319 const [profileResponse] = (yield Promise.all([
320 this._client.send('Profiler.takePreciseCoverage'),
321 this._client.send('Profiler.stopPreciseCoverage'),
322 this._client.send('Profiler.disable'),
323 this._client.send('Debugger.disable'),
324 ]));
325 helper.removeEventListeners(this._eventListeners);
326
327 const coverage = [];
328 for (const entry of profileResponse.result) {
329 let url = this._scriptURLs.get(entry.scriptId);
330 if (!url && this._reportAnonymousScripts)
331 url = 'debugger://VM' + entry.scriptId;
332 const text = this._scriptSources.get(entry.scriptId);
333 if (text === undefined || url === undefined)
334 continue;
335 const flattenRanges = [];
336 for (const func of entry.functions)
337 flattenRanges.push(...func.ranges);
338 const ranges = convertToDisjointRanges(flattenRanges);
339 coverage.push({url, ranges, text});
340 }
341 return coverage;
342 });}
343}
344
345class CSSCoverage {
346 /**
347 * @param {!Puppeteer.CDPSession} client
348 */
349 constructor(client) {
350 this._client = client;
351 this._enabled = false;
352 this._stylesheetURLs = new Map();
353 this._stylesheetSources = new Map();
354 this._eventListeners = [];
355 this._resetOnNavigation = false;
356 }
357
358 /**
359 * @param {!Object} options
360 */
361 /* async */ start(options = {}) {return (fn => {
362 const gen = fn.call(this);
363 return new Promise((resolve, reject) => {
364 function step(key, arg) {
365 let info, value;
366 try {
367 info = gen[key](arg);
368 value = info.value;
369 } catch (error) {
370 reject(error);
371 return;
372 }
373 if (info.done) {
374 resolve(value);
375 } else {
376 return Promise.resolve(value).then(
377 value => {
378 step('next', value);
379 },
380 err => {
381 step('throw', err);
382 });
383 }
384 }
385 return step('next');
386 });
387})(function*(){
388 assert(!this._enabled, 'CSSCoverage is already enabled');
389 this._resetOnNavigation = options.resetOnNavigation === undefined ? true : !!options.resetOnNavigation;
390 this._enabled = true;
391 this._stylesheetURLs.clear();
392 this._stylesheetSources.clear();
393 this._eventListeners = [
394 helper.addEventListener(this._client, 'CSS.styleSheetAdded', this._onStyleSheet.bind(this)),
395 helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),
396 ];
397 (yield Promise.all([
398 this._client.send('DOM.enable'),
399 this._client.send('CSS.enable'),
400 this._client.send('CSS.startRuleUsageTracking'),
401 ]));
402 });}
403
404 _onExecutionContextsCleared() {
405 if (!this._resetOnNavigation)
406 return;
407 this._stylesheetURLs.clear();
408 this._stylesheetSources.clear();
409 }
410
411 /**
412 * @param {!Protocol.CSS.styleSheetAddedPayload} event
413 */
414 /* async */ _onStyleSheet(event) {return (fn => {
415 const gen = fn.call(this);
416 return new Promise((resolve, reject) => {
417 function step(key, arg) {
418 let info, value;
419 try {
420 info = gen[key](arg);
421 value = info.value;
422 } catch (error) {
423 reject(error);
424 return;
425 }
426 if (info.done) {
427 resolve(value);
428 } else {
429 return Promise.resolve(value).then(
430 value => {
431 step('next', value);
432 },
433 err => {
434 step('throw', err);
435 });
436 }
437 }
438 return step('next');
439 });
440})(function*(){
441 const header = event.header;
442 // Ignore anonymous scripts
443 if (!header.sourceURL)
444 return;
445 try {
446 const response = (yield this._client.send('CSS.getStyleSheetText', {styleSheetId: header.styleSheetId}));
447 this._stylesheetURLs.set(header.styleSheetId, header.sourceURL);
448 this._stylesheetSources.set(header.styleSheetId, response.text);
449 } catch (e) {
450 // This might happen if the page has already navigated away.
451 debugError(e);
452 }
453 });}
454
455 /**
456 * @return {!Promise<!Array<!CoverageEntry>>}
457 */
458 /* async */ stop() {return (fn => {
459 const gen = fn.call(this);
460 return new Promise((resolve, reject) => {
461 function step(key, arg) {
462 let info, value;
463 try {
464 info = gen[key](arg);
465 value = info.value;
466 } catch (error) {
467 reject(error);
468 return;
469 }
470 if (info.done) {
471 resolve(value);
472 } else {
473 return Promise.resolve(value).then(
474 value => {
475 step('next', value);
476 },
477 err => {
478 step('throw', err);
479 });
480 }
481 }
482 return step('next');
483 });
484})(function*(){
485 assert(this._enabled, 'CSSCoverage is not enabled');
486 this._enabled = false;
487 const [ruleTrackingResponse] = (yield Promise.all([
488 this._client.send('CSS.stopRuleUsageTracking'),
489 this._client.send('CSS.disable'),
490 this._client.send('DOM.disable'),
491 ]));
492 helper.removeEventListeners(this._eventListeners);
493
494 // aggregate by styleSheetId
495 const styleSheetIdToCoverage = new Map();
496 for (const entry of ruleTrackingResponse.ruleUsage) {
497 let ranges = styleSheetIdToCoverage.get(entry.styleSheetId);
498 if (!ranges) {
499 ranges = [];
500 styleSheetIdToCoverage.set(entry.styleSheetId, ranges);
501 }
502 ranges.push({
503 startOffset: entry.startOffset,
504 endOffset: entry.endOffset,
505 count: entry.used ? 1 : 0,
506 });
507 }
508
509 const coverage = [];
510 for (const styleSheetId of this._stylesheetURLs.keys()) {
511 const url = this._stylesheetURLs.get(styleSheetId);
512 const text = this._stylesheetSources.get(styleSheetId);
513 const ranges = convertToDisjointRanges(styleSheetIdToCoverage.get(styleSheetId) || []);
514 coverage.push({url, ranges, text});
515 }
516
517 return coverage;
518 });}
519}
520
521/**
522 * @param {!Array<!{startOffset:number, endOffset:number, count:number}>} nestedRanges
523 * @return {!Array<!{start:number, end:number}>}
524 */
525function convertToDisjointRanges(nestedRanges) {
526 const points = [];
527 for (const range of nestedRanges) {
528 points.push({ offset: range.startOffset, type: 0, range });
529 points.push({ offset: range.endOffset, type: 1, range });
530 }
531 // Sort points to form a valid parenthesis sequence.
532 points.sort((a, b) => {
533 // Sort with increasing offsets.
534 if (a.offset !== b.offset)
535 return a.offset - b.offset;
536 // All "end" points should go before "start" points.
537 if (a.type !== b.type)
538 return b.type - a.type;
539 const aLength = a.range.endOffset - a.range.startOffset;
540 const bLength = b.range.endOffset - b.range.startOffset;
541 // For two "start" points, the one with longer range goes first.
542 if (a.type === 0)
543 return bLength - aLength;
544 // For two "end" points, the one with shorter range goes first.
545 return aLength - bLength;
546 });
547
548 const hitCountStack = [];
549 const results = [];
550 let lastOffset = 0;
551 // Run scanning line to intersect all ranges.
552 for (const point of points) {
553 if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) {
554 const lastResult = results.length ? results[results.length - 1] : null;
555 if (lastResult && lastResult.end === lastOffset)
556 lastResult.end = point.offset;
557 else
558 results.push({start: lastOffset, end: point.offset});
559 }
560 lastOffset = point.offset;
561 if (point.type === 0)
562 hitCountStack.push(point.range.count);
563 else
564 hitCountStack.pop();
565 }
566 // Filter out empty ranges.
567 return results.filter(range => range.end - range.start > 1);
568}
569