diff --git a/.eslintignore b/.eslintignore index 1f2d7ebda64f..cd661a4b358f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,8 @@ **/generated/** **/source-maps/** +lighthouse-cli/test/fixtures/byte-efficiency/bundle.js + /dist/** coverage/** diff --git a/.eslintrc.js b/.eslintrc.js index 6d28a329d05b..f8b2e01e1379 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,7 +50,7 @@ module.exports = { 'no-unused-vars': [2, { vars: 'all', args: 'after-used', - argsIgnorePattern: '(^reject$|^_$)', + argsIgnorePattern: '(^reject$|^_+$)', varsIgnorePattern: '(^_$)', }], 'space-infix-ops': 2, diff --git a/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js b/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js new file mode 100644 index 000000000000..f63e3158beb0 --- /dev/null +++ b/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js @@ -0,0 +1,156 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); +/******/ } +/******/ }; +/******/ +/******/ // define __esModule on exports +/******/ __webpack_require__.r = function(exports) { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ +/******/ // create a fake namespace object +/******/ // mode & 1: value is a module id, require it +/******/ // mode & 2: merge all properties of value into the ns +/******/ // mode & 4: return value when already ns object +/******/ // mode & 8|1: behave like require +/******/ __webpack_require__.t = function(value, mode) { +/******/ if(mode & 1) value = __webpack_require__(value); +/******/ if(mode & 8) return value; +/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; +/******/ var ns = Object.create(null); +/******/ __webpack_require__.r(ns); +/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); +/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); +/******/ return ns; +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports, __webpack_require__) { + +const a = __webpack_require__(1); +const b = __webpack_require__(2); +const c = __webpack_require__(3); + +if (true) { + a(); + b.half(); +} else {} + + +/***/ }), +/* 1 */ +/***/ (function(module, exports) { + +module.exports = function () { + const kb2 = '................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................'; + let i; + for (i = 0; i < 100; i++) { + i = i; + } + return i; +} + + +/***/ }), +/* 2 */ +/***/ (function(module, exports) { + +function half() { + const kb2 = '................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................'; + let i; + for (i = 0; i < 100; i++) { + i = i; + } + return i; +} + +function otherHalf() { + const kb2 = '................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................'; + let i; + for (i = 0; i < 100; i++) { + i = i; + } + return i; +} + +module.exports = { half, otherHalf }; + + +/***/ }), +/* 3 */ +/***/ (function(module, exports) { + +module.exports = function () { + const kb2 = '................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................'; + let i; + for (i = 0; i < 100; i++) { + i = i; + } + return i; +} + + +/***/ }) +/******/ ]); +//# sourceMappingURL=bundle.js.map \ No newline at end of file diff --git a/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js.map b/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js.map new file mode 100644 index 000000000000..bafb42a6396c --- /dev/null +++ b/lighthouse-cli/test/fixtures/byte-efficiency/bundle.js.map @@ -0,0 +1,13 @@ +{ + "version": 3, + "sources": [ + "webpack:///webpack/bootstrap", + "webpack:///./entry.js", + "webpack:///./a.js", + "webpack:///./b.js", + "webpack:///./c.js" + ], + "names": [], + "mappings": ";QAAA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;;QAEA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;;;QAGA;QACA;;QAEA;QACA;;QAEA;QACA;QACA;QACA,0CAA0C,gCAAgC;QAC1E;QACA;;QAEA;QACA;QACA;QACA,wDAAwD,kBAAkB;QAC1E;QACA,iDAAiD,cAAc;QAC/D;;QAEA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA;QACA,yCAAyC,iCAAiC;QAC1E,gHAAgH,mBAAmB,EAAE;QACrI;QACA;;QAEA;QACA;QACA;QACA,2BAA2B,0BAA0B,EAAE;QACvD,iCAAiC,eAAe;QAChD;QACA;QACA;;QAEA;QACA,sDAAsD,+DAA+D;;QAErH;QACA;;;QAGA;QACA;;;;;;;AClFA,UAAU,mBAAO,CAAC,CAAQ;AAC1B,UAAU,mBAAO,CAAC,CAAQ;AAC1B,UAAU,mBAAO,CAAC,CAAQ;;AAE1B,IAAI,IAAW;AACf;AACA;AACA,CAAC,MAAM,EAEN;;;;;;;ACTD;AACA;AACA;AACA,aAAa,SAAS;AACtB;AACA;AACA;AACA;;;;;;;ACPA;AACA;AACA;AACA,aAAa,SAAS;AACtB;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA,aAAa,SAAS;AACtB;AACA;AACA;AACA;;AAEA,kBAAkB;;;;;;;AClBlB;AACA;AACA;AACA,aAAa,SAAS;AACtB;AACA;AACA;AACA", + "file": "bundle.js" +} \ No newline at end of file diff --git a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html index e1368d5a63f1..51f3f5852afb 100644 --- a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html +++ b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html @@ -54,6 +54,7 @@ } + diff --git a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/byte-config.js b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/byte-config.js index 47e7ab85b046..a58a9084ae5e 100644 --- a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/byte-config.js +++ b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/byte-config.js @@ -27,6 +27,13 @@ const config = { ], throttlingMethod: 'devtools', }, + // source-maps is not yet in the default config. + passes: [{ + passName: 'defaultPass', + gatherers: [ + 'source-maps', + ], + }], }; module.exports = config; diff --git a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js index 28d3c8e9f0ec..3978ff7a3d99 100644 --- a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js +++ b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js @@ -29,6 +29,14 @@ const expectations = [ source: 'head', devtoolsNodePath: '2,HTML,0,HEAD,5,SCRIPT', }, + { + type: null, + src: 'http://localhost:10200/byte-efficiency/bundle.js', + async: false, + defer: false, + source: 'head', + devtoolsNodePath: '2,HTML,0,HEAD,6,SCRIPT', + }, { type: null, src: null, @@ -106,6 +114,12 @@ const expectations = [ wastedBytes: '6559 +/- 100', wastedPercent: 100, }, + { + url: 'http://localhost:10200/byte-efficiency/bundle.js', + totalBytes: '13000 +/- 1000', + wastedBytes: '2350 +/- 100', + wastedPercent: '19 +/- 5', + }, ], }, }, @@ -122,9 +136,38 @@ const expectations = [ details: { overallSavingsBytes: '>=25000', overallSavingsMs: '>300', - items: { - length: 2, - }, + items: [ + { + url: 'http://localhost:10200/byte-efficiency/script.js', + totalBytes: '53000 +/- 1000', + wastedBytes: '22000 +/- 1000', + }, + { + url: 'http://localhost:10200/byte-efficiency/tester.html', + totalBytes: '15000 +/- 1000', + wastedBytes: '6500 +/- 1000', + }, + { + url: 'http://localhost:10200/byte-efficiency/bundle.js', + totalBytes: 12913, + wastedBytes: 5827, + sources: [ + '…./b.js', + '…./c.js', + '…webpack/bootstrap', + ], + sourceBytes: [ + 4417, + 2200, + 2809, + ], + sourceWastedBytes: [ + 2191, + 2182, + 1259, + ], + }, + ], }, }, 'offscreen-images': { @@ -156,7 +199,7 @@ const expectations = [ overallSavingsMs: '>700', overallSavingsBytes: '>50000', items: { - length: 2, + length: 3, }, }, }, diff --git a/lighthouse-core/audits/byte-efficiency/unused-javascript.js b/lighthouse-core/audits/byte-efficiency/unused-javascript.js index 305d77469a35..1ff94e71eaae 100644 --- a/lighthouse-core/audits/byte-efficiency/unused-javascript.js +++ b/lighthouse-core/audits/byte-efficiency/unused-javascript.js @@ -6,6 +6,7 @@ 'use strict'; const ByteEfficiencyAudit = require('./byte-efficiency-audit.js'); +const JSBundles = require('../../computed/js-bundles.js'); const i18n = require('../../lib/i18n/i18n.js'); const UIStrings = { @@ -19,6 +20,41 @@ const UIStrings = { const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings); const IGNORE_THRESHOLD_IN_BYTES = 2048; +const IGNORE_BUNDLE_SOURCE_THRESHOLD_IN_BYTES = 512; + +/** + * @param {string[]} strings + */ +function commonPrefix(strings) { + if (!strings.length) { + return ''; + } + + const maxWord = strings.reduce((a, b) => a > b ? a : b); + let prefix = strings.reduce((a, b) => a > b ? b : a); + while (!maxWord.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + } + + return prefix; +} + +/** + * @param {string[]} strings + * @param {string} commonPrefix + * @return {string[]} + */ +function trimCommonPrefix(strings, commonPrefix) { + if (!commonPrefix) return strings; + return strings.map(s => s.startsWith(commonPrefix) ? '…' + s.slice(commonPrefix.length) : s); +} + +/** + * @typedef WasteData + * @property {Uint8Array} unusedByIndex + * @property {number} unusedLength + * @property {number} contentLength + */ class UnusedJavaScript extends ByteEfficiencyAudit { /** @@ -30,26 +66,25 @@ class UnusedJavaScript extends ByteEfficiencyAudit { title: str_(UIStrings.title), description: str_(UIStrings.description), scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC, - requiredArtifacts: ['JsUsage', 'devtoolsLogs', 'traces'], + requiredArtifacts: ['JsUsage', 'ScriptElements', 'devtoolsLogs', 'traces'], + __internalOptionalArtifacts: ['SourceMaps'], }; } /** - * @param {LH.Crdp.Profiler.ScriptCoverage} script - * @return {{unusedLength: number, contentLength: number}} + * @param {LH.Crdp.Profiler.ScriptCoverage} scriptCoverage + * @return {WasteData} */ - static computeWaste(script) { + static computeWaste(scriptCoverage) { let maximumEndOffset = 0; - for (const func of script.functions) { - for (const range of func.ranges) { - maximumEndOffset = Math.max(maximumEndOffset, range.endOffset); - } + for (const func of scriptCoverage.functions) { + maximumEndOffset = Math.max(maximumEndOffset, ...func.ranges.map(r => r.endOffset)); } // We only care about unused ranges of the script, so we can ignore all the nesting and safely // assume that if a range is unexecuted, all nested ranges within it will also be unexecuted. const unusedByIndex = new Uint8Array(maximumEndOffset); - for (const func of script.functions) { + for (const func of scriptCoverage.functions) { for (const range of func.ranges) { if (range.count === 0) { for (let i = range.startOffset; i < range.endOffset; i++) { @@ -65,43 +100,133 @@ class UnusedJavaScript extends ByteEfficiencyAudit { } return { + unusedByIndex, unusedLength: unused, contentLength: maximumEndOffset, }; } /** - * @param {Array<{unusedLength: number, contentLength: number}>} wasteData - * @param {LH.Artifacts.NetworkRequest} networkRecord + * @param {LH.Audit.ByteEfficiencyItem} item + * @param {WasteData[]} wasteData + * @param {LH.Artifacts.Bundle} bundle + * @param {ReturnType} lengths + * @param {number} bundleSourceUnusedThreshold + */ + static createBundleMultiData(item, wasteData, bundle, lengths, bundleSourceUnusedThreshold) { + if (!bundle.script.content) return; + + /** @type {Record} */ + const files = {}; + + const lineLengths = bundle.script.content.split('\n').map(l => l.length); + let totalSoFar = 0; + const lineOffsets = lineLengths.map(len => { + const retVal = totalSoFar; + totalSoFar += len + 1; + return retVal; + }); + + // @ts-ignore: We will upstream computeLastGeneratedColumns to CDT eventually. + bundle.map.computeLastGeneratedColumns(); + for (const mapping of bundle.map.mappings()) { + let offset = lineOffsets[mapping.lineNumber]; + + offset += mapping.columnNumber; + const lastColumnOfMapping = + // @ts-ignore: We will upstream lastColumnNumber to CDT eventually. + (mapping.lastColumnNumber - 1) || lineLengths[mapping.lineNumber]; + for (let i = mapping.columnNumber; i <= lastColumnOfMapping; i++) { + if (wasteData.every(data => data.unusedByIndex[offset] === 1)) { + const key = mapping.sourceURL || '(unmapped)'; + files[key] = (files[key] || 0) + 1; + } + offset += 1; + } + } + + const transferRatio = lengths.transfer / lengths.content; + const topUnusedFilesSizes = Object.entries(files) + .filter(([_, unusedBytes]) => unusedBytes * transferRatio >= bundleSourceUnusedThreshold) + .sort(([_, unusedBytes1], [__, unusedBytes2]) => unusedBytes2 - unusedBytes1) + .slice(0, 5) + .map(([key, unusedBytes]) => { + const total = key === '(unmapped)' ? bundle.sizes.unmappedBytes : bundle.sizes.files[key]; + return { + key, + unused: Math.round(unusedBytes * transferRatio), + total: Math.round(total * transferRatio), + }; + }); + + const commonSourcePrefix = commonPrefix([...bundle.map._sourceInfos.keys()]); + Object.assign(item, { + sources: trimCommonPrefix(topUnusedFilesSizes.map(d => d.key), commonSourcePrefix), + sourceBytes: topUnusedFilesSizes.map(d => d.total), + sourceWastedBytes: topUnusedFilesSizes.map(d => d.unused), + }); + } + + /** + * @param {WasteData[]} wasteData + * @param {string} url + * @param {ReturnType} lengths * @return {LH.Audit.ByteEfficiencyItem} */ - static mergeWaste(wasteData, networkRecord) { - let unusedLength = 0; - let contentLength = 0; + static mergeWaste(wasteData, url, lengths) { + let unused = 0; + let content = 0; + // TODO: this is right for multiple script tags in an HTML document, + // but may be wrong for multiple frames using the same script resource. for (const usage of wasteData) { - unusedLength += usage.unusedLength; - contentLength += usage.contentLength; + unused += usage.unusedLength; + content += usage.contentLength; } - const totalBytes = ByteEfficiencyAudit.estimateTransferSize(networkRecord, contentLength, - 'Script'); - const wastedRatio = (unusedLength / contentLength) || 0; - const wastedBytes = Math.round(totalBytes * wastedRatio); + const wastedRatio = (unused / content) || 0; + const wastedBytes = Math.round(lengths.transfer * wastedRatio); return { - url: networkRecord.url, - totalBytes, + url: url, + totalBytes: lengths.transfer, wastedBytes, wastedPercent: 100 * wastedRatio, }; } + /** + * @param {WasteData[]} wasteData + * @param {LH.Artifacts.NetworkRequest} networkRecord + */ + static determineLengths(wasteData, networkRecord) { + let unused = 0; + let content = 0; + // TODO: this is right for multiple script tags in an HTML document, + // but may be wrong for multiple frames using the same script resource. + for (const usage of wasteData) { + unused += usage.unusedLength; + content += usage.contentLength; + } + const transfer = ByteEfficiencyAudit.estimateTransferSize(networkRecord, content, 'Script'); + + return { + content, + unused, + transfer, + }; + } + /** * @param {LH.Artifacts} artifacts * @param {Array} networkRecords - * @return {ByteEfficiencyAudit.ByteEfficiencyProduct} + * @param {LH.Audit.Context} context + * @return {Promise} */ - static audit_(artifacts, networkRecords) { + static async audit_(artifacts, networkRecords, context) { + const bundles = artifacts.SourceMaps ? await JSBundles.request(artifacts, context) : []; + const {bundleSourceUnusedThreshold = IGNORE_BUNDLE_SOURCE_THRESHOLD_IN_BYTES} = + context.options || {}; + /** @type {Map>} */ const scriptsByUrl = new Map(); for (const script of artifacts.JsUsage) { @@ -111,21 +236,29 @@ class UnusedJavaScript extends ByteEfficiencyAudit { } const items = []; - for (const [url, scripts] of scriptsByUrl.entries()) { + for (const [url, scriptCoverage] of scriptsByUrl.entries()) { const networkRecord = networkRecords.find(record => record.url === url); if (!networkRecord) continue; - const wasteData = scripts.map(UnusedJavaScript.computeWaste); - const item = UnusedJavaScript.mergeWaste(wasteData, networkRecord); + const wasteData = scriptCoverage.map(UnusedJavaScript.computeWaste); + const lengths = UnusedJavaScript.determineLengths(wasteData, networkRecord); + const bundle = bundles.find(b => b.script.src === url); + const item = UnusedJavaScript.mergeWaste(wasteData, networkRecord.url, lengths); if (item.wastedBytes <= IGNORE_THRESHOLD_IN_BYTES) continue; + if (bundle) { + UnusedJavaScript.createBundleMultiData( + item, wasteData, bundle, lengths, bundleSourceUnusedThreshold); + } items.push(item); } return { items, headings: [ - {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)}, - {key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)}, - {key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)}, + /* eslint-disable max-len */ + {key: 'url', valueType: 'url', subRows: {key: 'sources', valueType: 'code'}, label: str_(i18n.UIStrings.columnURL)}, + {key: 'totalBytes', valueType: 'bytes', subRows: {key: 'sourceBytes'}, label: str_(i18n.UIStrings.columnSize)}, + {key: 'wastedBytes', valueType: 'bytes', subRows: {key: 'sourceWastedBytes'}, label: str_(i18n.UIStrings.columnWastedBytes)}, + /* eslint-enable max-len */ ], }; } diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js index 582b7dcea100..1e54ca15675b 100644 --- a/lighthouse-core/report/html/renderer/details-renderer.js +++ b/lighthouse-core/report/html/renderer/details-renderer.js @@ -404,9 +404,10 @@ class DetailsRenderer { label: '', }; const values = row[subRowsHeading.key]; - if (!Array.isArray(values)) continue; - const subRowsElement = this._renderSubRows(values, subRowsHeading); - valueFragment.appendChild(subRowsElement); + if (Array.isArray(values)) { + const subRowsElement = this._renderSubRows(values, subRowsHeading); + valueFragment.appendChild(subRowsElement); + } } if (valueFragment.childElementCount) { diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css index 6e0858f63732..59e75e5a1717 100644 --- a/lighthouse-core/report/html/report-styles.css +++ b/lighthouse-core/report/html/report-styles.css @@ -313,6 +313,10 @@ display: none !important; } +.lh-root pre { + margin: 0; +} + .lh-root details > summary { cursor: pointer; } diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-javascript-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-javascript-test.js index 931cc64aa31e..d4b45d6c04f9 100644 --- a/lighthouse-core/test/audits/byte-efficiency/unused-javascript-test.js +++ b/lighthouse-core/test/audits/byte-efficiency/unused-javascript-test.js @@ -5,18 +5,45 @@ */ 'use strict'; -const UnusedJavaScript = require('../../../audits/byte-efficiency/unused-javascript.js'); const assert = require('assert'); +const fs = require('fs'); +const UnusedJavaScript = require('../../../audits/byte-efficiency/unused-javascript.js'); +const networkRecordsToDevtoolsLog = require('../../network-records-to-devtools-log.js'); + +function load(name) { + const dir = `${__dirname}/../../fixtures/source-maps`; + const mapJson = fs.readFileSync(`${dir}/${name}.js.map`, 'utf-8'); + const content = fs.readFileSync(`${dir}/${name}.js`, 'utf-8'); + const usageJson = fs.readFileSync(`${dir}/${name}.usage.json`, 'utf-8'); + const exportedUsage = JSON.parse(usageJson); + + // Usage is exported from DevTools, which simplifies the real format of the + // usage protocol. + const usage = { + url: exportedUsage.url, + functions: [ + { + ranges: exportedUsage.ranges.map((range, i) => { + return { + startOffset: range.start, + endOffset: range.end, + count: i % 2 === 0 ? 0 : 1, + }; + }), + }, + ], + }; + + return {map: JSON.parse(mapJson), content, usage}; +} /* eslint-env jest */ function generateRecord(url, transferSize, resourceType) { - url = `https://google.com/${url}`; return {url, transferSize, resourceType}; } -function generateScript(url, ranges, transferSize = 1000) { - url = `https://google.com/${url}`; +function generateUsage(url, ranges, transferSize = 1000) { const functions = ranges.map(range => { return { ranges: [ @@ -35,21 +62,21 @@ function generateScript(url, ranges, transferSize = 1000) { describe('UnusedJavaScript audit', () => { describe('#computeWaste', () => { it('should identify used', () => { - const usage = generateScript('myscript.js', [[0, 100, true]]); + const usage = generateUsage('myscript.js', [[0, 100, true]]); const result = UnusedJavaScript.computeWaste(usage); assert.equal(result.unusedLength, 0); assert.equal(result.contentLength, 100); }); it('should identify unused', () => { - const usage = generateScript('myscript.js', [[0, 100, false]]); + const usage = generateUsage('myscript.js', [[0, 100, false]]); const result = UnusedJavaScript.computeWaste(usage); assert.equal(result.unusedLength, 100); assert.equal(result.contentLength, 100); }); it('should identify nested unused', () => { - const usage = generateScript('myscript.js', [ + const usage = generateUsage('myscript.js', [ [0, 100, true], // 40% used overall [0, 10, true], @@ -71,21 +98,24 @@ describe('UnusedJavaScript audit', () => { }); describe('audit_', () => { - const scriptUnknown = generateScript('', [[0, 3000, false]]); - const scriptA = generateScript('scriptA.js', [[0, 100, true]]); - const scriptB = generateScript('scriptB.js', [[0, 200, true], [0, 50, false]]); - const inlineA = generateScript('inline.html', [[0, 5000, true], [5000, 6000, false]]); - const inlineB = generateScript('inline.html', [[0, 15000, true], [0, 5000, false]]); - const recordA = generateRecord('scriptA.js', 35000, 'Script'); - const recordB = generateRecord('scriptB.js', 50000, 'Script'); - const recordInline = generateRecord('inline.html', 1000000, 'Document'); - - const result = UnusedJavaScript.audit_( - {JsUsage: [scriptA, scriptB, scriptUnknown, inlineA, inlineB]}, - [recordA, recordB, recordInline] - ); - - it('should merge duplicates', () => { + const domain = 'https://www.google.com'; + const scriptUnknown = generateUsage(domain, [[0, 3000, false]]); + const scriptA = generateUsage(`${domain}/scriptA.js`, [[0, 100, true]]); + const scriptB = generateUsage(`${domain}/scriptB.js`, [[0, 200, true], [0, 50, false]]); + const inlineA = generateUsage(`${domain}/inline.html`, [[0, 5000, true], [5000, 6000, false]]); + const inlineB = generateUsage(`${domain}/inline.html`, [[0, 15000, true], [0, 5000, false]]); + const recordA = generateRecord(`${domain}/scriptA.js`, 35000, 'Script'); + const recordB = generateRecord(`${domain}/scriptB.js`, 50000, 'Script'); + const recordInline = generateRecord(`${domain}/inline.html`, 1000000, 'Document'); + + it('should merge duplicates', async () => { + const context = {computedCache: new Map()}; + const networkRecords = [recordA, recordB, recordInline]; + const artifacts = { + JsUsage: [scriptA, scriptB, scriptUnknown, inlineA, inlineB], + devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)}, + }; + const result = await UnusedJavaScript.audit_(artifacts, networkRecords, context); assert.equal(result.items.length, 2); const scriptBWaste = result.items[0]; @@ -98,5 +128,62 @@ describe('UnusedJavaScript audit', () => { assert.equal(inlineWaste.wastedBytes, 6000); assert.equal(Math.round(inlineWaste.wastedPercent), 29); }); + + it('should augment when provided source maps', async () => { + const context = { + computedCache: new Map(), + options: { + // Default threshold is 1024, but is lowered here so that squoosh actually generates + // results. + // TODO(cjamcl): the bundle visualization feature will require most of the logic currently + // done in unused-javascript to be moved to a computed artifact. When that happens, these + // tests will go there, and the artifact will not have any thresholds (filtering will happen + // within the audits), so this test will not need a threshold. Until then, it does. + bundleSourceUnusedThreshold: 100, + }, + }; + const {map, content, usage} = load('squoosh'); + const url = 'https://squoosh.app/main-app.js'; + const networkRecords = [generateRecord(url, content.length, 'Script')]; + const artifacts = { + JsUsage: [usage], + devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)}, + SourceMaps: [{scriptUrl: url, map}], + ScriptElements: [{src: url, content}], + }; + const result = await UnusedJavaScript.audit_(artifacts, networkRecords, context); + + expect(result.items).toMatchInlineSnapshot(` + Array [ + Object { + "sourceBytes": Array [ + 10062, + 660, + 4043, + 2138, + 4117, + ], + "sourceWastedBytes": Array [ + 3760, + 660, + 500, + 293, + 256, + ], + "sources": Array [ + "(unmapped)", + "…src/codecs/webp/encoder-meta.ts", + "…src/lib/util.ts", + "…src/custom-els/RangeInput/index.ts", + "…node_modules/comlink/comlink.js", + ], + "totalBytes": 83748, + "url": "https://squoosh.app/main-app.js", + "wastedBytes": 6961, + "wastedPercent": 8.312435814764395, + }, + ] + `); + }); }); }); diff --git a/lighthouse-core/test/computed/js-bundles-test.js b/lighthouse-core/test/computed/js-bundles-test.js index c4ee55456526..702c58f65575 100644 --- a/lighthouse-core/test/computed/js-bundles-test.js +++ b/lighthouse-core/test/computed/js-bundles-test.js @@ -10,9 +10,9 @@ const fs = require('fs'); const JSBundles = require('../../computed/js-bundles.js'); function load(name) { - const mapData = fs.readFileSync(`${__dirname}/../fixtures/source-maps/${name}.js.map`, 'utf-8'); + const mapJson = fs.readFileSync(`${__dirname}/../fixtures/source-maps/${name}.js.map`, 'utf-8'); const content = fs.readFileSync(`${__dirname}/../fixtures/source-maps/${name}.js`, 'utf-8'); - return {map: JSON.parse(mapData), content}; + return {map: JSON.parse(mapJson), content}; } describe('JSBundles computed artifact', () => { diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js index 98f0aec30346..e959064743cc 100644 --- a/lighthouse-core/test/config/config-test.js +++ b/lighthouse-core/test/config/config-test.js @@ -217,6 +217,146 @@ describe('Config', () => { .toEqual(['viewport-dimensions', 'source-maps']); }); + // eslint-disable-next-line max-len + it('does not throw when an audit requests an optional artifact with no gatherer supplying it', async () => { + class DoesntNeedYourCrap extends Audit { + static get meta() { + return { + id: 'optional-artifact-audit', + title: 'none', + description: 'none', + requiredArtifacts: [ + 'URL', // base artifact + 'ViewportDimensions', // from gatherer + ], + __internalOptionalArtifacts: [ + 'SourceMaps', // Not in the config. + ], + }; + } + + static audit() {} + } + + // Shouldn't throw. + const config = new Config({ + extends: 'lighthouse:default', + audits: [DoesntNeedYourCrap], + }, { + // Trigger filtering logic. + onlyAudits: ['optional-artifact-audit'], + }); + expect(config.passes[0].gatherers.map(g => g.path)).toEqual(['viewport-dimensions']); + }); + + it('should keep optional artifacts in gatherers after filter', async () => { + class ButWillStillTakeYourCrap extends Audit { + static get meta() { + return { + id: 'optional-artifact-audit', + title: 'none', + description: 'none', + requiredArtifacts: [ + 'URL', // base artifact + 'ViewportDimensions', // from gatherer + ], + __internalOptionalArtifacts: [ + 'SourceMaps', // Is in the config. + ], + }; + } + + static audit() {} + } + + const config = new Config({ + extends: 'lighthouse:default', + // TODO(cjamcl): remove when source-maps is in default config. + passes: [{ + passName: 'defaultPass', + gatherers: [ + 'source-maps', + ], + }], + audits: [ButWillStillTakeYourCrap], + }, { + // Trigger filtering logic. + onlyAudits: ['optional-artifact-audit'], + }); + expect(config.passes[0].gatherers.map(g => g.path)) + .toEqual(['viewport-dimensions', 'source-maps']); + }); + + // eslint-disable-next-line max-len + it('does not throw when an audit requests an optional artifact with no gatherer supplying it', async () => { + class DoesntNeedYourCrap extends Audit { + static get meta() { + return { + id: 'optional-artifact-audit', + title: 'none', + description: 'none', + requiredArtifacts: [ + 'URL', // base artifact + 'ViewportDimensions', // from gatherer + ], + __internalOptionalArtifacts: [ + 'SourceMaps', // Not in the config. + ], + }; + } + + static audit() {} + } + + // Shouldn't throw. + const config = new Config({ + extends: 'lighthouse:default', + audits: [DoesntNeedYourCrap], + }, { + // Trigger filtering logic. + onlyAudits: ['optional-artifact-audit'], + }); + expect(config.passes[0].gatherers.map(g => g.path)).toEqual(['viewport-dimensions']); + }); + + it('should keep optional artifacts in gatherers after filter', async () => { + class ButWillStillTakeYourCrap extends Audit { + static get meta() { + return { + id: 'optional-artifact-audit', + title: 'none', + description: 'none', + requiredArtifacts: [ + 'URL', // base artifact + 'ViewportDimensions', // from gatherer + ], + __internalOptionalArtifacts: [ + 'SourceMaps', // Is in the config. + ], + }; + } + + static audit() {} + } + + const config = new Config({ + extends: 'lighthouse:default', + // TODO(cjamcl): remove when source-maps is in default config. + passes: [{ + passName: 'defaultPass', + gatherers: [ + 'source-maps', + ], + }], + audits: [ButWillStillTakeYourCrap], + }, { + // Trigger filtering logic. + onlyAudits: ['optional-artifact-audit'], + }); + expect(config.passes[0].gatherers.map(g => g.path)) + .toEqual(['viewport-dimensions', 'source-maps']); + }); + it('does not throw when an audit requires only base artifacts', () => { class BaseArtifactsAudit extends Audit { static get meta() { diff --git a/lighthouse-core/test/fixtures/source-maps/squoosh.usage.json b/lighthouse-core/test/fixtures/source-maps/squoosh.usage.json new file mode 100644 index 000000000000..24e8714d188a --- /dev/null +++ b/lighthouse-core/test/fixtures/source-maps/squoosh.usage.json @@ -0,0 +1,633 @@ +{ + "url": "https://squoosh.app/main-app.js", + "ranges": [ + { + "start": 0, + "end": 99 + }, + { + "start": 347, + "end": 1007 + }, + { + "start": 1042, + "end": 1085 + }, + { + "start": 1093, + "end": 1143 + }, + { + "start": 1149, + "end": 1151 + }, + { + "start": 1246, + "end": 1256 + }, + { + "start": 1276, + "end": 1288 + }, + { + "start": 1308, + "end": 1384 + }, + { + "start": 1404, + "end": 1416 + }, + { + "start": 1436, + "end": 1448 + }, + { + "start": 1468, + "end": 1480 + }, + { + "start": 1500, + "end": 1512 + }, + { + "start": 1532, + "end": 1544 + }, + { + "start": 1564, + "end": 1576 + }, + { + "start": 1596, + "end": 1608 + }, + { + "start": 1628, + "end": 1640 + }, + { + "start": 1660, + "end": 1672 + }, + { + "start": 1692, + "end": 1704 + }, + { + "start": 1724, + "end": 1768 + }, + { + "start": 1788, + "end": 1800 + }, + { + "start": 1820, + "end": 2119 + }, + { + "start": 3346, + "end": 3399 + }, + { + "start": 3532, + "end": 3572 + }, + { + "start": 4148, + "end": 4849 + }, + { + "start": 4869, + "end": 4888 + }, + { + "start": 4908, + "end": 4928 + }, + { + "start": 4948, + "end": 4973 + }, + { + "start": 4993, + "end": 5146 + }, + { + "start": 5166, + "end": 5185 + }, + { + "start": 5205, + "end": 5225 + }, + { + "start": 5245, + "end": 5270 + }, + { + "start": 5290, + "end": 5457 + }, + { + "start": 5477, + "end": 5496 + }, + { + "start": 5516, + "end": 5536 + }, + { + "start": 5556, + "end": 5581 + }, + { + "start": 5601, + "end": 5844 + }, + { + "start": 5864, + "end": 5883 + }, + { + "start": 5903, + "end": 5923 + }, + { + "start": 5943, + "end": 5968 + }, + { + "start": 5988, + "end": 6216 + }, + { + "start": 6236, + "end": 6255 + }, + { + "start": 6275, + "end": 6295 + }, + { + "start": 6315, + "end": 6340 + }, + { + "start": 6360, + "end": 6592 + }, + { + "start": 6612, + "end": 6631 + }, + { + "start": 6651, + "end": 6671 + }, + { + "start": 6691, + "end": 6716 + }, + { + "start": 6736, + "end": 6970 + }, + { + "start": 6990, + "end": 7009 + }, + { + "start": 7029, + "end": 7049 + }, + { + "start": 7069, + "end": 7094 + }, + { + "start": 7114, + "end": 7342 + }, + { + "start": 7362, + "end": 7381 + }, + { + "start": 7401, + "end": 7421 + }, + { + "start": 7441, + "end": 7466 + }, + { + "start": 7486, + "end": 10041 + }, + { + "start": 10061, + "end": 10508 + }, + { + "start": 10580, + "end": 10674 + }, + { + "start": 11094, + "end": 11096 + }, + { + "start": 12402, + "end": 13549 + }, + { + "start": 13569, + "end": 13788 + }, + { + "start": 13798, + "end": 13809 + }, + { + "start": 14664, + "end": 14677 + }, + { + "start": 14684, + "end": 14707 + }, + { + "start": 14717, + "end": 14728 + }, + { + "start": 14813, + "end": 14826 + }, + { + "start": 14861, + "end": 14874 + }, + { + "start": 17654, + "end": 17695 + }, + { + "start": 17715, + "end": 17737 + }, + { + "start": 17784, + "end": 17825 + }, + { + "start": 17845, + "end": 17867 + }, + { + "start": 17914, + "end": 17955 + }, + { + "start": 17975, + "end": 17997 + }, + { + "start": 18058, + "end": 18099 + }, + { + "start": 18119, + "end": 18141 + }, + { + "start": 18202, + "end": 18243 + }, + { + "start": 18263, + "end": 18285 + }, + { + "start": 18332, + "end": 18373 + }, + { + "start": 18393, + "end": 18415 + }, + { + "start": 18462, + "end": 18503 + }, + { + "start": 18523, + "end": 18545 + }, + { + "start": 18592, + "end": 18633 + }, + { + "start": 18653, + "end": 18675 + }, + { + "start": 18722, + "end": 18994 + }, + { + "start": 19090, + "end": 19101 + }, + { + "start": 19507, + "end": 19551 + }, + { + "start": 20082, + "end": 20092 + }, + { + "start": 20112, + "end": 20124 + }, + { + "start": 20144, + "end": 20226 + }, + { + "start": 20247, + "end": 20299 + }, + { + "start": 20320, + "end": 20339 + }, + { + "start": 20360, + "end": 20380 + }, + { + "start": 20401, + "end": 20426 + }, + { + "start": 20447, + "end": 20487 + }, + { + "start": 20508, + "end": 20560 + }, + { + "start": 20581, + "end": 20600 + }, + { + "start": 20621, + "end": 20641 + }, + { + "start": 20662, + "end": 20687 + }, + { + "start": 20708, + "end": 20776 + }, + { + "start": 20797, + "end": 20822 + }, + { + "start": 20843, + "end": 20911 + }, + { + "start": 20932, + "end": 20951 + }, + { + "start": 20972, + "end": 20992 + }, + { + "start": 21013, + "end": 21038 + }, + { + "start": 21059, + "end": 21103 + }, + { + "start": 21441, + "end": 21450 + }, + { + "start": 21497, + "end": 21500 + }, + { + "start": 21506, + "end": 21519 + }, + { + "start": 23765, + "end": 23773 + }, + { + "start": 24059, + "end": 24065 + }, + { + "start": 24236, + "end": 24309 + }, + { + "start": 27423, + "end": 27540 + }, + { + "start": 28176, + "end": 28218 + }, + { + "start": 29523, + "end": 29540 + }, + { + "start": 29869, + "end": 29878 + }, + { + "start": 29981, + "end": 29984 + }, + { + "start": 30154, + "end": 30157 + }, + { + "start": 30423, + "end": 30426 + }, + { + "start": 30739, + "end": 30742 + }, + { + "start": 31029, + "end": 31032 + }, + { + "start": 31131, + "end": 31134 + }, + { + "start": 31215, + "end": 31218 + }, + { + "start": 31418, + "end": 31421 + }, + { + "start": 31644, + "end": 31647 + }, + { + "start": 31750, + "end": 31753 + }, + { + "start": 31871, + "end": 31910 + }, + { + "start": 32400, + "end": 32401 + }, + { + "start": 32732, + "end": 33163 + }, + { + "start": 38213, + "end": 39138 + }, + { + "start": 39587, + "end": 39629 + }, + { + "start": 40887, + "end": 41027 + }, + { + "start": 41056, + "end": 41057 + }, + { + "start": 41123, + "end": 41176 + }, + { + "start": 41505, + "end": 41527 + }, + { + "start": 42063, + "end": 42441 + }, + { + "start": 42777, + "end": 43598 + }, + { + "start": 43823, + "end": 43858 + }, + { + "start": 44757, + "end": 44774 + }, + { + "start": 45103, + "end": 45125 + }, + { + "start": 45394, + "end": 45402 + }, + { + "start": 45806, + "end": 45828 + }, + { + "start": 50509, + "end": 51268 + }, + { + "start": 51573, + "end": 52402 + }, + { + "start": 52481, + "end": 52502 + }, + { + "start": 57643, + "end": 58402 + }, + { + "start": 59428, + "end": 59527 + }, + { + "start": 59571, + "end": 59955 + }, + { + "start": 63962, + "end": 65178 + }, + { + "start": 67149, + "end": 67385 + }, + { + "start": 67975, + "end": 68229 + }, + { + "start": 68408, + "end": 68455 + }, + { + "start": 68830, + "end": 68935 + }, + { + "start": 71888, + "end": 71934 + }, + { + "start": 72394, + "end": 72864 + }, + { + "start": 74270, + "end": 74760 + }, + { + "start": 75092, + "end": 75195 + }, + { + "start": 83549, + "end": 83742 + } + ] +} diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json index d4743b801efb..60cf5071b40a 100644 --- a/lighthouse-core/test/results/artifacts/artifacts.json +++ b/lighthouse-core/test/results/artifacts/artifacts.json @@ -2118,5 +2118,6 @@ "systemId": "", "publicId": "" }, - "MainDocumentContent": "\n\n\n\n\n\nDoBetterWeb Mega Tester... Of Death\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\n\n\n\n
\n

