summaryrefslogtreecommitdiffstats
path: root/devtools/client/responsive
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/responsive
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--devtools/client/responsive/actions/devices.js254
-rw-r--r--devtools/client/responsive/actions/index.js109
-rw-r--r--devtools/client/responsive/actions/moz.build13
-rw-r--r--devtools/client/responsive/actions/screenshot.js36
-rw-r--r--devtools/client/responsive/actions/ui.js71
-rw-r--r--devtools/client/responsive/actions/viewports.js128
-rw-r--r--devtools/client/responsive/components/App.js435
-rw-r--r--devtools/client/responsive/components/Device.js139
-rw-r--r--devtools/client/responsive/components/DeviceAdder.js229
-rw-r--r--devtools/client/responsive/components/DeviceForm.js231
-rw-r--r--devtools/client/responsive/components/DeviceInfo.js52
-rw-r--r--devtools/client/responsive/components/DeviceList.js97
-rw-r--r--devtools/client/responsive/components/DeviceModal.js303
-rw-r--r--devtools/client/responsive/components/DevicePixelRatioMenu.js100
-rw-r--r--devtools/client/responsive/components/DeviceSelector.js173
-rw-r--r--devtools/client/responsive/components/SettingsMenu.js122
-rw-r--r--devtools/client/responsive/components/Toolbar.js216
-rw-r--r--devtools/client/responsive/components/UserAgentInput.js103
-rw-r--r--devtools/client/responsive/components/ViewportDimension.js251
-rw-r--r--devtools/client/responsive/components/moz.build20
-rw-r--r--devtools/client/responsive/constants.js14
-rw-r--r--devtools/client/responsive/docs/devices.md37
-rw-r--r--devtools/client/responsive/images/grippers.svg6
-rw-r--r--devtools/client/responsive/images/rotate-viewport.svg7
-rw-r--r--devtools/client/responsive/images/touch-events.svg6
-rw-r--r--devtools/client/responsive/index.css750
-rw-r--r--devtools/client/responsive/index.js227
-rw-r--r--devtools/client/responsive/manager.js292
-rw-r--r--devtools/client/responsive/moz.build30
-rw-r--r--devtools/client/responsive/reducers.js11
-rw-r--r--devtools/client/responsive/reducers/devices.js124
-rw-r--r--devtools/client/responsive/reducers/moz.build12
-rw-r--r--devtools/client/responsive/reducers/screenshot.js38
-rw-r--r--devtools/client/responsive/reducers/ui.js135
-rw-r--r--devtools/client/responsive/reducers/viewports.js212
-rw-r--r--devtools/client/responsive/responsive-browser.css132
-rw-r--r--devtools/client/responsive/store.js14
-rw-r--r--devtools/client/responsive/test/browser/browser.toml207
-rw-r--r--devtools/client/responsive/test/browser/browser_cmd_click.js33
-rw-r--r--devtools/client/responsive/test/browser/browser_container_tab.js30
-rw-r--r--devtools/client/responsive/test/browser/browser_contextmenu_inspect.js55
-rw-r--r--devtools/client/responsive/test/browser/browser_device_change.js129
-rw-r--r--devtools/client/responsive/test/browser/browser_device_custom.js237
-rw-r--r--devtools/client/responsive/test/browser/browser_device_custom_edit.js117
-rw-r--r--devtools/client/responsive/test/browser/browser_device_custom_remove.js139
-rw-r--r--devtools/client/responsive/test/browser/browser_device_modal_exit.js51
-rw-r--r--devtools/client/responsive/test/browser/browser_device_modal_items.js98
-rw-r--r--devtools/client/responsive/test/browser/browser_device_modal_submit.js203
-rw-r--r--devtools/client/responsive/test/browser/browser_device_pixel_ratio_change.js136
-rw-r--r--devtools/client/responsive/test/browser/browser_device_selector_items.js79
-rw-r--r--devtools/client/responsive/test/browser/browser_device_state_restore.js155
-rw-r--r--devtools/client/responsive/test/browser/browser_device_width.js168
-rw-r--r--devtools/client/responsive/test/browser/browser_exit_button.js81
-rw-r--r--devtools/client/responsive/test/browser/browser_ext_messaging.js231
-rw-r--r--devtools/client/responsive/test/browser/browser_in_rdm_pane.js31
-rw-r--r--devtools/client/responsive/test/browser/browser_many_toggles.js51
-rw-r--r--devtools/client/responsive/test/browser/browser_max_touchpoints.js103
-rw-r--r--devtools/client/responsive/test/browser/browser_menu_item_01.js67
-rw-r--r--devtools/client/responsive/test/browser/browser_menu_item_02.js59
-rw-r--r--devtools/client/responsive/test/browser/browser_mouse_resize.js39
-rw-r--r--devtools/client/responsive/test/browser/browser_navigation.js102
-rw-r--r--devtools/client/responsive/test/browser/browser_network_throttling.js77
-rw-r--r--devtools/client/responsive/test/browser/browser_orientationchange_event.js244
-rw-r--r--devtools/client/responsive/test/browser/browser_page_redirection.js62
-rw-r--r--devtools/client/responsive/test/browser/browser_page_state.js91
-rw-r--r--devtools/client/responsive/test/browser/browser_page_style.js70
-rw-r--r--devtools/client/responsive/test/browser/browser_permission_doorhanger.js72
-rw-r--r--devtools/client/responsive/test/browser/browser_picker_link.js96
-rw-r--r--devtools/client/responsive/test/browser/browser_preloaded_newtab.js34
-rw-r--r--devtools/client/responsive/test/browser/browser_screenshot_button.js44
-rw-r--r--devtools/client/responsive/test/browser/browser_screenshot_button_warning.js59
-rw-r--r--devtools/client/responsive/test/browser/browser_scroll.js88
-rw-r--r--devtools/client/responsive/test/browser/browser_state_restore.js90
-rw-r--r--devtools/client/responsive/test/browser/browser_tab_close.js53
-rw-r--r--devtools/client/responsive/test/browser/browser_tab_not_selected.js43
-rw-r--r--devtools/client/responsive/test/browser/browser_tab_remoteness_change.js45
-rw-r--r--devtools/client/responsive/test/browser/browser_tab_remoteness_change_fission_switch_target.js42
-rw-r--r--devtools/client/responsive/test/browser/browser_target_blank.js25
-rw-r--r--devtools/client/responsive/test/browser/browser_telemetry_activate_rdm.js116
-rw-r--r--devtools/client/responsive/test/browser/browser_toolbox_computed_view.js64
-rw-r--r--devtools/client/responsive/test/browser/browser_toolbox_rule_view.js63
-rw-r--r--devtools/client/responsive/test/browser/browser_toolbox_rule_view_reload.js56
-rw-r--r--devtools/client/responsive/test/browser/browser_toolbox_swap_browsers.js175
-rw-r--r--devtools/client/responsive/test/browser/browser_toolbox_swap_inspector.js50
-rw-r--r--devtools/client/responsive/test/browser/browser_tooltip.js129
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_device.js100
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_does_not_trigger_hover_states.js78
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_event_iframes.js312
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_event_should_bubble.js51
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_pointerevents.js77
-rw-r--r--devtools/client/responsive/test/browser/browser_touch_simulation.js341
-rw-r--r--devtools/client/responsive/test/browser/browser_typeahead_find.js70
-rw-r--r--devtools/client/responsive/test/browser/browser_user_agent_input.js24
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_basics.js30
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_changed_meta.js124
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_fallback_width.js53
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resizing_after_reload.js88
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width.js72
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width_and_zoom.js89
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resizing_minimum_scale.js76
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resizing_scrollbar.js86
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_resolution_restore.js60
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_state_after_close.js51
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_zoom_resolution_invariant.js60
-rw-r--r--devtools/client/responsive/test/browser/browser_viewport_zoom_toggle.js101
-rw-r--r--devtools/client/responsive/test/browser/browser_window_close.js43
-rw-r--r--devtools/client/responsive/test/browser/browser_window_sizing.js93
-rw-r--r--devtools/client/responsive/test/browser/browser_zoom.js27
-rw-r--r--devtools/client/responsive/test/browser/contextual_identity.html6
-rw-r--r--devtools/client/responsive/test/browser/devices.json658
-rw-r--r--devtools/client/responsive/test/browser/doc_contextmenu_inspect.html3
-rw-r--r--devtools/client/responsive/test/browser/doc_page_state.html16
-rw-r--r--devtools/client/responsive/test/browser/doc_picker_link.html12
-rw-r--r--devtools/client/responsive/test/browser/doc_toolbox_rule_view.css10
-rw-r--r--devtools/client/responsive/test/browser/doc_toolbox_rule_view.html4
-rw-r--r--devtools/client/responsive/test/browser/doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs51
-rw-r--r--devtools/client/responsive/test/browser/favicon.html8
-rw-r--r--devtools/client/responsive/test/browser/favicon.icobin0 -> 1406 bytes
-rw-r--r--devtools/client/responsive/test/browser/geolocation.html13
-rw-r--r--devtools/client/responsive/test/browser/head.js1008
-rw-r--r--devtools/client/responsive/test/browser/hover.html37
-rw-r--r--devtools/client/responsive/test/browser/page_style.html7
-rw-r--r--devtools/client/responsive/test/browser/sjs_redirection.sjs34
-rw-r--r--devtools/client/responsive/test/browser/touch.html66
-rw-r--r--devtools/client/responsive/test/browser/touch_event_bubbles.html19
-rw-r--r--devtools/client/responsive/test/browser/touch_event_target.html18
-rw-r--r--devtools/client/responsive/test/xpcshell/.eslintrc.js6
-rw-r--r--devtools/client/responsive/test/xpcshell/head.js19
-rw-r--r--devtools/client/responsive/test/xpcshell/test_add_device.js36
-rw-r--r--devtools/client/responsive/test/xpcshell/test_add_device_type.js28
-rw-r--r--devtools/client/responsive/test/xpcshell/test_add_viewport.js24
-rw-r--r--devtools/client/responsive/test/xpcshell/test_change_device.js50
-rw-r--r--devtools/client/responsive/test/xpcshell/test_change_display_pixel_ratio.js26
-rw-r--r--devtools/client/responsive/test/xpcshell/test_change_network_throttling.js34
-rw-r--r--devtools/client/responsive/test/xpcshell/test_change_pixel_ratio.js27
-rw-r--r--devtools/client/responsive/test/xpcshell/test_change_user_agent.js28
-rw-r--r--devtools/client/responsive/test/xpcshell/test_resize_viewport.js37
-rw-r--r--devtools/client/responsive/test/xpcshell/test_rotate_viewport.js27
-rw-r--r--devtools/client/responsive/test/xpcshell/test_ua_parser.js129
-rw-r--r--devtools/client/responsive/test/xpcshell/test_update_device_displayed.js38
-rw-r--r--devtools/client/responsive/test/xpcshell/test_update_touch_simulation_enabled.js24
-rw-r--r--devtools/client/responsive/test/xpcshell/xpcshell.toml30
-rw-r--r--devtools/client/responsive/toolbar.xhtml20
-rw-r--r--devtools/client/responsive/types.js139
-rw-r--r--devtools/client/responsive/ui.js1075
-rw-r--r--devtools/client/responsive/utils/e10s.js99
-rw-r--r--devtools/client/responsive/utils/key.js25
-rw-r--r--devtools/client/responsive/utils/l10n.js16
-rw-r--r--devtools/client/responsive/utils/message.js55
-rw-r--r--devtools/client/responsive/utils/moz.build16
-rw-r--r--devtools/client/responsive/utils/notification.js60
-rw-r--r--devtools/client/responsive/utils/orientation.js76
-rw-r--r--devtools/client/responsive/utils/ua.js129
-rw-r--r--devtools/client/responsive/utils/window.js43
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
new file mode 100644
index 0000000000..d44438903b
--- /dev/null
+++ b/devtools/client/responsive/test/browser/favicon.ico
Binary files differ
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;