diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap index 65912628e525..239544b41b9c 100644 --- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap +++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap @@ -99,6 +99,9 @@ Object { Object { "path": "image-size-responsive", }, + Object { + "path": "preload-fonts", + }, Object { "path": "deprecations", }, @@ -752,6 +755,11 @@ Object { "id": "image-size-responsive", "weight": 1, }, + Object { + "group": "best-practices-ux", + "id": "preload-fonts", + "weight": 1, + }, Object { "group": "best-practices-browser-compat", "id": "doctype", diff --git a/lighthouse-cli/test/fixtures/perf/cors-fonts.css b/lighthouse-cli/test/fixtures/perf/cors-fonts.css index 7929ba24a8e4..d8c2e1fab2b6 100644 --- a/lighthouse-cli/test/fixtures/perf/cors-fonts.css +++ b/lighthouse-cli/test/fixtures/perf/cors-fonts.css @@ -2,6 +2,7 @@ font-family: 'Lobster Three'; font-style: normal; font-weight: 700; + font-display: optional; src: url("http://localhost:10503/perf/lobster-two-v10-latin-700.woff2?cors=true") format('woff2'); } .corsfont { diff --git a/lighthouse-cli/test/fixtures/perf/fonts.html b/lighthouse-cli/test/fixtures/perf/fonts.html index 8c8bf52b6b7a..a6635138533f 100644 --- a/lighthouse-cli/test/fixtures/perf/fonts.html +++ b/lighthouse-cli/test/fixtures/perf/fonts.html @@ -14,8 +14,14 @@ font-style: normal; font-weight: 700; font-display: optional; - /* We don't need `local` but keep around for testing robustness of our regex */ - src: local("Lobster Two"), url("./lobster-two-v10-latin-700.woff2?delay=4000") format('woff2'); + src: url("./lobster-two-v10-latin-700.woff2?delay=4000") format('woff2'); + } + @font-face { + font-family: 'Lobster Four'; + font-style: normal; + font-weight: 700; + font-display: optional; + src: url("./lobster-two-v10-latin-700.woff2?delay=1000") format('woff2'); } .webfont { font-family: Lobster, sans-serif; @@ -26,13 +32,24 @@ .nofont { font-family: Unknown, sans-serif; } + .optionalfont { + font-family: Lobster Four, sans-serif; + } + + + + + +

Let's load some sweet webfonts...

Let's load some sweet webfonts...

Some lovely text that uses the fallback font

Some lovely text that uses a CORS font

+ +

Some lovely text that uses an optional font

diff --git a/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js index dec7c2e6533f..6457e757f81c 100644 --- a/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js +++ b/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js @@ -147,9 +147,21 @@ module.exports = [ 'font-display': { score: 0, details: { - items: { - length: 2, - }, + items: [ + { + url: 'http://localhost:10200/perf/lobster-v20-latin-regular.woff2', + }, + ], + }, + }, + 'preload-fonts': { + score: 0, + details: { + items: [ + { + url: 'http://localhost:10200/perf/lobster-two-v10-latin-700.woff2?delay=1000', + }, + ], }, }, }, diff --git a/lighthouse-cli/test/smokehouse/test-definitions/perf/perf-config.js b/lighthouse-cli/test/smokehouse/test-definitions/perf/perf-config.js index 45c8a246ee09..2e1b4f4ee371 100644 --- a/lighthouse-cli/test/smokehouse/test-definitions/perf/perf-config.js +++ b/lighthouse-cli/test/smokehouse/test-definitions/perf/perf-config.js @@ -10,7 +10,9 @@ const perfConfig = { extends: 'lighthouse:default', settings: { throttlingMethod: 'devtools', - onlyCategories: ['performance'], + // preload-fonts isn't a performance audit, but can easily leverage the font + // webpages present here, hence the inclusion of 'best-practices'. + onlyCategories: ['performance', 'best-practices'], // A mixture of under, over, and meeting budget to exercise all paths. budgets: [{ diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js index d741668eaf46..5ddcdd75b6ad 100644 --- a/lighthouse-core/audits/font-display.js +++ b/lighthouse-core/audits/font-display.js @@ -54,9 +54,10 @@ class FontDisplay extends Audit { /** * @param {LH.Artifacts} artifacts + * @param {RegExp} passingFontDisplayRegex * @return {{passingURLs: Set, failingURLs: Set}} */ - static findFontDisplayDeclarations(artifacts) { + static findFontDisplayDeclarations(artifacts, passingFontDisplayRegex) { /** @type {Set} */ const passingURLs = new Set(); /** @type {Set} */ @@ -78,7 +79,7 @@ class FontDisplay extends Audit { // followed either by a semicolon or the end of a block. const fontDisplayMatch = declaration.match(/font-display\s*:\s*(\w+)\s*(;|\})/); const rawFontDisplay = (fontDisplayMatch && fontDisplayMatch[1]) || ''; - const hasPassingFontDisplay = PASSING_FONT_DISPLAY_REGEX.test(rawFontDisplay); + const hasPassingFontDisplay = passingFontDisplayRegex.test(rawFontDisplay); const targetURLSet = hasPassingFontDisplay ? passingURLs : failingURLs; // Finally convert the raw font URLs to the absolute URLs and add them to the set. @@ -141,7 +142,8 @@ class FontDisplay extends Audit { static async audit(artifacts, context) { const devtoolsLogs = artifacts.devtoolsLogs[this.DEFAULT_PASS]; const networkRecords = await NetworkRecords.request(devtoolsLogs, context); - const {passingURLs, failingURLs} = FontDisplay.findFontDisplayDeclarations(artifacts); + const {passingURLs, failingURLs} = + FontDisplay.findFontDisplayDeclarations(artifacts, PASSING_FONT_DISPLAY_REGEX); /** @type {Array} */ const warningURLs = []; diff --git a/lighthouse-core/audits/preload-fonts.js b/lighthouse-core/audits/preload-fonts.js new file mode 100644 index 000000000000..b8094f40fddc --- /dev/null +++ b/lighthouse-core/audits/preload-fonts.js @@ -0,0 +1,98 @@ +/** + * @license Copyright 2020 The Lighthouse Authors. 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'; + +/** + * @fileoverview + * Audit that checks whether fonts that use `font-display: optional` were preloaded. + */ + +const Audit = require('./audit.js'); +const i18n = require('./../lib/i18n/i18n.js'); +const FontDisplay = require('./../audits/font-display.js'); +const PASSING_FONT_DISPLAY_REGEX = /^(optional)$/; +const NetworkRecords = require('../computed/network-records.js'); + +const UIStrings = { + /** Title of a Lighthouse audit that provides detail on whether fonts that used `font-display: optional` were preloaded. This descriptive title is shown to users when all fonts that used `font-display: optional` were preloaded. */ + title: 'Fonts with `font-display: optional` are preloaded', + /** Title of a Lighthouse audit that provides detail on whether fonts that used `font-display: optional` were preloaded. This descriptive title is shown to users when one or more fonts used `font-display: optional` and were not preloaded. */ + failureTitle: 'Fonts with `font-display: optional` are not preloaded', + /** Description of a Lighthouse audit that tells the user why they should preload fonts if they are using `font-display: optional`. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */ + description: 'Preload `optional` fonts so first-time visitors may use them. [Learn More](https://web.dev/preload-optional-fonts/)', +}; + +const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings); + +class PreloadFontsAudit extends Audit { + /** + * @return {LH.Audit.Meta} + */ + static get meta() { + return { + id: 'preload-fonts', + title: str_(UIStrings.title), + failureTitle: str_(UIStrings.failureTitle), + description: str_(UIStrings.description), + requiredArtifacts: ['devtoolsLogs', 'URL', 'CSSUsage'], + }; + } + + /** + * Finds which font URLs were attempted to be preloaded, + * ignoring those that failed to be reused and were requested again. + * Note: document.fonts.load() is a valid way to preload fonts, + * but we are not currently checking for that. + * @param {Array} networkRecords + * @return {Set} + */ + static getURLsAttemptedToPreload(networkRecords) { + const attemptedURLs = networkRecords + .filter(req => req.resourceType === 'Font') + .filter(req => req.isLinkPreload) + .map(req => req.url); + + return new Set(attemptedURLs); + } + + /** + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + static async audit(artifacts, context) { + const devtoolsLog = artifacts.devtoolsLogs[this.DEFAULT_PASS]; + const networkRecords = await NetworkRecords.request(devtoolsLog, context); + + // Gets the URLs of fonts where font-display: optional. + const optionalFontURLs = + FontDisplay.findFontDisplayDeclarations(artifacts, PASSING_FONT_DISPLAY_REGEX).passingURLs; + + // Gets the URLs of fonts attempted to be preloaded. + const preloadedFontURLs = + PreloadFontsAudit.getURLsAttemptedToPreload(networkRecords); + + const results = Array.from(optionalFontURLs) + .filter(url => !preloadedFontURLs.has(url)) + .map(url => { + return {url}; + }); + + /** @type {LH.Audit.Details.Table['headings']} */ + const headings = [ + {key: 'url', itemType: 'url', text: str_(i18n.UIStrings.columnURL)}, + ]; + + return { + score: results.length > 0 ? 0 : 1, + details: Audit.makeTableDetails(headings, results), + notApplicable: optionalFontURLs.size === 0, + }; + } +} + +module.exports = PreloadFontsAudit; +module.exports.UIStrings = UIStrings; diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js index 4b6db5016b25..9aa56fa235b4 100644 --- a/lighthouse-core/config/default-config.js +++ b/lighthouse-core/config/default-config.js @@ -218,6 +218,7 @@ const defaultConfig = { 'content-width', 'image-aspect-ratio', 'image-size-responsive', + 'preload-fonts', 'deprecations', 'mainthread-work-breakdown', 'bootup-time', @@ -562,6 +563,7 @@ const defaultConfig = { {id: 'password-inputs-can-be-pasted-into', weight: 1, group: 'best-practices-ux'}, {id: 'image-aspect-ratio', weight: 1, group: 'best-practices-ux'}, {id: 'image-size-responsive', weight: 1, group: 'best-practices-ux'}, + {id: 'preload-fonts', weight: 1, group: 'best-practices-ux'}, // Browser Compatibility {id: 'doctype', weight: 1, group: 'best-practices-browser-compat'}, {id: 'charset', weight: 1, group: 'best-practices-browser-compat'}, diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json index da2325ab8485..39bfe213d8b0 100644 --- a/lighthouse-core/lib/i18n/locales/en-US.json +++ b/lighthouse-core/lib/i18n/locales/en-US.json @@ -1031,6 +1031,15 @@ "lighthouse-core/audits/performance-budget.js | title": { "message": "Performance budget" }, + "lighthouse-core/audits/preload-fonts.js | description": { + "message": "Preload `optional` fonts so first-time visitors may use them. [Learn More](https://web.dev/preload-optional-fonts/)" + }, + "lighthouse-core/audits/preload-fonts.js | failureTitle": { + "message": "Fonts with `font-display: optional` are not preloaded" + }, + "lighthouse-core/audits/preload-fonts.js | title": { + "message": "Fonts with `font-display: optional` are preloaded" + }, "lighthouse-core/audits/redirects-http.js | description": { "message": "If you've already set up HTTPS, make sure that you redirect all HTTP traffic to HTTPS in order to enable secure web features for all your users. [Learn more](https://web.dev/redirects-http/)." }, diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json index 56cccbf51aa8..037cee2b25e3 100644 --- a/lighthouse-core/lib/i18n/locales/en-XL.json +++ b/lighthouse-core/lib/i18n/locales/en-XL.json @@ -1031,6 +1031,15 @@ "lighthouse-core/audits/performance-budget.js | title": { "message": "P̂ér̂f́ôŕm̂án̂ćê b́ûd́ĝét̂" }, + "lighthouse-core/audits/preload-fonts.js | description": { + "message": "P̂ŕêĺôád̂ `optional` f́ôńt̂ś ŝó f̂ír̂śt̂-t́îḿê v́îśît́ôŕŝ ḿâý ûśê t́ĥém̂. [Ĺêár̂ń M̂ór̂é](https://web.dev/preload-optional-fonts/)" + }, + "lighthouse-core/audits/preload-fonts.js | failureTitle": { + "message": "F̂ón̂t́ŝ ẃît́ĥ `font-display: optional` ár̂é n̂ót̂ ṕr̂él̂óâd́êd́" + }, + "lighthouse-core/audits/preload-fonts.js | title": { + "message": "F̂ón̂t́ŝ ẃît́ĥ `font-display: optional` ár̂é p̂ŕêĺôád̂éd̂" + }, "lighthouse-core/audits/redirects-http.js | description": { "message": "Îf́ ŷóû'v́ê ál̂ŕêád̂ý ŝét̂ úp̂ H́T̂T́P̂Ś, m̂ák̂é ŝúr̂é t̂h́ât́ ŷóû ŕêd́îŕêćt̂ ál̂ĺ ĤT́T̂Ṕ t̂ŕâf́f̂íĉ t́ô H́T̂T́P̂Ś îń ôŕd̂ér̂ t́ô én̂áb̂ĺê śêćûŕê ẃêb́ f̂éât́ûŕêś f̂ór̂ ál̂ĺ ŷóûŕ ûśêŕŝ. [Ĺêár̂ń m̂ór̂é](https://web.dev/redirects-http/)." }, diff --git a/lighthouse-core/test/audits/preload-fonts-test.js b/lighthouse-core/test/audits/preload-fonts-test.js new file mode 100644 index 000000000000..808db38b3a36 --- /dev/null +++ b/lighthouse-core/test/audits/preload-fonts-test.js @@ -0,0 +1,204 @@ +/** + * @license Copyright 2020 The Lighthouse Authors. 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 PreloadFontsAudit = require('../../audits/preload-fonts.js'); +const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js'); + +/* eslint-env jest */ + +describe('Preload Fonts Audit', () => { + let networkRecords; + let stylesheet; + let context; + + beforeEach(() => { + stylesheet = {content: '', header: {}}; + context = {computedCache: new Map()}; + }); + + function getArtifacts() { + return { + devtoolsLogs: {[PreloadFontsAudit.DEFAULT_PASS]: networkRecordsToDevtoolsLog(networkRecords)}, + URL: {finalUrl: 'https://example.com/foo/bar/page'}, + CSSUsage: {stylesheets: [stylesheet]}, + }; + } + + describe('font-display is optional', () => { + it('fails if the font is not preloaded', async () => { + stylesheet.content = ` + @font-face { + font-display: optional; + src: url('/assets/font-a.woff'); + } + `; + + networkRecords = [ + { + url: 'https://example.com/assets/font-a.woff', + resourceType: 'Font', + isLinkPreload: false, + }, + ]; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.score).toEqual(0); + expect(result.details.items).toEqual([ + {url: networkRecords[0].url}, + ]); + }); + + it('passes if the font is preloaded', async () => { + stylesheet.content = ` + @font-face { + font-display: optional; + src: url('/assets/font-a.woff'); + } + `; + + networkRecords = [ + { + url: 'https://example.com/assets/font-a.woff', + resourceType: 'Font', + isLinkPreload: true, + }, + ]; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.details.items).toEqual([]); + expect(result.score).toEqual(1); + }); + }); + + describe('font-display is not optional', () => { + it('passes if the font is not preloaded', async () => { + stylesheet.content = ` + @font-face { + src: url('/assets/font-a.woff'); + } + `; + + networkRecords = [ + { + url: 'https://example.com/assets/font-a.woff', + resourceType: 'Font', + isLinkPreload: false, + }, + ]; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.score).toEqual(1); + expect(result.details.items).toEqual([]); + expect(result.notApplicable).toEqual(true); + }); + + it('passes if the font is preloaded', async () => { + stylesheet.content = ` + @font-face { + src: url('/assets/font-a.woff'); + } + `; + + networkRecords = [ + { + url: 'https://example.com/assets/font-a.woff', + resourceType: 'Font', + isLinkPreload: true, + }, + ]; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.score).toEqual(1); + expect(result.details.items).toEqual([]); + expect(result.notApplicable).toEqual(true); + }); + }); + + it('is not applicable on fonts where font-display is not optional', async () => { + stylesheet.content = ` + @font-face { + font-display: swap; + src: url('/assets/font-a.woff'); + } + + @font-face { + font-display: block; + src: url('https://example.com/foo/bar/document-font.woff'); + } + + @font-face { + font-display: fallback; + src: url('/assets/font-b.woff'); + } + + @font-face { + src: url('/assets/font-c.woff'); + } + `; + + networkRecords = []; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.score).toEqual(1); + expect(result.details.items).toEqual([]); + expect(result.notApplicable).toEqual(true); + }); + + it('handles multiple fonts', async () => { + stylesheet.content = ` + @font-face { + font-display: optional; + src: url('/assets/font-a.woff'); + } + + @font-face { + font-display: optional; + src: url('https://example.com/foo/bar/document-font.woff'); + } + + @font-face { + font-display: fallback; + src: url('/assets/font-b.woff'); + } + + @font-face { + font-display: optional; + src: url('/assets/font-c.woff'); + } + `; + + networkRecords = [ + { + url: 'https://example.com/assets/font-a.woff', + resourceType: 'Font', + isLinkPreload: true, + }, + { + url: 'https://example.com/foo/bar/document-font.woff', + resourceType: 'Font', + isLinkPreload: false, + }, + { + url: 'https://example.com/assets/font-b.woff', + resourceType: 'Font', + isLinkPreload: true, + }, + { + url: 'https://example.com/assets/font-c.woff', + resourceType: 'Font', + isLinkPreload: false, + }, + ]; + + const result = await PreloadFontsAudit.audit(getArtifacts(), context); + expect(result.score).toEqual(0); + expect(result.details.items).toEqual([ + {url: networkRecords[1].url}, + {url: networkRecords[3].url}, + ]); + }); +}); diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json index fb61ce11b049..c5147b5d79af 100644 --- a/lighthouse-core/test/results/artifacts/artifacts.json +++ b/lighthouse-core/test/results/artifacts/artifacts.json @@ -498,7 +498,7 @@ "startColumn": 0, "length": 677 }, - "content": "/**\n * @license Copyright 2016 Google Inc. All Rights Reserved.\n * 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\n * 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.\n */\n\nbody {\n background-color: #eee;\n}\n.doesnotapply {\n display: flexbox; /* FAIL */\n}\n" + "content": "/**\n * @license Copyright 2016 Google Inc. All Rights Reserved.\n * 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\n * 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.\n */\n\nbody {\n background-color: #eee;\n}\n.doesnotapply {\n display: flexbox; /* FAIL */\n}\n@font-face {\n font-family: 'Lobster Three';\n font-display: optional;\n src: url(\"http://localhost:10503/perf/lobster-two-v10-latin-700.woff2?cors=true\") format('woff2');\n}\n" }, { "header": { diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index 90220c595f67..4e22ece347de 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -670,6 +670,28 @@ ] } }, + "preload-fonts": { + "id": "preload-fonts", + "title": "Fonts with `font-display: optional` are not preloaded", + "description": "Preload `optional` fonts so first-time visitors may use them. [Learn More](https://web.dev/preload-optional-fonts/)", + "score": 0, + "scoreDisplayMode": "binary", + "details": { + "type": "table", + "headings": [ + { + "key": "url", + "itemType": "url", + "text": "URL" + } + ], + "items": [ + { + "url": "http://localhost:10503/perf/lobster-two-v10-latin-700.woff2?cors=true" + } + ] + } + }, "deprecations": { "id": "deprecations", "title": "Uses deprecated APIs", @@ -4824,6 +4846,11 @@ "weight": 1, "group": "best-practices-ux" }, + { + "id": "preload-fonts", + "weight": 1, + "group": "best-practices-ux" + }, { "id": "doctype", "weight": 1, @@ -5517,6 +5544,12 @@ "duration": 100, "entryType": "measure" }, + { + "startTime": 0, + "name": "lh:audit:preload-fonts", + "duration": 100, + "entryType": "measure" + }, { "startTime": 0, "name": "lh:audit:deprecations", @@ -6547,6 +6580,7 @@ "audits[errors-in-console].details.headings[0].text", "audits[image-aspect-ratio].details.headings[1].text", "audits[image-size-responsive].details.headings[1].text", + "audits[preload-fonts].details.headings[0].text", "audits.deprecations.details.headings[1].text", "audits[bootup-time].details.headings[0].text", "audits[network-rtt].details.headings[0].text", @@ -6687,6 +6721,12 @@ "lighthouse-core/audits/image-size-responsive.js | columnExpected": [ "audits[image-size-responsive].details.headings[4].text" ], + "lighthouse-core/audits/preload-fonts.js | failureTitle": [ + "audits[preload-fonts].title" + ], + "lighthouse-core/audits/preload-fonts.js | description": [ + "audits[preload-fonts].description" + ], "lighthouse-core/audits/deprecations.js | failureTitle": [ "audits.deprecations.title" ],