UNPKG

chrome-devtools-frontend

Version:
614 lines (514 loc) • 23.1 kB
// Copyright 2024 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../../core/common/common.js'; import * as Platform from '../../core/platform/platform.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as EmulationModel from '../../models/emulation/emulation.js'; import {createTarget, updateHostConfig} from '../../testing/EnvironmentHelpers.js'; import {describeWithMockConnection} from '../../testing/MockConnection.js'; import * as CrUXManager from './crux-manager.js'; const {urlString} = Platform.DevToolsPath; function mockResponse(scopes: {pageScope: CrUXManager.PageScope, deviceScope: CrUXManager.DeviceScope}|null = null): CrUXManager.CrUXResponse { return { record: { key: {}, metrics: { largest_contentful_paint: { histogram: [ {start: 0, end: 2500, density: 0.5}, {start: 2500, end: 4000, density: 0.3}, {start: 4000, density: 0.2}, ], percentiles: {p75: 1000}, // @ts-expect-error testScopes: scopes, }, cumulative_layout_shift: { histogram: [ {start: 0, end: 0.1, density: 0.1}, {start: 0.1, end: 0.25, density: 0.1}, {start: 0.25, density: 0.8}, ], percentiles: {p75: 0.25}, // @ts-expect-error testScopes: scopes, }, }, collectionPeriod: { firstDate: {year: 2024, month: 1, day: 1}, lastDate: {year: 2024, month: 1, day: 29}, }, }, }; } async function triggerMicroTaskQueue(): Promise<void> { await new Promise(resolve => setTimeout(resolve, 0)); } describeWithMockConnection('CrUXManager', () => { let cruxManager: CrUXManager.CrUXManager; let target: SDK.Target.Target; let resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel; let mockFetch: sinon.SinonStub; let mockConsoleError: sinon.SinonStub; beforeEach(async () => { const tabTarget = createTarget({type: SDK.Target.Type.TAB}); target = createTarget({parentTarget: tabTarget}); resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel; cruxManager = CrUXManager.CrUXManager.instance({forceNew: true}); mockFetch = sinon.stub(window, 'fetch'); mockConsoleError = sinon.stub(console, 'error'); EmulationModel.DeviceModeModel.DeviceModeModel.instance({forceNew: true}); }); afterEach(() => { mockFetch.restore(); mockConsoleError.restore(); cruxManager.getConfigSetting().set({enabled: false}); }); describe('storing the user consent', () => { it('uses global storage if the user is not in an OffTheRecord profile', async () => { updateHostConfig({isOffTheRecord: false}); const dummyStorage = new Common.Settings.SettingsStorage({}); const globalStorage = new Common.Settings.SettingsStorage({}); Common.Settings.Settings.instance({ forceNew: true, syncedStorage: dummyStorage, globalStorage, localStorage: dummyStorage, }); const manager = CrUXManager.CrUXManager.instance({forceNew: true}); manager.getConfigSetting().set({enabled: true}); assert.isTrue(globalStorage.has(manager.getConfigSetting().name)); }); it('uses session storage if the user is in an OffTheRecord profile', async () => { updateHostConfig({isOffTheRecord: true}); const dummyStorage = new Common.Settings.SettingsStorage({}); Common.Settings.Settings.instance({ forceNew: true, syncedStorage: dummyStorage, globalStorage: dummyStorage, localStorage: dummyStorage, }); const manager = CrUXManager.CrUXManager.instance({forceNew: true}); manager.getConfigSetting().set({enabled: true}); // SessionStorage is created and managed internally to the Settings // class, and is a private instance variable, so we cannot actually // assert that it contains the value. Best we can do here is to assert // that it did not use the dummy storage, which means that it must have // used session storage as those are the 4 available storage types. assert.isFalse(dummyStorage.has(manager.getConfigSetting().name)); }); }); it('isEnabled() returns if the user has consented)', async () => { const manager = CrUXManager.CrUXManager.instance({forceNew: true}); manager.getConfigSetting().set({enabled: true}); assert.isTrue(manager.isEnabled()); manager.getConfigSetting().set({enabled: false}); assert.isFalse(manager.isEnabled()); }); describe('getFieldDataForPage', () => { it('should request data for all scopes', async () => { mockFetch.callsFake(async () => new Response(JSON.stringify(mockResponse()), { status: 200, })); const pageResult = await cruxManager.getFieldDataForPage('https://example.com'); const fetchBodies = mockFetch.getCalls() .map(call => call.args[1].body) .sort() .map(body => JSON.parse(body) as CrUXManager.CrUXRequest); assert.deepEqual(pageResult, { 'origin-ALL': mockResponse(), 'origin-DESKTOP': mockResponse(), 'origin-PHONE': mockResponse(), 'origin-TABLET': null, 'url-ALL': mockResponse(), 'url-DESKTOP': mockResponse(), 'url-PHONE': mockResponse(), 'url-TABLET': null, warnings: [], }); assert.deepEqual(fetchBodies, [ { formFactor: 'DESKTOP', metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], origin: 'https://example.com', }, { formFactor: 'PHONE', metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], origin: 'https://example.com', }, { metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], origin: 'https://example.com', }, { formFactor: 'DESKTOP', metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], url: 'https://example.com/', }, { formFactor: 'PHONE', metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], url: 'https://example.com/', }, { metrics: [ 'first_contentful_paint', 'largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint', 'round_trip_time', 'form_factors', 'largest_contentful_paint_image_time_to_first_byte', 'largest_contentful_paint_image_resource_load_delay', 'largest_contentful_paint_image_resource_load_duration', 'largest_contentful_paint_image_element_render_delay', ], url: 'https://example.com/', }, ]); }); it('should return null for all scopes if no data is found', async () => { mockFetch.callsFake(async () => new Response('{"error": {"status": "NOT_FOUND"}}', { status: 404, })); const pageResult = await cruxManager.getFieldDataForPage('https://example.com'); assert.deepEqual(pageResult, { 'origin-ALL': null, 'origin-DESKTOP': null, 'origin-PHONE': null, 'origin-TABLET': null, 'url-ALL': null, 'url-DESKTOP': null, 'url-PHONE': null, 'url-TABLET': null, warnings: [], }); }); it('should cache responses', async () => { mockFetch.callsFake(async () => new Response(JSON.stringify(mockResponse()), { status: 200, })); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 6); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 6); }); it('should cache "NOT_FOUND" responses', async () => { mockFetch.callsFake(async () => new Response('{"error": {"status": "NOT_FOUND"}}', { status: 404, })); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 6); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 6); }); it('should not cache error responses', async () => { mockFetch.callsFake(async () => new Response('', { status: 500, })); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 6); sinon.assert.callCount(mockConsoleError, 6); await cruxManager.getFieldDataForPage('https://example.com'); sinon.assert.callCount(mockFetch, 12); sinon.assert.callCount(mockConsoleError, 12); }); it('should ignore hash and search params for caching', async () => { mockFetch.callsFake(async () => new Response(JSON.stringify(mockResponse()), { status: 200, })); await cruxManager.getFieldDataForPage('https://example.com#hash'); sinon.assert.callCount(mockFetch, 6); await cruxManager.getFieldDataForPage('https://example.com?search'); sinon.assert.callCount(mockFetch, 6); }); it('should exit early for localhost and non-public URLs', async () => { mockFetch.callsFake(async () => new Response(JSON.stringify(mockResponse()), { status: 200, })); await cruxManager.getFieldDataForPage('https://localhost:8080/'); await cruxManager.getFieldDataForPage('https://127.0.0.1:8000/'); await cruxManager.getFieldDataForPage('about:blank'); await cruxManager.getFieldDataForPage('chrome://tracing'); await cruxManager.getFieldDataForPage('chrome-extension://sdkfsddsdsisdof/dashboard.html'); sinon.assert.callCount(mockFetch, 0); }); }); describe('getFieldDataForCurrentPage', () => { let getFieldDataMock: sinon.SinonStub; beforeEach(() => { getFieldDataMock = sinon.stub(cruxManager, 'getFieldDataForPage'); getFieldDataMock.resolves({ 'origin-ALL': mockResponse({pageScope: 'origin', deviceScope: 'ALL'}), 'origin-DESKTOP': mockResponse({pageScope: 'origin', deviceScope: 'DESKTOP'}), 'origin-PHONE': mockResponse({pageScope: 'origin', deviceScope: 'PHONE'}), 'origin-TABLET': null, 'url-ALL': mockResponse({pageScope: 'url', deviceScope: 'ALL'}), 'url-DESKTOP': mockResponse({pageScope: 'url', deviceScope: 'DESKTOP'}), 'url-PHONE': mockResponse({pageScope: 'url', deviceScope: 'PHONE'}), 'url-TABLET': null, warnings: [], }); }); afterEach(() => { getFieldDataMock.restore(); }); it('should use main document URL if available', async () => { resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, { url: 'https://example.com/main/', isPrimaryFrame: () => true, } as SDK.ResourceTreeModel.ResourceTreeFrame); resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, { url: 'https://example.com/frame/', isPrimaryFrame: () => false, } as SDK.ResourceTreeModel.ResourceTreeFrame); const result = await cruxManager.getFieldDataForCurrentPageForTesting(); assert.deepEqual(result.warnings, []); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/main/'); }); it('should use URL override if set', async () => { target.setInspectedURL(urlString`https://example.com/inspected`); cruxManager.getConfigSetting().set( {enabled: false, override: 'https://example.com/override', overrideEnabled: true}); const result = await cruxManager.getFieldDataForCurrentPageForTesting(); assert.deepEqual(result.warnings, ['Field metrics are configured for a different URL than the current page.']); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/override'); }); it('should use origin map if set', async () => { target.setInspectedURL(urlString`http://localhost:8080/inspected?param`); cruxManager.getConfigSetting().set({ enabled: false, originMappings: [{ developmentOrigin: 'http://localhost:8080', productionOrigin: 'https://example.com', }], }); const result = await cruxManager.getFieldDataForCurrentPageForTesting(); assert.deepEqual(result.warnings, ['Field metrics are configured for a different URL than the current page.']); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/inspected'); }); it('should not use origin map if URL override is set', async () => { target.setInspectedURL(urlString`http://localhost:8080/inspected?param`); cruxManager.getConfigSetting().set({ enabled: false, override: 'https://google.com', overrideEnabled: true, originMappings: [{ developmentOrigin: 'http://localhost:8080', productionOrigin: 'https://example.com', }], }); const result = await cruxManager.getFieldDataForCurrentPageForTesting(); assert.deepEqual(result.warnings, ['Field metrics are configured for a different URL than the current page.']); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://google.com'); }); it('should use inspected URL if main document is unavailable', async () => { target.setInspectedURL(urlString`https://example.com/inspected`); const result = await cruxManager.getFieldDataForCurrentPageForTesting(); assert.deepEqual(result.warnings, []); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/inspected'); }); it('should wait for inspected URL if main document and inspected URL are unavailable', async () => { target.setInspectedURL(Platform.DevToolsPath.EmptyUrlString); const finishPromise = cruxManager.getFieldDataForCurrentPageForTesting(); await triggerMicroTaskQueue(); target.setInspectedURL(urlString`https://example.com/awaitInspected`); const result = await finishPromise; assert.deepEqual(result.warnings, []); sinon.assert.callCount(getFieldDataMock, 1); assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/awaitInspected'); }); it('getSelectedFieldMetricData - should take from selected page scope', async () => { cruxManager.getConfigSetting().set({enabled: true}); await cruxManager.refresh(); let data: CrUXManager.MetricResponse|undefined; cruxManager.fieldPageScope = 'origin'; data = cruxManager.getSelectedFieldMetricData('largest_contentful_paint'); assert.strictEqual(data?.percentiles?.p75, 1000); // @ts-expect-error assert.strictEqual(data?.testScopes.pageScope, 'origin'); cruxManager.fieldPageScope = 'url'; data = cruxManager.getSelectedFieldMetricData('largest_contentful_paint'); assert.strictEqual(data?.percentiles?.p75, 1000); // @ts-expect-error assert.strictEqual(data?.testScopes.pageScope, 'url'); }); it('should take from selected device scope', async () => { cruxManager.getConfigSetting().set({enabled: true}); await cruxManager.refresh(); cruxManager.fieldPageScope = 'url'; let data: CrUXManager.MetricResponse|undefined; cruxManager.fieldDeviceOption = 'ALL'; data = cruxManager.getSelectedFieldMetricData('largest_contentful_paint'); assert.strictEqual(data?.percentiles?.p75, 1000); // @ts-expect-error assert.strictEqual(data?.testScopes?.deviceScope, 'ALL'); cruxManager.fieldDeviceOption = 'PHONE'; data = cruxManager.getSelectedFieldMetricData('largest_contentful_paint'); assert.strictEqual(data?.percentiles?.p75, 1000); // @ts-expect-error assert.strictEqual(data?.testScopes?.deviceScope, 'PHONE'); }); it('auto device option should chose based on emulation', async () => { cruxManager.fieldDeviceOption = 'AUTO'; assert.strictEqual(cruxManager.getSelectedDeviceScope(), 'ALL'); cruxManager.getConfigSetting().set({enabled: true}); await cruxManager.refresh(); assert.strictEqual(cruxManager.getSelectedDeviceScope(), 'DESKTOP'); for (const device of EmulationModel.EmulatedDevices.EmulatedDevicesList.instance().standard()) { if (device.title === 'Moto G Power') { EmulationModel.DeviceModeModel.DeviceModeModel.instance().emulate( EmulationModel.DeviceModeModel.Type.Device, device, device.modes[0], 1); } } assert.strictEqual(cruxManager.getSelectedDeviceScope(), 'PHONE'); }); }); describe('automatic refresh', () => { let getFieldDataMock: sinon.SinonStub; let eventBodies: Array<CrUXManager.PageResult|undefined> = []; beforeEach(() => { eventBodies = []; cruxManager.addEventListener(CrUXManager.Events.FIELD_DATA_CHANGED, event => { eventBodies.push(event.data); }); getFieldDataMock = sinon.stub(cruxManager, 'getFieldDataForPage'); getFieldDataMock.resolves({ 'origin-ALL': null, 'origin-DESKTOP': null, 'origin-PHONE': null, 'origin-TABLET': null, 'url-ALL': null, 'url-DESKTOP': null, 'url-PHONE': null, 'url-TABLET': null, warnings: [], }); }); afterEach(() => { getFieldDataMock.restore(); }); it('should update when enabled setting changes', async () => { const setting = cruxManager.getConfigSetting(); setting.set({enabled: true}); await triggerMicroTaskQueue(); sinon.assert.callCount(getFieldDataMock, 1); assert.lengthOf(eventBodies, 2); assert.isUndefined(eventBodies[0]); assert.isObject(eventBodies[1]); setting.set({enabled: false}); await triggerMicroTaskQueue(); sinon.assert.callCount(getFieldDataMock, 1); assert.lengthOf(eventBodies, 3); assert.isUndefined(eventBodies[0]); assert.isObject(eventBodies[1]); assert.isUndefined(eventBodies[2]); }); it('should trigger on frame navigation if enabled', async () => { const setting = cruxManager.getConfigSetting(); setting.set({enabled: true}); await triggerMicroTaskQueue(); resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, { url: 'https://example.com/main/', isPrimaryFrame: () => true, } as SDK.ResourceTreeModel.ResourceTreeFrame); await triggerMicroTaskQueue(); sinon.assert.callCount(getFieldDataMock, 2); assert.lengthOf(eventBodies, 4); assert.isUndefined(eventBodies[0]); assert.isObject(eventBodies[1]); assert.isUndefined(eventBodies[2]); assert.isObject(eventBodies[3]); }); it('should trigger when URL override set', async () => { const setting = cruxManager.getConfigSetting(); setting.set({enabled: true}); await triggerMicroTaskQueue(); setting.set({enabled: true, override: 'https://example.com/override', overrideEnabled: true}); await triggerMicroTaskQueue(); sinon.assert.callCount(getFieldDataMock, 2); assert.lengthOf(eventBodies, 4); assert.isUndefined(eventBodies[0]); assert.isObject(eventBodies[1]); assert.isUndefined(eventBodies[2]); assert.isObject(eventBodies[3]); }); it('should not trigger on frame navigation if disabled', async () => { const setting = cruxManager.getConfigSetting(); setting.set({enabled: false}); resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.FrameNavigated, { url: 'https://example.com/main/', isPrimaryFrame: () => true, } as SDK.ResourceTreeModel.ResourceTreeFrame); await triggerMicroTaskQueue(); sinon.assert.callCount(getFieldDataMock, 0); assert.lengthOf(eventBodies, 2); assert.isUndefined(eventBodies[0]); assert.isUndefined(eventBodies[1]); }); }); });