diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/responsive | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/responsive')
154 files changed, 16010 insertions, 0 deletions
diff --git a/devtools/client/responsive/actions/devices.js b/devtools/client/responsive/actions/devices.js new file mode 100644 index 0000000000..b3f2bab982 --- /dev/null +++ b/devtools/client/responsive/actions/devices.js @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const asyncStorage = require("resource://devtools/shared/async-storage.js"); + +const { + ADD_DEVICE, + ADD_DEVICE_TYPE, + EDIT_DEVICE, + LOAD_DEVICE_LIST_START, + LOAD_DEVICE_LIST_ERROR, + LOAD_DEVICE_LIST_END, + REMOVE_DEVICE, + UPDATE_DEVICE_DISPLAYED, + UPDATE_DEVICE_MODAL, +} = require("resource://devtools/client/responsive/actions/index.js"); +const { + post, +} = require("resource://devtools/client/responsive/utils/message.js"); + +const { + addDevice, + editDevice, + getDevices, + removeDevice, +} = require("resource://devtools/client/shared/devices.js"); +const { + changeUserAgent, + toggleTouchSimulation, +} = require("resource://devtools/client/responsive/actions/ui.js"); +const { + changeDevice, + changePixelRatio, + changeViewportAngle, +} = require("resource://devtools/client/responsive/actions/viewports.js"); + +const DISPLAYED_DEVICES_PREF = "devtools.responsive.html.displayedDeviceList"; + +/** + * Returns an object containing the user preference of displayed devices. + * + * @return {Object} containing two Sets: + * - added: Names of the devices that were explicitly enabled by the user + * - removed: Names of the devices that were explicitly removed by the user + */ +function loadPreferredDevices() { + const preferredDevices = { + added: new Set(), + removed: new Set(), + }; + + if (Services.prefs.prefHasUserValue(DISPLAYED_DEVICES_PREF)) { + try { + let savedData = Services.prefs.getStringPref(DISPLAYED_DEVICES_PREF); + savedData = JSON.parse(savedData); + if (savedData.added && savedData.removed) { + preferredDevices.added = new Set(savedData.added); + preferredDevices.removed = new Set(savedData.removed); + } + } catch (e) { + console.error(e); + } + } + + return preferredDevices; +} + +/** + * Update the displayed device list preference with the given device list. + * + * @param {Object} containing two Sets: + * - added: Names of the devices that were explicitly enabled by the user + * - removed: Names of the devices that were explicitly removed by the user + */ +function updatePreferredDevices(devices) { + let devicesToSave = { + added: Array.from(devices.added), + removed: Array.from(devices.removed), + }; + devicesToSave = JSON.stringify(devicesToSave); + Services.prefs.setStringPref(DISPLAYED_DEVICES_PREF, devicesToSave); +} + +module.exports = { + // This function is only exported for testing purposes + _loadPreferredDevices: loadPreferredDevices, + + updatePreferredDevices, + + addCustomDevice(device) { + return async function ({ dispatch }) { + // Add custom device to device storage + await addDevice(device, "custom"); + dispatch({ + type: ADD_DEVICE, + device, + deviceType: "custom", + }); + }; + }, + + addDevice(device, deviceType) { + return { + type: ADD_DEVICE, + device, + deviceType, + }; + }, + + addDeviceType(deviceType) { + return { + type: ADD_DEVICE_TYPE, + deviceType, + }; + }, + + editCustomDevice(viewport, oldDevice, newDevice) { + return async function ({ dispatch }) { + // Edit custom device in storage + await editDevice(oldDevice, newDevice, "custom"); + // Notify the window that the device should be updated in the device selector. + post(window, { + type: "change-device", + device: newDevice, + viewport, + }); + + // Update UI if the device is selected. + if (viewport) { + dispatch(changeUserAgent(newDevice.userAgent)); + dispatch(toggleTouchSimulation(newDevice.touch)); + } + + dispatch({ + type: EDIT_DEVICE, + deviceType: "custom", + viewport, + oldDevice, + newDevice, + }); + }; + }, + + removeCustomDevice(device) { + return async function ({ dispatch }) { + // Remove custom device from device storage + await removeDevice(device, "custom"); + dispatch({ + type: REMOVE_DEVICE, + device, + deviceType: "custom", + }); + }; + }, + + updateDeviceDisplayed(device, deviceType, displayed) { + return { + type: UPDATE_DEVICE_DISPLAYED, + device, + deviceType, + displayed, + }; + }, + + loadDevices() { + return async function ({ dispatch }) { + dispatch({ type: LOAD_DEVICE_LIST_START }); + const preferredDevices = loadPreferredDevices(); + let deviceByTypes; + + try { + deviceByTypes = await getDevices(); + } catch (e) { + console.error("Could not load device list: " + e); + dispatch({ type: LOAD_DEVICE_LIST_ERROR }); + return; + } + + for (const [type, devices] of deviceByTypes.entries()) { + dispatch(module.exports.addDeviceType(type)); + for (const device of devices) { + if (device.os == "fxos") { + continue; + } + + const newDevice = Object.assign({}, device, { + displayed: + preferredDevices.added.has(device.name) || + (device.featured && !preferredDevices.removed.has(device.name)), + }); + + dispatch(module.exports.addDevice(newDevice, type)); + } + } + + // Add an empty "custom" type if it doesn't exist in device storage + if (!deviceByTypes.has("custom")) { + dispatch(module.exports.addDeviceType("custom")); + } + + dispatch({ type: LOAD_DEVICE_LIST_END }); + }; + }, + + restoreDeviceState() { + return async function ({ dispatch, getState }) { + const deviceState = await asyncStorage.getItem( + "devtools.responsive.deviceState" + ); + if (!deviceState) { + return; + } + + const { id, device: deviceName, deviceType } = deviceState; + const devices = getState().devices; + + if (!devices.types.includes(deviceType)) { + // Can't find matching device type. + return; + } + + const device = devices[deviceType].find(d => d.name === deviceName); + if (!device) { + // Can't find device with the same device name. + return; + } + + const viewport = getState().viewports[0]; + + post(window, { + type: "change-device", + device, + viewport, + }); + + dispatch(changeDevice(id, device.name, deviceType)); + dispatch(changeViewportAngle(id, viewport.angle)); + dispatch(changePixelRatio(id, device.pixelRatio)); + dispatch(changeUserAgent(device.userAgent)); + dispatch(toggleTouchSimulation(device.touch)); + }; + }, + + updateDeviceModal(isOpen, modalOpenedFromViewport = null) { + return { + type: UPDATE_DEVICE_MODAL, + isOpen, + modalOpenedFromViewport, + }; + }, +}; diff --git a/devtools/client/responsive/actions/index.js b/devtools/client/responsive/actions/index.js new file mode 100644 index 0000000000..7d42c06053 --- /dev/null +++ b/devtools/client/responsive/actions/index.js @@ -0,0 +1,109 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This file lists all of the actions available in responsive design. This +// central list of constants makes it easy to see all possible action names at +// a glance. Please add a comment with each new action type. + +const { createEnum } = require("resource://devtools/client/shared/enum.js"); + +const { + CHANGE_NETWORK_THROTTLING, +} = require("resource://devtools/client/shared/components/throttling/actions.js"); + +createEnum( + [ + // Add a new device. + "ADD_DEVICE", + + // Add a new device type. + "ADD_DEVICE_TYPE", + + // Add an additional viewport to display the document. + "ADD_VIEWPORT", + + // Change the device displayed in the viewport. + "CHANGE_DEVICE", + + // Change the location of the page. This may be triggered by the user + // directly entering a new URL, navigating with links, etc. + "CHANGE_LOCATION", + + // The pixel ratio of the display has changed. This may be triggered by the user + // when changing the monitor resolution, or when the window is dragged to a different + // display with a different pixel ratio. + "CHANGE_DISPLAY_PIXEL_RATIO", + + // Change the network throttling profile. + CHANGE_NETWORK_THROTTLING, + + // Change the user agent of the viewport. + "CHANGE_USER_AGENT", + + // The pixel ratio of the viewport has changed. This may be triggered by the user + // when changing the device displayed in the viewport, or when a pixel ratio is + // selected from the device pixel ratio dropdown. + "CHANGE_PIXEL_RATIO", + + // Change the viewport angle. + "CHANGE_VIEWPORT_ANGLE", + + // Edit a device. + "EDIT_DEVICE", + + // Indicates that the device list is being loaded. + "LOAD_DEVICE_LIST_START", + + // Indicates that the device list loading action threw an error. + "LOAD_DEVICE_LIST_ERROR", + + // Indicates that the device list has been loaded successfully. + "LOAD_DEVICE_LIST_END", + + // Remove a device. + "REMOVE_DEVICE", + + // Remove the viewport's device assocation. + "REMOVE_DEVICE_ASSOCIATION", + + // Resize the viewport. + "RESIZE_VIEWPORT", + + // Rotate the viewport. + "ROTATE_VIEWPORT", + + // Take a screenshot of the viewport. + "TAKE_SCREENSHOT_START", + + // Indicates when the screenshot action ends. + "TAKE_SCREENSHOT_END", + + // Toggles the left alignment of the viewports. + "TOGGLE_LEFT_ALIGNMENT", + + // Toggles the reload on touch simulation changes. + "TOGGLE_RELOAD_ON_TOUCH_SIMULATION", + + // Toggles the reload on user agent changes. + "TOGGLE_RELOAD_ON_USER_AGENT", + + // Toggles the touch simulation state of the viewports. + "TOGGLE_TOUCH_SIMULATION", + + // Toggles the user agent input displayed in the toolbar. + "TOGGLE_USER_AGENT_INPUT", + + // Update the device display state in the device selector. + "UPDATE_DEVICE_DISPLAYED", + + // Update the device modal state. + "UPDATE_DEVICE_MODAL", + + // Zoom the viewport. + "ZOOM_VIEWPORT", + ], + module.exports +); diff --git a/devtools/client/responsive/actions/moz.build b/devtools/client/responsive/actions/moz.build new file mode 100644 index 0000000000..d4fa0d243f --- /dev/null +++ b/devtools/client/responsive/actions/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "devices.js", + "index.js", + "screenshot.js", + "ui.js", + "viewports.js", +) diff --git a/devtools/client/responsive/actions/screenshot.js b/devtools/client/responsive/actions/screenshot.js new file mode 100644 index 0000000000..c429b41060 --- /dev/null +++ b/devtools/client/responsive/actions/screenshot.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + TAKE_SCREENSHOT_START, + TAKE_SCREENSHOT_END, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const message = require("resource://devtools/client/responsive/utils/message.js"); + +const animationFrame = () => + new Promise(resolve => { + window.requestAnimationFrame(resolve); + }); + +module.exports = { + takeScreenshot() { + return async function ({ dispatch }) { + await dispatch({ type: TAKE_SCREENSHOT_START }); + + // Waiting the next repaint, to ensure the react components + // can be properly render after the action dispatched above + await animationFrame(); + + window.postMessage({ type: "screenshot" }, "*"); + await message.wait(window, "screenshot-captured"); + + dispatch({ type: TAKE_SCREENSHOT_END }); + }; + }, +}; diff --git a/devtools/client/responsive/actions/ui.js b/devtools/client/responsive/actions/ui.js new file mode 100644 index 0000000000..b2f05cb7c9 --- /dev/null +++ b/devtools/client/responsive/actions/ui.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + CHANGE_DISPLAY_PIXEL_RATIO, + CHANGE_USER_AGENT, + TOGGLE_LEFT_ALIGNMENT, + TOGGLE_RELOAD_ON_TOUCH_SIMULATION, + TOGGLE_RELOAD_ON_USER_AGENT, + TOGGLE_TOUCH_SIMULATION, + TOGGLE_USER_AGENT_INPUT, +} = require("resource://devtools/client/responsive/actions/index.js"); + +module.exports = { + /** + * The pixel ratio of the display has changed. This may be triggered by the user + * when changing the monitor resolution, or when the window is dragged to a different + * display with a different pixel ratio. + */ + changeDisplayPixelRatio(displayPixelRatio) { + return { + type: CHANGE_DISPLAY_PIXEL_RATIO, + displayPixelRatio, + }; + }, + + changeUserAgent(userAgent) { + return { + type: CHANGE_USER_AGENT, + userAgent, + }; + }, + + toggleLeftAlignment(enabled) { + return { + type: TOGGLE_LEFT_ALIGNMENT, + enabled, + }; + }, + + toggleReloadOnTouchSimulation(enabled) { + return { + type: TOGGLE_RELOAD_ON_TOUCH_SIMULATION, + enabled, + }; + }, + + toggleReloadOnUserAgent(enabled) { + return { + type: TOGGLE_RELOAD_ON_USER_AGENT, + enabled, + }; + }, + + toggleTouchSimulation(enabled) { + return { + type: TOGGLE_TOUCH_SIMULATION, + enabled, + }; + }, + + toggleUserAgentInput(enabled) { + return { + type: TOGGLE_USER_AGENT_INPUT, + enabled, + }; + }, +}; diff --git a/devtools/client/responsive/actions/viewports.js b/devtools/client/responsive/actions/viewports.js new file mode 100644 index 0000000000..f8fec1a8c5 --- /dev/null +++ b/devtools/client/responsive/actions/viewports.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const asyncStorage = require("resource://devtools/shared/async-storage.js"); + +const { + ADD_VIEWPORT, + CHANGE_DEVICE, + CHANGE_PIXEL_RATIO, + CHANGE_VIEWPORT_ANGLE, + REMOVE_DEVICE_ASSOCIATION, + RESIZE_VIEWPORT, + ROTATE_VIEWPORT, + ZOOM_VIEWPORT, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const { + post, +} = require("resource://devtools/client/responsive/utils/message.js"); + +module.exports = { + /** + * Add an additional viewport to display the document. + */ + addViewport(userContextId = 0) { + return { + type: ADD_VIEWPORT, + userContextId, + }; + }, + + /** + * Change the viewport device. + */ + changeDevice(id, device, deviceType) { + return async function ({ dispatch }) { + dispatch({ + type: CHANGE_DEVICE, + id, + device, + deviceType, + }); + + try { + await asyncStorage.setItem("devtools.responsive.deviceState", { + id, + device, + deviceType, + }); + } catch (e) { + console.error(e); + } + }; + }, + + /** + * Change the viewport pixel ratio. + */ + changePixelRatio(id, pixelRatio = 0) { + return { + type: CHANGE_PIXEL_RATIO, + id, + pixelRatio, + }; + }, + + changeViewportAngle(id, angle) { + return { + type: CHANGE_VIEWPORT_ANGLE, + id, + angle, + }; + }, + + /** + * Remove the viewport's device assocation. + */ + removeDeviceAssociation(id) { + return async function ({ dispatch }) { + post(window, "remove-device-association"); + + dispatch({ + type: REMOVE_DEVICE_ASSOCIATION, + id, + }); + + await asyncStorage.removeItem("devtools.responsive.deviceState"); + }; + }, + + /** + * Resize the viewport. + */ + resizeViewport(id, width, height) { + return { + type: RESIZE_VIEWPORT, + id, + width, + height, + }; + }, + + /** + * Rotate the viewport. + */ + rotateViewport(id) { + return { + type: ROTATE_VIEWPORT, + id, + }; + }, + + /** + * Zoom the viewport. + */ + zoomViewport(id, zoom) { + return { + type: ZOOM_VIEWPORT, + id, + zoom, + }; + }, +}; diff --git a/devtools/client/responsive/components/App.js b/devtools/client/responsive/components/App.js new file mode 100644 index 0000000000..da9bec0527 --- /dev/null +++ b/devtools/client/responsive/components/App.js @@ -0,0 +1,435 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const Toolbar = createFactory( + require("resource://devtools/client/responsive/components/Toolbar.js") +); + +loader.lazyGetter(this, "DeviceModal", () => + createFactory( + require("resource://devtools/client/responsive/components/DeviceModal.js") + ) +); + +const { + changeNetworkThrottling, +} = require("resource://devtools/client/shared/components/throttling/actions.js"); +const { + addCustomDevice, + editCustomDevice, + removeCustomDevice, + updateDeviceDisplayed, + updateDeviceModal, + updatePreferredDevices, +} = require("resource://devtools/client/responsive/actions/devices.js"); +const { + takeScreenshot, +} = require("resource://devtools/client/responsive/actions/screenshot.js"); +const { + changeUserAgent, + toggleLeftAlignment, + toggleReloadOnTouchSimulation, + toggleReloadOnUserAgent, + toggleTouchSimulation, + toggleUserAgentInput, +} = require("resource://devtools/client/responsive/actions/ui.js"); +const { + changeDevice, + changePixelRatio, + changeViewportAngle, + removeDeviceAssociation, + resizeViewport, + rotateViewport, +} = require("resource://devtools/client/responsive/actions/viewports.js"); +const { + getOrientation, +} = require("resource://devtools/client/responsive/utils/orientation.js"); + +const Types = require("resource://devtools/client/responsive/types.js"); + +class App extends PureComponent { + static get propTypes() { + return { + devices: PropTypes.shape(Types.devices).isRequired, + dispatch: PropTypes.func.isRequired, + leftAlignmentEnabled: PropTypes.bool.isRequired, + networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, + viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired, + }; + } + + constructor(props) { + super(props); + + this.onAddCustomDevice = this.onAddCustomDevice.bind(this); + this.onChangeDevice = this.onChangeDevice.bind(this); + this.onChangeNetworkThrottling = this.onChangeNetworkThrottling.bind(this); + this.onChangePixelRatio = this.onChangePixelRatio.bind(this); + this.onChangeTouchSimulation = this.onChangeTouchSimulation.bind(this); + this.onChangeUserAgent = this.onChangeUserAgent.bind(this); + this.onChangeViewportOrientation = + this.onChangeViewportOrientation.bind(this); + this.onDeviceListUpdate = this.onDeviceListUpdate.bind(this); + this.onEditCustomDevice = this.onEditCustomDevice.bind(this); + this.onExit = this.onExit.bind(this); + this.onRemoveCustomDevice = this.onRemoveCustomDevice.bind(this); + this.onRemoveDeviceAssociation = this.onRemoveDeviceAssociation.bind(this); + this.doResizeViewport = this.doResizeViewport.bind(this); + this.onRotateViewport = this.onRotateViewport.bind(this); + this.onScreenshot = this.onScreenshot.bind(this); + this.onToggleLeftAlignment = this.onToggleLeftAlignment.bind(this); + this.onToggleReloadOnTouchSimulation = + this.onToggleReloadOnTouchSimulation.bind(this); + this.onToggleReloadOnUserAgent = this.onToggleReloadOnUserAgent.bind(this); + this.onToggleUserAgentInput = this.onToggleUserAgentInput.bind(this); + this.onUpdateDeviceDisplayed = this.onUpdateDeviceDisplayed.bind(this); + this.onUpdateDeviceModal = this.onUpdateDeviceModal.bind(this); + } + + onAddCustomDevice(device) { + this.props.dispatch(addCustomDevice(device)); + } + + onChangeDevice(id, device, deviceType) { + // Resize the viewport first. + this.doResizeViewport(id, device.width, device.height); + + // TODO: Bug 1332754: Move messaging and logic into the action creator so that the + // message is sent from the action creator and device property changes are sent from + // there instead of this function. + window.postMessage( + { + type: "change-device", + device, + viewport: device, + }, + "*" + ); + + const orientation = getOrientation(device, device); + + this.props.dispatch(changeViewportAngle(0, orientation.angle)); + this.props.dispatch(changeDevice(id, device.name, deviceType)); + this.props.dispatch(changePixelRatio(id, device.pixelRatio)); + this.props.dispatch(changeUserAgent(device.userAgent)); + this.props.dispatch(toggleTouchSimulation(device.touch)); + } + + onChangeNetworkThrottling(enabled, profile) { + window.postMessage( + { + type: "change-network-throttling", + enabled, + profile, + }, + "*" + ); + this.props.dispatch(changeNetworkThrottling(enabled, profile)); + } + + onChangePixelRatio(pixelRatio) { + window.postMessage( + { + type: "change-pixel-ratio", + pixelRatio, + }, + "*" + ); + this.props.dispatch(changePixelRatio(0, pixelRatio)); + } + + onChangeTouchSimulation(enabled) { + window.postMessage( + { + type: "change-touch-simulation", + enabled, + }, + "*" + ); + this.props.dispatch(toggleTouchSimulation(enabled)); + } + + onChangeUserAgent(userAgent) { + window.postMessage( + { + type: "change-user-agent", + userAgent, + }, + "*" + ); + this.props.dispatch(changeUserAgent(userAgent)); + } + + onChangeViewportOrientation(id, type, angle, isViewportRotated = false) { + window.postMessage( + { + type: "viewport-orientation-change", + orientationType: type, + angle, + isViewportRotated, + }, + "*" + ); + + if (isViewportRotated) { + this.props.dispatch(changeViewportAngle(id, angle)); + } + } + + onDeviceListUpdate(devices) { + updatePreferredDevices(devices); + } + + onEditCustomDevice(oldDevice, newDevice) { + // If the edited device is currently selected, then update its original association + // and reset UI state. + let viewport = this.props.viewports.find( + ({ device }) => device === oldDevice.name + ); + + if (viewport) { + viewport = { + ...viewport, + device: newDevice.name, + deviceType: "custom", + height: newDevice.height, + width: newDevice.width, + pixelRatio: newDevice.pixelRatio, + touch: newDevice.touch, + userAgent: newDevice.userAgent, + }; + } + + this.props.dispatch(editCustomDevice(viewport, oldDevice, newDevice)); + } + + onExit() { + window.postMessage({ type: "exit" }, "*"); + } + + onRemoveCustomDevice(device) { + // If the custom device is currently selected on any of the viewports, + // remove the device association and reset all the ui state. + for (const viewport of this.props.viewports) { + if (viewport.device === device.name) { + this.onRemoveDeviceAssociation(viewport.id); + } + } + + this.props.dispatch(removeCustomDevice(device)); + } + + onRemoveDeviceAssociation(id) { + // TODO: Bug 1332754: Move messaging and logic into the action creator so that device + // property changes are sent from there instead of this function. + this.props.dispatch(removeDeviceAssociation(id)); + this.props.dispatch(toggleTouchSimulation(false)); + this.props.dispatch(changePixelRatio(id, 0)); + this.props.dispatch(changeUserAgent("")); + } + + doResizeViewport(id, width, height) { + // This is the setter function that we pass to Toolbar and Viewports + // so they can modify the viewport. + window.postMessage( + { + type: "viewport-resize", + width, + height, + }, + "*" + ); + this.props.dispatch(resizeViewport(id, width, height)); + } + + /** + * Dispatches the rotateViewport action creator. This utilized by the RDM toolbar as + * a prop. + * + * @param {Number} id + * The viewport ID. + */ + onRotateViewport(id) { + let currentDevice; + const viewport = this.props.viewports[id]; + + for (const type of this.props.devices.types) { + for (const device of this.props.devices[type]) { + if (viewport.device === device.name) { + currentDevice = device; + } + } + } + + // If no device is selected, then assume the selected device's primary orientation is + // opposite of the viewport orientation. + if (!currentDevice) { + currentDevice = { + height: viewport.width, + width: viewport.height, + }; + } + + const currentAngle = Services.prefs.getIntPref( + "devtools.responsive.viewport.angle" + ); + const angleToRotateTo = currentAngle === 90 ? 0 : 90; + const { type, angle } = getOrientation( + currentDevice, + viewport, + angleToRotateTo + ); + + this.onChangeViewportOrientation(id, type, angle, true); + this.props.dispatch(rotateViewport(id)); + + window.postMessage( + { + type: "viewport-resize", + height: viewport.width, + width: viewport.height, + }, + "*" + ); + } + + onScreenshot() { + this.props.dispatch(takeScreenshot()); + } + + onToggleLeftAlignment() { + this.props.dispatch(toggleLeftAlignment()); + + window.postMessage( + { + type: "toggle-left-alignment", + leftAlignmentEnabled: this.props.leftAlignmentEnabled, + }, + "*" + ); + } + + onToggleReloadOnTouchSimulation() { + this.props.dispatch(toggleReloadOnTouchSimulation()); + } + + onToggleReloadOnUserAgent() { + this.props.dispatch(toggleReloadOnUserAgent()); + } + + onToggleUserAgentInput() { + this.props.dispatch(toggleUserAgentInput()); + } + + onUpdateDeviceDisplayed(device, deviceType, displayed) { + this.props.dispatch(updateDeviceDisplayed(device, deviceType, displayed)); + } + + onUpdateDeviceModal(isOpen, modalOpenedFromViewport) { + this.props.dispatch(updateDeviceModal(isOpen, modalOpenedFromViewport)); + window.postMessage({ type: "update-device-modal", isOpen }, "*"); + } + + render() { + const { devices, networkThrottling, screenshot, viewports } = this.props; + + const { + onAddCustomDevice, + onChangeDevice, + onChangeNetworkThrottling, + onChangePixelRatio, + onChangeTouchSimulation, + onChangeUserAgent, + onDeviceListUpdate, + onEditCustomDevice, + onExit, + onRemoveCustomDevice, + onRemoveDeviceAssociation, + doResizeViewport, + onRotateViewport, + onScreenshot, + onToggleLeftAlignment, + onToggleReloadOnTouchSimulation, + onToggleReloadOnUserAgent, + onToggleUserAgentInput, + onUpdateDeviceDisplayed, + onUpdateDeviceModal, + } = this; + + if (!viewports.length) { + return null; + } + + const selectedDevice = viewports[0].device; + const selectedPixelRatio = viewports[0].pixelRatio; + + let deviceAdderViewportTemplate = {}; + if (devices.modalOpenedFromViewport !== null) { + deviceAdderViewportTemplate = viewports[devices.modalOpenedFromViewport]; + } + + return dom.div( + { id: "app" }, + Toolbar({ + devices, + networkThrottling, + screenshot, + selectedDevice, + selectedPixelRatio, + viewport: viewports[0], + onChangeDevice, + onChangeNetworkThrottling, + onChangePixelRatio, + onChangeTouchSimulation, + onChangeUserAgent, + onExit, + onRemoveDeviceAssociation, + doResizeViewport, + onRotateViewport, + onScreenshot, + onToggleLeftAlignment, + onToggleReloadOnTouchSimulation, + onToggleReloadOnUserAgent, + onToggleUserAgentInput, + onUpdateDeviceModal, + }), + devices.isModalOpen + ? DeviceModal({ + deviceAdderViewportTemplate, + devices, + onAddCustomDevice, + onDeviceListUpdate, + onEditCustomDevice, + onRemoveCustomDevice, + onUpdateDeviceDisplayed, + onUpdateDeviceModal, + }) + : null + ); + } +} + +const mapStateToProps = state => { + return { + ...state, + leftAlignmentEnabled: state.ui.leftAlignmentEnabled, + }; +}; + +module.exports = connect(mapStateToProps)(App); diff --git a/devtools/client/responsive/components/Device.js b/devtools/client/responsive/components/Device.js new file mode 100644 index 0000000000..87c0845a70 --- /dev/null +++ b/devtools/client/responsive/components/Device.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + getFormatStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const { + parseUserAgent, +} = require("resource://devtools/client/responsive/utils/ua.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +const DeviceInfo = createFactory( + require("resource://devtools/client/responsive/components/DeviceInfo.js") +); + +class Device extends PureComponent { + static get propTypes() { + return { + // props.children are the buttons rendered as part of custom device label. + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]), + device: PropTypes.shape(Types.devices).isRequired, + onDeviceCheckboxChange: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Whether or not the device's input label is checked. + isChecked: this.props.device.isChecked, + }; + + this.onCheckboxChanged = this.onCheckboxChanged.bind(this); + } + + onCheckboxChanged(e) { + this.setState(prevState => { + return { isChecked: !prevState.isChecked }; + }); + + this.props.onDeviceCheckboxChange(e); + } + + getBrowserOrOsTooltip(agent) { + if (agent) { + return agent.name + (agent.version ? ` ${agent.version}` : ""); + } + + return null; + } + + getTooltip() { + const { device } = this.props; + const { browser, os } = parseUserAgent(device.userAgent); + + const browserTitle = this.getBrowserOrOsTooltip(browser); + const osTitle = this.getBrowserOrOsTooltip(os); + let browserAndOsTitle = null; + if (browserTitle && osTitle) { + browserAndOsTitle = getFormatStr( + "responsive.deviceDetails.browserAndOS", + browserTitle, + osTitle + ); + } else if (browserTitle || osTitle) { + browserAndOsTitle = browserTitle || osTitle; + } + + const sizeTitle = getFormatStr( + "responsive.deviceDetails.size", + device.width, + device.height + ); + + const dprTitle = getFormatStr( + "responsive.deviceDetails.DPR", + device.pixelRatio + ); + + const uaTitle = getFormatStr( + "responsive.deviceDetails.UA", + device.userAgent + ); + + const touchTitle = getFormatStr( + "responsive.deviceDetails.touch", + device.touch + ); + + return ( + `${browserAndOsTitle ? browserAndOsTitle + "\n" : ""}` + + `${sizeTitle}\n` + + `${dprTitle}\n` + + `${uaTitle}\n` + + `${touchTitle}\n` + ); + } + + render() { + const { children, device } = this.props; + const tooltip = this.getTooltip(); + + return dom.label( + { + className: "device-label", + key: device.name, + title: tooltip, + }, + dom.input({ + className: "device-input-checkbox", + name: device.name, + type: "checkbox", + value: device.name, + checked: device.isChecked, + onChange: this.onCheckboxChanged, + }), + DeviceInfo({ device }), + children + ); + } +} + +module.exports = Device; diff --git a/devtools/client/responsive/components/DeviceAdder.js b/devtools/client/responsive/components/DeviceAdder.js new file mode 100644 index 0000000000..b09da10f38 --- /dev/null +++ b/devtools/client/responsive/components/DeviceAdder.js @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const ViewportDimension = createFactory( + require("resource://devtools/client/responsive/components/ViewportDimension.js") +); + +const { + getFormatStr, + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class DeviceAdder extends PureComponent { + static get propTypes() { + return { + devices: PropTypes.shape(Types.devices).isRequired, + onAddCustomDevice: PropTypes.func.isRequired, + viewportTemplate: PropTypes.shape(Types.viewport).isRequired, + }; + } + + constructor(props) { + super(props); + + const { height, width } = this.props.viewportTemplate; + + this.state = { + deviceAdderDisplayed: false, + height, + width, + }; + + this.onChangeSize = this.onChangeSize.bind(this); + this.onDeviceAdderShow = this.onDeviceAdderShow.bind(this); + this.onDeviceAdderSave = this.onDeviceAdderSave.bind(this); + } + + onChangeSize(_, width, height) { + this.setState({ + width, + height, + }); + } + + onDeviceAdderShow() { + this.setState({ + deviceAdderDisplayed: true, + }); + } + + onDeviceAdderSave() { + const { devices, onAddCustomDevice } = this.props; + + if (!this.pixelRatioInput.checkValidity()) { + return; + } + + if (devices.custom.find(device => device.name == this.nameInput.value)) { + this.nameInput.setCustomValidity("Device name already in use"); + return; + } + + this.setState({ + deviceAdderDisplayed: false, + }); + + onAddCustomDevice({ + name: this.nameInput.value, + width: this.state.width, + height: this.state.height, + pixelRatio: parseFloat(this.pixelRatioInput.value), + userAgent: this.userAgentInput.value, + touch: this.touchInput.checked, + }); + } + + render() { + const { devices, viewportTemplate } = this.props; + + const { deviceAdderDisplayed, height, width } = this.state; + + if (!deviceAdderDisplayed) { + return dom.div( + { + id: "device-adder", + }, + dom.button( + { + id: "device-adder-show", + onClick: this.onDeviceAdderShow, + }, + getStr("responsive.addDevice") + ) + ); + } + + // If a device is currently selected, fold its attributes into a single object for use + // as the starting values of the form. If no device is selected, use the values for + // the current window. + let deviceName; + const normalizedViewport = Object.assign({}, viewportTemplate); + if (viewportTemplate.device) { + const device = devices[viewportTemplate.deviceType].find(d => { + return d.name == viewportTemplate.device; + }); + deviceName = getFormatStr( + "responsive.customDeviceNameFromBase", + device.name + ); + Object.assign(normalizedViewport, { + pixelRatio: device.pixelRatio, + userAgent: device.userAgent, + touch: device.touch, + }); + } else { + deviceName = getStr("responsive.customDeviceName"); + Object.assign(normalizedViewport, { + pixelRatio: window.devicePixelRatio, + userAgent: navigator.userAgent, + touch: false, + }); + } + + return dom.div( + { id: "device-adder" }, + dom.div( + { id: "device-adder-content" }, + dom.div( + { id: "device-adder-column-1" }, + dom.label( + { id: "device-adder-name" }, + dom.span( + { className: "device-adder-label" }, + getStr("responsive.deviceAdderName") + ), + dom.input({ + defaultValue: deviceName, + ref: input => { + this.nameInput = input; + }, + }) + ), + dom.label( + { id: "device-adder-size" }, + dom.span( + { className: "device-adder-label" }, + getStr("responsive.deviceAdderSize") + ), + ViewportDimension({ + viewport: { + width, + height, + }, + doResizeViewport: this.onChangeSize, + onRemoveDeviceAssociation: () => {}, + }) + ), + dom.label( + { id: "device-adder-pixel-ratio" }, + dom.span( + { className: "device-adder-label" }, + getStr("responsive.deviceAdderPixelRatio") + ), + dom.input({ + type: "number", + step: "any", + defaultValue: normalizedViewport.pixelRatio, + ref: input => { + this.pixelRatioInput = input; + }, + }) + ) + ), + dom.div( + { id: "device-adder-column-2" }, + dom.label( + { id: "device-adder-user-agent" }, + dom.span( + { className: "device-adder-label" }, + getStr("responsive.deviceAdderUserAgent") + ), + dom.input({ + defaultValue: normalizedViewport.userAgent, + ref: input => { + this.userAgentInput = input; + }, + }) + ), + dom.label( + { id: "device-adder-touch" }, + dom.span( + { className: "device-adder-label" }, + getStr("responsive.deviceAdderTouch") + ), + dom.input({ + defaultChecked: normalizedViewport.touch, + type: "checkbox", + ref: input => { + this.touchInput = input; + }, + }) + ) + ) + ), + dom.button( + { + id: "device-adder-save", + onClick: this.onDeviceAdderSave, + }, + getStr("responsive.deviceAdderSave") + ) + ); + } +} + +module.exports = DeviceAdder; diff --git a/devtools/client/responsive/components/DeviceForm.js b/devtools/client/responsive/components/DeviceForm.js new file mode 100644 index 0000000000..656f846d33 --- /dev/null +++ b/devtools/client/responsive/components/DeviceForm.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const ViewportDimension = createFactory( + require("resource://devtools/client/responsive/components/ViewportDimension.js") +); + +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class DeviceForm extends PureComponent { + static get propTypes() { + return { + formType: PropTypes.string.isRequired, + device: PropTypes.shape(Types.device).isRequired, + devices: PropTypes.shape(Types.devices).isRequired, + onSave: PropTypes.func.isRequired, + viewportTemplate: PropTypes.shape(Types.viewport).isRequired, + onDeviceFormHide: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + const { height, width } = this.props.viewportTemplate; + + this.state = { + height, + width, + }; + + this.nameInputRef = createRef(); + this.pixelRatioInputRef = createRef(); + this.touchInputRef = createRef(); + this.userAgentInputRef = createRef(); + + this.onChangeSize = this.onChangeSize.bind(this); + this.onDeviceFormHide = this.onDeviceFormHide.bind(this); + this.onDeviceFormSave = this.onDeviceFormSave.bind(this); + this.onInputFocus = this.onInputFocus.bind(this); + this.validateNameField = this.validateNameField.bind(this); + } + + onChangeSize(_, width, height) { + this.setState({ + width, + height, + }); + } + + onDeviceFormSave(e) { + e.preventDefault(); + + if (!this.pixelRatioInputRef.current.checkValidity()) { + return; + } + + if ( + !this.validateNameField( + this.nameInputRef.current.value, + this.props.device + ) + ) { + this.nameInputRef.current.setCustomValidity( + getStr("responsive.deviceNameAlreadyInUse") + ); + return; + } + + this.props.onSave({ + name: this.nameInputRef.current.value.trim(), + width: this.state.width, + height: this.state.height, + pixelRatio: parseFloat(this.pixelRatioInputRef.current.value), + userAgent: this.userAgentInputRef.current.value, + touch: this.touchInputRef.current.checked, + }); + + this.onDeviceFormHide(); + } + + onDeviceFormHide() { + // Ensure that we have onDeviceFormHide before calling it. + if (this.props.onDeviceFormHide) { + this.props.onDeviceFormHide(); + } + } + + onInputFocus(e) { + // If the formType is "add", select all text in input field when focused. + if (this.props.formType === "add") { + e.target.select(); + } + } + + /** + * Validates the name field's value. + * + * @param {String} value + * The input field value for the device name. + * @return {Boolean} true if device name is valid, false otherwise. + */ + validateNameField(value) { + const nameFieldValue = value.trim(); + let isValidDeviceName = false; + + // If the formType is "add", then we just need to check if a custom device with that + // same name exists. + if (this.props.formType === "add") { + isValidDeviceName = !this.props.devices.custom.find( + device => device.name == nameFieldValue + ); + } else { + // Otherwise, the formType is "edit". We'd have to find another device that + // already has the same name, but not itself, so: + // Find the index of the device being edited. Use this index value to distinguish + // between the device being edited from other devices in the list. + const deviceIndex = this.props.devices.custom.findIndex(({ name }) => { + return name === this.props.device.name; + }); + + isValidDeviceName = !this.props.devices.custom.find((d, index) => { + return d.name === nameFieldValue && index !== deviceIndex; + }); + } + + return isValidDeviceName; + } + + render() { + const { device, formType } = this.props; + const { width, height } = this.state; + + return dom.form( + { id: "device-form" }, + dom.label( + { id: "device-form-name" }, + dom.span( + { className: "device-form-label" }, + getStr("responsive.deviceAdderName") + ), + dom.input({ + defaultValue: device.name, + ref: this.nameInputRef, + onFocus: this.onInputFocus, + }) + ), + dom.label( + { id: "device-form-size" }, + dom.span( + { className: "device-form-label" }, + getStr("responsive.deviceAdderSize") + ), + ViewportDimension({ + viewport: { width, height }, + doResizeViewport: this.onChangeSize, + onRemoveDeviceAssociation: () => {}, + }) + ), + dom.label( + { id: "device-form-pixel-ratio" }, + dom.span( + { className: "device-form-label" }, + getStr("responsive.deviceAdderPixelRatio2") + ), + dom.input({ + type: "number", + step: "any", + defaultValue: device.pixelRatio, + ref: this.pixelRatioInputRef, + onFocus: this.onInputFocus, + }) + ), + dom.label( + { id: "device-form-user-agent" }, + dom.span( + { className: "device-form-label" }, + getStr("responsive.deviceAdderUserAgent2") + ), + dom.input({ + defaultValue: device.userAgent, + ref: this.userAgentInputRef, + onFocus: this.onInputFocus, + }) + ), + dom.label( + { id: "device-form-touch" }, + dom.input({ + defaultChecked: device.touch, + type: "checkbox", + ref: this.touchInputRef, + }), + dom.span( + { className: "device-form-label" }, + getStr("responsive.deviceAdderTouch2") + ) + ), + dom.div( + { className: "device-form-buttons" }, + dom.button( + { id: "device-form-save", onClick: this.onDeviceFormSave }, + formType === "add" + ? getStr("responsive.deviceAdderSave") + : getStr("responsive.deviceFormUpdate") + ), + dom.button( + { id: "device-form-cancel", onClick: this.onDeviceFormHide }, + getStr("responsive.deviceAdderCancel") + ) + ) + ); + } +} + +module.exports = DeviceForm; diff --git a/devtools/client/responsive/components/DeviceInfo.js b/devtools/client/responsive/components/DeviceInfo.js new file mode 100644 index 0000000000..2dc53bb603 --- /dev/null +++ b/devtools/client/responsive/components/DeviceInfo.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createElement, + Fragment, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + parseUserAgent, +} = require("resource://devtools/client/responsive/utils/ua.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class DeviceInfo extends PureComponent { + static get propTypes() { + return { + device: PropTypes.shape(Types.devices).isRequired, + }; + } + + renderBrowser({ name }) { + return dom.span({ + className: `device-browser ${name.toLowerCase()}`, + }); + } + + renderOS({ name, version }) { + const text = version ? `${name} ${version}` : name; + return dom.span({ className: "device-os" }, text); + } + + render() { + const { device } = this.props; + const { browser, os } = parseUserAgent(device.userAgent); + + return createElement( + Fragment, + null, + browser ? this.renderBrowser(browser) : dom.span(), + dom.span({ className: "device-name" }, device.name), + os ? this.renderOS(os) : dom.span() + ); + } +} + +module.exports = DeviceInfo; diff --git a/devtools/client/responsive/components/DeviceList.js b/devtools/client/responsive/components/DeviceList.js new file mode 100644 index 0000000000..3cf44f0e2e --- /dev/null +++ b/devtools/client/responsive/components/DeviceList.js @@ -0,0 +1,97 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const Types = require("resource://devtools/client/responsive/types.js"); + +const Device = createFactory( + require("resource://devtools/client/responsive/components/Device.js") +); + +class DeviceList extends PureComponent { + static get propTypes() { + return { + // Whether or not to show the custom device edit/remove buttons. + devices: PropTypes.shape(Types.devices).isRequired, + isDeviceFormShown: PropTypes.bool.isRequired, + onDeviceCheckboxChange: PropTypes.func.isRequired, + onDeviceFormHide: PropTypes.func.isRequired, + onDeviceFormShow: PropTypes.func.isRequired, + onRemoveCustomDevice: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + }; + } + + renderCustomDevice(device) { + const { + isDeviceFormShown, + type, + onDeviceCheckboxChange, + onRemoveCustomDevice, + } = this.props; + + // Show a remove button for custom devices. + const removeDeviceButton = dom.button({ + id: "device-edit-remove", + className: "device-remove-button devtools-button", + onClick: () => { + onRemoveCustomDevice(device); + this.props.onDeviceFormHide(); + }, + }); + + // Show an edit button for custom devices + const editButton = dom.button({ + id: "device-edit-button", + className: "devtools-button", + onClick: () => { + this.props.onDeviceFormShow("edit", device); + }, + }); + + return Device( + { + device, + key: device.name, + type, + onDeviceCheckboxChange, + }, + // Don't show the remove/edit buttons for a custom device if the form is open. + !isDeviceFormShown ? editButton : null, + !isDeviceFormShown ? removeDeviceButton : null + ); + } + + render() { + const { devices, type, onDeviceCheckboxChange } = this.props; + + return dom.div( + { className: "device-list" }, + devices[type].map(device => { + if (type === "custom") { + return this.renderCustomDevice(device); + } + + return Device({ + device, + key: device.name, + type, + onDeviceCheckboxChange, + }); + }) + ); + } +} + +module.exports = DeviceList; diff --git a/devtools/client/responsive/components/DeviceModal.js b/devtools/client/responsive/components/DeviceModal.js new file mode 100644 index 0000000000..1cd21951e3 --- /dev/null +++ b/devtools/client/responsive/components/DeviceModal.js @@ -0,0 +1,303 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const DeviceForm = createFactory( + require("resource://devtools/client/responsive/components/DeviceForm.js") +); +const DeviceList = createFactory( + require("resource://devtools/client/responsive/components/DeviceList.js") +); + +const { + getFormatStr, + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const { + getDeviceString, +} = require("resource://devtools/client/shared/devices.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class DeviceModal extends PureComponent { + static get propTypes() { + return { + deviceAdderViewportTemplate: PropTypes.shape(Types.viewport).isRequired, + devices: PropTypes.shape(Types.devices).isRequired, + onAddCustomDevice: PropTypes.func.isRequired, + onDeviceListUpdate: PropTypes.func.isRequired, + onEditCustomDevice: PropTypes.func.isRequired, + onRemoveCustomDevice: PropTypes.func.isRequired, + onUpdateDeviceDisplayed: PropTypes.func.isRequired, + onUpdateDeviceModal: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // The device form type can be 3 states: "add", "edit", or "". + // "add" - The form shown is adding a new device. + // "edit" - The form shown is editing an existing custom device. + // "" - The form is closed. + deviceFormType: "", + // The device being edited from the edit form. + editingDevice: null, + }; + for (const type of this.props.devices.types) { + for (const device of this.props.devices[type]) { + this.state[device.name] = device.displayed; + } + } + + this.onAddCustomDevice = this.onAddCustomDevice.bind(this); + this.onDeviceCheckboxChange = this.onDeviceCheckboxChange.bind(this); + this.onDeviceFormShow = this.onDeviceFormShow.bind(this); + this.onDeviceFormHide = this.onDeviceFormHide.bind(this); + this.onDeviceModalSubmit = this.onDeviceModalSubmit.bind(this); + this.onEditCustomDevice = this.onEditCustomDevice.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + } + + componentDidMount() { + window.addEventListener("keydown", this.onKeyDown, true); + } + + componentWillUnmount() { + this.onDeviceModalSubmit(); + window.removeEventListener("keydown", this.onKeyDown, true); + } + + onAddCustomDevice(device) { + this.props.onAddCustomDevice(device); + // Default custom devices to enabled + this.setState({ + [device.name]: true, + }); + } + + onDeviceCheckboxChange({ nativeEvent: { button }, target }) { + if (button !== 0) { + return; + } + this.setState({ + [target.value]: !this.state[target.value], + }); + } + + onDeviceFormShow(type, device) { + this.setState({ + deviceFormType: type, + editingDevice: device, + }); + } + + onDeviceFormHide() { + this.setState({ + deviceFormType: "", + editingDevice: null, + }); + } + + onDeviceModalSubmit() { + const { devices, onDeviceListUpdate, onUpdateDeviceDisplayed } = this.props; + + const preferredDevices = { + added: new Set(), + removed: new Set(), + }; + + for (const type of devices.types) { + for (const device of devices[type]) { + const newState = this.state[device.name]; + + if (device.featured && !newState) { + preferredDevices.removed.add(device.name); + } else if (!device.featured && newState) { + preferredDevices.added.add(device.name); + } + + if (this.state[device.name] != device.displayed) { + onUpdateDeviceDisplayed(device, type, this.state[device.name]); + } + } + } + + onDeviceListUpdate(preferredDevices); + } + + onEditCustomDevice(newDevice) { + this.props.onEditCustomDevice(this.state.editingDevice, newDevice); + + // We want to remove the original device name from state after editing, so create a + // new state setting the old key to null and the new one to true. + this.setState({ + [this.state.editingDevice.name]: null, + [newDevice.name]: true, + }); + } + + onKeyDown(event) { + if (!this.props.devices.isModalOpen) { + return; + } + // Escape keycode + if (event.keyCode === 27) { + const { onUpdateDeviceModal } = this.props; + onUpdateDeviceModal(false); + } + } + + renderAddForm() { + // If a device is currently selected, fold its attributes into a single object for use + // as the starting values of the form. If no device is selected, use the values for + // the current window. + const { deviceAdderViewportTemplate: viewportTemplate } = this.props; + const deviceTemplate = this.props.deviceAdderViewportTemplate; + if (viewportTemplate.device) { + const device = this.props.devices[viewportTemplate.deviceType].find(d => { + return d.name == viewportTemplate.device; + }); + Object.assign(deviceTemplate, { + pixelRatio: device.pixelRatio, + userAgent: device.userAgent, + touch: device.touch, + name: getFormatStr("responsive.customDeviceNameFromBase", device.name), + }); + } else { + Object.assign(deviceTemplate, { + pixelRatio: window.devicePixelRatio, + userAgent: navigator.userAgent, + touch: false, + name: getStr("responsive.customDeviceName"), + }); + } + + return DeviceForm({ + formType: "add", + device: deviceTemplate, + devices: this.props.devices, + onDeviceFormHide: this.onDeviceFormHide, + onSave: this.onAddCustomDevice, + viewportTemplate, + }); + } + + renderDevices() { + const sortedDevices = {}; + for (const type of this.props.devices.types) { + sortedDevices[type] = this.props.devices[type].sort((a, b) => + a.name.localeCompare(b.name) + ); + + sortedDevices[type].forEach(device => { + device.isChecked = this.state[device.name]; + }); + } + + return this.props.devices.types.map(type => { + return sortedDevices[type].length + ? dom.div( + { + className: `device-type device-type-${type}`, + key: type, + }, + dom.header({ className: "device-header" }, getDeviceString(type)), + DeviceList({ + devices: sortedDevices, + isDeviceFormShown: this.state.deviceFormType, + type, + onDeviceCheckboxChange: this.onDeviceCheckboxChange, + onDeviceFormHide: this.onDeviceFormHide, + onDeviceFormShow: this.onDeviceFormShow, + onEditCustomDevice: this.onEditCustomDevice, + onRemoveCustomDevice: this.props.onRemoveCustomDevice, + }) + ) + : null; + }); + } + + renderEditForm() { + return DeviceForm({ + formType: "edit", + device: this.state.editingDevice, + devices: this.props.devices, + onDeviceFormHide: this.onDeviceFormHide, + onSave: this.onEditCustomDevice, + viewportTemplate: { + width: this.state.editingDevice.width, + height: this.state.editingDevice.height, + }, + }); + } + + renderForm() { + let form = null; + + if (this.state.deviceFormType === "add") { + form = this.renderAddForm(); + } else if (this.state.deviceFormType === "edit") { + form = this.renderEditForm(); + } + + return form; + } + + render() { + const { onUpdateDeviceModal } = this.props; + const isDeviceFormShown = this.state.deviceFormType; + + return dom.div( + { + id: "device-modal-wrapper", + className: this.props.devices.isModalOpen ? "opened" : "closed", + }, + dom.div( + { className: "device-modal" }, + dom.div( + { className: "device-modal-header" }, + !isDeviceFormShown + ? dom.header( + { className: "device-modal-title" }, + getStr("responsive.deviceSettings"), + dom.button({ + id: "device-close-button", + className: "devtools-button", + onClick: () => onUpdateDeviceModal(false), + }) + ) + : null, + !isDeviceFormShown + ? dom.button( + { + id: "device-add-button", + onClick: () => this.onDeviceFormShow("add"), + }, + getStr("responsive.addDevice2") + ) + : null, + this.renderForm() + ), + dom.div({ className: "device-modal-content" }, this.renderDevices()) + ), + dom.div({ + className: "modal-overlay", + onClick: () => onUpdateDeviceModal(false), + }) + ); + } +} + +module.exports = DeviceModal; diff --git a/devtools/client/responsive/components/DevicePixelRatioMenu.js b/devtools/client/responsive/components/DevicePixelRatioMenu.js new file mode 100644 index 0000000000..7ad37b8e03 --- /dev/null +++ b/devtools/client/responsive/components/DevicePixelRatioMenu.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + getStr, + getFormatStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +loader.lazyRequireGetter( + this, + "showMenu", + "resource://devtools/client/shared/components/menu/utils.js", + true +); + +const PIXEL_RATIO_PRESET = [1, 2, 3]; + +class DevicePixelRatioMenu extends PureComponent { + static get propTypes() { + return { + devices: PropTypes.shape(Types.devices).isRequired, + displayPixelRatio: PropTypes.number.isRequired, + onChangePixelRatio: PropTypes.func.isRequired, + selectedDevice: PropTypes.string.isRequired, + selectedPixelRatio: PropTypes.number.isRequired, + }; + } + + constructor(props) { + super(props); + this.onShowDevicePixelMenu = this.onShowDevicePixelMenu.bind(this); + } + + onShowDevicePixelMenu(event) { + const { displayPixelRatio, onChangePixelRatio, selectedPixelRatio } = + this.props; + + const menuItems = PIXEL_RATIO_PRESET.map(value => { + return { + label: getFormatStr("responsive.devicePixelRatioOption", value), + type: "checkbox", + checked: + selectedPixelRatio > 0 + ? selectedPixelRatio === value + : displayPixelRatio === value, + click: () => onChangePixelRatio(+value), + }; + }); + + showMenu(menuItems, { + button: event.target, + }); + } + + render() { + const { devices, displayPixelRatio, selectedDevice, selectedPixelRatio } = + this.props; + + const isDisabled = + devices.listState !== Types.loadableState.LOADED || selectedDevice !== ""; + + let title; + if (isDisabled) { + title = getFormatStr("responsive.devicePixelRatio.auto", selectedDevice); + } else { + title = getStr("responsive.changeDevicePixelRatio"); + } + + return dom.button( + { + id: "device-pixel-ratio-menu", + className: "devtools-button devtools-dropdown-button", + disabled: isDisabled, + title, + onClick: this.onShowDevicePixelMenu, + }, + dom.span( + { className: "title" }, + getFormatStr( + "responsive.devicePixelRatioOption", + selectedPixelRatio || displayPixelRatio + ) + ) + ); + } +} + +module.exports = DevicePixelRatioMenu; diff --git a/devtools/client/responsive/components/DeviceSelector.js b/devtools/client/responsive/components/DeviceSelector.js new file mode 100644 index 0000000000..3028f2fa42 --- /dev/null +++ b/devtools/client/responsive/components/DeviceSelector.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { hr } = dom; +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const { + parseUserAgent, +} = require("resource://devtools/client/responsive/utils/ua.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +const MenuButton = createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") +); + +loader.lazyGetter(this, "MenuItem", () => { + const menuItemClass = require("resource://devtools/client/shared/components/menu/MenuItem.js"); + const menuItem = createFactory(menuItemClass); + menuItem.DUMMY_ICON = menuItemClass.DUMMY_ICON; + return menuItem; +}); + +loader.lazyGetter(this, "MenuList", () => { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +class DeviceSelector extends PureComponent { + static get propTypes() { + return { + devices: PropTypes.shape(Types.devices).isRequired, + onChangeDevice: PropTypes.func.isRequired, + onUpdateDeviceModal: PropTypes.func.isRequired, + selectedDevice: PropTypes.string.isRequired, + viewportId: PropTypes.number.isRequired, + }; + } + + getMenuProps(device) { + if (!device) { + return { icon: null, label: null, tooltip: null }; + } + + const { browser, os } = parseUserAgent(device.userAgent); + let label = device.name; + if (os) { + label += ` ${os.name}`; + if (os.version) { + label += ` ${os.version}`; + } + } + + let icon = null; + let tooltip = label; + if (browser) { + icon = `chrome://devtools/skin/images/browsers/${browser.name.toLowerCase()}.svg`; + tooltip += ` ${browser.name} ${browser.version}`; + } + + return { icon, label, tooltip }; + } + + getSelectedDevice() { + const { devices, selectedDevice } = this.props; + + if (!selectedDevice) { + return null; + } + + for (const type of devices.types) { + for (const device of devices[type]) { + if (selectedDevice === device.name) { + return device; + } + } + } + + return null; + } + + renderMenuList() { + const { + devices, + onChangeDevice, + onUpdateDeviceModal, + selectedDevice, + viewportId, + } = this.props; + + const menuItems = []; + + for (const type of devices.types) { + for (const device of devices[type]) { + if (device.displayed) { + const { icon, label, tooltip } = this.getMenuProps(device); + + menuItems.push( + MenuItem({ + key: label, + className: "device-selector-item", + checked: selectedDevice === device.name, + label, + icon: icon || MenuItem.DUMMY_ICON, + tooltip, + onClick: () => onChangeDevice(viewportId, device, type), + }) + ); + } + } + } + + menuItems.sort(function (a, b) { + return a.props.label.localeCompare(b.props.label); + }); + + if (menuItems.length) { + menuItems.push(hr({ key: "separator" })); + } + + menuItems.push( + MenuItem({ + key: "edit-device", + label: getStr("responsive.editDeviceList2"), + onClick: () => onUpdateDeviceModal(true, viewportId), + }) + ); + + return MenuList({}, menuItems); + } + + render() { + const { devices } = this.props; + const selectedDevice = this.getSelectedDevice(); + let { icon, label, tooltip } = this.getMenuProps(selectedDevice); + + if (!selectedDevice) { + label = getStr("responsive.responsiveMode"); + } + + // MenuButton is expected to be used in the toolbox document usually, + // but since RDM's frame also loads theme-switching.js, we can create + // MenuButtons (& HTMLTooltips) in the RDM frame document. + const toolboxDoc = window.document; + + return MenuButton( + { + id: "device-selector", + menuId: "device-selector-menu", + toolboxDoc, + className: "devtools-button devtools-dropdown-button", + label, + icon, + title: tooltip, + disabled: devices.listState !== Types.loadableState.LOADED, + }, + () => this.renderMenuList() + ); + } +} + +module.exports = DeviceSelector; diff --git a/devtools/client/responsive/components/SettingsMenu.js b/devtools/client/responsive/components/SettingsMenu.js new file mode 100644 index 0000000000..a221ce39ec --- /dev/null +++ b/devtools/client/responsive/components/SettingsMenu.js @@ -0,0 +1,122 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); + +loader.lazyRequireGetter( + this, + "showMenu", + "resource://devtools/client/shared/components/menu/utils.js", + true +); + +class SettingsMenu extends PureComponent { + static get propTypes() { + return { + leftAlignmentEnabled: PropTypes.bool.isRequired, + onToggleLeftAlignment: PropTypes.func.isRequired, + onToggleReloadOnTouchSimulation: PropTypes.func.isRequired, + onToggleReloadOnUserAgent: PropTypes.func.isRequired, + onToggleUserAgentInput: PropTypes.func.isRequired, + reloadOnTouchSimulation: PropTypes.bool.isRequired, + reloadOnUserAgent: PropTypes.bool.isRequired, + showUserAgentInput: PropTypes.bool.isRequired, + }; + } + + constructor(props) { + super(props); + this.onToggleSettingMenu = this.onToggleSettingMenu.bind(this); + } + + onToggleSettingMenu(event) { + const { + leftAlignmentEnabled, + onToggleLeftAlignment, + onToggleReloadOnTouchSimulation, + onToggleReloadOnUserAgent, + onToggleUserAgentInput, + reloadOnTouchSimulation, + reloadOnUserAgent, + showUserAgentInput, + } = this.props; + + const menuItems = [ + { + id: "toggleLeftAlignment", + checked: leftAlignmentEnabled, + label: getStr("responsive.leftAlignViewport"), + type: "checkbox", + click: () => { + onToggleLeftAlignment(); + }, + }, + "-", + { + id: "toggleUserAgentInput", + checked: showUserAgentInput, + label: getStr("responsive.showUserAgentInput"), + type: "checkbox", + click: () => { + onToggleUserAgentInput(); + }, + }, + "-", + { + id: "touchSimulation", + checked: reloadOnTouchSimulation, + label: getStr("responsive.reloadConditions.touchSimulation"), + type: "checkbox", + click: () => { + onToggleReloadOnTouchSimulation(); + }, + }, + { + id: "userAgent", + checked: reloadOnUserAgent, + label: getStr("responsive.reloadConditions.userAgent"), + type: "checkbox", + click: () => { + onToggleReloadOnUserAgent(); + }, + }, + ]; + + showMenu(menuItems, { + button: event.target, + }); + } + + render() { + return dom.button({ + id: "settings-button", + className: "devtools-button", + onClick: this.onToggleSettingMenu, + }); + } +} + +const mapStateToProps = state => { + return { + leftAlignmentEnabled: state.ui.leftAlignmentEnabled, + reloadOnTouchSimulation: state.ui.reloadOnTouchSimulation, + reloadOnUserAgent: state.ui.reloadOnUserAgent, + showUserAgentInput: state.ui.showUserAgentInput, + }; +}; + +module.exports = connect(mapStateToProps)(SettingsMenu); diff --git a/devtools/client/responsive/components/Toolbar.js b/devtools/client/responsive/components/Toolbar.js new file mode 100644 index 0000000000..a9a75dc9b3 --- /dev/null +++ b/devtools/client/responsive/components/Toolbar.js @@ -0,0 +1,216 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createElement, + createFactory, + Fragment, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const DevicePixelRatioMenu = createFactory( + require("resource://devtools/client/responsive/components/DevicePixelRatioMenu.js") +); +const DeviceSelector = createFactory( + require("resource://devtools/client/responsive/components/DeviceSelector.js") +); +const NetworkThrottlingMenu = createFactory( + require("resource://devtools/client/shared/components/throttling/NetworkThrottlingMenu.js") +); +const SettingsMenu = createFactory( + require("resource://devtools/client/responsive/components/SettingsMenu.js") +); +const ViewportDimension = createFactory( + require("resource://devtools/client/responsive/components/ViewportDimension.js") +); + +loader.lazyGetter(this, "UserAgentInput", function () { + return createFactory( + require("resource://devtools/client/responsive/components/UserAgentInput.js") + ); +}); + +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class Toolbar extends PureComponent { + static get propTypes() { + return { + devices: PropTypes.shape(Types.devices).isRequired, + displayPixelRatio: PropTypes.number.isRequired, + leftAlignmentEnabled: PropTypes.bool.isRequired, + networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired, + onChangeDevice: PropTypes.func.isRequired, + onChangeNetworkThrottling: PropTypes.func.isRequired, + onChangePixelRatio: PropTypes.func.isRequired, + onChangeTouchSimulation: PropTypes.func.isRequired, + onChangeUserAgent: PropTypes.func.isRequired, + onExit: PropTypes.func.isRequired, + onRemoveDeviceAssociation: PropTypes.func.isRequired, + doResizeViewport: PropTypes.func.isRequired, + onRotateViewport: PropTypes.func.isRequired, + onScreenshot: PropTypes.func.isRequired, + onToggleLeftAlignment: PropTypes.func.isRequired, + onToggleReloadOnTouchSimulation: PropTypes.func.isRequired, + onToggleReloadOnUserAgent: PropTypes.func.isRequired, + onToggleUserAgentInput: PropTypes.func.isRequired, + onUpdateDeviceModal: PropTypes.func.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, + selectedDevice: PropTypes.string.isRequired, + selectedPixelRatio: PropTypes.number.isRequired, + showUserAgentInput: PropTypes.bool.isRequired, + touchSimulationEnabled: PropTypes.bool.isRequired, + viewport: PropTypes.shape(Types.viewport).isRequired, + }; + } + + renderUserAgent() { + const { onChangeUserAgent, showUserAgentInput } = this.props; + + if (!showUserAgentInput) { + return null; + } + + return createElement( + Fragment, + null, + UserAgentInput({ + onChangeUserAgent, + }), + dom.div({ className: "devtools-separator" }) + ); + } + + render() { + const { + devices, + displayPixelRatio, + leftAlignmentEnabled, + networkThrottling, + onChangeDevice, + onChangeNetworkThrottling, + onChangePixelRatio, + onChangeTouchSimulation, + onExit, + onRemoveDeviceAssociation, + doResizeViewport, + onRotateViewport, + onScreenshot, + onToggleLeftAlignment, + onToggleReloadOnTouchSimulation, + onToggleReloadOnUserAgent, + onToggleUserAgentInput, + onUpdateDeviceModal, + screenshot, + selectedDevice, + selectedPixelRatio, + showUserAgentInput, + touchSimulationEnabled, + viewport, + } = this.props; + + return dom.header( + { + id: "toolbar", + className: [ + leftAlignmentEnabled ? "left-aligned" : "", + showUserAgentInput ? "user-agent" : "", + ] + .join(" ") + .trim(), + }, + dom.div( + { id: "toolbar-center-controls" }, + DeviceSelector({ + devices, + onChangeDevice, + onUpdateDeviceModal, + selectedDevice, + viewportId: viewport.id, + }), + dom.div({ className: "devtools-separator" }), + ViewportDimension({ + onRemoveDeviceAssociation, + doResizeViewport, + viewport, + }), + dom.button({ + id: "rotate-button", + className: `devtools-button viewport-orientation-${ + viewport.width > viewport.height ? "landscape" : "portrait" + }`, + onClick: () => onRotateViewport(viewport.id), + title: getStr("responsive.rotate"), + }), + dom.div({ className: "devtools-separator" }), + DevicePixelRatioMenu({ + devices, + displayPixelRatio, + onChangePixelRatio, + selectedDevice, + selectedPixelRatio, + }), + dom.div({ className: "devtools-separator" }), + NetworkThrottlingMenu({ + networkThrottling, + onChangeNetworkThrottling, + }), + dom.div({ className: "devtools-separator" }), + this.renderUserAgent(), + dom.button({ + id: "touch-simulation-button", + className: + "devtools-button" + (touchSimulationEnabled ? " checked" : ""), + title: touchSimulationEnabled + ? getStr("responsive.disableTouch") + : getStr("responsive.enableTouch"), + onClick: () => onChangeTouchSimulation(!touchSimulationEnabled), + }) + ), + dom.div( + { id: "toolbar-end-controls" }, + dom.button({ + id: "screenshot-button", + className: "devtools-button", + title: getStr("responsive.screenshot"), + onClick: onScreenshot, + disabled: screenshot.isCapturing, + }), + SettingsMenu({ + onToggleLeftAlignment, + onToggleReloadOnTouchSimulation, + onToggleReloadOnUserAgent, + onToggleUserAgentInput, + }), + dom.div({ className: "devtools-separator" }), + dom.button({ + id: "exit-button", + className: "devtools-button", + title: getStr("responsive.exit"), + onClick: onExit, + }) + ) + ); + } +} + +const mapStateToProps = state => { + return { + displayPixelRatio: state.ui.displayPixelRatio, + leftAlignmentEnabled: state.ui.leftAlignmentEnabled, + showUserAgentInput: state.ui.showUserAgentInput, + touchSimulationEnabled: state.ui.touchSimulationEnabled, + }; +}; + +module.exports = connect(mapStateToProps)(Toolbar); diff --git a/devtools/client/responsive/components/UserAgentInput.js b/devtools/client/responsive/components/UserAgentInput.js new file mode 100644 index 0000000000..d24fc99c6f --- /dev/null +++ b/devtools/client/responsive/components/UserAgentInput.js @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); + +class UserAgentInput extends PureComponent { + static get propTypes() { + return { + onChangeUserAgent: PropTypes.func.isRequired, + userAgent: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // The user agent input value. + value: this.props.userAgent, + }; + + this.onChange = this.onChange.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.props.userAgent !== nextProps.userAgent) { + this.setState({ value: nextProps.userAgent }); + } + } + + /** + * Input change handler. + * + * @param {Event} event + */ + onChange({ target }) { + const value = target.value; + + this.setState(prevState => { + return { + ...prevState, + value, + }; + }); + } + + /** + * Input key up handler. + * + * @param {Event} event + */ + onKeyUp({ target, keyCode }) { + if (keyCode == KeyCodes.DOM_VK_RETURN) { + this.props.onChangeUserAgent(target.value); + target.blur(); + } + + if (keyCode == KeyCodes.DOM_VK_ESCAPE) { + target.blur(); + } + } + + render() { + return dom.label( + { id: "user-agent-label" }, + "UA:", + dom.input({ + id: "user-agent-input", + className: "text-input", + onChange: this.onChange, + onKeyUp: this.onKeyUp, + placeholder: getStr("responsive.customUserAgent"), + type: "text", + value: this.state.value, + }) + ); + } +} + +const mapStateToProps = state => { + return { + userAgent: state.ui.userAgent, + }; +}; + +module.exports = connect(mapStateToProps)(UserAgentInput); diff --git a/devtools/client/responsive/components/ViewportDimension.js b/devtools/client/responsive/components/ViewportDimension.js new file mode 100644 index 0000000000..988b690e21 --- /dev/null +++ b/devtools/client/responsive/components/ViewportDimension.js @@ -0,0 +1,251 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + isKeyIn, +} = require("resource://devtools/client/responsive/utils/key.js"); +const { + MIN_VIEWPORT_DIMENSION, +} = require("resource://devtools/client/responsive/constants.js"); +const Types = require("resource://devtools/client/responsive/types.js"); + +class ViewportDimension extends PureComponent { + static get propTypes() { + return { + doResizeViewport: PropTypes.func.isRequired, + onRemoveDeviceAssociation: PropTypes.func.isRequired, + viewport: PropTypes.shape(Types.viewport).isRequired, + }; + } + + constructor(props) { + super(props); + + const { width, height } = props.viewport; + + this.state = { + width, + height, + isEditing: false, + isWidthValid: true, + isHeightValid: true, + }; + + this.isInputValid = this.isInputValid.bind(this); + this.onInputBlur = this.onInputBlur.bind(this); + this.onInputChange = this.onInputChange.bind(this); + this.onInputFocus = this.onInputFocus.bind(this); + this.onInputKeyDown = this.onInputKeyDown.bind(this); + this.onInputKeyUp = this.onInputKeyUp.bind(this); + this.onInputSubmit = this.onInputSubmit.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { width, height } = nextProps.viewport; + + this.setState({ + width, + height, + }); + } + + /** + * Return true if the given value is a number and greater than MIN_VIEWPORT_DIMENSION + * and false otherwise. + */ + isInputValid(value) { + return ( + /^\d{2,4}$/.test(value) && parseInt(value, 10) >= MIN_VIEWPORT_DIMENSION + ); + } + + onInputBlur() { + const { width, height } = this.props.viewport; + + if (this.state.width != width || this.state.height != height) { + this.onInputSubmit(); + } + + this.setState({ isEditing: false }); + } + + onInputChange({ target }, callback) { + if (target.value.length > 4) { + return; + } + + if (this.widthInput == target) { + this.setState( + { + width: target.value, + isWidthValid: this.isInputValid(target.value), + }, + callback + ); + } + + if (this.heightInput == target) { + this.setState( + { + height: target.value, + isHeightValid: this.isInputValid(target.value), + }, + callback + ); + } + } + + onInputFocus(e) { + this.setState({ isEditing: true }); + e.target.select(); + } + + onInputKeyDown(event) { + const increment = getIncrement(event); + if (!increment) { + return; + } + + const { target } = event; + target.value = parseInt(target.value, 10) + increment; + this.onInputChange(event, this.onInputSubmit); + + // Keep this event from having default processing. Since the field is a + // number field, default processing would trigger additional manipulations + // of the value, and we've already applied the desired amount. + event.preventDefault(); + } + + onInputKeyUp({ target, keyCode }) { + // On Enter, submit the input + if (keyCode == 13) { + this.onInputSubmit(); + } + + // On Esc, blur the target + if (keyCode == 27) { + target.blur(); + } + } + + onInputSubmit() { + const { viewport, onRemoveDeviceAssociation, doResizeViewport } = + this.props; + + if (!this.state.isWidthValid || !this.state.isHeightValid) { + const { width, height } = viewport; + + this.setState({ + width, + height, + isWidthValid: true, + isHeightValid: true, + }); + + return; + } + + // Change the device selector back to an unselected device + // TODO: Bug 1332754: Logic like this probably belongs in the action creator. + if (viewport.device) { + onRemoveDeviceAssociation(viewport.id); + } + + doResizeViewport( + viewport.id, + parseInt(this.state.width, 10), + parseInt(this.state.height, 10) + ); + } + + render() { + return dom.div( + { + className: + "viewport-dimension" + + (this.state.isEditing ? " editing" : "") + + (!this.state.isWidthValid || !this.state.isHeightValid + ? " invalid" + : ""), + }, + dom.input({ + ref: input => { + this.widthInput = input; + }, + className: + "text-input viewport-dimension-input" + + (this.state.isWidthValid ? "" : " invalid"), + size: 4, + type: "number", + value: this.state.width, + onBlur: this.onInputBlur, + onChange: this.onInputChange, + onFocus: this.onInputFocus, + onKeyDown: this.onInputKeyDown, + onKeyUp: this.onInputKeyUp, + }), + dom.span( + { + className: "viewport-dimension-separator", + }, + "×" + ), + dom.input({ + ref: input => { + this.heightInput = input; + }, + className: + "text-input viewport-dimension-input" + + (this.state.isHeightValid ? "" : " invalid"), + size: 4, + type: "number", + value: this.state.height, + onBlur: this.onInputBlur, + onChange: this.onInputChange, + onFocus: this.onInputFocus, + onKeyDown: this.onInputKeyDown, + onKeyUp: this.onInputKeyUp, + }) + ); + } +} + +/** + * Get the increment/decrement step to use for the provided key event. + */ +function getIncrement(event) { + const defaultIncrement = 1; + const largeIncrement = 100; + const mediumIncrement = 10; + + let increment = 0; + const key = event.keyCode; + + if (isKeyIn(key, "UP", "PAGE_UP")) { + increment = 1 * defaultIncrement; + } else if (isKeyIn(key, "DOWN", "PAGE_DOWN")) { + increment = -1 * defaultIncrement; + } + + if (event.shiftKey) { + if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) { + increment *= largeIncrement; + } else { + increment *= mediumIncrement; + } + } + + return increment; +} + +module.exports = ViewportDimension; diff --git a/devtools/client/responsive/components/moz.build b/devtools/client/responsive/components/moz.build new file mode 100644 index 0000000000..04ef642afc --- /dev/null +++ b/devtools/client/responsive/components/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "App.js", + "Device.js", + "DeviceForm.js", + "DeviceInfo.js", + "DeviceList.js", + "DeviceModal.js", + "DevicePixelRatioMenu.js", + "DeviceSelector.js", + "SettingsMenu.js", + "Toolbar.js", + "UserAgentInput.js", + "ViewportDimension.js", +) diff --git a/devtools/client/responsive/constants.js b/devtools/client/responsive/constants.js new file mode 100644 index 0000000000..a01cd69f51 --- /dev/null +++ b/devtools/client/responsive/constants.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// The minimum viewport width and height +exports.MIN_VIEWPORT_DIMENSION = 50; + +// Orientation types +exports.PORTRAIT_PRIMARY = "portrait-primary"; +exports.PORTRAIT_SECONDARY = "portrait-secondary"; +exports.LANDSCAPE_PRIMARY = "landscape-primary"; +exports.LANDSCAPE_SECONDARY = "landscape-secondary"; diff --git a/devtools/client/responsive/docs/devices.md b/devtools/client/responsive/docs/devices.md new file mode 100644 index 0000000000..ba54428638 --- /dev/null +++ b/devtools/client/responsive/docs/devices.md @@ -0,0 +1,37 @@ +# Updating List of Devices for RDM + +## Where to locate the list + +The devices list is a [RemoteSettings](https://firefox-source-docs.mozilla.org/services/settings/index.html) collection named `devtools-devices`. +A dump of the list can be found in [services/settings/dumps/main/devtools-devices.json](https://searchfox.org/mozilla-central/source/services/settings/dumps/main/devtools-devices.json). + +## Adding and removing devices + +There is no set criteria for which devices should be added or removed from the list. However, we can take this into account: + +- Adding the latest iPhone and iPad models from Apple. +- Adding the latest Galaxy series from Samsung. +- Checking out what devices Google Chrome supports in its DevTools. They keep the list hardcoded in [source](https://github.com/ChromeDevTools/devtools-frontend/blob/79095c6c14d96582806982b63e99ef936a6cd74c/front_end/models/emulation/EmulatedDevices.ts#L645) + +## Data format + +An important field is `featured`, which is a boolean. When set to `true`, the device will appear in the RDM dropdown. If it's set to `false`, the device will not appear in the dropdown, but can be enabled in the `Edit list` modal. +Each device has a user agent specified. We can get this value by: + +- At `https://developers.whatismybrowser.com/useragents/explore/` +- With a real device, open its default browser, and google "my user agent" will display a Google widget with the user agent string. +- Looking at Google's own list of devices (they also specify the user agent) + +## Releasing the changes + +First, make sure you can have access to RemoteSettings (see https://remote-settings.readthedocs.io/en/latest/getting-started.html#getting-started). + +You should then be able to add the device to the [RemoteSettings Stage instance](https://remote-settings.allizom.org/v1/admin/#/buckets/main-workspace/collections/devtools-devices/) using the interface. +Then use the RemoteSettings DevTools to make Firefox pull the devices list from the Stage instance (see https://remote-settings.readthedocs.io/en/latest/support.html?highlight=devtools#how-do-i-setup-firefox-to-pull-data-from-stage) +Once that is done, open RDM and make sure you can see the new addition in the Devices modal. + +If everything is good, you can then ask for review on the data change. Once this get approved, you can replicate the same changes to the [RemoteSettings Prod instance](https://remote-settings.mozilla.org/v1/admin/#/buckets/main-workspace/collections/devtools-devices/), reset the RemoteSettings DevTools settings, check RDM again just to be sure and finally ask for review on the data change. + +## Things to consider in the future + +- Galaxy Fold has two screens, how do we handle that? diff --git a/devtools/client/responsive/images/grippers.svg b/devtools/client/responsive/images/grippers.svg new file mode 100644 index 0000000000..91db83af9d --- /dev/null +++ b/devtools/client/responsive/images/grippers.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#A5A5A5"> + <path d="M16 3.2L3.1 16h1.7L16 4.9zM16 7.2L7.1 16h1.8L16 8.9zM16 11.1L11.1 16h1.8l3.1-3.1z" /> +</svg> diff --git a/devtools/client/responsive/images/rotate-viewport.svg b/devtools/client/responsive/images/rotate-viewport.svg new file mode 100644 index 0000000000..2a9fd2f6a8 --- /dev/null +++ b/devtools/client/responsive/images/rotate-viewport.svg @@ -0,0 +1,7 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> + <path fill="context-fill" fill-rule="evenodd" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H2zm6 2H2v10h6V3z"/> + <path fill="context-fill" d="M10.5 4c.62 0 1.07.03 1.4.11.34.08.53.2.66.34.14.16.25.4.33.83.08.42.11.98.11 1.72h-1.43a.2.2 0 0 0-.15.33l1.93 2.33c.08.1.22.1.3 0l1.93-2.33a.2.2 0 0 0-.15-.33H14c0-.76-.03-1.39-.13-1.9-.09-.52-.26-.96-.56-1.3-.3-.35-.7-.55-1.17-.66-.46-.1-1-.14-1.64-.14v1z"/> +</svg> diff --git a/devtools/client/responsive/images/touch-events.svg b/devtools/client/responsive/images/touch-events.svg new file mode 100644 index 0000000000..42de2d8e04 --- /dev/null +++ b/devtools/client/responsive/images/touch-events.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> + <path fill="context-fill" d="M3.174 6.222A3.99 3.99 0 0 1 2.5 4a4 4 0 1 1 7.326 2.222l-.83-.556A2.99 2.99 0 0 0 9.5 4a3 3 0 1 0-5.495 1.666l-.831.556zM5 9.4V3.5a1.5 1.5 0 1 1 3 0v3.085a1.499 1.499 0 0 1 1.764.607 1.499 1.499 0 0 1 2 .5A1.5 1.5 0 0 1 14 9h-.004c0 .155-.023.313-.074.468l-1.791 5.494A1.5 1.5 0 0 1 10.739 16V16H6.695a2.5 2.5 0 0 1-2.202-1.315L1.975 10.01a1.5 1.5 0 0 1 .443-1.927l.04-.029c.04-.029.04-.029.083-.056a1.36 1.36 0 0 1 1.87.446L5 9.4zm6.18 5.253L12.97 9.16a.5.5 0 0 0-.847-.488A.498.498 0 0 0 12 9a.5.5 0 1 1-1 0v-.5a.5.5 0 1 0-1 0 .5.5 0 1 1-1 0V8a.5.5 0 1 0-1 0 .5.5 0 0 1-1 0V3.5a.5.5 0 0 0-1 0v7.667c0 .504-.662.691-.926.262L3.56 8.967a.36.36 0 0 0-.494-.118l-.007.004-.008.006-.007.005-.04.028a.5.5 0 0 0-.147.643l2.518 4.676a1.5 1.5 0 0 0 1.32.789h4.013v-.001a.5.5 0 0 0 .473-.346z"/> +</svg> diff --git a/devtools/client/responsive/index.css b/devtools/client/responsive/index.css new file mode 100644 index 0000000000..c8342bc8ea --- /dev/null +++ b/devtools/client/responsive/index.css @@ -0,0 +1,750 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* TODO: May break up into component local CSS. Pending future discussions by + * React component group on how to best handle CSS. */ + +/** + * CSS Variables specific to the responsive design mode + */ + +:root { + --toolbar-row-height: 28px; + --rdm-box-shadow: 0 4px 4px 0 rgba(155, 155, 155, 0.26); + --viewport-active-color: #3b3b3b; + --input-invalid-border-color: var(--red-60); + --custom-device-button-hover: var(--grey-30); + --device-list-grid-template-columns: auto auto max-content 1fr; +} + +:root.theme-dark { + --rdm-box-shadow: 0 4px 4px 0 rgba(105, 105, 105, 0.26); + --viewport-active-color: #fcfcfc; + --input-invalid-border-color: var(--red-50); + --custom-device-button-hover: var(--grey-70); +} + +* { + box-sizing: border-box; +} + +:root, +input, +button { + font-size: 12px; +} + +input, +button { + color: var(--theme-toolbar-color); + background-color: var(--theme-tab-toolbar-background); +} + +html, +body, +#root { + height: 100%; + overflow: hidden; + color: var(--theme-body-color); +} + +.text-input { + border: 1px solid var(--theme-splitter-color); + margin: 0 1px; + padding: 2px; + font-size: 12px; + line-height: 16px; +} + +.text-input.invalid { + outline-color: var(--input-invalid-border-color); +} + +#app { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +/** + * Common styles for shared components + */ + +.devtools-separator { + height: 100%; + margin: 0 1px; +} + +/** + * Toolbar + */ + +#toolbar { + background-color: var(--theme-tab-toolbar-background); + border-bottom: 1px solid var(--theme-splitter-color); + display: grid; + grid-template-columns: auto min-content; + width: 100%; + user-select: none; +} + +#toolbar :is(select, button):focus-visible { + /* Adjust the outline otherwise it's clipped at the top */ + outline-offset: -1px +} + +#toolbar-center-controls, +#toolbar-end-controls { + display: grid; + grid-auto-flow: column; + grid-auto-rows: var(--toolbar-row-height); + grid-row-gap: 1px; + padding: 0 1px; +} + +#toolbar-center-controls { + grid-gap: 2px; + grid-template-columns: + [device-selector] + minmax(auto, 120px) + [separator] + max-content + [size-selector] + max-content + [rotate-icon] + max-content + [separator] + max-content + [dpr] + minmax(auto, 60px) + [separator] + max-content + [throttling] + minmax(auto, max-content) + [separator] + max-content + [touch-simulation] + max-content; +} + +#toolbar.user-agent #toolbar-center-controls { + grid-template-columns: + [device-selector] + minmax(auto, 120px) + [separator] + max-content + [size-selector] + max-content + [rotate-icon] + max-content + [separator] + max-content + [dpr] + minmax(auto, 60px) + [separator] + max-content + [throttling] + minmax(auto, max-content) + [separator] + max-content + [ua] + minmax(auto, 300px) + [separator] + max-content + [touch-simulation] + max-content; +} + +#toolbar:not(.left-aligned) { + grid-template-columns: 1fr auto 1fr min-content; + justify-content: center; +} + +#toolbar:not(.left-aligned) #toolbar-center-controls { + grid-column-start: 2; +} + +#toolbar:not(.left-aligned) #toolbar-end-controls { + justify-content: end; +} + +#user-agent-label { + display: flex; + align-items: center; + margin-inline-start: 3px; + margin-inline-end: 3px; +} + +#user-agent-input { + margin-inline-start: 3px; + flex: 1; +} + +@media (max-width: 520px) { + /* When room becomes scarce, no need to constrain the selectors with a minmax like we + do in larger viewports, here they can occupy 1fr */ + #toolbar.user-agent #toolbar-center-controls { + grid-template-columns: + [device-selector] + .8fr + [separator] + max-content + [size-selector] + max-content + [rotate-icon] + max-content + [separator] + max-content + [dpr] + .6fr + [separator] + max-content + [throttling] + 1fr + [separator] + max-content + [touch-simulation] + max-content; + } + + /* Drop the user agent label to the next line if there isn't enough space */ + #user-agent-label { + grid-column: 1 / -1; + grid-row: 2; + margin-inline-start: 9px; + } + + /* Since the UA is hidden, no need for a separator after it */ + #user-agent-label + .devtools-separator { + display: none; + } + + /* When the UA label is here and it's on a second line, draw a separator between the + 2 lines*/ + #toolbar.user-agent { + background-image: linear-gradient(to bottom, + transparent var(--toolbar-row-height), + var(--theme-splitter-color) var(--toolbar-row-height), + var(--theme-splitter-color) calc(var(--toolbar-row-height) + 1px), + transparent 0); + } +} + +#rotate-button::before { + background-image: url("chrome://devtools/content/responsive/images/rotate-viewport.svg"); +} + +#rotate-button.viewport-orientation-landscape::before { + transform: rotate(90deg) scaleX(-1); +} + +#touch-simulation-button::before { + background-image: url("chrome://devtools/content/responsive/images/touch-events.svg"); +} + +#screenshot-button::before { + background-image: url("chrome://devtools/skin/images/command-screenshot.svg"); +} + +#settings-button::before { + background-image: url("chrome://devtools/skin/images/settings.svg"); +} + +#exit-button::before { + background-image: url("chrome://devtools/skin/images/close.svg"); +} + +/* Briefly animate screenshot button when clicked */ +#screenshot-button:disabled { + opacity: 1 !important; +} + +#screenshot-button:disabled::before { + fill: var(--theme-icon-checked-color); +} + +#device-selector { + justify-self: end; + max-width: 100%; + margin-inline-start: 4px; +} + +#device-selector .title { + display: grid; + grid-template-columns: var(--device-list-grid-template-columns); +} + +#device-selector .title .device-name, +#device-selector .title .device-os { + margin-inline-start: 6px; +} + +#device-selector::before { + fill: var(--theme-icon-dimmed-color); +} + +/* Override the style defined in tooltips.css */ +.tooltip-container[type="doorhanger"] .menuitem > .command.iconic.device-selector-item > .label::before { + fill: var(--theme-icon-dimmed-color); +} + +/** + * Viewport Dimension Input + */ + +.viewport-dimension { + display: flex; + align-items: center; + margin: 1px; +} + +.viewport-dimension-input { + text-align: center; + width: 3em; + appearance: textfield; +} + +.viewport-dimension-separator { + user-select: none; + padding: 0 0.2em; +} + +/** + * Device Modal + */ + +@keyframes fade-in-and-up { + 0% { + opacity: 0; + transform: translateY(5px); + } + 100% { + opacity: 1; + transform: translateY(0px); + } +} + +@keyframes fade-down-and-out { + 0% { + opacity: 1; + transform: translateY(0px); + } + 100% { + opacity: 0; + transform: translateY(5px); + visibility: hidden; + } +} + +.device-modal { + display: grid; + grid-template-rows: minmax(80px, auto) auto; + background-color: var(--theme-toolbar-background); + border: 1px solid var(--theme-splitter-color); + border-radius: 2px; + box-shadow: var(--rdm-box-shadow); + position: absolute; + margin: auto; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 90%; + height: 90%; + max-width: 750px; + max-height: 730px; + z-index: 1; + overflow: hidden; +} + +/* Handles the opening/closing of the modal */ +#device-modal-wrapper.opened .device-modal { + animation: fade-in-and-up 0.3s ease forwards; +} + +#device-modal-wrapper.closed .device-modal { + animation: fade-down-and-out 0.3s ease forwards; +} + +#device-modal-wrapper.opened .modal-overlay { + background-color: var(--theme-splitter-color); + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 0; + opacity: 0.5; +} + +.device-modal-content { + display: grid; + grid-row-gap: 30px; + overflow: auto; + height: 100%; + padding: 10px 32px 50px 32px; +} + +/* On screens that are >750px*/ +@media (min-width: 750px) { + #device-form { + grid-template-areas: "name size dpr" + "user-agent touch buttons"; + } + + #device-form-name input, + #device-form-user-agent input { + width: 350px; + } + + .device-modal-content { + grid-template-columns: 1fr 1fr; + grid-template-areas: "phone phone" + "tablet laptop" + "tv custom"; + } + + .device-type-phones .device-list { + grid-template-columns: repeat(2, var(--device-list-grid-template-columns)); + } +} + +/* On screens that are between 450px and 749px */ +@media (min-width: 450px) and (max-width: 749px) { + #device-form { + grid-template-areas: "name size" + "user-agent dpr" + "touch buttons"; + grid-template-columns: 2fr 1fr; + } + + #device-form-name { + grid-area: name; + } + + #device-form-name input, + #device-form-user-agent input { + width: 100%; + } +} + +/* On screens that are <450px */ +@media (max-width: 449px) { + #device-form { + grid-template-areas: "name" + "size" + "dpr" + "user-agent" + "touch" + "buttons"; + } + + #device-form-name input, + #device-form-user-agent input { + width: 90%; + } + + #device-form-size { + justify-self: unset; + } +} + +@media (max-width: 749px) { + .device-modal-content { + grid-template-areas: "phone" + "phone" + "tablet" + "laptop" + "tv" + "custom"; + } + + .device-modal-header { + flex-direction: column; + } +} + +#device-close-button { + position: absolute; + top: 5px; + right: 2px; +} + +#device-close-button::before { + background-image: url("chrome://devtools/skin/images/close.svg"); +} + +.device-type { + display: flex; + flex-direction: column; +} + +.device-header { + font-size: 17px; + margin-bottom: 7px; + height: 20px; + text-transform: capitalize; +} + +.device-label { + color: var(--theme-body-color); + padding-bottom: 5px; + padding-top: 5px; + align-items: center; + display: grid; + grid-column: span 4; + grid-template-columns: subgrid; +} + +.device-label > button { + visibility: hidden; +} + +.device-label > span { + margin-right: 6px; +} + +.device-label:focus-within > button, +.device-label:hover > button { + visibility: visible; +} + +.device-label:focus-within, +.device-label:hover { + background-color: var(--toolbarbutton-hover-background); +} + +.device-modal-header { + display: flex; + justify-content: space-between; +} + +.device-modal-header > #device-add-button { + margin: 30px 75px 0 30px; +} + +.device-list { + display: grid; + font-size: 13px; + grid-template-columns: var(--device-list-grid-template-columns); +} + +.device-input-checkbox { + margin-right: 8px; +} + +.device-modal-title { + font-size: 22px; + margin: 30px 0 0px 30px; +} + +.device-browser { + width: 16px; + height: 16px; + background-size: cover; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); +} + +.device-browser.chrome { + background-image: url("chrome://devtools/skin/images/browsers/chrome.svg"); +} + +.device-browser.edge { + background-image: url("chrome://devtools/skin/images/browsers/edge.svg"); +} + +.device-browser.firefox { + background-image: url("chrome://devtools/skin/images/browsers/firefox.svg"); +} + +.device-browser.ie { + background-image: url("chrome://devtools/skin/images/browsers/ie.svg"); +} + +.device-browser.opera { + background-image: url("chrome://devtools/skin/images/browsers/opera.svg"); +} + +.device-browser.safari { + background-image: url("chrome://devtools/skin/images/browsers/safari.svg"); +} + +.device-remove-button:empty::before { + background-image: url("chrome://devtools/skin/images/close.svg"); +} + +/** + * Device Form + */ + +#device-form { + display: grid; + width: 100%; + background-color: var(--theme-toolbar-background); + min-height: 150px; + padding: 15px 20px 10px; + border-bottom: 1px solid var(--theme-splitter-color); + overflow: auto; + z-index: 1; +} + +#device-add-button, +#device-form button { + background-color: rgba(12, 12, 13, 0.1); + border: 1px solid var(--theme-splitter-color); + border-radius: 2px; + cursor: pointer; + width: 167px; + height: 32px; +} + +#device-edit-button::before { + background: url("chrome://devtools/skin/images/pencil-icon.svg") no-repeat 0 0; +} + +#device-edit-button, +#device-edit-remove { + cursor: pointer; +} + +#device-edit-button:focus-within, +#device-edit-button:hover, +#device-edit-remove.device-remove-button:focus-within, +#device-edit-remove.device-remove-button:hover { + background-color: var(--custom-device-button-hover); +} + +#device-form label { + display: flex; + flex-direction: column; + margin: 5px; +} + +#device-form label > .viewport-dimension { + color: var(--theme-body-color); + display: flex; + align-items: center; +} + +#device-form input { + background: transparent; + border: 1px solid var(--theme-toolbarbutton-active-background); + border-radius: 2px; + text-align: center; + color: var(--theme-body-color); + transition: all 0.25s ease; +} + +#device-form #device-form-name input, +#device-form #device-form-user-agent input { + text-align: left; + padding-left: 12px; + padding-right: 12px; +} + +#device-form input:focus { + color: var(--viewport-active-color); +} + +#device-form-pixel-ratio { + grid-area: dpr; +} + +#device-form-pixel-ratio input { + appearance: textfield; +} + +#device-form-user-agent { + grid-area: user-agent; +} + +#device-form-name input, +#device-form-pixel-ratio input, +#device-form-user-agent input, +#device-form-size input { + height: 35px; +} + +#device-form #device-form-touch { + flex-direction: row; + grid-area: touch; +} + +#device-form-touch .device-form-label { + align-self: center; + margin-left: 5px; +} + +#device-form #device-form-save { + background-color: #0060DF; + color: #fff; + border: 1px solid #0060DF; + width: 60px; + + &:focus-visible { + /* Adjust outline so it's separated from the background color */ + outline-offset: 2px; + } +} + +#device-form-size { + grid-area: size; +} + +#device-form-size input, +#device-form #device-form-cancel { + width: 60px; +} + +#device-form-save, +#device-form-cancel { + align-self: center; +} + +.device-form-buttons { + display: flex; + grid-area: buttons; + justify-content: space-evenly; + width: 154px; +} + +.device-form-label { + display: inline-block; + margin: 0 5px 5px 0; + min-width: 35px; + font-size: 13px; +} + +/* Device Types */ + +.device-type-phones { + grid-area: phone; +} + +.device-type-custom { + grid-area: custom; + align-self: start; +} + +.device-type-custom .device-list { + grid-template-columns: var(--device-list-grid-template-columns) auto auto; +} + +.device-type-custom .device-label { + grid-column: span 6; +} + +.device-type-tablets { + grid-area: tablet; +} + +.device-type-laptops { + grid-area: laptop; +} + +.device-type-televisions { + grid-area: tv; +} diff --git a/devtools/client/responsive/index.js b/devtools/client/responsive/index.js new file mode 100644 index 0000000000..84bdf16e12 --- /dev/null +++ b/devtools/client/responsive/index.js @@ -0,0 +1,227 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env browser */ + +"use strict"; + +const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" +); +const { require } = BrowserLoader({ + baseURI: "resource://devtools/client/responsive/", + window, +}); +const Telemetry = require("resource://devtools/client/shared/telemetry.js"); + +const { + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + Provider, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const message = require("resource://devtools/client/responsive/utils/message.js"); +const App = createFactory( + require("resource://devtools/client/responsive/components/App.js") +); +const Store = require("resource://devtools/client/responsive/store.js"); +const { + loadDevices, + restoreDeviceState, +} = require("resource://devtools/client/responsive/actions/devices.js"); +const { + addViewport, + changePixelRatio, + removeDeviceAssociation, + resizeViewport, + zoomViewport, +} = require("resource://devtools/client/responsive/actions/viewports.js"); +const { + changeDisplayPixelRatio, + changeUserAgent, + toggleTouchSimulation, +} = require("resource://devtools/client/responsive/actions/ui.js"); + +// Exposed for use by tests +window.require = require; + +// Tell the ResponsiveUIManager that the frame script has begun initializing. +message.post(window, "script-init"); + +const bootstrap = { + telemetry: new Telemetry(), + + store: null, + + async init() { + this.telemetry.toolOpened("responsive", this); + + const store = (this.store = Store()); + const provider = createElement(Provider, { store }, App()); + this._root = document.querySelector("#root"); + ReactDOM.render(provider, this._root); + message.post(window, "init:done"); + + this.destroy = this.destroy.bind(this); + window.addEventListener("unload", this.destroy, { once: true }); + }, + + destroy() { + window.removeEventListener("unload", this.destroy, { once: true }); + + // unmount to stop async action and renders after destroy + ReactDOM.unmountComponentAtNode(this._root); + + this.store = null; + + this.telemetry.toolClosed("responsive", this); + this.telemetry = null; + }, + + /** + * While most actions will be dispatched by React components, some external + * APIs that coordinate with the larger browser UI may also have actions to + * to dispatch. They can do so here. + */ + dispatch(action) { + if (!this.store) { + // If actions are dispatched after store is destroyed, ignore them. This + // can happen in tests that close the tool quickly while async tasks like + // initDevices() below are still pending. + return Promise.resolve(); + } + return this.store.dispatch(action); + }, +}; + +// manager.js sends a message to signal init +message.wait(window, "init").then(() => bootstrap.init()); + +// manager.js sends a message to signal init is done, which can be used for delayed +// startup work that shouldn't block initial load +message.wait(window, "post-init").then(() => { + bootstrap.dispatch(loadDevices()).then(() => { + bootstrap.dispatch(restoreDeviceState()); + }); +}); + +window.destroy = () => bootstrap.destroy(); +// Allows quick testing of actions from the console +window.dispatch = action => bootstrap.dispatch(action); + +// Expose the store on window for testing +Object.defineProperty(window, "store", { + get: () => bootstrap.store, + enumerable: true, +}); + +// Dispatch a `changeDisplayPixelRatio` action when the browser's pixel ratio is changing. +// This is usually triggered when the user changes the monitor resolution, or when the +// browser's window is dragged to a different display with a different pixel ratio. +// TODO: It would be better to move this watching into the actor, so that it can be +// better synchronized with any overrides that might be applied. Also, reading a single +// value like this makes less sense with multiple viewports. +function onDevicePixelRatioChange() { + const dpr = window.devicePixelRatio; + const mql = window.matchMedia(`(resolution: ${dpr}dppx)`); + + function listener() { + bootstrap.dispatch(changeDisplayPixelRatio(window.devicePixelRatio)); + mql.removeListener(listener); + onDevicePixelRatioChange(); + } + + mql.addListener(listener); +} + +/** + * Called by manager.js to add the initial viewport based on the original page. + */ +window.addInitialViewport = ({ userContextId }) => { + try { + onDevicePixelRatioChange(); + bootstrap.dispatch(changeDisplayPixelRatio(window.devicePixelRatio)); + bootstrap.dispatch(addViewport(userContextId)); + } catch (e) { + console.error(e); + } +}; + +window.getAssociatedDevice = () => { + const { viewports } = bootstrap.store.getState(); + if (!viewports.length) { + return null; + } + + return viewports[0].device; +}; + +/** + * Called by manager.js when tests want to check the viewport size. + */ +window.getViewportSize = () => { + const { viewports } = bootstrap.store.getState(); + if (!viewports.length) { + return null; + } + + const { width, height } = viewports[0]; + return { width, height }; +}; + +/** + * Called by manager.js to set viewport size from tests, etc. + */ +window.setViewportSize = ({ width, height }) => { + try { + bootstrap.dispatch(resizeViewport(0, width, height)); + } catch (e) { + console.error(e); + } +}; + +window.clearDeviceAssociation = () => { + try { + bootstrap.dispatch(removeDeviceAssociation(0)); + bootstrap.dispatch(toggleTouchSimulation(false)); + bootstrap.dispatch(changePixelRatio(0, 0)); + bootstrap.dispatch(changeUserAgent("")); + } catch (e) { + console.error(e); + } +}; + +/** + * Called by manager.js to access the viewport's browser, either for testing + * purposes or to reload it when touch simulation is enabled. + * A messageManager getter is added on the object to provide an easy access + * to the message manager without pulling the frame loader. + */ +window.getViewportBrowser = () => { + const browser = document.querySelector("iframe.browser"); + if (browser && !browser.messageManager) { + Object.defineProperty(browser, "messageManager", { + get() { + return this.frameLoader.messageManager; + }, + configurable: true, + enumerable: true, + }); + } + return browser; +}; + +/** + * Called by manager.js to zoom the viewport. + */ +window.setViewportZoom = zoom => { + try { + bootstrap.dispatch(zoomViewport(0, zoom)); + } catch (e) { + console.error(e); + } +}; diff --git a/devtools/client/responsive/manager.js b/devtools/client/responsive/manager.js new file mode 100644 index 0000000000..2491b7c67f --- /dev/null +++ b/devtools/client/responsive/manager.js @@ -0,0 +1,292 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +loader.lazyRequireGetter( + this, + "ResponsiveUI", + "resource://devtools/client/responsive/ui.js" +); +loader.lazyRequireGetter( + this, + "startup", + "resource://devtools/client/responsive/utils/window.js", + true +); +loader.lazyRequireGetter( + this, + "showNotification", + "resource://devtools/client/responsive/utils/notification.js", + true +); +loader.lazyRequireGetter( + this, + "l10n", + "resource://devtools/client/responsive/utils/l10n.js" +); +loader.lazyRequireGetter( + this, + "PriorityLevels", + "resource://devtools/client/shared/components/NotificationBox.js", + true +); +loader.lazyRequireGetter( + this, + "gDevTools", + "resource://devtools/client/framework/devtools.js", + true +); +loader.lazyRequireGetter( + this, + "gDevToolsBrowser", + "resource://devtools/client/framework/devtools-browser.js", + true +); +loader.lazyRequireGetter( + this, + "Telemetry", + "resource://devtools/client/shared/telemetry.js" +); + +/** + * ResponsiveUIManager is the external API for the browser UI, etc. to use when + * opening and closing the responsive UI. + */ +class ResponsiveUIManager { + constructor() { + this.activeTabs = new Map(); + + this.handleMenuCheck = this.handleMenuCheck.bind(this); + + EventEmitter.decorate(this); + } + + get telemetry() { + if (!this._telemetry) { + this._telemetry = new Telemetry(); + } + + return this._telemetry; + } + + /** + * Toggle the responsive UI for a tab. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with toggling. Currently includes: + * - `trigger`: String denoting the UI entry point, such as: + * - `toolbox`: Toolbox Button + * - `menu`: Browser Tools menu item + * - `shortcut`: Keyboard shortcut + * @return Promise + * Resolved when the toggling has completed. If the UI has opened, + * it is resolved to the ResponsiveUI instance for this tab. If the + * the UI has closed, there is no resolution value. + */ + toggle(window, tab, options = {}) { + const completed = this._toggleForTab(window, tab, options); + completed.catch(console.error); + return completed; + } + + _toggleForTab(window, tab, options) { + if (this.isActiveForTab(tab)) { + return this.closeIfNeeded(window, tab, options); + } + + return this.openIfNeeded(window, tab, options); + } + + /** + * Opens the responsive UI, if not already open. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with opening. Currently includes: + * - `trigger`: String denoting the UI entry point, such as: + * - `toolbox`: Toolbox Button + * - `menu`: Browser Tools menu item + * - `shortcut`: Keyboard shortcut + * @return Promise + * Resolved to the ResponsiveUI instance for this tab when opening is + * complete. + */ + async openIfNeeded(window, tab, options = {}) { + if (!this.isActiveForTab(tab)) { + this.initMenuCheckListenerFor(window); + + const ui = new ResponsiveUI(this, window, tab); + this.activeTabs.set(tab, ui); + + // Explicitly not await on telemetry to avoid delaying RDM opening + this.recordTelemetryOpen(window, tab, options); + + await gDevToolsBrowser.loadBrowserStyleSheet(window); + await this.setMenuCheckFor(tab, window); + await ui.inited; + this.emit("on", { tab }); + } + + return this.getResponsiveUIForTab(tab); + } + + /** + * Record all telemetry probes related to RDM opening. + */ + recordTelemetryOpen(window, tab, options) { + // Track whether a toolbox was opened before RDM was opened. + const toolbox = gDevTools.getToolboxForTab(tab); + const hostType = toolbox ? toolbox.hostType : "none"; + const hasToolbox = !!toolbox; + + if (hasToolbox) { + this.telemetry.scalarAdd("devtools.responsive.toolbox_opened_first", 1); + } + + this.telemetry.recordEvent("activate", "responsive_design", null, { + host: hostType, + width: Math.ceil(window.outerWidth / 50) * 50, + }); + + // Track opens keyed by the UI entry point used. + let { trigger } = options; + if (!trigger) { + trigger = "unknown"; + } + this.telemetry.keyedScalarAdd( + "devtools.responsive.open_trigger", + trigger, + 1 + ); + } + + /** + * Closes the responsive UI, if not already closed. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with closing. Currently includes: + * - `trigger`: String denoting the UI entry point, such as: + * - `toolbox`: Toolbox Button + * - `menu`: Browser Tools menu item + * - `shortcut`: Keyboard shortcut + * - `reason`: String detailing the specific cause for closing + * @return Promise + * Resolved (with no value) when closing is complete. + */ + async closeIfNeeded(window, tab, options = {}) { + if (this.isActiveForTab(tab)) { + const ui = this.activeTabs.get(tab); + const destroyed = await ui.destroy(options); + if (!destroyed) { + // Already in the process of destroying, abort. + return; + } + + this.activeTabs.delete(tab); + + if (!this.isActiveForWindow(window)) { + this.removeMenuCheckListenerFor(window); + } + this.emit("off", { tab }); + await this.setMenuCheckFor(tab, window); + + // Explicitly not await on telemetry to avoid delaying RDM closing + this.recordTelemetryClose(window, tab); + } + } + + recordTelemetryClose(window, tab) { + const toolbox = gDevTools.getToolboxForTab(tab); + + const hostType = toolbox ? toolbox.hostType : "none"; + + this.telemetry.recordEvent("deactivate", "responsive_design", null, { + host: hostType, + width: Math.ceil(window.outerWidth / 50) * 50, + }); + } + + /** + * Returns true if responsive UI is active for a given tab. + * + * @param tab + * The browser tab. + * @return boolean + */ + isActiveForTab(tab) { + return this.activeTabs.has(tab); + } + + /** + * Returns true if responsive UI is active in any tab in the given window. + * + * @param window + * The main browser chrome window. + * @return boolean + */ + isActiveForWindow(window) { + return [...this.activeTabs.keys()].some(t => t.ownerGlobal === window); + } + + /** + * Return the responsive UI controller for a tab. + * + * @param tab + * The browser tab. + * @return ResponsiveUI + * The UI instance for this tab. + */ + getResponsiveUIForTab(tab) { + return this.activeTabs.get(tab); + } + + handleMenuCheck({ target }) { + this.setMenuCheckFor(target); + } + + initMenuCheckListenerFor(window) { + const { tabContainer } = window.gBrowser; + tabContainer.addEventListener("TabSelect", this.handleMenuCheck); + } + + removeMenuCheckListenerFor(window) { + if (window?.gBrowser?.tabContainer) { + const { tabContainer } = window.gBrowser; + tabContainer.removeEventListener("TabSelect", this.handleMenuCheck); + } + } + + async setMenuCheckFor(tab, window = tab.ownerGlobal) { + await startup(window); + + const menu = window.document.getElementById("menu_responsiveUI"); + if (menu) { + menu.setAttribute("checked", this.isActiveForTab(tab)); + } + } + + showRemoteOnlyNotification(window, tab, { trigger } = {}) { + return showNotification(window, tab, { + toolboxButton: trigger == "toolbox", + msg: l10n.getStr("responsive.remoteOnly"), + priority: PriorityLevels.PRIORITY_CRITICAL_MEDIUM, + }); + } +} + +module.exports = new ResponsiveUIManager(); diff --git a/devtools/client/responsive/moz.build b/devtools/client/responsive/moz.build new file mode 100644 index 0000000000..d10333a350 --- /dev/null +++ b/devtools/client/responsive/moz.build @@ -0,0 +1,30 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "actions", + "components", + "reducers", + "utils", +] + +DevToolsModules( + "constants.js", + "index.js", + "manager.js", + "reducers.js", + "store.js", + "types.js", + "ui.js", +) + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Responsive Design Mode") + +SPHINX_TREES["/devtools/responsive"] = "docs" diff --git a/devtools/client/responsive/reducers.js b/devtools/client/responsive/reducers.js new file mode 100644 index 0000000000..434ef7a7ed --- /dev/null +++ b/devtools/client/responsive/reducers.js @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +exports.devices = require("resource://devtools/client/responsive/reducers/devices.js"); +exports.networkThrottling = require("resource://devtools/client/shared/components/throttling/reducer.js"); +exports.screenshot = require("resource://devtools/client/responsive/reducers/screenshot.js"); +exports.ui = require("resource://devtools/client/responsive/reducers/ui.js"); +exports.viewports = require("resource://devtools/client/responsive/reducers/viewports.js"); diff --git a/devtools/client/responsive/reducers/devices.js b/devtools/client/responsive/reducers/devices.js new file mode 100644 index 0000000000..98f0602175 --- /dev/null +++ b/devtools/client/responsive/reducers/devices.js @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + ADD_DEVICE, + ADD_DEVICE_TYPE, + EDIT_DEVICE, + LOAD_DEVICE_LIST_START, + LOAD_DEVICE_LIST_ERROR, + LOAD_DEVICE_LIST_END, + REMOVE_DEVICE, + UPDATE_DEVICE_DISPLAYED, + UPDATE_DEVICE_MODAL, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const Types = require("resource://devtools/client/responsive/types.js"); + +const INITIAL_DEVICES = { + isModalOpen: false, + listState: Types.loadableState.INITIALIZED, + modalOpenedFromViewport: null, + types: [], +}; + +const reducers = { + [ADD_DEVICE](devices, { device, deviceType }) { + return { + ...devices, + [deviceType]: [...devices[deviceType], device], + }; + }, + + [ADD_DEVICE_TYPE](devices, { deviceType }) { + return { + ...devices, + types: [...devices.types, deviceType], + [deviceType]: [], + }; + }, + + [EDIT_DEVICE](devices, { oldDevice, newDevice, deviceType }) { + const index = devices[deviceType].indexOf(oldDevice); + if (index < 0) { + return devices; + } + + devices[deviceType].splice(index, 1, newDevice); + + return { + ...devices, + [deviceType]: [...devices[deviceType]], + }; + }, + + [UPDATE_DEVICE_DISPLAYED](devices, { device, deviceType, displayed }) { + const newDevices = devices[deviceType].map(d => { + if (d == device) { + d.displayed = displayed; + } + + return d; + }); + + return { + ...devices, + [deviceType]: newDevices, + }; + }, + + [LOAD_DEVICE_LIST_START](devices, action) { + return { + ...devices, + listState: Types.loadableState.LOADING, + }; + }, + + [LOAD_DEVICE_LIST_ERROR](devices, action) { + return { + ...devices, + listState: Types.loadableState.ERROR, + }; + }, + + [LOAD_DEVICE_LIST_END](devices, action) { + return { + ...devices, + listState: Types.loadableState.LOADED, + }; + }, + + [REMOVE_DEVICE](devices, { device, deviceType }) { + const index = devices[deviceType].indexOf(device); + if (index < 0) { + return devices; + } + + const list = [...devices[deviceType]]; + list.splice(index, 1); + + return { + ...devices, + [deviceType]: list, + }; + }, + + [UPDATE_DEVICE_MODAL](devices, { isOpen, modalOpenedFromViewport }) { + return { + ...devices, + isModalOpen: isOpen, + modalOpenedFromViewport, + }; + }, +}; + +module.exports = function (devices = INITIAL_DEVICES, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return devices; + } + return reducer(devices, action); +}; diff --git a/devtools/client/responsive/reducers/moz.build b/devtools/client/responsive/reducers/moz.build new file mode 100644 index 0000000000..9f2fb54e16 --- /dev/null +++ b/devtools/client/responsive/reducers/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "devices.js", + "screenshot.js", + "ui.js", + "viewports.js", +) diff --git a/devtools/client/responsive/reducers/screenshot.js b/devtools/client/responsive/reducers/screenshot.js new file mode 100644 index 0000000000..67f26f1f9a --- /dev/null +++ b/devtools/client/responsive/reducers/screenshot.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TAKE_SCREENSHOT_END, + TAKE_SCREENSHOT_START, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const INITIAL_SCREENSHOT = { + isCapturing: false, +}; + +const reducers = { + [TAKE_SCREENSHOT_END](screenshot, action) { + return { + ...screenshot, + isCapturing: false, + }; + }, + + [TAKE_SCREENSHOT_START](screenshot, action) { + return { + ...screenshot, + isCapturing: true, + }; + }, +}; + +module.exports = function (screenshot = INITIAL_SCREENSHOT, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return screenshot; + } + return reducer(screenshot, action); +}; diff --git a/devtools/client/responsive/reducers/ui.js b/devtools/client/responsive/reducers/ui.js new file mode 100644 index 0000000000..bf03353bf3 --- /dev/null +++ b/devtools/client/responsive/reducers/ui.js @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + CHANGE_DISPLAY_PIXEL_RATIO, + CHANGE_USER_AGENT, + TOGGLE_LEFT_ALIGNMENT, + TOGGLE_RELOAD_ON_TOUCH_SIMULATION, + TOGGLE_RELOAD_ON_USER_AGENT, + TOGGLE_TOUCH_SIMULATION, + TOGGLE_USER_AGENT_INPUT, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const LEFT_ALIGNMENT_ENABLED = "devtools.responsive.leftAlignViewport.enabled"; +const RELOAD_ON_TOUCH_SIMULATION = + "devtools.responsive.reloadConditions.touchSimulation"; +const RELOAD_ON_USER_AGENT = "devtools.responsive.reloadConditions.userAgent"; +const SHOW_USER_AGENT_INPUT = "devtools.responsive.showUserAgentInput"; +const TOUCH_SIMULATION_ENABLED = "devtools.responsive.touchSimulation.enabled"; +const USER_AGENT = "devtools.responsive.userAgent"; + +const INITIAL_UI = { + // The pixel ratio of the display. + displayPixelRatio: 0, + // Whether or not the viewports are left aligned. + leftAlignmentEnabled: Services.prefs.getBoolPref( + LEFT_ALIGNMENT_ENABLED, + false + ), + // Whether or not to reload when touch simulation is toggled. + reloadOnTouchSimulation: Services.prefs.getBoolPref( + RELOAD_ON_TOUCH_SIMULATION, + false + ), + // Whether or not to reload when user agent is changed. + reloadOnUserAgent: Services.prefs.getBoolPref(RELOAD_ON_USER_AGENT, false), + // Whether or not to show the user agent input in the toolbar. + showUserAgentInput: Services.prefs.getBoolPref(SHOW_USER_AGENT_INPUT, false), + // Whether or not touch simulation is enabled. + touchSimulationEnabled: Services.prefs.getBoolPref( + TOUCH_SIMULATION_ENABLED, + false + ), + // The user agent of the viewport. + userAgent: Services.prefs.getCharPref(USER_AGENT, ""), +}; + +const reducers = { + [CHANGE_DISPLAY_PIXEL_RATIO](ui, { displayPixelRatio }) { + return { + ...ui, + displayPixelRatio, + }; + }, + + [CHANGE_USER_AGENT](ui, { userAgent }) { + Services.prefs.setCharPref(USER_AGENT, userAgent); + + return { + ...ui, + userAgent, + }; + }, + + [TOGGLE_LEFT_ALIGNMENT](ui, { enabled }) { + const leftAlignmentEnabled = + enabled !== undefined ? enabled : !ui.leftAlignmentEnabled; + + Services.prefs.setBoolPref(LEFT_ALIGNMENT_ENABLED, leftAlignmentEnabled); + + return { + ...ui, + leftAlignmentEnabled, + }; + }, + + [TOGGLE_RELOAD_ON_TOUCH_SIMULATION](ui, { enabled }) { + const reloadOnTouchSimulation = + enabled !== undefined ? enabled : !ui.reloadOnTouchSimulation; + + Services.prefs.setBoolPref( + RELOAD_ON_TOUCH_SIMULATION, + reloadOnTouchSimulation + ); + + return { + ...ui, + reloadOnTouchSimulation, + }; + }, + + [TOGGLE_RELOAD_ON_USER_AGENT](ui, { enabled }) { + const reloadOnUserAgent = + enabled !== undefined ? enabled : !ui.reloadOnUserAgent; + + Services.prefs.setBoolPref(RELOAD_ON_USER_AGENT, reloadOnUserAgent); + + return { + ...ui, + reloadOnUserAgent, + }; + }, + + [TOGGLE_TOUCH_SIMULATION](ui, { enabled }) { + Services.prefs.setBoolPref(TOUCH_SIMULATION_ENABLED, enabled); + + return { + ...ui, + touchSimulationEnabled: enabled, + }; + }, + + [TOGGLE_USER_AGENT_INPUT](ui, { enabled }) { + const showUserAgentInput = + enabled !== undefined ? enabled : !ui.showUserAgentInput; + + Services.prefs.setBoolPref(SHOW_USER_AGENT_INPUT, showUserAgentInput); + + return { + ...ui, + showUserAgentInput, + }; + }, +}; + +module.exports = function (ui = INITIAL_UI, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return ui; + } + return reducer(ui, action); +}; diff --git a/devtools/client/responsive/reducers/viewports.js b/devtools/client/responsive/reducers/viewports.js new file mode 100644 index 0000000000..71ced90975 --- /dev/null +++ b/devtools/client/responsive/reducers/viewports.js @@ -0,0 +1,212 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + ADD_VIEWPORT, + CHANGE_DEVICE, + CHANGE_PIXEL_RATIO, + CHANGE_VIEWPORT_ANGLE, + EDIT_DEVICE, + REMOVE_DEVICE_ASSOCIATION, + RESIZE_VIEWPORT, + ROTATE_VIEWPORT, + ZOOM_VIEWPORT, +} = require("resource://devtools/client/responsive/actions/index.js"); + +const VIEWPORT_WIDTH_PREF = "devtools.responsive.viewport.width"; +const VIEWPORT_HEIGHT_PREF = "devtools.responsive.viewport.height"; +const VIEWPORT_PIXEL_RATIO_PREF = "devtools.responsive.viewport.pixelRatio"; +const VIEWPORT_ANGLE_PREF = "devtools.responsive.viewport.angle"; + +let nextViewportId = 0; + +const INITIAL_VIEWPORTS = []; +const INITIAL_VIEWPORT = { + id: nextViewportId++, + angle: Services.prefs.getIntPref(VIEWPORT_ANGLE_PREF, 0), + device: "", + deviceType: "", + height: Services.prefs.getIntPref(VIEWPORT_HEIGHT_PREF, 480), + width: Services.prefs.getIntPref(VIEWPORT_WIDTH_PREF, 320), + pixelRatio: Services.prefs.getIntPref(VIEWPORT_PIXEL_RATIO_PREF, 0), + userContextId: 0, + zoom: 1, +}; + +const reducers = { + [ADD_VIEWPORT](viewports, { userContextId }) { + // For the moment, there can be at most one viewport. + if (viewports.length === 1) { + return viewports; + } + + return [ + ...viewports, + { + ...INITIAL_VIEWPORT, + userContextId, + }, + ]; + }, + + [CHANGE_DEVICE](viewports, { id, device, deviceType }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + return { + ...viewport, + device, + deviceType, + }; + }); + }, + + [CHANGE_PIXEL_RATIO](viewports, { id, pixelRatio }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + Services.prefs.setIntPref(VIEWPORT_PIXEL_RATIO_PREF, pixelRatio); + + return { + ...viewport, + pixelRatio, + }; + }); + }, + + [CHANGE_VIEWPORT_ANGLE](viewports, { id, angle }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + Services.prefs.setIntPref(VIEWPORT_ANGLE_PREF, angle); + + return { + ...viewport, + angle, + }; + }); + }, + + [EDIT_DEVICE](viewports, { viewport, newDevice, deviceType }) { + if (!viewport) { + return viewports; + } + + return viewports.map(v => { + if (v.id !== viewport.id) { + return viewport; + } + + Services.prefs.setIntPref(VIEWPORT_WIDTH_PREF, newDevice.width); + Services.prefs.setIntPref(VIEWPORT_HEIGHT_PREF, newDevice.height); + Services.prefs.setIntPref( + VIEWPORT_PIXEL_RATIO_PREF, + newDevice.pixelRatio + ); + + return { + ...viewport, + device: newDevice.name, + deviceType, + height: newDevice.height, + width: newDevice.width, + pixelRatio: newDevice.pixelRatio, + userAgent: newDevice.userAgent, + touch: newDevice.touch, + }; + }); + }, + + [REMOVE_DEVICE_ASSOCIATION](viewports, { id }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + return { + ...viewport, + device: "", + deviceType: "", + }; + }); + }, + + [RESIZE_VIEWPORT](viewports, { id, width, height }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + if (!height) { + height = viewport.height; + } + + if (!width) { + width = viewport.width; + } + + Services.prefs.setIntPref(VIEWPORT_WIDTH_PREF, width); + Services.prefs.setIntPref(VIEWPORT_HEIGHT_PREF, height); + + return { + ...viewport, + height, + width, + }; + }); + }, + + [ROTATE_VIEWPORT](viewports, { id }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + const height = viewport.width; + const width = viewport.height; + + Services.prefs.setIntPref(VIEWPORT_WIDTH_PREF, width); + Services.prefs.setIntPref(VIEWPORT_HEIGHT_PREF, height); + + return { + ...viewport, + height, + width, + }; + }); + }, + + [ZOOM_VIEWPORT](viewports, { id, zoom }) { + return viewports.map(viewport => { + if (viewport.id !== id) { + return viewport; + } + + if (!zoom) { + zoom = viewport.zoom; + } + + return { + ...viewport, + zoom, + }; + }); + }, +}; + +module.exports = function (viewports = INITIAL_VIEWPORTS, action) { + const reducer = reducers[action.type]; + if (!reducer) { + return viewports; + } + return reducer(viewports, action); +}; diff --git a/devtools/client/responsive/responsive-browser.css b/devtools/client/responsive/responsive-browser.css new file mode 100644 index 0000000000..86aa689e71 --- /dev/null +++ b/devtools/client/responsive/responsive-browser.css @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#appcontent[devtoolstheme="light"] { + --rdm-browser-container-background: #F5F5F6; + --rdm-browser-box-shadow: 0 4px 4px 0 rgba(155, 155, 155, 0.26); +} + +#appcontent[devtoolstheme="dark"] { + --rdm-browser-container-background: #18181a; /* This should map to --theme-toolbar-background. */ + --rdm-browser-box-shadow: 0 4px 4px 0 rgba(105, 105, 105, 0.26); +} + +.browserContainer.responsive-mode { + background-color: var(--rdm-browser-container-background); +} + +.browserContainer.responsive-mode > .rdm-toolbar { + border: none; + position: absolute; + z-index: var(--browser-stack-z-index-rdm-toolbar); + height: 30px; + top: 0; + left: 0; + width: 100%; +} + +.browserContainer.responsive-mode > .rdm-toolbar.accomodate-ua { + height: 60px; +} + +.browserContainer.responsive-mode > .rdm-toolbar.device-modal-opened { + height: 100%; +} + +.browserContainer.responsive-mode > .browserStack { + --resizer-offset: 4px; + --browser-viewport-width: calc(var(--rdm-width) + var(--resizer-offset)); + --browser-viewport-height: calc(var(--rdm-height) + var(--resizer-offset)); + --rdm-zoomed-width: calc(var(--rdm-width) * var(--rdm-zoom)); + --rdm-zoomed-height: calc(var(--rdm-height) * var(--rdm-zoom)); + min-height: 0; + min-width: 0; + overflow: auto; + contain: size; + grid-template-columns: 15px 1fr [center-align] var(--browser-viewport-width) 1fr; + grid-template-rows: 65px [margin-top-offset] var(--browser-viewport-height); +} + +.browserContainer.responsive-mode > .browserStack > :is(browser, .rdm-viewport-resize-handle) { + grid-column: center-align; + grid-row: margin-top-offset; +} + +.browserContainer.responsive-mode.left-aligned > .browserStack > :is(browser, .rdm-viewport-resize-handle) { + grid-column: left-align; +} + +.browserContainer.responsive-mode.left-aligned > .browserStack { + grid-template-columns: 15px [left-align] var(--browser-viewport-width) 1fr; +} + +html[dir="rtl"] .browserContainer.responsive-mode.left-aligned > .browserStack { + grid-template-columns: 1fr [left-align] var(--browser-viewport-width) 15px +} + +.browserContainer.responsive-mode > .browserStack > browser { + border: 1px solid var(--devtools-splitter-color); + box-shadow: var(--rdm-browser-box-shadow); + transform-origin: 50% 0; + transform: scale(var(--rdm-zoom), var(--rdm-zoom)); + + box-sizing: content-box; /* This is important to ensure that the pane has the + precise number of pixels defined by --rdm-width + and --rdm-height. */ + height: var(--rdm-height); + width: var(--rdm-width); +} + +.browserContainer.responsive-mode.left-aligned > .browserStack > browser { + transform-origin: 0 0; +} + +/* Resize handles */ + +.browserContainer.responsive-mode > .browserStack > .viewport-resize-handle { + width: 16px; + height: 16px; + background-image: url("./images/grippers.svg"); + margin-inline-end: var(--resizer-offset); + margin-block-end: var(--resizer-offset); + background-repeat: no-repeat; + background-origin: content-box; + cursor: nwse-resize; + align-self: end; + justify-self: right; + position: relative; + left: calc((var(--rdm-zoomed-width) - var(--rdm-width)) / 2); + top: calc(var(--rdm-zoomed-height) - var(--rdm-height)); +} + +.browserContainer.responsive-mode.left-aligned > .browserStack > .viewport-resize-handle { + left: calc(var(--rdm-zoomed-width) - var(--rdm-width)); +} + +.browserContainer.responsive-mode > .browserStack > .viewport-horizontal-resize-handle { + width: 5px; + height: calc(var(--rdm-zoomed-height) - 16px); + cursor: ew-resize; + justify-self: right; + position: relative; + left: calc((var(--rdm-zoomed-width) - var(--rdm-width)) / 2); +} + +.browserContainer.responsive-mode.left-aligned > .browserStack > .viewport-horizontal-resize-handle { + left: calc(var(--rdm-zoomed-width) - var(--rdm-width)); +} + +.browserContainer.responsive-mode > .browserStack > .viewport-vertical-resize-handle { + width: calc(var(--rdm-zoomed-width) - 16px); + height: 5px; + cursor: ns-resize; + align-self: end; + position: relative; + top: calc(var(--rdm-zoomed-height) - var(--rdm-height)); + left: calc((-1 * (var(--rdm-zoomed-width) - var(--rdm-width)) / 2)); +} + +.browserContainer.responsive-mode.left-aligned > .browserStack > .viewport-vertical-resize-handle { + left: 0; +} diff --git a/devtools/client/responsive/store.js b/devtools/client/responsive/store.js new file mode 100644 index 0000000000..10b283a56a --- /dev/null +++ b/devtools/client/responsive/store.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducers = require("resource://devtools/client/responsive/reducers.js"); + +module.exports = () => + createStore(reducers, { + // Uncomment this for logging in tests. + shouldLog: true, + }); diff --git a/devtools/client/responsive/test/browser/browser.toml b/devtools/client/responsive/test/browser/browser.toml new file mode 100644 index 0000000000..d71267d360 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser.toml @@ -0,0 +1,207 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +skip-if = ["os == 'win'"] # Win: Bug 1319248 +support-files = [ + "contextual_identity.html", + "doc_contextmenu_inspect.html", + "doc_page_state.html", + "doc_picker_link.html", + "doc_toolbox_rule_view.css", + "doc_toolbox_rule_view.html", + "doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs", + "favicon.html", + "favicon.ico", + "geolocation.html", + "head.js", + "hover.html", + "page_style.html", + "sjs_redirection.sjs", + "touch_event_bubbles.html", + "touch_event_target.html", + "touch.html", + "!/devtools/client/inspector/test/shared-head.js", + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "!/gfx/layers/apz/test/mochitest/apz_test_utils.js", + "!/testing/mochitest/tests/SimpleTest/paint_listener.js", +] + + +["browser_cmd_click.js"] +https_first_disabled = true + +["browser_container_tab.js"] +skip-if = ["os == 'linux'"] # Bug 1625501, bug 1629729 + +["browser_contextmenu_inspect.js"] + +["browser_device_change.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_device_custom.js"] +skip-if = ["a11y_checks"] # Bug 1858041 and 1849028 intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_device_custom_edit.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_device_custom_remove.js"] +skip-if = ["a11y_checks"] # Bug 1849028, 1858041 to investigate intermittent a11y_checks results (fails on Try, passes on Autoland) + +["browser_device_modal_exit.js"] + +["browser_device_modal_items.js"] + +["browser_device_modal_submit.js"] +skip-if = ["a11y_checks"] # Bug 1858041 and 1849028 intermittent a11y_checks results (fails on Autoland, passes on Try) + +["browser_device_pixel_ratio_change.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_device_selector_items.js"] + +["browser_device_state_restore.js"] + +["browser_device_width.js"] + +["browser_exit_button.js"] + +["browser_ext_messaging.js"] +https_first_disabled = true +tags = "devtools webextensions" + +["browser_in_rdm_pane.js"] + +["browser_many_toggles.js"] +skip-if = ["verify"] # Too many exceptions happening on test teardown + +["browser_max_touchpoints.js"] + +["browser_menu_item_01.js"] + +["browser_menu_item_02.js"] + +["browser_mouse_resize.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_navigation.js"] +https_first_disabled = true + +["browser_network_throttling.js"] + +["browser_orientationchange_event.js"] + +["browser_page_redirection.js"] + +["browser_page_state.js"] +https_first_disabled = true + +["browser_page_style.js"] + +["browser_permission_doorhanger.js"] +tags = "devtools geolocation" +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_picker_link.js"] + +["browser_preloaded_newtab.js"] + +["browser_screenshot_button.js"] + +["browser_screenshot_button_warning.js"] +https_first_disabled = true + +["browser_scroll.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_state_restore.js"] + +["browser_tab_close.js"] + +["browser_tab_not_selected.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked button#device-pixel-ratio-menu element may not be focusable + +["browser_tab_remoteness_change.js"] + +["browser_tab_remoteness_change_fission_switch_target.js"] + +["browser_target_blank.js"] +https_first_disabled = true + +["browser_telemetry_activate_rdm.js"] + +["browser_toolbox_computed_view.js"] + +["browser_toolbox_rule_view.js"] + +["browser_toolbox_rule_view_reload.js"] + +["browser_toolbox_swap_browsers.js"] + +["browser_toolbox_swap_inspector.js"] + +["browser_tooltip.js"] + +["browser_touch_device.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_touch_does_not_trigger_hover_states.js"] + +["browser_touch_event_iframes.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled +skip-if = [ + "http3", # Bug 1829298 + "http2", + "os == 'linux' && os_version == '18.04' && debug", # Bug 1717330 +] + +["browser_touch_event_should_bubble.js"] + +["browser_touch_pointerevents.js"] + +["browser_touch_simulation.js"] +https_first_disabled = true +skip-if = ["debug"] # timing-senstive tests should only run on optimized builds + +["browser_typeahead_find.js"] + +["browser_user_agent_input.js"] + +["browser_viewport_basics.js"] +https_first_disabled = true + +["browser_viewport_changed_meta.js"] +skip-if = ["a11y_checks"] # Bug 1849028 and 1824058 for causing intermittent crashes + +["browser_viewport_fallback_width.js"] + +["browser_viewport_resizing_after_reload.js"] + +["browser_viewport_resizing_fixed_width.js"] + +["browser_viewport_resizing_fixed_width_and_zoom.js"] + +["browser_viewport_resizing_minimum_scale.js"] + +["browser_viewport_resizing_scrollbar.js"] + +["browser_viewport_resolution_restore.js"] + +["browser_viewport_state_after_close.js"] + +["browser_viewport_zoom_resolution_invariant.js"] + +["browser_viewport_zoom_toggle.js"] + +["browser_window_close.js"] + +["browser_window_sizing.js"] + +["browser_zoom.js"] diff --git a/devtools/client/responsive/test/browser/browser_cmd_click.js b/devtools/client/responsive/test/browser/browser_cmd_click.js new file mode 100644 index 0000000000..25cdca20a4 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_cmd_click.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Ensure Cmd/Ctrl-clicking link opens a new tab + +const TAB_URL = "https://example.com/"; +const TEST_URL = `data:text/html,<a href="${TAB_URL}">Click me</a>`.replace( + / /g, + "%20" +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + // Cmd-click the link and wait for a new tab + await waitForFrameLoad(ui, TEST_URL); + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, TAB_URL); + BrowserTestUtils.synthesizeMouseAtCenter( + "a", + { + ctrlKey: true, + metaKey: true, + }, + ui.getViewportBrowser() + ); + const newTab = await newTabPromise; + ok(newTab, "New tab opened from link"); + await removeTab(newTab); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_container_tab.js b/devtools/client/responsive/test/browser/browser_container_tab.js new file mode 100644 index 0000000000..7be60207b8 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_container_tab.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify RDM opens for a container tab. + +const TEST_URL = "https://example.com/"; + +addRDMTask( + null, + async function () { + // Open a tab with about:newtab in a container. + const tab = await addTab(BROWSER_NEW_TAB_URL, { + userContextId: 2, + }); + is(tab.userContextId, 2, "Tab's container ID is correct"); + + // Open RDM and try to navigate + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + await navigateTo(TEST_URL); + ok(true, "Test URL navigated successfully"); + + await closeRDM(tab); + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_contextmenu_inspect.js b/devtools/client/responsive/test/browser/browser_contextmenu_inspect.js new file mode 100644 index 0000000000..6d02a61917 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_contextmenu_inspect.js @@ -0,0 +1,55 @@ +"use strict"; + +// Check that Inspect Element works in Responsive Design Mode. + +const TEST_URI = `${URL_ROOT}doc_contextmenu_inspect.html`; + +addRDMTask(TEST_URI, async function ({ ui, manager }) { + info("Open the responsive design mode and set its size to 500x500 to start"); + await setViewportSize(ui, manager, 500, 500); + + info("Open the inspector, rule-view and select the test node"); + const { inspector } = await openRuleView(); + + const startNodeFront = inspector.selection.nodeFront; + is(startNodeFront.displayName, "body", "body element is selected by default"); + + const onSelected = inspector.once("inspector-updated"); + + const contentAreaContextMenu = document.querySelector( + "#contentAreaContextMenu" + ); + const contextOpened = once(contentAreaContextMenu, "popupshown"); + + info("Simulate a context menu event from the top browser."); + BrowserTestUtils.synthesizeMouse( + ui.getViewportBrowser(), + 250, + 100, + { + type: "contextmenu", + button: 2, + }, + ui.tab.linkedBrowser + ); + + await contextOpened; + + info("Triggering the inspect action"); + await gContextMenu.inspectNode(); + + info("Hiding the menu"); + const contextClosed = once(contentAreaContextMenu, "popuphidden"); + contentAreaContextMenu.hidePopup(); + await contextClosed; + + await onSelected; + const newNodeFront = inspector.selection.nodeFront; + is( + newNodeFront.displayName, + "div", + "div element is selected after using Inspect Element" + ); + + await closeToolbox(); +}); diff --git a/devtools/client/responsive/test/browser/browser_device_change.js b/devtools/client/responsive/test/browser/browser_device_change.js new file mode 100644 index 0000000000..6a3fe643af --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_change.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests changing viewport device (need HTTP load for proper UA testing) + +const TEST_URL = `${URL_ROOT}doc_page_state.html`; +const DEFAULT_DPPX = window.devicePixelRatio; + +const Types = require("resource://devtools/client/responsive/types.js"); + +const testDevice = { + name: "Fake Phone RDM Test", + width: 320, + height: 570, + pixelRatio: 5.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "custom", + featured: true, +}; + +// Add the new device to the list +addDeviceForTest(testDevice); + +// Add the laptop to the device list +const { + updatePreferredDevices, +} = require("resource://devtools/client/responsive/actions/devices.js"); +updatePreferredDevices({ + added: ["Laptop with MDPI screen"], + removed: [], +}); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + reloadOnUAChange(true); + + // Test defaults + testViewportDimensions(ui, 320, 480); + info("Should have default UA at the start of the test"); + await testUserAgent(ui, DEFAULT_UA); + await testDevicePixelRatio(ui, DEFAULT_DPPX); + await testTouchEventsOverride(ui, false); + testViewportDeviceMenuLabel(ui, "Responsive"); + + // Test device with custom properties + await selectDevice(ui, "Fake Phone RDM Test"); + await waitForViewportResizeTo(ui, testDevice.width, testDevice.height); + info("Should have device UA now that device is applied"); + await testUserAgent(ui, testDevice.userAgent); + await testDevicePixelRatio(ui, testDevice.pixelRatio); + await testTouchEventsOverride(ui, true); + + // Test resetting device when resizing viewport + await testViewportResize( + ui, + ".viewport-vertical-resize-handle", + [-10, -10], + [0, -10], + { + hasDevice: true, + } + ); + + info("Should have default UA after resizing viewport"); + await testUserAgent(ui, DEFAULT_UA); + await testDevicePixelRatio(ui, DEFAULT_DPPX); + await testTouchEventsOverride(ui, false); + testViewportDeviceMenuLabel(ui, "Responsive"); + + // Test device with generic properties + await selectDevice(ui, "Laptop with MDPI screen"); + await waitForViewportResizeTo(ui, 1280, 800); + info("Should have default UA when using device without specific UA"); + await testUserAgent(ui, DEFAULT_UA); + await testDevicePixelRatio(ui, 1); + await testTouchEventsOverride(ui, false); + + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + const { ui } = await openRDM(tab); + + const { store } = ui.toolWindow; + + reloadOnUAChange(true); + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.viewports[0].device === "Laptop with MDPI screen" && + state.devices.listState == Types.loadableState.LOADED + ); + + // Select device with custom UA + const waitForReload = await watchForDevToolsReload(ui.getViewportBrowser()); + await selectDevice(ui, "Fake Phone RDM Test"); + await waitForReload(); + await waitForViewportResizeTo(ui, testDevice.width, testDevice.height); + info("Should have device UA now that device is applied"); + await testUserAgent(ui, testDevice.userAgent); + + // Browser will reload to clear the UA on RDM close + const onReload = BrowserTestUtils.browserLoaded(ui.getViewportBrowser()); + await closeRDM(tab); + await onReload; + + // Ensure UA is reset to default after closing RDM + info("Should have default UA after closing RDM"); + await testUserAgentFromBrowser(tab.linkedBrowser, DEFAULT_UA); + + await removeTab(tab); + + reloadOnUAChange(false); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_custom.js b/devtools/client/responsive/test/browser/browser_device_custom.js new file mode 100644 index 0000000000..fb63719b30 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_custom.js @@ -0,0 +1,237 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding and removing custom devices via the modal. + +const L10N = new LocalizationHelper( + "devtools/client/locales/device.properties", + true +); + +const device = { + name: "Test Device", + width: 400, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, +}; + +const unicodeDevice = { + name: "\u00B6\u00C7\u00DA\u00E7\u0126", + width: 400, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, +}; + +const TEST_URL = "data:text/html;charset=utf-8,"; + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + await openDeviceModal(ui); + + is( + getCustomHeaderEl(document), + null, + "There's no Custom header when we don't have custom devices" + ); + + info("Reveal device adder form, check that defaults match the viewport"); + const adderShow = document.getElementById("device-add-button"); + adderShow.click(); + testDeviceAdder(ui, { + name: "Custom Device", + width: 320, + height: 480, + pixelRatio: window.devicePixelRatio, + userAgent: navigator.userAgent, + touch: false, + }); + + info("Fill out device adder form and save"); + await addDeviceInModal(ui, device); + + info("Verify device defaults to enabled in modal"); + const deviceCb = [ + ...document.querySelectorAll(".device-input-checkbox"), + ].find(cb => { + return cb.value == device.name; + }); + ok(deviceCb, "Custom device checkbox added to modal"); + ok(deviceCb.checked, "Custom device enabled"); + + const customHeaderEl = getCustomHeaderEl(document); + ok(customHeaderEl, "There's a Custom header when add a custom devices"); + is( + customHeaderEl.textContent, + L10N.getStr(`device.custom`), + "The custom header has the expected text" + ); + + document.getElementById("device-close-button").click(); + + info("Look for custom device in device selector"); + const deviceSelector = document.getElementById("device-selector"); + await testMenuItems(toolWindow, deviceSelector, items => { + const menuItem = findMenuItem(items, device.name); + ok(menuItem, "Custom device menu item added to device selector"); + }); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { store, document } = toolWindow; + + info("Select existing device from the selector"); + await selectDevice(ui, "Test Device"); + + await openDeviceModal(ui); + + info( + "Reveal device adder form, check that defaults are based on selected device" + ); + const adderShow = document.getElementById("device-add-button"); + adderShow.click(); + testDeviceAdder( + ui, + Object.assign({}, device, { + name: "Test Device (Custom)", + }) + ); + + info("Remove previously added custom device"); + // Close the form since custom device buttons are only shown when form is not open. + const cancelButton = document.getElementById("device-form-cancel"); + cancelButton.click(); + + const deviceRemoveButton = document.querySelector(".device-remove-button"); + const removed = Promise.all([ + waitUntilState(store, state => !state.devices.custom.length), + once(ui, "device-association-removed"), + ]); + deviceRemoveButton.click(); + await removed; + + info("Close the form before submitting."); + document.getElementById("device-close-button").click(); + + info("Ensure custom device was removed from device selector"); + await waitUntilState(store, state => state.viewports[0].device == ""); + const deviceSelectorTitle = document.querySelector("#device-selector"); + is( + deviceSelectorTitle.textContent, + "Responsive", + "Device selector reset to no device" + ); + + info("Look for custom device in device selector"); + const deviceSelector = document.getElementById("device-selector"); + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const menuItem = findMenuItem(menuItems, device.name); + ok(!menuItem, "Custom device option removed from device selector"); + }); + + info("Ensure device properties like UA have been reset"); + await testUserAgent(ui, navigator.userAgent); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + await openDeviceModal(ui); + + info("Reveal device adder form"); + const adderShow = document.querySelector("#device-add-button"); + adderShow.click(); + + info( + "Fill out device adder form by setting details to unicode device and save" + ); + await addDeviceInModal(ui, unicodeDevice); + + info("Verify unicode device defaults to enabled in modal"); + const deviceCb = [ + ...document.querySelectorAll(".device-input-checkbox"), + ].find(cb => { + return cb.value == unicodeDevice.name; + }); + ok(deviceCb, "Custom unicode device checkbox added to modal"); + ok(deviceCb.checked, "Custom unicode device enabled"); + document.getElementById("device-close-button").click(); + + info("Look for custom unicode device in device selector"); + const deviceSelector = document.getElementById("device-selector"); + await testMenuItems(toolWindow, deviceSelector, items => { + const menuItem = findMenuItem(items, unicodeDevice.name); + ok(menuItem, "Custom unicode device option added to device selector"); + }); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + // Check if the unicode custom device is present in the list of device options since + // we want to ensure that unicode device names are not forgotten after restarting RDM + // see bug 1379687 + info("Look for custom unicode device in device selector"); + const deviceSelector = document.getElementById("device-selector"); + await testMenuItems(toolWindow, deviceSelector, items => { + const menuItem = findMenuItem(items, unicodeDevice.name); + ok(menuItem, "Custom unicode device option present in device selector"); + }); + }, + { waitForDeviceList: true } +); + +function testDeviceAdder(ui, expected) { + const { document } = ui.toolWindow; + + const nameInput = document.querySelector("#device-form-name input"); + const [widthInput, heightInput] = document.querySelectorAll( + "#device-form-size input" + ); + const pixelRatioInput = document.querySelector( + "#device-form-pixel-ratio input" + ); + const userAgentInput = document.querySelector( + "#device-form-user-agent input" + ); + const touchInput = document.querySelector("#device-form-touch input"); + + is(nameInput.value, expected.name, "Device name matches"); + is(parseInt(widthInput.value, 10), expected.width, "Width matches"); + is(parseInt(heightInput.value, 10), expected.height, "Height matches"); + is( + parseFloat(pixelRatioInput.value), + expected.pixelRatio, + "devicePixelRatio matches" + ); + is(userAgentInput.value, expected.userAgent, "User agent matches"); + is(touchInput.checked, expected.touch, "Touch matches"); +} + +function getCustomHeaderEl(doc) { + return doc.querySelector(`.device-type-custom .device-header`); +} diff --git a/devtools/client/responsive/test/browser/browser_device_custom_edit.js b/devtools/client/responsive/test/browser/browser_device_custom_edit.js new file mode 100644 index 0000000000..35fee3c906 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_custom_edit.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that adding a device, submitting it, and then editing it updates the device's +// original values when it is saved. + +const TEST_URL = "data:text/html;charset=utf-8,"; + +const device = { + name: "Original Custom Device", + width: 320, + height: 480, + pixelRatio: 1, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: false, +}; + +const newDevice = { + name: "Edited Custom Device", + width: 300, + height: 900, + pixelRatio: 4, + userAgent: "Different User agent", + touch: true, +}; + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + await openDeviceModal(ui); + + info("Add device."); + const adderShow = document.querySelector("#device-add-button"); + adderShow.click(); + await addDeviceInModal(ui, device); + + info("Submit the added device."); + let deviceSelector = document.getElementById("device-selector"); + document.getElementById("device-close-button").click(); + + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const originalDevice = findMenuItem(menuItems, device.name); + ok(originalDevice, "Original custom device menu item exists."); + }); + + info("Select the added device in menu."); + await selectDevice(ui, "Custom Device"); + await openDeviceModal(ui); + + info("Edit the device."); + const editorShow = document.querySelector( + ".device-type-custom #device-edit-button" + ); + editorShow.click(); + await editDeviceInModal(ui, device, newDevice); + + info("Ensure the edited device name is updated in the custom device list."); + const customDevicesList = document.querySelector(".device-type-custom"); + const editedCustomDevice = customDevicesList.querySelector(".device-name"); + is( + editedCustomDevice.textContent, + newDevice.name, + `${device.name} is updated to ${newDevice.name} in the custom device list` + ); + + info("Ensure the viewport width and height are updated in the toolbar."); + const [width, height] = document.querySelectorAll( + ".text-input.viewport-dimension-input" + ); + is(width.value, "300", "Viewport width is 300"); + is(height.value, "900", "Viewport height is 900"); + + info("Ensure the pixel ratio is updated in the toolbar."); + const devicePixelRatioSpan = document.querySelector( + "#device-pixel-ratio-menu span" + ); + is( + devicePixelRatioSpan.textContent, + "DPR: 4", + "Viewport pixel ratio is 4." + ); + + info("Ensure the user agent has been updated."); + const userAgentInput = document.querySelector("#user-agent-input"); + is( + userAgentInput.value, + newDevice.userAgent, + `Viewport user agent is ${newDevice.userAgent}` + ); + + info("Ensure touch simulation has been updated"); + const touchSimulation = document.querySelector("#touch-simulation-button"); + ok( + touchSimulation.classList.contains("checked"), + "Viewport touch simulation is enabled." + ); + + info( + "Ensure the edited device is updated in the device selector when submitted" + ); + document.getElementById("device-close-button").click(); + deviceSelector = document.getElementById("device-selector"); + + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const originalDevice = findMenuItem(menuItems, device.name); + const editedDevice = findMenuItem(menuItems, newDevice.name); + ok(!originalDevice, "Original custom device menu item does not exist"); + ok(editedDevice, "Edited Custom Device menu item exists"); + }); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_custom_remove.js b/devtools/client/responsive/test/browser/browser_device_custom_remove.js new file mode 100644 index 0000000000..8d31aed6cf --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_custom_remove.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding several devices and removing one to ensure the correct device is removed. + +const TEST_URL = "data:text/html;charset=utf-8,"; + +const device = { + width: 400, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, +}; + +const device1 = Object.assign({}, device, { + name: "Test Device 1", +}); + +const device2 = Object.assign({}, device, { + name: "Test Device 2", +}); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { store, document } = toolWindow; + + info("Verify that remove buttons affect the correct device"); + + const deviceSelector = document.getElementById("device-selector"); + + await openDeviceModal(ui); + + info("Reveal device adder form"); + let adderShow = document.querySelector("#device-add-button"); + adderShow.click(); + + info("Add test device 1"); + await addDeviceInModal(ui, device1); + + info("Reveal device adder form"); + adderShow = document.querySelector("#device-add-button"); + adderShow.click(); + + info("Add test device 2"); + await addDeviceInModal(ui, device2); + + info("Verify all custom devices default to enabled in modal"); + const deviceCbs = [ + ...document.querySelectorAll( + ".device-type-custom .device-input-checkbox" + ), + ]; + is(deviceCbs.length, 2, "Both devices have a checkbox in modal"); + for (const cb of deviceCbs) { + ok(cb.checked, "Custom device enabled"); + } + document.getElementById("device-close-button").click(); + + info("Look for device 1 and 2 in device selector"); + + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const deviceItem1 = findMenuItem(menuItems, device1.name); + const deviceItem2 = findMenuItem(menuItems, device2.name); + ok(deviceItem1, "Test device 1 menu item added to device selector"); + ok(deviceItem2, "Test device 2 menu item added to device selector"); + }); + + await openDeviceModal(ui); + + info("Remove device 2"); + const deviceRemoveButtons = [ + ...document.querySelectorAll(".device-remove-button"), + ]; + is( + deviceRemoveButtons.length, + 2, + "Both devices have a remove button in modal" + ); + const removed = waitUntilState( + store, + state => state.devices.custom.length == 1 + ); + deviceRemoveButtons[1].click(); + await removed; + document.getElementById("device-close-button").click(); + + info("Ensure device 2 is no longer in device selector"); + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const deviceItem1 = findMenuItem(menuItems, device1.name); + const deviceItem2 = findMenuItem(menuItems, device2.name); + ok(deviceItem1, "Test device 1 menu item exists"); + ok(!deviceItem2, "Test device 2 menu item removed"); + }); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + const deviceSelector = document.getElementById("device-selector"); + + info("Ensure device 1 is still in device selector"); + await testMenuItems(toolWindow, deviceSelector, menuItems => { + const deviceItem1 = findMenuItem(menuItems, device1.name); + const deviceItem2 = findMenuItem(menuItems, device2.name); + ok(deviceItem1, "Test device 1 menu item exists"); + ok(!deviceItem2, "Test device 2 option removed"); + }); + + await openDeviceModal(ui); + + info("Ensure device 1 is still in device modal"); + const deviceCbs = [ + ...document.querySelectorAll( + ".device-type-custom .device-input-checkbox" + ), + ]; + is(deviceCbs.length, 1, "Only 1 custom present in modal"); + const deviceCb1 = deviceCbs.find(cb => cb.value == device1.name); + ok( + deviceCb1 && deviceCb1.checked, + "Test device 1 checkbox exists and enabled" + ); + + info("Ensure device 2 is no longer in device modal"); + const deviceCb2 = deviceCbs.find(cb => cb.value == device2.name); + ok(!deviceCb2, "Test device 2 checkbox does not exist"); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_modal_exit.js b/devtools/client/responsive/test/browser/browser_device_modal_exit.js new file mode 100644 index 0000000000..5edc78f5e3 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_modal_exit.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test submitting display device changes on the device modal + +const TEST_URL = "data:text/html;charset=utf-8,"; + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { document, store } = ui.toolWindow; + + await openDeviceModal(ui); + + const preferredDevicesBefore = _loadPreferredDevices(); + + info("Check the first unchecked device and exit the modal."); + const uncheckedCb = [ + ...document.querySelectorAll(".device-input-checkbox"), + ].filter(cb => !cb.checked)[0]; + const value = uncheckedCb.value; + uncheckedCb.click(); + document.getElementById("device-close-button").click(); + + ok( + !store.getState().devices.isModalOpen, + "The device modal is closed on exit." + ); + + info("Check that the device list remains unchanged after exitting."); + const preferredDevicesAfter = _loadPreferredDevices(); + + is( + preferredDevicesAfter.added.size - preferredDevicesBefore.added.size, + 1, + "Got expected number of added devices." + ); + is( + preferredDevicesBefore.removed.size, + preferredDevicesAfter.removed.size, + "Got expected number of removed devices." + ); + ok( + !preferredDevicesAfter.removed.has(value), + value + " was not added to removed device list." + ); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_modal_items.js b/devtools/client/responsive/test/browser/browser_device_modal_items.js new file mode 100644 index 0000000000..54b6b51854 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_modal_items.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the content of device items in the modal. + +const TEST_URL = "data:text/html;charset=utf-8,"; +const { + parseUserAgent, +} = require("resource://devtools/client/responsive/utils/ua.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/device.properties", + true +); +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { store, document } = toolWindow; + + await openDeviceModal(ui); + + const { devices } = store.getState(); + + ok(devices.types.length, "We have some device types"); + + for (const type of devices.types) { + const list = devices[type]; + + const header = document.querySelector( + `.device-type-${type} .device-header` + ); + + if (type == "custom") { + // we don't have custom devices, so there shouldn't be a header for it. + is(list.length, 0, `We don't have any custom devices`); + ok(!header, `There's no header for "custom"`); + continue; + } + + ok(list.length, `We have ${type} devices`); + ok(header, `There's a header for ${type} devices`); + + is( + header?.textContent, + L10N.getStr(`device.${type}`), + `Got expected text for ${type} header` + ); + + for (const item of list) { + info(`Check the element for ${item.name} on the modal`); + + const targetEl = findDeviceLabel(item.name, document); + ok(targetEl, "The element for the device is on the modal"); + + const { browser, os } = parseUserAgent(item.userAgent); + const browserEl = targetEl.querySelector(".device-browser"); + if (browser) { + ok(browserEl, "The element for the browser is in the device element"); + const expectedClassName = browser.name.toLowerCase(); + ok( + browserEl.classList.contains(expectedClassName), + `The browser element contains .${expectedClassName}` + ); + } else { + ok( + !browserEl, + "The element for the browser is not in the device element" + ); + } + + const osEl = targetEl.querySelector(".device-os"); + if (os) { + ok(osEl, "The element for the os is in the device element"); + const expectedText = os.version + ? `${os.name} ${os.version}` + : os.name; + is( + osEl.textContent, + expectedText, + "The text in os element is correct" + ); + } else { + ok(!osEl, "The element for the os is not in the device element"); + } + } + } + }, + { waitForDeviceList: true } +); + +function findDeviceLabel(deviceName, document) { + const deviceNameEls = document.querySelectorAll(".device-name"); + const targetEl = [...deviceNameEls].find(el => el.textContent === deviceName); + return targetEl ? targetEl.closest(".device-label") : null; +} diff --git a/devtools/client/responsive/test/browser/browser_device_modal_submit.js b/devtools/client/responsive/test/browser/browser_device_modal_submit.js new file mode 100644 index 0000000000..45cf23e10e --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_modal_submit.js @@ -0,0 +1,203 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test submitting display device changes on the device modal +const { getDevices } = require("resource://devtools/client/shared/devices.js"); + +const addedDevice = { + name: "Fake Phone RDM Test", + width: 320, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: false, + os: "custom", + featured: true, +}; + +const TEST_URL = "data:text/html;charset=utf-8,"; + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document, store } = toolWindow; + const deviceSelector = document.getElementById("device-selector"); + + await openDeviceModal(ui); + + info( + "Checking displayed device checkboxes are checked in the device modal." + ); + const checkedCbs = document.querySelectorAll( + ".device-input-checkbox:checked" + ); + const remoteList = await getDevices(); + + const featuredCount = getNumberOfFeaturedDevices(remoteList); + is( + featuredCount, + checkedCbs.length, + "Got expected number of displayed devices." + ); + + for (const cb of checkedCbs) { + ok( + Object.keys(remoteList).filter(type => remoteList[type][cb.value]), + cb.value + " is correctly checked." + ); + } + + // Tests where the user adds a non-featured device + info("Check the first unchecked device and submit new device list."); + const uncheckedCb = document.querySelector( + ".device-input-checkbox:not(:checked)" + ); + const value = uncheckedCb.value; + uncheckedCb.click(); + document.getElementById("device-close-button").click(); + + ok( + !store.getState().devices.isModalOpen, + "The device modal is closed on submit." + ); + + info("Checking that the new device is added to the user preference list."); + let preferredDevices = _loadPreferredDevices(); + ok(preferredDevices.added.has(value), value + " in user added list."); + + info("Checking new device is added to the device selector."); + await testMenuItems(toolWindow, deviceSelector, menuItems => { + is( + menuItems.length - 1, + featuredCount + 1, + "Got expected number of devices in device selector." + ); + + const menuItem = findMenuItem(menuItems, value); + ok(menuItem, value + " added to the device selector."); + }); + + info("Reopen device modal and check new device is correctly checked"); + await openDeviceModal(ui); + + const previouslyClickedCb = [ + ...document.querySelectorAll(".device-input-checkbox"), + ].find(cb => cb.value === value); + ok(previouslyClickedCb.checked, value + " is checked in the device modal."); + + // Tests where the user removes a featured device + info("Uncheck the first checked device different than the previous one"); + const checkboxes = [...document.querySelectorAll(".device-input-checkbox")]; + const checkedCb = checkboxes.find(cb => { + if (!cb.checked || cb.value == value) { + return false; + } + // In the list, we have devices with similar names (e.g. "Galaxy Note 20" and "Galaxy Note 20 Ultra") + // Given how some test helpers are using `includes` to check device names, we might + // get positive result for "Galaxy Note 20" although it would actually match "Galaxy Note 20 Ultra". + // To prevent such issue without modifying existing helpers, we're excluding any + // item whose name is part of another device. + return !checkboxes.some( + innerCb => + innerCb.value !== cb.value && innerCb.value.includes(cb.value) + ); + }); + const checkedVal = checkedCb.value; + checkedCb.click(); + document.getElementById("device-close-button").click(); + + info("Checking that the device is removed from the user preference list."); + preferredDevices = _loadPreferredDevices(); + ok( + preferredDevices.removed.has(checkedVal), + checkedVal + " in removed list" + ); + + info("Checking that the device is not in the device selector."); + await testMenuItems(toolWindow, deviceSelector, menuItems => { + is( + menuItems.length - 1, + featuredCount, + "Got expected number of devices in device selector." + ); + + const menuItem = findMenuItem(menuItems, checkedVal); + ok(!menuItem, checkedVal + " removed from the device selector."); + }); + + info("Reopen device modal and check device is correctly unchecked"); + await openDeviceModal(ui); + ok( + [...document.querySelectorAll(".device-input-checkbox")].filter( + cb => !cb.checked && cb.value === checkedVal + )[0], + checkedVal + " is unchecked in the device modal." + ); + + // Let's add a dummy device to simulate featured flag changes for next test + addDeviceForTest(addedDevice); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + await openDeviceModal(ui); + + const remoteList = await getDevices(); + const featuredCount = getNumberOfFeaturedDevices(remoteList); + const preferredDevices = _loadPreferredDevices(); + + // Tests to prove that reloading the RDM didn't break our device list + info("Checking new featured device appears in the device selector."); + const deviceSelector = document.getElementById("device-selector"); + await testMenuItems(toolWindow, deviceSelector, items => { + is( + items.length - 1, + featuredCount - + preferredDevices.removed.size + + preferredDevices.added.size, + "Got expected number of devices in device selector." + ); + + const added = findMenuItem(items, addedDevice.name); + ok(added, "Dummy device added to the device selector."); + + for (const name of preferredDevices.added.keys()) { + const menuItem = findMenuItem(items, name); + ok(menuItem, "Device added by user still in the device selector."); + } + + for (const name of preferredDevices.removed.keys()) { + const menuItem = findMenuItem(items, name); + ok(!menuItem, "Device removed by user not in the device selector."); + } + }); + }, + { waitForDeviceList: true } +); + +/** + * Returns the number of featured devices + * + * @param {Map} devicesByType: Map of devices, keyed by type (as returned by getDevices) + * @returns {Integer} + */ +function getNumberOfFeaturedDevices(devicesByType) { + let count = 0; + const devices = [...devicesByType.values()].flat(); + for (const device of devices) { + if (device.featured && device.os != "fxos") { + count++; + } + } + return count; +} diff --git a/devtools/client/responsive/test/browser/browser_device_pixel_ratio_change.js b/devtools/client/responsive/test/browser/browser_device_pixel_ratio_change.js new file mode 100644 index 0000000000..c11313a188 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_pixel_ratio_change.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests changing viewport device pixel ratio + +const TEST_URL = "data:text/html;charset=utf-8,DevicePixelRatio list test"; +const DEFAULT_DPPX = window.devicePixelRatio; +const VIEWPORT_DPPX = DEFAULT_DPPX + 1; +const Types = require("resource://devtools/client/responsive/types.js"); + +const testDevice = { + name: "Fake Phone RDM Test", + width: 320, + height: 470, + pixelRatio: 5.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "custom", + featured: true, +}; + +// Add the new device to the list +addDeviceForTest(testDevice); + +addRDMTask( + TEST_URL, + async function ({ ui, manager }) { + await waitStartup(ui); + + await testDefaults(ui); + await testChangingDevice(ui); + await testResetWhenResizingViewport(ui); + await testChangingDevicePixelRatio(ui); + }, + { waitForDeviceList: true } +); + +async function waitStartup(ui) { + const { store } = ui.toolWindow; + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.devices.listState == Types.loadableState.LOADED + ); +} + +async function testDefaults(ui) { + info("Test Defaults"); + + const dppx = await getViewportDevicePixelRatio(ui); + is(dppx, DEFAULT_DPPX, "Content has expected devicePixelRatio"); + testViewportDevicePixelRatioSelect(ui, { + value: DEFAULT_DPPX, + disabled: false, + }); + testViewportDeviceMenuLabel(ui, "Responsive"); +} + +async function testChangingDevice(ui) { + info("Test Changing Device"); + + await selectDevice(ui, testDevice.name); + await waitForViewportResizeTo(ui, testDevice.width, testDevice.height); + const dppx = await waitForDevicePixelRatio(ui, testDevice.pixelRatio); + is(dppx, testDevice.pixelRatio, "Content has expected devicePixelRatio"); + testViewportDevicePixelRatioSelect(ui, { + value: testDevice.pixelRatio, + disabled: true, + }); + testViewportDeviceMenuLabel(ui, testDevice.name); +} + +async function testResetWhenResizingViewport(ui) { + info("Test reset when resizing the viewport"); + + await testViewportResize( + ui, + ".viewport-vertical-resize-handle", + [-10, -10], + [0, -10], + { + hasDevice: true, + } + ); + + const dppx = await waitForDevicePixelRatio(ui, DEFAULT_DPPX); + is(dppx, DEFAULT_DPPX, "Content has expected devicePixelRatio"); + + testViewportDevicePixelRatioSelect(ui, { + value: DEFAULT_DPPX, + disabled: false, + }); + testViewportDeviceMenuLabel(ui, "Responsive"); +} + +async function testChangingDevicePixelRatio(ui) { + info("Test changing device pixel ratio"); + + await selectDevicePixelRatio(ui, VIEWPORT_DPPX); + const dppx = await waitForDevicePixelRatio(ui, VIEWPORT_DPPX); + is(dppx, VIEWPORT_DPPX, "Content has expected devicePixelRatio"); + testViewportDevicePixelRatioSelect(ui, { + value: VIEWPORT_DPPX, + disabled: false, + }); + testViewportDeviceMenuLabel(ui, "Responsive"); +} + +function testViewportDevicePixelRatioSelect(ui, expected) { + info("Test viewport's DevicePixelRatio Select"); + + const button = ui.toolWindow.document.getElementById( + "device-pixel-ratio-menu" + ); + const title = ui.toolWindow.document.querySelector( + "#device-pixel-ratio-menu .title" + ); + is( + title.textContent, + `DPR: ${expected.value}`, + `DevicePixelRatio Select value should be: ${expected.value}` + ); + is( + button.disabled, + expected.disabled, + `DevicePixelRatio Select should be ${ + expected.disabled ? "disabled" : "enabled" + }.` + ); +} diff --git a/devtools/client/responsive/test/browser/browser_device_selector_items.js b/devtools/client/responsive/test/browser/browser_device_selector_items.js new file mode 100644 index 0000000000..63b8efdd50 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_selector_items.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the device selector button and the menu items. + +const MenuItem = require("resource://devtools/client/shared/components/menu/MenuItem.js"); + +const FIREFOX_ICON = + 'url("chrome://devtools/skin/images/browsers/firefox.svg")'; +const DUMMY_ICON = `url("${MenuItem.DUMMY_ICON}")`; + +const FIREFOX_DEVICE = { + name: "Device of Firefox user-agent", + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + width: 320, + height: 570, + pixelRatio: 5.5, + touch: true, + firefoxOS: true, + os: "custom", + featured: true, +}; + +const TEST_DEVICES = [ + { + name: FIREFOX_DEVICE.name, + hasIcon: true, + }, + { + name: "Laptop with MDPI screen", + hasIcon: false, + }, +]; + +addDeviceForTest(FIREFOX_DEVICE); + +// Add the laptop to the device list +const { + updatePreferredDevices, +} = require("resource://devtools/client/responsive/actions/devices.js"); +updatePreferredDevices({ + added: ["Laptop with MDPI screen"], + removed: [], +}); + +addRDMTask( + URL_ROOT, + async function ({ ui }) { + const deviceSelector = + ui.toolWindow.document.getElementById("device-selector"); + + for (const testDevice of TEST_DEVICES) { + info(`Check "${name}" device`); + await testMenuItems(ui.toolWindow, deviceSelector, menuItems => { + const menuItem = findMenuItem(menuItems, testDevice.name); + ok(menuItem, "The menu item is on the list"); + const label = menuItem.querySelector(".iconic > .label"); + const backgroundImage = ui.toolWindow.getComputedStyle( + label, + "::before" + ).backgroundImage; + const icon = testDevice.hasIcon ? FIREFOX_ICON : DUMMY_ICON; + is(backgroundImage, icon, "The icon is correct"); + }); + + info("Check device selector button"); + await selectDevice(ui, testDevice.name); + const backgroundImage = ui.toolWindow.getComputedStyle( + deviceSelector, + "::before" + ).backgroundImage; + const icon = testDevice.hasIcon ? FIREFOX_ICON : "none"; + is(backgroundImage, icon, "The icon is correct"); + } + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_state_restore.js b/devtools/client/responsive/test/browser/browser_device_state_restore.js new file mode 100644 index 0000000000..f8778795c2 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_state_restore.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the previous selected device is restored when reopening RDM. + +const TEST_URL = "data:text/html;charset=utf-8,"; +const DEFAULT_DPPX = window.devicePixelRatio; + +/* eslint-disable max-len */ +const TEST_DEVICE = { + name: "iPhone 6/7/8", + width: 375, + height: 667, + pixelRatio: 2, + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1", + touch: true, + firefoxOS: false, + os: "iOS", + featured: true, +}; +/* eslint-enable max-len */ + +// Add the device to the list +const { + updatePreferredDevices, +} = require("resource://devtools/client/responsive/actions/devices.js"); +updatePreferredDevices({ + added: [TEST_DEVICE.name], + removed: [], +}); + +const Types = require("resource://devtools/client/responsive/types.js"); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { store } = ui.toolWindow; + + reloadOnUAChange(true); + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.devices.listState == Types.loadableState.LOADED + ); + + info("Checking the default RDM state."); + testViewportDeviceMenuLabel(ui, "Responsive"); + testViewportDimensions(ui, 320, 480); + await testUserAgent(ui, DEFAULT_UA); + await testDevicePixelRatio(ui, DEFAULT_DPPX); + await testTouchEventsOverride(ui, false); + + info("Select a device"); + const waitForReload = await watchForDevToolsReload(ui.getViewportBrowser()); + await selectDevice(ui, TEST_DEVICE.name); + await waitForReload(); + await waitForViewportResizeTo(ui, TEST_DEVICE.width, TEST_DEVICE.height); + + info("Checking the RDM device state."); + testViewportDeviceMenuLabel(ui, TEST_DEVICE.name); + await testUserAgent(ui, TEST_DEVICE.userAgent); + await testDevicePixelRatio(ui, TEST_DEVICE.pixelRatio); + await testTouchEventsOverride(ui, TEST_DEVICE.touch); + + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); + +addRDMTaskWithPreAndPost( + TEST_URL, + function rdmPreTask({ browser }) { + reloadOnUAChange(true); + }, + async function ({ ui }) { + // Note: This code might be racy. Call watchForDevToolsReload as early as + // possible to catch the reload that will happen on RDM startup. + // We cannot easily call watchForDevToolsReload in the preTask because it + // needs RDM to be already started. Otherwise it will not find any devtools + // UI to wait for. + const waitForReload = await watchForDevToolsReload(ui.getViewportBrowser()); + + const { store } = ui.toolWindow; + + info( + "Reopening RDM and checking that the previous device state is restored." + ); + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.viewports[0].device === TEST_DEVICE.name && + state.devices.listState == Types.loadableState.LOADED + ); + await waitForViewportResizeTo(ui, TEST_DEVICE.width, TEST_DEVICE.height); + await waitForReload(); + + info("Checking the restored RDM state."); + testViewportDeviceMenuLabel(ui, TEST_DEVICE.name); + testViewportDimensions(ui, TEST_DEVICE.width, TEST_DEVICE.height); + await testUserAgent(ui, TEST_DEVICE.userAgent); + await testDevicePixelRatio(ui, TEST_DEVICE.pixelRatio); + await testTouchEventsOverride(ui, TEST_DEVICE.touch); + + info("Rotating the viewport."); + rotateViewport(ui); + + reloadOnUAChange(false); + }, + function rdmPostTask({ browser }) {}, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { store } = ui.toolWindow; + + reloadOnUAChange(true); + + info( + "Reopening RDM and checking that the previous device state is restored." + ); + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.viewports[0].device === TEST_DEVICE.name && + state.devices.listState == Types.loadableState.LOADED + ); + await waitForViewportResizeTo(ui, TEST_DEVICE.height, TEST_DEVICE.width); + const waitForReload = await watchForDevToolsReload(ui.getViewportBrowser()); + await waitForReload(); + + info("Checking the restored RDM state."); + testViewportDeviceMenuLabel(ui, TEST_DEVICE.name); + testViewportDimensions(ui, TEST_DEVICE.height, TEST_DEVICE.width); + await testUserAgent(ui, TEST_DEVICE.userAgent); + await testDevicePixelRatio(ui, TEST_DEVICE.pixelRatio); + await testTouchEventsOverride(ui, TEST_DEVICE.touch); + + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_device_width.js b/devtools/client/responsive/test/browser/browser_device_width.js new file mode 100644 index 0000000000..11dc8fd31c --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_device_width.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = + 'data:text/html;charset=utf-8,<iframe id="subframe" ' + + 'width="200" height="200"></iframe>'; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + ok(ui, "An instance of the RDM should be attached to the tab."); + await setViewportSizeAndAwaitReflow(ui, manager, 110, 500); + + info("Checking initial width/height properties."); + await doInitialChecks(ui, 110); + + info("Checking initial width/height with meta viewport on"); + await setTouchAndMetaViewportSupport(ui, true); + await doInitialChecks(ui, 980); + await setTouchAndMetaViewportSupport(ui, false); + + info("Changing the RDM size"); + await setViewportSizeAndAwaitReflow(ui, manager, 90, 500); + + info("Checking for screen props"); + await checkScreenProps(ui); + + info("Checking for screen props with meta viewport on"); + await setTouchAndMetaViewportSupport(ui, true); + await checkScreenProps(ui); + await setTouchAndMetaViewportSupport(ui, false); + + info("Checking for subframe props"); + await checkSubframeProps(ui); + + info("Checking for subframe props with meta viewport on"); + await setTouchAndMetaViewportSupport(ui, true); + await checkSubframeProps(ui); + await setTouchAndMetaViewportSupport(ui, false); + + info("Changing the RDM size using input keys"); + await setViewportSizeWithInputKeys(ui); + + info("Checking for screen props once again."); + await checkScreenProps2(ui); +}); + +async function setViewportSizeWithInputKeys(ui) { + const width = 320, + height = 500; + let resized = waitForViewportResizeTo(ui, width, height); + ui.setViewportSize({ width, height }); + await resized; + + const dimensions = ui.toolWindow.document.querySelectorAll( + ".viewport-dimension-input" + ); + + // Increase width value to 420 by using the Up arrow key + resized = waitForViewportResizeTo(ui, 420, height); + dimensions[0].focus(); + for (let i = 1; i <= 100; i++) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + } + await resized; + + // Resetting width value back to 320 using `Shift + Down` arrow + resized = waitForViewportResizeTo(ui, width, height); + dimensions[0].focus(); + for (let i = 1; i <= 10; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown", { shiftKey: true }); + } + await resized; + + // Increase height value to 600 by using `PageUp + Shift` key + resized = waitForViewportResizeTo(ui, width, 600); + dimensions[1].focus(); + EventUtils.synthesizeKey("KEY_PageUp", { shiftKey: true }); + await resized; + + // Resetting height value back to 500 by using `PageDown + Shift` key + resized = waitForViewportResizeTo(ui, width, height); + dimensions[1].focus(); + EventUtils.synthesizeKey("KEY_PageDown", { shiftKey: true }); + await resized; +} + +async function doInitialChecks(ui, expectedInnerWidth) { + const { innerWidth, matchesMedia, outerHeight, outerWidth } = + await grabContentInfo(ui); + is(innerWidth, expectedInnerWidth, "inner width should be as expected"); + is(outerWidth, 110, "device's outerWidth should be 110px"); + is(outerHeight, 500, "device's outerHeight should be 500px"); + isnot( + window.outerHeight, + outerHeight, + "window.outerHeight should not be the size of the device's outerHeight" + ); + isnot( + window.outerWidth, + outerWidth, + "window.outerWidth should not be the size of the device's outerWidth" + ); + ok(!matchesMedia, "media query shouldn't match."); +} + +async function checkScreenProps(ui) { + const { matchesMedia, screen } = await grabContentInfo(ui); + ok(matchesMedia, "media query should match"); + isnot( + window.screen.width, + screen.width, + "screen.width should not be the size of the screen." + ); + is(screen.width, 90, "screen.width should be the page width"); + is(screen.height, 500, "screen.height should be the page height"); +} + +async function checkScreenProps2(ui) { + const { screen } = await grabContentInfo(ui); + isnot( + window.screen.width, + screen.width, + "screen.width should not be the size of the screen." + ); +} + +async function checkSubframeProps(ui) { + const { outerWidth, matchesMedia, screen } = await grabContentSubframeInfo( + ui + ); + is(outerWidth, 90, "subframe outerWidth should be 90px"); + ok(matchesMedia, "subframe media query should match"); + is(screen.width, 90, "subframe screen.width should be the page width"); + is(screen.height, 500, "subframe screen.height should be the page height"); +} + +function grabContentInfo(ui) { + return SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + return { + screen: { + width: content.screen.width, + height: content.screen.height, + }, + innerWidth: content.innerWidth, + matchesMedia: content.matchMedia("(max-device-width:100px)").matches, + outerHeight: content.outerHeight, + outerWidth: content.outerWidth, + }; + }); +} + +function grabContentSubframeInfo(ui) { + return SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const subframe = content.document.getElementById("subframe"); + const win = subframe.contentWindow; + return { + screen: { + width: win.screen.width, + height: win.screen.height, + }, + innerWidth: win.innerWidth, + matchesMedia: win.matchMedia("(max-device-width:100px)").matches, + outerHeight: win.outerHeight, + outerWidth: win.outerWidth, + }; + }); +} diff --git a/devtools/client/responsive/test/browser/browser_exit_button.js b/devtools/client/responsive/test/browser/browser_exit_button.js new file mode 100644 index 0000000000..7fbcd3cc51 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_exit_button.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "data:text/html;charset=utf-8,"; + +// Test global exit button +addRDMTask(TEST_URL, async function (...args) { + await testExitButton(...args); +}); + +// Test global exit button on detached tab. +// See Bug 1262806 +addRDMTask( + null, + async function () { + let tab = await addTab(TEST_URL); + const { ui, manager } = await openRDM(tab); + + await waitBootstrap(ui); + + const waitTabIsDetached = Promise.all([ + once(tab, "TabClose"), + once(tab.linkedBrowser, "SwapDocShells"), + ]); + + // Detach the tab with RDM open. + const newWindow = gBrowser.replaceTabWithWindow(tab); + + // Wait until the tab is detached and the new window is fully initialized. + await waitTabIsDetached; + await newWindow.delayedStartupPromise; + + // Get the new tab instance. + tab = newWindow.gBrowser.tabs[0]; + + // Detaching a tab closes RDM. + ok( + !manager.isActiveForTab(tab), + "Responsive Design Mode is not active for the tab" + ); + + // Reopen the RDM and test the exit button again. + await testExitButton(await openRDM(tab)); + await BrowserTestUtils.closeWindow(newWindow); + }, + { onlyPrefAndTask: true } +); + +async function waitBootstrap(ui) { + const { toolWindow, tab } = ui; + const { store } = toolWindow; + const url = String(tab.linkedBrowser.currentURI.spec); + + // Wait until the viewport has been added. + await waitUntilState(store, state => state.viewports.length == 1); + + // Wait until the document has been loaded. + await waitForFrameLoad(ui, url); +} + +async function testExitButton({ ui, manager }) { + await waitBootstrap(ui); + + const exitButton = ui.toolWindow.document.getElementById("exit-button"); + + ok( + manager.isActiveForTab(ui.tab), + "Responsive Design Mode active for the tab" + ); + + exitButton.click(); + + await once(manager, "off"); + + ok( + !manager.isActiveForTab(ui.tab), + "Responsive Design Mode is not active for the tab" + ); +} diff --git a/devtools/client/responsive/test/browser/browser_ext_messaging.js b/devtools/client/responsive/test/browser/browser_ext_messaging.js new file mode 100644 index 0000000000..5d1b5cf317 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_ext_messaging.js @@ -0,0 +1,231 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-env webextensions */ + +"use strict"; + +const TEST_URL = "https://example.com/"; + +// These allowed rejections are copied from +// browser/components/extensions/test/browser/head.js. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Receiving end does not exist/ +); + +const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: [TEST_URL], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + background() { + let currentPort; + + browser.runtime.onConnect.addListener(port => { + currentPort = port; + port.onDisconnect.addListener(() => + browser.test.sendMessage("port-disconnected") + ); + port.onMessage.addListener(msg => + browser.test.sendMessage("port-message-received", msg) + ); + browser.test.sendMessage("port-connected"); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "test:port-message-send") { + browser.test.fail(`Unexpected test message received: ${msg}`); + } + + currentPort.postMessage("ping"); + }); + + browser.test.sendMessage("background:ready"); + }, + files: { + "content-script.js": function contentScript() { + const port = browser.runtime.connect(); + port.onMessage.addListener(msg => port.postMessage(`${msg}-pong`)); + }, + }, +}); + +add_task(async function setup_first_test() { + await extension.startup(); + + await extension.awaitMessage("background:ready"); +}); + +addRDMTaskWithPreAndPost( + TEST_URL, + async function pre_task() { + await extension.awaitMessage("port-connected"); + }, + async function test_port_kept_connected_on_switch_to_RDB() { + extension.sendMessage("test:port-message-send"); + + is( + await extension.awaitMessage("port-message-received"), + "ping-pong", + "Got the expected message back from the content script" + ); + }, + async function post_task() { + extension.sendMessage("test:port-message-send"); + + is( + await extension.awaitMessage("port-message-received"), + "ping-pong", + "Got the expected message back from the content script" + ); + } +); + +add_task(async function cleanup_first_test() { + await extension.awaitMessage("port-disconnected"); + + await extension.unload(); +}); + +addRDMTask(TEST_URL, async function test_tab_sender() { + const extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + content_scripts: [ + { + matches: [TEST_URL], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + async background() { + const TEST_URL = "https://example.com/"; // eslint-disable-line no-shadow + + browser.test.log("Background script init"); + + let extTab; + const contentMessage = new Promise(resolve => { + browser.test.log("Listen to content"); + const listener = async (msg, sender, respond) => { + browser.test.assertEq( + msg, + "hello-from-content", + "Background script got hello-from-content message" + ); + + const tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + browser.test.assertEq( + tabs.length, + 1, + "One tab is active in the current window" + ); + extTab = tabs[0]; + browser.test.log(`Tab: id ${extTab.id}, url ${extTab.url}`); + browser.test.assertEq(extTab.url, TEST_URL, "Tab has the test URL"); + + browser.test.assertTrue(!!sender, "Message has a sender"); + browser.test.assertTrue(!!sender.tab, "Message has a sender.tab"); + browser.test.assertEq( + sender.tab.id, + extTab.id, + "Sender's tab ID matches the RDM tab ID" + ); + browser.test.assertEq( + sender.tab.url, + extTab.url, + "Sender's tab URL matches the RDM tab URL" + ); + + browser.runtime.onMessage.removeListener(listener); + resolve(); + }; + browser.runtime.onMessage.addListener(listener); + }); + + // Wait for "resume" message so we know the content script is also ready. + await new Promise(resolve => { + browser.test.onMessage.addListener(resolve); + browser.test.sendMessage("background-script-ready"); + }); + + await contentMessage; + + browser.test.log("Send message from background to content"); + const contentSender = await browser.tabs.sendMessage( + extTab.id, + "hello-from-background" + ); + browser.test.assertEq( + contentSender.id, + browser.runtime.id, + "The sender ID in content matches this extension" + ); + + browser.test.notifyPass("rdm-messaging"); + }, + + files: { + "content-script.js": async function () { + browser.test.log("Content script init"); + + browser.test.log("Listen to background"); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + browser.test.assertEq( + msg, + "hello-from-background", + "Content script got hello-from-background message" + ); + + browser.test.assertTrue(!!sender, "Message has a sender"); + browser.test.assertTrue(!!sender.id, "Message has a sender.id"); + + const { id } = sender; + respond({ id }); + }); + + // Wait for "resume" message so we know the background script is also ready. + await new Promise(resolve => { + browser.test.onMessage.addListener(resolve); + browser.test.sendMessage("content-script-ready"); + }); + + browser.test.log("Send message from content to background"); + browser.runtime.sendMessage("hello-from-content"); + }, + }, + }); + + const contentScriptReady = extension2.awaitMessage("content-script-ready"); + const backgroundScriptReady = extension2.awaitMessage( + "background-script-ready" + ); + const finish = extension2.awaitFinish("rdm-messaging"); + + await extension2.startup(); + + // It appears the background script and content script can loaded in either order, so + // we'll wait for the both to listen before proceeding. + await backgroundScriptReady; + await contentScriptReady; + extension2.sendMessage("resume"); + + await finish; + await extension2.unload(); +}); diff --git a/devtools/client/responsive/test/browser/browser_in_rdm_pane.js b/devtools/client/responsive/test/browser/browser_in_rdm_pane.js new file mode 100644 index 0000000000..546e8d5b4a --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_in_rdm_pane.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify the inRDMPane property is set on a document when that +// document is being viewed in Responsive Design Mode. + +const TEST_URL = "http://example.com/"; + +addRDMTask(TEST_URL, async function ({ ui }) { + const viewportBrowser = ui.getViewportBrowser(); + + const contentURL = await SpecialPowers.spawn( + viewportBrowser, + [], + () => content.document.URL + ); + info("content URL is " + contentURL); + + const contentInRDMPane = await SpecialPowers.spawn( + viewportBrowser, + [], + () => docShell.browsingContext.inRDMPane + ); + + ok( + contentInRDMPane, + "After RDM is opened, document should have inRDMPane set to true." + ); +}); diff --git a/devtools/client/responsive/test/browser/browser_many_toggles.js b/devtools/client/responsive/test/browser/browser_many_toggles.js new file mode 100644 index 0000000000..a0fe19dba4 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_many_toggles.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify RDM can be toggled many times in a raw + +const TEST_URL = "https://example.com/"; + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + + // Record all currently active RDMs, there may not be one created on each loop + let opened = 0; + ResponsiveUIManager.on("on", () => opened++); + ResponsiveUIManager.on("off", () => opened--); + + for (let i = 0; i < 10; i++) { + info(`Toggling RDM #${i + 1}`); + // This may throw when we were just closing is still ongoing, + // ignore any exception. + openRDM(tab).catch(e => {}); + // Sometime pause in order to cover both full synchronous opening and close + // but also the same but with some pause between each operation. + if (i % 2 == 0) { + info("Wait a bit after open"); + await wait(250); + } + closeRDM(tab); + if (i % 3 == 0) { + info("Wait a bit after close"); + await wait(250); + } + } + + // Wait a bit so that we can receive the very last ResponsiveUIManager `on` event, + // and properly wait for its closing. + await wait(1000); + + // This is important to wait for all destruction as closing the tab while RDM + // is still closing may lead to exception because of pending cleanup RDP requests. + info("Wait for all opened RDM to be closed before closing the tab"); + await waitFor(() => opened == 0); + info("All RDM are closed"); + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_max_touchpoints.js b/devtools/client/responsive/test/browser/browser_max_touchpoints.js new file mode 100644 index 0000000000..a6feb1975f --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_max_touchpoints.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify that maxTouchPoints is updated when touch simulation is toggled. + +// TODO: This test should also check that maxTouchPoints is set properly on the iframe. +// This is currently not working and should be worked on in Bug 1706066. + +const TEST_DOCUMENT = `doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs`; +const TEST_COM_URL = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +addRDMTask(TEST_COM_URL, async function ({ ui, browser, tab }) { + reloadOnTouchChange(true); + info("Test initial value for maxTouchPoints."); + is( + await getMaxTouchPoints(browser), + 0, + "navigator.maxTouchPoints is 0 when touch simulation is not enabled" + ); + + info( + "Test value maxTouchPoints is non-zero when touch simulation is enabled." + ); + await toggleTouchSimulation(ui); + is( + await getMaxTouchPoints(browser), + 1, + "navigator.maxTouchPoints should be 1 after enabling touch simulation" + ); + + info("Toggling off touch simulation."); + await toggleTouchSimulation(ui); + is( + await getMaxTouchPoints(browser), + 0, + "navigator.maxTouchPoints should be 0 after turning off touch simulation" + ); + + info("Enabling touch simulation again"); + await toggleTouchSimulation(ui); + is( + await getMaxTouchPoints(browser), + 1, + "navigator.maxTouchPoints should be 1 after enabling touch simulation again" + ); + + info("Check maxTouchPoints override persists after reload"); + await reloadBrowser(); + + is( + await getMaxTouchPoints(browser), + 1, + "navigator.maxTouchPoints is still 1 after reloading" + ); + + info( + "Check that maxTouchPoints persist after navigating to a page that forces the creation of a new browsing context" + ); + const previousBrowsingContextId = browser.browsingContext.id; + + const waitForDevToolsReload = await watchForDevToolsReload(browser); + BrowserTestUtils.startLoadingURIString( + browser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await waitForDevToolsReload(); + + isnot( + browser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getMaxTouchPoints(browser), + 1, + "navigator.maxTouchPoints is still 1 after navigating to a new browsing context" + ); + + info("Check that the value is reset when closing RDM"); + // Closing RDM trigers a reload + const onPageReloaded = BrowserTestUtils.browserLoaded(browser, true); + await closeRDM(tab); + await onPageReloaded; + + is( + await getMaxTouchPoints(browser), + 0, + "navigator.maxTouchPoints is 0 after closing RDM" + ); + + reloadOnTouchChange(false); +}); + +function getMaxTouchPoints(browserOrBrowsingContext) { + return ContentTask.spawn( + browserOrBrowsingContext, + null, + () => content.navigator.maxTouchPoints + ); +} diff --git a/devtools/client/responsive/test/browser/browser_menu_item_01.js b/devtools/client/responsive/test/browser/browser_menu_item_01.js new file mode 100644 index 0000000000..72b5b835be --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_menu_item_01.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test RDM menu item is checked when expected, on multiple tabs. + +const TEST_URL = "data:text/html;charset=utf-8,"; + +const { + startup, +} = require("resource://devtools/client/responsive/utils/window.js"); + +const activateTab = tab => + new Promise(resolve => { + const { gBrowser } = tab.ownerGlobal; + const { tabContainer } = gBrowser; + + tabContainer.addEventListener("TabSelect", function listener({ type }) { + tabContainer.removeEventListener(type, listener); + resolve(); + }); + + gBrowser.selectedTab = tab; + }); + +const isMenuChecked = () => { + const menu = document.getElementById("menu_responsiveUI"); + return menu.getAttribute("checked") === "true"; +}; + +add_task(async function () { + await startup(window); + + ok(!isMenuChecked(), "RDM menu item is unchecked by default"); +}); + +let tab2; + +addRDMTaskWithPreAndPost( + TEST_URL, + function pre_task() { + ok(!isMenuChecked(), "RDM menu item is unchecked for new tab"); + }, + async function task({ browser }) { + ok(isMenuChecked(), "RDM menu item is checked with RDM open"); + + tab2 = await addTab(TEST_URL); + + ok(!isMenuChecked(), "RDM menu item is unchecked for new tab"); + + const tab = gBrowser.getTabForBrowser(browser); + await activateTab(tab); + + ok( + isMenuChecked(), + "RDM menu item is checked for the tab where RDM is open" + ); + }, + function post_task() { + ok(!isMenuChecked(), "RDM menu item is unchecked after RDM is closed"); + } +); + +add_task(async function () { + await removeTab(tab2); +}); diff --git a/devtools/client/responsive/test/browser/browser_menu_item_02.js b/devtools/client/responsive/test/browser/browser_menu_item_02.js new file mode 100644 index 0000000000..d0dd755580 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_menu_item_02.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test RDM menu item is checked when expected, on multiple windows. + +const TEST_URL = "data:text/html;charset=utf-8,"; + +const isMenuCheckedFor = ({ document }) => { + const menu = document.getElementById("menu_responsiveUI"); + return menu.getAttribute("checked") === "true"; +}; + +addRDMTask( + null, + async function () { + const window1 = await BrowserTestUtils.openNewBrowserWindow(); + const { gBrowser } = window1; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: TEST_URL }, + async function (browser) { + const tab = gBrowser.getTabForBrowser(browser); + + is( + window1, + Services.wm.getMostRecentWindow("navigator:browser"), + "The new window is the active one" + ); + + ok(!isMenuCheckedFor(window1), "RDM menu item is unchecked by default"); + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + ok(isMenuCheckedFor(window1), "RDM menu item is checked with RDM open"); + + await closeRDM(tab); + + ok( + !isMenuCheckedFor(window1), + "RDM menu item is unchecked with RDM closed" + ); + } + ); + + await BrowserTestUtils.closeWindow(window1); + + is( + window, + Services.wm.getMostRecentWindow("navigator:browser"), + "The original window is the active one" + ); + + ok(!isMenuCheckedFor(window), "RDM menu item is unchecked"); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_mouse_resize.js b/devtools/client/responsive/test/browser/browser_mouse_resize.js new file mode 100644 index 0000000000..62aa1b1eae --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_mouse_resize.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "data:text/html;charset=utf-8,"; + +addRDMTask( + TEST_URL, + async function ({ ui, manager }) { + const store = ui.toolWindow.store; + + // Wait until the viewport has been added + await waitUntilState(store, state => state.viewports.length == 1); + + await setViewportSize(ui, manager, 300, 300); + + // Do horizontal + vertical resize + await testViewportResize(ui, ".viewport-resize-handle", [10, 10], [10, 10]); + + // Do horizontal resize + await testViewportResize( + ui, + ".viewport-horizontal-resize-handle", + [-10, 10], + [-10, 0] + ); + + // Do vertical resize + await testViewportResize( + ui, + ".viewport-vertical-resize-handle", + [-10, -10], + [0, -10], + ui + ); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_navigation.js b/devtools/client/responsive/test/browser/browser_navigation.js new file mode 100644 index 0000000000..fbea607e6e --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_navigation.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the primary browser navigation UI to verify it's connected to the viewport. + +const DUMMY_1_URL = "https://example.com/"; +const TEST_URL = `${URL_ROOT_SSL}doc_page_state.html`; +const DUMMY_2_URL = "https://example.com/browser/"; +const DUMMY_3_URL = "https://example.com/browser/devtools/"; + +addRDMTask( + null, + async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + // Load up a sequence of pages: + // 0. DUMMY_1_URL + // 1. TEST_URL + // 2. DUMMY_2_URL + const tab = await addTab(DUMMY_1_URL); + const browser = tab.linkedBrowser; + await navigateTo(TEST_URL); + await navigateTo(DUMMY_2_URL); + + // Check session history state + let history = await getSessionHistory(browser); + is(history.index - 1, 2, "At page 2 in history"); + is(history.entries.length, 3, "3 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, TEST_URL, "Page 1 URL matches"); + is(history.entries[2].url, DUMMY_2_URL, "Page 2 URL matches"); + + // Go back one so we're at the test page + await back(browser); + + // Check session history state + history = await getSessionHistory(browser); + is(history.index - 1, 1, "At page 1 in history"); + is(history.entries.length, 3, "3 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, TEST_URL, "Page 1 URL matches"); + is(history.entries[2].url, DUMMY_2_URL, "Page 2 URL matches"); + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + ok(browser.webNavigation.canGoBack, "Going back is allowed"); + ok(browser.webNavigation.canGoForward, "Going forward is allowed"); + is(browser.documentURI.spec, TEST_URL, "documentURI matches page 1"); + is(browser.contentTitle, "Page State Test", "contentTitle matches page 1"); + + await forward(browser); + + ok(browser.webNavigation.canGoBack, "Going back is allowed"); + ok(!browser.webNavigation.canGoForward, "Going forward is not allowed"); + is(browser.documentURI.spec, DUMMY_2_URL, "documentURI matches page 2"); + is( + browser.contentTitle, + "mochitest index /browser/", + "contentTitle matches page 2" + ); + + await back(browser); + await back(browser); + + ok(!browser.webNavigation.canGoBack, "Going back is not allowed"); + ok(browser.webNavigation.canGoForward, "Going forward is allowed"); + is(browser.documentURI.spec, DUMMY_1_URL, "documentURI matches page 0"); + is( + browser.contentTitle, + "mochitest index /", + "contentTitle matches page 0" + ); + + await navigateTo(DUMMY_3_URL); + + ok(browser.webNavigation.canGoBack, "Going back is allowed"); + ok(!browser.webNavigation.canGoForward, "Going forward is not allowed"); + is(browser.documentURI.spec, DUMMY_3_URL, "documentURI matches page 3"); + is( + browser.contentTitle, + "mochitest index /browser/devtools/", + "contentTitle matches page 3" + ); + + await closeRDM(tab); + + // Check session history state + history = await getSessionHistory(browser); + is(history.index - 1, 1, "At page 1 in history"); + is(history.entries.length, 2, "2 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, DUMMY_3_URL, "Page 1 URL matches"); + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_network_throttling.js b/devtools/client/responsive/test/browser/browser_network_throttling.js new file mode 100644 index 0000000000..fbf97b172a --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_network_throttling.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + profiles, +} = require("resource://devtools/client/shared/components/throttling/profiles.js"); + +// Tests changing network throttling +const TEST_URL = "data:text/html;charset=utf-8,Network throttling test"; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + // Test defaults + testNetworkThrottlingSelectorLabel(ui, "No Throttling", "No Throttling"); + await testNetworkThrottlingState(ui, null); + + // Test a fast profile + await testThrottlingProfile( + ui, + "Wi-Fi", + "download 30Mbps, upload 15Mbps, latency 2ms" + ); + + // Test a slower profile + await testThrottlingProfile( + ui, + "Regular 3G", + "download 750Kbps, upload 250Kbps, latency 100ms" + ); + + // Test switching back to no throttling + await selectNetworkThrottling(ui, "No Throttling"); + testNetworkThrottlingSelectorLabel(ui, "No Throttling", "No Throttling"); + await testNetworkThrottlingState(ui, null); +}); + +function testNetworkThrottlingSelectorLabel( + ui, + expectedLabel, + expectedTooltip +) { + const title = ui.toolWindow.document.querySelector( + "#network-throttling-menu .title" + ); + is( + title.textContent, + expectedLabel, + `Button label should be changed to ${expectedLabel}` + ); + is( + title.parentNode.getAttribute("title"), + expectedTooltip, + `Button tooltip should be changed to ${expectedTooltip}` + ); +} + +var testNetworkThrottlingState = async function (ui, expected) { + const state = await ui.networkFront.getNetworkThrottling(); + Assert.deepEqual( + state, + expected, + "Network throttling state should be " + JSON.stringify(expected, null, 2) + ); +}; + +var testThrottlingProfile = async function (ui, profile, tooltip) { + await selectNetworkThrottling(ui, profile); + testNetworkThrottlingSelectorLabel(ui, profile, tooltip); + const data = profiles.find(({ id }) => id == profile); + const { download, upload, latency } = data; + await testNetworkThrottlingState(ui, { + downloadThroughput: download, + uploadThroughput: upload, + latency, + }); +}; diff --git a/devtools/client/responsive/test/browser/browser_orientationchange_event.js b/devtools/client/responsive/test/browser/browser_orientationchange_event.js new file mode 100644 index 0000000000..21818f2d81 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_orientationchange_event.js @@ -0,0 +1,244 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the "orientationchange" event is fired when the "rotate button" is clicked. + +// TODO: This test should also check that the orientation is set properly on the iframe. +// This is currently not working and should be worked on in Bug 1704830. + +const TEST_DOCUMENT = `doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs`; +const TEST_COM_URL = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +const testDevice = { + name: "Fake Phone RDM Test", + width: 320, + height: 570, + pixelRatio: 5.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "custom", + featured: true, +}; + +// Add the new device to the list +addDeviceForTest(testDevice); + +addRDMTask(TEST_COM_URL, async function ({ ui }) { + await pushPref("devtools.responsive.viewport.angle", 0); + + info("Check the original orientation values before the orientationchange"); + is( + await getScreenOrientationType(ui.getViewportBrowser()), + "portrait-primary", + "Primary orientation type is portrait-primary" + ); + + is( + await getScreenOrientationAngle(ui.getViewportBrowser()), + 0, + "Original angle is set at 0 degrees" + ); + + info( + "Check that rotating the viewport does trigger an orientationchange event" + ); + let waitForOrientationChangeEvent = isOrientationChangeEventEmitted( + ui.getViewportBrowser() + ); + rotateViewport(ui); + is( + await waitForOrientationChangeEvent, + true, + "'orientationchange' event fired" + ); + + is( + await getScreenOrientationType(ui.getViewportBrowser()), + "landscape-primary", + "Orientation state was updated to landscape-primary" + ); + + is( + await getScreenOrientationAngle(ui.getViewportBrowser()), + 90, + "Orientation angle was updated to 90 degrees" + ); + + info("Check that the viewport orientation values persist after reload"); + await reloadBrowser(); + + is( + await getScreenOrientationType(ui.getViewportBrowser()), + "landscape-primary", + "Orientation is still landscape-primary" + ); + is( + await getInitialScreenOrientationType(ui.getViewportBrowser()), + "landscape-primary", + "orientation type was set on the page very early in its lifecycle" + ); + is( + await getScreenOrientationAngle(ui.getViewportBrowser()), + 90, + "Orientation angle is still 90" + ); + is( + await getInitialScreenOrientationAngle(ui.getViewportBrowser()), + 90, + "orientation angle was set on the page early in its lifecycle" + ); + + info( + "Check that the viewport orientation values persist after navigating to a page that forces the creation of a new browsing context" + ); + const browser = ui.getViewportBrowser(); + const previousBrowsingContextId = browser.browsingContext.id; + const waitForReload = await watchForDevToolsReload(browser); + + BrowserTestUtils.startLoadingURIString( + browser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await waitForReload(); + + isnot( + browser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getScreenOrientationType(ui.getViewportBrowser()), + "landscape-primary", + "Orientation is still landscape-primary after navigating to a new browsing context" + ); + is( + await getInitialScreenOrientationType(ui.getViewportBrowser()), + "landscape-primary", + "orientation type was set on the page very early in its lifecycle" + ); + is( + await getScreenOrientationAngle(ui.getViewportBrowser()), + 90, + "Orientation angle is still 90 after navigating to a new browsing context" + ); + is( + await getInitialScreenOrientationAngle(ui.getViewportBrowser()), + 90, + "orientation angle was set on the page early in its lifecycle" + ); + + info( + "Check the orientationchange event is not dispatched when changing devices." + ); + waitForOrientationChangeEvent = isOrientationChangeEventEmitted( + ui.getViewportBrowser() + ); + + await selectDevice(ui, testDevice.name); + is( + await waitForOrientationChangeEvent, + false, + "orientationchange event was not dispatched when changing devices" + ); + + info("Check the new orientation values after selecting device."); + is( + await getScreenOrientationType(ui.getViewportBrowser()), + "portrait-primary", + "New orientation type is portrait-primary" + ); + + is( + await getScreenOrientationAngle(ui.getViewportBrowser()), + 0, + "Orientation angle is 0" + ); + + info( + "Check the orientationchange event is not dispatched when calling the command with the same orientation." + ); + waitForOrientationChangeEvent = isOrientationChangeEventEmitted( + ui.getViewportBrowser() + ); + + // We're directly calling the command here as there's no way to do such action from the UI. + await ui.commands.targetConfigurationCommand.updateConfiguration({ + rdmPaneOrientation: { + type: "portrait-primary", + angle: 0, + isViewportRotated: true, + }, + }); + is( + await waitForOrientationChangeEvent, + false, + "orientationchange event was not dispatched after trying to set the same orientation again" + ); +}); + +function getScreenOrientationType(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.screen.orientation.type; + }); +} + +function getScreenOrientationAngle(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.screen.orientation.angle + ); +} + +function getInitialScreenOrientationType(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialOrientationType + ); +} + +function getInitialScreenOrientationAngle(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialOrientationAngle + ); +} + +async function isOrientationChangeEventEmitted(browserOrBrowsingContext) { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const onOrientationChangeEvent = SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => { + content.eventController = new content.AbortController(); + return new Promise(resolve => { + content.window.addEventListener( + "orientationchange", + () => { + resolve(); + }, + { + signal: content.eventController.signal, + once: true, + } + ); + }); + } + ); + + const result = await Promise.race([onTimeout, onOrientationChangeEvent]); + + // Remove the event listener + await SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + content.eventController.abort(); + delete content.eventController; + }); + + return result !== "TIMEOUT"; +} diff --git a/devtools/client/responsive/test/browser/browser_page_redirection.js b/devtools/client/responsive/test/browser/browser_page_redirection.js new file mode 100644 index 0000000000..5a90aa1bac --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_page_redirection.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for redirection. + +const TEST_URL = `${URL_ROOT}sjs_redirection.sjs`; +const CUSTOM_USER_AGENT = "Mozilla/5.0 (Test Device) Firefox/74.0"; + +addRDMTask( + null, + async () => { + reloadOnUAChange(true); + + registerCleanupFunction(() => { + reloadOnUAChange(false); + }); + + const tab = await addTab(TEST_URL); + const browser = tab.linkedBrowser; + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + info("Change the user agent"); + await changeUserAgentInput(ui, CUSTOM_USER_AGENT); + await testUserAgent(ui, CUSTOM_USER_AGENT); + + info("Load a page which redirects"); + const onRedirectedPageLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + // wait specifically for the redirected page + url => url.includes(`?redirected`) + ); + BrowserTestUtils.startLoadingURIString(browser, `${TEST_URL}?redirect`); + await onRedirectedPageLoaded; + + info("Check the user agent for each requests"); + await SpecialPowers.spawn( + browser, + [CUSTOM_USER_AGENT], + expectedUserAgent => { + is( + content.wrappedJSObject.redirectRequestUserAgent, + expectedUserAgent, + `Sent user agent is correct for request that caused the redirect` + ); + is( + content.wrappedJSObject.requestUserAgent, + expectedUserAgent, + `Sent user agent is correct for the redirected page` + ); + } + ); + + await closeRDM(tab); + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_page_state.js b/devtools/client/responsive/test/browser/browser_page_state.js new file mode 100644 index 0000000000..6b01031fbb --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_page_state.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test page state to ensure page is not reloaded and session history is not +// modified. + +const DUMMY_1_URL = "https://example.com/"; +const TEST_URL = `${URL_ROOT_SSL}doc_page_state.html`; +const DUMMY_2_URL = "https://example.com/browser/"; + +addRDMTask( + null, + async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + // Load up a sequence of pages: + // 0. DUMMY_1_URL + // 1. TEST_URL + // 2. DUMMY_2_URL + const tab = await addTab(DUMMY_1_URL); + const browser = tab.linkedBrowser; + await navigateTo(TEST_URL); + await navigateTo(DUMMY_2_URL); + + // Check session history state + let history = await getSessionHistory(browser); + is(history.index - 1, 2, "At page 2 in history"); + is(history.entries.length, 3, "3 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, TEST_URL, "Page 1 URL matches"); + is(history.entries[2].url, DUMMY_2_URL, "Page 2 URL matches"); + + // Go back one so we're at the test page + await back(browser); + + // Check session history state + history = await getSessionHistory(browser); + is(history.index - 1, 1, "At page 1 in history"); + is(history.entries.length, 3, "3 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, TEST_URL, "Page 1 URL matches"); + is(history.entries[2].url, DUMMY_2_URL, "Page 2 URL matches"); + + // Click on content to set an altered state that would be lost on reload + await BrowserTestUtils.synthesizeMouseAtCenter("body", {}, browser); + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + // Check color inside the viewport + let color = await spawnViewportTask(ui, {}, function () { + return content + .getComputedStyle(content.document.body) + .getPropertyValue("background-color"); + }); + is( + color, + "rgb(0, 128, 0)", + "Content is still modified from click in viewport" + ); + + await closeRDM(tab); + + // Check color back in the browser tab + color = await SpecialPowers.spawn(browser, [], async function () { + return content + .getComputedStyle(content.document.body) + .getPropertyValue("background-color"); + }); + is( + color, + "rgb(0, 128, 0)", + "Content is still modified from click in browser tab" + ); + + // Check session history state + history = await getSessionHistory(browser); + is(history.index - 1, 1, "At page 1 in history"); + is(history.entries.length, 3, "3 pages in history"); + is(history.entries[0].url, DUMMY_1_URL, "Page 0 URL matches"); + is(history.entries[1].url, TEST_URL, "Page 1 URL matches"); + is(history.entries[2].url, DUMMY_2_URL, "Page 2 URL matches"); + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_page_style.js b/devtools/client/responsive/test/browser/browser_page_style.js new file mode 100644 index 0000000000..c59ba03b47 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_page_style.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the Page Style browser menu actions make it to the viewport, instead of +// applying to the RDM UI. + +const TEST_URL = `${URL_ROOT}page_style.html`; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + // Store the RDM body text color for later. + const rdmWindow = ui.toolWindow; + const rdmTextColor = rdmWindow.getComputedStyle( + rdmWindow.document.body + ).color; + + info( + "Trigger the no page style action and wait for the text color to change" + ); + let onPageColorChanged = waitForContentPageTextColor(ui, "rgb(0, 0, 0)"); + let menuItem = document.querySelector("#menu_pageStyleNoStyle"); + menuItem.click(); + let color = await onPageColorChanged; + + is( + color, + "rgb(0, 0, 0)", + "The text color is black, so the style was disabled" + ); + + info("Check that the RDM page style wasn't disabled"); + is( + rdmWindow.getComputedStyle(rdmWindow.document.body).color, + rdmTextColor, + "The color of the text in the RDM window is correct, so that style still applies" + ); + + info( + "Trigger the page style back and wait for the text color to change again" + ); + onPageColorChanged = waitForContentPageTextColor(ui, "rgb(255, 0, 0)"); + menuItem = document.querySelector("#menu_pageStylePersistentOnly"); + menuItem.click(); + color = await onPageColorChanged; + + is( + color, + "rgb(255, 0, 0)", + "The text color is red, so the style was enabled" + ); +}); + +function waitForContentPageTextColor(ui, expectedColor) { + return SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ expectedColor }], + function (args) { + return new Promise(resolve => { + const interval = content.setInterval(() => { + const color = content.getComputedStyle(content.document.body).color; + if (color === args.expectedColor) { + content.clearInterval(interval); + resolve(color); + } + }, 200); + }); + } + ); +} diff --git a/devtools/client/responsive/test/browser/browser_permission_doorhanger.js b/devtools/client/responsive/test/browser/browser_permission_doorhanger.js new file mode 100644 index 0000000000..25c10b9bf5 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_permission_doorhanger.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that permission popups asking for user approval still appear in RDM +const DUMMY_URL = "http://example.com/"; +const TEST_URL = `${URL_ROOT}geolocation.html`; +const TEST_SURL = TEST_URL.replace("http://example.com", "https://example.com"); + +function waitForGeolocationPrompt(win, browser) { + return new Promise(resolve => { + win.PopupNotifications.panel.addEventListener( + "popupshown", + function popupShown() { + const notification = win.PopupNotifications.getNotification( + "geolocation", + browser + ); + if (notification) { + win.PopupNotifications.panel.removeEventListener( + "popupshown", + popupShown + ); + resolve(); + } + } + ); + }); +} + +addRDMTask( + null, + async function () { + // we want to explicitly tests http and https, hence + // disabling https-first mode for this test. + await pushPref("dom.security.https_first", false); + + const tab = await addTab(DUMMY_URL); + const browser = tab.linkedBrowser; + const win = browser.ownerGlobal; + + let waitPromptPromise = waitForGeolocationPrompt(win, browser); + + // Checks if a geolocation permission doorhanger appears when openning a page + // requesting geolocation + await navigateTo(TEST_SURL); + await waitPromptPromise; + + ok(true, "Permission doorhanger appeared without RDM enabled"); + + // Lets switch back to the dummy website and enable RDM + await navigateTo(DUMMY_URL); + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + const newBrowser = ui.getViewportBrowser(); + waitPromptPromise = waitForGeolocationPrompt(win, newBrowser); + + // Checks if the doorhanger appeared again when reloading the geolocation + // page inside RDM + await navigateTo(TEST_SURL); + + await waitPromptPromise; + + ok(true, "Permission doorhanger appeared inside RDM"); + + await closeRDM(tab); + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_picker_link.js b/devtools/client/responsive/test/browser/browser_picker_link.js new file mode 100644 index 0000000000..1aedb06dd0 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_picker_link.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that picking a link when using RDM does not trigger a navigation. + * See Bug 1609199. + */ +const TEST_URI = `${URL_ROOT}doc_picker_link.html`; + +addRDMTask(TEST_URI, async function ({ ui, manager }) { + info("Open the rule-view and select the test node before opening RDM"); + const { inspector, toolbox } = await openRuleView(); + await selectNode("body", inspector); + + info("Open RDM"); + + // XXX: Using toggleTouchSimulation waits for browser loaded, which is not + // fired here? + info("Toggle Touch simulation"); + const { document } = ui.toolWindow; + const touchButton = document.getElementById("touch-simulation-button"); + const changed = once(ui, "touch-simulation-changed"); + touchButton.click(); + await changed; + + info("Waiting for element picker to become active."); + await startPicker(toolbox, ui); + + info("Move mouse over the pick-target"); + await hoverElement(inspector, ui, ".picker-link", 15, 15); + + // Add a listener on the "navigate" event. + let hasNavigated = false; + const { onDomCompleteResource } = + await waitForNextTopLevelDomCompleteResource(toolbox.commands); + + onDomCompleteResource.then(() => { + hasNavigated = true; + }); + + info("Click and pick the link"); + await pickElement(inspector, ui, ".picker-link"); + + // Wait until page to start navigation. + await wait(2000); + ok( + !hasNavigated, + "The page should not have navigated when picking the <a> element" + ); +}); + +/** + * startPicker, hoverElement and pickElement are slightly modified copies of + * inspector's head.js helpers, but using spawnViewportTask to interact with the + * content page (as well as some other slight modifications). + */ + +async function startPicker(toolbox, ui) { + info("Start the element picker"); + toolbox.win.focus(); + await toolbox.nodePicker.start(); + // By default make sure the content window is focused since the picker may not focus + // the content window by default. + await spawnViewportTask(ui, {}, async () => { + content.focus(); + }); +} + +async function hoverElement(inspector, ui, selector, x, y) { + info("Waiting for element " + selector + " to be hovered"); + const onHovered = inspector.toolbox.nodePicker.once("picker-node-hovered"); + await spawnViewportTask(ui, { selector, x, y }, async options => { + const target = content.document.querySelector(options.selector); + await EventUtils.synthesizeMouse( + target, + options.x, + options.y, + { type: "mousemove", isSynthesized: false }, + content + ); + }); + return onHovered; +} + +async function pickElement(inspector, ui, selector) { + info("Waiting for element " + selector + " to be picked"); + const onNewNodeFront = inspector.selection.once("new-node-front"); + await spawnViewportTask(ui, { selector }, async options => { + const target = content.document.querySelector(options.selector); + EventUtils.synthesizeClick(target); + }); + info("Returning on new-node-front"); + return onNewNodeFront; +} diff --git a/devtools/client/responsive/test/browser/browser_preloaded_newtab.js b/devtools/client/responsive/test/browser/browser_preloaded_newtab.js new file mode 100644 index 0000000000..bda4ada24d --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_preloaded_newtab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify RDM opens for a preloaded about:newtab browser. + +const TEST_URL = "https://example.com/"; + +addRDMTask( + null, + async function () { + const preloadedBrowser = gBrowser.preloadedBrowser; + + // Open a tab with about:newtab. + // Don't wait for load because the page is preloaded. + const tab = await addTab(BROWSER_NEW_TAB_URL, { + waitForLoad: false, + }); + const browser = tab.linkedBrowser; + is(browser, preloadedBrowser, "Got a preloaded browser for newtab"); + + // Open RDM and try to navigate + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + await navigateTo(TEST_URL); + ok(true, "Test URL navigated successfully"); + + await closeRDM(tab); + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_screenshot_button.js b/devtools/client/responsive/test/browser/browser_screenshot_button.js new file mode 100644 index 0000000000..a5176feeca --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_screenshot_button.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test global screenshot button + +const TEST_URL = "data:text/html;charset=utf-8,"; + +addRDMTask(TEST_URL, async function ({ ui }) { + const { toolWindow } = ui; + const { store, document } = toolWindow; + + info("Click the screenshot button"); + const screenshotButton = document.getElementById("screenshot-button"); + screenshotButton.click(); + + const whenScreenshotSucceeded = waitUntilScreenshot(); + + const filePath = await whenScreenshotSucceeded; + const image = new Image(); + image.src = PathUtils.toFileURI(filePath); + + await once(image, "load"); + + // We have only one viewport at the moment + const viewport = store.getState().viewports[0]; + const ratio = window.devicePixelRatio; + + is( + image.width, + viewport.width * ratio, + "screenshot width has the expected width" + ); + + is( + image.height, + viewport.height * ratio, + "screenshot width has the expected height" + ); + + await IOUtils.remove(filePath); + await resetDownloads(); +}); diff --git a/devtools/client/responsive/test/browser/browser_screenshot_button_warning.js b/devtools/client/responsive/test/browser/browser_screenshot_button_warning.js new file mode 100644 index 0000000000..f7940b3cbf --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_screenshot_button_warning.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that warning messages emitted when taking a screenshot are displayed in the UI. + +const TEST_URL = `http://example.net/document-builder.sjs?html= + <style> + body { + margin: 0; + height: 10001px; + } + </style>Hello world`; + +addRDMTask( + TEST_URL, + async function ({ ui, browser, manager }) { + const { toolWindow } = ui; + const { document } = toolWindow; + + info( + "Set a big viewport and high dpr so the screenshot dpr gets downsized" + ); + // The viewport can't be bigger than 9999×9999 + await setViewportSize(ui, manager, 9999, 9999); + const dpr = 3; + await selectDevicePixelRatio(ui, dpr); + await waitForDevicePixelRatio(ui, dpr); + + info("Click the screenshot button"); + const onScreenshotDownloaded = waitUntilScreenshot(); + const screenshotButton = document.getElementById("screenshot-button"); + screenshotButton.click(); + + const filePath = await onScreenshotDownloaded; + ok(filePath, "The screenshot was taken"); + + info( + "Check that a warning message was displayed to indicate the dpr was changed" + ); + + const box = gBrowser.getNotificationBox(browser); + await waitUntil(() => box.currentNotification); + + const notificationEl = box.currentNotification; + ok(notificationEl, "Notification should be visible"); + is( + notificationEl.messageText.textContent.trim(), + "The device pixel ratio was reduced to 1 as the resulting image was too large", + "The expected warning was displayed" + ); + + //Remove the downloaded screenshot file + await IOUtils.remove(filePath); + await resetDownloads(); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_scroll.js b/devtools/client/responsive/test/browser/browser_scroll.js new file mode 100644 index 0000000000..5e135ca632 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_scroll.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test is checking that keyboard scrolling of content in RDM + * behaves correctly, both with and without touch simulation enabled. + */ + +const PAINT_LISTENER_JS_URL = + URL_ROOT + "../../../../../../tests/SimpleTest/paint_listener.js"; + +const APZ_TEST_UTILS_JS_URL = + URL_ROOT + "../../../../../gfx/layers/apz/test/mochitest/apz_test_utils.js"; + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<head><meta name="viewport" content="width=100, height=100"/>' + + '<script src="' + + PAINT_LISTENER_JS_URL + + '"></script>' + + '<script src="' + + APZ_TEST_UTILS_JS_URL + + '"></script>' + + "</head>" + + '<div style="background:blue; width:200px; height:200px"></div>'; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + await setViewportSize(ui, manager, 50, 50); + const browser = ui.getViewportBrowser(); + + for (const mv in [true, false]) { + await ui.updateTouchSimulation(mv); + + info("Setting focus on the browser."); + browser.focus(); + + await SpecialPowers.spawn(browser, [], async () => { + // First of all, cancel any async scroll animation if there is. If there's + // an on-going async scroll animation triggered by synthesizeKey, below + // scrollTo call scrolls to a position nearby (0, 0) so that this test + // won't work as expected. + await content.wrappedJSObject.cancelScrollAnimation( + content.document.scrollingElement, + content + ); + + content.scrollTo(0, 0); + }); + + info("Testing scroll behavior with touch simulation " + mv + "."); + await testScrollingOfContent(ui); + } +}); + +async function testScrollingOfContent(ui) { + let scroll; + + info("Checking initial scroll conditions."); + const viewportScroll = await getViewportScroll(ui); + is(viewportScroll.x, 0, "Content should load with scrollX 0."); + is(viewportScroll.y, 0, "Content should load with scrollY 0."); + + /** + * Here we're going to send off some arrow key events to trigger scrolling. + * What we would like to be able to do is to await the scroll event and then + * check the scroll position to confirm the amount of scrolling that has + * happened. Unfortunately, APZ makes the scrolling happen asynchronously on + * the compositor thread, and it's very difficult to await the end state of + * the APZ animation -- see the tests in /gfx/layers/apz/test/mochitest for + * an example. For our purposes, it's sufficient to test that the scroll + * event is fired at all, and not worry about the amount of scrolling that + * has occurred at the time of the event. If the key events don't trigger + * scrolling, then no event will be fired and the test will time out. + */ + scroll = waitForViewportScroll(ui); + info("Synthesizing an arrow key down."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await scroll; + info("Scroll event was fired after arrow key down."); + + scroll = waitForViewportScroll(ui); + info("Synthesizing an arrow key right."); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await scroll; + info("Scroll event was fired after arrow key right."); +} diff --git a/devtools/client/responsive/test/browser/browser_state_restore.js b/devtools/client/responsive/test/browser/browser_state_restore.js new file mode 100644 index 0000000000..c41297cb63 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_state_restore.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the previous viewport size, user agent, dppx and touch simulation properties +// are restored when reopening RDM. + +const TEST_URL = "data:text/html;charset=utf-8,"; +const DEFAULT_DPPX = window.devicePixelRatio; +const NEW_DPPX = DEFAULT_DPPX + 1; +const NEW_USER_AGENT = "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0"; + +addRDMTask( + TEST_URL, + async function ({ ui, manager }) { + const { store } = ui.toolWindow; + + reloadOnTouchChange(true); + reloadOnUAChange(true); + + await waitUntilState(store, state => state.viewports.length == 1); + + info("Checking the default RDM state."); + testViewportDeviceMenuLabel(ui, "Responsive"); + testViewportDimensions(ui, 320, 480); + await testUserAgent(ui, DEFAULT_UA); + await testDevicePixelRatio(ui, DEFAULT_DPPX); + await testTouchEventsOverride(ui, false); + + info("Changing the RDM size, dppx, ua and toggle ON touch simulations."); + await setViewportSize(ui, manager, 90, 500); + await selectDevicePixelRatio(ui, NEW_DPPX); + await toggleTouchSimulation(ui); + await changeUserAgentInput(ui, NEW_USER_AGENT); + + reloadOnTouchChange(false); + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { store } = ui.toolWindow; + + reloadOnTouchChange(true); + reloadOnUAChange(true); + + info("Reopening RDM and checking that the previous state is restored."); + + await waitUntilState(store, state => state.viewports.length == 1); + + testViewportDimensions(ui, 90, 500); + await testUserAgent(ui, NEW_USER_AGENT); + await testDevicePixelRatio(ui, NEW_DPPX); + await testTouchEventsOverride(ui, true); + + info("Rotating the viewport."); + rotateViewport(ui); + + reloadOnTouchChange(false); + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + const { store } = ui.toolWindow; + + reloadOnTouchChange(true); + reloadOnUAChange(true); + + info("Reopening RDM and checking that the previous state is restored."); + + await waitUntilState(store, state => state.viewports.length == 1); + + testViewportDimensions(ui, 500, 90); + await testUserAgent(ui, NEW_USER_AGENT); + await testDevicePixelRatio(ui, NEW_DPPX); + await testTouchEventsOverride(ui, true); + + reloadOnTouchChange(false); + reloadOnUAChange(false); + }, + { waitForDeviceList: true } +); diff --git a/devtools/client/responsive/test/browser/browser_tab_close.js b/devtools/client/responsive/test/browser/browser_tab_close.js new file mode 100644 index 0000000000..6abe3536f9 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_tab_close.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify RDM closes synchronously when tabs are closed. + +const TEST_URL = "http://example.com/"; + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + const clientClosed = waitForClientClose(ui); + + closeRDM(tab, { + reason: "TabClose", + }); + + // This flag is set at the end of `ResponsiveUI.destroy`. If it is true + // without waiting for `closeRDM` above, then we must have closed + // synchronously. + is(ui.destroyed, true, "RDM closed synchronously"); + + await clientClosed; + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + const clientClosed = waitForClientClose(ui); + + await removeTab(tab); + + // This flag is set at the end of `ResponsiveUI.destroy`. If it is true without + // waiting for `closeRDM` itself and only removing the tab, then we must have closed + // synchronously in response to tab closing. + is(ui.destroyed, true, "RDM closed synchronously"); + + await clientClosed; + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_tab_not_selected.js b/devtools/client/responsive/test/browser/browser_tab_not_selected.js new file mode 100644 index 0000000000..ac36f788f7 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_tab_not_selected.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify RDM opens for the correct tab, even if it is not the currently +// selected tab. + +const TEST_URL = "http://example.com/"; + +addRDMTask( + null, + async function () { + info("Open two tabs"); + const tab1 = await addTab(TEST_URL); + const tab2 = await addTab(TEST_URL); + + is(gBrowser.selectedTab, tab2, "The selected tab is tab2"); + + info("Open RDM for the non-selected tab"); + const { ui } = await openRDM(tab1); + + ok(!ResponsiveUIManager.isActiveForTab(tab2), "RDM is not opened on tab2"); + + // Not mandatory for the test to pass, but it is helpful to see the RDM tab + // for Try failure screenshots. + info("Select the first tab"); + gBrowser.selectedTab = tab1; + + info("Try to update the DPI"); + await selectDevicePixelRatio(ui, 2); + const dppx = await waitForDevicePixelRatio(ui, 2, { + waitForTargetConfiguration: true, + }); + is(dppx, 2, "Content has expected devicePixelRatio"); + + const clientClosed = waitForClientClose(ui); + await removeTab(tab2); + await removeTab(tab1); + await clientClosed; + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_tab_remoteness_change.js b/devtools/client/responsive/test/browser/browser_tab_remoteness_change.js new file mode 100644 index 0000000000..9a445b6fd6 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_tab_remoteness_change.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify Fission-enabled RDM remains open when tab changes remoteness. + +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Permission denied to access property "document" on cross-origin object/ +); + +const Types = require("resource://devtools/client/responsive/types.js"); + +const TEST_URL = "http://example.com/"; + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + + const { ui } = await openRDM(tab); + const { store } = ui.toolWindow; + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.devices.listState == Types.loadableState.LOADED + ); + + // Load URL that requires the main process, forcing a remoteness flip + await navigateTo("about:robots"); + + // Bug 1625501: RDM will remain open when the embedded browser UI is enabled. + is(ui.destroyed, false, "RDM is still open."); + + info("Close RDM"); + await closeRDM(tab); + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_tab_remoteness_change_fission_switch_target.js b/devtools/client/responsive/test/browser/browser_tab_remoteness_change_fission_switch_target.js new file mode 100644 index 0000000000..0fad9aed2d --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_tab_remoteness_change_fission_switch_target.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for target switching. + +const PAGE_ON_CHILD = "http://example.com/"; +const PAGE_ON_MAIN = "about:robots"; + +const TEST_DPPX = 2; + +add_task(async function () { + // Set a pref for DPPX in order to assert whether the RDM is working correctly or not. + await pushPref("devtools.responsive.viewport.pixelRatio", TEST_DPPX); + + info("Open a page which runs on the child process"); + const tab = await addTab(PAGE_ON_CHILD); + await assertDocshell(tab, false, 0); + + info("Open RDM"); + await openRDM(tab); + await assertDocshell(tab, true, TEST_DPPX); + + info("Load a page which runs on the main process"); + await navigateTo(PAGE_ON_MAIN); + await assertDocshell(tab, true, TEST_DPPX); + + info("Close RDM"); + await closeRDM(tab); + await assertDocshell(tab, false, 0); + + await removeTab(tab); +}); + +async function assertDocshell(tab, expectedRDMMode, expectedDPPX) { + await asyncWaitUntil(async () => { + const { overrideDPPX, inRDMPane } = tab.linkedBrowser.browsingContext; + return inRDMPane === expectedRDMMode && overrideDPPX === expectedDPPX; + }); + ok(true, "The state of the docshell is correct"); +} diff --git a/devtools/client/responsive/test/browser/browser_target_blank.js b/devtools/client/responsive/test/browser/browser_target_blank.js new file mode 100644 index 0000000000..65dbf3386d --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_target_blank.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Ensure target="_blank" link opens a new tab + +const TAB_URL = "http://example.com/"; +const TEST_URL = + `data:text/html,<a href="${TAB_URL}" target="_blank">Click me</a>`.replace( + / /g, + "%20" + ); + +addRDMTask(TEST_URL, async function ({ ui }) { + // Click the target="_blank" link and wait for a new tab + await waitForFrameLoad(ui, TEST_URL); + const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, TAB_URL); + await spawnViewportTask(ui, {}, function () { + content.document.querySelector("a").click(); // eslint-disable-line + }); + const newTab = await newTabPromise; + ok(newTab, "New tab opened from link"); + await removeTab(newTab); +}); diff --git a/devtools/client/responsive/test/browser/browser_telemetry_activate_rdm.js b/devtools/client/responsive/test/browser/browser_telemetry_activate_rdm.js new file mode 100644 index 0000000000..7c3927fb11 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_telemetry_activate_rdm.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const URL = "data:text/html;charset=utf8,browser_telemetry_activate_rdm.js"; +const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; +const DATA = [ + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "responsive_design", + value: null, + extra: { + host: "none", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "responsive_design", + value: null, + extra: { + host: "none", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "activate", + object: "responsive_design", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, + { + timestamp: null, + category: "devtools.main", + method: "deactivate", + object: "responsive_design", + value: null, + extra: { + host: "bottom", + width: "1300", + }, + }, +]; + +addRDMTask( + null, + async function () { + // Let's reset the counts. + Services.telemetry.clearEvents(); + + // Ensure no events have been logged + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + ok(!snapshot.parent, "No events have been logged for the main process"); + + const tab = await addTab(URL); + + await openCloseRDM(tab); + await gDevTools.showToolboxForTab(tab, { toolId: "inspector" }); + await openCloseRDM(tab); + await checkResults(); + }, + { onlyPrefAndTask: true } +); + +async function openCloseRDM(tab) { + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + const clientClosed = waitForClientClose(ui); + + closeRDM(tab, { + reason: "TabClose", + }); + + // This flag is set at the end of `ResponsiveUI.destroy`. If it is true + // without waiting for `closeRDM` above, then we must have closed + // synchronously. + is(ui.destroyed, true, "RDM closed synchronously"); + + await clientClosed; +} + +async function checkResults() { + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const events = snapshot.parent.filter( + event => + event[1] === "devtools.main" && + (event[2] === "activate" || event[2] === "deactivate") + ); + + for (const i in events) { + const [timestamp, category, method, object, value, extra] = events[i]; + + const expected = DATA[i]; + + // ignore timestamp + Assert.greater(timestamp, 0, "timestamp is greater than 0"); + is(category, expected.category, "category is correct"); + is(method, expected.method, "method is correct"); + is(object, expected.object, "object is correct"); + is(value, expected.value, "value is correct"); + + // extras + is(extra.host, expected.extra.host, "host is correct"); + ok(extra.width > 0, "width is greater than 0"); + } +} diff --git a/devtools/client/responsive/test/browser/browser_toolbox_computed_view.js b/devtools/client/responsive/test/browser/browser_toolbox_computed_view.js new file mode 100644 index 0000000000..f22ee8d246 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_toolbox_computed_view.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that when the viewport is resized, the computed-view refreshes. + +const TEST_URI = + "data:text/html;charset=utf-8,<html><style>" + + "div {" + + " width: 500px;" + + " height: 10px;" + + " background: purple;" + + "} " + + "@media screen and (max-width: 200px) {" + + " div { " + + " width: 100px;" + + " }" + + "};" + + "</style><div></div></html>"; + +addRDMTask(TEST_URI, async function ({ ui, manager }) { + info("Open the responsive design mode and set its size to 500x500 to start"); + await setViewportSize(ui, manager, 500, 500); + + info("Open the inspector, computed-view and select the test node"); + const { inspector, view } = await openComputedView(); + await selectNode("div", inspector); + + info("Try shrinking the viewport and checking the applied styles"); + await testShrink(view, inspector, ui, manager); + + info("Try growing the viewport and checking the applied styles"); + await testGrow(view, inspector, ui, manager); + + await closeToolbox(); +}); + +async function testShrink(computedView, inspector, ui, manager) { + is(computedWidth(computedView), "500px", "Should show 500px initially."); + + const onRefresh = inspector.once("computed-view-refreshed"); + await setViewportSize(ui, manager, 100, 100); + await onRefresh; + + is(computedWidth(computedView), "100px", "Should be 100px after shrinking."); +} + +async function testGrow(computedView, inspector, ui, manager) { + const onRefresh = inspector.once("computed-view-refreshed"); + await setViewportSize(ui, manager, 500, 500); + await onRefresh; + + is(computedWidth(computedView), "500px", "Should be 500px after growing."); +} + +function computedWidth(computedView) { + for (const prop of computedView.propertyViews) { + if (prop.name === "width") { + return prop.valueNode.textContent; + } + } + return null; +} diff --git a/devtools/client/responsive/test/browser/browser_toolbox_rule_view.js b/devtools/client/responsive/test/browser/browser_toolbox_rule_view.js new file mode 100644 index 0000000000..ff4bc4e39f --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_toolbox_rule_view.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that when the viewport is resized, the rule-view refreshes. + +const TEST_URI = `${URL_ROOT}doc_toolbox_rule_view.html`; + +addRDMTask(TEST_URI, async function ({ ui, manager }) { + info("Open the responsive design mode and set its size to 500x500 to start"); + await setViewportSize(ui, manager, 500, 500); + + info("Open the inspector, rule-view and select the test node"); + const { inspector, view } = await openRuleView(); + await selectNode("div", inspector); + + info("Try shrinking the viewport and checking the applied styles"); + await testShrink(view, ui, manager); + + info("Try growing the viewport and checking the applied styles"); + await testGrow(view, ui, manager); + + info("Check that ESC still opens the split console"); + await testEscapeOpensSplitConsole(inspector); + + await closeToolbox(); +}); + +async function testShrink(ruleView, ui, manager) { + is(numberOfRules(ruleView), 2, "Should have two rules initially."); + + info("Resize to 100x100 and wait for the rule-view to update"); + const onRefresh = ruleView.once("ruleview-refreshed"); + await setViewportSize(ui, manager, 100, 100); + await onRefresh; + + is(numberOfRules(ruleView), 3, "Should have three rules after shrinking."); +} + +async function testGrow(ruleView, ui, manager) { + info("Resize to 500x500 and wait for the rule-view to update"); + const onRefresh = ruleView.once("ruleview-refreshed"); + await setViewportSize(ui, manager, 500, 500); + await onRefresh; + + is(numberOfRules(ruleView), 2, "Should have two rules after growing."); +} + +async function testEscapeOpensSplitConsole(inspector) { + ok(!inspector._toolbox._splitConsole, "Console is not split."); + + info("Press escape"); + const onSplit = inspector._toolbox.once("split-console"); + EventUtils.synthesizeKey("KEY_Escape"); + await onSplit; + + ok(inspector._toolbox._splitConsole, "Console is split after pressing ESC."); +} + +function numberOfRules(ruleView) { + return ruleView.element.querySelectorAll(".ruleview-code").length; +} diff --git a/devtools/client/responsive/test/browser/browser_toolbox_rule_view_reload.js b/devtools/client/responsive/test/browser/browser_toolbox_rule_view_reload.js new file mode 100644 index 0000000000..73aebac056 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_toolbox_rule_view_reload.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the ruleview is still correctly displayed after reloading the page. + * See Bug 1487284. + */ + +// To trigger the initial issue, the stylesheet needs to be fetched from the network +// monitor, so we can not use a data:uri with inline styles here. +const TEST_URI = `${URL_ROOT}doc_toolbox_rule_view.html`; + +addRDMTaskWithPreAndPost( + TEST_URI, + async function pre_task() { + info("Open the rule-view and select the test node before opening RDM"); + const ruleViewValues = await openRuleView(); + const { inspector, view } = ruleViewValues; + await selectNode("div", inspector); + + is(numberOfRules(view), 2, "Rule view has two rules."); + + return ruleViewValues; + }, + async function task({ preTaskValue }) { + const { inspector, view } = preTaskValue; + + info("Reload the current page"); + const onNewRoot = inspector.once("new-root"); + const onRuleViewRefreshed = inspector.once("rule-view-refreshed"); + await reloadBrowser(); + await onNewRoot; + await inspector.markup._waitForChildren(); + await onRuleViewRefreshed; + + // Await two reflows of the Rule View window. + await new Promise(resolve => { + view.styleWindow.requestAnimationFrame(() => { + view.styleWindow.requestAnimationFrame(resolve); + }); + }); + + is( + numberOfRules(view), + 2, + "Rule view still has two rules and is not empty." + ); + }, + null +); + +function numberOfRules(ruleView) { + return ruleView.element.querySelectorAll(".ruleview-code").length; +} diff --git a/devtools/client/responsive/test/browser/browser_toolbox_swap_browsers.js b/devtools/client/responsive/test/browser/browser_toolbox_swap_browsers.js new file mode 100644 index 0000000000..61cc3cd96e --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_toolbox_swap_browsers.js @@ -0,0 +1,175 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify that toolbox remains open when opening and closing RDM. + +const TEST_URL = "http://example.com/"; + +function getServerConnections(browser) { + ok(browser.isRemoteBrowser, "Content browser is remote"); + return SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + if (!DevToolsServer._connections) { + return 0; + } + return Object.getOwnPropertyNames(DevToolsServer._connections); + }); +} + +const checkServerConnectionCount = async function (browser, expected, msg) { + const conns = await getServerConnections(browser); + is(conns.length || 0, expected, "Server connection count: " + msg); +}; + +const checkToolbox = function (tab, location) { + const toolbox = gDevTools.getToolboxForTab(tab); + ok(!!toolbox, `Toolbox exists ${location}`); +}; + +addRDMTask( + "", + async function () { + const tab = await addTab(TEST_URL); + + const tabsInDifferentProcesses = + E10S_MULTI_ENABLED && + gBrowser.tabs[0].linkedBrowser.frameLoader.childID != + gBrowser.tabs[1].linkedBrowser.frameLoader.childID; + + info("Open toolbox outside RDM"); + { + // 0: No DevTools connections yet + await checkServerConnectionCount( + tab.linkedBrowser, + 0, + "0: No DevTools connections yet" + ); + const { toolbox } = await openInspector(); + if (tabsInDifferentProcesses) { + // 1: Two tabs open, but only one per content process + await checkServerConnectionCount( + tab.linkedBrowser, + 1, + "1: Two tabs open, but only one per content process" + ); + } else { + // 2: One for each tab (starting tab plus the one we opened) + await checkServerConnectionCount( + tab.linkedBrowser, + 2, + "2: One for each tab (starting tab plus the one we opened)" + ); + } + checkToolbox(tab, "outside RDM"); + const { ui } = await openRDM(tab); + if (tabsInDifferentProcesses) { + // 2: RDM UI adds an extra connection, 1 + 1 = 2 + await checkServerConnectionCount( + ui.getViewportBrowser(), + 2, + "2: RDM UI uses an extra connection" + ); + } else { + // 3: RDM UI adds an extra connection, 2 + 1 = 3 + await checkServerConnectionCount( + ui.getViewportBrowser(), + 3, + "3: RDM UI uses an extra connection" + ); + } + checkToolbox(tab, "after opening RDM"); + await closeRDM(tab); + if (tabsInDifferentProcesses) { + // 1: RDM UI closed, return to previous connection count + await checkServerConnectionCount( + tab.linkedBrowser, + 1, + "1: RDM UI closed, return to previous connection count" + ); + } else { + // 2: RDM UI closed, return to previous connection count + await checkServerConnectionCount( + tab.linkedBrowser, + 2, + "2: RDM UI closed, return to previous connection count" + ); + } + checkToolbox(tab, tab.linkedBrowser, "after closing RDM"); + await toolbox.destroy(); + // 0: All DevTools usage closed + await checkServerConnectionCount( + tab.linkedBrowser, + 0, + "0: All DevTools usage closed" + ); + } + + info("Open toolbox inside RDM"); + { + // 0: No DevTools connections yet + await checkServerConnectionCount( + tab.linkedBrowser, + 0, + "0: No DevTools connections yet" + ); + const { ui } = await openRDM(tab); + // 1: RDM UI uses an extra connection + await checkServerConnectionCount( + ui.getViewportBrowser(), + 1, + "1: RDM UI uses an extra connection" + ); + const { toolbox } = await openInspector(); + if (tabsInDifferentProcesses) { + // 2: Two tabs open, but only one per content process + await checkServerConnectionCount( + ui.getViewportBrowser(), + 2, + "2: Two tabs open, but only one per content process" + ); + } else { + // 3: One for each tab (starting tab plus the one we opened) + await checkServerConnectionCount( + ui.getViewportBrowser(), + 3, + "3: One for each tab (starting tab plus the one we opened)" + ); + } + checkToolbox(tab, ui.getViewportBrowser(), "inside RDM"); + await closeRDM(tab); + if (tabsInDifferentProcesses) { + // 1: RDM UI closed, one less connection + await checkServerConnectionCount( + tab.linkedBrowser, + 1, + "1: RDM UI closed, one less connection" + ); + } else { + // 2: RDM UI closed, one less connection + await checkServerConnectionCount( + tab.linkedBrowser, + 2, + "2: RDM UI closed, one less connection" + ); + } + checkToolbox(tab, tab.linkedBrowser, "after closing RDM"); + await toolbox.destroy(); + // 0: All DevTools usage closed + await checkServerConnectionCount( + tab.linkedBrowser, + 0, + "0: All DevTools usage closed" + ); + } + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_toolbox_swap_inspector.js b/devtools/client/responsive/test/browser/browser_toolbox_swap_inspector.js new file mode 100644 index 0000000000..a5262eeec5 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_toolbox_swap_inspector.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify that inspector does not reboot when opening and closing RDM. + +const TEST_URL = "http://example.com/"; + +const checkToolbox = async function (tab, location) { + const toolbox = gDevTools.getToolboxForTab(tab); + ok(!!toolbox, `Toolbox exists ${location}`); +}; + +addRDMTask( + "", + async function () { + const tab = await addTab(TEST_URL); + + info("Open toolbox outside RDM"); + { + const { toolbox, inspector } = await openInspector(); + inspector.walker.once("new-root", () => { + ok(false, "Inspector saw new root, would reboot!"); + }); + checkToolbox(tab, "outside RDM"); + await openRDM(tab); + checkToolbox(tab, "after opening RDM"); + await closeRDM(tab); + checkToolbox(tab, tab.linkedBrowser, "after closing RDM"); + await toolbox.destroy(); + } + + info("Open toolbox inside RDM"); + { + const { ui } = await openRDM(tab); + const { toolbox, inspector } = await openInspector(); + inspector.walker.once("new-root", () => { + ok(false, "Inspector saw new root, would reboot!"); + }); + checkToolbox(tab, ui.getViewportBrowser(), "inside RDM"); + await closeRDM(tab); + checkToolbox(tab, tab.linkedBrowser, "after closing RDM"); + await toolbox.destroy(); + } + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_tooltip.js b/devtools/client/responsive/test/browser/browser_tooltip.js new file mode 100644 index 0000000000..6721463725 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_tooltip.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_CONTENT = `<h1 title="test title" style="margin: 5px">test h1</h1>`; +const TEST_URL = `data:text/html;charset=utf-8,${TEST_CONTENT}`; + +// Test for the tooltip coordinate on the browsing document in RDM. + +addRDMTask(TEST_URL, async ({ ui }) => { + // Make this synthesize mouse events via the parent process because some + // code may cache the mouse cursor position like PresShell. That may prevent + // unexpected DOM events coming from the parent process. + await pushPref("test.events.async.enabled", true); + + info("Create a promise which waits until the tooltip will be shown"); + const tooltip = ui.browserWindow.gBrowser.ownerDocument.getElementById( + "remoteBrowserTooltip" + ); + const onTooltipShown = BrowserTestUtils.waitForEvent(tooltip, "popupshown"); + const onTooltipHidden = BrowserTestUtils.waitForEvent(tooltip, "popuphidden"); + + info("Show a tooltip"); + await spawnViewportTask(ui, {}, async () => { + const target = content.document.querySelector("h1"); + + // First, make sure to move mouse cursor outside the target. + info("Waiting for initial mousemove"); + { + // FYI: Some `mousemove` events may not be sent to the content process + // until all preparations done in the main process and the content + // process. Therefore, we need to synthesize `mousemove`s until we + // get it in the remote process at least once. Therefore, we cannot + // use a promise to wait a `mousemove` here and we need to use the while + // loop below. + let mouseMoveFired = false; + content.document.addEventListener( + "mousemove", + event => { + isnot( + event.target, + target, + "The first mousemove should be fired outside the target" + ); + mouseMoveFired = true; + }, + { + once: true, + } + ); + while (!mouseMoveFired) { + await EventUtils.synthesizeMouse( + target, + -2, + -2, + { type: "mousemove" }, + content + ); + await EventUtils.synthesizeMouse( + target, + -1, + -1, + { type: "mousemove" }, + content + ); + // Wait for the tooltip for the target is hidden even if it was visible. + await new Promise(resolve => + content.window.requestAnimationFrame(() => + content.window.requestAnimationFrame(resolve) + ) + ); + } + } + + const eventLogger = event => + info( + `${event.type}: path=[${event.composedPath()}], outerHTML=${ + event.target.outerHTML + }, relatedTarget=${event.relatedTarget?.outerHTML}` + ); + target.addEventListener("mouseover", eventLogger); + target.addEventListener("mouseout", eventLogger); + content.document.addEventListener("mousemove", eventLogger); + // Then, move cursor over the target. + await EventUtils.synthesizeMouse( + target, + 1, + 1, + { type: "mousemove", isSynthesized: false }, + content + ); + await EventUtils.synthesizeMouse( + target, + 2, + 1, + { type: "mousemove", isSynthesized: false }, + content + ); + await EventUtils.synthesizeMouse( + target, + 3, + 1, + { type: "mousemove", isSynthesized: false }, + content + ); + }); + + info("Wait for showing the tooltip"); + await onTooltipShown; + + info("Test the X coordinate of the tooltip"); + isnot(tooltip.screenX, 0, "The X coordinate of tooltip should not be 0"); + + // Finally, clean up the tooltip if we're running in the verify mode. + info("Wait for hiding the tooltip"); + await spawnViewportTask(ui, {}, async () => { + info("Cleaning up the tooltip with moving the cursor"); + const target = content.document.querySelector("h1"); + await EventUtils.synthesizeMouse( + target, + -1, + -1, + { type: "mousemove", isSynthesized: false }, + content + ); + }); + await onTooltipHidden; +}); diff --git a/devtools/client/responsive/test/browser/browser_touch_device.js b/devtools/client/responsive/test/browser/browser_touch_device.js new file mode 100644 index 0000000000..1c303bdd45 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_device.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests changing viewport touch simulation +const TEST_URL = "data:text/html;charset=utf-8,touch simulation test"; +const Types = require("resource://devtools/client/responsive/types.js"); + +const testDevice = { + name: "Fake Phone RDM Test", + width: 320, + height: 470, + pixelRatio: 5.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "custom", + featured: true, +}; + +// Add the new device to the list +addDeviceForTest(testDevice); + +addRDMTask( + TEST_URL, + async function ({ ui }) { + reloadOnTouchChange(true); + + await waitStartup(ui); + + await testDefaults(ui); + await testChangingDevice(ui); + await testResizingViewport(ui, true, false); + await testEnableTouchSimulation(ui); + await testResizingViewport(ui, false, true); + await testDisableTouchSimulation(ui); + + reloadOnTouchChange(false); + }, + { waitForDeviceList: true } +); + +async function waitStartup(ui) { + const { store } = ui.toolWindow; + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.devices.listState == Types.loadableState.LOADED + ); +} + +async function testDefaults(ui) { + info("Test Defaults"); + + await testTouchEventsOverride(ui, false); + testViewportDeviceMenuLabel(ui, "Responsive"); +} + +async function testChangingDevice(ui) { + info("Test Changing Device"); + + await selectDevice(ui, testDevice.name); + await waitForViewportResizeTo(ui, testDevice.width, testDevice.height); + await testTouchEventsOverride(ui, true); + testViewportDeviceMenuLabel(ui, testDevice.name); +} + +async function testResizingViewport(ui, hasDevice, touch) { + info(`Test resizing the viewport, device ${hasDevice}, touch ${touch}`); + + await testViewportResize( + ui, + ".viewport-vertical-resize-handle", + [-10, -10], + [0, -10], + { + hasDevice, + } + ); + await testTouchEventsOverride(ui, touch); + testViewportDeviceMenuLabel(ui, "Responsive"); +} + +async function testEnableTouchSimulation(ui) { + info("Test enabling touch simulation via button"); + + await toggleTouchSimulation(ui); + await testTouchEventsOverride(ui, true); +} + +async function testDisableTouchSimulation(ui) { + info("Test disabling touch simulation via button"); + + await toggleTouchSimulation(ui); + await testTouchEventsOverride(ui, false); +} diff --git a/devtools/client/responsive/test/browser/browser_touch_does_not_trigger_hover_states.js b/devtools/client/responsive/test/browser/browser_touch_does_not_trigger_hover_states.js new file mode 100644 index 0000000000..b19e64dc2b --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_does_not_trigger_hover_states.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that element hover states are not triggered when touch is enabled. + +const TEST_URL = `${URL_ROOT}hover.html`; + +addRDMTask(TEST_URL, async function ({ ui }) { + reloadOnTouchChange(true); + + await toggleTouchSimulation(ui); + + info("Test element hover states when touch is enabled."); + await testButtonHoverState(ui, "rgb(255, 0, 0)"); + await testDropDownHoverState(ui, "none"); + + await toggleTouchSimulation(ui); + + info("Test element hover states when touch is disabled."); + await testButtonHoverState(ui, "rgb(0, 0, 0)"); + await testDropDownHoverState(ui, "block"); + + reloadOnTouchChange(false); +}); + +async function testButtonHoverState(ui, expected) { + await SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ expected }], + async function (args) { + let button = content.document.querySelector("button"); + const { expected: contentExpected } = args; + + info("Move mouse into the button element."); + await EventUtils.synthesizeMouseAtCenter( + button, + { type: "mousemove", isSynthesized: false }, + content + ); + button = content.document.querySelector("button"); + const win = content.document.defaultView; + + is( + win.getComputedStyle(button).getPropertyValue("background-color"), + contentExpected, + `Button background color is ${contentExpected}.` + ); + } + ); +} + +async function testDropDownHoverState(ui, expected) { + await SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ expected }], + async function (args) { + const dropDownMenu = content.document.querySelector(".drop-down-menu"); + const { expected: contentExpected } = args; + + info("Move mouse into the drop down menu."); + await EventUtils.synthesizeMouseAtCenter( + dropDownMenu, + { type: "mousemove", isSynthesized: false }, + content + ); + const win = content.document.defaultView; + const menuItems = content.document.querySelector(".menu-items-list"); + + is( + win.getComputedStyle(menuItems).getPropertyValue("display"), + contentExpected, + `Menu items is display: ${contentExpected}.` + ); + } + ); +} diff --git a/devtools/client/responsive/test/browser/browser_touch_event_iframes.js b/devtools/client/responsive/test/browser/browser_touch_event_iframes.js new file mode 100644 index 0000000000..11b94d2ab1 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_event_iframes.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test simulated touch events can correctly target embedded iframes. + +// These tests put a target iframe in a small embedding area, nested +// different ways. Then a simulated mouse click is made on top of the +// target iframe. If everything works, the translation done in +// touch-simulator.js should exactly match the translation done in the +// Platform code, such that the target is hit by the synthesized tap +// is at the expected location. + +info("--- Starting viewport test output ---"); + +info(`*** WARNING *** This test will move the mouse pointer to simulate +native mouse clicks. Do not move the mouse during this test or you may +cause intermittent failures.`); + +// This test could run awhile, so request a 4x timeout duration. +requestLongerTimeout(4); + +// The viewport will be square, set to VIEWPORT_DIMENSION on each axis. +const VIEWPORT_DIMENSION = 200; + +const META_VIEWPORT_CONTENTS = ["width=device-width", "width=400"]; + +const DPRS = [1, 2, 3]; + +const URL_ROOT_2 = CHROME_URL_ROOT.replace( + "chrome://mochitests/content/", + "http://mochi.test:8888/" +); +const IFRAME_PATHS = [`${URL_ROOT}`, `${URL_ROOT_2}`]; + +const TESTS = [ + { + description: "untranslated iframe", + style: {}, + }, + { + description: "translated 50% iframe", + style: { + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + }, + }, + { + description: "translated 100% iframe", + style: { + position: "absolute", + left: "100%", + top: "100%", + transform: "translate(-100%, -100%)", + }, + }, +]; + +let testID = 0; + +for (const mvcontent of META_VIEWPORT_CONTENTS) { + info(`Starting test series with meta viewport content "${mvcontent}".`); + + const TEST_URL = + `data:text/html;charset=utf-8,` + + `<html><meta name="viewport" content="${mvcontent}">` + + `<body style="margin:0; width:100%; height:200%;">` + + `<iframe id="host" ` + + `style="margin:0; border:0; width:100%; height:100%"></iframe>` + + `</body></html>`; + + addRDMTask(TEST_URL, async function ({ ui, manager, browser }) { + await setViewportSize(ui, manager, VIEWPORT_DIMENSION, VIEWPORT_DIMENSION); + await setTouchAndMetaViewportSupport(ui, true); + + // Figure out our window origin in screen space, which we'll need as we calculate + // coordinates for our simulated click events. These values are in CSS units, which + // is weird, but we compensate for that later. + const screenToWindowX = window.mozInnerScreenX; + const screenToWindowY = window.mozInnerScreenY; + + for (const dpr of DPRS) { + await selectDevicePixelRatio(ui, dpr); + + for (const path of IFRAME_PATHS) { + for (const test of TESTS) { + const { description, style } = test; + + const title = `ID ${testID} - ${description} with DPR ${dpr} and path ${path}`; + + info(`Starting test ${title}.`); + + await spawnViewportTask( + ui, + { + title, + style, + path, + VIEWPORT_DIMENSION, + screenToWindowX, + screenToWindowY, + }, + async args => { + // Define a function that returns a promise for one message that + // contains, at least, the supplied prop, and resolves with the + // data from that message. If a timeout value is supplied, the + // promise will reject if the timeout elapses first. + const oneMatchingMessageWithTimeout = (win, prop, timeout) => { + return new Promise((resolve, reject) => { + let ourTimeoutID = 0; + + const ourListener = win.addEventListener("message", e => { + if (typeof e.data[prop] !== "undefined") { + if (ourTimeoutID) { + win.clearTimeout(ourTimeoutID); + } + win.removeEventListener("message", ourListener); + resolve(e.data); + } + }); + + if (timeout) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + ourTimeoutID = win.setTimeout(() => { + win.removeEventListener("message", ourListener); + reject( + `Timeout waiting for message with prop ${prop} after ${timeout}ms.` + ); + }, timeout); + } + }); + }; + + // Our checks are not always precise, due to rounding errors in the + // scaling from css to screen and back. For now we use an epsilon and + // a locally-defined isfuzzy to compensate. We can't use + // SimpleTest.isfuzzy, because it's not bridged to the ContentTask. + // If that is ever bridged, we can remove the isfuzzy definition here and + // everything should "just work". + function isfuzzy(actual, expected, epsilon, msg) { + if ( + actual >= expected - epsilon && + actual <= expected + epsilon + ) { + ok(true, msg); + } else { + // This will trigger the usual failure message for is. + is(actual, expected, msg); + } + } + + // This function takes screen coordinates in css pixels. + // TODO: This should stop using nsIDOMWindowUtils.sendNativeMouseEvent + // directly, and use `EventUtils.synthesizeNativeMouseEvent` in + // a message listener in the chrome. + function synthesizeNativeMouseClick(win, screenX, screenY) { + const utils = win.windowUtils; + const scale = win.devicePixelRatio; + + return new Promise(resolve => { + utils.sendNativeMouseEvent( + screenX * scale, + screenY * scale, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN, + 0, + 0, + win.document.documentElement, + () => { + utils.sendNativeMouseEvent( + screenX * scale, + screenY * scale, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP, + 0, + 0, + win.document.documentElement, + resolve + ); + } + ); + }); + } + + // We're done defining functions; start the actual loading of the iframe + // and triggering the onclick handler in its content. + const host = content.document.getElementById("host"); + + // Modify the iframe style by adding the properties in the + // provided style object. + for (const prop in args.style) { + info(`Setting style.${prop} to ${args.style[prop]}.`); + host.style[prop] = args.style[prop]; + } + + // Set the iframe source, and await the ready message. + const IFRAME_URL = args.path + "touch_event_target.html"; + const READY_TIMEOUT_MS = 5000; + const iframeReady = oneMatchingMessageWithTimeout( + content, + "ready", + READY_TIMEOUT_MS + ); + host.src = IFRAME_URL; + try { + await iframeReady; + } catch (error) { + ok(false, `${args.title} ${error}`); + return; + } + + info(`iframe has finished loading.`); + + // Await reflow of the parent window. + await new Promise(resolve => { + content.requestAnimationFrame(() => { + content.requestAnimationFrame(resolve); + }); + }); + + // Now we're going to calculate screen coordinates for the upper-left + // quadrant of the target area. We're going to do that by using the + // following sources: + // 1) args.screenToWindow: the window position in screen space, in CSS + // pixels. + // 2) host.getBoxQuadsFromWindowOrigin(): the iframe position, relative + // to the window origin, in CSS pixels. + // 3) args.VIEWPORT_DIMENSION: the viewport size, in CSS pixels. + // We calculate the screen position of the center of the upper-left + // quadrant of the iframe, then use sendNativeMouseEvent to dispatch + // a click at that position. It should trigger the RDM TouchSimulator + // and turn the mouse click into a touch event that hits the onclick + // handler in the iframe content. If it's done correctly, the message + // we get back should have x,y coordinates that match the center of the + // upper left quadrant of the iframe, in CSS units. + + const hostBounds = host + .getBoxQuadsFromWindowOrigin()[0] + .getBounds(); + const windowToHostX = hostBounds.left; + const windowToHostY = hostBounds.top; + + const screenToHostX = args.screenToWindowX + windowToHostX; + const screenToHostY = args.screenToWindowY + windowToHostY; + + const quadrantOffsetDoc = hostBounds.width * 0.25; + const hostUpperLeftQuadrantDocX = quadrantOffsetDoc; + const hostUpperLeftQuadrantDocY = quadrantOffsetDoc; + + const quadrantOffsetViewport = args.VIEWPORT_DIMENSION * 0.25; + const hostUpperLeftQuadrantViewportX = quadrantOffsetViewport; + const hostUpperLeftQuadrantViewportY = quadrantOffsetViewport; + + const targetX = screenToHostX + hostUpperLeftQuadrantViewportX; + const targetY = screenToHostY + hostUpperLeftQuadrantViewportY; + + // We're going to try a few times to click on the target area. Our method + // for triggering a native mouse click is vulnerable to interactive mouse + // moves while the test is running. Letting the click timeout gives us a + // chance to try again. + const CLICK_TIMEOUT_MS = 1000; + const CLICK_ATTEMPTS = 3; + let eventWasReceived = false; + + for (let attempt = 0; attempt < CLICK_ATTEMPTS; attempt++) { + const gotXAndY = oneMatchingMessageWithTimeout( + content, + "x", + CLICK_TIMEOUT_MS + ); + info( + `Sending native mousedown and mouseup to screen position ${targetX}, ${targetY} (attempt ${attempt}).` + ); + await synthesizeNativeMouseClick(content, targetX, targetY); + try { + const { x, y, screenX, screenY } = await gotXAndY; + eventWasReceived = true; + isfuzzy( + x, + hostUpperLeftQuadrantDocX, + 1, + `${args.title} got click at close enough X ${x}, screen is ${screenX}.` + ); + isfuzzy( + y, + hostUpperLeftQuadrantDocY, + 1, + `${args.title} got click at close enough Y ${y}, screen is ${screenY}.` + ); + break; + } catch (error) { + // That click didn't work. The for loop will trigger another attempt, + // or give up. + } + } + + if (!eventWasReceived) { + ok( + false, + `${args.title} failed to get a click after ${CLICK_ATTEMPTS} tries.` + ); + } + } + ); + + testID++; + } + } + } + }); +} diff --git a/devtools/client/responsive/test/browser/browser_touch_event_should_bubble.js b/devtools/client/responsive/test/browser/browser_touch_event_should_bubble.js new file mode 100644 index 0000000000..dc9da5df74 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_event_should_bubble.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that simulated touch events bubble. + +const TEST_URL = `${URL_ROOT}touch_event_bubbles.html`; + +addRDMTask(TEST_URL, async function ({ ui }) { + info("Toggling on touch simulation."); + reloadOnTouchChange(true); + await toggleTouchSimulation(ui); + + info("Test that touch event bubbles."); + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const outerDiv = content.document.getElementById("outer"); + const span = content.document.querySelector("span"); + + outerDiv.addEventListener("touchstart", () => { + span.style["background-color"] = "green"; // rgb(0, 128, 0) + }); + + const touchStartPromise = ContentTaskUtils.waitForEvent(span, "touchstart"); + await EventUtils.synthesizeMouseAtCenter( + span, + { type: "mousedown", isSynthesized: false }, + content + ); + await touchStartPromise; + + const win = content.document.defaultView; + const bg = win.getComputedStyle(span).getPropertyValue("background-color"); + + is( + bg, + "rgb(0, 128, 0)", + `span's background color should be rgb(0, 128, 0): got ${bg}` + ); + + await EventUtils.synthesizeMouseAtCenter( + span, + { type: "mouseup", isSynthesized: false }, + content + ); + }); + + info("Toggling off touch simulation."); + await toggleTouchSimulation(ui); + reloadOnTouchChange(false); +}); diff --git a/devtools/client/responsive/test/browser/browser_touch_pointerevents.js b/devtools/client/responsive/test/browser/browser_touch_pointerevents.js new file mode 100644 index 0000000000..a7196fba60 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_pointerevents.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that simulating touch only dispatches pointer events from a touch event. + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<div style="width:100px;height:100px;background-color:red"></div>' + + "</body>"; + +addRDMTask(TEST_URL, async function ({ ui }) { + info("Toggling on touch simulation."); + reloadOnTouchChange(true); + await toggleTouchSimulation(ui); + + await testPointerEvents(ui); + + info("Toggling off touch simulation."); + await toggleTouchSimulation(ui); + reloadOnTouchChange(false); +}); + +async function testPointerEvents(ui) { + info("Test that pointer events are from touch events"); + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const div = content.document.querySelector("div"); + + div.addEventListener("pointermove", () => { + div.style["background-color"] = "green"; //rgb(0,128,0) + }); + div.addEventListener("pointerdown", e => { + Assert.strictEqual( + e.pointerType, + "touch", + "Got pointer event from a touch event." + ); + }); + + info("Check that the pointerdown event is from a touch event."); + const pointerDownPromise = ContentTaskUtils.waitForEvent( + div, + "pointerdown" + ); + + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousedown", isSynthesized: false }, + content + ); + await pointerDownPromise; + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mouseup", isSynthesized: false }, + content + ); + + info( + "Check that a pointermove event was never dispatched from the mousemove event" + ); + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousemove", isSynthesized: false }, + content + ); + + const win = content.document.defaultView; + const bg = win.getComputedStyle(div).getPropertyValue("background-color"); + + is( + bg, + "rgb(255, 0, 0)", + `div's background color should still be red: rgb(255, 0, 0): got ${bg}` + ); + }); +} diff --git a/devtools/client/responsive/test/browser/browser_touch_simulation.js b/devtools/client/responsive/test/browser/browser_touch_simulation.js new file mode 100644 index 0000000000..b74c9f53c4 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_touch_simulation.js @@ -0,0 +1,341 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test global touch simulation button + +const TEST_URL = `${URL_ROOT_SSL}touch.html`; +const PREF_DOM_META_VIEWPORT_ENABLED = "dom.meta-viewport.enabled"; + +// A 300ms delay between a `touchend` and `click` event is added whenever double-tap zoom +// is allowed. +const DELAY_MIN = 250; + +addRDMTask(TEST_URL, async function ({ ui }) { + reloadOnTouchChange(true); + + await waitBootstrap(ui); + await testWithNoTouch(ui); + await toggleTouchSimulation(ui); + await promiseContentReflow(ui); + await testWithTouch(ui); + await testWithMetaViewportEnabled(ui); + await testWithMetaViewportDisabled(ui); + testTouchButton(ui); + + reloadOnTouchChange(false); +}); + +async function testWithNoTouch(ui) { + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const div = content.document.querySelector("div"); + let x = 0, + y = 0; + + info("testWithNoTouch: Initial test parameter and mouse mouse outside div"); + x = -1; + y = -1; + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + div.style.transform = "none"; + div.style.backgroundColor = ""; + + info("testWithNoTouch: Move mouse into the div element"); + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousemove", isSynthesized: false }, + content + ); + is(div.style.backgroundColor, "red", "mouseenter or mouseover should work"); + + info("testWithNoTouch: Drag the div element"); + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousedown", isSynthesized: false }, + content + ); + x = 100; + y = 100; + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + is(div.style.transform, "none", "touchmove shouldn't work"); + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mouseup", isSynthesized: false }, + content + ); + + info("testWithNoTouch: Move mouse out of the div element"); + x = -1; + y = -1; + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + is(div.style.backgroundColor, "blue", "mouseout or mouseleave should work"); + + info("testWithNoTouch: Click the div element"); + await EventUtils.synthesizeClick(div); + is( + div.dataset.isDelay, + "false", + "300ms delay between touch events and mouse events should not work" + ); + + // Assuming that this test runs on devices having no touch screen device. + ok( + !content.document.defaultView.matchMedia("(pointer: coarse)").matches, + "pointer: coarse shouldn't be matched" + ); + ok( + !content.document.defaultView.matchMedia("(hover: none)").matches, + "hover: none shouldn't be matched" + ); + ok( + !content.document.defaultView.matchMedia("(any-pointer: coarse)").matches, + "any-pointer: coarse shouldn't be matched" + ); + ok( + !content.document.defaultView.matchMedia("(any-hover: none)").matches, + "any-hover: none shouldn't be matched" + ); + }); +} + +async function testWithTouch(ui) { + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const div = content.document.querySelector("div"); + let x = 0, + y = 0; + + info("testWithTouch: Initial test parameter and mouse mouse outside div"); + x = -1; + y = -1; + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + div.style.transform = "none"; + div.style.backgroundColor = ""; + + info("testWithTouch: Move mouse into the div element"); + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousemove", isSynthesized: false }, + content + ); + isnot( + div.style.backgroundColor, + "red", + "mouseenter or mouseover should not work" + ); + + info("testWithTouch: Drag the div element"); + await EventUtils.synthesizeMouseAtCenter( + div, + { type: "mousedown", isSynthesized: false }, + content + ); + x = 100; + y = 100; + const touchMovePromise = ContentTaskUtils.waitForEvent(div, "touchmove"); + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + await touchMovePromise; + isnot(div.style.transform, "none", "touchmove should work"); + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mouseup", isSynthesized: false }, + content + ); + + info("testWithTouch: Move mouse out of the div element"); + x = -1; + y = -1; + await EventUtils.synthesizeMouse( + div, + x, + y, + { type: "mousemove", isSynthesized: false }, + content + ); + isnot( + div.style.backgroundColor, + "blue", + "mouseout or mouseleave should not work" + ); + + ok( + content.document.defaultView.matchMedia("(pointer: coarse)").matches, + "pointer: coarse should be matched" + ); + ok( + content.document.defaultView.matchMedia("(hover: none)").matches, + "hover: none should be matched" + ); + ok( + content.document.defaultView.matchMedia("(any-pointer: coarse)").matches, + "any-pointer: coarse should be matched" + ); + ok( + content.document.defaultView.matchMedia("(any-hover: none)").matches, + "any-hover: none should be matched" + ); + }); + + // Capturing touch events with the content window as a registered listener causes the + // "changedTouches" field to be undefined when using deprecated TouchEvent APIs. + // See Bug 1549220 and Bug 1588438 for more information on this issue. + info("Test that changed touches captured on the content window are defined."); + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const div = content.document.querySelector("div"); + + content.addEventListener( + "touchstart", + event => { + const changedTouch = event.changedTouches[0]; + ok(changedTouch, "Changed touch is defined."); + }, + { once: true } + ); + await EventUtils.synthesizeClick(div); + }); +} + +async function testWithMetaViewportEnabled(ui) { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_DOM_META_VIEWPORT_ENABLED, true]], + }); + + await SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ delay_min: DELAY_MIN }], + async function ({ delay_min }) { + // A helper for testing the delay between touchend and click events. + async function testDelay(mvc, el) { + const touchendPromise = ContentTaskUtils.waitForEvent(el, "touchend"); + const clickPromise = ContentTaskUtils.waitForEvent(el, "click"); + await EventUtils.synthesizeClick(el); + const { timeStamp: touchendTimestamp } = await touchendPromise; + const { timeStamp: clickTimeStamp } = await clickPromise; + const delay = clickTimeStamp - touchendTimestamp; + + const expected = delay >= delay_min; + + ok( + expected, + `${mvc}: There should be greater than a ${delay_min}ms delay between touch events and mouse events. Got delay of ${delay}ms` + ); + } + + // A helper function for waiting for reflow to complete. + const promiseReflow = () => { + return new Promise(resolve => { + content.window.requestAnimationFrame(() => { + content.window.requestAnimationFrame(resolve); + }); + }); + }; + + const meta = content.document.querySelector("meta[name=viewport]"); + const div = content.document.querySelector("div"); + + info( + "testWithMetaViewportEnabled: " + + "click the div element with <meta name='viewport'>" + ); + meta.content = ""; + await promiseReflow(); + await testDelay("(empty)", div); + } + ); + + await SpecialPowers.popPrefEnv(); +} + +async function testWithMetaViewportDisabled(ui) { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_DOM_META_VIEWPORT_ENABLED, false]], + }); + + await SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ delay_min: DELAY_MIN }], + async function ({ delay_min }) { + const meta = content.document.querySelector("meta[name=viewport]"); + const div = content.document.querySelector("div"); + + info( + "testWithMetaViewportDisabled: click the div with <meta name='viewport'>" + ); + meta.content = ""; + const touchendPromise = ContentTaskUtils.waitForEvent(div, "touchend"); + const clickPromise = ContentTaskUtils.waitForEvent(div, "click"); + await EventUtils.synthesizeClick(div); + const { timeStamp: touchendTimestamp } = await touchendPromise; + const { timeStamp: clickTimeStamp } = await clickPromise; + const delay = clickTimeStamp - touchendTimestamp; + + const expected = delay >= delay_min; + + ok( + expected, + `There should be greater than a ${delay_min}ms delay between touch events and mouse events. Got delay of ${delay}ms` + ); + } + ); +} + +function testTouchButton(ui) { + const { document } = ui.toolWindow; + const touchButton = document.getElementById("touch-simulation-button"); + + ok( + touchButton.classList.contains("checked"), + "Touch simulation is active at end of test." + ); + + touchButton.click(); + + ok( + !touchButton.classList.contains("checked"), + "Touch simulation is stopped on click." + ); + + touchButton.click(); + + ok( + touchButton.classList.contains("checked"), + "Touch simulation is started on click." + ); +} + +async function waitBootstrap(ui) { + await waitForFrameLoad(ui, TEST_URL); +} diff --git a/devtools/client/responsive/test/browser/browser_typeahead_find.js b/devtools/client/responsive/test/browser/browser_typeahead_find.js new file mode 100644 index 0000000000..7bc22de1ef --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_typeahead_find.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * This test attempts to exercise automatic triggering of typeaheadfind + * within RDM content. It does this by simulating keystrokes while + * various elements in the RDM content are focused. + + * The test currently does not work due to hitting the assert in + * Bug 516128. + */ + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<body id="body"><input id="input" type="text"/><p>text</body>'; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + // Turn on the pref that allows meta viewport support. + await pushPref("accessibility.typeaheadfind", true); + + const browser = ui.getViewportBrowser(); + + info("--- Starting test output ---"); + + const expected = [ + { + id: "body", + findTriggered: true, + }, + { + id: "input", + findTriggered: false, + }, + ]; + + for (const e of expected) { + await SpecialPowers.spawn(browser, [{ e }], async function (args) { + const { e: values } = args; + const element = content.document.getElementById(values.id); + + // Set focus on the desired element. + element.focus(); + }); + + // Press the 'T' key and see if find is triggered. + await BrowserTestUtils.synthesizeKey("t", {}, browser); + + const findBar = await gBrowser.getFindBar(); + + const findIsTriggered = findBar._findField.value == "t"; + is( + findIsTriggered, + e.findTriggered, + "Text input with focused element " + + e.id + + " should " + + (e.findTriggered ? "" : "not ") + + "trigger find." + ); + findBar._findField.value = ""; + + await SpecialPowers.spawn(browser, [], async function () { + // Clear focus. + content.document.activeElement.blur(); + }); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_user_agent_input.js b/devtools/client/responsive/test/browser/browser_user_agent_input.js new file mode 100644 index 0000000000..cd7b843790 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_user_agent_input.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "data:text/html;charset=utf-8,"; +const NEW_USER_AGENT = "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0"; + +addRDMTask(TEST_URL, async function ({ ui }) { + reloadOnUAChange(true); + + info("Check the default state of the user agent input"); + await testUserAgent(ui, DEFAULT_UA); + + info(`Change the user agent input to ${NEW_USER_AGENT}`); + await changeUserAgentInput(ui, NEW_USER_AGENT); + await testUserAgent(ui, NEW_USER_AGENT); + + info("Reset the user agent input back to the default UA"); + await changeUserAgentInput(ui, ""); + await testUserAgent(ui, DEFAULT_UA); + + reloadOnUAChange(false); +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_basics.js b/devtools/client/responsive/test/browser/browser_viewport_basics.js new file mode 100644 index 0000000000..b091763258 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_basics.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test viewports basics after opening, like size and location + +const TEST_URL = "https://example.org/"; +addRDMTask(TEST_URL, async function ({ ui }) { + const browser = ui.getViewportBrowser(); + + is( + ui.toolWindow.getComputedStyle(browser).getPropertyValue("width"), + "320px", + "Viewport has default width" + ); + is( + ui.toolWindow.getComputedStyle(browser).getPropertyValue("height"), + "480px", + "Viewport has default height" + ); + + // Browser's location should match original tab + await navigateTo(TEST_URL, { browser }); + + const location = await spawnViewportTask(ui, {}, function () { + return content.location.href; // eslint-disable-line + }); + is(location, TEST_URL, "Viewport location matches"); +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_changed_meta.js b/devtools/client/responsive/test/browser/browser_viewport_changed_meta.js new file mode 100644 index 0000000000..f0bafdd551 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_changed_meta.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that resolution is as expected when the viewport tag is changed. +// The page content is a 400 x 400 div in a 200 x 200 viewport. Initially, +// the viewport width is set to 800 at initial-scale 1, but then the tag +// content is changed. This triggers various rescale operations that will +// change the resolution of the page after reflow. + +// Chrome handles many of these cases differently. The Chrome results are +// included as TODOs, but labelled as "res_chrome" to indicate that the +// goal is not necessarily to match an agreed-upon standard, but to +// achieve web compatability through changing either Firefox or Chrome +// behavior. + +info("--- Starting viewport test output ---"); + +const WIDTH = 200; +const HEIGHT = 200; +const INITIAL_CONTENT = "width=800, initial-scale=1"; +const INITIAL_RES_TARGET = 1.0; +const TESTS = [ + // This checks that when the replaced content matches the original content, + // we get the same values as the original values. + { content: INITIAL_CONTENT, res_target: INITIAL_RES_TARGET }, + + // Section 1: Check the case of a viewport shrinking with the display width + // staying the same. In this case, the shrink will fit the max of the 400px + // content width and the viewport width into the 200px display area. + { content: "width=200", res_target: 0.5 }, // fitting 400px content + { content: "width=400", res_target: 0.5 }, // fitting 400px content/viewport + { content: "width=500", res_target: 0.4 }, // fitting 500px viewport + + // Section 2: Same as Section 1, but adds user-scalable=no. The expected + // results are similar to Section 1, but we ignore the content size and only + // adjust resolution to make the viewport fit into the display area. + { content: "width=200, user-scalable=no", res_target: 1.0 }, + { content: "width=400, user-scalable=no", res_target: 0.5 }, + { content: "width=500, user-scalable=no", res_target: 0.4 }, + + // Section 3: Same as Section 1, but adds initial-scale=1. Initial-scale + // prevents content shrink in Firefox, so the viewport is scaled based on its + // changing size relative to the display area. In this case, the resolution + // is increased to maintain the proportional amount of the previously visible + // content. With the initial conditions, the display area was showing 1/4 of + // the content at 0.25x resolution. As the viewport width is shrunk, the + // resolution will increase to ensure that only 1/4 of the content is visible. + // Essentially, the new viewport width times the resolution will equal 800px, + // the original viewport width times resolution. + // + // Chrome treats the initial-scale=1 as inviolable and sets resolution to 1.0. + { content: "width=200, initial-scale=1", res_target: 4.0, res_chrome: 1.0 }, + { content: "width=400, initial-scale=1", res_target: 2.0, res_chrome: 1.0 }, + { content: "width=500, initial-scale=1", res_target: 1.6, res_chrome: 1.0 }, + + // Section 4: Same as Section 3, but adds user-scalable=no. The combination + // of this and initial-scale=1 prevents the scaling-up of the resolution to + // keep the proportional amount of the previously visible content. + { content: "width=200, initial-scale=1, user-scalable=no", res_target: 1.0 }, + { content: "width=400, initial-scale=1, user-scalable=no", res_target: 1.0 }, + { content: "width=500, initial-scale=1, user-scalable=no", res_target: 1.0 }, +]; + +const TEST_URL = `data:text/html;charset=utf-8, + <html> + <head><meta name="viewport" content="${INITIAL_CONTENT}"></head> + <body style="margin:0"> + <div id="box" style="width:400px;height:400px;background-color:green">Initial</div> + </body> + </html>`; + +addRDMTask(TEST_URL, async function ({ ui, manager, browser }) { + await setViewportSize(ui, manager, WIDTH, HEIGHT); + await setTouchAndMetaViewportSupport(ui, true); + + // Check initial resolution value. + const initial_resolution = await spawnViewportTask(ui, {}, () => { + return content.windowUtils.getResolution(); + }); + + is( + initial_resolution.toFixed(2), + INITIAL_RES_TARGET.toFixed(2), + `Initial resolution is as expected.` + ); + + for (const test of TESTS) { + const { content: content, res_target, res_chrome } = test; + + await spawnViewportTask(ui, { content }, args => { + const box = content.document.getElementById("box"); + box.textContent = args.content; + + const meta = content.document.getElementsByTagName("meta")[0]; + info(`Changing meta viewport content to "${args.content}".`); + meta.content = args.content; + }); + + await promiseContentReflow(ui); + + const resolution = await spawnViewportTask(ui, {}, () => { + return content.windowUtils.getResolution(); + }); + + is( + resolution.toFixed(2), + res_target.toFixed(2), + `Replaced meta viewport content "${content}" resolution is as expected.` + ); + + if (typeof res_chrome !== "undefined") { + todo_is( + resolution.toFixed(2), + res_chrome.toFixed(2), + `Replaced meta viewport content "${content}" resolution matches Chrome resolution.` + ); + } + + info("Reload and wait for document to be loaded to prepare for next test."); + await reloadBrowser(); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_fallback_width.js b/devtools/client/responsive/test/browser/browser_viewport_fallback_width.js new file mode 100644 index 0000000000..73ae4b211b --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_fallback_width.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the viewport's ICB width will use the simulated screen width +// if the simulated width is larger than the desktop viewport width default +// (980px). + +// The HTML below sets up the test such that the "inner" div is aligned to the end +// (right-side) of the viewport. By doing this, it makes it easier to have our test +// target an element whose bounds are outside of the desktop viewport width default +// for device screens greater than 980px. +const TEST_URL = + `data:text/html;charset=utf-8,` + + `<div id="outer" style="display: grid; justify-items: end; font-size: 64px"> + <div id="inner">Click me!</div> + </div>`; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + info("Toggling on touch simulation."); + reloadOnTouchChange(true); + await toggleTouchSimulation(ui); + // It's important we set a viewport width larger than 980px for this test to be correct. + // So let's choose viewport width: 1280x600 + await setViewportSizeAndAwaitReflow(ui, manager, 1280, 600); + + await testICBWidth(ui); + + info("Toggling off touch simulation."); + await toggleTouchSimulation(ui); + reloadOnTouchChange(false); +}); + +async function testICBWidth(ui) { + await SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + const innerDiv = content.document.getElementById("inner"); + + innerDiv.addEventListener("click", () => { + innerDiv.style.color = "green"; //rgb(0,128,0) + }); + + info("Check that touch point (via click) registers on inner div."); + const mousedown = ContentTaskUtils.waitForEvent(innerDiv, "click"); + await EventUtils.synthesizeClick(innerDiv); + await mousedown; + + const win = content.document.defaultView; + const bg = win.getComputedStyle(innerDiv).getPropertyValue("color"); + + is(bg, "rgb(0, 128, 0)", "inner div's background color changed to green."); + }); +} diff --git a/devtools/client/responsive/test/browser/browser_viewport_resizing_after_reload.js b/devtools/client/responsive/test/browser/browser_viewport_resizing_after_reload.js new file mode 100644 index 0000000000..ad750326ae --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resizing_after_reload.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test viewport resizing, with and without meta viewport support. + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<head><meta name="viewport" content="width=device-width"/></head>' + + '<body style="margin:0px;min-width:600px">' + + '<div style="width:100%;height:100px;background-color:black"></div>' + + '<div style="width:100%;height:1100px;background-color:lightblue"></div>' + + "</body>"; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + info("--- Starting viewport test output ---"); + + // We're going to take a 300,600 viewport (before), reload it, + // then resize it to 600,300 (after) and then resize it back. + // At the before and after points, we'll measure zoom and the + // layout viewport width and height. + const expected = [ + { + metaSupport: false, + before: [1.0, 300, 600], + after: [1.0, 600, 300], + }, + { + metaSupport: true, + before: [0.5, 300, 600], + after: [1.0, 600, 300], + }, + ]; + + for (const e of expected) { + const b = e.before; + const a = e.after; + + const message = "Meta Viewport " + (e.metaSupport ? "ON" : "OFF"); + + // Ensure meta viewport is set. + info(message + " setting meta viewport support."); + await setTouchAndMetaViewportSupport(ui, e.metaSupport); + + // Get to the initial size and check values. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " before resize", + ui, + b[0], + b[1], + b[2] + ); + + // Force a reload. + await reloadBrowser(); + + // Check initial values again. + await testViewportZoomWidthAndHeight( + message + " after reload", + ui, + b[0], + b[1], + b[2] + ); + + // Move to the smaller size. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + await testViewportZoomWidthAndHeight( + message + " after resize", + ui, + a[0], + a[1], + a[2] + ); + + // Go back to the initial size and check again. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " return to initial size", + ui, + b[0], + b[1], + b[2] + ); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width.js b/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width.js new file mode 100644 index 0000000000..46a2529b12 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test viewport resizing, with and without meta viewport support. + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<head><meta name="viewport" content="width=300"/></head>' + + "<body>meta viewport width 300</body>"; +addRDMTask(TEST_URL, async function ({ ui, manager }) { + info("--- Starting viewport test output ---"); + + // We're going to take a 600,300 viewport (before) and resize it + // to 50,50 (after) and then resize it back. At the before and + // after points, we'll measure zoom and the layout viewport width + // and height. + const expected = [ + { + metaSupport: false, + before: [1.0, 600, 300], + after: [1.0, 50, 50], // Zoom is unaffected. + }, + { + metaSupport: true, + before: [2.0, 300, 150], + after: [0.25, 300, 300], // This checks that min-zoom is active. + }, + ]; + + for (const e of expected) { + const b = e.before; + const a = e.after; + + const message = "Meta Viewport " + (e.metaSupport ? "ON" : "OFF"); + + // Ensure meta viewport is set. + info(message + " setting meta viewport support."); + await setTouchAndMetaViewportSupport(ui, e.metaSupport); + + // Get to the initial size and check values. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + await testViewportZoomWidthAndHeight( + message + " before resize", + ui, + b[0], + b[1], + b[2] + ); + + // Move to the smaller size. + await setViewportSizeAndAwaitReflow(ui, manager, 50, 50); + await testViewportZoomWidthAndHeight( + message + " after resize", + ui, + a[0], + a[1], + a[2] + ); + + // Go back to the initial size and check again. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + await testViewportZoomWidthAndHeight( + message + " return to initial size", + ui, + b[0], + b[1], + b[2] + ); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width_and_zoom.js b/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width_and_zoom.js new file mode 100644 index 0000000000..c07bd84158 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width_and_zoom.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test viewport resizing, with and without meta viewport support. + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<head><meta name="viewport" content="width=device-width, ' + + 'initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"></head>' + + "<body>meta viewport scaled locked at 1.0</body>"; +addRDMTask(TEST_URL, async function ({ ui, manager }) { + info("--- Starting viewport test output ---"); + + // We're going to take a 300,600 viewport (before) and resize it + // to 600,300 (after) and then resize it back. At the before and + // after points, we'll measure zoom and the layout viewport width + // and height. + const expected = [ + { + metaSupport: false, + before: { + zoom: 1.0, + width: 300, + height: 600, + }, + after: { + zoom: 1.0, + width: 600, + height: 300, + }, + }, + { + metaSupport: true, + before: { + zoom: 1.0, + width: 300, + height: 600, + }, + after: { + zoom: 1.0, + width: 600, + height: 300, + }, + }, + ]; + + for (const e of expected) { + const b = e.before; + const a = e.after; + + const message = "Meta Viewport " + (e.metaSupport ? "ON" : "OFF"); + + // Ensure meta viewport is set. + info(message + " setting meta viewport support."); + await setTouchAndMetaViewportSupport(ui, e.metaSupport); + + // Get to the initial size and check values. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " before resize", + ui, + b.zoom, + b.width, + b.height + ); + + // Move to the smaller size. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + await testViewportZoomWidthAndHeight( + message + " after resize", + ui, + a.zoom, + a.width, + a.height + ); + + // Go back to the initial size and check again. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " return to initial size", + ui, + b.zoom, + b.width, + b.height + ); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_resizing_minimum_scale.js b/devtools/client/responsive/test/browser/browser_viewport_resizing_minimum_scale.js new file mode 100644 index 0000000000..b2984851d2 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resizing_minimum_scale.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test viewport resizing, with and without meta viewport support. + +const TEST_URL = + "data:text/html;charset=utf-8," + + '<head><meta name="viewport" content="initial-scale=1.0, ' + + 'minimum-scale=1.0, width=device-width"></head>' + + '<div style="width:100%;background-color:green">test</div>' + + "</body>"; +addRDMTask(TEST_URL, async function ({ ui, manager }) { + info("--- Starting viewport test output ---"); + + // We're going to take a 300,600 viewport (before) and resize it + // to 600,300 (after) and then resize it back. At the before and + // after points, we'll measure zoom and the layout viewport width + // and height. + const expected = [ + { + before: { + zoom: 1.0, + width: 300, + height: 600, + }, + after: { + zoom: 1.0, + width: 600, + height: 300, + }, + }, + ]; + + for (const e of expected) { + const b = e.before; + const a = e.after; + + const message = "Meta Viewport ON"; + + // Ensure meta viewport is set. + info(message + " setting meta viewport support."); + await setTouchAndMetaViewportSupport(ui, true); + + // Get to the initial size and check values. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " before resize", + ui, + b.zoom, + b.width, + b.height + ); + + // Move to the smaller size. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + await testViewportZoomWidthAndHeight( + message + " after resize", + ui, + a.zoom, + a.width, + a.height + ); + + // Go back to the initial size and check again. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + await testViewportZoomWidthAndHeight( + message + " return to initial size", + ui, + b.zoom, + b.width, + b.height + ); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_resizing_scrollbar.js b/devtools/client/responsive/test/browser/browser_viewport_resizing_scrollbar.js new file mode 100644 index 0000000000..ed4de1711e --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resizing_scrollbar.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test scrollbar appearance after viewport resizing, with and without +// meta viewport support. + +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/WindowSnapshot.js", + this +); + +// The quest for a TEST_ROOT: we have to choose a way of addressing the RDM document +// such that two things can happen: +// 1) addRDMTask can load it. +// 2) WindowSnapshot can take a picture of it. + +// The WindowSnapshot does not work cross-origin. We can't use a data URI, because those +// are considered cross-origin. + +// let TEST_ROOT = ""; + +// We can't use a relative URL, because addRDMTask can't load local files. +// TEST_ROOT = ""; + +// We can't use a mochi.test URL, because it's cross-origin. +// TEST_ROOT = +// "http://mochi.test:8888/browser/devtools/client/responsive/test/browser/"; + +// We can't use a chrome URL, because it triggers an assertion: RDM only available for +// remote tabs. +// TEST_ROOT = +// "chrome://mochitests/content/browser/devtools/client/responsive/test/browser/"; + +// So if we had an effective TEST_ROOT, we'd use it here and run our test. But we don't. +// The proposed "file_meta_1000_div.html" would just contain the html specified below as +// a data URI. +// const TEST_URL = TEST_ROOT + "file_meta_1000_div.html"; + +// Instead we're going to mess with a security preference to allow a data URI to be +// treated as same-origin. This doesn't work either for reasons that I don't understand. + +const TEST_URL = + "data:text/html;charset=utf-8," + + "<head>" + + '<meta name="viewport" content="width=device-width, initial-scale=1"/>' + + "</head>" + + '<body><div style="background:orange; width:1000px; height:1000px"></div></body>'; + +addRDMTask(TEST_URL, async function ({ ui, manager }) { + // Turn on the prefs that force overlay scrollbars to always be visible. + await SpecialPowers.pushPrefEnv({ + set: [["layout.testing.overlay-scrollbars.always-visible", true]], + }); + + info("--- Starting viewport test output ---"); + + const browser = ui.getViewportBrowser(); + + const expected = [false, true]; + for (const e of expected) { + const message = "Meta Viewport " + (e ? "ON" : "OFF"); + + // Ensure meta viewport is set. + info(message + " setting meta viewport support."); + await setTouchAndMetaViewportSupport(ui, e.metaSupport); + + // Get to the initial size and snapshot the window. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + const initialSnapshot = await snapshotWindow(browser); + + // Move to the rotated size. + await setViewportSizeAndAwaitReflow(ui, manager, 600, 300); + + // Reload the window. + await reloadBrowser(); + + // Go back to the initial size and take another snapshot. + await setViewportSizeAndAwaitReflow(ui, manager, 300, 600); + const finalSnapshot = await snapshotWindow(browser); + + const result = compareSnapshots(initialSnapshot, finalSnapshot, true); + is(result[2], result[1], "Window snapshots should match."); + } +}); diff --git a/devtools/client/responsive/test/browser/browser_viewport_resolution_restore.js b/devtools/client/responsive/test/browser/browser_viewport_resolution_restore.js new file mode 100644 index 0000000000..6e5f10dff6 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_resolution_restore.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that resolution is restored to its pre-RDM value after closing RDM. +// Do this by using a chrome-only method to force resolution before opening +// RDM, then letting RDM set its own preferred resolution due to the meta +// viewport settings. When we close RDM and check resolution, we check for +// something close to what we initially set, bracketed by these scaling +// factors: +const RESOLUTION_FACTOR_MIN = 0.96; +const RESOLUTION_FACTOR_MAX = 1.04; + +info("--- Starting viewport test output ---"); + +const WIDTH = 200; +const HEIGHT = 200; +const TESTS = [ + { content: "width=600" }, + { content: "width=600, initial-scale=1.0", res_restore: 0.782 }, + { content: "width=device-width", res_restore: 3.4 }, + { content: "width=device-width, initial-scale=2.0", res_restore: 1.1 }, +]; + +for (const { content, res_restore } of TESTS) { + const TEST_URL = + `data:text/html;charset=utf-8,` + + `<html><head><meta name="viewport" content="${content}"></head>` + + `<body><div style="width:100%;background-color:green">${content}</div>` + + `</body></html>`; + + addRDMTaskWithPreAndPost( + TEST_URL, + async function rdmPreTask({ browser }) { + if (res_restore) { + info(`Setting resolution to ${res_restore}.`); + browser.ownerGlobal.windowUtils.setResolutionAndScaleTo(res_restore); + } else { + info(`Not setting resolution.`); + } + }, + async function rdmTask({ ui, manager }) { + info(`Resizing viewport and ensuring that meta viewport is on.`); + await setViewportSize(ui, manager, WIDTH, HEIGHT); + await setTouchAndMetaViewportSupport(ui, true); + }, + async function rdmPostTask({ browser }) { + const resolution = browser.ownerGlobal.windowUtils.getResolution(); + const res_target = res_restore ? res_restore : 1.0; + + const res_min = res_target * RESOLUTION_FACTOR_MIN; + const res_max = res_target * RESOLUTION_FACTOR_MAX; + ok( + res_min <= resolution && res_max >= resolution, + `${content} resolution should be near ${res_target}, and we got ${resolution}.` + ); + } + ); +} diff --git a/devtools/client/responsive/test/browser/browser_viewport_state_after_close.js b/devtools/client/responsive/test/browser/browser_viewport_state_after_close.js new file mode 100644 index 0000000000..3bac3b3e01 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_state_after_close.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that after closing RDM the page goes back to its original state + +const TEST_URL = + "data:text/html;charset=utf-8,<style>h1 {width: 200px;} @media (hover:none) { h1 {width: 400px;background: tomato;}</style><h1>Hello</h1>"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + + reloadOnTouchChange(false); + reloadOnUAChange(false); + await pushPref("devtools.responsive.touchSimulation.enabled", true); + + is(await getH1Width(), 200, "<h1> has expected initial width"); + + for (let i = 0; i < 10; i++) { + info("Open responsive design mode"); + await openRDM(tab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const mql = content.matchMedia("(hover:none)"); + if (mql.matches) { + return; + } + await new Promise(res => + mql.addEventListener("change", res, { once: true }) + ); + }); + + is( + await getH1Width(), + 400, + "<h1> has expected width when RDM and touch simulation are enabled" + ); + + info("Close responsive design mode"); + await closeRDM(tab); + + is(await getH1Width(), 200, "<h1> has expected width after closing RDM"); + } +}); + +function getH1Width() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.document.querySelector("h1").getBoundingClientRect().width; + }); +} diff --git a/devtools/client/responsive/test/browser/browser_viewport_zoom_resolution_invariant.js b/devtools/client/responsive/test/browser/browser_viewport_zoom_resolution_invariant.js new file mode 100644 index 0000000000..69c6c0a95b --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_zoom_resolution_invariant.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that resolution is as expected for different types of meta viewport +// settings, as the RDM pane is zoomed to different values. + +const RESOLUTION_FACTOR_MIN = 0.96; +const RESOLUTION_FACTOR_MAX = 1.04; +const ZOOM_LEVELS = [ + 0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, + // TODO(emilio): These should pass. + // 0.3, + // 3.0, +]; + +info("--- Starting viewport test output ---"); + +const WIDTH = 200; +const HEIGHT = 200; +const TESTS = [ + { content: "width=600", res_target: 0.333 }, + { content: "width=600, initial-scale=1.0", res_target: 1.0 }, + { content: "width=device-width", res_target: 1.0 }, + { content: "width=device-width, initial-scale=2.0", res_target: 2.0 }, +]; + +for (const { content, res_target } of TESTS) { + const TEST_URL = + `data:text/html;charset=utf-8,` + + `<html><head><meta name="viewport" content="${content}"></head>` + + `<body><div style="width:100%;background-color:green">${content}</div>` + + `</body></html>`; + + addRDMTask(TEST_URL, async function ({ ui, manager, browser }) { + await setViewportSize(ui, manager, WIDTH, HEIGHT); + await setTouchAndMetaViewportSupport(ui, true); + + // Ensure we've reflowed the page at least once so that MVM has chosen + // the initial scale. + await promiseContentReflow(ui); + + for (const zoom of ZOOM_LEVELS.concat([...ZOOM_LEVELS].reverse())) { + info(`Set zoom to ${zoom}.`); + await promiseRDMZoom(ui, browser, zoom); + + const resolution = await spawnViewportTask(ui, {}, () => { + return content.windowUtils.getResolution(); + }); + + const res_min = res_target * RESOLUTION_FACTOR_MIN; + const res_max = res_target * RESOLUTION_FACTOR_MAX; + ok( + res_min <= resolution && res_max >= resolution, + `${content} zoom ${zoom} resolution should be near ${res_target}, and we got ${resolution}.` + ); + } + }); +} diff --git a/devtools/client/responsive/test/browser/browser_viewport_zoom_toggle.js b/devtools/client/responsive/test/browser/browser_viewport_zoom_toggle.js new file mode 100644 index 0000000000..400bfa99a9 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_viewport_zoom_toggle.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Verify full zoom levels inherit RDM full zoom after exiting RDM. + +const TEST_URL = "https://example.com/"; + +function getZoomForBrowser(browser) { + return ZoomManager.getZoomForBrowser(browser); +} + +function setZoomForBrowser(browser, zoom) { + ZoomManager.setZoomForBrowser(browser, zoom); +} + +addRDMTask( + null, + async function ({ message }) { + const INITIAL_ZOOM_LEVEL = 1; + const PRE_RDM_ZOOM_LEVEL = 1.5; + const MID_RDM_ZOOM_LEVEL = 2; + + const tab = await addTab(TEST_URL); + const browser = tab.linkedBrowser; + + await navigateTo(TEST_URL); + + // Get the initial zoom level. + const initialOuterZoom = getZoomForBrowser(browser); + is( + initialOuterZoom, + INITIAL_ZOOM_LEVEL, + "Initial outer zoom should be " + INITIAL_ZOOM_LEVEL + "." + ); + + // Change the zoom level before we open RDM. + setZoomForBrowser(browser, PRE_RDM_ZOOM_LEVEL); + + const preRDMOuterZoom = getZoomForBrowser(browser); + is( + preRDMOuterZoom, + PRE_RDM_ZOOM_LEVEL, + "Pre-RDM outer zoom should be " + PRE_RDM_ZOOM_LEVEL + "." + ); + + // Start RDM on the tab. This will fundamentally change the way that browser behaves. + // It will now pass all of its messages through to the RDM docshell, meaning that when + // we request zoom level from it now, we are getting the RDM zoom level. + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + const uiDocShell = ui.toolWindow.docShell; + + // Bug 1541692: openRDM behaves differently in the test harness than it does + // interactively. Interactively, many features of the container docShell -- including + // zoom -- are copied over to the RDM browser. In the test harness, this seems to first + // reset the docShell before toggling RDM, which makes checking the initial zoom of the + // RDM pane not useful. + + const preZoomUIZoom = uiDocShell.browsingContext.fullZoom; + is( + preZoomUIZoom, + INITIAL_ZOOM_LEVEL, + "Pre-zoom UI zoom should be " + INITIAL_ZOOM_LEVEL + "." + ); + + // Set the zoom level. This should tunnel to the inner browser and leave the UI alone. + setZoomForBrowser(browser, MID_RDM_ZOOM_LEVEL); + + // The UI zoom should be unchanged by this. + const postZoomUIZoom = uiDocShell.browsingContext.fullZoom; + is( + postZoomUIZoom, + preZoomUIZoom, + "UI zoom should be unchanged by RDM zoom." + ); + + // The RDM zoom should be changed. + const finalRDMZoom = getZoomForBrowser(browser); + is( + finalRDMZoom, + MID_RDM_ZOOM_LEVEL, + "RDM zoom should be " + MID_RDM_ZOOM_LEVEL + "." + ); + + // Leave RDM. This should cause the outer pane to take on the full zoom of the RDM pane. + await closeRDM(tab); + + const finalOuterZoom = getZoomForBrowser(browser); + is( + finalOuterZoom, + finalRDMZoom, + "Final outer zoom should match last RDM zoom." + ); + + await removeTab(tab); + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_window_close.js b/devtools/client/responsive/test/browser/browser_window_close.js new file mode 100644 index 0000000000..2af3d7fdd8 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_window_close.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +addRDMTask( + null, + async function () { + const NEW_WINDOW_URL = + "data:text/html;charset=utf-8,New window opened via window.open"; + const newWindowPromise = BrowserTestUtils.waitForNewWindow({ + // Passing the url param so the Promise will resolve once DOMContentLoaded is emitted + // on the new window tab + url: NEW_WINDOW_URL, + }); + window.open(NEW_WINDOW_URL, "_blank", "noopener,all"); + + const newWindow = await newWindowPromise; + ok(true, "Got new window"); + + info("Focus new window"); + newWindow.focus(); + + info("Open RDM"); + const tab = newWindow.gBrowser.selectedTab; + const { ui } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + + ok( + ResponsiveUIManager.isActiveForTab(tab), + "ResponsiveUI should be active for tab when the window is closed" + ); + + // Close the window on a tab with an active responsive design UI and + // wait for the UI to gracefully shutdown. This has leaked the window + // in the past. + info("Close the new window"); + const offPromise = once(ResponsiveUIManager, "off"); + await BrowserTestUtils.closeWindow(newWindow); + await offPromise; + }, + { onlyPrefAndTask: true } +); diff --git a/devtools/client/responsive/test/browser/browser_window_sizing.js b/devtools/client/responsive/test/browser/browser_window_sizing.js new file mode 100644 index 0000000000..6b8a558813 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_window_sizing.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that correct window sizing values are reported and unaffected by zoom. In +// particular, we want to ensure that the values for the window's outer and screen +// sizing values reflect the size of the viewport. + +const TEST_URL = "data:text/html;charset=utf-8,"; +const WIDTH = 375; +const HEIGHT = 450; +const ZOOM_LEVELS = [0.3, 0.5, 0.9, 1, 1.5, 2, 2.4]; + +addRDMTask( + null, + async function () { + const tab = await addTab(TEST_URL); + const browser = tab.linkedBrowser; + + const { ui, manager } = await openRDM(tab); + await waitForDeviceAndViewportState(ui); + await setViewportSize(ui, manager, WIDTH, HEIGHT); + + info("Ensure outer size values are unchanged at different zoom levels."); + for (let i = 0; i < ZOOM_LEVELS.length; i++) { + info(`Setting zoom level to ${ZOOM_LEVELS[i]}`); + await promiseRDMZoom(ui, browser, ZOOM_LEVELS[i]); + + await checkWindowOuterSize(ui, ZOOM_LEVELS[i]); + await checkWindowScreenSize(ui, ZOOM_LEVELS[i]); + } + }, + { onlyPrefAndTask: true } +); + +async function checkWindowOuterSize(ui, zoom_level) { + return SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ width: WIDTH, height: HEIGHT, zoom: zoom_level }], + async function ({ width, height, zoom }) { + // Approximate the outer size value returned on the window content with the expected + // value. We should expect, at the very most, a 2px difference between the two due + // to floating point rounding errors that occur when scaling from inner size CSS + // integer values to outer size CSS integer values. See Part 1 of Bug 1107456. + // Some of the drift is also due to full zoom scaling effects; see Bug 1577775. + Assert.lessOrEqual( + Math.abs(content.outerWidth - width), + 2, + `window.outerWidth zoom ${zoom} should be ${width} and we got ${content.outerWidth}.` + ); + Assert.lessOrEqual( + Math.abs(content.outerHeight - height), + 2, + `window.outerHeight zoom ${zoom} should be ${height} and we got ${content.outerHeight}.` + ); + } + ); +} + +async function checkWindowScreenSize(ui, zoom_level) { + return SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ width: WIDTH, height: HEIGHT, zoom: zoom_level }], + async function ({ width, height, zoom }) { + const { screen } = content; + + Assert.lessOrEqual( + Math.abs(screen.availWidth - width), + 2, + `screen.availWidth zoom ${zoom} should be ${width} and we got ${screen.availWidth}.` + ); + + Assert.lessOrEqual( + Math.abs(screen.availHeight - height), + 2, + `screen.availHeight zoom ${zoom} should be ${height} and we got ${screen.availHeight}.` + ); + + Assert.lessOrEqual( + Math.abs(screen.width - width), + 2, + `screen.width zoom " ${zoom} should be ${width} and we got ${screen.width}.` + ); + + Assert.lessOrEqual( + Math.abs(screen.height - height), + 2, + `screen.height zoom " ${zoom} should be ${height} and we got ${screen.height}.` + ); + } + ); +} diff --git a/devtools/client/responsive/test/browser/browser_zoom.js b/devtools/client/responsive/test/browser/browser_zoom.js new file mode 100644 index 0000000000..8eb0db3e18 --- /dev/null +++ b/devtools/client/responsive/test/browser/browser_zoom.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "data:text/html,foo"; + +addRDMTaskWithPreAndPost( + URL, + async function pre({ browser }) { + info("Setting zoom"); + // It's important that we do this so that we don't race with FullZoom's use + // of ContentSettings, which would reset the zoom. + FullZoom.setZoom(2.0, browser); + }, + async function task({ browser, ui }) { + is( + ZoomManager.getZoomForBrowser(browser), + 2.0, + "Zoom shouldn't have got lost" + ); + + // wait for the list of devices to be loaded to prevent pending promises + await waitForDeviceAndViewportState(ui); + }, + async function post() {} +); diff --git a/devtools/client/responsive/test/browser/contextual_identity.html b/devtools/client/responsive/test/browser/contextual_identity.html new file mode 100644 index 0000000000..05ad403fc6 --- /dev/null +++ b/devtools/client/responsive/test/browser/contextual_identity.html @@ -0,0 +1,6 @@ +<html><body> +<script> +"use strict"; +document.title = window.location.search; +</script> +</body></html> diff --git a/devtools/client/responsive/test/browser/devices.json b/devtools/client/responsive/test/browser/devices.json new file mode 100644 index 0000000000..46e842d3ff --- /dev/null +++ b/devtools/client/responsive/test/browser/devices.json @@ -0,0 +1,658 @@ +{ + "TYPES": [ + "phones", + "tablets", + "laptops", + "televisions", + "consoles", + "watches" + ], + "phones": [ + { + "name": "Firefox OS Flame", + "width": 320, + "height": 570, + "pixelRatio": 1.5, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Alcatel One Touch Fire", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Alcatel One Touch Fire C", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch4019X; rv:28.0) Gecko/28.0 Firefox/28.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Alcatel One Touch Fire E", + "width": 320, + "height": 480, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch6015X; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Apple iPhone 4", + "width": 320, + "height": 480, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "Apple iPhone 5", + "width": 320, + "height": 568, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X; en-us) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "Apple iPhone 5s", + "width": 320, + "height": 568, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_2_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13D15 Safari/601.1", + "touch": true, + "firefoxOS": false, + "os": "ios", + "featured": true + }, + { + "name": "Apple iPhone 6", + "width": 375, + "height": 667, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "Apple iPhone 6 Plus", + "width": 414, + "height": 736, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", + "touch": true, + "firefoxOS": false, + "os": "ios", + "featured": true + }, + { + "name": "Apple iPhone 6s", + "width": 375, + "height": 667, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", + "touch": true, + "firefoxOS": false, + "os": "ios", + "featured": true + }, + { + "name": "Apple iPhone 6s Plus", + "width": 414, + "height": 736, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "BlackBerry Z30", + "width": 360, + "height": 640, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+", + "touch": true, + "firefoxOS": false, + "os": "blackberryos" + }, + { + "name": "Geeksphone Keon", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Geeksphone Peak, Revolution", + "width": 360, + "height": 640, + "pixelRatio": 1.5, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Google Nexus S", + "width": 320, + "height": 533, + "pixelRatio": 1.5, + "userAgent": "Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Nexus S Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Google Nexus 4", + "width": 384, + "height": 640, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.4; en-us; Nexus 4 Build/JOP40D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36", + "touch": true, + "firefoxOS": true, + "os": "android", + "featured": true + }, + { + "name": "Google Nexus 5", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.4; en-us; Nexus 5 Build/JOP40D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36", + "touch": true, + "firefoxOS": true, + "os": "android", + "featured": true + }, + { + "name": "Google Nexus 6", + "width": 412, + "height": 732, + "pixelRatio": 3.5, + "userAgent": "Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.23 Mobile Safari/537.36", + "touch": true, + "firefoxOS": true, + "os": "android", + "featured": true + }, + { + "name": "Intex Cloud Fx", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "KDDI Fx0", + "width": 360, + "height": 640, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Mobile; LGL25; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "LG Fireweb", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; LG-D300; rv:18.1) Gecko/18.1 Firefox/18.1", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "LG Optimus L70", + "width": 384, + "height": 640, + "pixelRatio": 1.25, + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.1599.103 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Nokia Lumia 520", + "width": 320, + "height": 533, + "pixelRatio": 1.4, + "userAgent": "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)", + "touch": true, + "firefoxOS": false, + "os": "android", + "featured": true + }, + { + "name": "Nokia N9", + "width": 360, + "height": 640, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "OnePlus One", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Android 5.1.1; Mobile; rv:43.0) Gecko/43.0 Firefox/43.0", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Samsung Galaxy S3", + "width": 360, + "height": 640, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Samsung Galaxy S4", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Samsung Galaxy S5", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android", + "featured": true + }, + { + "name": "Samsung Galaxy S6", + "width": 360, + "height": 640, + "pixelRatio": 4, + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android", + "featured": true + }, + { + "name": "Sony Xperia Z3", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Spice Fire One Mi-FX1", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Symphony GoFox F15", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:30.0) Gecko/30.0 Firefox/30.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "ZTE Open", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; ZTEOPEN; rv:18.1) Gecko/18.0 Firefox/18.1", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "ZTE Open II", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; OPEN2; rv:28.0) Gecko/28.0 Firefox/28.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "ZTE Open C", + "width": 320, + "height": 450, + "pixelRatio": 1.5, + "userAgent": "Mozilla/5.0 (Mobile; OPENC; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + }, + { + "name": "Zen Fire 105", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + } + ], + "tablets": [ + { + "name": "Amazon Kindle Fire HDX 8.9", + "width": 1280, + "height": 800, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true", + "touch": true, + "firefoxOS": false, + "os": "fireos", + "featured": true + }, + { + "name": "Apple iPad", + "width": 1024, + "height": 768, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "Apple iPad Air 2", + "width": 1024, + "height": 768, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5", + "touch": true, + "firefoxOS": false, + "os": "ios", + "featured": true + }, + { + "name": "Apple iPad Mini", + "width": 1024, + "height": 768, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5", + "touch": true, + "firefoxOS": false, + "os": "ios" + }, + { + "name": "Apple iPad Mini 2", + "width": 1024, + "height": 768, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5", + "touch": true, + "firefoxOS": false, + "os": "ios", + "featured": true + }, + { + "name": "BlackBerry PlayBook", + "width": 1024, + "height": 600, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+", + "touch": true, + "firefoxOS": false, + "os": "blackberryos" + }, + { + "name": "Foxconn InFocus", + "width": 1280, + "height": 800, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Tablet; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Google Nexus 7", + "width": 960, + "height": 600, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android", + "featured": true + }, + { + "name": "Google Nexus 10", + "width": 1280, + "height": 800, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Samsung Galaxy Note 2", + "width": 360, + "height": 640, + "pixelRatio": 2, + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "touch": true, + "firefoxOS": false, + "os": "android" + }, + { + "name": "Samsung Galaxy Note 3", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", + "touch": true, + "firefoxOS": false, + "os": "android", + "featured": true + }, + { + "name": "Tesla Model S", + "width": 1200, + "height": 1920, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (X11; Linux) AppleWebKit/534.34 (KHTML, like Gecko) QtCarBrowser Safari/534.34", + "touch": true, + "firefoxOS": false, + "os": "linux" + }, + { + "name": "VIA Vixen", + "width": 1024, + "height": 600, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Tablet; rv:32.0) Gecko/32.0 Firefox/32.0", + "touch": true, + "firefoxOS": true, + "os": "fxos" + } + ], + "laptops": [ + { + "name": "Laptop (1366 x 768)", + "width": 1366, + "height": 768, + "pixelRatio": 1, + "userAgent": "", + "touch": false, + "firefoxOS": false, + "os": "windows", + "featured": true + }, + { + "name": "Laptop (1920 x 1080)", + "width": 1280, + "height": 720, + "pixelRatio": 1.5, + "userAgent": "", + "touch": false, + "firefoxOS": false, + "os": "windows", + "featured": true + }, + { + "name": "Laptop (1920 x 1080) with touch", + "width": 1280, + "height": 720, + "pixelRatio": 1.5, + "userAgent": "", + "touch": true, + "firefoxOS": false, + "os": "windows" + } + ], + "televisions": [ + { + "name": "720p HD Television", + "width": 1280, + "height": 720, + "pixelRatio": 1, + "userAgent": "", + "touch": false, + "firefoxOS": true, + "os": "custom" + }, + { + "name": "1080p Full HD Television", + "width": 1920, + "height": 1080, + "pixelRatio": 1, + "userAgent": "", + "touch": false, + "firefoxOS": true, + "os": "custom" + }, + { + "name": "4K Ultra HD Television", + "width": 3840, + "height": 2160, + "pixelRatio": 1, + "userAgent": "", + "touch": false, + "firefoxOS": true, + "os": "custom" + } + ], + "consoles": [ + { + "name": "Nintendo 3DS", + "width": 320, + "height": 240, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Nintendo 3DS; U; ; en) Version/1.7585.EU", + "touch": true, + "firefoxOS": false, + "os": "nintendo" + }, + { + "name": "Nintendo Wii U Gamepad", + "width": 854, + "height": 480, + "pixelRatio": 0.87, + "userAgent": "Mozilla/5.0 (Nintendo WiiU) AppleWebKit/536.28 (KHTML, like Gecko) NX/3.0.3.12.15 NintendoBrowser/4.1.1.9601.EU", + "touch": true, + "firefoxOS": false, + "os": "nintendo" + }, + { + "name": "Sony PlayStation Vita", + "width": 960, + "height": 544, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Playstation Vita 1.61) AppleWebKit/531.22.8 (KHTML, like Gecko) Silk/3.2", + "touch": true, + "firefoxOS": false, + "os": "playstation" + } + ], + "watches": [ + { + "name": "LG G Watch", + "width": 280, + "height": 280, + "pixelRatio": 1, + "userAgent": "", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "LG G Watch R", + "width": 320, + "height": 320, + "pixelRatio": 1, + "userAgent": "", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Motorola Moto 360", + "width": 320, + "height": 290, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Linux; Android 5.0.1; Moto 360 Build/LWX48T) AppleWebkit/537.36 (KHTML, like Gecko) Chrome/19.77.34.5 Mobile Safari/537.36", + "touch": true, + "firefoxOS": true, + "os": "android" + }, + { + "name": "Samsung Gear Live", + "width": 320, + "height": 320, + "pixelRatio": 1, + "userAgent": "", + "touch": true, + "firefoxOS": true, + "os": "android" + } + ] +} diff --git a/devtools/client/responsive/test/browser/doc_contextmenu_inspect.html b/devtools/client/responsive/test/browser/doc_contextmenu_inspect.html new file mode 100644 index 0000000000..ee325f5ad5 --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_contextmenu_inspect.html @@ -0,0 +1,3 @@ +<html> + <div style="width: 500px; height: 500px; background: red;"></div> +</html> diff --git a/devtools/client/responsive/test/browser/doc_page_state.html b/devtools/client/responsive/test/browser/doc_page_state.html new file mode 100644 index 0000000000..fb4d2acf01 --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_page_state.html @@ -0,0 +1,16 @@ +<!doctype html> +<html> + <head> + <title>Page State Test</title> + <style> + body { + height: 100vh; + background: red; + } + body.modified { + background: green; + } + </style> + </head> + <body onclick="this.classList.add('modified')"/> +</html> diff --git a/devtools/client/responsive/test/browser/doc_picker_link.html b/devtools/client/responsive/test/browser/doc_picker_link.html new file mode 100644 index 0000000000..fd358be443 --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_picker_link.html @@ -0,0 +1,12 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> +<html> + <!-- The <a> should point to a valid page to check that page navigation will + not happen when picking the element --> + <a + href="about:home" + class="picker-link" + style="display:block; padding: 10px; width:50px; height:50px;" + >Link should not open when picking</a> +</html> diff --git a/devtools/client/responsive/test/browser/doc_toolbox_rule_view.css b/devtools/client/responsive/test/browser/doc_toolbox_rule_view.css new file mode 100644 index 0000000000..7ed528635b --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_toolbox_rule_view.css @@ -0,0 +1,10 @@ +div { + width: 500px; + height: 10px; + background: purple; +} +@media screen and (max-width: 200px) { + div { + width: 100px; + } +}; diff --git a/devtools/client/responsive/test/browser/doc_toolbox_rule_view.html b/devtools/client/responsive/test/browser/doc_toolbox_rule_view.html new file mode 100644 index 0000000000..e4a311b7ec --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_toolbox_rule_view.html @@ -0,0 +1,4 @@ +<html> + <link rel="stylesheet" charset="UTF-8" type="text/css" media="screen" href="doc_toolbox_rule_view.css"/> + <div></div> +</html> diff --git a/devtools/client/responsive/test/browser/doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs b/devtools/client/responsive/test/browser/doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs new file mode 100644 index 0000000000..ba53928bed --- /dev/null +++ b/devtools/client/responsive/test/browser/doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs @@ -0,0 +1,51 @@ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "html", false); + + // Check the params and set the cross-origin-opener policy headers if needed + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + + const IFRAME_HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <script> + globalThis.initialOrientationAngle = screen.orientation.angle; + globalThis.initialOrientationType = screen.orientation.type; + </script> + </head> + <body> + <h1>Iframe</h1> + </body> + </html>`; + + const HTML = ` + <!doctype html> + <html> + <head> + <script> + globalThis.initialOrientationAngle = screen.orientation.angle; + globalThis.initialOrientationType = screen.orientation.type; + </script> + <meta charset=utf8> + </head> + <body> + <h1>Top-level document</h1> + <iframe src='${iframeOrigin}/document-builder.sjs?html=${encodeURI( + IFRAME_HTML + )}'></iframe> + </body> + </html>`; + + response.write(HTML); +} diff --git a/devtools/client/responsive/test/browser/favicon.html b/devtools/client/responsive/test/browser/favicon.html new file mode 100644 index 0000000000..2a0684007c --- /dev/null +++ b/devtools/client/responsive/test/browser/favicon.html @@ -0,0 +1,8 @@ +<!doctype html> +<html> + <head> + <title>Favicon Test</title> + <link rel="icon" href="favicon.ico"> + </head> + <body/> +</html> diff --git a/devtools/client/responsive/test/browser/favicon.ico b/devtools/client/responsive/test/browser/favicon.ico Binary files differnew file mode 100644 index 0000000000..d44438903b --- /dev/null +++ b/devtools/client/responsive/test/browser/favicon.ico diff --git a/devtools/client/responsive/test/browser/geolocation.html b/devtools/client/responsive/test/browser/geolocation.html new file mode 100644 index 0000000000..df0014dd02 --- /dev/null +++ b/devtools/client/responsive/test/browser/geolocation.html @@ -0,0 +1,13 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <title>Geolocation permission test</title> + </head> + <body> + <script type="text/javascript"> + "use strict"; + navigator.geolocation.getCurrentPosition(function(pos) {}); + </script> + </body> +</html> diff --git a/devtools/client/responsive/test/browser/head.js b/devtools/client/responsive/test/browser/head.js new file mode 100644 index 0000000000..d2b42316a9 --- /dev/null +++ b/devtools/client/responsive/test/browser/head.js @@ -0,0 +1,1008 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +// Import helpers for the inspector that are also shared with others +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +// Load APZ test utils so we properly wait after resize +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_utils.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/paint_listener.js", + this +); + +const { + _loadPreferredDevices, +} = require("resource://devtools/client/responsive/actions/devices.js"); +const { + getStr, +} = require("resource://devtools/client/responsive/utils/l10n.js"); +const { + getTopLevelWindow, +} = require("resource://devtools/client/responsive/utils/window.js"); +const { + addDevice, + removeDevice, + removeLocalDevices, +} = require("resource://devtools/client/shared/devices.js"); +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const asyncStorage = require("resource://devtools/shared/async-storage.js"); +const localTypes = require("resource://devtools/client/responsive/types.js"); + +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "resource://devtools/client/responsive/manager.js" +); +loader.lazyRequireGetter( + this, + "message", + "resource://devtools/client/responsive/utils/message.js" +); + +const E10S_MULTI_ENABLED = + Services.prefs.getIntPref("dom.ipc.processCount") > 1; +const TEST_URI_ROOT = + "http://example.com/browser/devtools/client/responsive/test/browser/"; +const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions."; +const DEFAULT_UA = Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler +).userAgent; + +SimpleTest.requestCompleteLog(); +SimpleTest.waitForExplicitFinish(); + +// Toggling the RDM UI involves several docShell swap operations, which are somewhat slow +// on debug builds. Usually we are just barely over the limit, so a blanket factor of 2 +// should be enough. +requestLongerTimeout(2); + +// The appearance of this notification causes intermittent behavior in some tests that +// send mouse events, since it causes the content to shift when it appears. +Services.prefs.setBoolPref( + "devtools.responsive.reloadNotification.enabled", + false +); +// Don't show the setting onboarding tooltip in the test suites. +Services.prefs.setBoolPref("devtools.responsive.show-setting-tooltip", false); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref( + "devtools.responsive.reloadNotification.enabled" + ); + Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList"); + Services.prefs.clearUserPref( + "devtools.responsive.reloadConditions.touchSimulation" + ); + Services.prefs.clearUserPref( + "devtools.responsive.reloadConditions.userAgent" + ); + Services.prefs.clearUserPref("devtools.responsive.show-setting-tooltip"); + Services.prefs.clearUserPref("devtools.responsive.showUserAgentInput"); + Services.prefs.clearUserPref("devtools.responsive.touchSimulation.enabled"); + Services.prefs.clearUserPref("devtools.responsive.userAgent"); + Services.prefs.clearUserPref("devtools.responsive.viewport.height"); + Services.prefs.clearUserPref("devtools.responsive.viewport.pixelRatio"); + Services.prefs.clearUserPref("devtools.responsive.viewport.width"); + await asyncStorage.removeItem("devtools.responsive.deviceState"); + await removeLocalDevices(); + + delete window.waitForAllPaintsFlushed; + delete window.waitForAllPaints; + delete window.promiseAllPaintsDone; +}); + +/** + * Adds a new test task that adds a tab with the given URL, awaits the + * preTask (if provided), opens responsive design mode, awaits the task, + * closes responsive design mode, awaits the postTask (if provided), and + * removes the tab. The final argument is an options object, with these + * optional properties: + * + * onlyPrefAndTask: if truthy, only the pref will be set and the task + * will be called, with none of the tab creation/teardown or open/close + * of RDM (default false). + * waitForDeviceList: if truthy, the function will wait until the device + * list is loaded before calling the task (default false). + * + * Example usage: + * + * addRDMTaskWithPreAndPost( + * TEST_URL, + * async function preTask({ message, browser }) { + * // Your pre-task goes here... + * }, + * async function task({ ui, manager, message, browser, preTaskValue, tab }) { + * // Your task goes here... + * }, + * async function postTask({ message, browser, preTaskValue, taskValue }) { + * // Your post-task goes here... + * }, + * { waitForDeviceList: true } + * ); + */ +function addRDMTaskWithPreAndPost(url, preTask, task, postTask, options) { + let onlyPrefAndTask = false; + let waitForDeviceList = false; + if (typeof options == "object") { + onlyPrefAndTask = !!options.onlyPrefAndTask; + waitForDeviceList = !!options.waitForDeviceList; + } + + add_task(async function () { + let tab; + let browser; + let preTaskValue = null; + let taskValue = null; + let ui; + let manager; + + if (!onlyPrefAndTask) { + tab = await addTab(url); + browser = tab.linkedBrowser; + + if (preTask) { + preTaskValue = await preTask({ message, browser }); + } + + const rdmValues = await openRDM(tab, { waitForDeviceList }); + ui = rdmValues.ui; + manager = rdmValues.manager; + } + + try { + taskValue = await task({ + ui, + manager, + message, + browser, + preTaskValue, + tab, + }); + } catch (err) { + ok(false, "Got an error: " + DevToolsUtils.safeErrorString(err)); + } + + if (!onlyPrefAndTask) { + await closeRDM(tab); + if (postTask) { + await postTask({ + message, + browser, + preTaskValue, + taskValue, + }); + } + await removeTab(tab); + } + + // Flush prefs to not only undo our earlier change, but also undo + // any changes made by the tasks. + await SpecialPowers.flushPrefEnv(); + }); +} + +/** + * This is a simplified version of addRDMTaskWithPreAndPost. Adds a new test + * task that adds a tab with the given URL, opens responsive design mode, + * closes responsive design mode, and removes the tab. + * + * Example usage: + * + * addRDMTask( + * TEST_URL, + * async function task({ ui, manager, message, browser }) { + * // Your task goes here... + * }, + * { waitForDeviceList: true } + * ); + */ +function addRDMTask(rdmURL, rdmTask, options) { + addRDMTaskWithPreAndPost(rdmURL, undefined, rdmTask, undefined, options); +} + +async function spawnViewportTask(ui, args, task) { + // Await a reflow after the task. + const result = await ContentTask.spawn(ui.getViewportBrowser(), args, task); + await promiseContentReflow(ui); + return result; +} + +function waitForFrameLoad(ui, targetURL) { + return spawnViewportTask(ui, { targetURL }, async function (args) { + if ( + (content.document.readyState == "complete" || + content.document.readyState == "interactive") && + content.location.href == args.targetURL + ) { + return; + } + await ContentTaskUtils.waitForEvent(this, "DOMContentLoaded"); + }); +} + +function waitForViewportResizeTo(ui, width, height) { + return new Promise(function (resolve) { + const isSizeMatching = data => data.width == width && data.height == height; + + // If the viewport has already the expected size, we resolve the promise immediately. + const size = ui.getViewportSize(); + if (isSizeMatching(size)) { + info(`Viewport already resized to ${width} x ${height}`); + resolve(); + return; + } + + // Otherwise, we'll listen to the viewport's resize event, and the + // browser's load end; since a racing condition can happen, where the + // viewport's listener is added after the resize, because the viewport's + // document was reloaded; therefore the test would hang forever. + // See bug 1302879. + const browser = ui.getViewportBrowser(); + + const onContentResize = data => { + if (!isSizeMatching(data)) { + return; + } + ui.off("content-resize", onContentResize); + browser.removeEventListener("mozbrowserloadend", onBrowserLoadEnd); + info(`Got content-resize to ${width} x ${height}`); + resolve(); + }; + + const onBrowserLoadEnd = async function () { + const data = ui.getViewportSize(ui); + onContentResize(data); + }; + + info(`Waiting for viewport-resize to ${width} x ${height}`); + // We're changing the viewport size, which may also change the content + // size. We wait on the viewport resize event, and check for the + // desired size. + ui.on("content-resize", onContentResize); + browser.addEventListener("mozbrowserloadend", onBrowserLoadEnd, { + once: true, + }); + }); +} + +var setViewportSize = async function (ui, manager, width, height) { + const size = ui.getViewportSize(); + info( + `Current size: ${size.width} x ${size.height}, ` + + `set to: ${width} x ${height}` + ); + if (size.width != width || size.height != height) { + const resized = waitForViewportResizeTo(ui, width, height); + ui.setViewportSize({ width, height }); + await resized; + } +}; + +// This performs the same function as setViewportSize, but additionally +// ensures that reflow of the viewport has completed. +var setViewportSizeAndAwaitReflow = async function ( + ui, + manager, + width, + height +) { + await setViewportSize(ui, manager, width, height); + await promiseContentReflow(ui); + await promiseApzFlushedRepaints(); +}; + +function getViewportDevicePixelRatio(ui) { + return SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + // Note that devicePixelRatio doesn't return the override to privileged + // code, see bug 1759962. + return content.browsingContext.overrideDPPX || content.devicePixelRatio; + }); +} + +function getElRect(selector, win) { + const el = win.document.querySelector(selector); + return el.getBoundingClientRect(); +} + +/** + * Drag an element identified by 'selector' by [x,y] amount. Returns + * the rect of the dragged element as it was before drag. + */ +function dragElementBy(selector, x, y, ui) { + const browserWindow = ui.getBrowserWindow(); + const rect = getElRect(selector, browserWindow); + const startPoint = { + clientX: Math.floor(rect.left + rect.width / 2), + clientY: Math.floor(rect.top + rect.height / 2), + }; + const endPoint = [startPoint.clientX + x, startPoint.clientY + y]; + + EventUtils.synthesizeMouseAtPoint( + startPoint.clientX, + startPoint.clientY, + { type: "mousedown" }, + browserWindow + ); + + // mousemove and mouseup are regular DOM listeners + EventUtils.synthesizeMouseAtPoint( + ...endPoint, + { type: "mousemove" }, + browserWindow + ); + EventUtils.synthesizeMouseAtPoint( + ...endPoint, + { type: "mouseup" }, + browserWindow + ); + + return rect; +} + +/** + * Resize the viewport and check that the resize happened as expected. + * + * @param {ResponsiveUI} ui + * The ResponsiveUI instance. + * @param {String} selector + * The css selector of the resize handler, eg .viewport-horizontal-resize-handle. + * @param {Array<number>} moveBy + * Array of 2 integers representing the x,y distance of the resize action. + * @param {Array<number>} moveBy + * Array of 2 integers representing the actual resize performed. + * @param {Object} options + * @param {Boolean} options.hasDevice + * Whether a device is currently set and will be overridden by the resize + */ +async function testViewportResize( + ui, + selector, + moveBy, + expectedHandleMove, + { hasDevice } = {} +) { + let deviceRemoved; + let waitForDevToolsReload; + if (hasDevice) { + // If a device was defined, a reload will be triggered by the resize, + // wait for devtools to reload completely. + waitForDevToolsReload = await watchForDevToolsReload( + ui.getViewportBrowser() + ); + // and wait for the device-associaton-removed event. + deviceRemoved = once(ui, "device-association-removed"); + } + + const resized = ui.once("viewport-resize-dragend"); + const startRect = dragElementBy(selector, ...moveBy, ui); + await resized; + + const endRect = getElRect(selector, ui.getBrowserWindow()); + is( + endRect.left - startRect.left, + expectedHandleMove[0], + `The x move of ${selector} is as expected` + ); + is( + endRect.top - startRect.top, + expectedHandleMove[1], + `The y move of ${selector} is as expected` + ); + + if (hasDevice) { + const { reloadTriggered } = await deviceRemoved; + if (reloadTriggered) { + await waitForDevToolsReload(); + } + } +} + +async function openDeviceModal(ui) { + const { document, store } = ui.toolWindow; + + info("Opening device modal through device selector."); + const onModalOpen = waitUntilState(store, state => state.devices.isModalOpen); + await selectMenuItem( + ui, + "#device-selector", + getStr("responsive.editDeviceList2") + ); + await onModalOpen; + + const modal = document.getElementById("device-modal-wrapper"); + ok( + modal.classList.contains("opened") && !modal.classList.contains("closed"), + "The device modal is displayed." + ); +} + +async function selectMenuItem({ toolWindow }, selector, value) { + const { document } = toolWindow; + + const button = document.querySelector(selector); + isnot( + button, + null, + `Selector "${selector}" should match an existing element.` + ); + + info(`Selecting ${value} in ${selector}.`); + + await testMenuItems(toolWindow, button, items => { + const menuItem = findMenuItem(items, value); + isnot( + menuItem, + undefined, + `Value "${value}" should match an existing menu item.` + ); + menuItem.click(); + }); +} + +/** + * Runs the menu items from the button's context menu against a test function. + * + * @param {Window} toolWindow + * A window reference. + * @param {Element} button + * The button that will show a context menu when clicked. + * @param {Function} testFn + * A test function that will be ran with the found menu item in the context menu + * as an argument. + */ +async function testMenuItems(toolWindow, button, testFn) { + // The context menu appears only in the top level window, which is different from + // the inner toolWindow. + const win = getTopLevelWindow(toolWindow); + + await new Promise(resolve => { + win.document.addEventListener( + "popupshown", + async () => { + if (button.id === "device-selector") { + const popup = toolWindow.document.querySelector( + "#device-selector-menu" + ); + const menuItems = [...popup.querySelectorAll(".menuitem > .command")]; + + testFn(menuItems); + + if (popup.classList.contains("tooltip-visible")) { + // Close the tooltip explicitly. + button.click(); + await waitUntil(() => !popup.classList.contains("tooltip-visible")); + } + } else { + const popup = win.document.querySelector( + 'menupopup[menu-api="true"]' + ); + const menuItems = [...popup.children]; + + testFn(menuItems); + + popup.hidePopup(); + } + + resolve(); + }, + { once: true } + ); + + button.click(); + }); +} + +const selectDevice = async (ui, value) => { + const browser = ui.getViewportBrowser(); + const waitForDevToolsReload = await watchForDevToolsReload(browser); + + const onDeviceChanged = once(ui, "device-changed"); + await selectMenuItem(ui, "#device-selector", value); + const { reloadTriggered } = await onDeviceChanged; + if (reloadTriggered) { + await waitForDevToolsReload(); + } +}; + +const selectDevicePixelRatio = (ui, value) => + selectMenuItem(ui, "#device-pixel-ratio-menu", `DPR: ${value}`); + +const selectNetworkThrottling = (ui, value) => + Promise.all([ + once(ui, "network-throttling-changed"), + selectMenuItem(ui, "#network-throttling-menu", value), + ]); + +function getSessionHistory(browser) { + if (Services.appinfo.sessionHistoryInParent) { + const browsingContext = browser.browsingContext; + const uri = browsingContext.currentWindowGlobal.documentURI.displaySpec; + const history = browsingContext.sessionHistory; + const body = ContentTask.spawn( + browser, + browsingContext, + function ( + // eslint-disable-next-line no-shadow + browsingContext + ) { + const docShell = browsingContext.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + return docShell.document.body; + } + ); + const { SessionHistory } = ChromeUtils.importESModule( + "resource://gre/modules/sessionstore/SessionHistory.sys.mjs" + ); + return SessionHistory.collectFromParent(uri, body, history); + } + return ContentTask.spawn(browser, null, function () { + const { SessionHistory } = ChromeUtils.importESModule( + "resource://gre/modules/sessionstore/SessionHistory.sys.mjs" + ); + return SessionHistory.collect(docShell); + }); +} + +function getContentSize(ui) { + return spawnViewportTask(ui, {}, () => ({ + width: content.screen.width, + height: content.screen.height, + })); +} + +function getViewportScroll(ui) { + return spawnViewportTask(ui, {}, () => ({ + x: content.scrollX, + y: content.scrollY, + })); +} + +async function waitForPageShow(browser) { + const tab = gBrowser.getTabForBrowser(browser); + const ui = ResponsiveUIManager.getResponsiveUIForTab(tab); + if (ui) { + browser = ui.getViewportBrowser(); + } + info( + "Waiting for pageshow from " + (ui ? "responsive" : "regular") + " browser" + ); + // Need to wait an extra tick after pageshow to ensure everyone is up-to-date, + // hence the waitForTick. + await BrowserTestUtils.waitForContentEvent(browser, "pageshow"); + return waitForTick(); +} + +function waitForViewportScroll(ui) { + return BrowserTestUtils.waitForContentEvent( + ui.getViewportBrowser(), + "scroll", + true + ); +} + +async function back(browser) { + const waitForDevToolsReload = await watchForDevToolsReload(browser); + const onPageShow = waitForPageShow(browser); + + browser.goBack(); + + await onPageShow; + await waitForDevToolsReload(); +} + +async function forward(browser) { + const waitForDevToolsReload = await watchForDevToolsReload(browser); + const onPageShow = waitForPageShow(browser); + + browser.goForward(); + + await onPageShow; + await waitForDevToolsReload(); +} + +function addDeviceForTest(device) { + info(`Adding Test Device "${device.name}" to the list.`); + addDevice(device); + + registerCleanupFunction(() => { + // Note that assertions in cleanup functions are not displayed unless they failed. + ok( + removeDevice(device), + `Removed Test Device "${device.name}" from the list.` + ); + }); +} + +async function waitForClientClose(ui) { + info("Waiting for RDM devtools client to close"); + await ui.commands.client.once("closed"); + info("RDM's devtools client is now closed"); +} + +async function testDevicePixelRatio(ui, expected) { + const dppx = await getViewportDevicePixelRatio(ui); + is(dppx, expected, `devicePixelRatio should be set to ${expected}`); +} + +async function testTouchEventsOverride(ui, expected) { + const { document } = ui.toolWindow; + const touchButton = document.getElementById("touch-simulation-button"); + + const flag = gBrowser.selectedBrowser.browsingContext.touchEventsOverride; + + is( + flag === "enabled", + expected, + `Touch events override should be ${expected ? "enabled" : "disabled"}` + ); + is( + touchButton.classList.contains("checked"), + expected, + `Touch simulation button should be ${expected ? "" : "in"}active.` + ); +} + +function testViewportDeviceMenuLabel(ui, expectedDeviceName) { + info("Test viewport's device select label"); + + const button = ui.toolWindow.document.querySelector("#device-selector"); + ok( + button.textContent.includes(expectedDeviceName), + `Device Select value ${button.textContent} should be: ${expectedDeviceName}` + ); +} + +async function toggleTouchSimulation(ui) { + const { document } = ui.toolWindow; + const browser = ui.getViewportBrowser(); + + const touchButton = document.getElementById("touch-simulation-button"); + const wasChecked = touchButton.classList.contains("checked"); + const onTouchSimulationChanged = once(ui, "touch-simulation-changed"); + const waitForDevToolsReload = await watchForDevToolsReload(browser); + const onTouchButtonStateChanged = waitFor( + () => touchButton.classList.contains("checked") !== wasChecked + ); + + touchButton.click(); + await Promise.all([ + onTouchSimulationChanged, + onTouchButtonStateChanged, + waitForDevToolsReload(), + ]); +} + +async function testUserAgent(ui, expected) { + const { document } = ui.toolWindow; + const userAgentInput = document.getElementById("user-agent-input"); + + if (expected === DEFAULT_UA) { + is(userAgentInput.value, "", "UA input should be empty"); + } else { + is(userAgentInput.value, expected, `UA input should be set to ${expected}`); + } + + await testUserAgentFromBrowser(ui.getViewportBrowser(), expected); +} + +async function testUserAgentFromBrowser(browser, expected) { + const ua = await SpecialPowers.spawn(browser, [], async function () { + return content.navigator.userAgent; + }); + is(ua, expected, `UA should be set to ${expected}`); +} + +function testViewportDimensions(ui, w, h) { + const viewport = ui.viewportElement; + + is( + ui.toolWindow.getComputedStyle(viewport).getPropertyValue("width"), + `${w}px`, + `Viewport should have width of ${w}px` + ); + is( + ui.toolWindow.getComputedStyle(viewport).getPropertyValue("height"), + `${h}px`, + `Viewport should have height of ${h}px` + ); +} + +async function changeUserAgentInput(ui, value) { + const { Simulate } = ui.toolWindow.require( + "resource://devtools/client/shared/vendor/react-dom-test-utils.js" + ); + const { document, store } = ui.toolWindow; + const browser = ui.getViewportBrowser(); + + const userAgentInput = document.getElementById("user-agent-input"); + userAgentInput.value = value; + Simulate.change(userAgentInput); + + const userAgentChanged = waitUntilState( + store, + state => state.ui.userAgent === value + ); + const changed = once(ui, "user-agent-changed"); + + const waitForDevToolsReload = await watchForDevToolsReload(browser); + Simulate.keyUp(userAgentInput, { keyCode: KeyCodes.DOM_VK_RETURN }); + await Promise.all([changed, waitForDevToolsReload(), userAgentChanged]); +} + +/** + * Assuming the device modal is open and the device adder form is shown, this helper + * function adds `device` via the form, saves it, and waits for it to appear in the store. + */ +function addDeviceInModal(ui, device) { + const { Simulate } = ui.toolWindow.require( + "resource://devtools/client/shared/vendor/react-dom-test-utils.js" + ); + const { document, store } = ui.toolWindow; + + const nameInput = document.querySelector("#device-form-name input"); + const [widthInput, heightInput] = document.querySelectorAll( + "#device-form-size input" + ); + const pixelRatioInput = document.querySelector( + "#device-form-pixel-ratio input" + ); + const userAgentInput = document.querySelector( + "#device-form-user-agent input" + ); + const touchInput = document.querySelector("#device-form-touch input"); + + nameInput.value = device.name; + Simulate.change(nameInput); + widthInput.value = device.width; + Simulate.change(widthInput); + Simulate.blur(widthInput); + heightInput.value = device.height; + Simulate.change(heightInput); + Simulate.blur(heightInput); + pixelRatioInput.value = device.pixelRatio; + Simulate.change(pixelRatioInput); + userAgentInput.value = device.userAgent; + Simulate.change(userAgentInput); + touchInput.checked = device.touch; + Simulate.change(touchInput); + + const existingCustomDevices = store.getState().devices.custom.length; + const adderSave = document.querySelector("#device-form-save"); + const saved = waitUntilState( + store, + state => state.devices.custom.length == existingCustomDevices + 1 + ); + Simulate.click(adderSave); + return saved; +} + +async function editDeviceInModal(ui, device, newDevice) { + const { Simulate } = ui.toolWindow.require( + "resource://devtools/client/shared/vendor/react-dom-test-utils.js" + ); + const { document, store } = ui.toolWindow; + + const nameInput = document.querySelector("#device-form-name input"); + const [widthInput, heightInput] = document.querySelectorAll( + "#device-form-size input" + ); + const pixelRatioInput = document.querySelector( + "#device-form-pixel-ratio input" + ); + const userAgentInput = document.querySelector( + "#device-form-user-agent input" + ); + const touchInput = document.querySelector("#device-form-touch input"); + + nameInput.value = newDevice.name; + Simulate.change(nameInput); + widthInput.value = newDevice.width; + Simulate.change(widthInput); + Simulate.blur(widthInput); + heightInput.value = newDevice.height; + Simulate.change(heightInput); + Simulate.blur(heightInput); + pixelRatioInput.value = newDevice.pixelRatio; + Simulate.change(pixelRatioInput); + userAgentInput.value = newDevice.userAgent; + Simulate.change(userAgentInput); + touchInput.checked = newDevice.touch; + Simulate.change(touchInput); + + const existingCustomDevices = store.getState().devices.custom.length; + const formSave = document.querySelector("#device-form-save"); + + const saved = waitUntilState( + store, + state => + state.devices.custom.length == existingCustomDevices && + state.devices.custom.find(({ name }) => name == newDevice.name) && + !state.devices.custom.find(({ name }) => name == device.name) + ); + + // Editing a custom device triggers a "device-change" message. + // Wait for the `device-changed` event to avoid unfinished requests during the + // tests. + const onDeviceChanged = ui.once("device-changed"); + + Simulate.click(formSave); + + await onDeviceChanged; + return saved; +} + +function findMenuItem(menuItems, name) { + return menuItems.find(menuItem => menuItem.textContent.includes(name)); +} + +function reloadOnUAChange(enabled) { + const pref = RELOAD_CONDITION_PREF_PREFIX + "userAgent"; + Services.prefs.setBoolPref(pref, enabled); +} + +function reloadOnTouchChange(enabled) { + const pref = RELOAD_CONDITION_PREF_PREFIX + "touchSimulation"; + Services.prefs.setBoolPref(pref, enabled); +} + +function rotateViewport(ui) { + const { document } = ui.toolWindow; + const rotateButton = document.getElementById("rotate-button"); + rotateButton.click(); +} + +// Call this to switch between on/off support for meta viewports. +async function setTouchAndMetaViewportSupport(ui, value) { + await ui.updateTouchSimulation(value); + info("Reload so the new configuration applies cleanly to the page"); + await reloadBrowser(); + + await promiseContentReflow(ui); +} + +// This function checks that zoom, layout viewport width and height +// are all as expected. +async function testViewportZoomWidthAndHeight(msg, ui, zoom, width, height) { + if (typeof zoom !== "undefined") { + const resolution = await spawnViewportTask(ui, {}, function () { + return content.windowUtils.getResolution(); + }); + is(resolution, zoom, msg + " should have expected zoom."); + } + + if (typeof width !== "undefined" || typeof height !== "undefined") { + const innerSize = await spawnViewportTask(ui, {}, function () { + return { + width: content.innerWidth, + height: content.innerHeight, + }; + }); + if (typeof width !== "undefined") { + is(innerSize.width, width, msg + " should have expected inner width."); + } + if (typeof height !== "undefined") { + is(innerSize.height, height, msg + " should have expected inner height."); + } + } +} + +function promiseContentReflow(ui) { + return SpecialPowers.spawn(ui.getViewportBrowser(), [], async function () { + return new Promise(resolve => { + content.window.requestAnimationFrame(() => { + content.window.requestAnimationFrame(resolve); + }); + }); + }); +} + +// This function returns a promise that will be resolved when the +// RDM zoom has been set and the content has finished rescaling +// to the new size. +async function promiseRDMZoom(ui, browser, zoom) { + const currentZoom = ZoomManager.getZoomForBrowser(browser); + if (currentZoom.toFixed(2) == zoom.toFixed(2)) { + return; + } + + const width = browser.getBoundingClientRect().width; + + ZoomManager.setZoomForBrowser(browser, zoom); + + // RDM resizes the browser as a result of a zoom change, so we wait for that. + // + // This also has the side effect of updating layout which ensures that any + // remote frame dimension update message gets there in time. + await BrowserTestUtils.waitForCondition(function () { + return browser.getBoundingClientRect().width != width; + }); +} + +async function waitForDeviceAndViewportState(ui) { + const { store } = ui.toolWindow; + + // Wait until the viewport has been added and the device list has been loaded + await waitUntilState( + store, + state => + state.viewports.length == 1 && + state.devices.listState == localTypes.loadableState.LOADED + ); +} + +/** + * Wait for the content page to be rendered with the expected pixel ratio. + * + * @param {ResponsiveUI} ui + * The ResponsiveUI instance. + * @param {Integer} expected + * The expected dpr for the content page. + * @param {Object} options + * @param {Boolean} options.waitForTargetConfiguration + * If set to true, the function will wait for the targetConfigurationCommand configuration + * to reflect the ratio that was set. This can be used to prevent pending requests + * to the actor. + */ +async function waitForDevicePixelRatio( + ui, + expected, + { waitForTargetConfiguration } = {} +) { + const dpx = await SpecialPowers.spawn( + ui.getViewportBrowser(), + [{ expected }], + function (args) { + const getDpr = function () { + return content.browsingContext.overrideDPPX || content.devicePixelRatio; + }; + const initial = getDpr(); + info( + `Listening for pixel ratio change ` + + `(current: ${initial}, expected: ${args.expected})` + ); + return new Promise(resolve => { + const mql = content.matchMedia(`(resolution: ${args.expected}dppx)`); + if (mql.matches) { + info(`Ratio already changed to ${args.expected}dppx`); + resolve(getDpr()); + return; + } + mql.addListener(function listener() { + info(`Ratio changed to ${args.expected}dppx`); + mql.removeListener(listener); + resolve(getDpr()); + }); + }); + } + ); + + if (waitForTargetConfiguration) { + // Ensure the configuration was updated so we limit the risk of the client closing before + // the server sent back the result of the updateConfiguration call. + await waitFor(() => { + return ( + ui.commands.targetConfigurationCommand.configuration.overrideDPPX === + expected + ); + }); + } + + return dpx; +} diff --git a/devtools/client/responsive/test/browser/hover.html b/devtools/client/responsive/test/browser/hover.html new file mode 100644 index 0000000000..62037dd442 --- /dev/null +++ b/devtools/client/responsive/test/browser/hover.html @@ -0,0 +1,37 @@ +<!doctype html> +<meta charset="UTF-8"> +<style> + button { + background-color: rgb(255, 0, 0); + color: black; + } + + button:hover { + background-color: rgb(0, 0, 0); + color: white; + } + + .drop-down-menu { + height: 100px; + width: 100px; + } + + .drop-down-menu .menu-items-list { + display: none; + } + + .drop-down-menu:hover .menu-items-list { + display: block; + } +</style> +<div> + <button>Test Button</button> + <div class="drop-down-menu"> + <div class="menu-title">Test Menu</div> + <ul class="menu-items-list"> + <li class="item-one">One</li> + <li class="item-two">Two</li> + <li class="item-three">Three</li> + </ul> + </div> +</div> diff --git a/devtools/client/responsive/test/browser/page_style.html b/devtools/client/responsive/test/browser/page_style.html new file mode 100644 index 0000000000..d6adad8856 --- /dev/null +++ b/devtools/client/responsive/test/browser/page_style.html @@ -0,0 +1,7 @@ +<style> +body{ + color: red; +} +</style> + +Hello RDM diff --git a/devtools/client/responsive/test/browser/sjs_redirection.sjs b/devtools/client/responsive/test/browser/sjs_redirection.sjs new file mode 100644 index 0000000000..9bd88a1f8e --- /dev/null +++ b/devtools/client/responsive/test/browser/sjs_redirection.sjs @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function handleRequest(request, response) { + const query = new URLSearchParams(request.queryString); + + const requestUserAgent = request.getHeader("user-agent"); + const redirectRequestUserAgent = getState( + "redirect-request-user-agent-header" + ); + + const shouldRedirect = query.has("redirect"); + if (shouldRedirect) { + response.setStatusLine(request.httpVersion, 302, "Found"); + setState("redirect-request-user-agent-header", requestUserAgent); + response.setHeader( + "Location", + `http://${request.host}${request.path}?redirected` + ); + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(` + <script> + globalThis.requestUserAgent = ${JSON.stringify(requestUserAgent)}; + globalThis.redirectRequestUserAgent = ${JSON.stringify( + redirectRequestUserAgent + )}; + </script> + ${requestUserAgent} + `); + } +} diff --git a/devtools/client/responsive/test/browser/touch.html b/devtools/client/responsive/test/browser/touch.html new file mode 100644 index 0000000000..eed55426bd --- /dev/null +++ b/devtools/client/responsive/test/browser/touch.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> + +<meta charset="utf-8" /> +<meta name="viewport" /> +<title>test</title> + + +<style> + div { + border: 1px solid red; + width: 100px; height: 100px; + } +</style> + +<div data-is-delay="false"></div> + +<script type="text/javascript"> + "use strict"; + const div = document.querySelector("div"); + let initX, initY; + + div.style.transform = "none"; + div.style.backgroundColor = ""; + + div.addEventListener("touchstart", function (evt) { + const touch = evt.changedTouches[0]; + initX = touch.pageX; + initY = touch.pageY; + }, true); + + div.addEventListener("touchmove", function (evt) { + const touch = evt.changedTouches[0]; + const deltaX = touch.pageX - initX; + const deltaY = touch.pageY - initY; + div.style.transform = "translate(" + deltaX + "px, " + deltaY + "px)"; + }, true); + + div.addEventListener("touchend", function (evt) { + if (!evt.touches.length) { + div.style.transform = "none"; + } + }, true); + + div.addEventListener("mouseenter", function (evt) { + div.style.backgroundColor = "red"; + }, true); + div.addEventListener("mouseover", function(evt) { + div.style.backgroundColor = "red"; + }, true); + + div.addEventListener("mouseout", function (evt) { + div.style.backgroundColor = "blue"; + }, true); + + div.addEventListener("mouseleave", function (evt) { + div.style.backgroundColor = "blue"; + }, true); + + div.addEventListener("mousedown", null, true); + + div.addEventListener("mousemove", null, true); + + div.addEventListener("mouseup", null, true); + + div.addEventListener("click", null, true); +</script> diff --git a/devtools/client/responsive/test/browser/touch_event_bubbles.html b/devtools/client/responsive/test/browser/touch_event_bubbles.html new file mode 100644 index 0000000000..9e8decbc54 --- /dev/null +++ b/devtools/client/responsive/test/browser/touch_event_bubbles.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> + +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>Simulated touch events should bubble</title> + +<style> + span { + background-color: red; + height: 100px; + width: 100px; + } +</style> + +<div id="outer"> + <div id="inner"> + <span>Hello</span> + </div> +</div> diff --git a/devtools/client/responsive/test/browser/touch_event_target.html b/devtools/client/responsive/test/browser/touch_event_target.html new file mode 100644 index 0000000000..bac3cfbf1e --- /dev/null +++ b/devtools/client/responsive/test/browser/touch_event_target.html @@ -0,0 +1,18 @@ +<script> +'use strict'; + +document.documentElement.onclick = (e) => { + window.top.postMessage({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY }, "*"); +}; + +window.onload = () => { + window.top.postMessage({ ready: true }, "*"); +} +</script> +<style> +body { + margin: 0; + background: green; +} +</style> +<body></body> diff --git a/devtools/client/responsive/test/xpcshell/.eslintrc.js b/devtools/client/responsive/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..c447323956 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for xpcshell. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/responsive/test/xpcshell/head.js b/devtools/client/responsive/test/xpcshell/head.js new file mode 100644 index 0000000000..ee4933416c --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/head.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const Store = require("resource://devtools/client/responsive/store.js"); + +const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); + +Services.prefs.setBoolPref("devtools.testing", true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.testing"); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_add_device.js b/devtools/client/responsive/test/xpcshell/test_add_device.js new file mode 100644 index 0000000000..cea6e30989 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_add_device.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding a new device. + +const { + addDevice, + addDeviceType, +} = require("resource://devtools/client/responsive/actions/devices.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + const device = { + name: "Firefox OS Flame", + width: 320, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "fxos", + }; + + dispatch(addDeviceType("phones")); + dispatch(addDevice(device, "phones")); + + equal(getState().devices.phones.length, 1, "Correct number of phones"); + ok( + getState().devices.phones.includes(device), + "Device phone list contains Firefox OS Flame" + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_add_device_type.js b/devtools/client/responsive/test/xpcshell/test_add_device_type.js new file mode 100644 index 0000000000..e824574380 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_add_device_type.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding a new device type. + +const { + addDeviceType, +} = require("resource://devtools/client/responsive/actions/devices.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(addDeviceType("phones")); + + equal(getState().devices.types.length, 1, "Correct number of device types"); + equal( + getState().devices.phones.length, + 0, + "Defaults to an empty array of phones" + ); + ok( + getState().devices.types.includes("phones"), + "Device types contain phones" + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_add_viewport.js b/devtools/client/responsive/test/xpcshell/test_add_viewport.js new file mode 100644 index 0000000000..c9a6d9500b --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_add_viewport.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding viewports to the page. + +const { + addViewport, +} = require("resource://devtools/client/responsive/actions/viewports.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().viewports.length, 0, "Defaults to no viewpots at startup"); + + dispatch(addViewport()); + equal(getState().viewports.length, 1, "One viewport total"); + + // For the moment, there can be at most one viewport. + dispatch(addViewport()); + equal(getState().viewports.length, 1, "One viewport total, again"); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_change_device.js b/devtools/client/responsive/test/xpcshell/test_change_device.js new file mode 100644 index 0000000000..f5e3ab7f73 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_change_device.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test changing the viewport device. + +const { + addDevice, + addDeviceType, +} = require("resource://devtools/client/responsive/actions/devices.js"); +const { + addViewport, + changeDevice, +} = require("resource://devtools/client/responsive/actions/viewports.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(addDeviceType("phones")); + dispatch( + addDevice( + { + name: "Firefox OS Flame", + width: 320, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "fxos", + }, + "phones" + ) + ); + dispatch(addViewport()); + + let viewport = getState().viewports[0]; + equal(viewport.device, "", "Default device is unselected"); + + dispatch(changeDevice(0, "Firefox OS Flame", "phones")); + + viewport = getState().viewports[0]; + equal( + viewport.device, + "Firefox OS Flame", + "Changed to Firefox OS Flame device" + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_change_display_pixel_ratio.js b/devtools/client/responsive/test/xpcshell/test_change_display_pixel_ratio.js new file mode 100644 index 0000000000..7cc2604b7b --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_change_display_pixel_ratio.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test changing the display pixel ratio. + +const { + changeDisplayPixelRatio, +} = require("resource://devtools/client/responsive/actions/ui.js"); + +const NEW_PIXEL_RATIO = 5.5; + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().ui.displayPixelRatio, 0, "Defaults to 0 at startup"); + + dispatch(changeDisplayPixelRatio(NEW_PIXEL_RATIO)); + equal( + getState().ui.displayPixelRatio, + NEW_PIXEL_RATIO, + `Display Pixel Ratio changed to ${NEW_PIXEL_RATIO}` + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_change_network_throttling.js b/devtools/client/responsive/test/xpcshell/test_change_network_throttling.js new file mode 100644 index 0000000000..eb762682d8 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_change_network_throttling.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test changing the network throttling state + +const { + changeNetworkThrottling, +} = require("resource://devtools/client/shared/components/throttling/actions.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + ok( + !getState().networkThrottling.enabled, + "Network throttling is disabled by default." + ); + equal( + getState().networkThrottling.profile, + "", + "Network throttling profile is empty by default." + ); + + dispatch(changeNetworkThrottling(true, "Bob")); + + ok(getState().networkThrottling.enabled, "Network throttling is enabled."); + equal( + getState().networkThrottling.profile, + "Bob", + "Network throttling profile is set." + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_change_pixel_ratio.js b/devtools/client/responsive/test/xpcshell/test_change_pixel_ratio.js new file mode 100644 index 0000000000..d462cf39cc --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_change_pixel_ratio.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test changing the viewport pixel ratio. + +const { + addViewport, + changePixelRatio, +} = require("resource://devtools/client/responsive/actions/viewports.js"); +const NEW_PIXEL_RATIO = 5.5; + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(addViewport()); + dispatch(changePixelRatio(0, NEW_PIXEL_RATIO)); + + const viewport = getState().viewports[0]; + equal( + viewport.pixelRatio, + NEW_PIXEL_RATIO, + `Viewport's pixel ratio changed to ${NEW_PIXEL_RATIO}` + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_change_user_agent.js b/devtools/client/responsive/test/xpcshell/test_change_user_agent.js new file mode 100644 index 0000000000..e92d3f110f --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_change_user_agent.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test changing the user agent. + +const { + changeUserAgent, +} = require("resource://devtools/client/responsive/actions/ui.js"); + +const NEW_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"; + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + equal(getState().ui.userAgent, "", "User agent is empty by default."); + + dispatch(changeUserAgent(NEW_USER_AGENT)); + equal( + getState().ui.userAgent, + NEW_USER_AGENT, + `User Agent changed to ${NEW_USER_AGENT}` + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_resize_viewport.js b/devtools/client/responsive/test/xpcshell/test_resize_viewport.js new file mode 100644 index 0000000000..83780ad195 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_resize_viewport.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test resizing the viewport. + +const { + addViewport, + resizeViewport, +} = require("resource://devtools/client/responsive/actions/viewports.js"); +const { + toggleTouchSimulation, +} = require("resource://devtools/client/responsive/actions/ui.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(addViewport()); + dispatch(resizeViewport(0, 500, 500)); + + let viewport = getState().viewports[0]; + equal(viewport.width, 500, "Resized width of 500"); + equal(viewport.height, 500, "Resized height of 500"); + + dispatch(toggleTouchSimulation(true)); + dispatch(resizeViewport(0, 400, 400)); + + viewport = getState().viewports[0]; + equal(viewport.width, 400, "Resized width of 400 (with touch simulation on)"); + equal( + viewport.height, + 400, + "Resized height of 400 (with touch simulation on)" + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_rotate_viewport.js b/devtools/client/responsive/test/xpcshell/test_rotate_viewport.js new file mode 100644 index 0000000000..7d34daa39f --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_rotate_viewport.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test rotating the viewport. + +const { + addViewport, + rotateViewport, +} = require("resource://devtools/client/responsive/actions/viewports.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + dispatch(addViewport()); + + let viewport = getState().viewports[0]; + equal(viewport.width, 320, "Default width of 320"); + equal(viewport.height, 480, "Default height of 480"); + + dispatch(rotateViewport(0)); + viewport = getState().viewports[0]; + equal(viewport.width, 480, "Rotated width of 480"); + equal(viewport.height, 320, "Rotated height of 320"); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_ua_parser.js b/devtools/client/responsive/test/xpcshell/test_ua_parser.js new file mode 100644 index 0000000000..152a504498 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_ua_parser.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for user agent parser. + +const { + parseUserAgent, +} = require("resource://devtools/client/responsive/utils/ua.js"); + +const TEST_DATA = [ + { + userAgent: + "Mozilla/5.0 (Android 4.4.3; Tablet; rv:41.0) Gecko/41.0 Firefox/41.0", + expectedBrowser: { name: "Firefox", version: "41" }, + expectedOS: { name: "Android", version: "4.4.3" }, + }, + { + userAgent: + "Mozilla/5.0 (Android 8.0.0; Mobile; rv:70.0) Gecko/70.0 Firefox/70.0", + expectedBrowser: { name: "Firefox", version: "70" }, + expectedOS: { name: "Android", version: "8" }, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/70.1", + expectedBrowser: { name: "Firefox", version: "70.1" }, + expectedOS: { name: "Windows NT", version: "6.1" }, + }, + { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:61.0) Gecko/20100101 Firefox/70.0", + expectedBrowser: { name: "Firefox", version: "70" }, + expectedOS: { name: "Mac OSX", version: "10.13" }, + }, + { + userAgent: + "Mozilla/5.0 (X11; Linux i586; rv:31.0) Gecko/20100101 Firefox/70.0", + expectedBrowser: { name: "Firefox", version: "70" }, + expectedOS: { name: "Linux", version: null }, + }, + { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/13.2b11866 Mobile/16A366 Safari/605.1.15", + expectedBrowser: { name: "Firefox", version: "13.2b11866" }, + expectedOS: { name: "iOS", version: "12" }, + }, + { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/12.0 Mobile/15A372 Safari/604.1", + expectedBrowser: { name: "Safari", version: "12" }, + expectedOS: { name: "iOS", version: "12" }, + }, + { + userAgent: + "Mozilla/5.0 (iPad; CPU OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1", + expectedBrowser: { name: "Safari", version: "14.1" }, + expectedOS: { name: "iPadOS", version: "14.7.1" }, + }, + { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.8 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7", + expectedBrowser: { name: "Safari", version: "9.1" }, + expectedOS: { name: "Mac OSX", version: "10.11.6" }, + }, + { + userAgent: + "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.87 Mobile Safari/537.36", + expectedBrowser: { name: "Chrome", version: "67" }, + expectedOS: { name: "Android", version: "8" }, + }, + { + userAgent: + "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36", + expectedBrowser: { name: "Chrome", version: "87" }, + expectedOS: { name: "Android", version: "11" }, + }, + { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.105 Mobile/15E148 Safari/605.1", + expectedBrowser: { name: "Chrome", version: "69" }, + expectedOS: { name: "iOS", version: "12" }, + }, + { + userAgent: + "Mozilla/5.0 (X11; CrOS x86_64 11895.118.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.159 Safari/537.36", + expectedBrowser: { name: "Chrome", version: "74" }, + expectedOS: { name: "Chrome OS", version: "11895.118" }, + }, + { + userAgent: + "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263", + expectedBrowser: { name: "Edge", version: "14.14263" }, + expectedOS: { name: "Windows Phone", version: "10.0" }, + }, + { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 OPR/43.0.2442.991", + expectedBrowser: { name: "Opera", version: "43" }, + expectedOS: { name: "Windows NT", version: "10.0" }, + }, + { + userAgent: + "Opera/9.80 (Linux armv7l) Presto/2.12.407 Version/12.51 , D50u-D1-UHD/V1.5.16-UHD (Vizio, D50u-D1, Wireless)", + expectedBrowser: { name: "Opera", version: "9.80" }, + expectedOS: { name: "Linux", version: null }, + }, + { + userAgent: + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)", + expectedBrowser: { name: "IE", version: "6" }, + expectedOS: { name: "Windows NT", version: "5.1" }, + }, + { + userAgent: "1080p Full HD Television", + expectedBrowser: null, + expectedOS: null, + }, +]; + +add_task(async function () { + for (const { userAgent, expectedBrowser, expectedOS } of TEST_DATA) { + info(`Test for ${userAgent}`); + const { browser, os } = parseUserAgent(userAgent); + deepEqual(browser, expectedBrowser, "Parsed browser is correct"); + deepEqual(os, expectedOS, "Parsed OS is correct"); + } +}); diff --git a/devtools/client/responsive/test/xpcshell/test_update_device_displayed.js b/devtools/client/responsive/test/xpcshell/test_update_device_displayed.js new file mode 100644 index 0000000000..2d04b546ba --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_update_device_displayed.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test updating the device `displayed` property + +const { + addDevice, + addDeviceType, + updateDeviceDisplayed, +} = require("resource://devtools/client/responsive/actions/devices.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + const device = { + name: "Firefox OS Flame", + width: 320, + height: 570, + pixelRatio: 1.5, + userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + touch: true, + firefoxOS: true, + os: "fxos", + }; + + dispatch(addDeviceType("phones")); + dispatch(addDevice(device, "phones")); + dispatch(updateDeviceDisplayed(device, "phones", true)); + + equal(getState().devices.phones.length, 1, "Correct number of phones"); + ok( + getState().devices.phones[0].displayed, + "Device phone list contains enabled Firefox OS Flame" + ); +}); diff --git a/devtools/client/responsive/test/xpcshell/test_update_touch_simulation_enabled.js b/devtools/client/responsive/test/xpcshell/test_update_touch_simulation_enabled.js new file mode 100644 index 0000000000..e96c4b3a86 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/test_update_touch_simulation_enabled.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test updating the touch simulation `enabled` property + +const { + toggleTouchSimulation, +} = require("resource://devtools/client/responsive/actions/ui.js"); + +add_task(async function () { + const store = Store(); + const { getState, dispatch } = store; + + ok( + !getState().ui.touchSimulationEnabled, + "Touch simulation is disabled by default." + ); + + dispatch(toggleTouchSimulation(true)); + + ok(getState().ui.touchSimulationEnabled, "Touch simulation is enabled."); +}); diff --git a/devtools/client/responsive/test/xpcshell/xpcshell.toml b/devtools/client/responsive/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..10f33b82b7 --- /dev/null +++ b/devtools/client/responsive/test/xpcshell/xpcshell.toml @@ -0,0 +1,30 @@ +[DEFAULT] +tags = "devtools" +head = "head.js" +firefox-appdir = "browser" + +["test_add_device.js"] + +["test_add_device_type.js"] + +["test_add_viewport.js"] + +["test_change_device.js"] + +["test_change_display_pixel_ratio.js"] + +["test_change_network_throttling.js"] + +["test_change_pixel_ratio.js"] + +["test_change_user_agent.js"] + +["test_resize_viewport.js"] + +["test_rotate_viewport.js"] + +["test_ua_parser.js"] + +["test_update_device_displayed.js"] + +["test_update_touch_simulation_enabled.js"] diff --git a/devtools/client/responsive/toolbar.xhtml b/devtools/client/responsive/toolbar.xhtml new file mode 100644 index 0000000000..a977119a41 --- /dev/null +++ b/devtools/client/responsive/toolbar.xhtml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <link + rel="stylesheet" + type="text/css" + href="chrome://devtools/content/responsive/index.css" + /> + <script src="chrome://devtools/content/shared/theme-switching.js"></script> + <script src="resource://devtools/client/responsive/index.js"></script> + </head> + <body role="application"> + <div id="root"></div> + </body> +</html> diff --git a/devtools/client/responsive/types.js b/devtools/client/responsive/types.js new file mode 100644 index 0000000000..561f7803f6 --- /dev/null +++ b/devtools/client/responsive/types.js @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { createEnum } = require("resource://devtools/client/shared/enum.js"); + +// React PropTypes are used to describe the expected "shape" of various common +// objects that get passed down as props to components. + +/* ENUMS */ + +/** + * An enum containing the possible states for loadable things. + */ +exports.loadableState = createEnum([ + "INITIALIZED", + "LOADING", + "LOADED", + "ERROR", +]); + +/* DEVICE */ + +/** + * A single device that can be displayed in the viewport. + */ +const device = { + // The name of the device + name: PropTypes.string, + + // The width of the device + width: PropTypes.number, + + // The height of the device + height: PropTypes.number, + + // The pixel ratio of the device + pixelRatio: PropTypes.number, + + // The user agent string of the device + userAgent: PropTypes.string, + + // Whether or not it is a touch device + touch: PropTypes.bool, + + // The operating system of the device + os: PropTypes.string, + + // Whether or not the device is displayed in the device selector + displayed: PropTypes.bool, +}; + +/** + * A list of devices and their types that can be displayed in the viewport. + */ +exports.devices = { + // An array of device types + types: PropTypes.arrayOf(PropTypes.string), + + // An array of phone devices + phones: PropTypes.arrayOf(PropTypes.shape(device)), + + // An array of tablet devices + tablets: PropTypes.arrayOf(PropTypes.shape(device)), + + // An array of laptop devices + laptops: PropTypes.arrayOf(PropTypes.shape(device)), + + // An array of television devices + televisions: PropTypes.arrayOf(PropTypes.shape(device)), + + // An array of console devices + consoles: PropTypes.arrayOf(PropTypes.shape(device)), + + // An array of watch devices + watches: PropTypes.arrayOf(PropTypes.shape(device)), + + // Whether or not the device modal is open + isModalOpen: PropTypes.bool, + + // Viewport id that triggered the modal to open + modalOpenedFromViewport: PropTypes.number, + + // Device list state, possible values are exported above in an enum + listState: PropTypes.oneOf(Object.keys(exports.loadableState)), +}; + +/* VIEWPORT */ + +/** + * Network throttling state for a given viewport. + */ +exports.networkThrottling = { + // Whether or not network throttling is enabled + enabled: PropTypes.bool, + + // Name of the selected throttling profile + profile: PropTypes.string, +}; + +/** + * A single viewport displaying a document. + */ +exports.viewport = { + // The id of the viewport + id: PropTypes.number, + + // The currently selected device applied to the viewport + device: PropTypes.string, + + // The currently selected device type applied to the viewport + deviceType: PropTypes.string, + + // The width of the viewport + width: PropTypes.number, + + // The height of the viewport + height: PropTypes.number, + + // The device pixel ratio of the viewport + pixelRatio: PropTypes.number, + + // The user context (container) ID for the viewport + // Defaults to 0 meaning the default context + userContextId: PropTypes.number, +}; + +/* ACTIONS IN PROGRESS */ + +/** + * The progression of the screenshot. + */ +exports.screenshot = { + // Whether screenshot capturing is in progress + isCapturing: PropTypes.bool, +}; diff --git a/devtools/client/responsive/ui.js b/devtools/client/responsive/ui.js new file mode 100644 index 0000000000..713158d654 --- /dev/null +++ b/devtools/client/responsive/ui.js @@ -0,0 +1,1075 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getOrientation, +} = require("resource://devtools/client/responsive/utils/orientation.js"); +const Constants = require("resource://devtools/client/responsive/constants.js"); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +loader.lazyRequireGetter( + this, + "throttlingProfiles", + "resource://devtools/client/shared/components/throttling/profiles.js" +); +loader.lazyRequireGetter( + this, + "message", + "resource://devtools/client/responsive/utils/message.js" +); +loader.lazyRequireGetter( + this, + "showNotification", + "resource://devtools/client/responsive/utils/notification.js", + true +); +loader.lazyRequireGetter( + this, + "PriorityLevels", + "resource://devtools/client/shared/components/NotificationBox.js", + true +); +loader.lazyRequireGetter( + this, + "l10n", + "resource://devtools/client/responsive/utils/l10n.js" +); +loader.lazyRequireGetter( + this, + "asyncStorage", + "resource://devtools/shared/async-storage.js" +); +loader.lazyRequireGetter( + this, + "captureAndSaveScreenshot", + "resource://devtools/client/shared/screenshot.js", + true +); + +const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions."; +const RELOAD_NOTIFICATION_PREF = + "devtools.responsive.reloadNotification.enabled"; + +function debug(msg) { + // console.log(`RDM manager: ${msg}`); +} + +/** + * ResponsiveUI manages the responsive design tool for a specific tab. The + * actual tool itself lives in a separate chrome:// document that is loaded into + * the tab upon opening responsive design. This object acts a helper to + * integrate the tool into the surrounding browser UI as needed. + */ +class ResponsiveUI { + /** + * @param {ResponsiveUIManager} manager + * The ResponsiveUIManager instance. + * @param {ChromeWindow} window + * The main browser chrome window (that holds many tabs). + * @param {Tab} tab + * The specific browser <tab> element this responsive instance is for. + */ + constructor(manager, window, tab) { + this.manager = manager; + // The main browser chrome window (that holds many tabs). + this.browserWindow = window; + // The specific browser tab this responsive instance is for. + this.tab = tab; + + // Flag set when destruction has begun. + this.destroying = false; + // Flag set when destruction has ended. + this.destroyed = false; + // The iframe containing the RDM UI. + this.rdmFrame = null; + + // Bind callbacks for resizers. + this.onResizeDrag = this.onResizeDrag.bind(this); + this.onResizeStart = this.onResizeStart.bind(this); + this.onResizeStop = this.onResizeStop.bind(this); + + this.onTargetAvailable = this.onTargetAvailable.bind(this); + + this.networkFront = null; + // Promise resovled when the UI init has completed. + this.inited = this.init(); + + EventEmitter.decorate(this); + } + + get toolWindow() { + return this.rdmFrame.contentWindow; + } + + get docShell() { + return this.toolWindow.docShell; + } + + get viewportElement() { + return this.browserStackEl.querySelector("browser"); + } + + get currentTarget() { + return this.commands.targetCommand.targetFront; + } + + get watcherFront() { + return this.resourceCommand.watcherFront; + } + + /** + * Open RDM while preserving the state of the page. + */ + async init() { + debug("Init start"); + + this.initRDMFrame(); + + // Hide the browser content temporarily while things move around to avoid displaying + // strange intermediate states. + this.hideBrowserUI(); + + // Watch for tab close and window close so we can clean up RDM synchronously + this.tab.addEventListener("TabClose", this); + this.browserWindow.addEventListener("unload", this); + this.rdmFrame.contentWindow.addEventListener("message", this); + + this.tab.linkedBrowser.enterResponsiveMode(); + + // Listen to FullZoomChange events coming from the browser window, + // so that we can zoom the size of the viewport by the same amount. + this.browserWindow.addEventListener("FullZoomChange", this); + + // Get the protocol ready to speak with responsive emulation actor + debug("Wait until RDP server connect"); + await this.connectToServer(); + + // Restore the previous UI state. + await this.restoreUIState(); + + // Show the browser UI now that its state is ready. + this.showBrowserUI(); + + // Non-blocking message to tool UI to start any delayed init activities + message.post(this.toolWindow, "post-init"); + + debug("Init done"); + } + + /** + * Initialize the RDM iframe inside of the browser document. + */ + initRDMFrame() { + const { document: doc, gBrowser } = this.browserWindow; + const rdmFrame = doc.createElement("iframe"); + rdmFrame.src = "chrome://devtools/content/responsive/toolbar.xhtml"; + rdmFrame.classList.add("rdm-toolbar"); + + // Create resizer handlers + const resizeHandle = doc.createElement("div"); + resizeHandle.classList.add( + "rdm-viewport-resize-handle", + "viewport-resize-handle" + ); + const resizeHandleX = doc.createElement("div"); + resizeHandleX.classList.add( + "rdm-viewport-resize-handle", + "viewport-horizontal-resize-handle" + ); + const resizeHandleY = doc.createElement("div"); + resizeHandleY.classList.add( + "rdm-viewport-resize-handle", + "viewport-vertical-resize-handle" + ); + + this.browserContainerEl = gBrowser.getBrowserContainer( + gBrowser.getBrowserForTab(this.tab) + ); + this.browserStackEl = + this.browserContainerEl.querySelector(".browserStack"); + + this.browserContainerEl.classList.add("responsive-mode"); + + // Prepend the RDM iframe inside of the current tab's browser container. + this.browserContainerEl.prepend(rdmFrame); + + this.browserStackEl.append(resizeHandle); + this.browserStackEl.append(resizeHandleX); + this.browserStackEl.append(resizeHandleY); + + // Wait for the frame script to be loaded. + message.wait(rdmFrame.contentWindow, "script-init").then(async () => { + // Notify the frame window that the Resposnive UI manager has begun initializing. + // At this point, we can render our React content inside the frame. + message.post(rdmFrame.contentWindow, "init"); + // Wait for the tools to be rendered above the content. The frame script will + // then dispatch the necessary actions to the Redux store to give the toolbar the + // state it needs. + message.wait(rdmFrame.contentWindow, "init:done").then(() => { + rdmFrame.contentWindow.addInitialViewport({ + userContextId: this.tab.userContextId, + }); + }); + }); + + this.rdmFrame = rdmFrame; + + this.resizeHandle = resizeHandle; + this.resizeHandle.addEventListener("mousedown", this.onResizeStart); + + this.resizeHandleX = resizeHandleX; + this.resizeHandleX.addEventListener("mousedown", this.onResizeStart); + + this.resizeHandleY = resizeHandleY; + this.resizeHandleY.addEventListener("mousedown", this.onResizeStart); + + this.resizeToolbarObserver = new this.browserWindow.ResizeObserver( + entries => { + for (const entry of entries) { + // If the toolbar needs extra space for the UA input, then set a class + // that will accomodate its height. We should also make sure to keep + // the width value we're toggling against in sync with the media-query + // in devtools/client/responsive/index.css + this.rdmFrame.classList.toggle( + "accomodate-ua", + entry.contentBoxSize[0].inlineSize < 520 + ); + } + } + ); + + this.resizeToolbarObserver.observe(this.browserStackEl); + } + + /** + * Close RDM and restore page content back into a regular tab. + * + * @param object + * Destroy options, which currently includes a `reason` string. + * @return boolean + * Whether this call is actually destroying. False means destruction + * was already in progress. + */ + async destroy(options) { + if (this.destroying) { + return false; + } + this.destroying = true; + + // If our tab is about to be closed, there's not enough time to exit + // gracefully, but that shouldn't be a problem since the tab will go away. + // So, skip any waiting when we're about to close the tab. + const isTabDestroyed = + !this.tab.linkedBrowser || this.responsiveFront?.isDestroyed(); + const isWindowClosing = options?.reason === "unload" || isTabDestroyed; + const isTabContentDestroying = + isWindowClosing || options?.reason === "TabClose"; + + // Ensure init has finished before starting destroy + if (!isTabContentDestroying) { + await this.inited; + + // Restore screen orientation of physical device. + await Promise.all([ + this.updateScreenOrientation("landscape-primary", 0), + this.updateMaxTouchPointsEnabled(false), + ]); + + // Hide browser UI to avoid displaying weird intermediate states while closing. + this.hideBrowserUI(); + + // Resseting the throtting needs to be done before the + // network events watching is stopped. + await this.updateNetworkThrottling(); + } + + this.tab.removeEventListener("TabClose", this); + this.browserWindow.removeEventListener("unload", this); + this.tab.linkedBrowser.leaveResponsiveMode(); + + this.browserWindow.removeEventListener("FullZoomChange", this); + this.rdmFrame.contentWindow.removeEventListener("message", this); + + // Remove observers on the stack. + this.resizeToolbarObserver.unobserve(this.browserStackEl); + + // Cleanup the frame content before disconnecting the frame element. + this.rdmFrame.contentWindow.destroy(); + + this.rdmFrame.remove(); + + // Clean up resize handlers + this.resizeHandle.remove(); + this.resizeHandleX.remove(); + this.resizeHandleY.remove(); + + this.browserContainerEl.classList.remove("responsive-mode"); + this.browserStackEl.style.removeProperty("--rdm-width"); + this.browserStackEl.style.removeProperty("--rdm-height"); + this.browserStackEl.style.removeProperty("--rdm-zoom"); + + // Ensure the tab is reloaded if required when exiting RDM so that no emulated + // settings are left in a customized state. + if (!isTabContentDestroying) { + let reloadNeeded = false; + await this.updateDPPX(null); + reloadNeeded |= + (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); + if (reloadNeeded) { + await this.reloadBrowser(); + } + + // Unwatch targets & resources as the last step. If we are not waching for + // any resource & target anymore, the JSWindowActors will be unregistered + // which will trigger an early destruction of the RDM target, before we + // could finalize the cleanup. + this.commands.targetCommand.unwatchTargets({ + types: [this.commands.targetCommand.TYPES.FRAME], + onAvailable: this.onTargetAvailable, + }); + + this.resourceCommand.unwatchResources( + [this.resourceCommand.TYPES.NETWORK_EVENT], + { onAvailable: this.onNetworkResourceAvailable } + ); + + this.commands.targetCommand.destroy(); + } + + // Show the browser UI now. + this.showBrowserUI(); + + // Destroy local state + this.browserContainerEl = null; + this.browserStackEl = null; + this.browserWindow = null; + this.tab = null; + this.inited = null; + this.rdmFrame = null; + this.resizeHandle = null; + this.resizeHandleX = null; + this.resizeHandleY = null; + this.resizeToolbarObserver = null; + + // Destroying the commands will close the devtools client used to speak with responsive emulation actor. + // The actor handles clearing any overrides itself, so it's not necessary to clear + // anything on shutdown client side. + const commandsDestroyed = this.commands.destroy(); + if (!isTabContentDestroying) { + await commandsDestroyed; + } + this.commands = this.responsiveFront = null; + this.destroyed = true; + + return true; + } + + async connectToServer() { + this.commands = await CommandsFactory.forTab(this.tab); + this.resourceCommand = this.commands.resourceCommand; + + await this.commands.targetCommand.startListening(); + + await this.commands.targetCommand.watchTargets({ + types: [this.commands.targetCommand.TYPES.FRAME], + onAvailable: this.onTargetAvailable, + }); + + // To support network throttling the resource command + // needs to be watching for network resources. + await this.resourceCommand.watchResources( + [this.resourceCommand.TYPES.NETWORK_EVENT], + { onAvailable: this.onNetworkResourceAvailable } + ); + + this.networkFront = await this.watcherFront.getNetworkParentActor(); + } + + /** + * Show one-time notification about reloads for responsive emulation. + */ + showReloadNotification() { + if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) { + showNotification(this.browserWindow, this.tab, { + msg: l10n.getFormatStr("responsive.reloadNotification.description2"), + }); + Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false); + } + } + + reloadOnChange(id) { + this.showReloadNotification(); + const pref = RELOAD_CONDITION_PREF_PREFIX + id; + return Services.prefs.getBoolPref(pref, false); + } + + hideBrowserUI() { + this.tab.linkedBrowser.style.visibility = "hidden"; + this.resizeHandle.style.visibility = "hidden"; + } + + showBrowserUI() { + this.tab.linkedBrowser.style.removeProperty("visibility"); + this.resizeHandle.style.removeProperty("visibility"); + } + + handleEvent(event) { + const { browserWindow, tab } = this; + + switch (event.type) { + case "message": + this.handleMessage(event); + break; + case "FullZoomChange": + // Get the current device size and update to that size, which + // will pick up changes to the zoom. + const { width, height } = this.getViewportSize(); + this.updateViewportSize(width, height); + break; + case "TabClose": + case "unload": + this.manager.closeIfNeeded(browserWindow, tab, { + reason: event.type, + }); + break; + } + } + + handleMessage(event) { + if (event.origin !== "chrome://devtools") { + return; + } + + switch (event.data.type) { + case "change-device": + this.onChangeDevice(event); + break; + case "change-network-throttling": + this.onChangeNetworkThrottling(event); + break; + case "change-pixel-ratio": + this.onChangePixelRatio(event); + break; + case "change-touch-simulation": + this.onChangeTouchSimulation(event); + break; + case "change-user-agent": + this.onChangeUserAgent(event); + break; + case "exit": + this.onExit(); + break; + case "remove-device-association": + this.onRemoveDeviceAssociation(); + break; + case "viewport-orientation-change": + this.onRotateViewport(event); + break; + case "viewport-resize": + this.onResizeViewport(event); + break; + case "screenshot": + this.onScreenshot(); + break; + case "toggle-left-alignment": + this.onToggleLeftAlignment(event); + break; + case "update-device-modal": + this.onUpdateDeviceModal(event); + break; + } + } + + async onChangeDevice(event) { + const { pixelRatio, touch, userAgent } = event.data.device; + let reloadNeeded = false; + await this.updateDPPX(pixelRatio); + + // Get the orientation values of the device we are changing to and update. + const { device, viewport } = event.data; + const { type, angle } = getOrientation(device, viewport); + await this.updateScreenOrientation(type, angle); + await this.updateMaxTouchPointsEnabled(touch); + + reloadNeeded |= + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(touch, reloadOnTouchSimulationChange); + + if (reloadNeeded) { + this.reloadBrowser(); + } + + // Used by tests + this.emitForTests("device-changed", { + reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange, + }); + } + + async onChangeNetworkThrottling(event) { + const { enabled, profile } = event.data; + await this.updateNetworkThrottling(enabled, profile); + // Used by tests + this.emit("network-throttling-changed"); + } + + onChangePixelRatio(event) { + const { pixelRatio } = event.data; + this.updateDPPX(pixelRatio); + } + + async onChangeTouchSimulation(event) { + const { enabled } = event.data; + + await this.updateMaxTouchPointsEnabled(enabled); + + await this.updateTouchSimulation( + enabled, + this.reloadOnChange("touchSimulation") + ); + + // Used by tests + this.emit("touch-simulation-changed"); + } + + async onChangeUserAgent(event) { + const { userAgent } = event.data; + const reloadNeeded = + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + if (reloadNeeded) { + this.reloadBrowser(); + } + this.emit("user-agent-changed"); + } + + onExit() { + const { browserWindow, tab } = this; + this.manager.closeIfNeeded(browserWindow, tab); + } + + async onRemoveDeviceAssociation() { + let reloadNeeded = false; + await this.updateDPPX(null); + reloadNeeded |= + (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); + if (reloadNeeded) { + this.reloadBrowser(); + } + // Used by tests + this.emitForTests("device-association-removed", { + reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange, + }); + } + + /** + * Resizing the browser on mousemove + */ + onResizeDrag({ screenX, screenY }) { + if (!this.isResizing || !this.rdmFrame.contentWindow) { + return; + } + + const zoom = this.tab.linkedBrowser.fullZoom; + + let deltaX = (screenX - this.lastScreenX) / zoom; + let deltaY = (screenY - this.lastScreenY) / zoom; + + const leftAlignmentEnabled = Services.prefs.getBoolPref( + "devtools.responsive.leftAlignViewport.enabled", + false + ); + + if (!leftAlignmentEnabled) { + // The viewport is centered horizontally, so horizontal resize resizes + // by twice the distance the mouse was dragged - on left and right side. + deltaX = deltaX * 2; + } + + if (this.ignoreX) { + deltaX = 0; + } + if (this.ignoreY) { + deltaY = 0; + } + + const viewportSize = this.getViewportSize(); + + let width = Math.round(viewportSize.width + deltaX); + let height = Math.round(viewportSize.height + deltaY); + + if (width < Constants.MIN_VIEWPORT_DIMENSION) { + width = Constants.MIN_VIEWPORT_DIMENSION; + } else if (width != viewportSize.width) { + this.lastScreenX = screenX; + } + + if (height < Constants.MIN_VIEWPORT_DIMENSION) { + height = Constants.MIN_VIEWPORT_DIMENSION; + } else if (height != viewportSize.height) { + this.lastScreenY = screenY; + } + + // Update the RDM store and viewport size with the new width and height. + this.rdmFrame.contentWindow.setViewportSize({ width, height }); + this.updateViewportSize(width, height); + + // Change the device selector back to an unselected device + if (this.rdmFrame.contentWindow.getAssociatedDevice()) { + this.rdmFrame.contentWindow.clearDeviceAssociation(); + } + } + + /** + * Start the process of resizing the browser. + */ + onResizeStart({ target, screenX, screenY }) { + this.browserWindow.addEventListener("mousemove", this.onResizeDrag, true); + this.browserWindow.addEventListener("mouseup", this.onResizeStop, true); + + this.isResizing = true; + this.lastScreenX = screenX; + this.lastScreenY = screenY; + this.ignoreX = target === this.resizeHandleY; + this.ignoreY = target === this.resizeHandleX; + } + + /** + * Stop the process of resizing the browser. + */ + onResizeStop() { + this.browserWindow.removeEventListener( + "mousemove", + this.onResizeDrag, + true + ); + this.browserWindow.removeEventListener("mouseup", this.onResizeStop, true); + + this.isResizing = false; + this.lastScreenX = 0; + this.lastScreenY = 0; + this.ignoreX = false; + this.ignoreY = false; + + // Used by tests. + this.emit("viewport-resize-dragend"); + } + + onResizeViewport(event) { + const { width, height } = event.data; + this.updateViewportSize(width, height); + this.emit("viewport-resize", { + width, + height, + }); + } + + async onRotateViewport(event) { + const { orientationType: type, angle, isViewportRotated } = event.data; + await this.updateScreenOrientation(type, angle, isViewportRotated); + } + + async onScreenshot() { + const messages = await captureAndSaveScreenshot( + this.currentTarget, + this.browserWindow + ); + + const priorityMap = { + error: PriorityLevels.PRIORITY_CRITICAL_HIGH, + warn: PriorityLevels.PRIORITY_WARNING_HIGH, + }; + for (const { text, level } of messages) { + // captureAndSaveScreenshot returns "saved" messages, that indicate where the + // screenshot was saved. We don't want to display them as the download UI can be + // used to open the file. + if (level !== "warn" && level !== "error") { + continue; + } + + showNotification(this.browserWindow, this.tab, { + msg: text, + priority: priorityMap[level], + }); + } + + message.post(this.rdmFrame.contentWindow, "screenshot-captured"); + } + + onToggleLeftAlignment(event) { + this.updateUIAlignment(event.data.leftAlignmentEnabled); + } + + onUpdateDeviceModal(event) { + this.rdmFrame.classList.toggle("device-modal-opened", event.data.isOpen); + } + + async hasDeviceState() { + const deviceState = await asyncStorage.getItem( + "devtools.responsive.deviceState" + ); + return !!deviceState; + } + + /** + * Restores the previous UI state. + */ + async restoreUIState() { + const leftAlignmentEnabled = Services.prefs.getBoolPref( + "devtools.responsive.leftAlignViewport.enabled", + false + ); + + this.updateUIAlignment(leftAlignmentEnabled); + + const height = Services.prefs.getIntPref( + "devtools.responsive.viewport.height", + 0 + ); + const width = Services.prefs.getIntPref( + "devtools.responsive.viewport.width", + 0 + ); + this.updateViewportSize(width, height); + } + + /** + * Restores the previous actor state. + * + * @param {Boolean} isTargetSwitching + */ + async restoreActorState(isTargetSwitching) { + // It's possible the target will switch to a page loaded in the + // parent-process (i.e: about:robots). When this happens, the values set + // on the BrowsingContext by RDM are not preserved. So we need to call + // enterResponsiveMode whenever there is a target switch. + this.tab.linkedBrowser.enterResponsiveMode(); + + // If the target follows the window global lifecycle, the configuration was already + // restored from the server during target switch, so we can stop here. + // This function is still called at startup to restore potential state from previous + // RDM session so we only stop here during target switching. + if ( + isTargetSwitching && + this.commands.targetCommand.targetFront.targetForm + .followWindowGlobalLifeCycle + ) { + return; + } + + const hasDeviceState = await this.hasDeviceState(); + if (hasDeviceState) { + // Return if there is a device state to restore, this will be done when the + // device list is loaded after the post-init. + return; + } + + const height = Services.prefs.getIntPref( + "devtools.responsive.viewport.height", + 0 + ); + const pixelRatio = Services.prefs.getIntPref( + "devtools.responsive.viewport.pixelRatio", + 0 + ); + const touchSimulationEnabled = Services.prefs.getBoolPref( + "devtools.responsive.touchSimulation.enabled", + false + ); + const userAgent = Services.prefs.getCharPref( + "devtools.responsive.userAgent", + "" + ); + const width = Services.prefs.getIntPref( + "devtools.responsive.viewport.width", + 0 + ); + + // Restore the previously set orientation, or get it from the initial viewport if it + // wasn't set yet. + const { type, angle } = + this.commands.targetConfigurationCommand.configuration + .rdmPaneOrientation || + this.getInitialViewportOrientation({ + width, + height, + }); + + await this.updateDPPX(pixelRatio); + await this.updateScreenOrientation(type, angle); + await this.updateMaxTouchPointsEnabled(touchSimulationEnabled); + + if (touchSimulationEnabled) { + await this.updateTouchSimulation(touchSimulationEnabled); + } + + let reloadNeeded = false; + if (userAgent) { + reloadNeeded |= + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + } + if (reloadNeeded) { + await this.reloadBrowser(); + } + } + + /** + * Set or clear the emulated device pixel ratio. + * + * @param {Number|null} dppx: The ratio to simulate. Set to null to disable the + * simulation and roll back to the original ratio + */ + async updateDPPX(dppx = null) { + await this.commands.targetConfigurationCommand.updateConfiguration({ + overrideDPPX: dppx, + }); + } + + /** + * Set or clear network throttling. + * + * @return boolean + * Whether a reload is needed to apply the change. + * (This is always immediate, so it's always false.) + */ + async updateNetworkThrottling(enabled, profile) { + if (!enabled) { + await this.networkFront.clearNetworkThrottling(); + return false; + } + const data = throttlingProfiles.profiles.find(({ id }) => id == profile); + const { download, upload, latency } = data; + await this.networkFront.setNetworkThrottling({ + downloadThroughput: download, + uploadThroughput: upload, + latency, + }); + return false; + } + + /** + * Set or clear the emulated user agent. + * + * @param {String|null} userAgent: The user agent to set on the page. Set to null to revert + * the user agent to its original value + * @return {Boolean} Whether a reload is needed to apply the change. + */ + async updateUserAgent(userAgent) { + const getConfigurationCustomUserAgent = () => + this.commands.targetConfigurationCommand.configuration.customUserAgent || + ""; + const previousCustomUserAgent = getConfigurationCustomUserAgent(); + await this.commands.targetConfigurationCommand.updateConfiguration({ + customUserAgent: userAgent, + }); + + const updatedUserAgent = getConfigurationCustomUserAgent(); + return previousCustomUserAgent !== updatedUserAgent; + } + + /** + * Set or clear touch simulation. When setting to true, this method will + * additionally set meta viewport override. + * When setting to false, this method will clear all touch simulation and meta viewport + * overrides, returning to default behavior for both settings. + * + * @param {boolean} enabled + * @param {boolean} reloadOnTouchSimulationToggle: Set to true to trigger a page reload + * if the touch simulation state changes. + */ + async updateTouchSimulation(enabled, reloadOnTouchSimulationToggle) { + await this.commands.targetConfigurationCommand.updateConfiguration({ + touchEventsOverride: enabled ? "enabled" : null, + reloadOnTouchSimulationToggle, + }); + } + + /** + * Sets the screen orientation values of the simulated device. + * + * @param {String} type + * The orientation type to update the current device screen to. + * @param {Number} angle + * The rotation angle to update the current device screen to. + * @param {Boolean} isViewportRotated + * Whether or not the reason for updating the screen orientation is a result + * of actually rotating the device via the RDM toolbar. If true, then an + * "orientationchange" event is simulated. Otherwise, the screen orientation is + * updated because of changing devices, opening RDM, or the page has been + * reloaded/navigated to, so we should not be simulating "orientationchange". + */ + async updateScreenOrientation(type, angle, isViewportRotated = false) { + await this.commands.targetConfigurationCommand.simulateScreenOrientationChange( + { + type, + angle, + isViewportRotated, + } + ); + } + + /** + * Sets whether or not maximum touch points are supported for the simulated device. + * + * @param {Boolean} touchSimulationEnabled + * Whether or not touch is enabled for the simulated device. + */ + async updateMaxTouchPointsEnabled(touchSimulationEnabled) { + return this.commands.targetConfigurationCommand.updateConfiguration({ + rdmPaneMaxTouchPoints: touchSimulationEnabled ? 1 : 0, + }); + } + + /** + * Sets whether or not the RDM UI should be left-aligned. + * + * @param {Boolean} leftAlignmentEnabled + * Whether or not the UI is left-aligned. + */ + updateUIAlignment(leftAlignmentEnabled) { + this.browserContainerEl.classList.toggle( + "left-aligned", + leftAlignmentEnabled + ); + } + + /** + * Sets the browser element to be the given width and height. + * + * @param {Number} width + * The viewport's width. + * @param {Number} height + * The viewport's height. + */ + updateViewportSize(width, height) { + const zoom = this.tab.linkedBrowser.fullZoom; + + // Setting this with a variable on the stack instead of directly as width/height + // on the <browser> because we'll need to use this for the alert dialog as well. + this.browserStackEl.style.setProperty("--rdm-width", `${width}px`); + this.browserStackEl.style.setProperty("--rdm-height", `${height}px`); + this.browserStackEl.style.setProperty("--rdm-zoom", zoom); + + // This is a bit premature, but we emit a content-resize event here. It + // would be preferrable to wait until the viewport is actually resized, + // but the "resize" event is not triggered by this style change. The + // content-resize message is only used by tests, and if needed those tests + // can use the testing function setViewportSizeAndAwaitReflow to ensure + // the viewport has had time to reach this size. + this.emit("content-resize", { + width, + height, + }); + } + + /** + * Helper for tests. Assumes a single viewport for now. + */ + getViewportSize() { + // The getViewportSize function is loaded in index.js, and might not be + // available yet. + if (this.toolWindow.getViewportSize) { + return this.toolWindow.getViewportSize(); + } + + return { width: 0, height: 0 }; + } + + /** + * Helper for tests, etc. Assumes a single viewport for now. + */ + async setViewportSize(size) { + await this.inited; + + // Ensure that width and height are valid. + let { width, height } = size; + if (!size.width) { + width = this.getViewportSize().width; + } + + if (!size.height) { + height = this.getViewportSize().height; + } + + this.rdmFrame.contentWindow.setViewportSize({ width, height }); + this.updateViewportSize(width, height); + } + + /** + * Helper for tests/reloading the viewport. Assumes a single viewport for now. + */ + getViewportBrowser() { + return this.tab.linkedBrowser; + } + + /** + * Helper for contacting the viewport content. Assumes a single viewport for now. + */ + getViewportMessageManager() { + return this.getViewportBrowser().messageManager; + } + + /** + * Helper for getting the initial viewport orientation. + */ + getInitialViewportOrientation(viewport) { + return getOrientation(viewport, viewport); + } + + /** + * Helper for tests to get the browser's window. + */ + getBrowserWindow() { + return this.browserWindow; + } + + async onTargetAvailable({ targetFront, isTargetSwitching }) { + if (this.destroying) { + return; + } + + if (targetFront.isTopLevel) { + this.responsiveFront = await targetFront.getFront("responsive"); + + if (this.destroying) { + return; + } + + await this.restoreActorState(isTargetSwitching); + this.emitForTests("responsive-ui-target-switch-done"); + } + } + // This just needed to setup watching for network resources, + // to support network throttling. + onNetworkResourceAvailable() {} + + /** + * Reload the current tab. + */ + async reloadBrowser() { + await this.commands.targetCommand.reloadTopLevelTarget(); + } +} + +module.exports = ResponsiveUI; diff --git a/devtools/client/responsive/utils/e10s.js b/devtools/client/responsive/utils/e10s.js new file mode 100644 index 0000000000..62ec6924a6 --- /dev/null +++ b/devtools/client/responsive/utils/e10s.js @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// The prefix used for RDM messages in content. +// see: devtools/client/responsive/browser/content.js +const MESSAGE_PREFIX = "ResponsiveMode:"; +const REQUEST_DONE_SUFFIX = ":Done"; + +/** + * Registers a message `listener` that is called every time messages of + * specified `message` is emitted on the given message manager. + * @param {nsIMessageListenerManager} mm + * The Message Manager + * @param {String} message + * The message. It will be prefixed with the constant `MESSAGE_PREFIX` + * @param {Function} listener + * The listener function that processes the message. + */ +function on(mm, message, listener) { + mm.addMessageListener(MESSAGE_PREFIX + message, listener); +} +exports.on = on; + +/** + * Removes a message `listener` for the specified `message` on the given + * message manager. + * @param {nsIMessageListenerManager} mm + * The Message Manager + * @param {String} message + * The message. It will be prefixed with the constant `MESSAGE_PREFIX` + * @param {Function} listener + * The listener function that processes the message. + */ +function off(mm, message, listener) { + mm.removeMessageListener(MESSAGE_PREFIX + message, listener); +} +exports.off = off; + +/** + * Resolves a promise the next time the specified `message` is sent over the + * given message manager. + * @param {nsIMessageListenerManager} mm + * The Message Manager + * @param {String} message + * The message. It will be prefixed with the constant `MESSAGE_PREFIX` + * @returns {Promise} + * A promise that is resolved when the given message is emitted. + */ +function once(mm, message) { + return new Promise(resolve => { + on(mm, message, function onMessage({ data }) { + off(mm, message, onMessage); + resolve(data); + }); + }); +} +exports.once = once; + +/** + * Asynchronously emit a `message` to the listeners of the given message + * manager. + * + * @param {nsIMessageListenerManager} mm + * The Message Manager + * @param {String} message + * The message. It will be prefixed with the constant `MESSAGE_PREFIX`. + * @param {Object} data + * A JSON object containing data to be delivered to the listeners. + */ +function emit(mm, message, data) { + mm.sendAsyncMessage(MESSAGE_PREFIX + message, data); +} +exports.emit = emit; + +/** + * Asynchronously send a "request" over the given message manager, and returns + * a promise that is resolved when the request is complete. + * + * @param {nsIMessageListenerManager} mm + * The Message Manager + * @param {String} message + * The message. It will be prefixed with the constant `MESSAGE_PREFIX`, and + * also suffixed with `REQUEST_DONE_SUFFIX` for the reply. + * @param {Object} data + * A JSON object containing data to be delivered to the listeners. + * @returns {Promise} + * A promise that is resolved when the request is done. + */ +function request(mm, message, data) { + const done = once(mm, message + REQUEST_DONE_SUFFIX); + + emit(mm, message, data); + + return done; +} +exports.request = request; diff --git a/devtools/client/responsive/utils/key.js b/devtools/client/responsive/utils/key.js new file mode 100644 index 0000000000..22c7278b2f --- /dev/null +++ b/devtools/client/responsive/utils/key.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +/** + * Helper to check if the provided key matches one of the expected keys. + * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes. + * + * @param {String} key + * the key to check (can be a keyCode). + * @param {...String} keys + * list of possible keys allowed. + * @return {Boolean} true if the key matches one of the keys. + */ +function isKeyIn(key, ...keys) { + return keys.some(expectedKey => { + return key === KeyCodes["DOM_VK_" + expectedKey]; + }); +} + +exports.isKeyIn = isKeyIn; diff --git a/devtools/client/responsive/utils/l10n.js b/devtools/client/responsive/utils/l10n.js new file mode 100644 index 0000000000..bccea57040 --- /dev/null +++ b/devtools/client/responsive/utils/l10n.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const STRINGS_URI = "devtools/client/locales/responsive.properties"; +const L10N = new LocalizationHelper(STRINGS_URI); + +module.exports = { + getStr: (...args) => L10N.getStr(...args), + getFormatStr: (...args) => L10N.getFormatStr(...args), + getFormatStrWithNumbers: (...args) => L10N.getFormatStrWithNumbers(...args), + numberWithDecimals: (...args) => L10N.numberWithDecimals(...args), +}; diff --git a/devtools/client/responsive/utils/message.js b/devtools/client/responsive/utils/message.js new file mode 100644 index 0000000000..d06f95ab27 --- /dev/null +++ b/devtools/client/responsive/utils/message.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const REQUEST_DONE_SUFFIX = ":done"; + +function wait(win, type) { + return new Promise(resolve => { + const onMessage = event => { + if (event.data.type !== type) { + return; + } + win.removeEventListener("message", onMessage); + resolve(); + }; + win.addEventListener("message", onMessage); + }); +} + +/** + * Post a message to some window. + * + * @param win + * The window to post to. + * @param typeOrMessage + * Either a string or and an object representing the message to send. + * If this is a string, it will be expanded into an object with the string as the + * `type` field. If this is an object, it will be sent as is. + */ +function post(win, typeOrMessage) { + // When running unit tests on XPCShell, there is no window to send messages to. + if (!win) { + return; + } + + let message = typeOrMessage; + if (typeof typeOrMessage == "string") { + message = { + type: typeOrMessage, + }; + } + win.postMessage(message, "*"); +} + +function request(win, type) { + const done = wait(win, type + REQUEST_DONE_SUFFIX); + post(win, type); + return done; +} + +exports.wait = wait; +exports.post = post; +exports.request = request; diff --git a/devtools/client/responsive/utils/moz.build b/devtools/client/responsive/utils/moz.build new file mode 100644 index 0000000000..503da8932a --- /dev/null +++ b/devtools/client/responsive/utils/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "e10s.js", + "key.js", + "l10n.js", + "message.js", + "notification.js", + "orientation.js", + "ua.js", + "window.js", +) diff --git a/devtools/client/responsive/utils/notification.js b/devtools/client/responsive/utils/notification.js new file mode 100644 index 0000000000..a261fe4252 --- /dev/null +++ b/devtools/client/responsive/utils/notification.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + "gDevTools", + "resource://devtools/client/framework/devtools.js", + true +); + +/** + * Displays a notification either at the browser or toolbox level, depending on whether + * a toolbox is currently open for this tab. + * + * @param window + * The main browser chrome window. + * @param tab + * The browser tab. + * @param options + * Other options associated with opening. Currently includes: + * - `toolbox`: Whether initiated via toolbox button + * - `msg`: String to show in the notification + * - `priority`: Priority level for the notification, which affects the icon and + * overall appearance. + */ +async function showNotification( + window, + tab, + { toolboxButton, msg, priority } = {} +) { + // Default to using the browser's per-tab notification box + let nbox = window.gBrowser.getNotificationBox(tab.linkedBrowser); + + // If opening was initiated by a toolbox button, check for an open + // toolbox for the tab. If one exists, use the toolbox's notification box so that the + // message is placed closer to the action taken by the user. + if (toolboxButton) { + const toolbox = gDevTools.getToolboxForTab(tab); + if (toolbox) { + nbox = toolbox.notificationBox; + } + } + + const value = "devtools-responsive"; + if (nbox.getNotificationWithValue(value)) { + // Notification already displayed + return; + } + + if (!priority) { + priority = nbox.PRIORITY_INFO_MEDIUM; + } + + nbox.appendNotification(value, { label: msg, priority }); +} + +exports.showNotification = showNotification; diff --git a/devtools/client/responsive/utils/orientation.js b/devtools/client/responsive/utils/orientation.js new file mode 100644 index 0000000000..500eab8faa --- /dev/null +++ b/devtools/client/responsive/utils/orientation.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + PORTRAIT_PRIMARY, + LANDSCAPE_PRIMARY, +} = require("resource://devtools/client/responsive/constants.js"); + +/** + * Helper that gets the screen orientation of the device displayed in the RDM viewport. + * This function take in both a device and viewport object and an optional rotated angle. + * If a rotated angle is passed, then we calculate what the orientation type of the device + * would be in relation to its current orientation. Otherwise, return the current + * orientation and angle. + * + * @param {Object} device + * The device whose content is displayed in the viewport. Used to determine the + * primary orientation. + * @param {Object} viewport + * The viewport displaying device content. Used to determine the current + * orientation type of the device while in RDM. + * @param {Number|null} angleToRotateTo + * Optional. The rotated angle specifies the degree to which the device WILL be + * turned to. If undefined, then only return the current orientation and angle + * of the device. + * @return {Object} the orientation of the device. + */ +function getOrientation(device, viewport, angleToRotateTo = null) { + const { width: deviceWidth, height: deviceHeight } = device; + const { width: viewportWidth, height: viewportHeight } = viewport; + + // Determine the primary orientation of the device screen. + const primaryOrientation = + deviceHeight >= deviceWidth ? PORTRAIT_PRIMARY : LANDSCAPE_PRIMARY; + + // Determine the current orientation of the device screen. + const currentOrientation = + viewportHeight >= viewportWidth ? PORTRAIT_PRIMARY : LANDSCAPE_PRIMARY; + + // Calculate the orientation angle of the device. + let angle; + + if (typeof angleToRotateTo === "number") { + angle = angleToRotateTo; + } else if (currentOrientation !== primaryOrientation) { + angle = 90; + } else { + angle = 0; + } + + // Calculate the orientation type of the device. + let orientationType = currentOrientation; + + // If the viewport orientation is different from the primary orientation and the angle + // to rotate to is 0, then we are moving the device orientation back to its primary + // orientation. + if (currentOrientation !== primaryOrientation && angleToRotateTo === 0) { + orientationType = primaryOrientation; + } else if (angleToRotateTo === 90 || angleToRotateTo === 270) { + if (currentOrientation.includes("portrait")) { + orientationType = LANDSCAPE_PRIMARY; + } else if (currentOrientation.includes("landscape")) { + orientationType = PORTRAIT_PRIMARY; + } + } + + return { + type: orientationType, + angle, + }; +} + +exports.getOrientation = getOrientation; diff --git a/devtools/client/responsive/utils/ua.js b/devtools/client/responsive/utils/ua.js new file mode 100644 index 0000000000..70c99c552d --- /dev/null +++ b/devtools/client/responsive/utils/ua.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BROWSERS = [ + { + name: "Firefox", + mustContain: new RegExp(`(?:Firefox|FxiOS)\/(${getVersionRegex(1, 1)})`), + }, + { + name: "Opera", + mustContain: new RegExp(`(?:OPR|Opera)\/(${getVersionRegex(1, 1)})`), + }, + { + name: "Safari", + mustContain: new RegExp(`Version\/(${getVersionRegex(1, 1)}).+Safari`), + mustNotContain: new RegExp("Chrome|Chromium"), + }, + { + name: "Edge", + mustContain: new RegExp(`Edge\/(${getVersionRegex(0, 1)})`), + }, + { + name: "Chrome", + mustContain: new RegExp(`(?:Chrome|CriOS)\/(${getVersionRegex(1, 1)})`), + }, + { + name: "IE", + mustContain: new RegExp(`MSIE (${getVersionRegex(1, 1)})`), + }, +]; + +const OSES = [ + { + name: "iOS", + minMinorVersionCount: 0, + mustContain: new RegExp(`CPU iPhone OS (${getVersionRegex(0, 2)})`), + }, + { + name: "iPadOS", + minMinorVersionCount: 0, + mustContain: new RegExp(`CPU OS (${getVersionRegex(0, 2)})`), + }, + { + name: "Windows Phone", + minMinorVersionCount: 1, + mustContain: new RegExp(`Windows Phone (${getVersionRegex(1, 2)})`), + }, + { + name: "Chrome OS", + minMinorVersionCount: 1, + mustContain: new RegExp(`CrOS .+ (${getVersionRegex(1, 2)})`), + }, + { + name: "Android", + minMinorVersionCount: 0, + mustContain: new RegExp(`Android (${getVersionRegex(0, 2)})`), + }, + { + name: "Windows NT", + minMinorVersionCount: 1, + mustContain: new RegExp(`Windows NT (${getVersionRegex(1, 2)})`), + }, + { + name: "Mac OSX", + minMinorVersionCount: 1, + mustContain: new RegExp(`Intel Mac OS X (${getVersionRegex(1, 2)})`), + }, + { + name: "Linux", + mustContain: new RegExp("Linux"), + }, +]; + +function getVersionRegex(minMinorVersionCount, maxMinorVersionCount) { + return `\\d+(?:[._][0-9a-z]+){${minMinorVersionCount},${maxMinorVersionCount}}`; +} + +function detect(ua, dataset) { + for (const { + name, + mustContain, + mustNotContain, + minMinorVersionCount, + } of dataset) { + const result = mustContain.exec(ua); + + if (!result) { + continue; + } + + if (mustNotContain && mustNotContain.test(ua)) { + continue; + } + + let version = null; + + if (result && result.length === 2) { + // Remove most minor version if that expresses 0. + let parts = result[1].match(/([0-9a-z]+)/g); + parts = parts.reverse(); + const validVersionIndex = parts.findIndex( + part => parseInt(part, 10) !== 0 + ); + if (validVersionIndex !== -1) { + parts = parts.splice(validVersionIndex); + for (let i = 0; i < minMinorVersionCount + 1 - parts.length; i++) { + parts.unshift(0); + } + } + version = parts.reverse().join("."); + } + + return { name, version }; + } + + return null; +} + +function parseUserAgent(ua) { + return { + browser: detect(ua, BROWSERS), + os: detect(ua, OSES), + }; +} + +module.exports = { parseUserAgent }; diff --git a/devtools/client/responsive/utils/window.js b/devtools/client/responsive/utils/window.js new file mode 100644 index 0000000000..f9bdaf0879 --- /dev/null +++ b/devtools/client/responsive/utils/window.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Returns the `nsIDOMWindow` toplevel window for any child/inner window + */ +function getTopLevelWindow(window) { + return window.browsingContext.topChromeWindow; +} +exports.getTopLevelWindow = getTopLevelWindow; + +function getDOMWindowUtils(window) { + return window.windowUtils; +} +exports.getDOMWindowUtils = getDOMWindowUtils; + +/** + * Check if the given browser window has finished the startup. + * @params {nsIDOMWindow} window + */ +const isStartupFinished = window => window.gBrowserInit?.delayedStartupFinished; + +function startup(window) { + return new Promise(resolve => { + if (isStartupFinished(window)) { + resolve(window); + return; + } + Services.obs.addObserver(function listener({ subject }) { + if (subject === window) { + Services.obs.removeObserver( + listener, + "browser-delayed-startup-finished" + ); + resolve(window); + } + }, "browser-delayed-startup-finished"); + }); +} +exports.startup = startup; |