blob: 156fc8a0710210ccf54d26decc39f467674f7be8 [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 sinon from 'sinon';
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as EmulationModel from '../../models/emulation/emulation.js';
import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
import {setupLocaleHooks} from '../../testing/LocaleHelpers.js';
import type * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as MobileThrottling from '../mobile_throttling/mobile_throttling.js';
import * as Emulation from './emulation.js';
function createFakeSetting<T>(defaultValue: T): Common.Settings.Setting<T> {
let value = defaultValue;
const listeners: Array<{listener: (_: Common.EventTarget.EventTargetEvent<T>) => void, thisObject?: object}> = [];
return {
get: () => value,
set: (v: T) => {
const isChanged = JSON.stringify(value) !== JSON.stringify(v);
value = v;
if (isChanged) {
for (const {listener, thisObject} of listeners) {
listener.call(thisObject, {data: v} as Common.EventTarget.EventTargetEvent<T>);
}
}
},
addChangeListener: (listener: (_: Common.EventTarget.EventTargetEvent<T>) => void, thisObject?: object) => {
listeners.push({listener, thisObject});
return {listener, thisObject};
},
removeChangeListener: (listener: (_: Common.EventTarget.EventTargetEvent<T>) => void, thisObject?: object) => {
const index = listeners.findIndex(l => l.listener === listener && l.thisObject === thisObject);
if (index >= 0) {
listeners.splice(index, 1);
}
},
setTitle: () => {},
title: () => '',
asRegExp: () => null,
type: () => Common.Settings.SettingType.BOOLEAN,
getAsArray: () => [],
name: 'fake',
} as unknown as Common.Settings.Setting<T>;
}
describeWithEnvironment('DeviceModeToolbar', () => {
setupLocaleHooks();
let tabTarget: SDK.Target.Target|undefined;
let target: SDK.Target.Target;
let deviceModeModel: EmulationModel.DeviceModeModel.DeviceModeModel;
let toolbar: Emulation.DeviceModeToolbar.DeviceModeToolbar;
beforeEach(() => {
i18n.DevToolsLocale.DevToolsLocale.instance({
create: true,
data: {
navigatorLanguage: 'en-US',
settingLanguage: 'en-US',
lookupClosestDevToolsLocale: () => 'en-US',
},
});
sinon.stub(Common.Settings.Settings, 'instance').returns({
createSetting: <T>(_name: string, defaultValue: T) => createFakeSetting(defaultValue),
moduleSetting: <T>(name: string) => {
if (name === 'custom-network-conditions') {
return createFakeSetting([] as T);
}
return createFakeSetting(false as T);
},
createLocalSetting: <T>(_name: string, defaultValue: T) => createFakeSetting(defaultValue),
} as unknown as Common.Settings.Settings);
tabTarget = createTarget({type: SDK.Target.Type.TAB});
createTarget({parentTarget: tabTarget, subtype: 'prerender'});
target = createTarget({parentTarget: tabTarget});
deviceModeModel = EmulationModel.DeviceModeModel.DeviceModeModel.instance({forceNew: true});
// Stub ThrottlingManager to avoid dependency on network condition settings.
const fakeMenuButton = new UI.Toolbar.ToolbarMenuButton(() => {}, undefined, undefined, 'throttle-menu');
const fakeSelectElement = document.createElement('select');
sinon.stub(MobileThrottling.ThrottlingManager.ThrottlingManager, 'instance').returns({
createMobileThrottlingButton: () => fakeMenuButton,
createSaveDataOverrideSelector: () => fakeSelectElement,
} as unknown as MobileThrottling.ThrottlingManager.ThrottlingManager);
toolbar = new Emulation.DeviceModeToolbar.DeviceModeToolbar();
toolbar.model = deviceModeModel;
});
afterEach(async () => {
await UI.Widget.Widget.allUpdatesComplete;
toolbar?.detach();
deviceModeModel?.dispose();
});
/**
* Finds the rotate/screen-rotation toolbar button inside the toolbar element.
* The button is a devtools-button with jslogContext 'screen-rotation'.
*/
function findRotateButton(): Buttons.Button.Button {
const buttons = toolbar.element.querySelectorAll<Buttons.Button.Button>('devtools-button.toolbar-button');
const button = [...buttons].find(b => b.getAttribute('jslog')?.includes('screen-rotation'));
assert.exists(button, 'Could not find rotate button');
return button;
}
describe('UI updates on model changes', () => {
let renderSpy: sinon.SinonSpy;
beforeEach(() => {
renderSpy = sinon.spy(Emulation.DeviceModeToolbar.DeviceModeToolbar.prototype, 'performUpdate');
// Re-create toolbar so that it registers the spied update method.
toolbar = new Emulation.DeviceModeToolbar.DeviceModeToolbar();
toolbar.model = deviceModeModel;
renderSpy.resetHistory();
});
afterEach(() => {
renderSpy.restore();
});
it('updates UI when scale changes', async () => {
deviceModeModel.scaleSetting().set(1.5);
await toolbar.updateComplete;
sinon.assert.called(renderSpy);
});
it('updates UI when User Agent changes', async () => {
deviceModeModel.uaSetting().set(EmulationModel.DeviceModeModel.UA.DESKTOP);
await toolbar.updateComplete;
sinon.assert.called(renderSpy);
});
it('updates UI when Device Pixel Ratio changes', async () => {
deviceModeModel.deviceScaleFactorSetting().set(2);
await toolbar.updateComplete;
sinon.assert.called(renderSpy);
});
it('updates UI on generic model update events', async () => {
deviceModeModel.dispatchEventToListeners(EmulationModel.DeviceModeModel.Events.UPDATED);
await toolbar.updateComplete;
sinon.assert.called(renderSpy);
});
});
describe('screen orientation lock', () => {
it('disables the rotate button when screen orientation is locked', async () => {
// Set up responsive mode so the rotate button is initially enabled.
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
const modeButton = findRotateButton();
assert.isFalse(modeButton.disabled, 'rotate button should initially be enabled');
// Lock orientation.
const emulationModel = target.model(SDK.EmulationModel.EmulationModel);
assert.isNotNull(emulationModel);
emulationModel.screenOrientationLockChanged({
locked: true,
orientation: {type: Protocol.Emulation.ScreenOrientationType.PortraitPrimary, angle: 0},
});
toolbar.requestUpdate();
await toolbar.updateComplete;
const updatedModeButton = findRotateButton();
assert.isTrue(updatedModeButton.disabled, 'rotate button should be disabled when orientation is locked');
assert.include(updatedModeButton.title, 'locked');
});
it('re-enables the rotate button when screen orientation is unlocked', async () => {
// Set up responsive mode.
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
// Lock, then unlock orientation.
const emulationModel = target.model(SDK.EmulationModel.EmulationModel);
assert.isNotNull(emulationModel);
emulationModel.screenOrientationLockChanged({
locked: true,
orientation: {type: Protocol.Emulation.ScreenOrientationType.PortraitPrimary, angle: 0},
});
toolbar.requestUpdate();
await toolbar.updateComplete;
const modeButton = findRotateButton();
assert.isTrue(modeButton.disabled, 'rotate button should be disabled when locked');
emulationModel.screenOrientationLockChanged({locked: false});
toolbar.requestUpdate();
await toolbar.updateComplete;
const updatedModeButton = findRotateButton();
assert.isFalse(updatedModeButton.disabled, 'rotate button should be re-enabled after unlock');
assert.include(updatedModeButton.title, 'Rotate');
});
it('does not rotate when screen orientation is locked and rotate button is clicked', async () => {
// Set up responsive mode.
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
// Lock orientation.
const emulationModel = target.model(SDK.EmulationModel.EmulationModel);
assert.isNotNull(emulationModel);
emulationModel.screenOrientationLockChanged({
locked: true,
orientation: {type: Protocol.Emulation.ScreenOrientationType.PortraitPrimary, angle: 0},
});
toolbar.requestUpdate();
await toolbar.updateComplete;
// Spy on setWidth/setHeight to ensure no rotation happens.
const setWidthSpy = sinon.spy(deviceModeModel, 'setWidth');
const setHeightSpy = sinon.spy(deviceModeModel, 'setHeight');
// Click the rotate button.
const modeButton = findRotateButton();
modeButton.click();
sinon.assert.notCalled(setWidthSpy);
sinon.assert.notCalled(setHeightSpy);
});
});
describe('device mode switching', () => {
it('tests toolbar state when switching modes', async () => {
const fakePhone = EmulationModel.EmulatedDevices.EmulatedDevicesList.instance().standard()[0];
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.None, null, null);
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.None);
assert.isNull(deviceModeModel.device());
const select = toolbar.element.querySelector('.toolbar-has-dropdown-shrinkable') as HTMLSelectElement;
select.value = 'Responsive';
select.dispatchEvent(new Event('change'));
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.Responsive);
assert.isNull(deviceModeModel.device());
let rotateButton = findRotateButton();
assert.isFalse(rotateButton.disabled);
let widthInput = toolbar.element.querySelector('.device-mode-size-input') as HTMLInputElement;
assert.isFalse(widthInput.disabled);
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.None, null, null);
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.None);
assert.isNull(deviceModeModel.device());
select.value = fakePhone.title;
select.dispatchEvent(new Event('change'));
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.Device);
assert.strictEqual(deviceModeModel.device(), fakePhone);
rotateButton = findRotateButton();
assert.isFalse(rotateButton.disabled);
widthInput = toolbar.element.querySelector('.device-mode-size-input') as HTMLInputElement;
assert.isTrue(widthInput.disabled);
select.value = 'Responsive';
select.dispatchEvent(new Event('change'));
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.Responsive);
assert.isNull(deviceModeModel.device());
rotateButton = findRotateButton();
assert.isFalse(rotateButton.disabled);
widthInput = toolbar.element.querySelector('.device-mode-size-input') as HTMLInputElement;
assert.isFalse(widthInput.disabled);
select.value = fakePhone.title;
select.dispatchEvent(new Event('change'));
await toolbar.updateComplete;
assert.strictEqual(deviceModeModel.type(), EmulationModel.DeviceModeModel.Type.Device);
assert.strictEqual(deviceModeModel.device(), fakePhone);
rotateButton = findRotateButton();
assert.isFalse(rotateButton.disabled);
widthInput = toolbar.element.querySelector('.device-mode-size-input') as HTMLInputElement;
assert.isTrue(widthInput.disabled);
});
});
it('resets the device dropdown to the current device when "Edit" is selected', async () => {
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
const select = toolbar.element.querySelector('select');
assert.instanceOf(select, HTMLSelectElement);
assert.strictEqual(select.value, 'Responsive');
// Simulate selecting "Edit"
select.value = 'Edit';
select.dispatchEvent(new Event('change'));
await toolbar.updateComplete;
// It should have reverted to "Responsive"
assert.strictEqual(select.value, 'Responsive');
});
it('shows "Fit to window" at top of zoom menu and no auto-adjust zoom button', async () => {
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
// Ensure no auto-adjust zoom toolbar button exists.
const buttons = toolbar.element.querySelectorAll<Buttons.Button.Button>('devtools-button.toolbar-button');
const autoAdjustButton = [...buttons].find(b => b.title === 'Auto-adjust zoom');
assert.isUndefined(autoAdjustButton, 'Auto-adjust zoom button should be removed from toolbar');
// Check zoom dropdown menu options.
const selects = toolbar.element.querySelectorAll<HTMLSelectElement>('select');
const zoomSelect = [...selects].find(s => s.getAttribute('aria-label') === 'Zoom');
assert.exists(zoomSelect, 'Zoom select should exist');
const options = [...zoomSelect.options].map(o => o.text);
assert.strictEqual(options[0], 'Fit to window', 'First option should be "Fit to window" without percentage');
});
it('renders standard devices grouped into optgroups by form factor', async () => {
deviceModeModel.emulate(EmulationModel.DeviceModeModel.Type.Responsive, null, null);
toolbar.requestUpdate();
await toolbar.updateComplete;
const deviceSelect = toolbar.element.querySelector<HTMLSelectElement>('select[aria-label="Device type"]');
assert.exists(deviceSelect);
const optgroups = deviceSelect.querySelectorAll('optgroup');
const labels = [...optgroups].map(og => og.label);
assert.include(labels, 'Mobile');
assert.include(labels, 'Foldables');
assert.include(labels, 'Tablets & Desktops');
assert.include(labels, 'Smart Displays');
});
});