From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001
From: Daniel Baumann
Date: Fri, 19 Apr 2024 02:47:55 +0200
Subject: Adding upstream version 124.0.1.
Signed-off-by: Daniel Baumann
---
devtools/client/responsive/actions/devices.js | 254 +++++
devtools/client/responsive/actions/index.js | 109 ++
devtools/client/responsive/actions/moz.build | 13 +
devtools/client/responsive/actions/screenshot.js | 36 +
devtools/client/responsive/actions/ui.js | 71 ++
devtools/client/responsive/actions/viewports.js | 128 +++
devtools/client/responsive/components/App.js | 435 ++++++++
devtools/client/responsive/components/Device.js | 139 +++
.../client/responsive/components/DeviceAdder.js | 229 +++++
.../client/responsive/components/DeviceForm.js | 231 +++++
.../client/responsive/components/DeviceInfo.js | 52 +
.../client/responsive/components/DeviceList.js | 97 ++
.../client/responsive/components/DeviceModal.js | 303 ++++++
.../responsive/components/DevicePixelRatioMenu.js | 100 ++
.../client/responsive/components/DeviceSelector.js | 173 ++++
.../client/responsive/components/SettingsMenu.js | 122 +++
devtools/client/responsive/components/Toolbar.js | 216 ++++
.../client/responsive/components/UserAgentInput.js | 103 ++
.../responsive/components/ViewportDimension.js | 251 +++++
devtools/client/responsive/components/moz.build | 20 +
devtools/client/responsive/constants.js | 14 +
devtools/client/responsive/docs/devices.md | 37 +
devtools/client/responsive/images/grippers.svg | 6 +
.../client/responsive/images/rotate-viewport.svg | 7 +
devtools/client/responsive/images/touch-events.svg | 6 +
devtools/client/responsive/index.css | 750 ++++++++++++++
devtools/client/responsive/index.js | 227 +++++
devtools/client/responsive/manager.js | 292 ++++++
devtools/client/responsive/moz.build | 30 +
devtools/client/responsive/reducers.js | 11 +
devtools/client/responsive/reducers/devices.js | 124 +++
devtools/client/responsive/reducers/moz.build | 12 +
devtools/client/responsive/reducers/screenshot.js | 38 +
devtools/client/responsive/reducers/ui.js | 135 +++
devtools/client/responsive/reducers/viewports.js | 212 ++++
devtools/client/responsive/responsive-browser.css | 132 +++
devtools/client/responsive/store.js | 14 +
.../client/responsive/test/browser/browser.toml | 207 ++++
.../responsive/test/browser/browser_cmd_click.js | 33 +
.../test/browser/browser_container_tab.js | 30 +
.../test/browser/browser_contextmenu_inspect.js | 55 +
.../test/browser/browser_device_change.js | 129 +++
.../test/browser/browser_device_custom.js | 237 +++++
.../test/browser/browser_device_custom_edit.js | 117 +++
.../test/browser/browser_device_custom_remove.js | 139 +++
.../test/browser/browser_device_modal_exit.js | 51 +
.../test/browser/browser_device_modal_items.js | 98 ++
.../test/browser/browser_device_modal_submit.js | 203 ++++
.../browser/browser_device_pixel_ratio_change.js | 136 +++
.../test/browser/browser_device_selector_items.js | 79 ++
.../test/browser/browser_device_state_restore.js | 155 +++
.../test/browser/browser_device_width.js | 168 +++
.../responsive/test/browser/browser_exit_button.js | 81 ++
.../test/browser/browser_ext_messaging.js | 231 +++++
.../responsive/test/browser/browser_in_rdm_pane.js | 31 +
.../test/browser/browser_many_toggles.js | 51 +
.../test/browser/browser_max_touchpoints.js | 103 ++
.../test/browser/browser_menu_item_01.js | 67 ++
.../test/browser/browser_menu_item_02.js | 59 ++
.../test/browser/browser_mouse_resize.js | 39 +
.../responsive/test/browser/browser_navigation.js | 102 ++
.../test/browser/browser_network_throttling.js | 77 ++
.../browser/browser_orientationchange_event.js | 244 +++++
.../test/browser/browser_page_redirection.js | 62 ++
.../responsive/test/browser/browser_page_state.js | 91 ++
.../responsive/test/browser/browser_page_style.js | 70 ++
.../test/browser/browser_permission_doorhanger.js | 72 ++
.../responsive/test/browser/browser_picker_link.js | 96 ++
.../test/browser/browser_preloaded_newtab.js | 34 +
.../test/browser/browser_screenshot_button.js | 44 +
.../browser/browser_screenshot_button_warning.js | 59 ++
.../responsive/test/browser/browser_scroll.js | 88 ++
.../test/browser/browser_state_restore.js | 90 ++
.../responsive/test/browser/browser_tab_close.js | 53 +
.../test/browser/browser_tab_not_selected.js | 43 +
.../test/browser/browser_tab_remoteness_change.js | 45 +
..._tab_remoteness_change_fission_switch_target.js | 42 +
.../test/browser/browser_target_blank.js | 25 +
.../test/browser/browser_telemetry_activate_rdm.js | 116 +++
.../test/browser/browser_toolbox_computed_view.js | 64 ++
.../test/browser/browser_toolbox_rule_view.js | 63 ++
.../browser/browser_toolbox_rule_view_reload.js | 56 +
.../test/browser/browser_toolbox_swap_browsers.js | 175 ++++
.../test/browser/browser_toolbox_swap_inspector.js | 50 +
.../responsive/test/browser/browser_tooltip.js | 129 +++
.../test/browser/browser_touch_device.js | 100 ++
.../browser_touch_does_not_trigger_hover_states.js | 78 ++
.../test/browser/browser_touch_event_iframes.js | 312 ++++++
.../browser/browser_touch_event_should_bubble.js | 51 +
.../test/browser/browser_touch_pointerevents.js | 77 ++
.../test/browser/browser_touch_simulation.js | 341 +++++++
.../test/browser/browser_typeahead_find.js | 70 ++
.../test/browser/browser_user_agent_input.js | 24 +
.../test/browser/browser_viewport_basics.js | 30 +
.../test/browser/browser_viewport_changed_meta.js | 124 +++
.../browser/browser_viewport_fallback_width.js | 53 +
.../browser_viewport_resizing_after_reload.js | 88 ++
.../browser_viewport_resizing_fixed_width.js | 72 ++
...owser_viewport_resizing_fixed_width_and_zoom.js | 89 ++
.../browser_viewport_resizing_minimum_scale.js | 76 ++
.../browser/browser_viewport_resizing_scrollbar.js | 86 ++
.../browser/browser_viewport_resolution_restore.js | 60 ++
.../browser/browser_viewport_state_after_close.js | 51 +
.../browser_viewport_zoom_resolution_invariant.js | 60 ++
.../test/browser/browser_viewport_zoom_toggle.js | 101 ++
.../test/browser/browser_window_close.js | 43 +
.../test/browser/browser_window_sizing.js | 93 ++
.../client/responsive/test/browser/browser_zoom.js | 27 +
.../test/browser/contextual_identity.html | 6 +
.../client/responsive/test/browser/devices.json | 658 ++++++++++++
.../test/browser/doc_contextmenu_inspect.html | 3 +
.../responsive/test/browser/doc_page_state.html | 16 +
.../responsive/test/browser/doc_picker_link.html | 12 +
.../test/browser/doc_toolbox_rule_view.css | 10 +
.../test/browser/doc_toolbox_rule_view.html | 4 +
...rame_and_isolated_cross_origin_capabilities.sjs | 51 +
.../client/responsive/test/browser/favicon.html | 8 +
.../client/responsive/test/browser/favicon.ico | Bin 0 -> 1406 bytes
.../responsive/test/browser/geolocation.html | 13 +
devtools/client/responsive/test/browser/head.js | 1008 ++++++++++++++++++
devtools/client/responsive/test/browser/hover.html | 37 +
.../client/responsive/test/browser/page_style.html | 7 +
.../responsive/test/browser/sjs_redirection.sjs | 34 +
devtools/client/responsive/test/browser/touch.html | 66 ++
.../test/browser/touch_event_bubbles.html | 19 +
.../test/browser/touch_event_target.html | 18 +
.../client/responsive/test/xpcshell/.eslintrc.js | 6 +
devtools/client/responsive/test/xpcshell/head.js | 19 +
.../responsive/test/xpcshell/test_add_device.js | 36 +
.../test/xpcshell/test_add_device_type.js | 28 +
.../responsive/test/xpcshell/test_add_viewport.js | 24 +
.../responsive/test/xpcshell/test_change_device.js | 50 +
.../xpcshell/test_change_display_pixel_ratio.js | 26 +
.../xpcshell/test_change_network_throttling.js | 34 +
.../test/xpcshell/test_change_pixel_ratio.js | 27 +
.../test/xpcshell/test_change_user_agent.js | 28 +
.../test/xpcshell/test_resize_viewport.js | 37 +
.../test/xpcshell/test_rotate_viewport.js | 27 +
.../responsive/test/xpcshell/test_ua_parser.js | 129 +++
.../test/xpcshell/test_update_device_displayed.js | 38 +
.../test_update_touch_simulation_enabled.js | 24 +
.../client/responsive/test/xpcshell/xpcshell.toml | 30 +
devtools/client/responsive/toolbar.xhtml | 20 +
devtools/client/responsive/types.js | 139 +++
devtools/client/responsive/ui.js | 1075 ++++++++++++++++++++
devtools/client/responsive/utils/e10s.js | 99 ++
devtools/client/responsive/utils/key.js | 25 +
devtools/client/responsive/utils/l10n.js | 16 +
devtools/client/responsive/utils/message.js | 55 +
devtools/client/responsive/utils/moz.build | 16 +
devtools/client/responsive/utils/notification.js | 60 ++
devtools/client/responsive/utils/orientation.js | 76 ++
devtools/client/responsive/utils/ua.js | 129 +++
devtools/client/responsive/utils/window.js | 43 +
154 files changed, 16010 insertions(+)
create mode 100644 devtools/client/responsive/actions/devices.js
create mode 100644 devtools/client/responsive/actions/index.js
create mode 100644 devtools/client/responsive/actions/moz.build
create mode 100644 devtools/client/responsive/actions/screenshot.js
create mode 100644 devtools/client/responsive/actions/ui.js
create mode 100644 devtools/client/responsive/actions/viewports.js
create mode 100644 devtools/client/responsive/components/App.js
create mode 100644 devtools/client/responsive/components/Device.js
create mode 100644 devtools/client/responsive/components/DeviceAdder.js
create mode 100644 devtools/client/responsive/components/DeviceForm.js
create mode 100644 devtools/client/responsive/components/DeviceInfo.js
create mode 100644 devtools/client/responsive/components/DeviceList.js
create mode 100644 devtools/client/responsive/components/DeviceModal.js
create mode 100644 devtools/client/responsive/components/DevicePixelRatioMenu.js
create mode 100644 devtools/client/responsive/components/DeviceSelector.js
create mode 100644 devtools/client/responsive/components/SettingsMenu.js
create mode 100644 devtools/client/responsive/components/Toolbar.js
create mode 100644 devtools/client/responsive/components/UserAgentInput.js
create mode 100644 devtools/client/responsive/components/ViewportDimension.js
create mode 100644 devtools/client/responsive/components/moz.build
create mode 100644 devtools/client/responsive/constants.js
create mode 100644 devtools/client/responsive/docs/devices.md
create mode 100644 devtools/client/responsive/images/grippers.svg
create mode 100644 devtools/client/responsive/images/rotate-viewport.svg
create mode 100644 devtools/client/responsive/images/touch-events.svg
create mode 100644 devtools/client/responsive/index.css
create mode 100644 devtools/client/responsive/index.js
create mode 100644 devtools/client/responsive/manager.js
create mode 100644 devtools/client/responsive/moz.build
create mode 100644 devtools/client/responsive/reducers.js
create mode 100644 devtools/client/responsive/reducers/devices.js
create mode 100644 devtools/client/responsive/reducers/moz.build
create mode 100644 devtools/client/responsive/reducers/screenshot.js
create mode 100644 devtools/client/responsive/reducers/ui.js
create mode 100644 devtools/client/responsive/reducers/viewports.js
create mode 100644 devtools/client/responsive/responsive-browser.css
create mode 100644 devtools/client/responsive/store.js
create mode 100644 devtools/client/responsive/test/browser/browser.toml
create mode 100644 devtools/client/responsive/test/browser/browser_cmd_click.js
create mode 100644 devtools/client/responsive/test/browser/browser_container_tab.js
create mode 100644 devtools/client/responsive/test/browser/browser_contextmenu_inspect.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_change.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_custom.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_custom_edit.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_custom_remove.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_modal_exit.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_modal_items.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_modal_submit.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_pixel_ratio_change.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_selector_items.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_state_restore.js
create mode 100644 devtools/client/responsive/test/browser/browser_device_width.js
create mode 100644 devtools/client/responsive/test/browser/browser_exit_button.js
create mode 100644 devtools/client/responsive/test/browser/browser_ext_messaging.js
create mode 100644 devtools/client/responsive/test/browser/browser_in_rdm_pane.js
create mode 100644 devtools/client/responsive/test/browser/browser_many_toggles.js
create mode 100644 devtools/client/responsive/test/browser/browser_max_touchpoints.js
create mode 100644 devtools/client/responsive/test/browser/browser_menu_item_01.js
create mode 100644 devtools/client/responsive/test/browser/browser_menu_item_02.js
create mode 100644 devtools/client/responsive/test/browser/browser_mouse_resize.js
create mode 100644 devtools/client/responsive/test/browser/browser_navigation.js
create mode 100644 devtools/client/responsive/test/browser/browser_network_throttling.js
create mode 100644 devtools/client/responsive/test/browser/browser_orientationchange_event.js
create mode 100644 devtools/client/responsive/test/browser/browser_page_redirection.js
create mode 100644 devtools/client/responsive/test/browser/browser_page_state.js
create mode 100644 devtools/client/responsive/test/browser/browser_page_style.js
create mode 100644 devtools/client/responsive/test/browser/browser_permission_doorhanger.js
create mode 100644 devtools/client/responsive/test/browser/browser_picker_link.js
create mode 100644 devtools/client/responsive/test/browser/browser_preloaded_newtab.js
create mode 100644 devtools/client/responsive/test/browser/browser_screenshot_button.js
create mode 100644 devtools/client/responsive/test/browser/browser_screenshot_button_warning.js
create mode 100644 devtools/client/responsive/test/browser/browser_scroll.js
create mode 100644 devtools/client/responsive/test/browser/browser_state_restore.js
create mode 100644 devtools/client/responsive/test/browser/browser_tab_close.js
create mode 100644 devtools/client/responsive/test/browser/browser_tab_not_selected.js
create mode 100644 devtools/client/responsive/test/browser/browser_tab_remoteness_change.js
create mode 100644 devtools/client/responsive/test/browser/browser_tab_remoteness_change_fission_switch_target.js
create mode 100644 devtools/client/responsive/test/browser/browser_target_blank.js
create mode 100644 devtools/client/responsive/test/browser/browser_telemetry_activate_rdm.js
create mode 100644 devtools/client/responsive/test/browser/browser_toolbox_computed_view.js
create mode 100644 devtools/client/responsive/test/browser/browser_toolbox_rule_view.js
create mode 100644 devtools/client/responsive/test/browser/browser_toolbox_rule_view_reload.js
create mode 100644 devtools/client/responsive/test/browser/browser_toolbox_swap_browsers.js
create mode 100644 devtools/client/responsive/test/browser/browser_toolbox_swap_inspector.js
create mode 100644 devtools/client/responsive/test/browser/browser_tooltip.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_device.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_does_not_trigger_hover_states.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_event_iframes.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_event_should_bubble.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_pointerevents.js
create mode 100644 devtools/client/responsive/test/browser/browser_touch_simulation.js
create mode 100644 devtools/client/responsive/test/browser/browser_typeahead_find.js
create mode 100644 devtools/client/responsive/test/browser/browser_user_agent_input.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_basics.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_changed_meta.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_fallback_width.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resizing_after_reload.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resizing_fixed_width_and_zoom.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resizing_minimum_scale.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resizing_scrollbar.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_resolution_restore.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_state_after_close.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_zoom_resolution_invariant.js
create mode 100644 devtools/client/responsive/test/browser/browser_viewport_zoom_toggle.js
create mode 100644 devtools/client/responsive/test/browser/browser_window_close.js
create mode 100644 devtools/client/responsive/test/browser/browser_window_sizing.js
create mode 100644 devtools/client/responsive/test/browser/browser_zoom.js
create mode 100644 devtools/client/responsive/test/browser/contextual_identity.html
create mode 100644 devtools/client/responsive/test/browser/devices.json
create mode 100644 devtools/client/responsive/test/browser/doc_contextmenu_inspect.html
create mode 100644 devtools/client/responsive/test/browser/doc_page_state.html
create mode 100644 devtools/client/responsive/test/browser/doc_picker_link.html
create mode 100644 devtools/client/responsive/test/browser/doc_toolbox_rule_view.css
create mode 100644 devtools/client/responsive/test/browser/doc_toolbox_rule_view.html
create mode 100644 devtools/client/responsive/test/browser/doc_with_remote_iframe_and_isolated_cross_origin_capabilities.sjs
create mode 100644 devtools/client/responsive/test/browser/favicon.html
create mode 100644 devtools/client/responsive/test/browser/favicon.ico
create mode 100644 devtools/client/responsive/test/browser/geolocation.html
create mode 100644 devtools/client/responsive/test/browser/head.js
create mode 100644 devtools/client/responsive/test/browser/hover.html
create mode 100644 devtools/client/responsive/test/browser/page_style.html
create mode 100644 devtools/client/responsive/test/browser/sjs_redirection.sjs
create mode 100644 devtools/client/responsive/test/browser/touch.html
create mode 100644 devtools/client/responsive/test/browser/touch_event_bubbles.html
create mode 100644 devtools/client/responsive/test/browser/touch_event_target.html
create mode 100644 devtools/client/responsive/test/xpcshell/.eslintrc.js
create mode 100644 devtools/client/responsive/test/xpcshell/head.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_add_device.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_add_device_type.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_add_viewport.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_change_device.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_change_display_pixel_ratio.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_change_network_throttling.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_change_pixel_ratio.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_change_user_agent.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_resize_viewport.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_rotate_viewport.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_ua_parser.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_update_device_displayed.js
create mode 100644 devtools/client/responsive/test/xpcshell/test_update_touch_simulation_enabled.js
create mode 100644 devtools/client/responsive/test/xpcshell/xpcshell.toml
create mode 100644 devtools/client/responsive/toolbar.xhtml
create mode 100644 devtools/client/responsive/types.js
create mode 100644 devtools/client/responsive/ui.js
create mode 100644 devtools/client/responsive/utils/e10s.js
create mode 100644 devtools/client/responsive/utils/key.js
create mode 100644 devtools/client/responsive/utils/l10n.js
create mode 100644 devtools/client/responsive/utils/message.js
create mode 100644 devtools/client/responsive/utils/moz.build
create mode 100644 devtools/client/responsive/utils/notification.js
create mode 100644 devtools/client/responsive/utils/orientation.js
create mode 100644 devtools/client/responsive/utils/ua.js
create mode 100644 devtools/client/responsive/utils/window.js
(limited to 'devtools/client/responsive')
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 @@
+
+
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 @@
+
+
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 @@
+
+
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,Click me`.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,';
+
+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 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=
+ 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," +
+ '' +
+ '' +
+ '' +
+ "" +
+ '';
+
+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,Click me`.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,";
+
+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 = `
test 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,` +
+ `` +
+ `` +
+ `` +
+ ``;
+
+ 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," +
+ '' +
+ "
text
+
Initial
+
";
+
+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.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.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," +
+ '
';
+
+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,
+
+