diff --git a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js index 2d4b085164ad..9350b2782076 100644 --- a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js +++ b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js @@ -35,7 +35,7 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { title: str_(UIStrings.title), description: str_(UIStrings.description), scoreDisplayMode: ByteEfficiencyAudit.SCORING_MODES.NUMERIC, - requiredArtifacts: ['OptimizedImages', 'devtoolsLogs', 'traces'], + requiredArtifacts: ['OptimizedImages', 'ImageElements', 'devtoolsLogs', 'traces'], }; } @@ -49,12 +49,31 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { return {bytes, percent}; } + /** + * @param {LH.Artifacts.ImageElement} imageElement + * @return {number} + */ + static estimateJPEGSizeFromDimensions(imageElement) { + const totalPixels = imageElement.naturalWidth * imageElement.naturalHeight; + // Even JPEGs with lots of detail can usually be compressed down to <1 byte per pixel + // Using 4:2:2 subsampling already gets an uncompressed bitmap to 2 bytes per pixel. + // The compression ratio for JPEG is usually somewhere around 10:1 depending on content, so + // 8:1 is a reasonable expectation for web content which is 1.5MB for a 6MP image. + const expectedBytesPerPixel = 2 * 1 / 8; + return Math.round(totalPixels * expectedBytesPerPixel); + } + /** * @param {LH.Artifacts} artifacts * @return {ByteEfficiencyAudit.ByteEfficiencyProduct} */ static audit_(artifacts) { + const pageURL = artifacts.URL.finalUrl; const images = artifacts.OptimizedImages; + const imageElements = artifacts.ImageElements; + /** @type {Map} */ + const imageElementsByURL = new Map(); + imageElements.forEach(img => imageElementsByURL.set(img.src, img)); /** @type {Array<{url: string, fromProtocol: boolean, isCrossOrigin: boolean, totalBytes: number, wastedBytes: number}>} */ const items = []; @@ -63,18 +82,34 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { if (image.failed) { warnings.push(`Unable to decode ${URL.getURLDisplayName(image.url)}`); continue; - } else if (/(jpeg|bmp)/.test(image.mimeType) === false || - image.originalSize < image.jpegSize + IGNORE_THRESHOLD_IN_BYTES) { + } else if (/(jpeg|bmp)/.test(image.mimeType) === false) { continue; } + let jpegSize = image.jpegSize; + let fromProtocol = true; + + if (typeof jpegSize === 'undefined') { + const imageElement = imageElementsByURL.get(image.url); + if (!imageElement) { + warnings.push(`Unable to locate resource ${URL.getURLDisplayName(image.url)}`); + continue; + } + + jpegSize = UsesOptimizedImages.estimateJPEGSizeFromDimensions(imageElement); + fromProtocol = false; + } + + if (image.originalSize < jpegSize + IGNORE_THRESHOLD_IN_BYTES) continue; + const url = URL.elideDataURI(image.url); - const jpegSavings = UsesOptimizedImages.computeSavings(image); + const isCrossOrigin = !URL.originsMatch(pageURL, image.url); + const jpegSavings = UsesOptimizedImages.computeSavings({...image, jpegSize}); items.push({ url, - fromProtocol: image.fromProtocol, - isCrossOrigin: !image.isSameOrigin, + fromProtocol, + isCrossOrigin, totalBytes: image.originalSize, wastedBytes: jpegSavings.bytes, }); diff --git a/lighthouse-core/audits/byte-efficiency/uses-webp-images.js b/lighthouse-core/audits/byte-efficiency/uses-webp-images.js index fb681dd3ac40..50865298fc23 100644 --- a/lighthouse-core/audits/byte-efficiency/uses-webp-images.js +++ b/lighthouse-core/audits/byte-efficiency/uses-webp-images.js @@ -49,12 +49,32 @@ class UsesWebPImages extends ByteEfficiencyAudit { return {bytes, percent}; } + /** + * @param {LH.Artifacts.ImageElement} imageElement + * @return {number} + */ + static estimateWebPSizeFromDimensions(imageElement) { + const totalPixels = imageElement.naturalWidth * imageElement.naturalHeight; + // See uses-optimized-images for the rationale behind our 2 byte-per-pixel baseline and + // JPEG compression ratio of 8:1. + // WebP usually gives ~20% additional savings on top of that, so we will use 10:1. + // This is quite pessimistic as their study shows a photographic compression ratio of ~29:1. + // https://developers.google.com/speed/webp/docs/webp_lossless_alpha_study#results + const expectedBytesPerPixel = 2 * 1 / 10; + return Math.round(totalPixels * expectedBytesPerPixel); + } + /** * @param {LH.Artifacts} artifacts * @return {ByteEfficiencyAudit.ByteEfficiencyProduct} */ static audit_(artifacts) { + const pageURL = artifacts.URL.finalUrl; const images = artifacts.OptimizedImages; + const imageElements = artifacts.ImageElements; + /** @type {Map} */ + const imageElementsByURL = new Map(); + imageElements.forEach(img => imageElementsByURL.set(img.src, img)); /** @type {Array} */ const items = []; @@ -63,17 +83,32 @@ class UsesWebPImages extends ByteEfficiencyAudit { if (image.failed) { warnings.push(`Unable to decode ${URL.getURLDisplayName(image.url)}`); continue; - } else if (image.originalSize < image.webpSize + IGNORE_THRESHOLD_IN_BYTES) { - continue; } + let webpSize = image.webpSize; + let fromProtocol = true; + + if (typeof webpSize === 'undefined') { + const imageElement = imageElementsByURL.get(image.url); + if (!imageElement) { + warnings.push(`Unable to locate resource ${URL.getURLDisplayName(image.url)}`); + continue; + } + + webpSize = UsesWebPImages.estimateWebPSizeFromDimensions(imageElement); + fromProtocol = false; + } + + if (image.originalSize < webpSize + IGNORE_THRESHOLD_IN_BYTES) continue; + const url = URL.elideDataURI(image.url); - const webpSavings = UsesWebPImages.computeSavings(image); + const isCrossOrigin = !URL.originsMatch(pageURL, image.url); + const webpSavings = UsesWebPImages.computeSavings({...image, webpSize: webpSize}); items.push({ url, - fromProtocol: image.fromProtocol, - isCrossOrigin: !image.isSameOrigin, + fromProtocol, + isCrossOrigin, totalBytes: image.originalSize, wastedBytes: webpSavings.bytes, }); diff --git a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js index 6ca6a9a16416..0a96d98b3c04 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/optimized-images.js @@ -16,6 +16,12 @@ const NetworkRequest = require('../../../lib/network-request'); const Sentry = require('../../../lib/sentry'); const Driver = require('../../driver.js'); // eslint-disable-line no-unused-vars +// Image encoding can be slow and we don't want to spend forever on it. +// Cap our encoding to 5 seconds, anything after that will be estimated. +const MAX_TIME_TO_SPEND_ENCODING = 5000; +// Cap our image file size at 2MB, anything bigger than that will be estimated. +const MAX_RESOURCE_SIZE_TO_ENCODE = 2000 * 1024; + const JPEG_QUALITY = 0.92; const WEBP_QUALITY = 0.85; @@ -23,63 +29,19 @@ const MINIMUM_IMAGE_SIZE = 4096; // savings of <4 KB will be ignored in the audi const IMAGE_REGEX = /^image\/((x|ms|x-ms)-)?(png|bmp|jpeg)$/; -/** @typedef {{isSameOrigin: boolean, isBase64DataUri: boolean, requestId: string, url: string, mimeType: string, resourceSize: number}} SimplifiedNetworkRecord */ - -/* global document, Image, atob */ - -/** - * Runs in the context of the browser - * @param {string} url - * @return {Promise<{jpeg: {base64: number, binary: number}, webp: {base64: number, binary: number}}>} - */ -/* istanbul ignore next */ -function getOptimizedNumBytes(url) { - return new Promise(function(resolve, reject) { - 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 - * @return {{base64: number, binary: number}} - */ - function getTypeStats(type, quality) { - const dataURI = canvas.toDataURL(type, quality); - const base64 = dataURI.slice(dataURI.indexOf(',') + 1); - return {base64: base64.length, binary: atob(base64).length}; - } - - img.addEventListener('error', reject); - img.addEventListener('load', () => { - try { - canvas.height = img.height; - canvas.width = img.width; - context.drawImage(img, 0, 0); - - const jpeg = getTypeStats('image/jpeg', 0.92); - const webp = getTypeStats('image/webp', 0.85); - - resolve({jpeg, webp}); - } catch (err) { - reject(err); - } - }, false); - - img.src = url; - }); -} +/** @typedef {{requestId: string, url: string, mimeType: string, resourceSize: number}} SimplifiedNetworkRecord */ class OptimizedImages extends Gatherer { + constructor() { + super(); + this._encodingStartAt = 0; + } + /** - * @param {string} pageUrl * @param {Array} networkRecords * @return {Array} */ - static filterImageRequests(pageUrl, networkRecords) { + static filterImageRequests(networkRecords) { /** @type {Set} */ const seenUrls = new Set(); return networkRecords.reduce((prev, record) => { @@ -90,14 +52,10 @@ class OptimizedImages extends Gatherer { seenUrls.add(record.url); const isOptimizableImage = record.resourceType === NetworkRequest.TYPES.Image && IMAGE_REGEX.test(record.mimeType); - const isSameOrigin = URL.originsMatch(pageUrl, record.url); - const isBase64DataUri = /^data:.{2,40}base64\s*,/.test(record.url); const actualResourceSize = Math.min(record.resourceSize || 0, record.transferSize || 0); if (isOptimizableImage && actualResourceSize > MINIMUM_IMAGE_SIZE) { prev.push({ - isSameOrigin, - isBase64DataUri, requestId: record.requestId, url: record.url, mimeType: record.mimeType, @@ -126,48 +84,25 @@ class OptimizedImages extends Gatherer { /** * @param {Driver} driver * @param {SimplifiedNetworkRecord} networkRecord - * @return {Promise} + * @return {Promise<{originalSize: number, jpegSize?: number, webpSize?: number}>} */ - calculateImageStats(driver, networkRecord) { - return Promise.resolve(networkRecord.requestId).then(requestId => { - if (this._getEncodedResponseUnsupported) return; - return this._getEncodedResponse(driver, requestId, 'jpeg').then(jpegData => { - return this._getEncodedResponse(driver, requestId, 'webp').then(webpData => { - return { - fromProtocol: true, - originalSize: networkRecord.resourceSize, - jpegSize: jpegData.encodedSize, - webpSize: webpData.encodedSize, - }; - }); - }).catch(err => { - if (/wasn't found/.test(err.message)) { - // Mark non-support so we don't keep attempting the protocol method over and over - this._getEncodedResponseUnsupported = true; - } else { - throw err; - } - }); - }).then(result => { - if (result) return result; - - // Take the slower fallback path if getEncodedResponse didn't work - // CORS canvas tainting doesn't support cross-origin images, so skip them early - if (!networkRecord.isSameOrigin && !networkRecord.isBase64DataUri) return null; - - const script = `(${getOptimizedNumBytes.toString()})(${JSON.stringify(networkRecord.url)})`; - return driver.evaluateAsync(script).then(stats => { - if (!stats) return null; - const isBase64DataUri = networkRecord.isBase64DataUri; - const base64Length = networkRecord.url.length - networkRecord.url.indexOf(',') - 1; - return { - fromProtocol: false, - originalSize: isBase64DataUri ? base64Length : networkRecord.resourceSize, - jpegSize: isBase64DataUri ? stats.jpeg.base64 : stats.jpeg.binary, - webpSize: isBase64DataUri ? stats.webp.base64 : stats.webp.binary, - }; - }); - }); + async calculateImageStats(driver, networkRecord) { + const originalSize = networkRecord.resourceSize; + // Once we've hit our execution time limit or when the image is too big, don't try to re-encode it. + // Images in this execution path will fallback to byte-per-pixel heuristics on the audit side. + if (Date.now() - this._encodingStartAt > MAX_TIME_TO_SPEND_ENCODING || + originalSize > MAX_RESOURCE_SIZE_TO_ENCODE) { + return {originalSize, jpegSize: undefined, webpSize: undefined}; + } + + const jpegData = await this._getEncodedResponse(driver, networkRecord.requestId, 'jpeg'); + const webpData = await this._getEncodedResponse(driver, networkRecord.requestId, 'webp'); + + return { + originalSize, + jpegSize: jpegData.encodedSize, + webpSize: webpData.encodedSize, + }; } /** @@ -176,16 +111,14 @@ class OptimizedImages extends Gatherer { * @return {Promise} */ async computeOptimizedImages(driver, imageRecords) { + this._encodingStartAt = Date.now(); + /** @type {LH.Artifacts['OptimizedImages']} */ const results = []; for (const record of imageRecords) { try { const stats = await this.calculateImageStats(driver, record); - if (stats === null) { - continue; - } - /** @type {LH.Artifacts.OptimizedImage} */ const image = {failed: false, ...stats, ...record}; results.push(image); @@ -214,7 +147,9 @@ class OptimizedImages extends Gatherer { */ afterPass(passContext, loadData) { const networkRecords = loadData.networkRecords; - const imageRecords = OptimizedImages.filterImageRequests(passContext.url, networkRecords); + const imageRecords = OptimizedImages + .filterImageRequests(networkRecords) + .sort((a, b) => b.resourceSize - a.resourceSize); return Promise.resolve() .then(_ => this.computeOptimizedImages(passContext.driver, imageRecords)) diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js index 72d9bd5f1fdc..7b89b157f51d 100644 --- a/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js +++ b/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js @@ -5,24 +5,42 @@ */ 'use strict'; -const UsesOptimizedImagesAudit = - require('../../../audits/byte-efficiency/uses-optimized-images.js'); -const assert = require('assert'); - -function generateImage(type, originalSize, webpSize, jpegSize) { - const isData = /^data:/.test(type); - if (isData) { - type = type.slice('data:'.length); +const OptimizedImagesAudit = require('../../../audits/byte-efficiency/uses-optimized-images.js'); + +function generateArtifacts(images) { + const optimizedImages = []; + const imageElements = []; + + for (const image of images) { + let {type = 'jpeg'} = image; + const isData = /^data:/.test(type); + if (isData) { + type = type.slice('data:'.length); + } + + const mimeType = image.mimeType || `image/${type}`; + const url = isData + ? `data:${mimeType};base64,reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly ` + + 'reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly long' + : `http://google.com/image.${type}`; + + optimizedImages.push({ + url, + mimeType, + ...image, + }); + + imageElements.push({ + src: url, + naturalWidth: image.width, + naturalHeight: image.height, + }); } return { - isBase64DataUri: isData, - url: isData ? - `data:image/${type};base64,reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly ` + - 'reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly long' : - `http://google.com/image.${type}`, - mimeType: `image/${type}`, - originalSize, webpSize, jpegSize, + URL: {finalUrl: 'http://google.com/'}, + ImageElements: imageElements, + OptimizedImages: optimizedImages, }; } @@ -30,64 +48,101 @@ function generateImage(type, originalSize, webpSize, jpegSize) { describe('Page uses optimized images', () => { it('ignores files when there is only insignificant savings', () => { - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [ - generateImage('jpeg', 5000, 4000, 4500), - ], - }); + const artifacts = generateArtifacts([{originalSize: 5000, jpegSize: 4500}]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); - assert.equal(auditResult.items.length, 0); + expect(auditResult.items).toEqual([]); }); it('flags files when there is only small savings', () => { - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [ - generateImage('jpeg', 15000, 4000, 4500), - ], - }); + const artifacts = generateArtifacts([{originalSize: 15000, jpegSize: 4500}]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([ + { + fromProtocol: true, + isCrossOrigin: false, + totalBytes: 15000, + wastedBytes: 15000 - 4500, + url: 'http://google.com/image.jpeg', + }, + ]); + }); - assert.equal(auditResult.items.length, 1); + it('estimates savings on files without jpegSize', () => { + const artifacts = generateArtifacts([{originalSize: 1e6, width: 1000, height: 1000}]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([ + { + fromProtocol: false, + isCrossOrigin: false, + totalBytes: 1e6, + wastedBytes: 1e6 - 1000 * 1000 * 2 / 8, + url: 'http://google.com/image.jpeg', + }, + ]); }); - it('ignores files when no jpeg savings is available', () => { - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [ - generateImage('png', 150000, 40000), - ], - }); + it('estimates savings on cross-origin files', () => { + const artifacts = generateArtifacts([{ + url: 'http://localhost:1234/image.jpg', originalSize: 50000, jpegSize: 20000, + }]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); + + expect(auditResult.items).toMatchObject([ + { + fromProtocol: true, + isCrossOrigin: true, + url: 'http://localhost:1234/image.jpg', + }, + ]); + }); + + it('ignores files when file type is not JPEG/BMP', () => { + const artifacts = generateArtifacts([{type: 'png', originalSize: 150000}]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); - assert.equal(auditResult.items.length, 0); + expect(auditResult.items).toEqual([]); }); it('passes when all images are sufficiently optimized', () => { - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [ - generateImage('png', 50000, 30000), - generateImage('jpeg', 50000, 30000, 50001), - generateImage('png', 50000, 30000), - generateImage('jpeg', 50000, 30000, 50001), - generateImage('png', 49999, 30000), - ], - }); + const artifacts = generateArtifacts([ + {type: 'png', originalSize: 50000}, + {type: 'jpeg', originalSize: 50000, jpegSize: 50001}, + {type: 'png', originalSize: 50000}, + ]); + + const auditResult = OptimizedImagesAudit.audit_(artifacts); - assert.equal(auditResult.items.length, 0); + expect(auditResult.items).toEqual([]); }); - it('limits output of data URIs', () => { - const image = generateImage('data:jpeg', 50000, 30000, 30000); - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [image], - }); + it('elides data URIs', () => { + const artifacts = generateArtifacts([ + {type: 'data:jpeg', originalSize: 15000, jpegSize: 4500}, + ]); + + const auditResult = OptimizedImagesAudit.audit_(artifacts); - const actualUrl = auditResult.items[0].url; - assert.ok(actualUrl.length < image.url.length, `${actualUrl} >= ${image.url}`); + expect(auditResult.items).toHaveLength(1); + expect(auditResult.items[0].url).toMatch(/^data.{2,40}/); }); it('warns when images have failed', () => { - const auditResult = UsesOptimizedImagesAudit.audit_({ - OptimizedImages: [{failed: true, url: 'http://localhost/image.jpg'}], - }); + const artifacts = generateArtifacts([{failed: true, url: 'http://localhost/image.jpeg'}]); + const auditResult = OptimizedImagesAudit.audit_(artifacts); + + expect(auditResult.items).toHaveLength(0); + expect(auditResult.warnings).toHaveLength(1); + }); + + it('warns when missing ImageElement', () => { + const artifacts = generateArtifacts([{originalSize: 1e6}]); + artifacts.ImageElements = []; + const auditResult = OptimizedImagesAudit.audit_(artifacts); - assert.ok(/image.jpg/.test(auditResult.warnings[0])); + expect(auditResult.items).toHaveLength(0); + expect(auditResult.warnings).toHaveLength(1); }); }); diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-webp-images-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-webp-images-test.js new file mode 100644 index 000000000000..611e908bca7b --- /dev/null +++ b/lighthouse-core/test/audits/byte-efficiency/uses-webp-images-test.js @@ -0,0 +1,141 @@ +/** + * @license Copyright 2019 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const WebPImagesAudit = require('../../../audits/byte-efficiency/uses-webp-images.js'); + +function generateArtifacts(images) { + const optimizedImages = []; + const imageElements = []; + + for (const image of images) { + let {type = 'jpeg'} = image; + const isData = /^data:/.test(type); + if (isData) { + type = type.slice('data:'.length); + } + + const mimeType = image.mimeType || `image/${type}`; + const url = isData + ? `data:${mimeType};base64,reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly ` + + 'reaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaly long' + : `http://google.com/image.${type}`; + + optimizedImages.push({ + url, + mimeType, + ...image, + }); + + imageElements.push({ + src: url, + naturalWidth: image.width, + naturalHeight: image.height, + }); + } + + return { + URL: {finalUrl: 'http://google.com/'}, + ImageElements: imageElements, + OptimizedImages: optimizedImages, + }; +} + +/* eslint-env jest */ + +describe('Page uses optimized images', () => { + it('ignores files when there is only insignificant savings', () => { + const artifacts = generateArtifacts([{originalSize: 5000, webpSize: 4500}]); + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([]); + }); + + it('flags files when there is only small savings', () => { + const artifacts = generateArtifacts([{originalSize: 15000, webpSize: 4500}]); + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([ + { + fromProtocol: true, + isCrossOrigin: false, + totalBytes: 15000, + wastedBytes: 15000 - 4500, + url: 'http://google.com/image.jpeg', + }, + ]); + }); + + it('estimates savings on files without webpSize', () => { + const artifacts = generateArtifacts([{originalSize: 1e6, width: 1000, height: 1000}]); + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([ + { + fromProtocol: false, + isCrossOrigin: false, + totalBytes: 1e6, + wastedBytes: 1e6 - 1000 * 1000 * 2 / 10, + url: 'http://google.com/image.jpeg', + }, + ]); + }); + + it('estimates savings on cross-origin files', () => { + const artifacts = generateArtifacts([{ + url: 'http://localhost:1234/image.jpeg', originalSize: 50000, webpSize: 20000, + }]); + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toMatchObject([ + { + fromProtocol: true, + isCrossOrigin: true, + url: 'http://localhost:1234/image.jpeg', + }, + ]); + }); + + it('passes when all images are sufficiently optimized', () => { + const artifacts = generateArtifacts([ + {type: 'png', originalSize: 50000, webpSize: 50001}, + {type: 'jpeg', originalSize: 50000, webpSize: 50001}, + {type: 'bmp', originalSize: 4000, webpSize: 2000}, + ]); + + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toEqual([]); + }); + + it('elides data URIs', () => { + const artifacts = generateArtifacts([ + {type: 'data:webp', originalSize: 15000, webpSize: 4500}, + ]); + + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toHaveLength(1); + expect(auditResult.items[0].url).toMatch(/^data.{2,40}/); + }); + + it('warns when images have failed', () => { + const artifacts = generateArtifacts([{failed: true, url: 'http://localhost/image.jpeg'}]); + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toHaveLength(0); + expect(auditResult.warnings).toHaveLength(1); + }); + + it('warns when missing ImageElement', () => { + const artifacts = generateArtifacts([{originalSize: 1e6}]); + artifacts.ImageElements = []; + const auditResult = WebPImagesAudit.audit_(artifacts); + + expect(auditResult.items).toHaveLength(0); + expect(auditResult.warnings).toHaveLength(1); + }); +}); diff --git a/lighthouse-core/test/gather/gatherers/dobetterweb/optimized-images-test.js b/lighthouse-core/test/gather/gatherers/dobetterweb/optimized-images-test.js index d017f0387938..7af9f3e10f65 100644 --- a/lighthouse-core/test/gather/gatherers/dobetterweb/optimized-images-test.js +++ b/lighthouse-core/test/gather/gatherers/dobetterweb/optimized-images-test.js @@ -7,24 +7,19 @@ /* eslint-env jest */ -const OptimizedImages = - require('../../../../gather/gatherers/dobetterweb/optimized-images'); -const assert = require('assert'); +const OptimizedImages = require('../../../../gather/gatherers/dobetterweb/optimized-images'); let options; let optimizedImages; -const fakeImageStats = { - jpeg: {base64: 100, binary: 80}, - webp: {base64: 80, binary: 60}, -}; + const traceData = { networkRecords: [ { requestId: '1', url: 'http://google.com/image.jpg', mimeType: 'image/jpeg', - resourceSize: 10000, - transferSize: 20000, + resourceSize: 10000000, + transferSize: 20000000, resourceType: 'Image', finished: true, }, @@ -110,79 +105,67 @@ describe('Optimized images', () => { options = { url: 'http://google.com/', driver: { - evaluateAsync: function() { - return Promise.resolve(fakeImageStats); - }, - sendCommand: function() { - return Promise.reject(new Error('wasn\'t found')); + sendCommand: function(command, params) { + const encodedSize = params.encoding === 'webp' ? 60 : 80; + return Promise.resolve({encodedSize}); }, }, }; }); - it('returns all images', () => { - return optimizedImages.afterPass(options, traceData).then(artifact => { - assert.equal(artifact.length, 4); - assert.ok(/image.jpg/.test(artifact[0].url)); - assert.ok(/transparent.png/.test(artifact[1].url)); - assert.ok(/image.bmp/.test(artifact[2].url)); - // skip cross-origin for now - // assert.ok(/gmail.*image.jpg/.test(artifact[3].url)); - assert.ok(/data: image/.test(artifact[3].url)); - }); - }); - - it('computes sizes', () => { - const checkSizes = (stat, original, webp, jpeg) => { - assert.equal(stat.originalSize, original); - assert.equal(stat.webpSize, webp); - assert.equal(stat.jpegSize, jpeg); - }; - - return optimizedImages.afterPass(options, traceData).then(artifact => { - assert.equal(artifact.length, 4); - checkSizes(artifact[0], 10000, 60, 80); - checkSizes(artifact[1], 11000, 60, 80); - checkSizes(artifact[2], 9000, 60, 80); - // skip cross-origin for now - // checkSizes(artifact[3], 15000, 60, 80); - checkSizes(artifact[3], 20, 80, 100); // uses base64 data - }); + it('returns all images, sorted with sizes', async () => { + const artifact = await optimizedImages.afterPass(options, traceData); + expect(artifact).toHaveLength(5); + expect(artifact).toMatchObject([ + { + jpegSize: undefined, + webpSize: undefined, + originalSize: 10000000, + url: 'http://google.com/image.jpg', + }, + { + jpegSize: 80, + webpSize: 60, + originalSize: 15000, + url: 'http://gmail.com/image.jpg', + }, + { + jpegSize: 80, + webpSize: 60, + originalSize: 14000, + url: 'data: image/jpeg ; base64 ,SgVcAT32587935321...', + }, + { + jpegSize: 80, + webpSize: 60, + originalSize: 11000, + url: 'http://google.com/transparent.png', + }, + { + jpegSize: 80, + webpSize: 60, + originalSize: 9000, + url: 'http://google.com/image.bmp', + }, + ]); }); it('handles partial driver failure', () => { let calls = 0; - options.driver.evaluateAsync = () => { + options.driver.sendCommand = () => { calls++; if (calls > 2) { return Promise.reject(new Error('whoops driver failed')); } else { - return Promise.resolve(fakeImageStats); + return Promise.resolve({encodedSize: 60}); } }; return optimizedImages.afterPass(options, traceData).then(artifact => { const failed = artifact.find(record => record.failed); - assert.equal(artifact.length, 4); - assert.ok(failed, 'passed along failure'); - assert.ok(/whoops/.test(failed.errMsg), 'passed along error message'); - }); - }); - - it('supports Audits.getEncodedResponse', () => { - options.driver.sendCommand = (method, params) => { - const encodedSize = params.encoding === 'webp' ? 60 : 80; - return Promise.resolve({encodedSize}); - }; - - return optimizedImages.afterPass(options, traceData).then(artifact => { - assert.equal(artifact.length, 5); - assert.equal(artifact[0].originalSize, 10000); - assert.equal(artifact[0].webpSize, 60); - assert.equal(artifact[0].jpegSize, 80); - // supports cross-origin - assert.ok(/gmail.*image.jpg/.test(artifact[3].url)); + expect(artifact).toHaveLength(5); + expect(failed && failed.errMsg).toEqual('whoops driver failed'); }); }); diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index 825833b9475c..99546c265971 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -263,13 +263,10 @@ declare global { export interface OptimizedImage { failed: false; - fromProtocol: boolean; originalSize: number; - jpegSize: number; - webpSize: number; + jpegSize?: number; + webpSize?: number; - isSameOrigin: boolean; - isBase64DataUri: boolean; requestId: string; url: string; mimeType: string; @@ -280,8 +277,6 @@ declare global { failed: true; errMsg: string; - isSameOrigin: boolean; - isBase64DataUri: boolean; requestId: string; url: string; mimeType: string;