diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
index ad342f016794..2568ca42c744 100644
--- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
+++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
@@ -113,7 +113,16 @@
-
+
+
+
+
+
+
+
+
+
+
@@ -316,6 +325,10 @@ Do better web tester page
stampTemplate('unoptimized-images-tmpl', document.body);
}
+function responsiveImagesTest() {
+ stampTemplate('responsive-images-tmpl', document.body);
+}
+
function deprecationsTest() {
stampTemplate('deprecations-tmpl', document.head);
@@ -351,6 +364,7 @@ Do better web tester page
oldCSSFlexboxTest();
unusedCssRulesTest();
unoptimizedImagesTest();
+ responsiveImagesTest();
deprecationsTest();
} else {
if (params.has('documentWrite')) {
@@ -392,6 +406,9 @@ Do better web tester page
if (params.has('unoptimizedimages')) {
unoptimizedImagesTest();
}
+ if (params.has('responsiveimages')) {
+ responsiveImagesTest();
+ }
if (params.has('deprecations')) {
deprecationsTest();
}
diff --git a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
index 5fbdb74d930d..165bc3dba80b 100644
--- a/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
+++ b/lighthouse-cli/test/smokehouse/dobetterweb/dbw-expectations.js
@@ -134,6 +134,14 @@ module.exports = [
'uses-optimized-images': {
score: false
},
+ 'uses-responsive-images': {
+ score: false,
+ extendedInfo: {
+ value: {
+ length: 2
+ }
+ }
+ },
'deprecations': {
score: false,
extendedInfo: {
diff --git a/lighthouse-core/audits/dobetterweb/uses-responsive-images.js b/lighthouse-core/audits/dobetterweb/uses-responsive-images.js
new file mode 100644
index 000000000000..bb6804185321
--- /dev/null
+++ b/lighthouse-core/audits/dobetterweb/uses-responsive-images.js
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+ /**
+ * @fileoverview Checks to see if the images used on the page are larger than
+ * their display sizes. The audit will list all images that are larger than
+ * their display size regardless of DPR (a 1000px wide image displayed as a
+ * 500px high-res image on a Retina display will show up as 75% unused);
+ * however, the audit will only fail pages that use images that have waste
+ * when computed with DPR taken into account.
+ */
+'use strict';
+
+const Audit = require('../audit');
+const URL = require('../../lib/url-shim');
+const Formatter = require('../../formatters/formatter');
+
+const KB_IN_BYTES = 1024;
+const WASTEFUL_THRESHOLD_AS_RATIO = 0.1;
+
+class UsesResponsiveImages extends Audit {
+ /**
+ * @return {!AuditMeta}
+ */
+ static get meta() {
+ return {
+ category: 'Images',
+ name: 'uses-responsive-images',
+ description: 'Site uses appropriate image sizes',
+ helpText:
+ 'Image sizes served should be based on the device display size to save network bytes. ' +
+ 'Learn more about [responsive images](https://developers.google.com/web/fundamentals/design-and-ui/media/images) ' +
+ 'and [client hints](https://developers.google.com/web/updates/2015/09/automating-resource-selection-with-client-hints).',
+ requiredArtifacts: ['ImageUsage', 'ContentWidth']
+ };
+ }
+
+ /**
+ * @param {!Object} image
+ * @param {number} DPR devicePixelRatio
+ * @return {?Object}
+ */
+ static computeWaste(image, DPR) {
+ const url = URL.getDisplayName(image.src);
+ const actualPixels = image.naturalWidth * image.naturalHeight;
+ const usedPixels = image.clientWidth * image.clientHeight;
+ const usedPixelsFullDPR = usedPixels * Math.pow(DPR, 2);
+ const wastedRatio = 1 - (usedPixels / actualPixels);
+ const wastedRatioFullDPR = 1 - (usedPixelsFullDPR / actualPixels);
+
+ if (!Number.isFinite(wastedRatio)) {
+ return new Error(`Invalid image sizing information ${url}`);
+ } else if (wastedRatio <= 0) {
+ // Image did not have sufficient resolution to fill display at DPR=1
+ return null;
+ }
+
+ // TODO(#1517): use an average transfer time for data URI images
+ const size = image.networkRecord.resourceSize;
+ const transferTimeInMs = 1000 * (image.networkRecord.endTime -
+ image.networkRecord.responseReceivedTime);
+ const wastedBytes = Math.round(size * wastedRatio);
+ const wastedTime = Math.round(transferTimeInMs * wastedRatio);
+ const percentSavings = Math.round(100 * wastedRatio);
+ const label = `${Math.round(size / KB_IN_BYTES)}KB total, ${percentSavings}% potential savings`;
+
+ return {
+ wastedBytes,
+ wastedTime,
+ isWasteful: wastedRatioFullDPR > WASTEFUL_THRESHOLD_AS_RATIO,
+ result: {url, label},
+ };
+ }
+
+ /**
+ * @param {!Artifacts} artifacts
+ * @return {!AuditResult}
+ */
+ static audit(artifacts) {
+ const images = artifacts.ImageUsage;
+ const contentWidth = artifacts.ContentWidth;
+
+ let debugString;
+ let totalWastedBytes = 0;
+ let totalWastedTime = 0;
+ let hasWastefulImage = false;
+ const DPR = contentWidth.devicePixelRatio;
+ const results = images.reduce((results, image) => {
+ if (!image.networkRecord) {
+ return results;
+ }
+
+ const processed = UsesResponsiveImages.computeWaste(image, DPR);
+ if (!processed) {
+ return results;
+ } else if (processed instanceof Error) {
+ debugString = processed.message;
+ return results;
+ }
+
+ hasWastefulImage = hasWastefulImage || processed.isWasteful;
+ totalWastedTime += processed.wastedTime;
+ totalWastedBytes += processed.wastedBytes;
+ results.push(processed.result);
+ return results;
+ }, []);
+
+ let displayValue;
+ if (results.length) {
+ const totalWastedKB = Math.round(totalWastedBytes / KB_IN_BYTES);
+ displayValue = `${totalWastedKB}KB (~${totalWastedTime}ms) potential savings`;
+ }
+
+ return UsesResponsiveImages.generateAuditResult({
+ debugString,
+ displayValue,
+ rawValue: !hasWastefulImage,
+ extendedInfo: {
+ formatter: Formatter.SUPPORTED_FORMATS.URLLIST,
+ value: results
+ }
+ });
+ }
+}
+
+module.exports = UsesResponsiveImages;
diff --git a/lighthouse-core/config/default.json b/lighthouse-core/config/default.json
index e82fae421bbd..427fc8878644 100644
--- a/lighthouse-core/config/default.json
+++ b/lighthouse-core/config/default.json
@@ -9,6 +9,7 @@
"theme-color",
"manifest",
"accessibility",
+ "image-usage",
"content-width"
]
},
@@ -96,6 +97,7 @@
"dobetterweb/script-blocking-first-paint",
"dobetterweb/uses-http2",
"dobetterweb/uses-optimized-images",
+ "dobetterweb/uses-responsive-images",
"dobetterweb/uses-passive-event-listeners"
],
@@ -294,7 +296,8 @@
"name": "Using bytes efficiently",
"audits": {
"unused-css-rules": {},
- "uses-optimized-images": {}
+ "uses-optimized-images": {},
+ "uses-responsive-images": {}
}
}, {
"name": "Using modern CSS features",
diff --git a/lighthouse-core/gather/gatherers/content-width.js b/lighthouse-core/gather/gatherers/content-width.js
index 8cc8b66a2e4d..f2d388dcad2c 100644
--- a/lighthouse-core/gather/gatherers/content-width.js
+++ b/lighthouse-core/gather/gatherers/content-width.js
@@ -24,9 +24,11 @@ const Gatherer = require('./gatherer');
function getContentWidth() {
// window.innerWidth to get the scrollable size of the window (irrespective of zoom)
// window.outerWidth to get the size of the visible area
+ // window.devicePixelRatio to get ratio of logical pixels to physical pixels
return Promise.resolve({
scrollWidth: window.innerWidth,
- viewportWidth: window.outerWidth
+ viewportWidth: window.outerWidth,
+ devicePixelRatio: window.devicePixelRatio,
});
}
@@ -39,7 +41,8 @@ class ContentWidth extends Gatherer {
.then(returnedValue => {
if (!Number.isFinite(returnedValue.scrollWidth) ||
- !Number.isFinite(returnedValue.viewportWidth)) {
+ !Number.isFinite(returnedValue.viewportWidth) ||
+ !Number.isFinite(returnedValue.devicePixelRatio)) {
throw new Error(`ContentWidth results were not numeric: ${JSON.stringify(returnedValue)}`);
}
@@ -47,7 +50,8 @@ class ContentWidth extends Gatherer {
}, _ => {
return {
scrollWidth: -1,
- viewportWidth: -1
+ viewportWidth: -1,
+ devicePixelRatio: -1,
};
});
}
diff --git a/lighthouse-core/gather/gatherers/image-usage.js b/lighthouse-core/gather/gatherers/image-usage.js
new file mode 100644
index 000000000000..cc2bdbc6f10a
--- /dev/null
+++ b/lighthouse-core/gather/gatherers/image-usage.js
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ /**
+ * @fileoverview Gathers all images used on the page with their src, size,
+ * and attribute information. Executes script in the context of the page.
+ */
+'use strict';
+
+const Gatherer = require('./gatherer');
+
+/* global document, Image */
+
+/* istanbul ignore next */
+function collectImageElementInfo() {
+ return [...document.querySelectorAll('img')].map(element => {
+ return {
+ // currentSrc used over src to get the url as determined by the browser
+ // after taking into account srcset/media/sizes/etc.
+ src: element.currentSrc,
+ clientWidth: element.clientWidth,
+ clientHeight: element.clientHeight,
+ naturalWidth: element.naturalWidth,
+ naturalHeight: element.naturalHeight,
+ isPicture: element.parentElement.tagName === 'PICTURE',
+ };
+ });
+}
+
+/* istanbul ignore next */
+function determineNaturalSize(url) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.addEventListener('error', reject);
+ img.addEventListener('load', () => {
+ resolve({
+ naturalWidth: img.naturalWidth,
+ naturalHeight: img.naturalHeight
+ });
+ });
+
+ img.src = url;
+ });
+}
+
+class ImageUsage extends Gatherer {
+
+ /**
+ * @param {{src: string}} element
+ * @return {!Promise}
+ */
+ fetchElementWithSizeInformation(element) {
+ const url = JSON.stringify(element.src);
+ return this.driver.evaluateAsync(`(${determineNaturalSize.toString()})(${url})`)
+ .then(size => {
+ return Object.assign(element, size);
+ });
+ }
+
+ afterPass(options, traceData) {
+ const driver = this.driver = options.driver;
+ const indexedNetworkRecords = traceData.networkRecords.reduce((map, record) => {
+ if (/^image/.test(record._mimeType)) {
+ map[record._url] = {
+ url: record.url,
+ resourceSize: record.resourceSize,
+ startTime: record.startTime,
+ endTime: record.endTime,
+ responseReceivedTime: record.responseReceivedTime
+ };
+ }
+
+ return map;
+ }, {});
+
+ return driver.evaluateAsync(`(${collectImageElementInfo.toString()})()`)
+ .then(elements => {
+ return elements.reduce((promise, element) => {
+ return promise.then(collector => {
+ // link up the image with its network record
+ element.networkRecord = indexedNetworkRecords[element.src];
+
+ // Images within `picture` behave strangely and natural size information
+ // isn't accurate. Try to get the actual size if we can.
+ const elementPromise = element.isPicture && element.networkRecord ?
+ this.fetchElementWithSizeInformation(element) :
+ Promise.resolve(element);
+
+ return elementPromise.then(element => {
+ collector.push(element);
+ return collector;
+ });
+ });
+ }, Promise.resolve([]));
+ });
+ }
+}
+
+module.exports = ImageUsage;
diff --git a/lighthouse-core/test/audits/dobetterweb/uses-responsive-images-test.js b/lighthouse-core/test/audits/dobetterweb/uses-responsive-images-test.js
new file mode 100644
index 000000000000..e8b7c0b8a466
--- /dev/null
+++ b/lighthouse-core/test/audits/dobetterweb/uses-responsive-images-test.js
@@ -0,0 +1,129 @@
+/**
+ * Copyright 2017 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict';
+
+const UsesOptimizedImagesAudit = require('../../../audits/dobetterweb/uses-responsive-images.js');
+const assert = require('assert');
+
+/* eslint-env mocha */
+function generateRecord(resourceSizeInKb, durationInMs) {
+ return {
+ resourceSize: resourceSizeInKb * 1024,
+ endTime: durationInMs / 1000,
+ responseReceivedTime: 0
+ };
+}
+
+function generateSize(width, height, prefix) {
+ prefix = prefix || 'client';
+
+ const size = {};
+ size[`${prefix}Width`] = width;
+ size[`${prefix}Height`] = height;
+ return size;
+}
+
+function generateImage(clientSize, naturalSize, networkRecord, src) {
+ src = src || 'https://google.com/logo.png';
+
+ const image = {src, networkRecord};
+ Object.assign(image, clientSize, naturalSize);
+ return image;
+}
+
+describe('Page uses responsive images', () => {
+ it('fails when an image is much larger than displayed size', () => {
+ const auditResult = UsesOptimizedImagesAudit.audit({
+ ContentWidth: {devicePixelRatio: 1},
+ ImageUsage: [
+ generateImage(
+ generateSize(100, 100),
+ generateSize(200, 200, 'natural'),
+ generateRecord(60, 250)
+ ),
+ generateImage(
+ generateSize(100, 100),
+ generateSize(90, 90),
+ generateRecord(30, 200)
+ ),
+ ],
+ });
+
+ assert.equal(auditResult.rawValue, false);
+ assert.equal(auditResult.extendedInfo.value.length, 1);
+ assert.ok(/45KB/.test(auditResult.displayValue), 'computes total kb');
+ });
+
+ it('fails when an image is much larger than DPR displayed size', () => {
+ const auditResult = UsesOptimizedImagesAudit.audit({
+ ContentWidth: {devicePixelRatio: 2},
+ ImageUsage: [
+ generateImage(
+ generateSize(100, 100),
+ generateSize(300, 300, 'natural'),
+ generateRecord(90, 500)
+ ),
+ ],
+ });
+
+ assert.equal(auditResult.rawValue, false);
+ assert.equal(auditResult.extendedInfo.value.length, 1);
+ assert.ok(/80KB/.test(auditResult.displayValue), 'compute total kb');
+ });
+
+ it('handles images without network record', () => {
+ const auditResult = UsesOptimizedImagesAudit.audit({
+ ContentWidth: {devicePixelRatio: 2},
+ ImageUsage: [
+ generateImage(
+ generateSize(100, 100),
+ generateSize(300, 300, 'natural'),
+ null
+ ),
+ ],
+ });
+
+ assert.equal(auditResult.rawValue, true);
+ assert.equal(auditResult.extendedInfo.value.length, 0);
+ });
+
+ it('passes when all images are not wasteful', () => {
+ const auditResult = UsesOptimizedImagesAudit.audit({
+ ContentWidth: {devicePixelRatio: 2},
+ ImageUsage: [
+ generateImage(
+ generateSize(200, 200),
+ generateSize(210, 210, 'natural'),
+ generateRecord(100, 300)
+ ),
+ generateImage(
+ generateSize(100, 100),
+ generateSize(210, 210, 'natural'),
+ generateRecord(90, 500)
+ ),
+ generateImage(
+ generateSize(100, 100),
+ generateSize(80, 80, 'natural'),
+ generateRecord(20, 100),
+ 'data:image/jpeg;base64,foobar'
+ ),
+ ],
+ });
+
+ assert.equal(auditResult.rawValue, true);
+ assert.equal(auditResult.extendedInfo.value.length, 2);
+ });
+});
diff --git a/lighthouse-core/test/gather/gatherers/content-width-test.js b/lighthouse-core/test/gather/gatherers/content-width-test.js
index 58018cde396a..3f38c33e27fe 100644
--- a/lighthouse-core/test/gather/gatherers/content-width-test.js
+++ b/lighthouse-core/test/gather/gatherers/content-width-test.js
@@ -33,7 +33,8 @@ describe('Content Width gatherer', () => {
evaluateAsync() {
return Promise.resolve({
scrollWidth: 400,
- viewportWidth: 400
+ viewportWidth: 400,
+ devicePixelRatio: 2,
});
}
}