From da393621cf55f02bad3785857b54301e2994525e Mon Sep 17 00:00:00 2001 From: Brendan Kenny Date: Fri, 20 Apr 2018 11:49:06 -0700 Subject: [PATCH 1/3] core(tsc): add type checking to dbw gatherers --- .../dobetterweb/all-event-listeners.js | 131 ++++++++++-------- .../anchors-with-no-rel-noopener.js | 8 +- .../gather/gatherers/dobetterweb/appcache.js | 8 +- .../gather/gatherers/dobetterweb/domstats.js | 40 +++--- .../gatherers/dobetterweb/js-libraries.js | 13 +- .../gatherers/dobetterweb/optimized-images.js | 58 +++++--- .../password-inputs-with-prevented-paste.js | 21 +-- .../dobetterweb/response-compression.js | 39 +++--- .../dobetterweb/tags-blocking-first-paint.js | 115 ++++++++------- .../gather/gatherers/dobetterweb/websql.js | 14 +- lighthouse-core/lib/element.js | 27 +++- .../dobetterweb/response-compression-test.js | 14 +- .../tags-blocking-first-paint-test.js | 2 +- tsconfig.json | 3 +- typings/artifacts.d.ts | 46 ++++++ typings/web-inspector.d.ts | 16 ++- 16 files changed, 343 insertions(+), 212 deletions(-) diff --git a/lighthouse-core/gather/gatherers/dobetterweb/all-event-listeners.js b/lighthouse-core/gather/gatherers/dobetterweb/all-event-listeners.js index c298a1447a92..0789435cb1f6 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/all-event-listeners.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/all-event-listeners.js @@ -11,48 +11,63 @@ 'use strict'; const Gatherer = require('../gatherer'); +const Driver = require('../../driver.js'); // eslint-disable-line no-unused-vars +const Element = require('../../../lib/element.js'); // eslint-disable-line no-unused-vars class EventListeners extends Gatherer { - listenForScriptParsedEvents() { - this._listener = script => { - this._parsedScripts.set(script.scriptId, script); + /** + * @param {Driver} driver + */ + async listenForScriptParsedEvents(driver) { + /** @type {Map} */ + const parsedScripts = new Map(); + /** @param {LH.Crdp.Debugger.ScriptParsedEvent} script */ + const scriptListener = script => { + parsedScripts.set(script.scriptId, script); }; - this.driver.on('Debugger.scriptParsed', this._listener); - return this.driver.sendCommand('Debugger.enable'); - } - unlistenForScriptParsedEvents() { - this.driver.off('Debugger.scriptParsed', this._listener); - return this.driver.sendCommand('Debugger.disable'); + // Enable and disable Debugger domain, triggering flood of parsed scripts. + driver.on('Debugger.scriptParsed', scriptListener); + await driver.sendCommand('Debugger.enable'); + await driver.sendCommand('Debugger.disable'); + driver.off('Debugger.scriptParsed', scriptListener); + + return parsedScripts; } /** + * @param {Driver} driver * @param {number|string} nodeIdOrObject The node id of the element or the - * string of and object ('document', 'window'). - * @return {!Promise>} + * string of an object ('document', 'window'). + * @return {Promise<{listeners: Array, tagName: string}>} * @private */ - _listEventListeners(nodeIdOrObject) { + _listEventListeners(driver, nodeIdOrObject) { let promise; if (typeof nodeIdOrObject === 'string') { - promise = this.driver.sendCommand('Runtime.evaluate', { + promise = driver.sendCommand('Runtime.evaluate', { expression: nodeIdOrObject, objectGroup: 'event-listeners-gatherer', // populates event handler info. - }); + }).then(result => result.result); } else { - promise = this.driver.sendCommand('DOM.resolveNode', { + promise = driver.sendCommand('DOM.resolveNode', { nodeId: nodeIdOrObject, objectGroup: 'event-listeners-gatherer', // populates event handler info. - }); + }).then(result => result.object); } - return promise.then(result => { - const obj = result.object || result.result; - return this.driver.sendCommand('DOMDebugger.getEventListeners', { - objectId: obj.objectId, + return promise.then(obj => { + const objectId = obj.objectId; + const description = obj.description; + if (!objectId || !description) { + return {listeners: [], tagName: ''}; + } + + return driver.sendCommand('DOMDebugger.getEventListeners', { + objectId, }).then(results => { - return {listeners: results.listeners, tagName: obj.description}; + return {listeners: results.listeners, tagName: description}; }); }); } @@ -61,31 +76,35 @@ class EventListeners extends Gatherer { * Collects the event listeners attached to an object and formats the results. * listenForScriptParsedEvents should be called before this method to ensure * the page's parsed scripts are collected at page load. - * @param {string} nodeId The node to look for attached event listeners. - * @return {!Promise>} List of event listeners attached to + * @param {Driver} driver + * @param {Map} parsedScripts + * @param {string|number} nodeId The node to look for attached event listeners. + * @return {Promise} List of event listeners attached to * the node. */ - getEventListeners(nodeId) { + getEventListeners(driver, parsedScripts, nodeId) { + /** @type {LH.Artifacts['EventListeners']} */ const matchedListeners = []; - return this._listEventListeners(nodeId).then(results => { + return this._listEventListeners(driver, nodeId).then(results => { results.listeners.forEach(listener => { // Slim down the list of parsed scripts to match the found event // listeners that have the same script id. - const script = this._parsedScripts.get(listener.scriptId); + const script = parsedScripts.get(listener.scriptId); if (script) { // Combine the EventListener object and the result of the // Debugger.scriptParsed event so we get .url and other // needed properties. - const combo = Object.assign(listener, script); - combo.objectName = results.tagName; - - // Note: line/col numbers are zero-index. Add one to each so we have - // actual file line/col numbers. - combo.line = combo.lineNumber + 1; - combo.col = combo.columnNumber + 1; - - matchedListeners.push(combo); + matchedListeners.push({ + url: script.url, + type: listener.type, + handler: listener.handler, + objectName: results.tagName, + // Note: line/col numbers are zero-index. Add one to each so we have + // actual file line/col numbers. + line: listener.lineNumber + 1, + col: listener.columnNumber + 1, + }); } }); @@ -95,35 +114,33 @@ class EventListeners extends Gatherer { /** * Aggregates the event listeners used on each element into a single list. - * @param {!Array} nodes List of elements to fetch event listeners for. - * @return {!Promise>} Resolves to a list of all the event + * @param {Driver} driver + * @param {Map} parsedScripts + * @param {Array} nodeIds List of objects or nodeIds to fetch event listeners for. + * @return {Promise} Resolves to a list of all the event * listeners found across the elements. */ - collectListeners(nodes) { + collectListeners(driver, parsedScripts, nodeIds) { // Gather event listeners from each node in parallel. - return Promise.all(nodes.map(node => { - return this.getEventListeners(node.element ? node.element.nodeId : node); - })).then(nestedListeners => [].concat(...nestedListeners)); + return Promise.all(nodeIds.map(node => this.getEventListeners(driver, parsedScripts, node))) + .then(nestedListeners => nestedListeners.reduce((prev, curr) => prev.concat(curr))); } /** - * @param {!Object} options - * @return {!Promise>} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { - this.driver = options.driver; - this._parsedScripts = new Map(); - return options.driver.sendCommand('DOM.enable') - .then(() => this.listenForScriptParsedEvents()) - .then(() => this.unlistenForScriptParsedEvents()) - .then(() => options.driver.getElementsInDocument()) - .then(nodes => { - nodes.push('document', 'window'); - return this.collectListeners(nodes); - }).then(listeners => { - return options.driver.sendCommand('DOM.disable') - .then(() => listeners); - }); + async afterPass(passContext) { + const driver = passContext.driver; + await passContext.driver.sendCommand('DOM.enable'); + const parsedScripts = await this.listenForScriptParsedEvents(driver); + + const elements = await passContext.driver.getElementsInDocument(); + const elementIds = [...elements.map(el => el.getNodeId()), 'document', 'window']; + + const listeners = await this.collectListeners(driver, parsedScripts, elementIds); + await passContext.driver.sendCommand('DOM.disable'); + return listeners; } } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js b/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js index b3d0d78a25bf..1a2cd0ce3675 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/anchors-with-no-rel-noopener.js @@ -10,10 +10,10 @@ const DOMHelpers = require('../../../lib/dom-helpers.js'); class AnchorsWithNoRelNoopener extends Gatherer { /** - * @param {!Object} options - * @return {!Promise>} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { + afterPass(passContext) { const expression = `(function() { ${DOMHelpers.getElementsInDocumentFnString}; // define function on page const selector = 'a[target="_blank"]:not([rel~="noopener"]):not([rel~="noreferrer"])'; @@ -25,7 +25,7 @@ class AnchorsWithNoRelNoopener extends Gatherer { })); })()`; - return options.driver.evaluateAsync(expression); + return passContext.driver.evaluateAsync(expression); } } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/appcache.js b/lighthouse-core/gather/gatherers/dobetterweb/appcache.js index cdff85fc208e..8202fcab4803 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/appcache.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/appcache.js @@ -11,11 +11,11 @@ class AppCacheManifest extends Gatherer { /** * Retrurns the value of the html element's manifest attribute or null if it * is not defined. - * @param {!Object} options - * @return {!Promise} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { - const driver = options.driver; + afterPass(passContext) { + const driver = passContext.driver; return driver.querySelector('html') .then(node => node && node.getAttribute('manifest')); diff --git a/lighthouse-core/gather/gatherers/dobetterweb/domstats.js b/lighthouse-core/gather/gatherers/dobetterweb/domstats.js index 36a45c030387..a00fecce7f06 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/domstats.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/domstats.js @@ -3,7 +3,7 @@ * 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. */ - +// @ts-nocheck /** * @fileoverview Gathers stats about the max height and width of the DOM tree * and total number of nodes used on the page. @@ -17,20 +17,20 @@ const Gatherer = require('../gatherer'); /** * Gets the opening tag text of the given node. - * @param {!Node} - * @return {string} + * @param {Element} element + * @return {?string} */ -function getOuterHTMLSnippet(node) { +function getOuterHTMLSnippet(element) { const reOpeningTag = /^.*?>/; - const match = node.outerHTML.match(reOpeningTag); + const match = element.outerHTML.match(reOpeningTag); return match && match[0]; } /** * Constructs a pretty label from element's selectors. For example, given *
, returns 'div#myid.myclass'. - * @param {!HTMLElement} element - * @return {!string} + * @param {Element} element + * @return {string} */ /* istanbul ignore next */ function createSelectorsLabel(element) { @@ -54,8 +54,8 @@ function createSelectorsLabel(element) { } /** - * @param {!HTMLElement} element - * @return {!Array} + * @param {Node} element + * @return {Array} */ /* istanbul ignore next */ function elementPathInDOM(element) { @@ -89,9 +89,9 @@ function elementPathInDOM(element) { /** * Calculates the maximum tree depth of the DOM. - * @param {!HTMLElement} element Root of the tree to look in. + * @param {HTMLElement} element Root of the tree to look in. * @param {boolean=} deep True to include shadow roots. Defaults to true. - * @return {!number} + * @return {LH.Artifacts.DOMStats} */ /* istanbul ignore next */ function getDOMStats(element, deep=true) { @@ -100,6 +100,10 @@ function getDOMStats(element, deep=true) { let maxWidth = 0; let parentWithMostChildren = null; + /** + * @param {Element} element + * @param {number} depth + */ const _calcDOMWidthAndHeight = function(element, depth=1) { if (depth > maxDepth) { deepestNode = element; @@ -141,21 +145,21 @@ function getDOMStats(element, deep=true) { class DOMStats extends Gatherer { /** - * @param {!Object} options - * @return {!Promise>} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { + afterPass(passContext) { const expression = `(function() { ${getOuterHTMLSnippet.toString()}; ${createSelectorsLabel.toString()}; ${elementPathInDOM.toString()}; return (${getDOMStats.toString()}(document.documentElement)); })()`; - return options.driver.sendCommand('DOM.enable') - .then(() => options.driver.evaluateAsync(expression, {useIsolation: true})) - .then(results => options.driver.getElementsInDocument().then(allNodes => { + return passContext.driver.sendCommand('DOM.enable') + .then(() => passContext.driver.evaluateAsync(expression, {useIsolation: true})) + .then(results => passContext.driver.getElementsInDocument().then(allNodes => { results.totalDOMNodes = allNodes.length; - return options.driver.sendCommand('DOM.disable').then(() => results); + return passContext.driver.sendCommand('DOM.disable').then(() => results); })); } } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/js-libraries.js b/lighthouse-core/gather/gatherers/dobetterweb/js-libraries.js index 3201a36cd0d6..06f04d577bbd 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/js-libraries.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/js-libraries.js @@ -20,15 +20,15 @@ const libDetectorSource = fs.readFileSync( /** * Obtains a list of detected JS libraries and their versions. - * @return {!Array} */ -/* eslint-disable camelcase */ /* istanbul ignore next */ function detectLibraries() { + /** @type {LH.Artifacts['JSLibraries']} */ const libraries = []; // d41d8cd98f00b204e9800998ecf8427e_ is a consistent prefix used by the detect libraries // see https://github.com/HTTPArchive/httparchive/issues/77#issuecomment-291320900 + // @ts-ignore - injected libDetectorSource var Object.entries(d41d8cd98f00b204e9800998ecf8427e_LibraryDetectorTests).forEach(([name, lib]) => { try { const result = lib.test(window); @@ -44,20 +44,19 @@ function detectLibraries() { return libraries; } -/* eslint-enable camelcase */ class JSLibraries extends Gatherer { /** - * @param {!Object} options - * @return {!Promise>} + * @param {LH.Gatherer.PassContext} passContext + * @return {!Promise} */ - afterPass(options) { + afterPass(passContext) { const expression = `(function () { ${libDetectorSource}; return (${detectLibraries.toString()}()); })()`; - return options.driver.evaluateAsync(expression); + return passContext.driver.evaluateAsync(expression); } } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js index b773c588a925..b6fd9a5d0ca3 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js @@ -13,6 +13,7 @@ const Gatherer = require('../gatherer'); const URL = require('../../../lib/url-shim'); const Sentry = require('../../../lib/sentry'); +const Driver = require('../../driver.js'); // eslint-disable-line no-unused-vars const JPEG_QUALITY = 0.92; const WEBP_QUALITY = 0.85; @@ -24,7 +25,7 @@ const MINIMUM_IMAGE_SIZE = 4096; // savings of <4 KB will be ignored in the audi /** * Runs in the context of the browser * @param {string} url - * @return {!Promise<{jpeg: Object, webp: Object}>} + * @return {Promise<{jpeg: Object, webp: Object}>} */ /* istanbul ignore next */ function getOptimizedNumBytes(url) { @@ -32,7 +33,14 @@ function getOptimizedNumBytes(url) { const img = new Image(); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); + if (!context) { + return reject(new Error('unable to create canvas context')); + } + /** + * @param {'image/jpeg'|'image/webp'} type + * @param {number} quality + */ function getTypeStats(type, quality) { const dataURI = canvas.toDataURL(type, quality); const base64 = dataURI.slice(dataURI.indexOf(',') + 1); @@ -62,8 +70,8 @@ function getOptimizedNumBytes(url) { class OptimizedImages extends Gatherer { /** * @param {string} pageUrl - * @param {!NetworkRecords} networkRecords - * @return {!Array<{url: string, isBase64DataUri: boolean, mimeType: string, resourceSize: number}>} + * @param {Array} networkRecords + * @return {Array} */ static filterImageRequests(pageUrl, networkRecords) { const seenUrls = new Set(); @@ -79,7 +87,7 @@ class OptimizedImages extends Gatherer { const isSameOrigin = URL.originsMatch(pageUrl, record._url); const isBase64DataUri = /^data:.{2,40}base64\s*,/.test(record._url); - const actualResourceSize = Math.min(record._resourceSize, record._transferSize); + const actualResourceSize = Math.min(record._resourceSize || 0, record._transferSize || 0); if (isOptimizableImage && actualResourceSize > MINIMUM_IMAGE_SIZE) { prev.push({ isSameOrigin, @@ -92,14 +100,14 @@ class OptimizedImages extends Gatherer { } return prev; - }, []); + }, /** @type {Array} */ ([])); } /** - * @param {!Object} driver + * @param {Driver} driver * @param {string} requestId - * @param {string} encoding Either webp or jpeg. - * @return {!Promise<{encodedSize: number}>} + * @param {'jpeg'|'webp'} encoding Either webp or jpeg. + * @return {Promise} */ _getEncodedResponse(driver, requestId, encoding) { const quality = encoding === 'jpeg' ? JPEG_QUALITY : WEBP_QUALITY; @@ -108,9 +116,9 @@ class OptimizedImages extends Gatherer { } /** - * @param {!Object} driver - * @param {{url: string, isBase64DataUri: boolean, resourceSize: number}} networkRecord - * @return {!Promise} + * @param {Driver} driver + * @param {SimplifiedNetworkRecord} networkRecord + * @return {Promise} */ calculateImageStats(driver, networkRecord) { // TODO(phulce): remove this dance of trying _getEncodedResponse with a fallback when Audits @@ -157,17 +165,21 @@ class OptimizedImages extends Gatherer { } /** - * @param {!Object} driver - * @param {!Array} imageRecords - * @return {!Promise>} + * @param {Driver} driver + * @param {Array} imageRecords + * @return {Promise} */ computeOptimizedImages(driver, imageRecords) { + /** @type {LH.Artifacts['OptimizedImages']} */ + const result = []; + return imageRecords.reduce((promise, record) => { return promise.then(results => { return this.calculateImageStats(driver, record) .catch(err => { // Track this with Sentry since these errors aren't surfaced anywhere else, but we don't // want to tank the entire run due to a single image. + // @ts-ignore TODO(bckenny): Sentry type checking Sentry.captureException(err, { tags: {gatherer: 'OptimizedImages'}, extra: {imageUrl: URL.elideDataURI(record.url)}, @@ -183,20 +195,22 @@ class OptimizedImages extends Gatherer { return results.concat(Object.assign(stats, record)); }); }); - }, Promise.resolve([])); + }, Promise.resolve(result)); } + /** @typedef {{isSameOrigin: boolean, isBase64DataUri: boolean, requestId: string, url: string, mimeType: string, resourceSize: number}} SimplifiedNetworkRecord */ + /** - * @param {!Object} options - * @param {{networkRecords: !Array}} traceData - * @return {!Promise} + * @param {LH.Gatherer.PassContext} passContext + * @param {LH.Gatherer.LoadData} loadData + * @return {Promise} */ - afterPass(options, traceData) { - const networkRecords = traceData.networkRecords; - const imageRecords = OptimizedImages.filterImageRequests(options.url, networkRecords); + afterPass(passContext, loadData) { + const networkRecords = loadData.networkRecords; + const imageRecords = OptimizedImages.filterImageRequests(passContext.url, networkRecords); return Promise.resolve() - .then(_ => this.computeOptimizedImages(options.driver, imageRecords)) + .then(_ => this.computeOptimizedImages(passContext.driver, imageRecords)) .then(results => { const successfulResults = results.filter(result => !result.failed); if (results.length && !successfulResults.length) { diff --git a/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js b/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js index 84e270993e2f..20adfdca485a 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/password-inputs-with-prevented-paste.js @@ -10,16 +10,20 @@ const Gatherer = require('../gatherer'); // This is run in the page, not Lighthouse itself. +/** + * @return {LH.Artifacts['PasswordInputsWithPreventedPaste']} + */ /* istanbul ignore next */ function findPasswordInputsWithPreventedPaste() { /** - * Gets the opening tag text of the given node. - * @param {!Node} + * Gets the opening tag text of the given element. + * @param {Element} element * @return {string} */ - function getOuterHTMLSnippet(node) { + function getOuterHTMLSnippet(element) { const reOpeningTag = /^.*?>/; - const match = node.outerHTML.match(reOpeningTag); + const match = element.outerHTML.match(reOpeningTag); + // @ts-ignore We are confident match was found. return match && match[0]; } @@ -36,12 +40,11 @@ function findPasswordInputsWithPreventedPaste() { class PasswordInputsWithPreventedPaste extends Gatherer { /** - * @param {!Object} options - * @return {!Promise>} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { - const driver = options.driver; - return driver.evaluateAsync( + afterPass(passContext) { + return passContext.driver.evaluateAsync( `(${findPasswordInputsWithPreventedPaste.toString()}())` ); } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/response-compression.js b/lighthouse-core/gather/gatherers/dobetterweb/response-compression.js index e6c6d68c7a18..9a93fb1973b0 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/response-compression.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/response-compression.js @@ -18,28 +18,32 @@ const compressionTypes = ['gzip', 'br', 'deflate']; const binaryMimeTypes = ['image', 'audio', 'video']; const CHROME_EXTENSION_PROTOCOL = 'chrome-extension:'; +/** @typedef {{requestId: string, url: string, mimeType: string, transferSize: number, resourceSize: number, gzipSize: number}} ResponseInfo */ + class ResponseCompression extends Gatherer { /** - * @param {!NetworkRecords} networkRecords - * @return {!Array<{url: string, isBase64DataUri: boolean, mimeType: string, resourceSize: number}>} + * @param {Array} networkRecords + * @return {Array} */ static filterUnoptimizedResponses(networkRecords) { + /** @type {Array} */ const unoptimizedResponses = []; networkRecords.forEach(record => { - const mimeType = record.mimeType; - const resourceType = record.resourceType(); + const mimeType = record._mimeType; + const resourceType = record._resourceType; + const resourceSize = record._resourceSize; const isBinaryResource = mimeType && binaryMimeTypes.some(type => mimeType.startsWith(type)); const isTextBasedResource = !isBinaryResource && resourceType && resourceType.isTextType(); const isChromeExtensionResource = record.url.startsWith(CHROME_EXTENSION_PROTOCOL); - if (!isTextBasedResource || !record.resourceSize || !record.finished || + if (!isTextBasedResource || !resourceSize || !record.finished || isChromeExtensionResource || !record.transferSize || record.statusCode === 304) { return; } - const isContentEncoded = record.responseHeaders.find(header => + const isContentEncoded = (record._responseHeaders || []).find(header => compressionHeaders.includes(header.name.toLowerCase()) && compressionTypes.includes(header.value) ); @@ -48,9 +52,10 @@ class ResponseCompression extends Gatherer { unoptimizedResponses.push({ requestId: record.requestId, url: record.url, - mimeType: record.mimeType, + mimeType: mimeType, transferSize: record.transferSize, - resourceSize: record.resourceSize, + resourceSize: resourceSize, + gzipSize: 0, }); } }); @@ -58,19 +63,19 @@ class ResponseCompression extends Gatherer { return unoptimizedResponses; } - afterPass(options, traceData) { - const networkRecords = traceData.networkRecords; + /** + * @param {LH.Gatherer.PassContext} passContext + * @param {LH.Gatherer.LoadData} loadData + */ + afterPass(passContext, loadData) { + const networkRecords = loadData.networkRecords; const textRecords = ResponseCompression.filterUnoptimizedResponses(networkRecords); - const driver = options.driver; + const driver = passContext.driver; return Promise.all(textRecords.map(record => { - const contentPromise = driver.getRequestContent(record.requestId); - const timeoutPromise = new Promise(resolve => setTimeout(resolve, 3000)); - return Promise.race([contentPromise, timeoutPromise]).then(content => { - // if we don't have any content gzipSize is set to 0 + return driver.getRequestContent(record.requestId).then(content => { + // if we don't have any content, gzipSize is already set to 0 if (!content) { - record.gzipSize = 0; - return record; } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js index ca5dbe03d56d..736b612296e9 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js @@ -3,7 +3,7 @@ * 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. */ - +// @ts-nocheck - TODO: cut down on exported artifact properties not needed by audits /** * @fileoverview * Identifies stylesheets, HTML Imports, and scripts that potentially block @@ -20,6 +20,7 @@ 'use strict'; const Gatherer = require('../gatherer'); +const Driver = require('../../driver.js'); // eslint-disable-line no-unused-vars /* global document,window,HTMLLinkElement */ @@ -41,27 +42,34 @@ function installMediaListener() { } /* istanbul ignore next */ +/** + * @return {Promise<{tagName: string, url: string, src: string, href: string, rel: string, media: string, disabled: boolean, mediaChanges: {href: string, media: string, msSinceHTMLEnd: number, matches: boolean}}>} + */ function collectTagsThatBlockFirstPaint() { return new Promise((resolve, reject) => { try { const tagList = [...document.querySelectorAll('link, head script[src]')] .filter(tag => { if (tag.tagName === 'SCRIPT') { + const scriptTag = /** @type {HTMLScriptElement} */ (tag); return ( - !tag.hasAttribute('async') && - !tag.hasAttribute('defer') && - !/^data:/.test(tag.src) && - tag.getAttribute('type') !== 'module' + !scriptTag.hasAttribute('async') && + !scriptTag.hasAttribute('defer') && + !/^data:/.test(scriptTag.src) && + scriptTag.getAttribute('type') !== 'module' ); + } else if (tag.tagName === 'LINK') { + // Filter stylesheet/HTML imports that block rendering. + // https://www.igvita.com/2012/06/14/debunking-responsive-css-performance-myths/ + // https://www.w3.org/TR/html-imports/#dfn-import-async-attribute + const linkTag = /** @type {HTMLLinkElement} */ (tag); + const blockingStylesheet = linkTag.rel === 'stylesheet' && + window.matchMedia(linkTag.media).matches && !linkTag.disabled; + const blockingImport = linkTag.rel === 'import' && !linkTag.hasAttribute('async'); + return blockingStylesheet || blockingImport; } - // Filter stylesheet/HTML imports that block rendering. - // https://www.igvita.com/2012/06/14/debunking-responsive-css-performance-myths/ - // https://www.w3.org/TR/html-imports/#dfn-import-async-attribute - const blockingStylesheet = - tag.rel === 'stylesheet' && window.matchMedia(tag.media).matches && !tag.disabled; - const blockingImport = tag.rel === 'import' && !tag.hasAttribute('async'); - return blockingStylesheet || blockingImport; + return false; }) .map(tag => { return { @@ -83,40 +91,45 @@ function collectTagsThatBlockFirstPaint() { }); } -function filteredAndIndexedByUrl(networkRecords) { - return networkRecords.reduce((prev, record) => { - if (!record.finished) { - return prev; - } - - const isParserGenerated = record._initiator.type === 'parser'; - // A stylesheet only blocks script if it was initiated by the parser - // https://html.spec.whatwg.org/multipage/semantics.html#interactions-of-styling-and-scripting - const isParserScriptOrStyle = /(css|script)/.test(record._mimeType) && isParserGenerated; - const isFailedRequest = record._failed; - const isHtml = record._mimeType && record._mimeType.includes('html'); - - // Filter stylesheet, javascript, and html import mimetypes. - // Include 404 scripts/links generated by the parser because they are likely blocking. - if (isHtml || isParserScriptOrStyle || (isFailedRequest && isParserGenerated)) { - prev[record._url] = { - isLinkPreload: record.isLinkPreload, - transferSize: record._transferSize, - startTime: record._startTime, - endTime: record._endTime, - }; - } +class TagsBlockingFirstPaint extends Gatherer { + /** + * @param {Array} networkRecords + */ + static _filteredAndIndexedByUrl(networkRecords) { + /** @type {Object} */ + const result = {}; - return prev; - }, {}); -} + return networkRecords.reduce((prev, record) => { + if (!record.finished) { + return prev; + } + + const isParserGenerated = record._initiator.type === 'parser'; + // A stylesheet only blocks script if it was initiated by the parser + // https://html.spec.whatwg.org/multipage/semantics.html#interactions-of-styling-and-scripting + const isParserScriptOrStyle = /(css|script)/.test(record._mimeType) && isParserGenerated; + const isFailedRequest = record._failed; + const isHtml = record._mimeType && record._mimeType.includes('html'); + + // Filter stylesheet, javascript, and html import mimetypes. + // Include 404 scripts/links generated by the parser because they are likely blocking. + if (isHtml || isParserScriptOrStyle || (isFailedRequest && isParserGenerated)) { + prev[record._url] = { + isLinkPreload: record.isLinkPreload, + transferSize: record._transferSize, + startTime: record._startTime, + endTime: record._endTime, + }; + } -class TagsBlockingFirstPaint extends Gatherer { - constructor() { - super(); - this._filteredAndIndexedByUrl = filteredAndIndexedByUrl; + return prev; + }, result); } + /** + * @param {Driver} driver + * @param {Array} networkRecords + */ static findBlockingTags(driver, networkRecords) { const scriptSrc = `(${collectTagsThatBlockFirstPaint.toString()}())`; const firstRequestEndTime = networkRecords.reduce( @@ -124,7 +137,7 @@ class TagsBlockingFirstPaint extends Gatherer { Infinity ); return driver.evaluateAsync(scriptSrc).then(tags => { - const requests = filteredAndIndexedByUrl(networkRecords); + const requests = TagsBlockingFirstPaint._filteredAndIndexedByUrl(networkRecords); return tags.reduce((prev, tag) => { const request = requests[tag.url]; @@ -158,19 +171,19 @@ class TagsBlockingFirstPaint extends Gatherer { } /** - * @param {!Object} context + * @param {LH.Gatherer.PassContext} passContext */ - beforePass(context) { - return context.driver.evaluteScriptOnNewDocument(`(${installMediaListener.toString()})()`); + beforePass(passContext) { + return passContext.driver.evaluteScriptOnNewDocument(`(${installMediaListener.toString()})()`); } /** - * @param {!Object} context - * @param {{networkRecords: !Array}} tracingData - * @return {!Array<{tag: string, transferSize: number, startTime: number, endTime: number}>} + * @param {LH.Gatherer.PassContext} passContext + * @param {LH.Gatherer.LoadData} loadData + * @return {Promise} */ - afterPass(context, tracingData) { - return TagsBlockingFirstPaint.findBlockingTags(context.driver, tracingData.networkRecords); + afterPass(passContext, loadData) { + return TagsBlockingFirstPaint.findBlockingTags(passContext.driver, loadData.networkRecords); } } diff --git a/lighthouse-core/gather/gatherers/dobetterweb/websql.js b/lighthouse-core/gather/gatherers/dobetterweb/websql.js index 20c2b07fc570..e37c53537201 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/websql.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/websql.js @@ -6,11 +6,17 @@ 'use strict'; const Gatherer = require('../gatherer'); +const Driver = require('../../driver.js'); // eslint-disable-line no-unused-vars const MAX_WAIT_TIMEOUT = 500; class WebSQL extends Gatherer { + /** + * @param {Driver} driver + * @return {Promise} + */ listenForDatabaseEvents(driver) { + /** @type {NodeJS.Timer} */ let timeout; return new Promise((resolve, reject) => { @@ -32,11 +38,11 @@ class WebSQL extends Gatherer { /** * Returns WebSQL database information or null if none was found. - * @param {!Object} options - * @return {?{id: string, domain: string, name: string, version: string}} + * @param {LH.Gatherer.PassContext} passContext + * @return {Promise} */ - afterPass(options) { - return this.listenForDatabaseEvents(options.driver) + afterPass(passContext) { + return this.listenForDatabaseEvents(passContext.driver) .then(result => { return result && result.database; }); diff --git a/lighthouse-core/lib/element.js b/lighthouse-core/lib/element.js index 5f29b92ee6ac..9136c66d7ca4 100644 --- a/lighthouse-core/lib/element.js +++ b/lighthouse-core/lib/element.js @@ -3,10 +3,15 @@ * 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. */ -// @ts-nocheck 'use strict'; +const Driver = require('../gather/driver.js'); // eslint-disable-line no-unused-vars + class Element { + /** + * @param {{nodeId: number}} element + * @param {Driver} driver + */ constructor(element, driver) { if (!element || !driver) { throw Error('Driver and element required to create Element'); @@ -16,8 +21,8 @@ class Element { } /** - * @param {!string} name Attribute name - * @return {!Promise} The attribute value or null if not found + * @param {string} name Attribute name + * @return {Promise} The attribute value or null if not found */ getAttribute(name) { return this.driver @@ -25,7 +30,7 @@ class Element { nodeId: this.element.nodeId, }) /** - * @param {!{attributes: !Array}} resp The element attribute names & values are interleaved + * @param resp The element attribute names & values are interleaved */ .then(resp => { const attrIndex = resp.attributes.indexOf(name); @@ -38,8 +43,15 @@ class Element { } /** - * @param {!string} propName Property name - * @return {!Promise} The property value + * @return {number} + */ + getNodeId() { + return this.element.nodeId; + } + + /** + * @param {string} propName Property name + * @return {Promise} The property value */ getProperty(propName) { return this.driver @@ -47,6 +59,9 @@ class Element { nodeId: this.element.nodeId, }) .then(resp => { + if (!resp.object.objectId) { + return null; + } return this.driver.getObjectProperty(resp.object.objectId, propName); }); } diff --git a/lighthouse-core/test/gather/gatherers/dobetterweb/response-compression-test.js b/lighthouse-core/test/gather/gatherers/dobetterweb/response-compression-test.js index 3c76928181ed..b8177cc81864 100644 --- a/lighthouse-core/test/gather/gatherers/dobetterweb/response-compression-test.js +++ b/lighthouse-core/test/gather/gatherers/dobetterweb/response-compression-test.js @@ -203,14 +203,12 @@ describe('Optimized responses', () => { record.transferSize = record._transferSize; record.responseHeaders = record._responseHeaders; record.requestId = record._requestId; - record.resourceType = () => { - return Object.assign( - { - isTextType: () => record._resourceType._isTextType, - }, - record._resourceType - ); - }; + record._resourceType = Object.assign( + { + isTextType: () => record._resourceType._isTextType, + }, + record._resourceType + ); return record; }); diff --git a/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js b/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js index 50f2ab714f5c..6987b261492c 100644 --- a/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js +++ b/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js @@ -103,7 +103,7 @@ describe('First paint blocking tags', () => { }); it('return filtered and indexed requests', () => { - const actual = tagsBlockingFirstPaint + const actual = TagsBlockingFirstPaint ._filteredAndIndexedByUrl(traceData.networkRecords); return assert.deepEqual(actual, { 'http://google.com/css/style.css': { diff --git a/tsconfig.json b/tsconfig.json index b660af5e6775..7d23f47a9d79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,8 +21,7 @@ "lighthouse-core/lib/emulation.js", "lighthouse-core/gather/computed/metrics/*.js", "lighthouse-core/gather/connections/**/*.js", - "lighthouse-core/gather/gatherers/*.js", - "lighthouse-core/gather/gatherers/seo/*.js", + "lighthouse-core/gather/gatherers/**/*.js", "lighthouse-core/scripts/*.js", "lighthouse-core/audits/seo/robots-txt.js", "./typings/*.d.ts" diff --git a/typings/artifacts.d.ts b/typings/artifacts.d.ts index ed15d96c6ba3..6f9a7290adfb 100644 --- a/typings/artifacts.d.ts +++ b/typings/artifacts.d.ts @@ -19,6 +19,10 @@ declare global { // Remaining are provided by gatherers Accessibility: Artifacts.Accessibility; + /** Information on all anchors in the page that aren't nofollow or noreferrer. */ + AnchorsWithNoRelNoopener: {href: string; rel: string; target: string}[]; + /** The value of the page's manifest attribute, or null if not defined */ + AppCacheManifest: string | null; CacheContents: string[]; /** Href values of link[rel=canonical] nodes found in HEAD (or null, if no href attribute). */ Canonical: (string | null)[]; @@ -26,13 +30,18 @@ declare global { /** The href and innerText of all non-nofollow anchors in the page. */ CrawlableLinks: {href: string, text: string}[]; CSSUsage: {rules: Crdp.CSS.RuleUsage[], stylesheets: Artifacts.CSSStyleSheetInfo[]}; + /** Information on the size of all DOM nodes in the page and the most extreme members. */ + DOMStats: Artifacts.DOMStats; /** Relevant attributes and child properties of all s, s and s in the page. */ EmbeddedContent: Artifacts.EmbeddedContentInfo[]; + /** Information on all event listeners in the page. */ + EventListeners: {url: string, type: string, handler?: {description?: string}, objectName: string, line: number, col: number}[]; FontSize: Artifacts.FontSize; /** The hreflang and href values of all link[rel=alternate] nodes found in HEAD. */ Hreflang: {href: string, hreflang: string}[]; HTMLWithoutJavaScript: {value: string}; HTTPRedirect: {value: boolean}; + JSLibraries: {name: string, version: string, npmPkgName: string}[]; JsUsageArtifact: Crdp.Profiler.ScriptCoverage[]; Manifest: ReturnType | null; /** The value of the 's content attribute, or null. */ @@ -40,15 +49,21 @@ declare global { /** The value of the 's content attribute, or null. */ MetaRobots: string|null; Offline: number; + OptimizedImages: Artifacts.OptimizedImage[]; + PasswordInputsWithPreventedPaste: {snippet: string}[]; /** Information on fetching and the content of the /robots.txt file. */ RobotsTxt: {status: number|null, content: string|null}; RuntimeExceptions: Crdp.Runtime.ExceptionThrownEvent[]; Scripts: Record; ServiceWorker: {versions: Crdp.ServiceWorker.ServiceWorkerVersion[]}; + /** Information on