Repository: GoogleChrome/lighthouse
Pull request #8708 (2019-04-29 12:39:25)

report(lightwallet): render performance-budget section
<!-- Thank you for submitting a pull request! -->

**Summary**
<!-- What kind of change does this PR introduce? -->
<!-- Is this a bugfix, feature, refactoring, build related change, etc? -->

<!-- Describe the need for this change -->

<!-- Link any documentation or information that would help understand this change -->

**Related Issues/PRs**
#8675, #8427, #8709, #8708,#8522, #8539
<!-- Provide any additional information we might need to understand the pull request -->
<img width="878" alt="Screen Shot 2019-04-29 at 8 55 04 AM" src="https://user-images.githubusercontent.com/6076184/56897430-8e648a80-6a5c-11e9-8fd4-2831cdafa0e3.png">


khempenius: Updated.
brendankenny: @paulirish is it possible to redeploy this with new master?
paulirish: woohoo! now we got budget section in the now deployment! https://lighthouse-gce0hkow5.now.sh/

![image](https://user-images.githubusercontent.com/39191/57236535-25d85900-6fda-11e9-818e-22145bf3b716.png)

khempenius: Updated.

All resolved except this comment from @patrickhulce:
"also not sure if we should do this by audit id or by group and/or if there's any point to having the budgets group anymore if we keep it by id?

FWIW I think I'd lean towards group since that's how we're doing everything else, but then again it might be a bit confusing that it's a single audit in the group so 🤷‍♂"

Patch:
diff --git a/lighthouse-core/audits/apple-touch-icon.js b/lighthouse-core/audits/apple-touch-icon.js
new file mode 100644
index 000000000..46313a29e
--- /dev/null
+++ b/lighthouse-core/audits/apple-touch-icon.js
@@ -0,0 +1,71 @@
+/**
+ * @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 Audit = require('./audit.js');
+const i18n = require('../lib/i18n/i18n.js');
+
+/**
+ * @fileoverview Audits if a page has an `apple-touch-icon` link element with a valid href.
+ */
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that tells the user that their site contains a vaild touch icon. This descriptive title is shown when the page contains a valid iOS touch icon. "apple-touch-icon" is an HTML attribute value and should not be translated. */
+  title: 'Provides a valid `apple-touch-icon`',
+  /** Title of a Lighthouse audit that tells the user that their site contains a vaild touch icon. This descriptive title is shown when the page does not contain a valid iOS touch icon. "apple-touch-icon" is an HTML attribute value and should not be translated. */
+  failureTitle: 'Does not provide a valid `apple-touch-icon`',
+  /** Description of a Lighthouse audit that tells the user what having a valid apple-touch-icon does. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. "apple-touch-icon" is an HTML attribute value and should not be translated. */
+  description: 'For ideal appearance on iOS when users add to the home screen, define an ' +
+  'apple-touch-icon. It must point to a non-transparent 192px (or 180px) square PNG. ' +
+  '[Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).',
+  /** Warning that HTML attribute `apple-touch-icon-precomposed` should not be used in favor of `apple-touch-icon`.  "apple-touch-icon-precomposed" and "apple-touch-icon" are HTML attribute values and should not be translated. */
+  precomposedWarning: '`apple-touch-icon-precomposed` is out of date; ' +
+  '`apple-touch-icon` is preferred.',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+class AppleTouchIcon extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'apple-touch-icon',
+      title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
+      description: str_(UIStrings.description),
+      requiredArtifacts: ['LinkElements'],
+    };
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @return {LH.Audit.Product}
+   */
+  static audit(artifacts) {
+    const appleTouchIcons = artifacts.LinkElements
+      .filter(el => el.rel === 'apple-touch-icon' || el.rel === 'apple-touch-icon-precomposed')
+      .filter(el => !!el.href);
+
+    // Audit passes if an `apple-touch-icon` exists.
+    const passed = appleTouchIcons.length !== 0;
+
+    const warnings = [];
+    if (appleTouchIcons.filter(el => el.rel === 'apple-touch-icon-precomposed').length !== 0
+      && appleTouchIcons.filter(el => el.rel === 'apple-touch-icon').length === 0) {
+      warnings.push(str_(UIStrings.precomposedWarning));
+    }
+
+    return {
+      score: passed ? 1 : 0,
+      warnings,
+    };
+  }
+}
+
+module.exports = AppleTouchIcon;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index 9184e5492..d309072f7 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -179,6 +179,7 @@ const defaultConfig = {
     'critical-request-chains',
     'redirects',
     'installable-manifest',
+    'apple-touch-icon',
     'splash-screen',
     'themed-omnibox',
     'content-width',
@@ -388,7 +389,7 @@ const defaultConfig = {
         {id: 'bootup-time', weight: 0, group: 'diagnostics'},
         {id: 'mainthread-work-breakdown', weight: 0, group: 'diagnostics'},
         {id: 'font-display', weight: 0, group: 'diagnostics'},
-        {id: 'performance-budget', weight: 0},
+        {id: 'performance-budget', weight: 0, group: 'budgets'},
         {id: 'resource-summary', weight: 0, group: 'diagnostics'},
         // Audits past this point don't belong to a group and will not be shown automatically
         {id: 'network-requests', weight: 0},
@@ -523,6 +524,7 @@ const defaultConfig = {
         {id: 'content-width', weight: 1, group: 'pwa-optimized'},
         {id: 'viewport', weight: 2, group: 'pwa-optimized'},
         {id: 'without-javascript', weight: 1, group: 'pwa-optimized'},
+        {id: 'apple-touch-icon', weight: 1, group: 'pwa-optimized'},
         // Manual audits
         {id: 'pwa-cross-browser', weight: 0},
         {id: 'pwa-page-transitions', weight: 0},
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 02e444d58..adfc34c8f 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -423,6 +423,22 @@
     "message": "`<video>` elements contain a `<track>` element with `[kind=\"description\"]`",
     "description": "Title of an accesibility audit that evaluates if all video elements have child track elements that contain a description of the video content. This title is descriptive of the successful state and is shown to users when no user action is required."
   },
+  "lighthouse-core/audits/apple-touch-icon.js | description": {
+    "message": "For ideal appearance on iOS when users add to the home screen, define an apple-touch-icon. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).",
+    "description": "Description of a Lighthouse audit that tells the user what having a valid apple-touch-icon does. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. \"apple-touch-icon\" is an HTML attribute value and should not be translated."
+  },
+  "lighthouse-core/audits/apple-touch-icon.js | failureTitle": {
+    "message": "Does not provide a valid `apple-touch-icon`",
+    "description": "Title of a Lighthouse audit that tells the user that their site contains a vaild touch icon. This descriptive title is shown when the page does not contain a valid iOS touch icon. \"apple-touch-icon\" is an HTML attribute value and should not be translated."
+  },
+  "lighthouse-core/audits/apple-touch-icon.js | precomposedWarning": {
+    "message": "`apple-touch-icon-precomposed` is out of date; `apple-touch-icon` is preferred.",
+    "description": "Warning that HTML attribute `apple-touch-icon-precomposed` should not be used in favor of `apple-touch-icon`.  \"apple-touch-icon-precomposed\" and \"apple-touch-icon\" are HTML attribute values and should not be translated."
+  },
+  "lighthouse-core/audits/apple-touch-icon.js | title": {
+    "message": "Provides a valid `apple-touch-icon`",
+    "description": "Title of a Lighthouse audit that tells the user that their site contains a vaild touch icon. This descriptive title is shown when the page contains a valid iOS touch icon. \"apple-touch-icon\" is an HTML attribute value and should not be translated."
+  },
   "lighthouse-core/audits/bootup-time.js | chromeExtensionsWarning": {
     "message": "Chrome extensions negatively affected this page's load performance. Try auditing the page in incognito mode or from a Chrome profile without extensions.",
     "description": "A message displayed in a Lighthouse audit result warning that Chrome extensions on the user's system substantially affected Lighthouse's measurements and instructs the user on how to run again without those extensions."
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 943f80d69..8ca8c4539 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -164,6 +164,20 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
       filmstripEl && timelineEl.appendChild(filmstripEl);
     }
 
+    // Budgets
+    const budgetAudit = category.auditRefs.find(audit => audit.id === 'performance-budget');
+    if (budgetAudit && budgetAudit.result.details) {
+      const table = this.detailsRenderer.render(budgetAudit.result.details);
+      if (table) {
+        table.id = budgetAudit.id;
+        table.classList.add('lh-audit');
+        const budgetsGroupEl = this.renderAuditGroup(groups.budgets);
+        budgetsGroupEl.appendChild(table);
+        budgetsGroupEl.classList.add('lh-audit-group--budgets');
+        element.appendChild(budgetsGroupEl);
+      }
+    }
+
     // Opportunities
     const opportunityAudits = category.auditRefs
         .filter(audit => audit.group === 'load-opportunities' && !Util.showAsPassed(audit.result))
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 39f353415..e66126a4b 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -839,6 +839,21 @@
   vertical-align: middle;
 }
 
+/* Style the "over budget" columns red. */
+.lh-audit-group--budgets .lh-table tbody tr td:nth-child(4),
+.lh-audit-group--budgets .lh-table tbody tr td:nth-child(5){
+  color: var(--color-red-700);
+}
+
+/* Align the "over budget request count" text to be close to the "over budget bytes" column. */
+.lh-audit-group--budgets .lh-table tbody tr td:nth-child(4){
+  text-align: right;
+}
+
+.lh-audit-group--budgets .lh-table {
+  width: 100%;
+}
+
 .lh-audit-group--pwa-fast-reliable .lh-audit-group__header::before {
   content: '';
   background-image: var(--pwa-fast-reliable-gray-url);
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index cc96aee34..6df3186d4 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -143,7 +143,6 @@ limitations under the License.
   </div>
 </template>
 
-
 <!-- Lighthouse perf opportunity header -->
 <template id="tmpl-lh-opportunity-header">
   <div class="lh-load-opportunity__header lh-load-opportunity__cols">
@@ -152,7 +151,6 @@ limitations under the License.
   </div>
 </template>
 
-
 <!-- Lighthouse score container -->
 <template id="tmpl-lh-scores-wrapper">
   <style>
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 89196f363..401a3ff9f 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -20,6 +20,14 @@
             "scoreDisplayMode": "binary",
             "title": "Uses Application Cache"
         },
+        "apple-touch-icon": {
+            "description": "For ideal appearance on iOS when users add to the home screen, define an apple-touch-icon. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).",
+            "id": "apple-touch-icon",
+            "score": 0.0,
+            "scoreDisplayMode": "binary",
+            "title": "Does not provide a valid `apple-touch-icon`",
+            "warnings": []
+        },
         "aria-allowed-attr": {
             "description": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://dequeuniversity.com/rules/axe/3.1/aria-allowed-attr?application=lighthouse).",
             "id": "aria-allowed-attr",
@@ -3576,6 +3584,7 @@
                     "weight": 0.0
                 },
                 {
+                    "group": "budgets",
                     "id": "performance-budget",
                     "weight": 0.0
                 },
@@ -3683,6 +3692,11 @@
                     "id": "without-javascript",
                     "weight": 1.0
                 },
