blob: b439a59e361f9ef51be48ccac46e7235c5baa743 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chai';
import * as sinon from 'sinon';
import * as Common from '../../../core/common/common.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as Protocol from '../../../generated/protocol.js';
import {raf, renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
import {cleanTestDOM} from '../../../testing/DOMHooks.js';
import {createTarget, describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js';
import {MockCDPConnection} from '../../../testing/MockCDPConnection.js';
import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
import * as ApplicationComponents from './components.js';
describeWithEnvironment('AdsView', () => {
let target: SDK.Target.Target;
let connection: MockCDPConnection;
let clock: sinon.SinonFakeTimers;
let originalResizeObserver: typeof ResizeObserver;
beforeEach(() => {
// Stub ResizeObserver to prevent errors in tests caused by the data grid's internal
// size monitoring, which relies on a functional ResizeObserver.
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = (class {
observe(): void {
}
unobserve(): void {
}
disconnect(): void {
}
}) as unknown as typeof ResizeObserver;
clock = sinon.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout'],
});
connection = new MockCDPConnection();
connection.setSuccessHandler('Ads.getAdMetrics', () => ({
metrics: {
viewportAdDensityByArea: 10,
averageViewportAdDensityByArea: 5,
viewportAdCount: 5,
averageViewportAdCount: 2,
totalAdCpuTime: 150,
totalAdNetworkBytes: 2048,
updateAdFrames: [],
removeAdFrames: [],
},
}));
const tabTarget = createTarget({type: SDK.Target.Type.TAB, connection});
createTarget({parentTarget: tabTarget, subtype: 'prerender'});
target = createTarget({parentTarget: tabTarget});
sinon.stub(SDK.FrameManager.FrameManager.instance(), 'getFrame').callsFake((frameId: string) => {
return {
id: frameId,
resourceTreeModel: () => ({
target: () => target,
}),
getOwnerDeferredDOMNode: async () => {
return {
resolvePromise: async () => {
return {
getAttribute: (attr: string) => attr === 'id' ? `ad-iframe-${frameId}` : null,
} as unknown as SDK.DOMModel.DOMNode;
},
} as unknown as SDK.DOMModel.DeferredDOMNode;
},
} as unknown as SDK.ResourceTreeModel.ResourceTreeFrame;
});
});
afterEach(async () => {
cleanTestDOM();
await raf();
await RenderCoordinator.done();
// Flush RenderCoordinator's DEADLOCK_TIMEOUT (1500ms).
// RenderCoordinator sets a 1500ms timeout to detect deadlocks. When using fake timers,
// this timeout is tracked by TrackAsyncOperations. If we don't advance the clock,
// the test runner will fail, complaining about a dangling setTimeout promise.
clock.tick(1500);
sinon.restore();
globalThis.ResizeObserver = originalResizeObserver;
});
it('renders initial state correctly', async () => {
const panel = new ApplicationComponents.AdsView.AdsView();
await panel.updateComplete;
await RenderCoordinator.done();
assert.include(panel.contentElement.textContent, 'Viewport ad density');
assert.include(panel.contentElement.textContent, '0%');
assert.include(panel.contentElement.textContent, '0.00%');
assert.include(panel.contentElement.textContent, '0');
assert.include(panel.contentElement.textContent, '0.00');
assert.include(panel.contentElement.textContent, '0\xa0B');
assert.include(panel.contentElement.textContent, '0\xa0ms');
});
it('polls and renders ad metrics', async () => {
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
// Wait for the initial poll to resolve
await clock.tickAsync(0);
await panel.updateComplete;
await RenderCoordinator.done();
assert.include(panel.contentElement.textContent, '10%');
assert.include(panel.contentElement.textContent, '5.00%');
assert.include(panel.contentElement.textContent, '5');
assert.include(panel.contentElement.textContent, '2.00');
assert.include(panel.contentElement.textContent, '2.0\xa0kB');
assert.include(panel.contentElement.textContent, '150\xa0ms');
panel.detach();
});
it('clears metrics on primary page changed', async () => {
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
assert.exists(resourceTreeModel);
// Wait for the initial poll to resolve
await clock.tickAsync(0);
await panel.updateComplete;
await RenderCoordinator.done();
assert.include(panel.contentElement.textContent, '10%'); // verify data was loaded
// Simulate primary page changed, but first update the mock to return 0
connection.setHandler('Ads.getAdMetrics', null);
connection.setSuccessHandler('Ads.getAdMetrics', () => ({
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [],
removeAdFrames: [],
},
}));
resourceTreeModel.dispatchEventToListeners(SDK.ResourceTreeModel.Events.PrimaryPageChanged, {
frame: {} as SDK.ResourceTreeModel.ResourceTreeFrame,
type: SDK.ResourceTreeModel.PrimaryPageChangeType.NAVIGATION,
});
await panel.updateComplete;
await RenderCoordinator.done();
// Should be reset to 0s
assert.include(panel.contentElement.textContent, 'Viewport ad density');
assert.include(panel.contentElement.textContent, '0%');
assert.include(panel.contentElement.textContent, '0.00%');
assert.include(panel.contentElement.textContent, '0');
assert.include(panel.contentElement.textContent, '0.00');
assert.include(panel.contentElement.textContent, '0\xa0B');
assert.include(panel.contentElement.textContent, '0\xa0ms');
panel.detach();
});
it('renders ad frames in data grid and handles updates/removals', async () => {
let callCount = 0;
connection.setHandler('Ads.getAdMetrics', null);
connection.setSuccessHandler('Ads.getAdMetrics', () => {
callCount++;
if (callCount === 1) {
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [
{
frameId: 'frame-1' as Protocol.Page.FrameId,
initialOrigin: 'https://example.com',
cpuTime: 100,
networkBytes: 1024,
},
{
frameId: 'frame-2' as Protocol.Page.FrameId,
initialOrigin: 'https://example2.com',
cpuTime: 50,
networkBytes: 1024,
},
],
removeAdFrames: [],
},
};
}
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [],
removeAdFrames: ['frame-1' as Protocol.Page.FrameId],
},
};
});
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
// Wait for the initial poll and subsequent async element ID fetches to resolve
await clock.tickAsync(0);
await panel.updateComplete;
await RenderCoordinator.done();
assert.include(panel.contentElement.textContent, 'Ad iframes (total 2)');
assert.include(panel.contentElement.textContent, 'ad-iframe-frame-1');
assert.include(panel.contentElement.textContent, 'https://example.com');
assert.include(panel.contentElement.textContent, '100\xa0ms');
assert.include(panel.contentElement.textContent, '1.0\xa0kB');
assert.include(panel.contentElement.textContent, 'ad-iframe-frame-2');
assert.include(panel.contentElement.textContent, 'https://example2.com');
assert.include(panel.contentElement.textContent, '50\xa0ms');
// Wait for the next poll
await clock.tickAsync(600);
await panel.updateComplete;
await RenderCoordinator.done();
assert.include(panel.contentElement.textContent, 'Ad iframes (total 1)');
assert.notInclude(panel.contentElement.textContent, 'https://example.com');
assert.include(panel.contentElement.textContent, 'https://example2.com');
panel.detach();
});
it('sorts ad frames when data grid header is clicked', async () => {
let callCount = 0;
connection.setHandler('Ads.getAdMetrics', null);
connection.setSuccessHandler('Ads.getAdMetrics', () => {
callCount++;
if (callCount === 1) {
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [
{
frameId: 'frame-1' as Protocol.Page.FrameId,
initialOrigin: 'https://example.com',
cpuTime: 100,
networkBytes: 1024,
},
{
frameId: 'frame-2' as Protocol.Page.FrameId,
initialOrigin: 'https://example2.com',
cpuTime: 50,
networkBytes: 2048,
},
],
removeAdFrames: [],
},
};
}
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [],
removeAdFrames: [],
},
};
});
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
// Wait for the initial poll and subsequent async element ID fetches to resolve
await clock.tickAsync(0);
await panel.updateComplete;
await RenderCoordinator.done();
const dataGrid = panel.contentElement.querySelector('devtools-data-grid');
assert.isNotNull(dataGrid);
// Initial order: frame-1, then frame-2 (based on data returned)
assert.isNotNull(dataGrid.shadowRoot);
const internalDataGrid = dataGrid.shadowRoot.querySelector('.data-grid');
assert.isNotNull(internalDataGrid);
let rows = Array.from(internalDataGrid.querySelectorAll('tbody tr.data-grid-data-grid-node'));
assert.include(rows[0].textContent, 'frame-1');
assert.include(rows[1].textContent, 'frame-2');
const cpuTimeHeader = internalDataGrid.querySelector('th.cpuTime-column');
assert.isNotNull(cpuTimeHeader);
// First click sorts ascending by default (50, 100, meaning frame-2 then frame-1)
cpuTimeHeader.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
await panel.updateComplete;
await RenderCoordinator.done({waitForWork: true});
rows = Array.from(internalDataGrid.querySelectorAll('tbody tr.data-grid-data-grid-node'));
assert.include(rows[0].textContent, 'frame-2');
assert.include(rows[1].textContent, 'frame-1');
// Second click sorts descending (100, 50, meaning frame-1 then frame-2)
cpuTimeHeader.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
await panel.updateComplete;
await RenderCoordinator.done({waitForWork: true});
rows = Array.from(internalDataGrid.querySelectorAll('tbody tr.data-grid-data-grid-node'));
assert.include(rows[0].textContent, 'frame-1');
assert.include(rows[1].textContent, 'frame-2');
panel.detach();
});
it('toggles ad highlights when the checkbox is clicked', async () => {
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
await panel.updateComplete;
await RenderCoordinator.done();
const setting = Common.Settings.Settings.instance().moduleSetting('show-ad-highlights');
setting.set(false);
await panel.updateComplete;
await RenderCoordinator.done();
const devtoolsCheckbox = panel.contentElement.querySelector('devtools-checkbox');
assert.isNotNull(devtoolsCheckbox);
assert.isFalse(devtoolsCheckbox.checked, 'Checkbox should initially be unchecked');
// Click the checkbox
devtoolsCheckbox.click();
await panel.updateComplete;
await RenderCoordinator.done();
assert.isTrue(setting.get(), 'Setting should be true after checking the box');
assert.isTrue(devtoolsCheckbox.checked, 'Checkbox should be checked after click');
panel.detach();
});
it('reveals the frame when the element ID button is clicked', async () => {
let callCount = 0;
connection.setHandler('Ads.getAdMetrics', null);
connection.setSuccessHandler('Ads.getAdMetrics', () => {
callCount++;
if (callCount === 1) {
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [
{
frameId: 'frame-1' as Protocol.Page.FrameId,
initialOrigin: 'https://example.com',
cpuTime: 100,
networkBytes: 1024,
},
],
removeAdFrames: [],
},
};
}
return {
metrics: {
viewportAdDensityByArea: 0,
averageViewportAdDensityByArea: 0,
viewportAdCount: 0,
averageViewportAdCount: 0,
totalAdCpuTime: 0,
totalAdNetworkBytes: 0,
updateAdFrames: [],
removeAdFrames: [],
},
};
});
const panel = new ApplicationComponents.AdsView.AdsView();
renderElementIntoDOM(panel);
// Wait for the initial poll and subsequent async element ID fetches to resolve
await clock.tickAsync(0);
await panel.updateComplete;
await RenderCoordinator.done();
const revealStub = sinon.stub(Common.Revealer.RevealerRegistry.instance(), 'reveal').resolves();
const linkButton = panel.contentElement.querySelector('.devtools-link') as HTMLButtonElement;
assert.isNotNull(linkButton);
linkButton.click();
sinon.assert.calledOnce(revealStub);
const revealable = revealStub.firstCall.args[0] as SDK.ResourceTreeModel.ResourceTreeFrame;
assert.strictEqual(revealable.id, 'frame-1');
panel.detach();
});
});