diff --git a/docs/api.md b/docs/api.md index 4141bf9e24c..41bbc9e399f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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) @@ -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" diff --git a/lib/Coverage.js b/lib/Coverage.js new file mode 100644 index 00000000000..195dd4ad4ef --- /dev/null +++ b/lib/Coverage.js @@ -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}>>} + */ + 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} nestedRanges + * @return {!Array} + */ +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); +} + diff --git a/lib/Page.js b/lib/Page.js index 297a7064213..82df3cf7300 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -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} */ 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 */ diff --git a/test/assets/jscoverage/involved.html b/test/assets/jscoverage/involved.html new file mode 100644 index 00000000000..889c86bed58 --- /dev/null +++ b/test/assets/jscoverage/involved.html @@ -0,0 +1,15 @@ + diff --git a/test/assets/jscoverage/multiple.html b/test/assets/jscoverage/multiple.html new file mode 100644 index 00000000000..bdef59885b2 --- /dev/null +++ b/test/assets/jscoverage/multiple.html @@ -0,0 +1,2 @@ + + diff --git a/test/assets/jscoverage/ranges.html b/test/assets/jscoverage/ranges.html new file mode 100644 index 00000000000..a537a7da6a7 --- /dev/null +++ b/test/assets/jscoverage/ranges.html @@ -0,0 +1,2 @@ + diff --git a/test/assets/jscoverage/script1.js b/test/assets/jscoverage/script1.js new file mode 100644 index 00000000000..3bd241b50e7 --- /dev/null +++ b/test/assets/jscoverage/script1.js @@ -0,0 +1 @@ +console.log(3); diff --git a/test/assets/jscoverage/script2.js b/test/assets/jscoverage/script2.js new file mode 100644 index 00000000000..3bd241b50e7 --- /dev/null +++ b/test/assets/jscoverage/script2.js @@ -0,0 +1 @@ +console.log(3); diff --git a/test/assets/jscoverage/simple.html b/test/assets/jscoverage/simple.html new file mode 100644 index 00000000000..49eeeea6ae1 --- /dev/null +++ b/test/assets/jscoverage/simple.html @@ -0,0 +1,2 @@ + diff --git a/test/assets/jscoverage/sourceurl.html b/test/assets/jscoverage/sourceurl.html new file mode 100644 index 00000000000..e4777503201 --- /dev/null +++ b/test/assets/jscoverage/sourceurl.html @@ -0,0 +1,4 @@ + diff --git a/test/assets/jscoverage/unused.html b/test/assets/jscoverage/unused.html new file mode 100644 index 00000000000..59c4a5a70b4 --- /dev/null +++ b/test/assets/jscoverage/unused.html @@ -0,0 +1 @@ + diff --git a/test/golden/jscoverage-involved.txt b/test/golden/jscoverage-involved.txt new file mode 100644 index 00000000000..03cd1836aaa --- /dev/null +++ b/test/golden/jscoverage-involved.txt @@ -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" + } +] \ No newline at end of file diff --git a/test/test.js b/test/test.js index 701fdf2ec76..40a068c6ac6 100644 --- a/test/test.js +++ b/test/test.js @@ -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) { diff --git a/utils/doclint/check_public_api/index.js b/utils/doclint/check_public_api/index.js index 82018c13f5a..da2949f2ea1 100644 --- a/utils/doclint/check_public_api/index.js +++ b/utils/doclint/check_public_api/index.js @@ -24,6 +24,7 @@ const EXCLUDE_CLASSES = new Set([ 'Downloader', 'EmulationManager', 'FrameManager', + 'JSCoverage', 'Helper', 'Launcher', 'Multimap',