lighthouse 13.1.0 → 13.2.0

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.
Files changed (110) hide show
  1. package/cli/bin.js +5 -0
  2. package/core/audits/accessibility/autocomplete-valid.d.ts +10 -0
  3. package/core/audits/accessibility/autocomplete-valid.js +44 -0
  4. package/core/audits/accessibility/presentation-role-conflict.d.ts +10 -0
  5. package/core/audits/accessibility/presentation-role-conflict.js +46 -0
  6. package/core/audits/accessibility/svg-img-alt.d.ts +10 -0
  7. package/core/audits/accessibility/svg-img-alt.js +44 -0
  8. package/core/audits/agentic/agent-accessibility-tree.d.ts +19 -0
  9. package/core/audits/agentic/agent-accessibility-tree.js +115 -0
  10. package/core/audits/agentic/llms-txt.d.ts +20 -0
  11. package/core/audits/agentic/llms-txt.js +111 -0
  12. package/core/audits/insights/insight-audit.d.ts +2 -2
  13. package/core/audits/insights/insight-audit.js +16 -6
  14. package/core/audits/layout-shifts.js +1 -1
  15. package/core/audits/server-response-time.js +3 -3
  16. package/core/audits/webmcp-form-coverage.d.ts +16 -0
  17. package/core/audits/webmcp-form-coverage.js +90 -0
  18. package/core/audits/webmcp-registered-tools.d.ts +21 -0
  19. package/core/audits/webmcp-registered-tools.js +149 -0
  20. package/core/audits/webmcp-schema-validity.d.ts +22 -0
  21. package/core/audits/webmcp-schema-validity.js +141 -0
  22. package/core/computed/document-urls.js +0 -1
  23. package/core/computed/main-resource.js +0 -2
  24. package/core/computed/metrics/lantern-metric.js +4 -4
  25. package/core/computed/metrics/lcp-breakdown.js +1 -1
  26. package/core/computed/metrics/time-to-first-byte.js +1 -1
  27. package/core/computed/navigation-insights.js +2 -1
  28. package/core/computed/network-analysis.js +0 -1
  29. package/core/config/agentic-browsing-config.d.ts +12 -0
  30. package/core/config/agentic-browsing-config.js +73 -0
  31. package/core/config/default-config.js +6 -0
  32. package/core/gather/gatherers/accessibility.js +5 -1
  33. package/core/gather/gatherers/agentic/llms-txt.d.ts +10 -0
  34. package/core/gather/gatherers/agentic/llms-txt.js +28 -0
  35. package/core/gather/gatherers/inputs.js +2 -0
  36. package/core/gather/gatherers/meta-elements.js +1 -1
  37. package/core/gather/gatherers/trace-elements.js +1 -1
  38. package/core/gather/gatherers/webmcp-schema.d.ts +25 -0
  39. package/core/gather/gatherers/webmcp-schema.js +105 -0
  40. package/core/gather/gatherers/webmcp.d.ts +58 -0
  41. package/core/gather/gatherers/webmcp.js +159 -0
  42. package/core/index.d.ts +1 -0
  43. package/core/index.js +1 -0
  44. package/core/lib/cdt/generated/SourceMap.js +2 -2
  45. package/core/lib/navigation-error.js +0 -6
  46. package/core/lib/network-request.js +0 -1
  47. package/core/lib/page-functions.d.ts +3 -3
  48. package/core/lib/page-functions.js +11 -4
  49. package/core/lib/tracehouse/trace-processor.d.ts +5 -4
  50. package/core/lib/tracehouse/trace-processor.js +85 -19
  51. package/core/runner.js +3 -0
  52. package/core/scoring.d.ts +1 -0
  53. package/dist/report/bundle.esm.js +1 -1
  54. package/dist/report/flow.js +3 -3
  55. package/dist/report/standalone.js +1 -1
  56. package/flow-report/src/summary/category.tsx +1 -1
  57. package/package.json +11 -11
  58. package/report/renderer/category-renderer.js +1 -1
  59. package/report/renderer/report-utils.d.ts +2 -1
  60. package/report/renderer/report-utils.js +7 -2
  61. package/shared/localization/locales/ar-XB.json +72 -36
  62. package/shared/localization/locales/ar.json +72 -36
  63. package/shared/localization/locales/bg.json +72 -36
  64. package/shared/localization/locales/ca.json +72 -36
  65. package/shared/localization/locales/cs.json +72 -36
  66. package/shared/localization/locales/da.json +74 -38
  67. package/shared/localization/locales/de.json +72 -36
  68. package/shared/localization/locales/el.json +73 -37
  69. package/shared/localization/locales/en-GB.json +74 -38
  70. package/shared/localization/locales/en-US.json +245 -17
  71. package/shared/localization/locales/en-XL.json +245 -17
  72. package/shared/localization/locales/es-419.json +72 -36
  73. package/shared/localization/locales/es.json +73 -37
  74. package/shared/localization/locales/fi.json +72 -36
  75. package/shared/localization/locales/fil.json +74 -38
  76. package/shared/localization/locales/fr.json +162 -126
  77. package/shared/localization/locales/he.json +74 -38
  78. package/shared/localization/locales/hi.json +73 -37
  79. package/shared/localization/locales/hr.json +72 -36
  80. package/shared/localization/locales/hu.json +73 -37
  81. package/shared/localization/locales/id.json +74 -38
  82. package/shared/localization/locales/it.json +72 -36
  83. package/shared/localization/locales/ja.json +72 -36
  84. package/shared/localization/locales/ko.json +72 -36
  85. package/shared/localization/locales/lt.json +72 -36
  86. package/shared/localization/locales/lv.json +72 -36
  87. package/shared/localization/locales/nl.json +73 -37
  88. package/shared/localization/locales/no.json +72 -36
  89. package/shared/localization/locales/pl.json +72 -36
  90. package/shared/localization/locales/pt-PT.json +72 -36
  91. package/shared/localization/locales/pt.json +74 -38
  92. package/shared/localization/locales/ro.json +72 -36
  93. package/shared/localization/locales/ru.json +72 -36
  94. package/shared/localization/locales/sk.json +72 -36
  95. package/shared/localization/locales/sl.json +72 -36
  96. package/shared/localization/locales/sr-Latn.json +73 -37
  97. package/shared/localization/locales/sr.json +73 -37
  98. package/shared/localization/locales/sv.json +75 -39
  99. package/shared/localization/locales/ta.json +73 -37
  100. package/shared/localization/locales/te.json +72 -36
  101. package/shared/localization/locales/th.json +73 -37
  102. package/shared/localization/locales/tr.json +72 -36
  103. package/shared/localization/locales/uk.json +72 -36
  104. package/shared/localization/locales/vi.json +74 -38
  105. package/shared/localization/locales/zh-HK.json +72 -36
  106. package/shared/localization/locales/zh-TW.json +74 -38
  107. package/shared/localization/locales/zh.json +75 -39
  108. package/types/artifacts.d.ts +33 -0
  109. package/types/config.d.ts +1 -0
  110. package/types/lhr/lhr.d.ts +11 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import BaseGatherer from '../base-gatherer.js';
