core(tsc): add type checking to dbw gatherers (#5005)

This commit is contained in:
Brendan Kenny 2018-04-20 14:27:38 -07:00 коммит произвёл GitHub
Родитель 1ce15d25fb
Коммит b150d71a6d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 345 добавлений и 213 удалений

Просмотреть файл

@ -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<string, LH.Crdp.Debugger.ScriptParsedEvent>} */
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<!Array<{listeners: !Array, tagName: string}>>}
* string of an object ('document', 'window').
* @return {Promise<{listeners: Array<LH.Crdp.DOMDebugger.EventListener>, 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<!Array<!Object>>} List of event listeners attached to
* @param {Driver} driver
* @param {Map<string, LH.Crdp.Debugger.ScriptParsedEvent>} parsedScripts
* @param {string|number} nodeId The node to look for attached event listeners.
* @return {Promise<LH.Artifacts['EventListeners']>} 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<!Element>} nodes List of elements to fetch event listeners for.
* @return {!Promise<!Array<!Object>>} Resolves to a list of all the event
* @param {Driver} driver
* @param {Map<string, LH.Crdp.Debugger.ScriptParsedEvent>} parsedScripts
* @param {Array<string|number>} nodeIds List of objects or nodeIds to fetch event listeners for.
* @return {Promise<LH.Artifacts['EventListeners']>} 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<!Array<!Object>>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['EventListeners']>}
*/
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;
}
}

Просмотреть файл

@ -10,10 +10,10 @@ const DOMHelpers = require('../../../lib/dom-helpers.js');
class AnchorsWithNoRelNoopener extends Gatherer {
/**
* @param {!Object} options
* @return {!Promise<!Array<{href: string, rel: string, target: string}>>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['AnchorsWithNoRelNoopener']>}
*/
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);
}
}

Просмотреть файл

@ -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<?string>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['AppCacheManifest']>}
*/
afterPass(options) {
const driver = options.driver;
afterPass(passContext) {
const driver = passContext.driver;
return driver.querySelector('html')
.then(node => node && node.getAttribute('manifest'));

Просмотреть файл

@ -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,21 @@ 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) {
/* istanbul ignore next */
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
* <div id="myid" class="myclass">, returns 'div#myid.myclass'.
* @param {!HTMLElement} element
* @return {!string}
* @param {Element} element
* @return {string}
*/
/* istanbul ignore next */
function createSelectorsLabel(element) {
@ -54,8 +55,8 @@ function createSelectorsLabel(element) {
}
/**
* @param {!HTMLElement} element
* @return {!Array<string>}
* @param {Node} element
* @return {Array<string>}
*/
/* istanbul ignore next */
function elementPathInDOM(element) {
@ -89,9 +90,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 +101,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 +146,21 @@ function getDOMStats(element, deep=true) {
class DOMStats extends Gatherer {
/**
* @param {!Object} options
* @return {!Promise<!Array<!Object>>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['DOMStats']>}
*/
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);
}));
}
}

Просмотреть файл

@ -20,15 +20,15 @@ const libDetectorSource = fs.readFileSync(
/**
* Obtains a list of detected JS libraries and their versions.
* @return {!Array<!{name: string, version: string, npmPkgName: string}>}
*/
/* 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<!Array<!Object>>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['JSLibraries']>}
*/
afterPass(options) {
afterPass(passContext) {
const expression = `(function () {
${libDetectorSource};
return (${detectLibraries.toString()}());
})()`;
return options.driver.evaluateAsync(expression);
return passContext.driver.evaluateAsync(expression);
}
}

Просмотреть файл

@ -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<LH.WebInspector.NetworkRequest>} networkRecords
* @return {Array<SimplifiedNetworkRecord>}
*/
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<SimplifiedNetworkRecord>} */ ([]));
}
/**
* @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<LH.Crdp.Audits.GetEncodedResponseResponse>}
*/
_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<?{fromProtocol: boolean, originalSize: number, jpegSize: number, webpSize: number}>}
* @param {Driver} driver
* @param {SimplifiedNetworkRecord} networkRecord
* @return {Promise<?{fromProtocol: boolean, originalSize: number, jpegSize: number, webpSize: number}>}
*/
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<!Object>} imageRecords
* @return {!Promise<!Array<!Object>>}
* @param {Driver} driver
* @param {Array<SimplifiedNetworkRecord>} imageRecords
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
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<!NetworRecord>}} traceData
* @return {!Promise<!Array<!Object>}
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['OptimizedImages']>}
*/
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) {

Просмотреть файл

@ -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<!Array<{name: string, id: string}>>}
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['PasswordInputsWithPreventedPaste']>}
*/
afterPass(options) {
const driver = options.driver;
return driver.evaluateAsync(
afterPass(passContext) {
return passContext.driver.evaluateAsync(
`(${findPasswordInputsWithPreventedPaste.toString()}())`
);
}

Просмотреть файл

@ -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<LH.WebInspector.NetworkRequest>} networkRecords
* @return {Array<ResponseInfo>}
*/
static filterUnoptimizedResponses(networkRecords) {
/** @type {Array<ResponseInfo>} */
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;
}

Просмотреть файл

@ -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 */
@ -40,6 +41,9 @@ function installMediaListener() {
});
}
/**
* @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}}>}
*/
/* istanbul ignore next */
function collectTagsThatBlockFirstPaint() {
return new Promise((resolve, reject) => {
@ -47,21 +51,25 @@ function collectTagsThatBlockFirstPaint() {
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,
};
}
return prev;
}, {});
}
class TagsBlockingFirstPaint extends Gatherer {
constructor() {
super();
this._filteredAndIndexedByUrl = filteredAndIndexedByUrl;
/**
* @param {Array<LH.WebInspector.NetworkRequest>} networkRecords
*/
static _filteredAndIndexedByUrl(networkRecords) {
/** @type {Object<string, {isLinkPreload: boolean, transferSize: number, startTime: number, endTime: number}>} */
const result = {};
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,
};
}
return prev;
}, result);
}
/**
* @param {Driver} driver
* @param {Array<LH.WebInspector.NetworkRequest>} 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<!NetworkRecord>}} tracingData
* @return {!Array<{tag: string, transferSize: number, startTime: number, endTime: number}>}
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['TagsBlockingFirstPaint']>}
*/
afterPass(context, tracingData) {
return TagsBlockingFirstPaint.findBlockingTags(context.driver, tracingData.networkRecords);
afterPass(passContext, loadData) {
return TagsBlockingFirstPaint.findBlockingTags(passContext.driver, loadData.networkRecords);
}
}

