chromedevtools--chrome-devtools-mcp
b4546ef86b
release-please / release-please (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 26 (push) Has been cancelled
Check code before submitting / [Required] Check correct format (push) Has been cancelled
Check code before submitting / [Required] Check docs updated (push) Has been cancelled
Compile and run tests / [Required] Tests passed (push) Has been cancelled
Assortment of various flakiness conditions found running tests in a loop locally: This PR introduces a comprehensive set of hermetic retry layers and aggressive timeout handlers across the test suite to insulate it from random Chromium startup hangs, CDP deadlocks, and Puppeteer lifecycle flakes. It guarantees that temporary browser infrastructure failures are automatically retried without failing the CI, while actual code assertion failures still fail fast. ### Test Harness & Retry Improvements • tests/utils.ts: Rewrote withBrowser to include a 30-second internal timeout and a 3-attempt retry loop. If Chromium locks up or disconnects (Target closed / socket hang up), the browser is forcibly evicted (via SIGKILL if browser.close() hangs) and the test setup is cleanly retried. • tests/index.test.ts: Wrapped withClient (used by E2E tests) in a 3-attempt retry loop to handle the daemon/Chromium hanging during launch and triggering the 60-second MCP client timeout. • tests/browser.test.ts: Added a safeClose helper that imposes a 2-second timeout before SIGKILLing browsers, and wrapped raw Puppeteer tests in runWithRetry to handle startup hangs. • tests/shutdown.test.ts: Added a setupServerWithRetry helper to prevent random 60s RPC timeouts when the server's Chrome instance hangs during boot. ### Flaky Operations & Navigation Fixes • src/tools/performance.ts & tests/tools/performance.test.ts: Replaced the notoriously flaky waitUntil: ['networkidle0'] with 'load' when navigating to about:blank in performance_start_trace. This prevents random 10-second Navigation timeout exceeded errors. Also stubbed goto in the associated unit tests for better hermeticity. • src/McpContext.ts: Wrapped browser.installExtension() with a 15-second timeout to prevent deadlocks when an extension fails to load. • tests/tools/extensions.test.ts: Removed flaky headless UI navigations to chrome://extensions in favor of using the context.listExtensions() API. • tests/tools/pages.test.js.snapshot: Synced test snapshots to reflect updated environment baselines.
576 行
20 KiB
TypeScript
576 行
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import {describe, it, afterEach, beforeEach} from 'node:test';
|
|
import zlib from 'node:zlib';
|
|
|
|
import sinon from 'sinon';
|
|
|
|
import {
|
|
analyzeInsight,
|
|
startTrace,
|
|
stopTrace,
|
|
} from '../../src/tools/performance.js';
|
|
import type {TraceResult} from '../../src/trace-processing/parse.js';
|
|
import {
|
|
parseRawTraceBuffer,
|
|
traceResultIsSuccess,
|
|
} from '../../src/trace-processing/parse.js';
|
|
import {loadTraceAsBuffer} from '../trace-processing/fixtures/load.js';
|
|
import {withMcpContext} from '../utils.js';
|
|
|
|
describe('performance', () => {
|
|
afterEach(() => {
|
|
sinon.restore();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
sinon.stub(globalThis, 'fetch').callsFake(async url => {
|
|
const cruxEndpoint =
|
|
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
|
if (url.toString().startsWith(cruxEndpoint)) {
|
|
return new Response(JSON.stringify(cruxResponseFixture()), {
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch to ${url}`);
|
|
});
|
|
});
|
|
|
|
describe('performance_start_trace', () => {
|
|
it('starts a trace recording', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(false);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
sinon.stub(selectedPage, 'goto').resolves(null);
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
assert.ok(context.isRunningPerformanceTrace());
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace is being recorded/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('can navigate to about:blank and record a page reload', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
const gotoStub = sinon.stub(selectedPage, 'goto');
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
sinon.assert.calledWithExactly(gotoStub, 'about:blank', {
|
|
waitUntil: 'load',
|
|
});
|
|
sinon.assert.calledWithExactly(gotoStub, 'https://www.test.com', {
|
|
waitUntil: ['load'],
|
|
});
|
|
assert.ok(context.isRunningPerformanceTrace());
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace is being recorded/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('can autostop and store a recording', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
sinon.stub(selectedPage, 'goto').callsFake(() => Promise.resolve(null));
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.callsFake(() => {
|
|
return Promise.resolve(rawData);
|
|
});
|
|
|
|
const clock = sinon.useFakeTimers();
|
|
const handlerPromise = startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: true},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
// In the handler we wait 5 seconds after the page load event (which is
|
|
// what DevTools does), hence we now fake-progress time to allow
|
|
// the handler to complete. We allow extra time because the Trace
|
|
// Engine also uses some timers to yield updates and we need those to
|
|
// execute.
|
|
await clock.tickAsync(6_000);
|
|
await handlerPromise;
|
|
clock.restore();
|
|
|
|
sinon.assert.calledOnce(startTracingStub);
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
assert.strictEqual(
|
|
context.isRunningPerformanceTrace(),
|
|
false,
|
|
'Tracing was stopped',
|
|
);
|
|
assert.strictEqual(context.recordedTraces().length, 1);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/The performance trace has been stopped/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('errors if a recording is already active', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const startTracingStub = sinon.stub(selectedPage.tracing, 'start');
|
|
await startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: false},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.notCalled(startTracingStub);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(/a performance trace is already running/),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('supports filePath', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
// rawData is the decompressed buffer (based on loadTraceAsBuffer implementation).
|
|
// We want to simulate saving it as a .gz file, so the tool should compress it.
|
|
const expectedCompressedData = zlib.gzipSync(rawData);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = 'test-trace.json.gz';
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage, 'url').callsFake(() => 'https://www.test.com');
|
|
sinon.stub(selectedPage, 'goto').callsFake(() => Promise.resolve(null));
|
|
sinon.stub(selectedPage.tracing, 'start');
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
const saveFileStub = sinon
|
|
.stub(context, 'saveFile')
|
|
.resolves({filename: filePath});
|
|
|
|
const handlerPromise = startTrace.handler(
|
|
{
|
|
params: {reload: true, autoStop: true, filePath},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
// In the handler we wait 5 seconds after the page load event (which is
|
|
// what DevTools does), hence we now fake-progress time to allow
|
|
// the handler to complete. We allow extra time because the Trace
|
|
// Engine also uses some timers to yield updates and we need those to
|
|
// execute.
|
|
await handlerPromise;
|
|
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
`The raw trace data was saved to ${filePath}.`,
|
|
),
|
|
);
|
|
sinon.assert.calledOnce(saveFileStub);
|
|
const [savedData, savedPath] = saveFileStub.firstCall.args;
|
|
assert.strictEqual(savedPath, filePath);
|
|
// Compare the saved data with expected compressed data
|
|
// We can't compare buffers directly with strictEqual easily if they are different instances, but deepStrictEqual works for Buffers.
|
|
assert.deepStrictEqual(savedData, expectedCompressedData);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('performance_analyze_insight', () => {
|
|
async function parseTrace(fileName: string): Promise<TraceResult> {
|
|
const rawData = loadTraceAsBuffer(fileName);
|
|
const result = await parseRawTraceBuffer(rawData);
|
|
if (!traceResultIsSuccess(result)) {
|
|
assert.fail(`Unexpected trace parse error: ${result.error}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
it('returns the information on the insight', async () => {
|
|
const trace = await parseTrace('web-dev-with-commit.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.storeTraceRecording(trace);
|
|
context.setIsRunningPerformanceTrace(false);
|
|
|
|
await analyzeInsight.handler(
|
|
{
|
|
params: {
|
|
insightSetId: 'NAVIGATION_0',
|
|
insightName: 'LCPBreakdown',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.ok(response.attachedTracedInsight);
|
|
});
|
|
});
|
|
|
|
it('returns an error if no trace has been recorded', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await analyzeInsight.handler(
|
|
{
|
|
params: {
|
|
insightSetId: '8463DF94CD61B265B664E7F768183DE3',
|
|
insightName: 'LCPBreakdown',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(
|
|
response.responseLines
|
|
.join('\n')
|
|
.match(
|
|
/No recorded traces found. Record a performance trace so you have Insights to analyze./,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('performance_stop_trace', () => {
|
|
it('does nothing if the trace is not running and does not error', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(false);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon.stub(selectedPage.tracing, 'stop');
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
sinon.assert.notCalled(stopTracingStub);
|
|
assert.strictEqual(context.isRunningPerformanceTrace(), false);
|
|
});
|
|
});
|
|
|
|
it('will stop the trace and return trace info when a trace is running', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.callsFake(async () => {
|
|
return rawData;
|
|
});
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
'The performance trace has been stopped.',
|
|
),
|
|
);
|
|
assert.strictEqual(context.recordedTraces().length, 1);
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
});
|
|
});
|
|
|
|
it('throws an error if parsing the trace buffer fails', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.returns(Promise.resolve(undefined));
|
|
|
|
await assert.rejects(
|
|
stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
),
|
|
/There was an unexpected error parsing the trace/,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('supports filePath', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
const filePath = 'test-trace.json';
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
const stopTracingStub = sinon
|
|
.stub(selectedPage.tracing, 'stop')
|
|
.resolves(rawData);
|
|
const saveFileStub = sinon
|
|
.stub(context, 'saveFile')
|
|
.resolves({filename: filePath});
|
|
|
|
await stopTrace.handler(
|
|
{params: {filePath}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
sinon.assert.calledOnce(stopTracingStub);
|
|
sinon.assert.calledOnce(saveFileStub);
|
|
sinon.assert.calledWith(saveFileStub, rawData, filePath);
|
|
assert.ok(
|
|
response.responseLines.includes(
|
|
`The raw trace data was saved to ${filePath}.`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not fetch CrUX data if performanceCrux is false', async () => {
|
|
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const cruxEndpoint =
|
|
'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
|
|
const cruxCall = (globalThis.fetch as sinon.SinonStub)
|
|
.getCalls()
|
|
.find(call => call.args[0].toString().startsWith(cruxEndpoint));
|
|
assert.strictEqual(
|
|
cruxCall,
|
|
undefined,
|
|
'CrUX fetch should not have been called',
|
|
);
|
|
},
|
|
{performanceCrux: false},
|
|
);
|
|
});
|
|
|
|
it('fetches CrUX data for desktop and includes it in the summary', async () => {
|
|
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(rawData);
|
|
|
|
const fetchStub = globalThis.fetch as sinon.SinonStub;
|
|
fetchStub.resetHistory();
|
|
fetchStub.callsFake(async (url, options) => {
|
|
const body = options?.body ? JSON.parse(options.body as string) : {};
|
|
const requestedUrl = body.url || body.origin || 'https://web.dev/';
|
|
const lcp = body.formFactor === 'DESKTOP' ? 1000 : 2595;
|
|
return new Response(
|
|
JSON.stringify(cruxResponseFixture(requestedUrl, lcp)),
|
|
{
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
},
|
|
);
|
|
});
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle(context);
|
|
const fullOutput = result.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
assert.ok(fetchStub.called, 'CrUX fetch should have been called');
|
|
assert.ok(
|
|
fullOutput.includes('Metrics (field / real users)'),
|
|
'Summary should include field data',
|
|
);
|
|
assert.ok(
|
|
fullOutput.includes('LCP: 1000 ms'),
|
|
'Summary should include desktop LCP value',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('fetches CrUX data for mobile and includes it in the summary', async () => {
|
|
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
|
|
// Use a unique URL to avoid cache issues
|
|
const jsonString = new TextDecoder().decode(rawData);
|
|
const modifiedJsonString = jsonString.replaceAll(
|
|
'https://web.dev/',
|
|
'https://mobile.web.dev/',
|
|
);
|
|
const modifiedData = new TextEncoder().encode(modifiedJsonString);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
context.setIsRunningPerformanceTrace(true);
|
|
const selectedPage = context.getSelectedMcpPage().pptrPage;
|
|
sinon.stub(selectedPage.tracing, 'stop').resolves(modifiedData);
|
|
|
|
// Emulate mobile
|
|
await context.getSelectedMcpPage().emulate({
|
|
viewport: {
|
|
width: 375,
|
|
height: 667,
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
deviceScaleFactor: 2,
|
|
},
|
|
});
|
|
|
|
const fetchStub = globalThis.fetch as sinon.SinonStub;
|
|
fetchStub.resetHistory();
|
|
fetchStub.callsFake(async (url, options) => {
|
|
const body = options?.body ? JSON.parse(options.body as string) : {};
|
|
const requestedUrl = body.url || body.origin || 'https://web.dev/';
|
|
const lcp = body.formFactor === 'PHONE' ? 2000 : 2595;
|
|
return new Response(
|
|
JSON.stringify(cruxResponseFixture(requestedUrl, lcp)),
|
|
{
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
},
|
|
);
|
|
});
|
|
|
|
await stopTrace.handler(
|
|
{params: {}, page: context.getSelectedMcpPage()},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const result = await response.handle(context);
|
|
const fullOutput = result.content
|
|
.map(c => (c.type === 'text' ? c.text : ''))
|
|
.join('\n');
|
|
|
|
assert.ok(fetchStub.called, 'CrUX fetch should have been called');
|
|
assert.ok(
|
|
fullOutput.includes('Metrics (field / real users)'),
|
|
'Summary should include field data',
|
|
);
|
|
assert.ok(
|
|
fullOutput.includes('LCP: 2000 ms'),
|
|
'Summary should include mobile LCP value',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
function cruxResponseFixture(url = 'https://web.dev/', lcp = 2595) {
|
|
// Ideally we could use `mockResponse` from 'chrome-devtools-frontend/front_end/models/crux-manager/CrUXManager.test.ts'
|
|
// But test files are not published in the cdtf npm package.
|
|
return {
|
|
record: {
|
|
key: {
|
|
url,
|
|
},
|
|
metrics: {
|
|
form_factors: {
|
|
fractions: {desktop: 0.5056, phone: 0.4796, tablet: 0.0148},
|
|
},
|
|
largest_contentful_paint: {
|
|
histogram: [
|
|
{start: 0, end: 2500, density: 0.7309},
|
|
{start: 2500, end: 4000, density: 0.163},
|
|
{start: 4000, density: 0.1061},
|
|
],
|
|
percentiles: {p75: lcp},
|
|
},
|
|
largest_contentful_paint_image_element_render_delay: {
|
|
percentiles: {p75: 786},
|
|
},
|
|
largest_contentful_paint_image_resource_load_delay: {
|
|
percentiles: {p75: 86},
|
|
},
|
|
largest_contentful_paint_image_time_to_first_byte: {
|
|
percentiles: {p75: 1273},
|
|
},
|
|
cumulative_layout_shift: {
|
|
histogram: [
|
|
{start: '0.00', end: '0.10', density: 0.8665},
|
|
{start: '0.10', end: '0.25', density: 0.0716},
|
|
{start: '0.25', density: 0.0619},
|
|
],
|
|
percentiles: {p75: '0.06'},
|
|
},
|
|
interaction_to_next_paint: {
|
|
histogram: [
|
|
{start: 0, end: 200, density: 0.8414},
|
|
{start: 200, end: 500, density: 0.1081},
|
|
{start: 500, density: 0.0505},
|
|
],
|
|
percentiles: {p75: 140},
|
|
},
|
|
largest_contentful_paint_image_resource_load_duration: {
|
|
percentiles: {p75: 451},
|
|
},
|
|
round_trip_time: {
|
|
histogram: [
|
|
{start: 0, end: 75, density: 0.3663},
|
|
{start: 75, end: 275, density: 0.5089},
|
|
{start: 275, density: 0.1248},
|
|
],
|
|
percentiles: {p75: 178},
|
|
},
|
|
first_contentful_paint: {
|
|
histogram: [
|
|
{start: 0, end: 1800, density: 0.5899},
|
|
{start: 1800, end: 3000, density: 0.2439},
|
|
{start: 3000, density: 0.1662},
|
|
],
|
|
percentiles: {p75: 2425},
|
|
},
|
|
},
|
|
collectionPeriod: {
|
|
firstDate: {year: 2025, month: 12, day: 8},
|
|
lastDate: {year: 2026, month: 1, day: 4},
|
|
},
|
|
},
|
|
};
|
|
}
|