Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(largest-contentful-paint-element): add phases table #14891

Merged
merged 17 commits into from
May 3, 2023
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(2)}%`;
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