Просмотреть файл

@ -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<?LH.Crdp.Database.AddDatabaseEvent>}
*/
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<LH.Artifacts['WebSQL']>}
*/
afterPass(options) {
return this.listenForDatabaseEvents(options.driver)
afterPass(passContext) {
return this.listenForDatabaseEvents(passContext.driver)
.then(result => {
return result && result.database;
});

Просмотреть файл

@ -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<?string>} The attribute value or null if not found
* @param {string} name Attribute name
* @return {Promise<?string>} 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<!string>}} 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<?string>} The property value
* @return {number}
*/
getNodeId() {
return this.element.nodeId;
}
/**
* @param {string} propName Property name
* @return {Promise<?string>} 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);
});
}

Просмотреть файл

@ -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;
});

Просмотреть файл

@ -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': {

Просмотреть файл

@ -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"

46
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 <html> 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 <object>s, <embed>s and <applet>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<typeof parseManifest> | null;
/** The value of the <meta name="description">'s content attribute, or null. */
@ -40,15 +49,21 @@ declare global {
/** The value of the <meta name="robots">'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<string, string>;
ServiceWorker: {versions: Crdp.ServiceWorker.ServiceWorkerVersion[]};
/** Information on <script> and <link> tags blocking first paint. */
TagsBlockingFirstPaint: Artifacts.TagBlockingFirstPaint[];
ThemeColor: string|null;
URL: {initialUrl: string, finalUrl: string};
Viewport: string|null;
ViewportDimensions: Artifacts.ViewportDimensions;
/** WebSQL database information for the page or null if none was found. */
WebSQL: Crdp.Database.Database | null;
// TODO(bckenny): remove this for real computed artifacts approach
requestTraceOfTab(trace: Trace): Promise<Artifacts.TraceOfTab>
@ -74,6 +89,12 @@ declare global {
content: string;
}
export interface DOMStats {
totalDOMNodes: number;
width: {max: number, pathToElement: Array<string>, snippet: string};
depth: {max: number, pathToElement: Array<string>, snippet: string};
}
export interface EmbeddedContentInfo {
tagName: string;
type: string | null;
@ -109,6 +130,31 @@ declare global {
}
}
export interface OptimizedImage {
isSameOrigin: boolean;
isBase64DataUri: boolean;
requestId: string;
url: string;
mimeType: string;
resourceSize: number;
fromProtocol?: boolean;
originalSize?: number;
jpegSize?: number;
webpSize?: number;
failed?: boolean;
err?: Error;
}
export interface TagBlockingFirstPaint {
startTime: number;
endTime: number;
transferSize: number;
tag: {
tagName: string;
url: string;
};
}
export interface ViewportDimensions {
innerWidth: number;
innerHeight: number;

16
typings/web-inspector.d.ts поставляемый
Просмотреть файл

@ -6,9 +6,11 @@
declare global {
module LH.WebInspector {
// TODO(bckenny): standardize on underscored internal API
// externs for chrome-devtools-frontend/front_end/sdk/NetworkRequest.js
export interface NetworkRequest {
requestId: string;
_requestId: string;
connectionId: string;
connectionReused: boolean;
@ -22,6 +24,8 @@ declare global {
endTime: number;
transferSize: number;
_transferSize?: number;
_resourceSize?: number;
finished: boolean;
statusCode: number;
@ -33,9 +37,10 @@ declare global {
_initiator: NetworkRequestInitiator;
_timing: NetworkRequestTiming;
// TODO(bckenny): type from ResourceType.js
_resourceType: any;
_resourceType: ResourceType;
_mimeType: string;
priority(): 'VeryHigh' | 'High' | 'Medium' | 'Low';
_responseHeaders?: {name: string, value: string}[];
_fetchedViaServiceWorker?: boolean;
}
@ -58,6 +63,13 @@ declare global {
sendEnd: number;
receiveHeadersEnd: number;
}
export interface ResourceType {
name(): string;
_name: string;
title(): string;
isTextType(): boolean;
}
}
}