Skip to content

Commit

Permalink
core(largest-contentful-paint-element): add phases table (#14891)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamraine committed May 3, 2023
1 parent 4c23ad8 commit 40c1608
Show file tree
Hide file tree
Showing 8 changed files with 530 additions and 97 deletions.
14 changes: 8 additions & 6 deletions cli/test/smokehouse/test-definitions/perf-frame-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ const expectations = {
},
'largest-contentful-paint-element': {
details: {
items: [{
node: {
// Element should be from main frame while metric is not LCPAllFrames.
nodeLabel: 'This is the main frame LCP and FCP.',
},
}],
items: {0: {
items: [{
node: {
// Element should be from main frame while metric is not LCPAllFrames.
nodeLabel: 'This is the main frame LCP and FCP.',
},
}],
}},
},
},
},
Expand Down
18 changes: 10 additions & 8 deletions cli/test/smokehouse/test-definitions/perf-trace-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,17 @@ const expectations = {
score: null,
displayValue: '1 element found',
details: {
items: [
{
node: {
type: 'node',
nodeLabel: 'section > img',
path: '0,HTML,1,BODY,1,DIV,a,#document-fragment,0,SECTION,0,IMG',
},
items: {
0: {
items: [{
node: {
type: 'node',
nodeLabel: 'section > img',
path: '0,HTML,1,BODY,1,DIV,a,#document-fragment,0,SECTION,0,IMG',
},
}],
},
],
},
},
},
'lcp-lazy-loaded': {
Expand Down
81 changes: 77 additions & 4 deletions core/audits/largest-contentful-paint-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,29 @@

import {Audit} from './audit.js';
import * as i18n from '../lib/i18n/i18n.js';
import {LargestContentfulPaint} from '../computed/metrics/largest-contentful-paint.js';
import {LCPBreakdown} from '../computed/metrics/lcp-breakdown.js';

const UIStrings = {
/** Descriptive title of a diagnostic audit that provides the element that was determined to be the Largest Contentful Paint. */
title: 'Largest Contentful Paint element',
/** Description of a Lighthouse audit that tells the user that the element shown was determined to be the Largest Contentful Paint. */
description: 'This is the largest contentful element painted within the viewport. ' +
'[Learn more about the Largest Contentful Paint element](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)',
/** Label for a column in a data table; entries will be the name of a phase in the Largest Contentful Paint (LCP) metric. */
columnPhase: 'Phase',
/** Label for a column in a data table; entries will be the percent of Largest Contentful Paint (LCP) that a phase covers. */
columnPercentOfLCP: '% of LCP',
/** Label for a column in a data table; entries will be the amount of time spent in a phase in the Largest Contentful Paint (LCP) metric. */
columnTiming: 'Timing',
/** Table item value for the Time To First Byte (TTFB) phase of the Largest Contentful Paint (LCP) metric. */
itemTTFB: 'TTFB',
/** Table item value for the load delay phase of the Largest Contentful Paint (LCP) metric. */
itemLoadDelay: 'Load Delay',
/** Table item value for the load time phase of the Largest Contentful Paint (LCP) metric. */
itemLoadTime: 'Load Time',
/** Table item value for the render delay phase of the Largest Contentful Paint (LCP) metric. */
itemRenderDelay: 'Render Delay',
};

const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
Expand All @@ -28,15 +44,64 @@ class LargestContentfulPaintElement extends Audit {
description: str_(UIStrings.description),
scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
supportedModes: ['navigation'],
requiredArtifacts: ['traces', 'TraceElements'],
requiredArtifacts:
['traces', 'TraceElements', 'devtoolsLogs', 'GatherContext', 'settings', 'URL'],
};
}

/**
* @param {LH.Artifacts} artifacts
* @return {LH.Audit.Product}
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Details.Table|undefined>}
*/
static audit(artifacts) {
static async makePhaseTable(artifacts, context) {
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const gatherContext = artifacts.GatherContext;
const metricComputationData = {trace, devtoolsLog, gatherContext,
settings: context.settings, URL: artifacts.URL};

const {timing: metricLcp} =
await LargestContentfulPaint.request(metricComputationData, context);
const {ttfb, loadStart, loadEnd} = await LCPBreakdown.request(metricComputationData, context);

let loadDelay = 0;
let loadTime = 0;
let renderDelay = metricLcp - ttfb;

if (loadStart && loadEnd) {
loadDelay = loadStart - ttfb;
loadTime = loadEnd - loadStart;
renderDelay = metricLcp - loadEnd;
}

const results = [
{phase: str_(UIStrings.itemTTFB), timing: ttfb},
{phase: str_(UIStrings.itemLoadDelay), timing: loadDelay},
{phase: str_(UIStrings.itemLoadTime), timing: loadTime},
{phase: str_(UIStrings.itemRenderDelay), timing: renderDelay},
].map(result => {
const percent = 100 * result.timing / metricLcp;
const percentStr = `${percent.toFixed(0)}%`;
return {...result, percent: percentStr};
});

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
{key: 'phase', valueType: 'text', label: str_(UIStrings.columnPhase)},
{key: 'percent', valueType: 'text', label: str_(UIStrings.columnPercentOfLCP)},
{key: 'timing', valueType: 'ms', label: str_(UIStrings.columnTiming)},
];

return Audit.makeTableDetails(headings, results);
}

/**
* @param {LH.Artifacts} artifacts
* @param {LH.Audit.Context} context
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts, context) {
const lcpElement = artifacts.TraceElements
.find(element => element.traceEventType === 'largest-contentful-paint');
const lcpElementDetails = [];
Expand All @@ -51,7 +116,15 @@ class LargestContentfulPaintElement extends Audit {
{key: 'node', valueType: 'node', label: str_(i18n.UIStrings.columnElement)},
];

const details = Audit.makeTableDetails(headings, lcpElementDetails);
const elementTable = Audit.makeTableDetails(headings, lcpElementDetails);

const items = [elementTable];
if (elementTable.items.length) {
const phaseTable = await this.makePhaseTable(artifacts, context);
if (phaseTable) items.push(phaseTable);
}

const details = Audit.makeListDetails(items);

const displayValue = str_(i18n.UIStrings.displayValueElementsFound,
{nodeCount: lcpElementDetails.length});
Expand Down
124 changes: 117 additions & 7 deletions core/test/audits/largest-contentful-paint-element-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,67 @@
*/

import LargestContentfulPaintElementAudit from '../../audits/largest-contentful-paint-element.js';
import {defaultSettings} from '../../config/constants.js';
import {createTestTrace} from '../create-test-trace.js';
import {networkRecordsToDevtoolsLog} from '../network-records-to-devtools-log.js';

const requestedUrl = 'http://example.com:3000';
const mainDocumentUrl = 'http://www.example.com:3000';

const scriptUrl = 'http://www.example.com/script.js';
const imageUrl = 'http://www.example.com/image.png';

function mockNetworkRecords() {
return [{
requestId: '2',
priority: 'High',
isLinkPreload: false,
networkRequestTime: 0,
networkEndTime: 500,
timing: {sendEnd: 0, receiveHeadersEnd: 500},
transferSize: 400,
url: requestedUrl,
frameId: 'ROOT_FRAME',
},
{
requestId: '2:redirect',
resourceType: 'Document',
priority: 'High',
isLinkPreload: false,
networkRequestTime: 500,
responseHeadersEndTime: 800,
networkEndTime: 1000,
timing: {sendEnd: 0, receiveHeadersEnd: 300},
transferSize: 16_000,
url: mainDocumentUrl,
frameId: 'ROOT_FRAME',
},
{
requestId: '3',
resourceType: 'Script',
priority: 'High',
isLinkPreload: false,
networkRequestTime: 1000,
networkEndTime: 2000,
transferSize: 32_000,
url: scriptUrl,
initiator: {type: 'parser', url: mainDocumentUrl},
frameId: 'ROOT_FRAME',
},
{
requestId: '4',
resourceType: 'Image',
priority: 'High',
isLinkPreload: false,
networkRequestTime: 2000,
networkEndTime: 4500,
transferSize: 640_000,
url: imageUrl,
initiator: {type: 'script', url: scriptUrl},
frameId: 'ROOT_FRAME',
}];
}

describe('Performance: largest-contentful-paint-element audit', () => {
it('correctly surfaces the LCP element', async () => {
const artifacts = {
Expand All @@ -18,27 +79,76 @@ describe('Performance: largest-contentful-paint-element audit', () => {
},
type: 'text',
}],
settings: JSON.parse(JSON.stringify(defaultSettings)),
traces: {
defaultPass: createTestTrace({
traceEnd: 6000,
largestContentfulPaint: 8000,
}),
},
devtoolsLogs: {
defaultPass: networkRecordsToDevtoolsLog(mockNetworkRecords()),
},
URL: {
requestedUrl,
mainDocumentUrl,
finalDisplayedUrl: mainDocumentUrl,
},
GatherContext: {gatherMode: 'navigation'},
};

const auditResult = await LargestContentfulPaintElementAudit.audit(artifacts);
const context = {settings: artifacts.settings, computedCache: new Map()};
const auditResult = await LargestContentfulPaintElementAudit.audit(artifacts, context);

expect(auditResult.score).toEqual(1);
expect(auditResult.notApplicable).toEqual(false);
expect(auditResult.displayValue).toBeDisplayString('1 element found');
expect(auditResult.details.items).toHaveLength(1);
expect(auditResult.details.items[0].node.path).toEqual('1,HTML,3,BODY,5,DIV,0,HEADER');
expect(auditResult.details.items[0].node.nodeLabel).toEqual('My Test Label');
expect(auditResult.details.items[0].node.snippet).toEqual('<h1 class="test-class">');
expect(auditResult.details.items).toHaveLength(2);
expect(auditResult.details.items[0].items).toHaveLength(1);
expect(auditResult.details.items[0].items[0].node.path).toEqual('1,HTML,3,BODY,5,DIV,0,HEADER');
expect(auditResult.details.items[0].items[0].node.nodeLabel).toEqual('My Test Label');
expect(auditResult.details.items[0].items[0].node.snippet).toEqual('<h1 class="test-class">');

// LCP phases
expect(auditResult.details.items[1].items).toHaveLength(4);
expect(auditResult.details.items[1].items[0].phase).toBeDisplayString('TTFB');
expect(auditResult.details.items[1].items[0].timing).toBeCloseTo(800, 0.1);
expect(auditResult.details.items[1].items[1].phase).toBeDisplayString('Load Delay');
expect(auditResult.details.items[1].items[1].timing).toBeCloseTo(651, 0.1);
expect(auditResult.details.items[1].items[2].phase).toBeDisplayString('Load Time');
expect(auditResult.details.items[1].items[2].timing).toBeCloseTo(1813.7, 0.1);
expect(auditResult.details.items[1].items[3].phase).toBeDisplayString('Render Delay');
expect(auditResult.details.items[1].items[3].timing).toBeCloseTo(2539.2, 0.1);
});

it('doesn\'t throw an error when there is nothing to show', async () => {
const artifacts = {
TraceElements: [],
settings: JSON.parse(JSON.stringify(defaultSettings)),
traces: {
defaultPass: createTestTrace({
traceEnd: 6000,
largestContentfulPaint: 4500,
}),
},
devtoolsLogs: {
defaultPass: networkRecordsToDevtoolsLog(mockNetworkRecords()),
},
URL: {
requestedUrl,
mainDocumentUrl,
finalDisplayedUrl: mainDocumentUrl,
},
GatherContext: {gatherMode: 'navigation'},
};

const auditResult = await LargestContentfulPaintElementAudit.audit(artifacts);
const context = {settings: artifacts.settings, computedCache: new Map()};
const auditResult = await LargestContentfulPaintElementAudit.audit(artifacts, context);

expect(auditResult.score).toEqual(1);
expect(auditResult.notApplicable).toEqual(true);
expect(auditResult.displayValue).toBeDisplayString('0 elements found');
expect(auditResult.details.items).toHaveLength(0);
expect(auditResult.details.items).toHaveLength(1);
expect(auditResult.details.items[0].items).toHaveLength(0);
});
});
Loading

0 comments on commit 40c1608

Please sign in to comment.