Do better web tester page

\n Hi there!\n\n \n \n Facebook\n \n \n \n
\n\n
touchmove section
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + "MainDocumentContent": "\n\n\n\n\n\nDoBetterWeb Mega Tester... Of Death\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\n\n\n\n
\n

Do better web tester page

\n Hi there!\n\n \n \n Facebook\n \n \n \n
\n\n
touchmove section
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "SourceMaps": [] } \ No newline at end of file diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index 12cafeff60aa..26ec2d33f047 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -5280,6 +5280,12 @@ "duration": 100, "entryType": "measure" }, + { + "startTime": 0, + "name": "lh:computed:JSBundles", + "duration": 100, + "entryType": "measure" + }, { "startTime": 0, "name": "lh:audit:uses-webp-images", diff --git a/package.json b/package.json index 608fc9b05e25..76b353bfb766 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ }, { "path": "./dist/lighthouse-dt-bundle.js", - "maxSize": "440 kB" + "maxSize": "450 kB" }, { "path": "./dist/lightrider/report-generator-bundle.js", diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json index edac8142b0ee..add66f1de4ca 100644 --- a/proto/sample_v2_round_trip.json +++ b/proto/sample_v2_round_trip.json @@ -5195,6 +5195,12 @@ "name": "lh:audit:unused-javascript", "startTime": 0.0 }, + { + "duration": 100.0, + "entryType": "measure", + "name": "lh:computed:JSBundles", + "startTime": 0.0 + }, { "duration": 100.0, "entryType": "measure",