lighthouse 8.5.1-dev.20211007 → 8.5.1-dev.20211011

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -373,7 +373,7 @@
373
373
  width: max-content;
374
374
  background-color: var(--report-background-color);
375
375
  border: 1px solid var(--color-gray-900);
376
- border-radius: 5px;
376
+ border-radius: 3px;
377
377
  padding: var(--base-spacing);
378
378
  right: 0;
379
379
  box-shadow: 0px 4px 4px var(--summary-tooltip-box-shadow-color);
@@ -5,9 +5,12 @@
5
5
  */
6
6
 
7
7
  import {FunctionComponent} from 'preact';
8
+ import {useEffect, useState} from 'preact/hooks';
8
9
 
9
10
  import {NavigationIcon, SnapshotIcon, TimespanIcon} from './icons';
10
- import {getScreenDimensions, getScreenshot} from './util';
11
+ import {getFilmstripFrames, getScreenDimensions, getScreenshot} from './util';
12
+
13
+ const ANIMATION_FRAME_DURATION_MS = 250;
11
14
 
12
15
  export const Separator: FunctionComponent = () => {
13
16
  return <div className="Separator" role="separator"></div>;
@@ -39,12 +42,42 @@ export const FlowSegment: FunctionComponent<{mode?: LH.Result.GatherMode}> = ({m
39
42
  );
40
43
  };
41
44
 
45
+ const FlowStepAnimatedThumbnail: FunctionComponent<{
46
+ frames: Array<{data: string}>,
47
+ width: number,
48
+ height: number,
49
+ }> = ({frames, width, height}) => {
50
+ const [frameIndex, setFrameIndex] = useState(0);
51
+ // Handle a frame array of a different length being set.
52
+ const effectiveFrameIndex = frameIndex % frames.length;
53
+
54
+ useEffect(() => {
55
+ const interval = setInterval(
56
+ () => setFrameIndex(i => (i + 1) % frames.length),
57
+ ANIMATION_FRAME_DURATION_MS
58
+ );
59
+
60
+ return () => clearInterval(interval);
61
+ }, [frames.length]);
62
+
63
+ return (
64
+ <img
65
+ className="FlowStepThumbnail"
66
+ data-testid="FlowStepAnimatedThumbnail"
67
+ src={frames[effectiveFrameIndex].data}
68
+ style={{width, height}}
69
+ alt="Animated screenshots of a page tested by Lighthouse"
70
+ />
71
+ );
72
+ };
73
+
42
74
  export const FlowStepThumbnail: FunctionComponent<{
43
75
  reportResult: LH.ReportResult,
44
76
  width?: number,
45
77
  height?: number,
46
78
  }> = ({reportResult, width, height}) => {
47
79
  const screenshot = getScreenshot(reportResult);
80
+ const frames = getFilmstripFrames(reportResult);
48
81
 
49
82
  // Resize the image to fit the viewport aspect ratio.
50
83
  const dimensions = getScreenDimensions(reportResult);
@@ -54,6 +87,15 @@ export const FlowStepThumbnail: FunctionComponent<{
54
87
  width = dimensions.width * height / dimensions.height;
55
88
  }
56
89
 
90
+ if (!width || !height) {
91
+ console.warn(new Error('FlowStepThumbnail requested without any dimensions').stack);
92
+ return <></>;
93
+ }
94
+
95
+ if (reportResult.gatherMode === 'timespan' && frames && frames.length) {
96
+ return <FlowStepAnimatedThumbnail frames={frames} width={width} height={height} />;
97
+ }
98
+
57
99
  return <>
58
100
  {
59
101
  screenshot &&
@@ -35,11 +35,16 @@ export const SummaryTooltip: FunctionComponent<{
35
35
  gatherMode: LH.Result.GatherMode
36
36
  }> = ({category, gatherMode}) => {
37
37
  const strings = useUIStrings();
38
- const {numPassed, numAudits, totalWeight} = Util.calculateCategoryFraction(category);
38
+ const {
39
+ numPassed,
40
+ numPassableAudits,
41
+ numInformative,
42
+ totalWeight,
43
+ } = Util.calculateCategoryFraction(category);
39
44
 
40
45
  const displayAsFraction = Util.shouldDisplayAsFraction(gatherMode);
41
46
  const rating = displayAsFraction ?
42
- Util.calculateRating(numPassed / numAudits) :
47
+ Util.calculateRating(numPassed / numPassableAudits) :
43
48
  Util.calculateRating(category.score);
44
49
 
45
50
  return (
@@ -64,8 +69,19 @@ export const SummaryTooltip: FunctionComponent<{
64
69
  }
65
70
  </div>
66
71
  <div className="SummaryTooltip__fraction">
67
- {`${numPassed} audits passed / ${numAudits} audits run`}
72
+ {
73
+ // TODO(FLOW-I18N): Placeholder format.
74
+ `${numPassed} audits passed / ${numPassableAudits} passable audits`
75
+ }
68
76
  </div>
77
+ {
78
+ // TODO(FLOW-I18N): Placeholder format.
79
+ numInformative ?
80
+ <div className="SummaryTooltip__informative">
81
+ {`${numInformative} informative audits`}
82
+ </div> :
83
+ null
84
+ }
69
85
  </div>
70
86
  );
71
87
  };
@@ -63,10 +63,7 @@ export const SummaryFlowStep: FunctionComponent<{
63
63
  <Separator/>
64
64
  </div>
65
65
  }
66
- {
67
- lhr.gatherMode !== 'timespan' &&
68
- <FlowStepThumbnail reportResult={reportResult} width={THUMBNAIL_WIDTH}/>
69
- }
66
+ <FlowStepThumbnail reportResult={reportResult} width={THUMBNAIL_WIDTH}/>
70
67
  <FlowSegment mode={lhr.gatherMode}/>
71
68
  <div className="SummaryFlowStep__label">
72
69
  <div className="SummaryFlowStep__mode">{modeDescription}</div>
@@ -129,6 +126,7 @@ export const SummaryHeader: FunctionComponent = () => {
129
126
  }
130
127
  }
131
128
 
129
+ // TODO(FLOW-I18N): Placeholder format.
132
130
  const subtitleCounts = [];
133
131
  if (numNavigation) subtitleCounts.push(`${numNavigation} navigation reports`);
134
132
  if (numTimespan) subtitleCounts.push(`${numTimespan} timespan reports`);
@@ -43,6 +43,7 @@ export function getScreenDimensions(reportResult: LH.ReportResult) {
43
43
  export function getScreenshot(reportResult: LH.ReportResult) {
44
44
  const fullPageScreenshotAudit = reportResult.audits['full-page-screenshot'];
45
45
  const fullPageScreenshot =
46
+ fullPageScreenshotAudit &&
46
47
  fullPageScreenshotAudit.details &&
47
48
  fullPageScreenshotAudit.details.type === 'full-page-screenshot' &&
48
49
  fullPageScreenshotAudit.details.screenshot.data;
@@ -50,6 +51,20 @@ export function getScreenshot(reportResult: LH.ReportResult) {
50
51
  return fullPageScreenshot || null;
51
52
  }
52
53
 
54
+ export function getFilmstripFrames(
55
+ reportResult: LH.ReportResult
56
+ ): Array<{data: string}> | undefined {
57
+ const filmstripAudit = reportResult.audits['screenshot-thumbnails'];
58
+ if (!filmstripAudit) return undefined;
59
+
60
+ const frameItems =
61
+ filmstripAudit.details &&
62
+ filmstripAudit.details.type === 'filmstrip' &&
63
+ filmstripAudit.details.items;
64
+
65
+ return frameItems || undefined;
66
+ }
67
+
53
68
  export function getModeDescription(mode: LH.Result.GatherMode, strings: UIStringsType) {
54
69
  switch (mode) {
55
70
  case 'navigation': return strings.navigationDescription;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
3
+ * 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
4
+ * 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.
5
+ */
6
+
7
+ import {jest} from '@jest/globals';
8
+ import {act, render} from '@testing-library/preact';
9
+
10
+ import {FlowStepThumbnail} from '../src/common';
11
+
12
+ let lhr: LH.ReportResult;
13
+
14
+ jest.useFakeTimers();
15
+
16
+ describe('FlowStepThumbnail', () => {
17
+ beforeEach(() => {
18
+ global.console.warn = jest.fn();
19
+
20
+ lhr = {
21
+ gatherMode: 'navigation',
22
+ configSettings: {screenEmulation: {width: 400, height: 600}},
23
+ audits: {
24
+ 'full-page-screenshot': {
25
+ details: {
26
+ type: 'full-page-screenshot',
27
+ screenshot: {data: 'baef01', width: 400, height: 600},
28
+ nodes: {},
29
+ },
30
+ },
31
+ },
32
+ } as any;
33
+ });
34
+
35
+ it('renders a thumbnail', () => {
36
+ const root = render(<FlowStepThumbnail reportResult={lhr} width={200} height={200} />);
37
+
38
+ const thumbnail = root.getByAltText(/Screenshot/);
39
+ expect(thumbnail.style.width).toEqual('200px');
40
+ expect(thumbnail.style.height).toEqual('200px');
41
+ });
42
+
43
+ it('renders nothing without dimensions', () => {
44
+ const root = render(<FlowStepThumbnail reportResult={lhr} />);
45
+
46
+ expect(() => root.getByAltText(/Screenshot/)).toThrow();
47
+ expect(global.console.warn).toHaveBeenCalled();
48
+ });
49
+
50
+ it('interpolates height', () => {
51
+ const root = render(<FlowStepThumbnail reportResult={lhr} width={200} />);
52
+
53
+ const thumbnail = root.getByAltText(/Screenshot/);
54
+ expect(thumbnail.style.width).toEqual('200px');
55
+ expect(thumbnail.style.height).toEqual('300px');
56
+ });
57
+
58
+ it('interpolates width', () => {
59
+ const root = render(<FlowStepThumbnail reportResult={lhr} height={150} />);
60
+
61
+ const thumbnail = root.getByAltText(/Screenshot/);
62
+ expect(thumbnail.style.width).toEqual('100px');
63
+ expect(thumbnail.style.height).toEqual('150px');
64
+ });
65
+
66
+ it('renders animated thumbnail for timespan', async () => {
67
+ lhr.gatherMode = 'timespan';
68
+ lhr.audits['screenshot-thumbnails'] = {
69
+ details: {
70
+ type: 'filmstrip',
71
+ items: [
72
+ {data: 'frame1'},
73
+ {data: 'frame2'},
74
+ ],
75
+ },
76
+ } as any;
77
+ const root = render(<FlowStepThumbnail reportResult={lhr} height={150} />);
78
+
79
+ const thumbnail = root.getByAltText(/Animated/) as HTMLImageElement;
80
+ expect(thumbnail.style.width).toEqual('100px');
81
+ expect(thumbnail.style.height).toEqual('150px');
82
+
83
+ expect(thumbnail.src).toContain('frame1');
84
+ await act(() => {
85
+ jest.advanceTimersByTime(251);
86
+ });
87
+ expect(thumbnail.src).toContain('frame2');
88
+ });
89
+ });
@@ -28,11 +28,12 @@ beforeEach(() => {
28
28
  describe('SummaryTooltip', () => {
29
29
  it('renders tooltip with rating', async () => {
30
30
  const category: any = {
31
+ id: 'performance',
31
32
  score: 1,
32
33
  auditRefs: [
33
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1},
34
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1},
35
- {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 1},
34
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
35
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
36
+ {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
36
37
  ],
37
38
  };
38
39
 
@@ -43,16 +44,17 @@ describe('SummaryTooltip', () => {
43
44
 
44
45
  expect(root.getByText('Average')).toBeTruthy();
45
46
  expect(() => root.getByText(/^[0-9]+$/)).toThrow();
46
- expect(root.getByText('2 audits passed / 3 audits run')).toBeTruthy();
47
+ expect(root.getByText('2 audits passed / 3 passable audits')).toBeTruthy();
47
48
  });
48
49
 
49
50
  it('renders tooltip without rating', async () => {
50
51
  const category: any = {
52
+ id: 'performance',
51
53
  score: 1,
52
54
  auditRefs: [
53
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 0},
54
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 0},
55
- {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 0},
55
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 0, group: 'diagnostics'},
56
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 0, group: 'diagnostics'},
57
+ {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 0, group: 'diagnostics'},
56
58
  ],
57
59
  };
58
60
 
@@ -63,16 +65,17 @@ describe('SummaryTooltip', () => {
63
65
 
64
66
  expect(() => root.getByText(/^(Average|Good|Poor)$/)).toThrow();
65
67
  expect(() => root.getByText(/^[0-9]+$/)).toThrow();
66
- expect(root.getByText('2 audits passed / 3 audits run')).toBeTruthy();
68
+ expect(root.getByText('2 audits passed / 3 passable audits')).toBeTruthy();
67
69
  });
68
70
 
69
71
  it('renders scored category tooltip with score', async () => {
70
72
  const category: any = {
73
+ id: 'performance',
71
74
  score: 1,
72
75
  auditRefs: [
73
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1},
74
- {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1},
75
- {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 1},
76
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
77
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
78
+ {result: {score: 0, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
76
79
  ],
77
80
  };
78
81
 
@@ -83,6 +86,28 @@ describe('SummaryTooltip', () => {
83
86
 
84
87
  expect(root.getByText('Good')).toBeTruthy();
85
88
  expect(root.getByText('100')).toBeTruthy();
86
- expect(root.getByText('2 audits passed / 3 audits run')).toBeTruthy();
89
+ expect(root.getByText('2 audits passed / 3 passable audits')).toBeTruthy();
90
+ });
91
+
92
+ it('renders informative audit count if any', async () => {
93
+ const category: any = {
94
+ id: 'performance',
95
+ score: 1,
96
+ auditRefs: [
97
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
98
+ {result: {score: 1, scoreDisplayMode: 'binary'}, weight: 1, group: 'diagnostics'},
99
+ {result: {score: 0, scoreDisplayMode: 'informative'}, weight: 1, group: 'diagnostics'},
100
+ ],
101
+ };
102
+
103
+ const root = render(
104
+ <SummaryTooltip category={category} gatherMode="navigation"/>,
105
+ {wrapper}
106
+ );
107
+
108
+ expect(root.getByText('Good')).toBeTruthy();
109
+ expect(root.getByText('100')).toBeTruthy();
110
+ expect(root.getByText('2 audits passed / 2 passable audits')).toBeTruthy();
111
+ expect(root.getByText('1 informative audits')).toBeTruthy();
87
112
  });
88
113
  });
@@ -200,26 +200,42 @@ function pruneExpectations(localConsole, lhr, expected, reportOptions) {
200
200
  JSON.stringify(value, null, 2),
201
201
  `Actual Chromium version: ${getChromeVersion()}`,
202
202
  ].join(' '));
203
- delete obj[key];
203
+ if (Array.isArray(obj)) {
204
+ obj.splice(Number(key), 1);
205
+ } else {
206
+ delete obj[key];
207
+ }
204
208
  } else if (value._legacyOnly && isFraggleRock) {
205
209
  localConsole.log([
206
210
  `[${key}] marked legacy only but run is Fraggle Rock, pruning expectation:`,
207
211
  JSON.stringify(value, null, 2),
208
212
  ].join(' '));
209
- delete obj[key];
213
+ if (Array.isArray(obj)) {
214
+ obj.splice(Number(key), 1);
215
+ } else {
216
+ delete obj[key];
217
+ }
210
218
  } else if (value._fraggleRockOnly && !isFraggleRock) {
211
219
  localConsole.log([
212
220
  `[${key}] marked Fraggle Rock only but run is legacy, pruning expectation:`,
213
221
  JSON.stringify(value, null, 2),
214
222
  `Actual channel: ${lhr.configSettings.channel}`,
215
223
  ].join(' '));
216
- delete obj[key];
224
+ if (Array.isArray(obj)) {
225
+ obj.splice(Number(key), 1);
226
+ } else {
227
+ delete obj[key];
228
+ }
217
229
  } else if (value._skipInBundled && !isBundled) {
218
230
  localConsole.log([
219
231
  `[${key}] marked as skip in bundled and runner is bundled, pruning expectation:`,
220
232
  JSON.stringify(value, null, 2),
221
233
  ].join(' '));
222
- delete obj[key];
234
+ if (Array.isArray(obj)) {
235
+ obj.splice(Number(key), 1);
236
+ } else {
237
+ delete obj[key];
238
+ }
223
239
  } else {
224
240
  pruneRecursively(value);
225
241
  }
@@ -521,15 +521,31 @@ class Util {
521
521
  * @param {LH.ReportResult.Category} category
522
522
  */
523
523
  static calculateCategoryFraction(category) {
524
- const numAudits = category.auditRefs.length;
525
-
524
+ let numPassableAudits = 0;
526
525
  let numPassed = 0;
526
+ let numInformative = 0;
527
527
  let totalWeight = 0;
528
528
  for (const auditRef of category.auditRefs) {
529
+ const auditPassed = Util.showAsPassed(auditRef.result);
530
+ const notDisplayed = !auditRef.group && category.id === 'performance';
531
+
532
+ // Don't count the audit if it's manual, N/A, or isn't displayed.
533
+ if (notDisplayed ||
534
+ auditRef.result.scoreDisplayMode === 'manual' ||
535
+ auditRef.result.scoreDisplayMode === 'notApplicable') {
536
+ continue;
537
+ } else if (auditRef.result.scoreDisplayMode === 'informative') {
538
+ if (!auditPassed) {
539
+ ++numInformative;
540
+ }
541
+ continue;
542
+ }
543
+
544
+ ++numPassableAudits;
529
545
  totalWeight += auditRef.weight;
530
- if (Util.showAsPassed(auditRef.result)) numPassed++;
546
+ if (auditPassed) numPassed++;
531
547
  }
532
- return {numPassed, numAudits, totalWeight};
548
+ return {numPassed, numPassableAudits, numInformative, totalWeight};
533
549
  }
534
550
  }
535
551
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lighthouse",
3
- "version": "8.5.1-dev.20211007",
3
+ "version": "8.5.1-dev.20211011",
4
4
  "description": "Automated auditing, performance metrics, and best practices for the web.",
5
5
  "main": "./lighthouse-core/index.js",
6
6
  "bin": {
@@ -176,6 +176,7 @@
176
176
  "ts-jest": "^27.0.4",
177
177
  "typed-query-selector": "^2.4.0",
178
178
  "typescript": "4.4.3",
179
+ "wait-for-expect": "^3.0.2",
179
180
  "webtreemap-cdt": "^3.2.1"
180
181
  },
181
182
  "dependencies": {
@@ -375,12 +375,12 @@ export class CategoryRenderer {
375
375
  const wrapper = this.dom.find('a.lh-fraction__wrapper', tmpl);
376
376
  this.dom.safelySetHref(wrapper, `#${category.id}`);
377
377
 
378
- const {numPassed, numAudits, totalWeight} = Util.calculateCategoryFraction(category);
378
+ const {numPassed, numPassableAudits, totalWeight} = Util.calculateCategoryFraction(category);
379
379
 
380
- const fraction = numPassed / numAudits;
380
+ const fraction = numPassed / numPassableAudits;
381
381
  const content = this.dom.find('.lh-fraction__content', tmpl);
382
382
  const text = this.dom.createElement('span');
383
- text.textContent = `${numPassed}/${numAudits}`;
383
+ text.textContent = `${numPassed}/${numPassableAudits}`;
384
384
  content.appendChild(text);
385
385
 
386
386
  let rating = Util.calculateRating(fraction);
@@ -172,33 +172,35 @@ export class PerformanceCategoryRenderer extends CategoryRenderer {
172
172
  }
173
173
 
174
174
  // Metrics.
175
- const metricAuditsEl = this.renderAuditGroup(groups.metrics);
175
+ const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
176
+ if (metricAudits.length) {
177
+ const metricAuditsEl = this.renderAuditGroup(groups.metrics);
176
178
 
177
- // Metric descriptions toggle.
178
- const toggleTmpl = this.dom.createComponent('metricsToggle');
179
- const _toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
180
- metricAuditsEl.append(..._toggleEl.childNodes);
179
+ // Metric descriptions toggle.
180
+ const toggleTmpl = this.dom.createComponent('metricsToggle');
181
+ const _toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
182
+ metricAuditsEl.append(..._toggleEl.childNodes);
181
183
 
182
- const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
183
- const metricsBoxesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics-container');
184
+ const metricsBoxesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics-container');
184
185
 
185
- metricAudits.forEach(item => {
186
- metricsBoxesEl.appendChild(this._renderMetric(item));
187
- });
186
+ metricAudits.forEach(item => {
187
+ metricsBoxesEl.appendChild(this._renderMetric(item));
188
+ });
188
189
 
189
- const estValuesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics__disclaimer');
190
- const disclaimerEl = this.dom.convertMarkdownLinkSnippets(strings.varianceDisclaimer);
191
- estValuesEl.appendChild(disclaimerEl);
190
+ const estValuesEl = this.dom.createChildOf(metricAuditsEl, 'div', 'lh-metrics__disclaimer');
191
+ const disclaimerEl = this.dom.convertMarkdownLinkSnippets(strings.varianceDisclaimer);
192
+ estValuesEl.appendChild(disclaimerEl);
192
193
 
193
- // Add link to score calculator.
194
- const calculatorLink = this.dom.createChildOf(estValuesEl, 'a', 'lh-calclink');
195
- calculatorLink.target = '_blank';
196
- calculatorLink.textContent = strings.calculatorLink;
197
- this.dom.safelySetHref(calculatorLink, this._getScoringCalculatorHref(category.auditRefs));
194
+ // Add link to score calculator.
195
+ const calculatorLink = this.dom.createChildOf(estValuesEl, 'a', 'lh-calclink');
196
+ calculatorLink.target = '_blank';
197
+ calculatorLink.textContent = strings.calculatorLink;
198
+ this.dom.safelySetHref(calculatorLink, this._getScoringCalculatorHref(category.auditRefs));
198
199
 
199
200
 
200
- metricAuditsEl.classList.add('lh-audit-group--metrics');
201
- element.appendChild(metricAuditsEl);
201
+ metricAuditsEl.classList.add('lh-audit-group--metrics');
202
+ element.appendChild(metricAuditsEl);
203
+ }
202
204
 
203
205
  // Filmstrip
204
206
  const timelineEl = this.dom.createChildOf(element, 'div', 'lh-filmstrip-container');
@@ -518,15 +518,31 @@ export class Util {
518
518
  * @param {LH.ReportResult.Category} category
519
519
  */
520
520
  static calculateCategoryFraction(category) {
521
- const numAudits = category.auditRefs.length;
522
-
521
+ let numPassableAudits = 0;
523
522
  let numPassed = 0;
523
+ let numInformative = 0;
524
524
  let totalWeight = 0;
525
525
  for (const auditRef of category.auditRefs) {
526
+ const auditPassed = Util.showAsPassed(auditRef.result);
527
+ const notDisplayed = !auditRef.group && category.id === 'performance';
528
+
529
+ // Don't count the audit if it's manual, N/A, or isn't displayed.
530
+ if (notDisplayed ||
531
+ auditRef.result.scoreDisplayMode === 'manual' ||
532
+ auditRef.result.scoreDisplayMode === 'notApplicable') {
533
+ continue;
534
+ } else if (auditRef.result.scoreDisplayMode === 'informative') {
535
+ if (!auditPassed) {
536
+ ++numInformative;
537
+ }
538
+ continue;
539
+ }
540
+
541
+ ++numPassableAudits;
526
542
  totalWeight += auditRef.weight;
527
- if (Util.showAsPassed(auditRef.result)) numPassed++;
543
+ if (auditPassed) numPassed++;
528
544
  }
529
- return {numPassed, numAudits, totalWeight};
545
+ return {numPassed, numPassableAudits, numInformative, totalWeight};
530
546
  }
531
547
  }
532
548
 
@@ -297,7 +297,7 @@ describe('CategoryRenderer', () => {
297
297
  );
298
298
 
299
299
  const gauge = categoryDOM.querySelector('.lh-fraction__content');
300
- assert.equal(gauge.textContent.trim(), '49/54', 'fraction is included');
300
+ assert.equal(gauge.textContent.trim(), '13/18', 'fraction is included');
301
301
 
302
302
  const score = categoryDOM.querySelector('.lh-category-header');
303
303
  const title = score.querySelector('.lh-fraction__label');
@@ -69,6 +69,18 @@ describe('PerfCategoryRenderer', () => {
69
69
  assert.equal(timelineElements.length + nontimelineElements.length, metricAudits.length);
70
70
  });
71
71
 
72
+ it('does not render metrics section if no metric group audits', () => {
73
+ // Remove metrics from category
74
+ const newCategory = JSON.parse(JSON.stringify(category));
75
+ newCategory.auditRefs = category.auditRefs.filter(audit => audit.group !== 'metrics');
76
+
77
+ const categoryDOM = renderer.render(newCategory, sampleResults.categoryGroups);
78
+ const sections = categoryDOM.querySelectorAll('.lh-category > .lh-audit-group');
79
+ const metricSection = categoryDOM.querySelector('.lh-audit-group--metrics');
80
+ assert.ok(!metricSection);
81
+ assert.equal(sections.length, 4);
82
+ });
83
+
72
84
  it('renders the metrics variance disclaimer as markdown', () => {
73
85
  const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
74
86
  const disclaimerEl =
@@ -399,17 +399,77 @@ describe('util helpers', () => {
399
399
  describe('#calculateCategoryFraction', () => {
400
400
  it('returns passed audits and total audits', () => {
401
401
  const category = {
402
+ id: 'performance',
402
403
  auditRefs: [
403
- {weight: 3, result: {score: 1, scoreDisplayMode: 'binary'}},
404
- {weight: 2, result: {score: 1, scoreDisplayMode: 'binary'}},
405
- {weight: 0, result: {score: 1, scoreDisplayMode: 'binary'}},
406
- {weight: 1, result: {score: 0, scoreDisplayMode: 'binary'}},
404
+ {weight: 3, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
405
+ {weight: 2, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
406
+ {weight: 0, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
407
+ {weight: 1, result: {score: 0, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
407
408
  ],
408
409
  };
409
- const {numPassed, numAudits, totalWeight} = Util.calculateCategoryFraction(category);
410
- expect(numPassed).toEqual(3);
411
- expect(numAudits).toEqual(4);
412
- expect(totalWeight).toEqual(6);
410
+ const fraction = Util.calculateCategoryFraction(category);
411
+ expect(fraction).toEqual({
412
+ numPassableAudits: 4,
413
+ numPassed: 3,
414
+ numInformative: 0,
415
+ totalWeight: 6,
416
+ });
417
+ });
418
+
419
+ it('ignores manual audits, N/A audits, and performance audits with no group', () => {
420
+ const category = {
421
+ id: 'performance',
422
+ auditRefs: [
423
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
424
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}},
425
+ {weight: 1, result: {score: 0, scoreDisplayMode: 'manual'}, group: 'diagnostics'},
426
+ {weight: 1, result: {score: 0, scoreDisplayMode: 'notApplicable'}, group: 'diagnostics'},
427
+ ],
428
+ };
429
+ const fraction = Util.calculateCategoryFraction(category);
430
+ expect(fraction).toEqual({
431
+ numPassableAudits: 1,
432
+ numPassed: 1,
433
+ numInformative: 0,
434
+ totalWeight: 1,
435
+ });
436
+ });
437
+
438
+ it('does not ignore audits with no group in non-performance category', () => {
439
+ const category = {
440
+ id: 'seo',
441
+ auditRefs: [
442
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
443
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}},
444
+ {weight: 1, result: {score: 0, scoreDisplayMode: 'manual'}, group: 'diagnostics'},
445
+ ],
446
+ };
447
+ const fraction = Util.calculateCategoryFraction(category);
448
+ expect(fraction).toEqual({
449
+ numPassableAudits: 2,
450
+ numPassed: 2,
451
+ numInformative: 0,
452
+ totalWeight: 2,
453
+ });
454
+ });
455
+
456
+ it('tracks informative audits separately', () => {
457
+ const category = {
458
+ id: 'performance',
459
+ auditRefs: [
460
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
461
+ {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}, group: 'diagnostics'},
462
+ {weight: 0, result: {score: 1, scoreDisplayMode: 'informative'}, group: 'diagnostics'},
463
+ {weight: 1, result: {score: 0, scoreDisplayMode: 'informative'}, group: 'diagnostics'},
464
+ ],
465
+ };
466
+ const fraction = Util.calculateCategoryFraction(category);
467
+ expect(fraction).toEqual({
468
+ numPassableAudits: 2,
469
+ numPassed: 2,
470
+ numInformative: 2,
471
+ totalWeight: 2,
472
+ });
413
473
  });
414
474
  });
415
475
  });