8
+ import {resolveNodeIdToObjectId} from '../driver/dom.js';
9
+ import {pageFunctions} from '../../lib/page-functions.js';
10
+ import {ExecutionContext} from '../driver/execution-context.js';
11
+
12
+ class WebMcpSchemaIssues extends BaseGatherer {
13
+ /** @type {LH.Gatherer.GathererMeta} */
14
+ meta = {
15
+ supportedModes: ['navigation', 'snapshot'],
16
+ };
17
+
18
+ constructor() {
19
+ super();
20
+ /** @type {LH.Artifacts.WebMcpSchemaIssue[]} */
21
+ this._issues = [];
22
+ this._onIssueAdded = this.onIssueAdded.bind(this);
23
+ }
24
+
25
+ /**
26
+ * @param {Record<string, any>} event
27
+ */
28
+ onIssueAdded(event) {
29
+ const issue = event.issue;
30
+ if (!issue || issue.code !== 'GenericIssue') return;
31
+
32
+ const details = issue.details?.genericIssueDetails;
33
+ if (!details) return;
34
+
35
+ const errorType = details.errorType;
36
+ if (errorType && (
37
+ errorType === 'FormModelContextMissingToolName' ||
38
+ errorType === 'FormModelContextMissingToolDescription' ||
39
+ errorType === 'FormModelContextRequiredParameterMissingName' ||
40
+ errorType === 'FormModelContextParameterMissingTitleAndDescription' ||
41
+ errorType === 'FormModelContextParameterMissingName'
42
+ )) {
43
+ this._issues.push(details);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {LH.Gatherer.Context} passContext
49
+ */
50
+ async startInstrumentation(passContext) {
51
+ const session = passContext.driver.defaultSession;
52
+ session.on('Audits.issueAdded', this._onIssueAdded);
53
+ await session.sendCommand('Audits.enable');
54
+ }
55
+
56
+ /**
57
+ * @param {LH.Gatherer.Context} passContext
58
+ */
59
+ async stopInstrumentation(passContext) {
60
+ const session = passContext.driver.defaultSession;
61
+ session.off('Audits.issueAdded', this._onIssueAdded);
62
+ }
63
+
64
+ /**
65
+ * @param {LH.Gatherer.Context} context
66
+ * @return {Promise<LH.Artifacts.WebMcpSchemaIssue[]>}
67
+ */
68
+ async getArtifact(context) {
69
+ const session = context.driver.defaultSession;
70
+
71
+ const deps = ExecutionContext.serializeDeps([
72
+ pageFunctions.getNodeDetails,
73
+ ]);
74
+
75
+ const promises = this._issues.map(async (issue) => {
76
+ const processedIssue = {...issue};
77
+ if (issue.violatingNodeId) {
78
+ try {
79
+ const objectId = await resolveNodeIdToObjectId(session, issue.violatingNodeId);
80
+ if (objectId) {
81
+ const response = await session.sendCommand('Runtime.callFunctionOn', {
82
+ objectId,
83
+ functionDeclaration: `function () {
84
+ ${deps}
85
+ return getNodeDetails(this);
86
+ }`,
87
+ returnByValue: true,
88
+ awaitPromise: true,
89
+ });
90
+ if (response && response.result && response.result.value) {
91
+ processedIssue.nodeDetails = response.result.value;
92
+ }
93
+ }
94
+ } catch (err) {
95
+ // Ignore error
96
+ }
97
+ }
98
+ return processedIssue;
99
+ });
100
+
101
+ return Promise.all(promises);
102
+ }
103
+ }
104
+
105
+ export default WebMcpSchemaIssues;
@@ -0,0 +1,58 @@
1
+ export default WebMCP;
2
+ export type WebMCPTool = {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: Record<string, any>;
6
+ frameId: string;
7
+ backendNodeId?: number | undefined;
8
+ stackTrace?: any;
9
+ nodeDetails?: import("../../index.js").Artifacts.NodeDetails | undefined;
10
+ };
11
+ /**
12
+ * @typedef {Object} WebMCPTool
13
+ * @property {string} name
14
+ * @property {string} description
15
+ * @property {Record<string, any>} inputSchema
16
+ * @property {string} frameId
17
+ * @property {number} [backendNodeId]
18
+ * @property {any} [stackTrace]
19
+ * @property {LH.Artifacts.NodeDetails} [nodeDetails]
20
+ */
21
+ declare class WebMCP extends BaseGatherer {
22
+ /** @type {WebMCPTool[]} */
23
+ _tools: WebMCPTool[];
24
+ _isSupported: boolean;
25
+ _onToolsAdded: (event: {
26
+ tools: WebMCPTool[];
27
+ }) => void;
28
+ _onToolsRemoved: (event: {
29
+ tools: WebMCPTool[];
30
+ }) => void;
31
+ /**
32
+ * @param {{tools: WebMCPTool[]}} event
33
+ */
34
+ onToolsAdded(event: {
35
+ tools: WebMCPTool[];
36
+ }): void;
37
+ /**
38
+ * @param {{tools: WebMCPTool[]}} event
39
+ */
40
+ onToolsRemoved(event: {
41
+ tools: WebMCPTool[];
42
+ }): void;
43
+ /**
44
+ * @param {LH.Gatherer.Context} passContext
45
+ */
46
+ startInstrumentation(passContext: LH.Gatherer.Context): Promise<void>;
47
+ /**
48
+ * @param {LH.Gatherer.Context} passContext
49
+ */
50
+ stopInstrumentation(passContext: LH.Gatherer.Context): Promise<void>;
51
+ /**
52
+ * @param {LH.Gatherer.Context} context
53
+ * @return {Promise<LH.Artifacts['WebMCP']>}
54
+ */
55
+ getArtifact(context: LH.Gatherer.Context): Promise<LH.Artifacts["WebMCP"]>;
56
+ }
57
+ import BaseGatherer from '../base-gatherer.js';
58
+ //# sourceMappingURL=webmcp.d.ts.map
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * @fileoverview Capture WebMCP data
9
+ */
10
+
11
+ import BaseGatherer from '../base-gatherer.js';
12
+ import {resolveNodeIdToObjectId} from '../driver/dom.js';
13
+ import {pageFunctions} from '../../lib/page-functions.js';
14
+ import {ExecutionContext} from '../driver/execution-context.js';
15
+
16
+ /**
17
+ * @typedef {Object} WebMCPTool
18
+ * @property {string} name
19
+ * @property {string} description
20
+ * @property {Record<string, any>} inputSchema
21
+ * @property {string} frameId
22
+ * @property {number} [backendNodeId]
23
+ * @property {any} [stackTrace]
24
+ * @property {LH.Artifacts.NodeDetails} [nodeDetails]
25
+ */
26
+ class WebMCP extends BaseGatherer {
27
+ /** @type {LH.Gatherer.GathererMeta} */
28
+ meta = {
29
+ supportedModes: ['navigation', 'snapshot'],
30
+ };
31
+
32
+ constructor() {
33
+ super();
34
+ /** @type {WebMCPTool[]} */
35
+ this._tools = [];
36
+ this._isSupported = true;
37
+ this._onToolsAdded = this.onToolsAdded.bind(this);
38
+ this._onToolsRemoved = this.onToolsRemoved.bind(this);
39
+ }
40
+
41
+ /**
42
+ * @param {{tools: WebMCPTool[]}} event
43
+ */
44
+ // TODO: Handle WebMCP tools per frame.
45
+ onToolsAdded(event) {
46
+ // Note that as of M148, there is a bug in WebMCP CDP.
47
+ // While WebMCP is enabled, any newly registered tool will
48
+ // have an empty schema.
49
+ if (event.tools) {
50
+ this._tools.push(...event.tools);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * @param {{tools: WebMCPTool[]}} event
56
+ */
57
+ onToolsRemoved(event) {
58
+ if (event.tools) {
59
+ const removedNames = new Set(event.tools.map(t => t.name));
60
+ this._tools = this._tools.filter(t => !removedNames.has(t.name));
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {LH.Gatherer.Context} passContext
66
+ */
67
+ async startInstrumentation(passContext) {
68
+ const session = passContext.driver.defaultSession;
69
+
70
+ // @ts-expect-error - WebMCP domain might not be in types yet.
71
+ session.on('WebMCP.toolsAdded', this._onToolsAdded);
72
+ // @ts-expect-error
73
+ session.on('WebMCP.toolsRemoved', this._onToolsRemoved);
74
+
75
+ try {
76
+ await session.sendCommand('WebMCP.enable');
77
+ } catch (err) {
78
+ if (err.message.includes('\'WebMCP.enable\' wasn\'t found')) {
79
+ this._isSupported = false;
80
+ return;
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {LH.Gatherer.Context} passContext
88
+ */
89
+ async stopInstrumentation(passContext) {
90
+ const session = passContext.driver.defaultSession;
91
+ // @ts-expect-error
92
+ session.off('WebMCP.toolsAdded', this._onToolsAdded);
93
+ // @ts-expect-error
94
+ session.off('WebMCP.toolsRemoved', this._onToolsRemoved);
95
+ try {
96
+ await session.sendCommand('WebMCP.disable');
97
+ } catch (err) {
98
+ // Ignore errors
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @param {LH.Gatherer.Context} context
104
+ * @return {Promise<LH.Artifacts['WebMCP']>}
105
+ */
106
+ async getArtifact(context) {
107
+ const isSupported = await context.driver.executionContext.evaluate(
108
+ // @ts-expect-error - modelContext is not in types
109
+ () => typeof navigator.modelContext !== 'undefined',
110
+ {args: [], useIsolation: true}
111
+ );
112
+ if (!isSupported || !this._isSupported) {
113
+ return {isSupported: false, tools: []};
114
+ }
115
+
116
+ const session = context.driver.defaultSession;
117
+
118
+ // Remove duplicates based on name, keeping the latest occurrence.
119
+ const toolMap = new Map();
120
+ for (const tool of this._tools) {
121
+ toolMap.set(tool.name, tool);
122
+ }
123
+
124
+ const resolvedTools = [];
125
+ for (const tool of toolMap.values()) {
126
+ if (tool.backendNodeId) {
127
+ try {
128
+ const objectId = await resolveNodeIdToObjectId(session, tool.backendNodeId);
129
+ if (objectId) {
130
+ const deps = ExecutionContext.serializeDeps([
131
+ pageFunctions.getNodeDetails,
132
+ ]);
133
+ const response = await session.sendCommand('Runtime.callFunctionOn', {
134
+ objectId,
135
+ functionDeclaration: `function () {
136
+ ${deps}
137
+ return getNodeDetails(this);
138
+ }`,
139
+ returnByValue: true,
140
+ awaitPromise: true,
141
+ });
142
+ if (response && response.result && response.result.value) {
143
+ tool.nodeDetails = response.result.value;
144
+ }
145
+ }
146
+ } catch (err) {
147
+ // Ignore error
148
+ }
149
+ }
150
+ resolvedTools.push(tool);
151
+ }
152
+ return {
153
+ isSupported: true,
154
+ tools: resolvedTools,
155
+ };
156
+ }
157
+ }
158
+
159
+ export default WebMCP;
package/core/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { default as Gatherer } from "./gather/base-gatherer.js";
4
4
  export { NetworkRecords } from "./computed/network-records.js";
5
5
  export { default as defaultConfig } from "./config/default-config.js";
6
6
  export { default as desktopConfig } from "./config/desktop-config.js";
7
+ export { default as agenticBrowsingConfig } from "./config/agentic-browsing-config.js";
7
8
  export * from "../types/lh.js";
8
9
  /**
9
10
  * Run Lighthouse.
package/core/index.js CHANGED
@@ -121,6 +121,7 @@ export {default as Gatherer} from './gather/base-gatherer.js';
121
121
  export {NetworkRecords} from './computed/network-records.js';
122
122
  export {default as defaultConfig} from './config/default-config.js';
123
123
  export {default as desktopConfig} from './config/desktop-config.js';
124
+ export {default as agenticBrowsingConfig} from './config/agentic-browsing-config.js';
124
125
  export * from '../types/lh.js';
125
126
  export {
126
127
  startFlow,
@@ -381,7 +381,7 @@ class SourceMap {
381
381
  let nameIndex = 0;
382
382
  const names = map.names ?? [];
383
383
  const tokenIter = new TokenIterator(map.mappings);
384
- let sourceURL = this.#sourceInfos[sourceIndex].sourceURL;
384
+ let sourceURL = this.#sourceInfos[sourceIndex]?.sourceURL;
385
385
  while (true) {
386
386
  if (tokenIter.peek() === ',') {
387
387
  tokenIter.next();
@@ -404,7 +404,7 @@ class SourceMap {
404
404
  const sourceIndexDelta = tokenIter.nextVLQ();
405
405
  if (sourceIndexDelta) {
406
406
  sourceIndex += sourceIndexDelta;
407
- sourceURL = this.#sourceInfos[sourceIndex].sourceURL;
407
+ sourceURL = this.#sourceInfos[sourceIndex]?.sourceURL;
408
408
  }
409
409
  sourceLineNumber += tokenIter.nextVLQ();
410
410
  sourceColumnNumber += tokenIter.nextVLQ();
@@ -131,7 +131,6 @@ function getNonHtmlError(finalRecord) {
131
131
  function getPageLoadError(navigationError, context) {
132
132
  const {url, networkRecords} = context;
133
133
  const mainRecordLantern = Lantern.Core.NetworkAnalyzer.findResourceForUrl(
134
- // @ts-expect-error - trace engine types for InitiatorType are outdated
135
134
  networkRecords,
136
135
  url
137
136
  );
@@ -144,7 +143,6 @@ function getPageLoadError(navigationError, context) {
144
143
  record.resourceType === NetworkRequest.TYPES.Document
145
144
  );
146
145
  if (documentRequests.length) {
147
- // @ts-expect-error - mainRecord is inferred as a Lantern request from findResourceForUrl, but we assign a raw record here.
148
146
  mainRecord = documentRequests.reduce((min, r) => {
149
147
  return r.networkRequestTime < min.networkRequestTime ? r : min;
150
148
  });
@@ -164,12 +162,8 @@ function getPageLoadError(navigationError, context) {
164
162
  context.warnings.push(str_(UIStrings.warningXhtml));
165
163
  }
166
164
 
167
- // @ts-expect-error - mainRecord may be typed as a Lantern request, but functions expect a raw record.
168
165
  const networkError = getNetworkError(mainRecord, context);
169
- // @ts-expect-error - mainRecord may be typed as a Lantern request, but functions expect a raw record.
170
166
  const interstitialError = getInterstitialError(mainRecord, networkRecords);
171
- // @ts-expect-error - finalRecord may be a Lantern request, which is compatible enough
172
- // for getNonHtmlError.
173
167
  const nonHtmlError = getNonHtmlError(finalRecord);
174
168
 
175
169
  // We want to special-case the interstitial beyond FAILED_DOCUMENT_REQUEST. See https://github.com/GoogleChrome/lighthouse/pull/8865#issuecomment-497507618
@@ -592,7 +592,6 @@ class NetworkRequest {
592
592
 
593
593
  record.fromWorker = record.sessionTargetType === 'worker';
594
594
 
595
- // @ts-expect-error - trace engine types for InitiatorType are outdated
596
595
  return {
597
596
  rawRequest: record,
598
597
  ...record,
@@ -88,10 +88,10 @@ declare namespace getOuterHTMLSnippet {
88
88
  */
89
89
  declare function computeBenchmarkIndex(): number;
90
90
  /**
91
- * @param {Element|ShadowRoot} element
92
- * @return {LH.Artifacts.NodeDetails}
91
+ * @param {Node|ShadowRoot} node
92
+ * @return {LH.Artifacts.NodeDetails | null}
93
93
  */
94
- declare function getNodeDetails(element: Element | ShadowRoot): LH.Artifacts.NodeDetails;
94
+ declare function getNodeDetails(node: Node | ShadowRoot): LH.Artifacts.NodeDetails | null;
95
95
  declare namespace getNodeDetails {
96
96
  function toString(): string;
97
97
  }
@@ -450,16 +450,23 @@ function wrapRequestIdleCallback(cpuSlowdownMultiplier) {
450
450
  }
451
451
 
452
452
  /**
453
- * @param {Element|ShadowRoot} element
454
- * @return {LH.Artifacts.NodeDetails}
453
+ * @param {Node|ShadowRoot} node
454
+ * @return {LH.Artifacts.NodeDetails | null}
455
455
  */
456
- function getNodeDetails(element) {
456
+ function getNodeDetails(node) {
457
457
  // This bookkeeping is for the FullPageScreenshot gatherer.
458
458
  if (!window.__lighthouseNodesDontTouchOrAllVarianceGoesAway) {
459
459
  window.__lighthouseNodesDontTouchOrAllVarianceGoesAway = new Map();
460
460
  }
461
461
 
462
- element = element instanceof ShadowRoot ? element.host : element;
462
+ let elem = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
463
+ if (!elem && node instanceof ShadowRoot) {
464
+ elem = node.host;
465
+ }
466
+
467
+ if (!elem) return null;
468
+
469
+ const element = /** @type {Element} */ (elem);
463
470
  const selector = getNodeSelector(element);
464
471
 
465
472
  // Create an id that will be unique across all execution contexts.
@@ -196,9 +196,10 @@ export class TraceProcessor {
196
196
  *
197
197
  * @param {LH.TraceEvent[]} events
198
198
  * @param {LH.TraceEvent} timeOriginEvent
199
+ * @param {string|undefined} mainFrameId
199
200
  * @return {{lcp: LCPEvent | undefined, invalidated: boolean}}
200
201
  */
201
- static computeValidLCPAllFrames(events: LH.TraceEvent[], timeOriginEvent: LH.TraceEvent): {
202
+ static computeValidLCPAllFrames(events: LH.TraceEvent[], timeOriginEvent: LH.TraceEvent, mainFrameId: string | undefined): {
202
203
  lcp: LCPEvent | undefined;
203
204
  invalidated: boolean;
204
205
  };
@@ -217,7 +218,7 @@ export class TraceProcessor {
217
218
  * @param {LH.Trace} trace
218
219
  * @param {{timeOriginDeterminationMethod?: TimeOriginDeterminationMethod}} [options]
219
220
  * @return {LH.Artifacts.ProcessedTrace}
220
- */
221
+ */
221
222
  static processTrace(trace: LH.Trace, options?: {
222
223
  timeOriginDeterminationMethod?: TimeOriginDeterminationMethod;
223
224
  }): LH.Artifacts.ProcessedTrace;
@@ -226,7 +227,7 @@ export class TraceProcessor {
226
227
  * origin in addition to the standard microsecond monotonic timestamps.
227
228
  * @param {LH.Artifacts.ProcessedTrace} processedTrace
228
229
  * @return {LH.Artifacts.ProcessedNavigation}
229
- */
230
+ */
230
231
  static processNavigation(processedTrace: LH.Artifacts.ProcessedTrace): LH.Artifacts.ProcessedNavigation;
231
232
  /**
232
233
  * Computes the last observable timestamp in a set of trace events.
@@ -272,7 +273,7 @@ export class TraceProcessor {
272
273
  * in addition to the standard microsecond monotonic timestamps.
273
274
  * @param {Array<LH.TraceEvent>} frameEvents
274
275
  * @param {{timeOriginEvt: LH.TraceEvent}} options
275
- */
276
+ */
276
277
  static computeNavigationTimingsForFrame(frameEvents: Array<LH.TraceEvent>, options: {
277
278
  timeOriginEvt: LH.TraceEvent;
278
279
  }): {
@@ -349,7 +349,7 @@ class TraceProcessor {
349
349
 
350
350
  const ret = this.getMainThreadTopLevelEventDurations(events, startTime, endTime);
351
351
  return this._riskPercentiles(ret.durations, totalTime, percentiles,
352
- ret.clippedLength);
352
+ ret.clippedLength);
353
353
  }
354
354
 
355
355
  /**
@@ -479,9 +479,9 @@ class TraceProcessor {
479
479
  const firstResourceSendEvt = events.find(e => e.name === 'ResourceSendRequest');
480
480
  // We know that these properties exist if we found the events, but TSC doesn't.
481
481
  if (navStartEvt?.args?.data &&
482
- firstResourceSendEvt &&
483
- firstResourceSendEvt.pid === navStartEvt.pid &&
484
- firstResourceSendEvt.tid === navStartEvt.tid) {
482
+ firstResourceSendEvt &&
483
+ firstResourceSendEvt.pid === navStartEvt.pid &&
484
+ firstResourceSendEvt.tid === navStartEvt.tid) {
485
485
  const frameId = navStartEvt.args.frame;
486
486
  if (frameId) {
487
487
  return {
@@ -552,9 +552,9 @@ class TraceProcessor {
552
552
  */
553
553
  static isScheduleableTask(evt) {
554
554
  return evt.name === SCHEDULABLE_TASK_TITLE_LH ||
555
- evt.name === SCHEDULABLE_TASK_TITLE_ALT1 ||
556
- evt.name === SCHEDULABLE_TASK_TITLE_ALT2 ||
557
- evt.name === SCHEDULABLE_TASK_TITLE_ALT3;
555
+ evt.name === SCHEDULABLE_TASK_TITLE_ALT1 ||
556
+ evt.name === SCHEDULABLE_TASK_TITLE_ALT2 ||
557
+ evt.name === SCHEDULABLE_TASK_TITLE_ALT3;
558
558
  }
559
559
 
560
560
  /**
@@ -563,7 +563,7 @@ class TraceProcessor {
563
563
  */
564
564
  static isLCPEvent(evt) {
565
565
  if (evt.name !== 'largestContentfulPaint::Invalidate' &&
566
- evt.name !== 'largestContentfulPaint::Candidate') return false;
566
+ evt.name !== 'largestContentfulPaint::Candidate') return false;
567
567
  return Boolean(evt.args?.frame);
568
568
  }
569
569
 
@@ -602,9 +602,10 @@ class TraceProcessor {
602
602
  *
603
603
  * @param {LH.TraceEvent[]} events
604
604
  * @param {LH.TraceEvent} timeOriginEvent
605
+ * @param {string|undefined} mainFrameId
605
606
  * @return {{lcp: LCPEvent | undefined, invalidated: boolean}}
606
607
  */
607
- static computeValidLCPAllFrames(events, timeOriginEvent) {
608
+ static computeValidLCPAllFrames(events, timeOriginEvent, mainFrameId) {
608
609
  const lcpEvents = events.filter(this.isLCPEvent).reverse();
609
610
 
610
611
  /** @type {Map<string, LCPEvent>} */
@@ -628,6 +629,53 @@ class TraceProcessor {
628
629
  }
629
630
  }
630
631
 
632
+ // If no standard LCP candidate is found, try the UKM AllFramesEvents.
633
+ if (!maxLcpAcrossFrames) {
634
+ const ukmEvents = events.filter(
635
+ (e) =>
636
+ e.name.includes('LargestContentfulPaint') && e.name.includes('UKM')
637
+ );
638
+
639
+ // In the rare cases this whole fallback is necessary, the
640
+ // NavStartToLargestContentfulPaint::Candidate::AllFrames::UKM events are missing too.
641
+ // As a result, the only useful signal left is the AllFrames invalidates.
642
+ // Not ideal since they are 1 paint behind, but.. better than the dreaded
643
+ // NO_LCP error
644
+ const targetEventName =
645
+ 'NavStartToLargestContentfulPaint::Invalidate::AllFrames::UKM';
646
+ const ukmInvalidates = ukmEvents.filter((e) => e.name === targetEventName);
647
+
648
+ if (ukmInvalidates.length > 0) {
649
+ ukmInvalidates.sort((a, b) => a.ts - b.ts);
650
+ const lastInvalidate = ukmInvalidates[ukmInvalidates.length - 1];
651
+
652
+ log.warn(
653
+ 'TraceProcessor',
654
+ 'LCP candidate missing, falling back to UKM Invalidate event.'
655
+ );
656
+
657
+ // Construct a mock LCP candidate event
658
+ maxLcpAcrossFrames = /** @type {LCPCandidateEvent} */ (
659
+ /** @type {unknown} */ ({
660
+ name: 'largestContentfulPaint::Candidate',
661
+ cat: 'loading',
662
+ ph: lastInvalidate.ph,
663
+ ts: lastInvalidate.ts,
664
+ pid: lastInvalidate.pid,
665
+ tid: lastInvalidate.tid,
666
+ args: {
667
+ frame: mainFrameId || 'main_frame', // Mocked frame ID
668
+ data: {
669
+ size: 1, // Don't know the actuall size so we assign it 1
670
+ isMainFrame: true,
671
+ isOutermostMainFrame: true,
672
+ },
673
+ },
674
+ })
675
+ );
676
+ }
677
+ }
678
+
631
679
  return {
632
680
  lcp: maxLcpAcrossFrames,
633
681
  // LCP events were found, but final LCP event of every frame was an invalidate event.
@@ -669,7 +717,7 @@ class TraceProcessor {
669
717
  * @param {LH.Trace} trace
670
718
  * @param {{timeOriginDeterminationMethod?: TimeOriginDeterminationMethod}} [options]
671
719
  * @return {LH.Artifacts.ProcessedTrace}
672
- */
720
+ */
673
721
  static processTrace(trace, options) {
674
722
  const {timeOriginDeterminationMethod = 'auto'} = options || {};
675
723
 
@@ -677,9 +725,9 @@ class TraceProcessor {
677
725
  // *must* be stable to keep events correctly nested.
678
726
  const keyEvents = this.filteredTraceSort(trace.traceEvents, e => {
679
727
  return e.cat.includes('blink.user_timing') ||
680
- e.cat.includes('loading') ||
681
- e.cat.includes('devtools.timeline') ||
682
- e.cat === '__metadata';
728
+ e.cat.includes('loading') ||
729
+ e.cat.includes('devtools.timeline') ||
730
+ e.cat === '__metadata';
683
731
  });
684
732
 
685
733
  // Find the inspected frame
@@ -699,7 +747,7 @@ class TraceProcessor {
699
747
  // Begin collection of frame tree information with TracingStartedInBrowser,
700
748
  // which should be present even without navigations.
701
749
  const tracingStartedFrames = keyEvents
702
- .find(e => e.name === 'TracingStartedInBrowser')?.args?.data?.frames;
750
+ .find(e => e.name === 'TracingStartedInBrowser')?.args?.data?.frames;
703
751
  if (tracingStartedFrames) {
704
752
  for (const frame of tracingStartedFrames) {
705
753
  framesById.set(frame.frame, {
@@ -738,12 +786,18 @@ class TraceProcessor {
738
786
  // Filter to just events matching the main frame ID, just to make sure.
739
787
  /** @param {LH.TraceEvent} e */
740
788
  function associatedToMainFrame(e) {
789
+ if (e.name === 'NavStartToLargestContentfulPaint::Invalidate::AllFrames::UKM') {
790
+ return true;
791
+ }
741
792
  const frameId = TraceProcessor.getFrameId(e);
742
793
  return frameId === mainFrameInfo.frameId;
743
794
  }
744
795
 
745
796
  /** @param {LH.TraceEvent} e */
746
797
  function associatedToAllFrames(e) {
798
+ if (e.name.includes('LargestContentfulPaint') && e.name.includes('UKM')) {
799
+ return true;
800
+ }
747
801
  const frameId = TraceProcessor.getFrameId(e);
748
802
  return frameId ? inspectedTreeFrameIds.includes(frameId) : false;
749
803
  }
@@ -805,9 +859,16 @@ class TraceProcessor {
805
859
  * origin in addition to the standard microsecond monotonic timestamps.
806
860
  * @param {LH.Artifacts.ProcessedTrace} processedTrace
807
861
  * @return {LH.Artifacts.ProcessedNavigation}
808
- */
862
+ */
809
863
  static processNavigation(processedTrace) {
810
- const {frameEvents, frameTreeEvents, timeOriginEvt, timings, timestamps} = processedTrace;
864
+ const {
865
+ frameEvents,
866
+ frameTreeEvents,
867
+ timeOriginEvt,
868
+ timings,
869
+ timestamps,
870
+ mainFrameInfo,
871
+ } = processedTrace;
811
872
 
812
873
  // Compute the key frame timings for the main frame.
813
874
  const frameTimings = this.computeNavigationTimingsForFrame(frameEvents, {timeOriginEvt});
@@ -822,7 +883,11 @@ class TraceProcessor {
822
883
  }
823
884
 
824
885
  // Compute LCP for all frames.
825
- const lcpAllFramesEvt = this.computeValidLCPAllFrames(frameTreeEvents, timeOriginEvt).lcp;
886
+ const lcpAllFramesEvt = this.computeValidLCPAllFrames(
887
+ frameTreeEvents,
888
+ timeOriginEvt,
889
+ mainFrameInfo.frameId
890
+ ).lcp;
826
891
 
827
892
  /** @param {number} ts */
828
893
  const getTiming = ts => (ts - timeOriginEvt.ts) / 1000;
@@ -950,7 +1015,7 @@ class TraceProcessor {
950
1015
  * in addition to the standard microsecond monotonic timestamps.
951
1016
  * @param {Array<LH.TraceEvent>} frameEvents
952
1017
  * @param {{timeOriginEvt: LH.TraceEvent}} options
953
- */
1018
+ */
954
1019
  static computeNavigationTimingsForFrame(frameEvents, options) {
955
1020
  const {timeOriginEvt} = options;
956
1021
 
@@ -967,7 +1032,8 @@ class TraceProcessor {
967
1032
  }
968
1033
 
969
1034
  // This function accepts events spanning multiple frames, but this usage will only provide events from the main frame.
970
- const lcpResult = this.computeValidLCPAllFrames(frameEvents, timeOriginEvt);
1035
+ const frameId = frameEvents.map(e => TraceProcessor.getFrameId(e)).find(Boolean);
1036
+ const lcpResult = this.computeValidLCPAllFrames(frameEvents, timeOriginEvt, frameId);
971
1037
 
972
1038
  const load = frameEvents.find(e => e.name === 'loadEventEnd' && e.ts > timeOriginEvt.ts);
973
1039
  const domContentLoaded = frameEvents.find(