+                {
+                    "group": "pwa-optimized",
+                    "id": "apple-touch-icon",
+                    "weight": 1.0
+                },
                 {
                     "id": "pwa-cross-browser",
                     "weight": 0.0
@@ -3699,7 +3713,7 @@
             "description": "These checks validate the aspects of a Progressive Web App. [Learn more](https://developers.google.com/web/progressive-web-apps/checklist).",
             "id": "pwa",
             "manualDescription": "These checks are required by the baseline [PWA Checklist](https://developers.google.com/web/progressive-web-apps/checklist) but are not automatically checked by Lighthouse. They do not affect your score but it's important that you verify them manually.",
-            "score": 0.42,
+            "score": 0.41,
             "title": "Progressive Web App"
         },
         "seo": {
@@ -4208,6 +4222,12 @@
                 "name": "lh:computed:ManifestValues",
                 "startTime": 0.0
             },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:audit:apple-touch-icon",
+                "startTime": 0.0
+            },
             {
                 "duration": 100.0,
                 "entryType": "measure",


Test patch:
diff --git a/clients/test/extension/extension-test.js b/clients/test/extension/extension-test.js
index 1517e5c00..63e86e88c 100644
--- a/clients/test/extension/extension-test.js
+++ b/clients/test/extension/extension-test.js
@@ -135,8 +135,10 @@ describe('Lighthouse chrome extension', function() {
     for (const category of lighthouseCategories) {
       let expected = getAuditsOfCategory(category);
       if (category === 'performance') {
-        expected = getAuditsOfCategory(category).filter(a => !!a.group);
+        expected = getAuditsOfCategory(category)
+          .filter(a => !!a.group && a.id !== 'performance-budget');
       }
+      // Performance budget audit is not included in the Chrome extension of Lighthouse
       expected = expected.map(audit => audit.id);
       const elementIds = await getAuditElementsIds({category, selector: selectors.audits});
 
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index d1e45ec5d..d2aa1fbba 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -69,6 +69,9 @@ Object {
     Object {
       "path": "installable-manifest",
     },
+    Object {
+      "path": "apple-touch-icon",
+    },
     Object {
       "path": "splash-screen",
     },
@@ -818,6 +821,7 @@ Object {
           "weight": 0,
         },
         Object {
+          "group": "budgets",
           "id": "performance-budget",
           "weight": 0,
         },
@@ -923,6 +927,11 @@ Object {
           "id": "without-javascript",
           "weight": 1,
         },
+        Object {
+          "group": "pwa-optimized",
+          "id": "apple-touch-icon",
+          "weight": 1,
+        },
         Object {
           "id": "pwa-cross-browser",
           "weight": 0,
diff --git a/lighthouse-cli/test/smokehouse/pwa-expectations.js b/lighthouse-cli/test/smokehouse/pwa-expectations.js
index b4a91c98c..85616240b 100644
--- a/lighthouse-cli/test/smokehouse/pwa-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa-expectations.js
@@ -66,6 +66,9 @@ module.exports = [
         'content-width': {
           score: 1,
         },
+        'apple-touch-icon': {
+          score: 1,
+        },
 
         // "manual" audits. Just verify in the results.
         'pwa-cross-browser': {
@@ -128,6 +131,9 @@ module.exports = [
         'content-width': {
           score: 1,
         },
+        'apple-touch-icon': {
+          score: 1,
+        },
 
         // "manual" audits. Just verify in the results.
         'pwa-cross-browser': {
diff --git a/lighthouse-cli/test/smokehouse/pwa2-expectations.js b/lighthouse-cli/test/smokehouse/pwa2-expectations.js
index 3a56038d3..b8b93d09b 100644
--- a/lighthouse-cli/test/smokehouse/pwa2-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa2-expectations.js
@@ -61,6 +61,12 @@ module.exports = [
         'content-width': {
           score: 1,
         },
+        'apple-touch-icon': {
+          score: 1,
+          warnings: [
+            /apple-touch-icon-precomposed/,
+          ],
+        },
 
         // "manual" audits. Just verify in the results.
         'pwa-cross-browser': {
@@ -123,6 +129,9 @@ module.exports = [
         'content-width': {
           score: 1,
         },
+        'apple-touch-icon': {
+          score: 0,
+        },
 
         // "manual" audits. Just verify in the results.
         'pwa-cross-browser': {
diff --git a/lighthouse-cli/test/smokehouse/pwa3-expectations.js b/lighthouse-cli/test/smokehouse/pwa3-expectations.js
index 351a34272..d05f37387 100644
--- a/lighthouse-cli/test/smokehouse/pwa3-expectations.js
+++ b/lighthouse-cli/test/smokehouse/pwa3-expectations.js
@@ -58,6 +58,9 @@ module.exports = [
         'content-width': {
           score: 1,
         },
+        'apple-touch-icon': {
+          score: 1,
+        },
 
         // "manual" audits. Just verify in the results.
         'pwa-cross-browser': {
diff --git a/lighthouse-core/test/audits/apple-touch-icon-test.js b/lighthouse-core/test/audits/apple-touch-icon-test.js
new file mode 100644
index 000000000..39ce2a1b0
--- /dev/null
+++ b/lighthouse-core/test/audits/apple-touch-icon-test.js
@@ -0,0 +1,98 @@
+/**
+ * @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 AppleTouchIcon = require('../../audits/apple-touch-icon.js');
+
+/* eslint-env jest */
+
+describe('PWA: apple-touch-icon audit', () => {
+  it(`fails when apple-touch-icon is not present`, () => {
+    const artifacts = {
+      LinkElements: [
+        {rel: 'iOS-touch-thing', href: 'https://example.com/else.png'},
+        {rel: 'something else', href: 'https://example.com/else.png'},
+      ],
+    };
+
+    const {score} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(0);
+  });
+
+  it(`fails when apple-touch-icon does not have an href`, () => {
+    const artifacts = {
+      LinkElements: [{rel: 'apple-touch-icon'}],
+    };
+
+    const {score} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(0);
+  });
+
+  it(`passes but warns when apple-touch-icon-precomposed only`, () => {
+    const artifacts = {
+      LinkElements: [{rel: 'apple-touch-icon-precomposed', href: 'https://example.com/touch-icon.png'}],
+    };
+
+    const {score, warnings} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(1);
+    expect(warnings[0]).toBeDisplayString('`apple-touch-icon-precomposed` is ' +
+      'out of date; `apple-touch-icon` is preferred.');
+  });
+
+  it(`passes with no warning when precomposed with normal`, () => {
+    const artifacts = {
+      LinkElements: [
+        {rel: 'apple-touch-icon', href: 'https://example.com/touch-icon.png'},
+        {rel: 'apple-touch-icon-precomposed', href: 'https://example.com/touch-icon.png'},
+      ],
+    };
+
+    const {score, warnings} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(1);
+    expect(warnings).toEqual([]);
+  });
+
+  it(`passes when apple-touch-icon is on page`, () => {
+    const artifacts = {
+      LinkElements: [{rel: 'apple-touch-icon', href: 'https://example.com/touch-icon.png'}],
+    };
+
+    const {score} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(1);
+  });
+
+  it(`passes with multiple apple-touch-icons on page`, () => {
+    const artifacts = {
+      LinkElements: [
+        {rel: 'apple-touch-icon', sizes: '152x152', href: 'https://example.com/touch-icon.png'},
+        {rel: 'apple-touch-icon', sizes: '180x180', href: 'https://example.com/touch-icon.png'},
+      ],
+    };
+
+    const {score} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(1);
+  });
+
+  it(`passes when lots of LinkElements`, () => {
+    const artifacts = {
+      LinkElements: [
+        {rel: 'iOS-touch-thing', href: 'https://example.com/else.png'},
+        {rel: 'apple-touch-icon', href: 'https://example.com/touch-icon.png'},
+        {rel: 'something else', href: 'https://example.com/else.png'},
+      ],
+    };
+
+    const {score} = AppleTouchIcon.audit(artifacts);
+
+    expect(score).toBe(1);
+  });
+});
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index 8e691f5a5..b18f27521 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -366,7 +366,7 @@ describe('CategoryRenderer', () => {
       const manualAudits = elem.querySelectorAll('.lh-clump--manual .lh-audit');
 
       assert.equal(passedAudits.length, 2);
-      assert.equal(failedAudits.length, 8);
+      assert.equal(failedAudits.length, 9);
       assert.equal(warningAudits.length, 2);
       assert.equal(manualAudits.length, 3);
     });
@@ -380,7 +380,7 @@ describe('CategoryRenderer', () => {
       const failedAudits = elem.querySelectorAll('.lh-clump--failed .lh-audit');
 
       assert.equal(passedAudits.length, 0);
-      assert.equal(failedAudits.length, 12);
+      assert.equal(failedAudits.length, 13);
     });
 
     it('expands warning audit group', () => {
diff --git a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
index 776763a7c..42c115c40 100644
--- a/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/performance-category-renderer-test.js
@@ -68,7 +68,7 @@ describe('PerfCategoryRenderer', () => {
   it('renders the sections', () => {
     const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
     const sections = categoryDOM.querySelectorAll('.lh-category > .lh-audit-group');
-    assert.equal(sections.length, 4);
+    assert.equal(sections.length, 5);
   });
 
   it('renders the metrics', () => {
@@ -151,7 +151,8 @@ describe('PerfCategoryRenderer', () => {
     const passedSection = categoryDOM.querySelector('.lh-category > .lh-clump--passed');
 
     const passedAudits = category.auditRefs.filter(audit =>
-        audit.group && audit.group !== 'metrics' && Util.showAsPassed(audit.result));
+      audit.group && audit.group !== 'metrics' && audit.id !== 'performance-budget'
+        && Util.showAsPassed(audit.result));
     const passedElements = passedSection.querySelectorAll('.lh-audit');
     assert.equal(passedElements.length, passedAudits.length);
   });
@@ -175,6 +176,36 @@ describe('PerfCategoryRenderer', () => {
     });
   });
 
+  describe('budgets', () => {
+    it('renders a performance budget', () => {
+      const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
+
+      const budgetsGroup = categoryDOM.querySelector('.lh-audit-group.lh-audit-group--budgets');
+      assert.ok(budgetsGroup);
+
+      const header = budgetsGroup.querySelector('.lh-audit-group__header');
+      assert.ok(header);
+
+      const budgetTable = budgetsGroup.querySelector('#performance-budget.lh-table');
+      assert.ok(budgetTable);
+
+      const lhrBudgetEntries = sampleResults.audits['performance-budget'].details.items;
+      const tableRows = budgetTable.querySelectorAll('tbody > tr');
+      assert.strictEqual(tableRows.length, lhrBudgetEntries.length);
+    });
+
+    it('does not render a budget table when performance-budget audit is notApplicable', () => {
+      const budgetlessCategory = JSON.parse(JSON.stringify(category));
+      const budgetRef = budgetlessCategory.auditRefs.find(a => a.id === 'performance-budget');
+      budgetRef.result.scoreDisplayMode = 'notApplicable';
+      delete budgetRef.result.details;
+
+      const categoryDOM = renderer.render(budgetlessCategory, sampleResults.categoryGroups);
+      const budgetsGroup = categoryDOM.querySelector('.lh-audit-group.lh-audit-group--budgets');
+      assert.strictEqual(budgetsGroup, null);
+    });
+  });
+
   // This is done all in CSS, but tested here.
   describe('metric description toggles', () => {
     let container;
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index d6d8e6a98..81da4fe67 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -255,7 +255,8 @@ describe('ReportRenderer', () => {
 
     let notApplicableCount = 0;
     Object.values(clonedSampleResult.audits).forEach(audit => {
-      if (audit.scoreDisplayMode === 'notApplicable') {
+      // The performance-budget audit is omitted from the DOM when it is not applicable
+      if (audit.scoreDisplayMode === 'notApplicable' && audit.id !== 'performance-budget') {
         notApplicableCount++;
         audit.scoreDisplayMode = 'not_applicable';
       }
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 95f002dd0..7fe39c6e7 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -476,6 +476,14 @@
         ]
       }
     },
+    "apple-touch-icon": {
+      "id": "apple-touch-icon",
+      "title": "Does not provide a valid `apple-touch-icon`",
+      "description": "For ideal appearance on iOS when users add to the home screen, define an apple-touch-icon. It must point to a non-transparent 192px (or 180px) square PNG. [Learn More](https://developers.google.com/web/fundamentals/design-and-ux/browser-customization/).",
+      "score": 0,
+      "scoreDisplayMode": "binary",
+      "warnings": []
+    },
     "splash-screen": {
       "id": "splash-screen",
       "title": "Is not configured for a custom splash screen",
@@ -3448,7 +3456,8 @@
         },
         {
           "id": "performance-budget",
-          "weight": 0
+          "weight": 0,
+          "group": "budgets"
         },
         {
           "id": "resource-summary",
@@ -3929,6 +3938,11 @@
           "weight": 1,
           "group": "pwa-optimized"
         },
+        {
+          "id": "apple-touch-icon",
+          "weight": 1,
+          "group": "pwa-optimized"
+        },
         {
           "id": "pwa-cross-browser",
           "weight": 0
@@ -3943,7 +3957,7 @@
         }
       ],
       "id": "pwa",
-      "score": 0.42
+      "score": 0.41
     }
   },
   "categoryGroups": {
@@ -4312,6 +4326,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:apple-touch-icon",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:splash-screen",
@@ -5150,6 +5170,12 @@
       "lighthouse-core/audits/redirects.js | description": [
         "audits.redirects.description"
       ],
+      "lighthouse-core/audits/apple-touch-icon.js | failureTitle": [
+        "audits[apple-touch-icon].title"
+      ],
+      "lighthouse-core/audits/apple-touch-icon.js | description": [
+        "audits[apple-touch-icon].description"
+      ],
       "lighthouse-core/audits/mainthread-work-breakdown.js | title": [
         "audits[mainthread-work-breakdown].title"
       ],