feat: Implement JavaScript Coverage (#1673)

This patch introduces a new `page.coverage` namespace with two methods:
- `page.coverage.startJSCoverage` to initiate JavaScript coverage
  recording
- `page.coverage.stopJSCoverage` to stop JavaScript coverage and get
  results
This commit is contained in:
Andrey Lushnikov 2018-01-02 19:53:53 -08:00 committed by GitHub
parent bd73e4b7b8
commit d062381978
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 366 additions and 0 deletions

View File

@ -52,6 +52,7 @@
* [page.close()](#pageclose)
* [page.content()](#pagecontent)
* [page.cookies(...urls)](#pagecookiesurls)
* [page.coverage](#pagecoverage)
* [page.deleteCookie(...cookies)](#pagedeletecookiecookies)
* [page.emulate(options)](#pageemulateoptions)
* [page.emulateMedia(mediaType)](#pageemulatemediamediatype)
@ -197,6 +198,9 @@
* [target.page()](#targetpage)
* [target.type()](#targettype)
* [target.url()](#targeturl)
- [class: Coverage](#class-coverage)
* [coverage.startJSCoverage(options)](#coveragestartjscoverageoptions)
* [coverage.stopJSCoverage()](#coveragestopjscoverage)
<!-- tocstop -->
@ -600,6 +604,10 @@ Gets the full HTML contents of the page, including the doctype.
If no URLs are specified, this method returns cookies for the current page URL.
If URLs are specified, only cookies for those URLs are returned.
#### page.coverage
- returns: <[Coverage]>
#### page.deleteCookie(...cookies)
- `...cookies` <...[Object]>
- `name` <[string]> **required**
@ -2144,6 +2152,41 @@ Identifies what kind of target this is. Can be `"page"`, `"service_worker"`, or
#### target.url()
- returns: <[string]>
### class: Coverage
#### coverage.startJSCoverage(options)
- `options` <[Object]> Set of configurable options for coverage
- `resetOnNavigation` <[boolean]> Whether to reset coverage on every navigation. Defaults to `true`.
- returns: <[Promise]> Promise that resolves when coverage is started
#### coverage.stopJSCoverage()
- returns: <[Promise]<[Array]<[Object]>>> Promise that resolves to the array of coverage reports for all non-anonymous scripts
- `url` <[string]> Script URL
- `text` <[string]> Script content
- `ranges` <[Array]<[Object]>> Script ranges that were executed. Ranges are sorted and non-overlapping.
- `start` <[number]> A start offset in text, inclusive
- `end` <[number]> An end offset in text, exclusive
> **NOTE** JavaScript Coverage doesn't include anonymous scripts; however, scripts with sourceURLs are
reported.
An example of using JavaScript coverage to get percentage of executed
code:
```js
await page.startJSCoverage();
await page.goto('https://example.com');
const coverage = await page.stopJSCoverage();
let totalBytes = 0;
let usedBytes = 0;
for (const entry of coverage) {
totalBytes += entry.text.length;
for (const range of entry.ranges)
usedBytes += range.end - range.start - 1;
}
console.log(`Coverage is ${usedBytes / totalBytes * 100}%`);
```
[Array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array "Array"
[boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean"
[Buffer]: https://nodejs.org/api/buffer.html#buffer_class_buffer "Buffer"
@ -2158,6 +2201,7 @@ Identifies what kind of target this is. Can be `"page"`, `"service_worker"`, or
[Frame]: #class-frame "Frame"
[ConsoleMessage]: #class-consolemessage "ConsoleMessage"
[ChildProcess]: https://nodejs.org/api/child_process.html "ChildProcess"
[Coverage]: #class-coverage "Coverage"
[iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols "Iterator"
[Response]: #class-response "Response"
[Request]: #class-request "Request"

178
lib/Coverage.js Normal file
View File

@ -0,0 +1,178 @@
/**
* Copyright 2017 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const {helper, debugError} = require('./helper');
class Coverage {
/**
* @param {!Puppeteer.Session} client
*/
constructor(client) {
this._jsCoverage = new JSCoverage(client);
}
/**
* @param {!Object} options
*/
async startJSCoverage(options) {
return await this._jsCoverage.start(options);
}
async stopJSCoverage() {
return await this._jsCoverage.stop();
}
}
module.exports = {Coverage};
helper.tracePublicAPI(Coverage);
class JSCoverage {
/**
* @param {!Puppeteer.Session} client
*/
constructor(client) {
this._client = client;
this._enabled = false;
this._scriptURLs = new Map();
this._scriptSources = new Map();
this._eventListeners = [];
this._resetOnNavigation = false;
}
/**
* @param {!Object} options
*/
async start(options = {}) {
console.assert(!this._enabled, 'JSCoverage is already enabled');
this._resetOnNavigation = options.resetOnNavigation === undefined ? true : !!options.resetOnNavigation;
this._enabled = true;
this._scriptURLs.clear();
this._scriptSources.clear();
this._eventListeners = [
helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)),
helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)),
];
await Promise.all([
this._client.send('Profiler.enable'),
this._client.send('Profiler.startPreciseCoverage', {callCount: false, detailed: true}),
this._client.send('Debugger.enable'),
this._client.send('Debugger.setSkipAllPauses', {skip: true})
]);
}
_onExecutionContextsCleared() {
if (!this._resetOnNavigation)
return;
this._scriptURLs.clear();
this._scriptSources.clear();
}
/**
* @param {!Object} event
*/
async _onScriptParsed(event) {
// Ignore anonymous scripts
if (!event.url)
return;
try {
const response = await this._client.send('Debugger.getScriptSource', {scriptId: event.scriptId});
this._scriptURLs.set(event.scriptId, event.url);
this._scriptSources.set(event.scriptId, response.scriptSource);
} catch (e) {
// This might happen if the page has already navigated away.
debugError(e);
}
}
/**
* @return {!Promise<!Array<!{url:string, text:string, ranges:!Array<!{start:number, end:number}>}>>}
*/
async stop() {
console.assert(this._enabled, 'JSCoverage is not enabled');
this._enabled = false;
const [profileResponse] = await Promise.all([
this._client.send('Profiler.takePreciseCoverage'),
this._client.send('Profiler.stopPreciseCoverage'),
this._client.send('Profiler.disable'),
this._client.send('Debugger.disable'),
]);
helper.removeEventListeners(this._eventListeners);
const coverage = [];
for (const entry of profileResponse.result) {
const url = this._scriptURLs.get(entry.scriptId);
const text = this._scriptSources.get(entry.scriptId);
if (text === undefined || url === undefined)
continue;
const flattenRanges = [];
for (const func of entry.functions)
flattenRanges.push(...func.ranges);
const ranges = convertToDisjointRanges(flattenRanges);
coverage.push({url, ranges, text});
}
return coverage;
}
}
/**
* @param {!Array<!{startOffset:number, endOffset:number, count:number}>} nestedRanges
* @return {!Array<!{start:number, end:number}>}
*/
function convertToDisjointRanges(nestedRanges) {
const points = [];
for (const range of nestedRanges) {
points.push({ offset: range.startOffset, type: 0, range });
points.push({ offset: range.endOffset, type: 1, range });
}
// Sort points to form a valid parenthesis sequence.
points.sort((a, b) => {
// Sort with increasing offsets.
if (a.offset !== b.offset)
return a.offset - b.offset;
// All "end" points should go before "start" points.
if (a.type !== b.type)
return b.type - a.type;
const aLength = a.range.endOffset - a.range.startOffset;
const bLength = b.range.endOffset - b.range.startOffset;
// For two "start" points, the one with longer range goes first.
if (a.type === 0)
return bLength - aLength;
// For two "end" points, the one with shorter range goes first.
return aLength - bLength;
});
const hitCountStack = [];
const results = [];
let lastOffset = 0;
// Run scanning line to intersect all ranges.
for (const point of points) {
if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) {
const lastResult = results.length ? results[results.length - 1] : null;
if (lastResult && lastResult.end === lastOffset)
lastResult.end = point.offset;
else
results.push({start: lastOffset, end: point.offset});
}
lastOffset = point.offset;
if (point.type === 0)
hitCountStack.push(point.range.count);
else
hitCountStack.pop();
}
// Filter out empty ranges.
return results.filter(range => range.end - range.start > 1);
}

View File

@ -25,6 +25,7 @@ const {FrameManager} = require('./FrameManager');
const {Keyboard, Mouse, Touchscreen} = require('./Input');
const Tracing = require('./Tracing');
const {helper, debugError} = require('./helper');
const {Coverage} = require('./Coverage');
const writeFileAsync = helper.promisify(fs.writeFile);
@ -77,6 +78,7 @@ class Page extends EventEmitter {
/** @type {!Map<string, Function>} */
this._pageBindings = new Map();
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
this._coverage = new Coverage(client);
this._screenshotTaskQueue = screenshotTaskQueue;
@ -123,6 +125,13 @@ class Page extends EventEmitter {
return this._touchscreen;
}
/**
* @return {!Coverage}
*/
get coverage() {
return this._coverage;
}
/**
* @param {string} selector
*/

View File

@ -0,0 +1,15 @@
<script>
function foo() {
if (1 > 2)
console.log(1);
if (1 < 2)
console.log(2);
let x = 1 > 2 ? 'foo' : 'bar';
let y = 1 < 2 ? 'foo' : 'bar';
let z = () => {};
let q = () => {};
q();
}
foo();
</script>

View File

@ -0,0 +1,2 @@
<script src='script1.js'></script>
<script src='script2.js'></script>

View File

@ -0,0 +1,2 @@
<script>
function unused(){}console.log('used!');</script>

View File

@ -0,0 +1 @@
console.log(3);

View File

@ -0,0 +1 @@
console.log(3);

View File

@ -0,0 +1,2 @@
<script>
function foo() {function bar() { } console.log(1); } foo(); </script>

View File

@ -0,0 +1,4 @@
<script>
console.log(1);
//# sourceURL=nicename.js
</script>

View File

@ -0,0 +1 @@
<script>function foo() { }</script>

View File

@ -0,0 +1,28 @@
[
{
"url": "http://localhost:8907/jscoverage/involved.html",
"ranges": [
{
"start": 0,
"end": 35
},
{
"start": 50,
"end": 100
},
{
"start": 107,
"end": 141
},
{
"start": 148,
"end": 160
},
{
"start": 168,
"end": 207
}
],
"text": "\nfunction foo() {\n if (1 > 2)\n console.log(1);\n if (1 < 2)\n console.log(2);\n let x = 1 > 2 ? 'foo' : 'bar';\n let y = 1 < 2 ? 'foo' : 'bar';\n let z = () => {};\n let q = () => {};\n q();\n}\n\nfoo();\n"
}
]

View File

@ -3411,6 +3411,84 @@ describe('Page', function() {
}
});
});
describe('JSCoverage', function() {
it('should work', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/simple.html');
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(1);
expect(coverage[0].url).toContain('/jscoverage/simple.html');
expect(coverage[0].ranges).toEqual([
{ start: 0, end: 17 },
{ start: 35, end: 61 },
]);
});
it('should report sourceURLs', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/sourceurl.html');
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(1);
expect(coverage[0].url).toBe('nicename.js');
});
it('should ignore anonymous scripts', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => console.log(1));
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(0);
});
it('should report multiple scripts', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/multiple.html');
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(2);
coverage.sort((a, b) => a.url.localeCompare(b.url));
expect(coverage[0].url).toContain('/jscoverage/script1.js');
expect(coverage[1].url).toContain('/jscoverage/script2.js');
});
it('should report right ranges', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/ranges.html');
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(1);
const entry = coverage[0];
expect(entry.ranges.length).toBe(1);
const range = entry.ranges[0];
expect(entry.text.substring(range.start, range.end)).toBe(`console.log('used!');`);
});
it('should report scripts that have no coverage', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/unused.html');
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(1);
const entry = coverage[0];
expect(entry.url).toContain('unused.html');
expect(entry.ranges.length).toBe(0);
});
it('should work with conditionals', async function({page, server}) {
await page.coverage.startJSCoverage();
await page.goto(server.PREFIX + '/jscoverage/involved.html');
const coverage = await page.coverage.stopJSCoverage();
expect(JSON.stringify(coverage, null, 2)).toBeGolden('jscoverage-involved.txt');
});
describe('resetOnNavigation', function() {
it('should report scripts across navigations when disabled', async function({page, server}) {
await page.coverage.startJSCoverage({resetOnNavigation: false});
await page.goto(server.PREFIX + '/jscoverage/multiple.html');
await page.goto(server.EMPTY_PAGE);
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(2);
});
it('should NOT report scripts across navigations when enabled', async function({page, server}) {
await page.coverage.startJSCoverage(); // Enabled by default.
await page.goto(server.PREFIX + '/jscoverage/multiple.html');
await page.goto(server.EMPTY_PAGE);
const coverage = await page.coverage.stopJSCoverage();
expect(coverage.length).toBe(0);
});
});
});
});
if (process.env.COVERAGE) {

View File

@ -24,6 +24,7 @@ const EXCLUDE_CLASSES = new Set([
'Downloader',
'EmulationManager',
'FrameManager',
'JSCoverage',
'Helper',
'Launcher',
'Multimap',