summaryrefslogtreecommitdiff
path: root/devtools/client/responsive.html
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/client/responsive.html
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloaduxp-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/responsive.html')
-rw-r--r--devtools/client/responsive.html/actions/devices.js138
-rw-r--r--devtools/client/responsive.html/actions/display-pixel-ratio.js23
-rw-r--r--devtools/client/responsive.html/actions/index.js77
-rw-r--r--devtools/client/responsive.html/actions/location.js22
-rw-r--r--devtools/client/responsive.html/actions/moz.build16
-rw-r--r--devtools/client/responsive.html/actions/network-throttling.js21
-rw-r--r--devtools/client/responsive.html/actions/screenshot.js82
-rw-r--r--devtools/client/responsive.html/actions/touch-simulation.js22
-rw-r--r--devtools/client/responsive.html/actions/viewports.js81
-rw-r--r--devtools/client/responsive.html/app.js209
-rw-r--r--devtools/client/responsive.html/browser/moz.build11
-rw-r--r--devtools/client/responsive.html/browser/swap.js309
-rw-r--r--devtools/client/responsive.html/browser/tunnel.js619
-rw-r--r--devtools/client/responsive.html/browser/web-navigation.js179
-rw-r--r--devtools/client/responsive.html/components/browser.js149
-rw-r--r--devtools/client/responsive.html/components/device-modal.js181
-rw-r--r--devtools/client/responsive.html/components/device-selector.js122
-rw-r--r--devtools/client/responsive.html/components/dpr-selector.js131
-rw-r--r--devtools/client/responsive.html/components/global-toolbar.js101
-rw-r--r--devtools/client/responsive.html/components/moz.build19
-rw-r--r--devtools/client/responsive.html/components/network-throttling-selector.js92
-rw-r--r--devtools/client/responsive.html/components/resizable-viewport.js195
-rw-r--r--devtools/client/responsive.html/components/viewport-dimension.js173
-rw-r--r--devtools/client/responsive.html/components/viewport-toolbar.js55
-rw-r--r--devtools/client/responsive.html/components/viewport.js114
-rw-r--r--devtools/client/responsive.html/components/viewports.js70
-rw-r--r--devtools/client/responsive.html/constants.js8
-rw-r--r--devtools/client/responsive.html/docs/browser-swap.md146
-rw-r--r--devtools/client/responsive.html/images/close.svg6
-rw-r--r--devtools/client/responsive.html/images/grippers.svg6
-rw-r--r--devtools/client/responsive.html/images/moz.build14
-rw-r--r--devtools/client/responsive.html/images/rotate-viewport.svg6
-rw-r--r--devtools/client/responsive.html/images/screenshot.svg7
-rw-r--r--devtools/client/responsive.html/images/select-arrow.svg37
-rw-r--r--devtools/client/responsive.html/images/touch-events.svg6
-rw-r--r--devtools/client/responsive.html/index.css521
-rw-r--r--devtools/client/responsive.html/index.js166
-rw-r--r--devtools/client/responsive.html/index.xhtml19
-rw-r--r--devtools/client/responsive.html/manager.js597
-rw-r--r--devtools/client/responsive.html/moz.build28
-rw-r--r--devtools/client/responsive.html/reducers.js13
-rw-r--r--devtools/client/responsive.html/reducers/devices.js86
-rw-r--r--devtools/client/responsive.html/reducers/display-pixel-ratio.js26
-rw-r--r--devtools/client/responsive.html/reducers/location.js25
-rw-r--r--devtools/client/responsive.html/reducers/moz.build15
-rw-r--r--devtools/client/responsive.html/reducers/network-throttling.js33
-rw-r--r--devtools/client/responsive.html/reducers/screenshot.js31
-rw-r--r--devtools/client/responsive.html/reducers/touch-simulation.js31
-rw-r--r--devtools/client/responsive.html/reducers/viewports.js118
-rw-r--r--devtools/client/responsive.html/responsive-ua.css6
-rw-r--r--devtools/client/responsive.html/store.js33
-rw-r--r--devtools/client/responsive.html/test/browser/.eslintrc.js6
-rw-r--r--devtools/client/responsive.html/test/browser/browser.ini44
-rw-r--r--devtools/client/responsive.html/test/browser/browser_device_change.js95
-rw-r--r--devtools/client/responsive.html/test/browser/browser_device_modal_error.js35
-rw-r--r--devtools/client/responsive.html/test/browser/browser_device_modal_exit.js45
-rw-r--r--devtools/client/responsive.html/test/browser/browser_device_modal_submit.js146
-rw-r--r--devtools/client/responsive.html/test/browser/browser_device_width.js66
-rw-r--r--devtools/client/responsive.html/test/browser/browser_dpr_change.js140
-rw-r--r--devtools/client/responsive.html/test/browser/browser_exit_button.js70
-rw-r--r--devtools/client/responsive.html/test/browser/browser_frame_script_active.js46
-rw-r--r--devtools/client/responsive.html/test/browser/browser_menu_item_01.js62
-rw-r--r--devtools/client/responsive.html/test/browser/browser_menu_item_02.js49
-rw-r--r--devtools/client/responsive.html/test/browser/browser_mouse_resize.js27
-rw-r--r--devtools/client/responsive.html/test/browser/browser_navigation.js98
-rw-r--r--devtools/client/responsive.html/test/browser/browser_network_throttling.js56
-rw-r--r--devtools/client/responsive.html/test/browser/browser_page_state.js76
-rw-r--r--devtools/client/responsive.html/test/browser/browser_permission_doorhanger.js52
-rw-r--r--devtools/client/responsive.html/test/browser/browser_resize_cmd.js148
-rw-r--r--devtools/client/responsive.html/test/browser/browser_screenshot_button.js59
-rw-r--r--devtools/client/responsive.html/test/browser/browser_tab_close.js43
-rw-r--r--devtools/client/responsive.html/test/browser/browser_tab_remoteness_change.js45
-rw-r--r--devtools/client/responsive.html/test/browser/browser_toolbox_computed_view.js63
-rw-r--r--devtools/client/responsive.html/test/browser/browser_toolbox_rule_view.js78
-rw-r--r--devtools/client/responsive.html/test/browser/browser_toolbox_swap_browsers.js128
-rw-r--r--devtools/client/responsive.html/test/browser/browser_touch_device.js77
-rw-r--r--devtools/client/responsive.html/test/browser/browser_touch_simulation.js228
-rw-r--r--devtools/client/responsive.html/test/browser/browser_viewport_basics.js30
-rw-r--r--devtools/client/responsive.html/test/browser/browser_window_close.js25
-rw-r--r--devtools/client/responsive.html/test/browser/devices.json651
-rw-r--r--devtools/client/responsive.html/test/browser/doc_page_state.html16
-rw-r--r--devtools/client/responsive.html/test/browser/geolocation.html13
-rw-r--r--devtools/client/responsive.html/test/browser/head.js401
-rw-r--r--devtools/client/responsive.html/test/browser/touch.html86
-rw-r--r--devtools/client/responsive.html/test/unit/.eslintrc.js6
-rw-r--r--devtools/client/responsive.html/test/unit/head.js21
-rw-r--r--devtools/client/responsive.html/test/unit/test_add_device.js35
-rw-r--r--devtools/client/responsive.html/test/unit/test_add_device_type.js22
-rw-r--r--devtools/client/responsive.html/test/unit/test_add_viewport.js23
-rw-r--r--devtools/client/responsive.html/test/unit/test_change_device.js42
-rw-r--r--devtools/client/responsive.html/test/unit/test_change_display_pixel_ratio.js22
-rw-r--r--devtools/client/responsive.html/test/unit/test_change_location.js22
-rw-r--r--devtools/client/responsive.html/test/unit/test_change_network_throttling.js27
-rw-r--r--devtools/client/responsive.html/test/unit/test_change_pixel_ratio.js22
-rw-r--r--devtools/client/responsive.html/test/unit/test_resize_viewport.js21
-rw-r--r--devtools/client/responsive.html/test/unit/test_rotate_viewport.js25
-rw-r--r--devtools/client/responsive.html/test/unit/test_update_device_displayed.js37
-rw-r--r--devtools/client/responsive.html/test/unit/test_update_touch_simulation_enabled.js23
-rw-r--r--devtools/client/responsive.html/test/unit/xpcshell.ini18
-rw-r--r--devtools/client/responsive.html/types.js164
-rw-r--r--devtools/client/responsive.html/utils/e10s.js103
-rw-r--r--devtools/client/responsive.html/utils/enum.js21
-rw-r--r--devtools/client/responsive.html/utils/l10n.js16
-rw-r--r--devtools/client/responsive.html/utils/message.js38
-rw-r--r--devtools/client/responsive.html/utils/moz.build12
105 files changed, 9119 insertions, 0 deletions
diff --git a/devtools/client/responsive.html/actions/devices.js b/devtools/client/responsive.html/actions/devices.js
new file mode 100644
index 0000000000..b06134450b
--- /dev/null
+++ b/devtools/client/responsive.html/actions/devices.js
@@ -0,0 +1,138 @@
+/* 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,
+ LOAD_DEVICE_LIST_START,
+ LOAD_DEVICE_LIST_ERROR,
+ LOAD_DEVICE_LIST_END,
+ UPDATE_DEVICE_DISPLAYED,
+ UPDATE_DEVICE_MODAL_OPEN,
+} = require("./index");
+
+const { getDevices } = require("devtools/client/shared/devices");
+
+const Services = require("Services");
+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() {
+ let preferredDevices = {
+ "added": new Set(),
+ "removed": new Set(),
+ };
+
+ if (Services.prefs.prefHasUserValue(DISPLAYED_DEVICES_PREF)) {
+ try {
+ let savedData = Services.prefs.getCharPref(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.setCharPref(DISPLAYED_DEVICES_PREF, devicesToSave);
+}
+
+module.exports = {
+
+ // This function is only exported for testing purposes
+ _loadPreferredDevices: loadPreferredDevices,
+
+ updatePreferredDevices: updatePreferredDevices,
+
+ addDevice(device, deviceType) {
+ return {
+ type: ADD_DEVICE,
+ device,
+ deviceType,
+ };
+ },
+
+ addDeviceType(deviceType) {
+ return {
+ type: ADD_DEVICE_TYPE,
+ deviceType,
+ };
+ },
+
+ updateDeviceDisplayed(device, deviceType, displayed) {
+ return {
+ type: UPDATE_DEVICE_DISPLAYED,
+ device,
+ deviceType,
+ displayed,
+ };
+ },
+
+ loadDevices() {
+ return function* (dispatch, getState) {
+ yield dispatch({ type: LOAD_DEVICE_LIST_START });
+ let preferredDevices = loadPreferredDevices();
+ let devices;
+
+ try {
+ devices = yield getDevices();
+ } catch (e) {
+ console.error("Could not load device list: " + e);
+ dispatch({ type: LOAD_DEVICE_LIST_ERROR });
+ return;
+ }
+
+ for (let type of devices.TYPES) {
+ dispatch(module.exports.addDeviceType(type));
+ for (let device of devices[type]) {
+ if (device.os == "fxos") {
+ continue;
+ }
+
+ let newDevice = Object.assign({}, device, {
+ displayed: preferredDevices.added.has(device.name) ||
+ (device.featured && !(preferredDevices.removed.has(device.name))),
+ });
+
+ dispatch(module.exports.addDevice(newDevice, type));
+ }
+ }
+ dispatch({ type: LOAD_DEVICE_LIST_END });
+ };
+ },
+
+ updateDeviceModalOpen(isOpen) {
+ return {
+ type: UPDATE_DEVICE_MODAL_OPEN,
+ isOpen,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/actions/display-pixel-ratio.js b/devtools/client/responsive.html/actions/display-pixel-ratio.js
new file mode 100644
index 0000000000..ff3343bb5d
--- /dev/null
+++ b/devtools/client/responsive.html/actions/display-pixel-ratio.js
@@ -0,0 +1,23 @@
+/* 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 } = require("./index");
+
+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,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/actions/index.js b/devtools/client/responsive.html/actions/index.js
new file mode 100644
index 0000000000..06cc8d1a5c
--- /dev/null
+++ b/devtools/client/responsive.html/actions/index.js
@@ -0,0 +1,77 @@
+/* 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("../utils/enum");
+
+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",
+
+ // 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 DPR dropdown.
+ "CHANGE_PIXEL_RATIO",
+
+ // Change the touch simulation state.
+ "CHANGE_TOUCH_SIMULATION",
+
+ // 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 the viewport's device assocation.
+ "REMOVE_DEVICE",
+
+ // 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",
+
+ // Update the device display state in the device selector.
+ "UPDATE_DEVICE_DISPLAYED",
+
+ // Update the device modal open state.
+ "UPDATE_DEVICE_MODAL_OPEN",
+
+], module.exports);
diff --git a/devtools/client/responsive.html/actions/location.js b/devtools/client/responsive.html/actions/location.js
new file mode 100644
index 0000000000..565825e5e6
--- /dev/null
+++ b/devtools/client/responsive.html/actions/location.js
@@ -0,0 +1,22 @@
+/* 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_LOCATION } = require("./index");
+
+module.exports = {
+
+ /**
+ * The location of the page has changed. This may be triggered by the user
+ * directly entering a new URL, navigating with links, etc.
+ */
+ changeLocation(location) {
+ return {
+ type: CHANGE_LOCATION,
+ location,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/actions/moz.build b/devtools/client/responsive.html/actions/moz.build
new file mode 100644
index 0000000000..8f44c71189
--- /dev/null
+++ b/devtools/client/responsive.html/actions/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ 'devices.js',
+ 'display-pixel-ratio.js',
+ 'index.js',
+ 'location.js',
+ 'network-throttling.js',
+ 'screenshot.js',
+ 'touch-simulation.js',
+ 'viewports.js',
+)
diff --git a/devtools/client/responsive.html/actions/network-throttling.js b/devtools/client/responsive.html/actions/network-throttling.js
new file mode 100644
index 0000000000..e92fb995c8
--- /dev/null
+++ b/devtools/client/responsive.html/actions/network-throttling.js
@@ -0,0 +1,21 @@
+/* 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_NETWORK_THROTTLING,
+} = require("./index");
+
+module.exports = {
+
+ changeNetworkThrottling(enabled, profile) {
+ return {
+ type: CHANGE_NETWORK_THROTTLING,
+ enabled,
+ profile,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/actions/screenshot.js b/devtools/client/responsive.html/actions/screenshot.js
new file mode 100644
index 0000000000..8d660d74f1
--- /dev/null
+++ b/devtools/client/responsive.html/actions/screenshot.js
@@ -0,0 +1,82 @@
+/* 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("./index");
+
+const { getFormatStr } = require("../utils/l10n");
+const { getToplevelWindow } = require("sdk/window/utils");
+const { Task: { spawn } } = require("devtools/shared/task");
+const e10s = require("../utils/e10s");
+
+const CAMERA_AUDIO_URL = "resource://devtools/client/themes/audio/shutter.wav";
+
+const animationFrame = () => new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+});
+
+function getFileName() {
+ let date = new Date();
+ let month = ("0" + (date.getMonth() + 1)).substr(-2);
+ let day = ("0" + date.getDate()).substr(-2);
+ let dateString = [date.getFullYear(), month, day].join("-");
+ let timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
+
+ return getFormatStr("responsive.screenshotGeneratedFilename", dateString,
+ timeString);
+}
+
+function createScreenshotFor(node) {
+ let mm = node.frameLoader.messageManager;
+
+ return e10s.request(mm, "RequestScreenshot");
+}
+
+function saveToFile(data, filename) {
+ return spawn(function* () {
+ const chromeWindow = getToplevelWindow(window);
+ const chromeDocument = chromeWindow.document;
+
+ // append .png extension to filename if it doesn't exist
+ filename = filename.replace(/\.png$|$/i, ".png");
+
+ chromeWindow.saveURL(data, filename, null,
+ true, true,
+ chromeDocument.documentURIObject, chromeDocument);
+ });
+}
+
+function simulateCameraEffects(node) {
+ let cameraAudio = new window.Audio(CAMERA_AUDIO_URL);
+ cameraAudio.play();
+ node.animate({ opacity: [ 0, 1 ] }, 500);
+}
+
+module.exports = {
+
+ takeScreenshot() {
+ return function* (dispatch, getState) {
+ yield dispatch({ type: TAKE_SCREENSHOT_START });
+
+ // Waiting the next repaint, to ensure the react components
+ // can be properly render after the action dispatched above
+ yield animationFrame();
+
+ let iframe = document.querySelector("iframe");
+ let data = yield createScreenshotFor(iframe);
+
+ simulateCameraEffects(iframe);
+
+ yield saveToFile(data, getFileName());
+
+ dispatch({ type: TAKE_SCREENSHOT_END });
+ };
+ }
+};
diff --git a/devtools/client/responsive.html/actions/touch-simulation.js b/devtools/client/responsive.html/actions/touch-simulation.js
new file mode 100644
index 0000000000..8f98101e7a
--- /dev/null
+++ b/devtools/client/responsive.html/actions/touch-simulation.js
@@ -0,0 +1,22 @@
+/* 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 {
+ CHANGE_TOUCH_SIMULATION
+} = require("./index");
+
+module.exports = {
+
+ changeTouchSimulation(enabled) {
+ return {
+ type: CHANGE_TOUCH_SIMULATION,
+ enabled,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/actions/viewports.js b/devtools/client/responsive.html/actions/viewports.js
new file mode 100644
index 0000000000..7e51ada4a5
--- /dev/null
+++ b/devtools/client/responsive.html/actions/viewports.js
@@ -0,0 +1,81 @@
+/* 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,
+ REMOVE_DEVICE,
+ RESIZE_VIEWPORT,
+ ROTATE_VIEWPORT
+} = require("./index");
+
+module.exports = {
+
+ /**
+ * Add an additional viewport to display the document.
+ */
+ addViewport() {
+ return {
+ type: ADD_VIEWPORT,
+ };
+ },
+
+ /**
+ * Change the viewport device.
+ */
+ changeDevice(id, device) {
+ return {
+ type: CHANGE_DEVICE,
+ id,
+ device,
+ };
+ },
+
+ /**
+ * Change the viewport pixel ratio.
+ */
+ changePixelRatio(id, pixelRatio = 0) {
+ return {
+ type: CHANGE_PIXEL_RATIO,
+ id,
+ pixelRatio,
+ };
+ },
+
+ /**
+ * Remove the viewport's device assocation.
+ */
+ removeDevice(id) {
+ return {
+ type: REMOVE_DEVICE,
+ id,
+ };
+ },
+
+ /**
+ * Resize the viewport.
+ */
+ resizeViewport(id, width, height) {
+ return {
+ type: RESIZE_VIEWPORT,
+ id,
+ width,
+ height,
+ };
+ },
+
+ /**
+ * Rotate the viewport.
+ */
+ rotateViewport(id) {
+ return {
+ type: ROTATE_VIEWPORT,
+ id,
+ };
+ },
+
+};
diff --git a/devtools/client/responsive.html/app.js b/devtools/client/responsive.html/app.js
new file mode 100644
index 0000000000..739d32b0ed
--- /dev/null
+++ b/devtools/client/responsive.html/app.js
@@ -0,0 +1,209 @@
+/* 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 { createClass, createFactory, PropTypes, DOM: dom } =
+ require("devtools/client/shared/vendor/react");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+
+const {
+ updateDeviceDisplayed,
+ updateDeviceModalOpen,
+ updatePreferredDevices,
+} = require("./actions/devices");
+const { changeNetworkThrottling } = require("./actions/network-throttling");
+const { takeScreenshot } = require("./actions/screenshot");
+const { changeTouchSimulation } = require("./actions/touch-simulation");
+const {
+ changeDevice,
+ changePixelRatio,
+ removeDevice,
+ resizeViewport,
+ rotateViewport,
+} = require("./actions/viewports");
+const DeviceModal = createFactory(require("./components/device-modal"));
+const GlobalToolbar = createFactory(require("./components/global-toolbar"));
+const Viewports = createFactory(require("./components/viewports"));
+const Types = require("./types");
+
+let App = createClass({
+ displayName: "App",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ displayPixelRatio: Types.pixelRatio.value.isRequired,
+ location: Types.location.isRequired,
+ networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ touchSimulation: PropTypes.shape(Types.touchSimulation).isRequired,
+ viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
+ },
+
+ onBrowserMounted() {
+ window.postMessage({ type: "browser-mounted" }, "*");
+ },
+
+ onChangeDevice(id, device) {
+ window.postMessage({
+ type: "change-device",
+ device,
+ }, "*");
+ this.props.dispatch(changeDevice(id, device.name));
+ this.props.dispatch(changeTouchSimulation(device.touch));
+ this.props.dispatch(changePixelRatio(id, device.pixelRatio));
+ },
+
+ onChangeNetworkThrottling(enabled, profile) {
+ window.postMessage({
+ type: "change-network-throtting",
+ 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(changeTouchSimulation(enabled));
+ },
+
+ onContentResize({ width, height }) {
+ window.postMessage({
+ type: "content-resize",
+ width,
+ height,
+ }, "*");
+ },
+
+ onDeviceListUpdate(devices) {
+ updatePreferredDevices(devices);
+ },
+
+ onExit() {
+ window.postMessage({ type: "exit" }, "*");
+ },
+
+ onRemoveDevice(id) {
+ // TODO: Bug 1332754: Move messaging and logic into the action creator.
+ window.postMessage({
+ type: "remove-device",
+ }, "*");
+ this.props.dispatch(removeDevice(id));
+ this.props.dispatch(changeTouchSimulation(false));
+ this.props.dispatch(changePixelRatio(id, 0));
+ },
+
+ onResizeViewport(id, width, height) {
+ this.props.dispatch(resizeViewport(id, width, height));
+ },
+
+ onRotateViewport(id) {
+ this.props.dispatch(rotateViewport(id));
+ },
+
+ onScreenshot() {
+ this.props.dispatch(takeScreenshot());
+ },
+
+ onUpdateDeviceDisplayed(device, deviceType, displayed) {
+ this.props.dispatch(updateDeviceDisplayed(device, deviceType, displayed));
+ },
+
+ onUpdateDeviceModalOpen(isOpen) {
+ this.props.dispatch(updateDeviceModalOpen(isOpen));
+ },
+
+ render() {
+ let {
+ devices,
+ displayPixelRatio,
+ location,
+ networkThrottling,
+ screenshot,
+ touchSimulation,
+ viewports,
+ } = this.props;
+
+ let {
+ onBrowserMounted,
+ onChangeDevice,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onContentResize,
+ onDeviceListUpdate,
+ onExit,
+ onRemoveDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onScreenshot,
+ onUpdateDeviceDisplayed,
+ onUpdateDeviceModalOpen,
+ } = this;
+
+ let selectedDevice = "";
+ let selectedPixelRatio = { value: 0 };
+
+ if (viewports.length) {
+ selectedDevice = viewports[0].device;
+ selectedPixelRatio = viewports[0].pixelRatio;
+ }
+
+ return dom.div(
+ {
+ id: "app",
+ },
+ GlobalToolbar({
+ devices,
+ displayPixelRatio,
+ networkThrottling,
+ screenshot,
+ selectedDevice,
+ selectedPixelRatio,
+ touchSimulation,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onExit,
+ onScreenshot,
+ }),
+ Viewports({
+ devices,
+ location,
+ screenshot,
+ viewports,
+ onBrowserMounted,
+ onChangeDevice,
+ onContentResize,
+ onRemoveDevice,
+ onRotateViewport,
+ onResizeViewport,
+ onUpdateDeviceModalOpen,
+ }),
+ DeviceModal({
+ devices,
+ onDeviceListUpdate,
+ onUpdateDeviceDisplayed,
+ onUpdateDeviceModalOpen,
+ })
+ );
+ },
+
+});
+
+module.exports = connect(state => state)(App);
diff --git a/devtools/client/responsive.html/browser/moz.build b/devtools/client/responsive.html/browser/moz.build
new file mode 100644
index 0000000000..f99bbc443d
--- /dev/null
+++ b/devtools/client/responsive.html/browser/moz.build
@@ -0,0 +1,11 @@
+# -*- 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(
+ 'swap.js',
+ 'tunnel.js',
+ 'web-navigation.js',
+)
diff --git a/devtools/client/responsive.html/browser/swap.js b/devtools/client/responsive.html/browser/swap.js
new file mode 100644
index 0000000000..7ab0280651
--- /dev/null
+++ b/devtools/client/responsive.html/browser/swap.js
@@ -0,0 +1,309 @@
+/* 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 { Ci } = require("chrome");
+const promise = require("promise");
+const { Task } = require("devtools/shared/task");
+const { tunnelToInnerBrowser } = require("./tunnel");
+
+/**
+ * Swap page content from an existing tab into a new browser within a container
+ * page. Page state is preserved by using `swapFrameLoaders`, just like when
+ * you move a tab to a new window. This provides a seamless transition for the
+ * user since the page is not reloaded.
+ *
+ * See /devtools/docs/responsive-design-mode.md for a high level overview of how
+ * this is used in RDM. The steps described there are copied into the code
+ * below.
+ *
+ * For additional low level details about swapping browser content,
+ * see /devtools/client/responsive.html/docs/browser-swap.md.
+ *
+ * @param tab
+ * A browser tab with content to be swapped.
+ * @param containerURL
+ * URL to a page that holds an inner browser.
+ * @param getInnerBrowser
+ * Function that returns a Promise to the inner browser within the
+ * container page. It is called with the outer browser that loaded the
+ * container page.
+ */
+function swapToInnerBrowser({ tab, containerURL, getInnerBrowser }) {
+ let gBrowser = tab.ownerDocument.defaultView.gBrowser;
+ let innerBrowser;
+ let tunnel;
+
+ // Dispatch a custom event each time the _viewport content_ is swapped from one browser
+ // to another. DevTools server code uses this to follow the content if there is an
+ // active DevTools connection. While browser.xml does dispatch it's own SwapDocShells
+ // event, this one is easier for DevTools to follow because it's only emitted once per
+ // transition, instead of twice like SwapDocShells.
+ let dispatchDevToolsBrowserSwap = (from, to) => {
+ let CustomEvent = tab.ownerDocument.defaultView.CustomEvent;
+ let event = new CustomEvent("DevTools:BrowserSwap", {
+ detail: to,
+ bubbles: true,
+ });
+ from.dispatchEvent(event);
+ };
+
+ return {
+
+ start: Task.async(function* () {
+ tab.isResponsiveDesignMode = true;
+
+ // Freeze navigation temporarily to avoid "blinking" in the location bar.
+ freezeNavigationState(tab);
+
+ // 1. Create a temporary, hidden tab to load the tool UI.
+ let containerTab = gBrowser.addTab("about:blank", {
+ skipAnimation: true,
+ forceNotRemote: true,
+ });
+ gBrowser.hideTab(containerTab);
+ let containerBrowser = containerTab.linkedBrowser;
+ // Prevent the `containerURL` from ending up in the tab's history.
+ containerBrowser.loadURIWithFlags(containerURL, {
+ flags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY,
+ });
+
+ // Copy tab listener state flags to container tab. Each tab gets its own tab
+ // listener and state flags which cache document loading progress. The state flags
+ // are checked when switching tabs to update the browser UI. The later step of
+ // `swapBrowsersAndCloseOther` will fold the state back into the main tab.
+ let stateFlags = gBrowser._tabListeners.get(tab).mStateFlags;
+ gBrowser._tabListeners.get(containerTab).mStateFlags = stateFlags;
+
+ // 2. Mark the tool tab browser's docshell as active so the viewport frame
+ // is created eagerly and will be ready to swap.
+ // This line is crucial when the tool UI is loaded into a background tab.
+ // Without it, the viewport browser's frame is created lazily, leading to
+ // a multi-second delay before it would be possible to `swapFrameLoaders`.
+ // Even worse than the delay, there appears to be no obvious event fired
+ // after the frame is set lazily, so it's unclear how to know that work
+ // has finished.
+ containerBrowser.docShellIsActive = true;
+
+ // 3. Create the initial viewport inside the tool UI.
+ // The calling application will use container page loaded into the tab to
+ // do whatever it needs to create the inner browser.
+ yield tabLoaded(containerTab);
+ innerBrowser = yield getInnerBrowser(containerBrowser);
+ addXULBrowserDecorations(innerBrowser);
+ if (innerBrowser.isRemoteBrowser != tab.linkedBrowser.isRemoteBrowser) {
+ throw new Error("The inner browser's remoteness must match the " +
+ "original tab.");
+ }
+
+ // 4. Swap tab content from the regular browser tab to the browser within
+ // the viewport in the tool UI, preserving all state via
+ // `gBrowser._swapBrowserDocShells`.
+ dispatchDevToolsBrowserSwap(tab.linkedBrowser, innerBrowser);
+ gBrowser._swapBrowserDocShells(tab, innerBrowser);
+
+ // 5. Force the original browser tab to be non-remote since the tool UI
+ // must be loaded in the parent process, and we're about to swap the
+ // tool UI into this tab.
+ gBrowser.updateBrowserRemoteness(tab.linkedBrowser, false);
+
+ // 6. Swap the tool UI (with viewport showing the content) into the
+ // original browser tab and close the temporary tab used to load the
+ // tool via `swapBrowsersAndCloseOther`.
+ gBrowser.swapBrowsersAndCloseOther(tab, containerTab);
+
+ // 7. Start a tunnel from the tool tab's browser to the viewport browser
+ // so that some browser UI functions, like navigation, are connected to
+ // the content in the viewport, instead of the tool page.
+ tunnel = tunnelToInnerBrowser(tab.linkedBrowser, innerBrowser);
+ yield tunnel.start();
+
+ // Swapping browsers disconnects the find bar UI from the browser.
+ // If the find bar has been initialized, reconnect it.
+ if (gBrowser.isFindBarInitialized(tab)) {
+ let findBar = gBrowser.getFindBar(tab);
+ findBar.browser = tab.linkedBrowser;
+ if (!findBar.hidden) {
+ // Force the find bar to activate again, restoring the search string.
+ findBar.onFindCommand();
+ }
+ }
+
+ // Force the browser UI to match the new state of the tab and browser.
+ thawNavigationState(tab);
+ gBrowser.setTabTitle(tab);
+ gBrowser.updateCurrentBrowser(true);
+ }),
+
+ stop() {
+ // 1. Stop the tunnel between outer and inner browsers.
+ tunnel.stop();
+ tunnel = null;
+
+ // 2. Create a temporary, hidden tab to hold the content.
+ let contentTab = gBrowser.addTab("about:blank", {
+ skipAnimation: true,
+ });
+ gBrowser.hideTab(contentTab);
+ let contentBrowser = contentTab.linkedBrowser;
+
+ // 3. Mark the content tab browser's docshell as active so the frame
+ // is created eagerly and will be ready to swap.
+ contentBrowser.docShellIsActive = true;
+
+ // 4. Swap tab content from the browser within the viewport in the tool UI
+ // to the regular browser tab, preserving all state via
+ // `gBrowser._swapBrowserDocShells`.
+ dispatchDevToolsBrowserSwap(innerBrowser, contentBrowser);
+ gBrowser._swapBrowserDocShells(contentTab, innerBrowser);
+ innerBrowser = null;
+
+ // Copy tab listener state flags to content tab. See similar comment in `start`
+ // above for more details.
+ let stateFlags = gBrowser._tabListeners.get(tab).mStateFlags;
+ gBrowser._tabListeners.get(contentTab).mStateFlags = stateFlags;
+
+ // 5. Force the original browser tab to be remote since web content is
+ // loaded in the child process, and we're about to swap the content
+ // into this tab.
+ gBrowser.updateBrowserRemoteness(tab.linkedBrowser, true);
+
+ // 6. Swap the content into the original browser tab and close the
+ // temporary tab used to hold the content via
+ // `swapBrowsersAndCloseOther`.
+ dispatchDevToolsBrowserSwap(contentBrowser, tab.linkedBrowser);
+ gBrowser.swapBrowsersAndCloseOther(tab, contentTab);
+
+ // Swapping browsers disconnects the find bar UI from the browser.
+ // If the find bar has been initialized, reconnect it.
+ if (gBrowser.isFindBarInitialized(tab)) {
+ let findBar = gBrowser.getFindBar(tab);
+ findBar.browser = tab.linkedBrowser;
+ if (!findBar.hidden) {
+ // Force the find bar to activate again, restoring the search string.
+ findBar.onFindCommand();
+ }
+ }
+
+ gBrowser = null;
+
+ // The focus manager seems to get a little dizzy after all this swapping. If a
+ // content element had been focused inside the viewport before stopping, it will
+ // have lost focus. Activate the frame to restore expected focus.
+ tab.linkedBrowser.frameLoader.activateRemoteFrame();
+
+ delete tab.isResponsiveDesignMode;
+ },
+
+ };
+}
+
+/**
+ * Browser navigation properties we'll freeze temporarily to avoid "blinking" in the
+ * location bar, etc. caused by the containerURL peeking through before the swap is
+ * complete.
+ */
+const NAVIGATION_PROPERTIES = [
+ "currentURI",
+ "contentTitle",
+ "securityUI",
+];
+
+function freezeNavigationState(tab) {
+ // Browser navigation properties we'll freeze temporarily to avoid "blinking" in the
+ // location bar, etc. caused by the containerURL peeking through before the swap is
+ // complete.
+ for (let property of NAVIGATION_PROPERTIES) {
+ let value = tab.linkedBrowser[property];
+ Object.defineProperty(tab.linkedBrowser, property, {
+ get() {
+ return value;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+}
+
+function thawNavigationState(tab) {
+ // Thaw out the properties we froze at the beginning now that the swap is complete.
+ for (let property of NAVIGATION_PROPERTIES) {
+ delete tab.linkedBrowser[property];
+ }
+}
+
+/**
+ * Browser elements that are passed to `gBrowser._swapBrowserDocShells` are
+ * expected to have certain properties that currently exist only on
+ * <xul:browser> elements. In particular, <iframe mozbrowser> elements don't
+ * have them.
+ *
+ * Rather than duplicate the swapping code used by the browser to work around
+ * this, we stub out the missing properties needed for the swap to complete.
+ */
+function addXULBrowserDecorations(browser) {
+ if (browser.isRemoteBrowser == undefined) {
+ Object.defineProperty(browser, "isRemoteBrowser", {
+ get() {
+ return this.getAttribute("remote") == "true";
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+ if (browser.messageManager == undefined) {
+ Object.defineProperty(browser, "messageManager", {
+ get() {
+ return this.frameLoader.messageManager;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+ if (browser.outerWindowID == undefined) {
+ Object.defineProperty(browser, "outerWindowID", {
+ get() {
+ return browser._outerWindowID;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+
+ // It's not necessary for these to actually do anything. These properties are
+ // swapped between browsers in browser.xml's `swapDocShells`, and then their
+ // `swapBrowser` methods are called, so we define them here for that to work
+ // without errors. During the swap process above, these will move from the
+ // the new inner browser to the original tab's browser (step 4) and then to
+ // the temporary container tab's browser (step 7), which is then closed.
+ if (browser._remoteWebNavigationImpl == undefined) {
+ browser._remoteWebNavigationImpl = {
+ swapBrowser() {},
+ };
+ }
+ if (browser._remoteWebProgressManager == undefined) {
+ browser._remoteWebProgressManager = {
+ swapBrowser() {},
+ };
+ }
+}
+
+function tabLoaded(tab) {
+ let deferred = promise.defer();
+
+ function handle(event) {
+ if (event.originalTarget != tab.linkedBrowser.contentDocument ||
+ event.target.location.href == "about:blank") {
+ return;
+ }
+ tab.linkedBrowser.removeEventListener("load", handle, true);
+ deferred.resolve(event);
+ }
+
+ tab.linkedBrowser.addEventListener("load", handle, true);
+ return deferred.promise;
+}
+
+exports.swapToInnerBrowser = swapToInnerBrowser;
diff --git a/devtools/client/responsive.html/browser/tunnel.js b/devtools/client/responsive.html/browser/tunnel.js
new file mode 100644
index 0000000000..fdbfe89184
--- /dev/null
+++ b/devtools/client/responsive.html/browser/tunnel.js
@@ -0,0 +1,619 @@
+/* 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 { Ci } = require("chrome");
+const Services = require("Services");
+const { Task } = require("devtools/shared/task");
+const { BrowserElementWebNavigation } = require("./web-navigation");
+const { getStack } = require("devtools/shared/platform/stack");
+
+// A symbol used to hold onto the frame loader from the outer browser while tunneling.
+const FRAME_LOADER = Symbol("devtools/responsive/frame-loader");
+
+function debug(msg) {
+ // console.log(msg);
+}
+
+/**
+ * Properties swapped between browsers by browser.xml's `swapDocShells`. See also the
+ * list at /devtools/client/responsive.html/docs/browser-swap.md.
+ */
+const SWAPPED_BROWSER_STATE = [
+ "_remoteFinder",
+ "_securityUI",
+ "_documentURI",
+ "_documentContentType",
+ "_contentTitle",
+ "_characterSet",
+ "_contentPrincipal",
+ "_imageDocument",
+ "_fullZoom",
+ "_textZoom",
+ "_isSyntheticDocument",
+ "_innerWindowID",
+ "_manifestURI",
+];
+
+/**
+ * This module takes an "outer" <xul:browser> from a browser tab as described by
+ * Firefox's tabbrowser.xml and wires it up to an "inner" <iframe mozbrowser>
+ * browser element containing arbitrary page content of interest.
+ *
+ * The inner <iframe mozbrowser> element is _just_ the page content. It is not
+ * enough to to replace <xul:browser> on its own. <xul:browser> comes along
+ * with lots of associated functionality via XBL bindings defined for such
+ * elements in browser.xml and remote-browser.xml, and the Firefox UI depends on
+ * these various things to make the UI function.
+ *
+ * By mapping various methods, properties, and messages from the outer browser
+ * to the inner browser, we can control the content inside the inner browser
+ * using the standard Firefox UI elements for navigation, reloading, and more.
+ *
+ * The approaches used in this module were chosen to avoid needing changes to
+ * the core browser for this specialized use case. If we start to increase
+ * usage of <iframe mozbrowser> in the core browser, we should avoid this module
+ * and instead refactor things to work with mozbrowser directly.
+ *
+ * For the moment though, this serves as a sufficient path to connect the
+ * Firefox UI to a mozbrowser.
+ *
+ * @param outer
+ * A <xul:browser> from a regular browser tab.
+ * @param inner
+ * A <iframe mozbrowser> containing page content to be wired up to the
+ * primary browser UI via the outer browser.
+ */
+function tunnelToInnerBrowser(outer, inner) {
+ let browserWindow = outer.ownerDocument.defaultView;
+ let gBrowser = browserWindow.gBrowser;
+ let mmTunnel;
+
+ return {
+
+ start: Task.async(function* () {
+ if (outer.isRemoteBrowser) {
+ throw new Error("The outer browser must be non-remote.");
+ }
+ if (!inner.isRemoteBrowser) {
+ throw new Error("The inner browser must be remote.");
+ }
+
+ // Various browser methods access the `frameLoader` property, including:
+ // * `saveBrowser` from contentAreaUtils.js
+ // * `docShellIsActive` from remote-browser.xml
+ // * `hasContentOpener` from remote-browser.xml
+ // * `preserveLayers` from remote-browser.xml
+ // * `receiveMessage` from SessionStore.jsm
+ // In general, these methods are interested in the `frameLoader` for the content,
+ // so we redirect them to the inner browser's `frameLoader`.
+ outer[FRAME_LOADER] = outer.frameLoader;
+ Object.defineProperty(outer, "frameLoader", {
+ get() {
+ let stack = getStack();
+ // One exception is `receiveMessage` from SessionStore.jsm. SessionStore
+ // expects data updates to come in as messages targeted to a <xul:browser>.
+ // In addition, it verifies[1] correctness by checking that the received
+ // message's `targetFrameLoader` property matches the `frameLoader` of the
+ // <xul:browser>. To keep SessionStore functioning as expected, we give it the
+ // outer `frameLoader` as if nothing has changed.
+ // [1]: https://dxr.mozilla.org/mozilla-central/rev/b1b18f25c0ea69d9ee57c4198d577dfcd0129ce1/browser/components/sessionstore/SessionStore.jsm#716
+ if (stack.caller.filename.endsWith("SessionStore.jsm")) {
+ return outer[FRAME_LOADER];
+ }
+ return inner.frameLoader;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+
+ // The `outerWindowID` of the content is used by browser actions like view source
+ // and print. They send the ID down to the client to find the right content frame
+ // to act on.
+ Object.defineProperty(outer, "outerWindowID", {
+ get() {
+ return inner.outerWindowID;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+
+ // The `permanentKey` property on a <xul:browser> is used to index into various maps
+ // held by the session store. When you swap content around with
+ // `_swapBrowserDocShells`, these keys are also swapped so they follow the content.
+ // This means the key that matches the content is on the inner browser. Since we
+ // want the browser UI to believe the page content is part of the outer browser, we
+ // copy the content's `permanentKey` up to the outer browser.
+ debug("Copy inner permanentKey to outer browser");
+ outer.permanentKey = inner.permanentKey;
+
+ // Replace the outer browser's native messageManager with a message manager tunnel
+ // which we can use to route messages of interest to the inner browser instead.
+ // Note: The _actual_ messageManager accessible from
+ // `browser.frameLoader.messageManager` is not overridable and is left unchanged.
+ // Only the XBL getter `browser.messageManager` is overridden. Browser UI code
+ // always uses this getter instead of `browser.frameLoader.messageManager` directly,
+ // so this has the effect of overriding the message manager for browser UI code.
+ mmTunnel = new MessageManagerTunnel(outer, inner);
+
+ // We are tunneling to an inner browser with a specific remoteness, so it is simpler
+ // for the logic of the browser UI to assume this tab has taken on that remoteness,
+ // even though it's not true. Since the actions the browser UI performs are sent
+ // down to the inner browser by this tunnel, the tab's remoteness effectively is the
+ // remoteness of the inner browser.
+ outer.setAttribute("remote", "true");
+
+ // Clear out any cached state that references the current non-remote XBL binding,
+ // such as form fill controllers. Otherwise they will remain in place and leak the
+ // outer docshell.
+ outer.destroy();
+ // The XBL binding for remote browsers uses the message manager for many actions in
+ // the UI and that works well here, since it gives us one main thing we need to
+ // route to the inner browser (the messages), instead of having to tweak many
+ // different browser properties. It is safe to alter a XBL binding dynamically.
+ // The content within is not reloaded.
+ outer.style.MozBinding = "url(chrome://browser/content/tabbrowser.xml" +
+ "#tabbrowser-remote-browser)";
+
+ // The constructor of the new XBL binding is run asynchronously and there is no
+ // event to signal its completion. Spin an event loop to watch for properties that
+ // are set by the contructor.
+ while (!outer._remoteWebNavigation) {
+ Services.tm.currentThread.processNextEvent(true);
+ }
+
+ // Replace the `webNavigation` object with our own version which tries to use
+ // mozbrowser APIs where possible. This replaces the webNavigation object that the
+ // remote-browser.xml binding creates. We do not care about it's original value
+ // because stop() will remove the remote-browser.xml binding and these will no
+ // longer be used.
+ let webNavigation = new BrowserElementWebNavigation(inner);
+ webNavigation.copyStateFrom(inner._remoteWebNavigationImpl);
+ outer._remoteWebNavigation = webNavigation;
+ outer._remoteWebNavigationImpl = webNavigation;
+
+ // Now that we've flipped to the remote browser XBL binding, add `progressListener`
+ // onto the remote version of `webProgress`. Normally tabbrowser.xml does this step
+ // when it creates a new browser, etc. Since we manually changed the XBL binding
+ // above, it caused a fresh webProgress object to be created which does not have any
+ // listeners added. So, we get the listener that gBrowser is using for the tab and
+ // reattach it here.
+ let tab = gBrowser.getTabForBrowser(outer);
+ let filteredProgressListener = gBrowser._tabFilters.get(tab);
+ outer.webProgress.addProgressListener(filteredProgressListener);
+
+ // Add the inner browser to tabbrowser's WeakMap from browser to tab. This assists
+ // with tabbrowser's processing of some events such as MozLayerTreeReady which
+ // bubble up from the remote content frame and trigger tabbrowser to lookup the tab
+ // associated with the browser that triggered the event.
+ gBrowser._tabForBrowser.set(inner, tab);
+
+ // All of the browser state from content was swapped onto the inner browser. Pull
+ // this state up to the outer browser.
+ for (let property of SWAPPED_BROWSER_STATE) {
+ outer[property] = inner[property];
+ }
+
+ // Expose `PopupNotifications` on the content's owner global.
+ // This is used by PermissionUI.jsm for permission doorhangers.
+ // Note: This pollutes the responsive.html tool UI's global.
+ Object.defineProperty(inner.ownerGlobal, "PopupNotifications", {
+ get() {
+ return outer.ownerGlobal.PopupNotifications;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+
+ // Expose `whereToOpenLink` on the content's owner global.
+ // This is used by ContentClick.jsm when opening links in ways other than just
+ // navigating the viewport.
+ // Note: This pollutes the responsive.html tool UI's global.
+ Object.defineProperty(inner.ownerGlobal, "whereToOpenLink", {
+ get() {
+ return outer.ownerGlobal.whereToOpenLink;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+
+ // Add mozbrowser event handlers
+ inner.addEventListener("mozbrowseropenwindow", this);
+ }),
+
+ handleEvent(event) {
+ if (event.type != "mozbrowseropenwindow") {
+ return;
+ }
+
+ // Minimal support for <a target/> and window.open() which just ensures we at
+ // least open them somewhere (in a new tab). The following things are ignored:
+ // * Specific target names (everything treated as _blank)
+ // * Window features
+ // * window.opener
+ // These things are deferred for now, since content which does depend on them seems
+ // outside the main focus of RDM.
+ let { detail } = event;
+ event.preventDefault();
+ let uri = Services.io.newURI(detail.url, null, null);
+ // This API is used mainly because it's near the path used for <a target/> with
+ // regular browser tabs (which calls `openURIInFrame`). The more elaborate APIs
+ // that support openers, window features, etc. didn't seem callable from JS and / or
+ // this event doesn't give enough info to use them.
+ browserWindow.browserDOMWindow
+ .openURI(uri, null, Ci.nsIBrowserDOMWindow.OPEN_NEWTAB,
+ Ci.nsIBrowserDOMWindow.OPEN_NEW);
+ },
+
+ stop() {
+ let tab = gBrowser.getTabForBrowser(outer);
+ let filteredProgressListener = gBrowser._tabFilters.get(tab);
+
+ // The browser's state has changed over time while the tunnel was active. Push the
+ // the current state down to the inner browser, so that it follows the content in
+ // case that browser will be swapped elsewhere.
+ for (let property of SWAPPED_BROWSER_STATE) {
+ inner[property] = outer[property];
+ }
+
+ // Remove the inner browser from the WeakMap from browser to tab.
+ gBrowser._tabForBrowser.delete(inner);
+
+ // Remove the progress listener we added manually.
+ outer.webProgress.removeProgressListener(filteredProgressListener);
+
+ // Reset the XBL binding back to the default.
+ outer.destroy();
+ outer.style.MozBinding = "";
+
+ // Reset @remote since this is now back to a regular, non-remote browser
+ outer.setAttribute("remote", "false");
+
+ // Delete browser window properties exposed on content's owner global
+ delete inner.ownerGlobal.PopupNotifications;
+ delete inner.ownerGlobal.whereToOpenLink;
+
+ // Remove mozbrowser event handlers
+ inner.removeEventListener("mozbrowseropenwindow", this);
+
+ mmTunnel.destroy();
+ mmTunnel = null;
+
+ // Reset overridden XBL properties and methods. Deleting the override
+ // means it will fallback to the original XBL binding definitions which
+ // are on the prototype.
+ delete outer.frameLoader;
+ delete outer[FRAME_LOADER];
+ delete outer.outerWindowID;
+
+ // Invalidate outer's permanentKey so that SessionStore stops associating
+ // things that happen to the outer browser with the content inside in the
+ // inner browser.
+ outer.permanentKey = { id: "zombie" };
+
+ browserWindow = null;
+ gBrowser = null;
+ },
+
+ };
+}
+
+exports.tunnelToInnerBrowser = tunnelToInnerBrowser;
+
+/**
+ * This module allows specific messages of interest to be directed from the
+ * outer browser to the inner browser (and vice versa) in a targetted fashion
+ * without having to touch the original code paths that use them.
+ */
+function MessageManagerTunnel(outer, inner) {
+ if (outer.isRemoteBrowser) {
+ throw new Error("The outer browser must be non-remote.");
+ }
+ this.outer = outer;
+ this.inner = inner;
+ this.tunneledMessageNames = new Set();
+ this.init();
+}
+
+MessageManagerTunnel.prototype = {
+
+ /**
+ * Most message manager methods are left alone and are just passed along to
+ * the outer browser's real message manager.
+ */
+ PASS_THROUGH_METHODS: [
+ "killChild",
+ "assertPermission",
+ "assertContainApp",
+ "assertAppHasPermission",
+ "assertAppHasStatus",
+ "removeDelayedFrameScript",
+ "getDelayedFrameScripts",
+ "loadProcessScript",
+ "removeDelayedProcessScript",
+ "getDelayedProcessScripts",
+ "addWeakMessageListener",
+ "removeWeakMessageListener",
+ ],
+
+ /**
+ * The following methods are overridden with special behavior while tunneling.
+ */
+ OVERRIDDEN_METHODS: [
+ "loadFrameScript",
+ "addMessageListener",
+ "removeMessageListener",
+ "sendAsyncMessage",
+ ],
+
+ OUTER_TO_INNER_MESSAGES: [
+ // Messages sent from remote-browser.xml
+ "Browser:PurgeSessionHistory",
+ "InPermitUnload",
+ "PermitUnload",
+ // Messages sent from browser.js
+ "Browser:Reload",
+ // Messages sent from SelectParentHelper.jsm
+ "Forms:DismissedDropDown",
+ "Forms:MouseOut",
+ "Forms:MouseOver",
+ "Forms:SelectDropDownItem",
+ // Messages sent from SessionStore.jsm
+ "SessionStore:flush",
+ ],
+
+ INNER_TO_OUTER_MESSAGES: [
+ // Messages sent to RemoteWebProgress.jsm
+ "Content:LoadURIResult",
+ "Content:LocationChange",
+ "Content:ProgressChange",
+ "Content:SecurityChange",
+ "Content:StateChange",
+ "Content:StatusChange",
+ // Messages sent to remote-browser.xml
+ "DOMTitleChanged",
+ "ImageDocumentLoaded",
+ "Forms:ShowDropDown",
+ "Forms:HideDropDown",
+ "InPermitUnload",
+ "PermitUnload",
+ // Messages sent to tabbrowser.xml
+ "contextmenu",
+ // Messages sent to SelectParentHelper.jsm
+ "Forms:UpdateDropDown",
+ // Messages sent to browser.js
+ "PageVisibility:Hide",
+ "PageVisibility:Show",
+ // Messages sent to SessionStore.jsm
+ "SessionStore:update",
+ // Messages sent to BrowserTestUtils.jsm
+ "browser-test-utils:loadEvent",
+ ],
+
+ OUTER_TO_INNER_MESSAGE_PREFIXES: [
+ // Messages sent from nsContextMenu.js
+ "ContextMenu:",
+ // Messages sent from DevTools
+ "debug:",
+ // Messages sent from findbar.xml
+ "Findbar:",
+ // Messages sent from RemoteFinder.jsm
+ "Finder:",
+ // Messages sent from InlineSpellChecker.jsm
+ "InlineSpellChecker:",
+ // Messages sent from pageinfo.js
+ "PageInfo:",
+ // Messages sent from printUtils.js
+ "Printing:",
+ // Messages sent from browser-social.js
+ "Social:",
+ "PageMetadata:",
+ // Messages sent from viewSourceUtils.js
+ "ViewSource:",
+ ],
+
+ INNER_TO_OUTER_MESSAGE_PREFIXES: [
+ // Messages sent to nsContextMenu.js
+ "ContextMenu:",
+ // Messages sent to DevTools
+ "debug:",
+ // Messages sent to findbar.xml
+ "Findbar:",
+ // Messages sent to RemoteFinder.jsm
+ "Finder:",
+ // Messages sent to pageinfo.js
+ "PageInfo:",
+ // Messages sent to printUtils.js
+ "Printing:",
+ // Messages sent to browser-social.js
+ "Social:",
+ "PageMetadata:",
+ // Messages sent to viewSourceUtils.js
+ "ViewSource:",
+ ],
+
+ OUTER_TO_INNER_FRAME_SCRIPTS: [
+ // DevTools server for OOP frames
+ "resource://devtools/server/child.js"
+ ],
+
+ get outerParentMM() {
+ if (!this.outer[FRAME_LOADER]) {
+ return null;
+ }
+ return this.outer[FRAME_LOADER].messageManager;
+ },
+
+ get outerChildMM() {
+ // This is only possible because we require the outer browser to be
+ // non-remote, so we're able to reach into its window and use the child
+ // side message manager there.
+ let docShell = this.outer[FRAME_LOADER].docShell;
+ return docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIContentFrameMessageManager);
+ },
+
+ get innerParentMM() {
+ if (!this.inner.frameLoader) {
+ return null;
+ }
+ return this.inner.frameLoader.messageManager;
+ },
+
+ init() {
+ for (let method of this.PASS_THROUGH_METHODS) {
+ // Workaround bug 449811 to ensure a fresh binding each time through the loop
+ let _method = method;
+ this[_method] = (...args) => {
+ if (!this.outerParentMM) {
+ return null;
+ }
+ return this.outerParentMM[_method](...args);
+ };
+ }
+
+ for (let name of this.INNER_TO_OUTER_MESSAGES) {
+ this.innerParentMM.addMessageListener(name, this);
+ this.tunneledMessageNames.add(name);
+ }
+
+ Services.obs.addObserver(this, "message-manager-close", false);
+
+ // Replace the outer browser's messageManager with this tunnel
+ Object.defineProperty(this.outer, "messageManager", {
+ value: this,
+ writable: false,
+ configurable: true,
+ enumerable: true,
+ });
+ },
+
+ destroy() {
+ if (this.destroyed) {
+ return;
+ }
+ this.destroyed = true;
+ debug("Destroy tunnel");
+
+ // Watch for the messageManager to close. In most cases, the caller will stop the
+ // tunnel gracefully before this, but when the browser window closes or application
+ // exits, we may not see the high-level close events.
+ Services.obs.removeObserver(this, "message-manager-close");
+
+ // Reset the messageManager. Deleting the override means it will fallback to the
+ // original XBL binding definitions which are on the prototype.
+ delete this.outer.messageManager;
+
+ for (let name of this.tunneledMessageNames) {
+ this.innerParentMM.removeMessageListener(name, this);
+ }
+
+ // Some objects may have cached this tunnel as the messageManager for a frame. To
+ // ensure it keeps working after tunnel close, rewrite the overidden methods as pass
+ // through methods.
+ for (let method of this.OVERRIDDEN_METHODS) {
+ // Workaround bug 449811 to ensure a fresh binding each time through the loop
+ let _method = method;
+ this[_method] = (...args) => {
+ if (!this.outerParentMM) {
+ return null;
+ }
+ return this.outerParentMM[_method](...args);
+ };
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic != "message-manager-close") {
+ return;
+ }
+ if (subject == this.innerParentMM) {
+ debug("Inner messageManager has closed");
+ this.destroy();
+ }
+ if (subject == this.outerParentMM) {
+ debug("Outer messageManager has closed");
+ this.destroy();
+ }
+ },
+
+ loadFrameScript(url, ...args) {
+ debug(`Calling loadFrameScript for ${url}`);
+
+ if (!this.OUTER_TO_INNER_FRAME_SCRIPTS.includes(url)) {
+ debug(`Should load ${url} into inner?`);
+ this.outerParentMM.loadFrameScript(url, ...args);
+ return;
+ }
+
+ debug(`Load ${url} into inner`);
+ this.innerParentMM.loadFrameScript(url, ...args);
+ },
+
+ addMessageListener(name, ...args) {
+ debug(`Calling addMessageListener for ${name}`);
+
+ debug(`Add outer listener for ${name}`);
+ // Add an outer listener, just like a simple pass through
+ this.outerParentMM.addMessageListener(name, ...args);
+
+ // If the message name is part of a prefix we're tunneling, we also need to add the
+ // tunnel as an inner listener.
+ if (this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix))) {
+ debug(`Add inner listener for ${name}`);
+ this.innerParentMM.addMessageListener(name, this);
+ this.tunneledMessageNames.add(name);
+ }
+ },
+
+ removeMessageListener(name, ...args) {
+ debug(`Calling removeMessageListener for ${name}`);
+
+ debug(`Remove outer listener for ${name}`);
+ // Remove an outer listener, just like a simple pass through
+ this.outerParentMM.removeMessageListener(name, ...args);
+
+ // Leave the tunnel as an inner listener for the case of prefix messages to avoid
+ // tracking counts of add calls. The inner listener will get removed on destroy.
+ },
+
+ sendAsyncMessage(name, ...args) {
+ debug(`Calling sendAsyncMessage for ${name}`);
+
+ if (!this._shouldTunnelOuterToInner(name)) {
+ debug(`Should ${name} go to inner?`);
+ this.outerParentMM.sendAsyncMessage(name, ...args);
+ return;
+ }
+
+ debug(`${name} outer -> inner`);
+ this.innerParentMM.sendAsyncMessage(name, ...args);
+ },
+
+ receiveMessage({ name, data, objects, principal, sync }) {
+ if (!this._shouldTunnelInnerToOuter(name)) {
+ debug(`Received unexpected message ${name}`);
+ return undefined;
+ }
+
+ debug(`${name} inner -> outer, sync: ${sync}`);
+ if (sync) {
+ return this.outerChildMM.sendSyncMessage(name, data, objects, principal);
+ }
+ this.outerChildMM.sendAsyncMessage(name, data, objects, principal);
+ return undefined;
+ },
+
+ _shouldTunnelOuterToInner(name) {
+ return this.OUTER_TO_INNER_MESSAGES.includes(name) ||
+ this.OUTER_TO_INNER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix));
+ },
+
+ _shouldTunnelInnerToOuter(name) {
+ return this.INNER_TO_OUTER_MESSAGES.includes(name) ||
+ this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix));
+ },
+
+};
diff --git a/devtools/client/responsive.html/browser/web-navigation.js b/devtools/client/responsive.html/browser/web-navigation.js
new file mode 100644
index 0000000000..4519df0bdc
--- /dev/null
+++ b/devtools/client/responsive.html/browser/web-navigation.js
@@ -0,0 +1,179 @@
+/* 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 { Ci, Cu, Cr } = require("chrome");
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+const Services = require("Services");
+const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
+
+function readInputStreamToString(stream) {
+ return NetUtil.readInputStreamToString(stream, stream.available());
+}
+
+/**
+ * This object aims to provide the nsIWebNavigation interface for mozbrowser elements.
+ * nsIWebNavigation is one of the interfaces expected on <xul:browser>s, so this wrapper
+ * helps mozbrowser elements support this.
+ *
+ * It attempts to use the mozbrowser API wherever possible, however some methods don't
+ * exist yet, so we fallback to the WebNavigation frame script messages in those cases.
+ * Ideally the mozbrowser API would eventually be extended to cover all properties and
+ * methods used here.
+ *
+ * This is largely copied from RemoteWebNavigation.js, which uses the message manager to
+ * perform all actions.
+ */
+function BrowserElementWebNavigation(browser) {
+ this._browser = browser;
+}
+
+BrowserElementWebNavigation.prototype = {
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIWebNavigation,
+ Ci.nsISupports
+ ]),
+
+ get _mm() {
+ return this._browser.frameLoader.messageManager;
+ },
+
+ canGoBack: false,
+ canGoForward: false,
+
+ goBack() {
+ this._browser.goBack();
+ },
+
+ goForward() {
+ this._browser.goForward();
+ },
+
+ gotoIndex(index) {
+ // No equivalent in the current BrowserElement API
+ this._sendMessage("WebNavigation:GotoIndex", { index });
+ },
+
+ loadURI(uri, flags, referrer, postData, headers) {
+ // No equivalent in the current BrowserElement API
+ this.loadURIWithOptions(uri, flags, referrer,
+ Ci.nsIHttpChannel.REFERRER_POLICY_DEFAULT,
+ postData, headers, null);
+ },
+
+ loadURIWithOptions(uri, flags, referrer, referrerPolicy, postData, headers,
+ baseURI) {
+ // No equivalent in the current BrowserElement API
+ this._sendMessage("WebNavigation:LoadURI", {
+ uri,
+ flags,
+ referrer: referrer ? referrer.spec : null,
+ referrerPolicy: referrerPolicy,
+ postData: postData ? readInputStreamToString(postData) : null,
+ headers: headers ? readInputStreamToString(headers) : null,
+ baseURI: baseURI ? baseURI.spec : null,
+ });
+ },
+
+ setOriginAttributesBeforeLoading(originAttributes) {
+ // No equivalent in the current BrowserElement API
+ this._sendMessage("WebNavigation:SetOriginAttributes", {
+ originAttributes,
+ });
+ },
+
+ reload(flags) {
+ let hardReload = false;
+ if (flags & this.LOAD_FLAGS_BYPASS_PROXY ||
+ flags & this.LOAD_FLAGS_BYPASS_CACHE) {
+ hardReload = true;
+ }
+ this._browser.reload(hardReload);
+ },
+
+ stop(flags) {
+ // No equivalent in the current BrowserElement API
+ this._sendMessage("WebNavigation:Stop", { flags });
+ },
+
+ get document() {
+ return this._browser.contentDocument;
+ },
+
+ _currentURI: null,
+ get currentURI() {
+ if (!this._currentURI) {
+ this._currentURI = Services.io.newURI("about:blank", null, null);
+ }
+ return this._currentURI;
+ },
+ set currentURI(uri) {
+ this._browser.src = uri.spec;
+ },
+
+ referringURI: null,
+
+ // Bug 1233803 - accessing the sessionHistory of remote browsers should be
+ // done in content scripts.
+ get sessionHistory() {
+ throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+ },
+ set sessionHistory(value) {
+ throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+ },
+
+ _sendMessage(message, data) {
+ try {
+ this._mm.sendAsyncMessage(message, data);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ },
+
+ swapBrowser(browser) {
+ throw Cr.NS_ERROR_NOT_IMPLEMENTED;
+ },
+
+ copyStateFrom(otherWebNavigation) {
+ const state = [
+ "canGoBack",
+ "canGoForward",
+ "_currentURI",
+ ];
+ for (let property of state) {
+ this[property] = otherWebNavigation[property];
+ }
+ },
+
+};
+
+const FLAGS = [
+ "LOAD_FLAGS_MASK",
+ "LOAD_FLAGS_NONE",
+ "LOAD_FLAGS_IS_REFRESH",
+ "LOAD_FLAGS_IS_LINK",
+ "LOAD_FLAGS_BYPASS_HISTORY",
+ "LOAD_FLAGS_REPLACE_HISTORY",
+ "LOAD_FLAGS_BYPASS_CACHE",
+ "LOAD_FLAGS_BYPASS_PROXY",
+ "LOAD_FLAGS_CHARSET_CHANGE",
+ "LOAD_FLAGS_STOP_CONTENT",
+ "LOAD_FLAGS_FROM_EXTERNAL",
+ "LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP",
+ "LOAD_FLAGS_FIRST_LOAD",
+ "LOAD_FLAGS_ALLOW_POPUPS",
+ "LOAD_FLAGS_BYPASS_CLASSIFIER",
+ "LOAD_FLAGS_FORCE_ALLOW_COOKIES",
+ "STOP_NETWORK",
+ "STOP_CONTENT",
+ "STOP_ALL",
+];
+
+for (let flag of FLAGS) {
+ BrowserElementWebNavigation.prototype[flag] = Ci.nsIWebNavigation[flag];
+}
+
+exports.BrowserElementWebNavigation = BrowserElementWebNavigation;
diff --git a/devtools/client/responsive.html/components/browser.js b/devtools/client/responsive.html/components/browser.js
new file mode 100644
index 0000000000..f2902905b5
--- /dev/null
+++ b/devtools/client/responsive.html/components/browser.js
@@ -0,0 +1,149 @@
+/* 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 { Task } = require("devtools/shared/task");
+const flags = require("devtools/shared/flags");
+const { getToplevelWindow } = require("sdk/window/utils");
+const { DOM: dom, createClass, addons, PropTypes } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const e10s = require("../utils/e10s");
+const message = require("../utils/message");
+
+module.exports = createClass({
+
+ /**
+ * This component is not allowed to depend directly on frequently changing
+ * data (width, height) due to the use of `dangerouslySetInnerHTML` below.
+ * Any changes in props will cause the <iframe> to be removed and added again,
+ * throwing away the current state of the page.
+ */
+ displayName: "Browser",
+
+ propTypes: {
+ location: Types.location.isRequired,
+ swapAfterMount: PropTypes.bool.isRequired,
+ onBrowserMounted: PropTypes.func.isRequired,
+ onContentResize: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ /**
+ * Once the browser element has mounted, load the frame script and enable
+ * various features, like floating scrollbars.
+ */
+ componentDidMount: Task.async(function* () {
+ // If we are not swapping browsers after mount, it's safe to start the frame
+ // script now.
+ if (!this.props.swapAfterMount) {
+ yield this.startFrameScript();
+ }
+
+ // Notify manager.js that this browser has mounted, so that it can trigger
+ // a swap if needed and continue with the rest of its startup.
+ this.props.onBrowserMounted();
+
+ // If we are swapping browsers after mount, wait for the swap to complete
+ // and start the frame script after that.
+ if (this.props.swapAfterMount) {
+ yield message.wait(window, "start-frame-script");
+ yield this.startFrameScript();
+ message.post(window, "start-frame-script:done");
+ }
+
+ // Stop the frame script when requested in the future.
+ message.wait(window, "stop-frame-script").then(() => {
+ this.stopFrameScript();
+ });
+ }),
+
+ onContentResize(msg) {
+ let { onContentResize } = this.props;
+ let { width, height } = msg.data;
+ onContentResize({
+ width,
+ height,
+ });
+ },
+
+ startFrameScript: Task.async(function* () {
+ let { onContentResize } = this;
+ let browser = this.refs.browserContainer.querySelector("iframe.browser");
+ let mm = browser.frameLoader.messageManager;
+
+ // Notify tests when the content has received a resize event. This is not
+ // quite the same timing as when we _set_ a new size around the browser,
+ // since it still needs to do async work before the content is actually
+ // resized to match.
+ e10s.on(mm, "OnContentResize", onContentResize);
+
+ let ready = e10s.once(mm, "ChildScriptReady");
+ mm.loadFrameScript("resource://devtools/client/responsivedesign/" +
+ "responsivedesign-child.js", true);
+ yield ready;
+
+ let browserWindow = getToplevelWindow(window);
+ let requiresFloatingScrollbars =
+ !browserWindow.matchMedia("(-moz-overlay-scrollbars)").matches;
+
+ yield e10s.request(mm, "Start", {
+ requiresFloatingScrollbars,
+ // Tests expect events on resize to yield on various size changes
+ notifyOnResize: flags.testing,
+ });
+ }),
+
+ stopFrameScript: Task.async(function* () {
+ let { onContentResize } = this;
+
+ let browser = this.refs.browserContainer.querySelector("iframe.browser");
+ let mm = browser.frameLoader.messageManager;
+ e10s.off(mm, "OnContentResize", onContentResize);
+ yield e10s.request(mm, "Stop");
+ message.post(window, "stop-frame-script:done");
+ }),
+
+ render() {
+ let {
+ location,
+ } = this.props;
+
+ // innerHTML expects & to be an HTML entity
+ location = location.replace(/&/g, "&amp;");
+
+ return dom.div(
+ {
+ ref: "browserContainer",
+ className: "browser-container",
+
+ /**
+ * React uses a whitelist for attributes, so we need some way to set
+ * attributes it does not know about, such as @mozbrowser. If this were
+ * the only issue, we could use componentDidMount or ref: node => {} to
+ * set the atttibutes. In the case of @remote, the attribute must be set
+ * before the element is added to the DOM to have any effect, which we
+ * are able to do with this approach.
+ *
+ * @noisolation and @allowfullscreen are needed so that these frames
+ * have the same access to browser features as regular browser tabs.
+ * The `swapFrameLoaders` platform API we use compares such features
+ * before allowing the swap to proceed.
+ */
+ dangerouslySetInnerHTML: {
+ __html: `<iframe class="browser" mozbrowser="true" remote="true"
+ noisolation="true" allowfullscreen="true"
+ src="${location}" width="100%" height="100%">
+ </iframe>`
+ }
+ }
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/device-modal.js b/devtools/client/responsive.html/components/device-modal.js
new file mode 100644
index 0000000000..d28b974725
--- /dev/null
+++ b/devtools/client/responsive.html/components/device-modal.js
@@ -0,0 +1,181 @@
+/* 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 { DOM: dom, createClass, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+const { getStr } = require("../utils/l10n");
+const Types = require("../types");
+
+module.exports = createClass({
+ displayName: "DeviceModal",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ onDeviceListUpdate: PropTypes.func.isRequired,
+ onUpdateDeviceDisplayed: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ getInitialState() {
+ return {};
+ },
+
+ componentDidMount() {
+ window.addEventListener("keydown", this.onKeyDown, true);
+ },
+
+ componentWillReceiveProps(nextProps) {
+ let {
+ devices,
+ } = nextProps;
+
+ for (let type of devices.types) {
+ for (let device of devices[type]) {
+ this.setState({
+ [device.name]: device.displayed,
+ });
+ }
+ }
+ },
+
+ componentWillUnmount() {
+ window.removeEventListener("keydown", this.onKeyDown, true);
+ },
+
+ onDeviceCheckboxClick({ target }) {
+ this.setState({
+ [target.value]: !this.state[target.value]
+ });
+ },
+
+ onDeviceModalSubmit() {
+ let {
+ devices,
+ onDeviceListUpdate,
+ onUpdateDeviceDisplayed,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ let preferredDevices = {
+ "added": new Set(),
+ "removed": new Set(),
+ };
+
+ for (let type of devices.types) {
+ for (let device of devices[type]) {
+ let 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);
+ onUpdateDeviceModalOpen(false);
+ },
+
+ onKeyDown(event) {
+ if (!this.props.devices.isModalOpen) {
+ return;
+ }
+ // Escape keycode
+ if (event.keyCode === 27) {
+ let {
+ onUpdateDeviceModalOpen
+ } = this.props;
+ onUpdateDeviceModalOpen(false);
+ }
+ },
+
+ render() {
+ let {
+ devices,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ const sortedDevices = {};
+ for (let type of devices.types) {
+ sortedDevices[type] = Object.assign([], devices[type])
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ return dom.div(
+ {
+ id: "device-modal-wrapper",
+ className: this.props.devices.isModalOpen ? "opened" : "closed",
+ },
+ dom.div(
+ {
+ className: "device-modal container",
+ },
+ dom.button({
+ id: "device-close-button",
+ className: "toolbar-button devtools-button",
+ onClick: () => onUpdateDeviceModalOpen(false),
+ }),
+ dom.div(
+ {
+ className: "device-modal-content",
+ },
+ devices.types.map(type => {
+ return dom.div(
+ {
+ className: "device-type",
+ key: type,
+ },
+ dom.header(
+ {
+ className: "device-header",
+ },
+ type
+ ),
+ sortedDevices[type].map(device => {
+ return dom.label(
+ {
+ className: "device-label",
+ key: device.name,
+ },
+ dom.input({
+ className: "device-input-checkbox",
+ type: "checkbox",
+ value: device.name,
+ checked: this.state[device.name],
+ onChange: this.onDeviceCheckboxClick,
+ }),
+ device.name
+ );
+ })
+ );
+ })
+ ),
+ dom.button(
+ {
+ id: "device-submit-button",
+ onClick: this.onDeviceModalSubmit,
+ },
+ getStr("responsive.done")
+ )
+ ),
+ dom.div(
+ {
+ className: "modal-overlay",
+ onClick: () => onUpdateDeviceModalOpen(false),
+ }
+ )
+ );
+ },
+});
diff --git a/devtools/client/responsive.html/components/device-selector.js b/devtools/client/responsive.html/components/device-selector.js
new file mode 100644
index 0000000000..3215ce5fb0
--- /dev/null
+++ b/devtools/client/responsive.html/components/device-selector.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 { getStr } = require("../utils/l10n");
+const { DOM: dom, createClass, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const OPEN_DEVICE_MODAL_VALUE = "OPEN_DEVICE_MODAL";
+
+module.exports = createClass({
+ displayName: "DeviceSelector",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ onSelectChange({ target }) {
+ let {
+ devices,
+ onChangeDevice,
+ onResizeViewport,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ if (target.value === OPEN_DEVICE_MODAL_VALUE) {
+ onUpdateDeviceModalOpen(true);
+ return;
+ }
+ for (let type of devices.types) {
+ for (let device of devices[type]) {
+ if (device.name === target.value) {
+ onResizeViewport(device.width, device.height);
+ onChangeDevice(device);
+ return;
+ }
+ }
+ }
+ },
+
+ render() {
+ let {
+ devices,
+ selectedDevice,
+ } = this.props;
+
+ let options = [];
+ for (let type of devices.types) {
+ for (let device of devices[type]) {
+ if (device.displayed) {
+ options.push(device);
+ }
+ }
+ }
+
+ options.sort(function (a, b) {
+ return a.name.localeCompare(b.name);
+ });
+
+ let selectClass = "viewport-device-selector";
+ if (selectedDevice) {
+ selectClass += " selected";
+ }
+
+ let state = devices.listState;
+ let listContent;
+
+ if (state == Types.deviceListState.LOADED) {
+ listContent = [dom.option({
+ value: "",
+ title: "",
+ disabled: true,
+ hidden: true,
+ }, getStr("responsive.noDeviceSelected")),
+ options.map(device => {
+ return dom.option({
+ key: device.name,
+ value: device.name,
+ title: "",
+ }, device.name);
+ }),
+ dom.option({
+ value: OPEN_DEVICE_MODAL_VALUE,
+ title: "",
+ }, getStr("responsive.editDeviceList"))];
+ } else if (state == Types.deviceListState.LOADING
+ || state == Types.deviceListState.INITIALIZED) {
+ listContent = [dom.option({
+ value: "",
+ title: "",
+ disabled: true,
+ }, getStr("responsive.deviceListLoading"))];
+ } else if (state == Types.deviceListState.ERROR) {
+ listContent = [dom.option({
+ value: "",
+ title: "",
+ disabled: true,
+ }, getStr("responsive.deviceListError"))];
+ }
+
+ return dom.select(
+ {
+ className: selectClass,
+ value: selectedDevice,
+ title: selectedDevice,
+ onChange: this.onSelectChange,
+ disabled: (state !== Types.deviceListState.LOADED),
+ },
+ ...listContent
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/dpr-selector.js b/devtools/client/responsive.html/components/dpr-selector.js
new file mode 100644
index 0000000000..31b8db1c21
--- /dev/null
+++ b/devtools/client/responsive.html/components/dpr-selector.js
@@ -0,0 +1,131 @@
+/* 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 { DOM: dom, createClass, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const { getStr, getFormatStr } = require("../utils/l10n");
+
+const PIXEL_RATIO_PRESET = [1, 2, 3];
+
+const createVisibleOption = value =>
+ dom.option({
+ value,
+ title: value,
+ key: value,
+ }, value);
+
+const createHiddenOption = value =>
+ dom.option({
+ value,
+ title: value,
+ hidden: true,
+ disabled: true,
+ }, value);
+
+module.exports = createClass({
+ displayName: "DPRSelector",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ displayPixelRatio: Types.pixelRatio.value.isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ selectedPixelRatio: PropTypes.shape(Types.pixelRatio).isRequired,
+ onChangePixelRatio: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ getInitialState() {
+ return {
+ isFocused: false
+ };
+ },
+
+ onFocusChange({type}) {
+ this.setState({
+ isFocused: type === "focus"
+ });
+ },
+
+ onSelectChange({ target }) {
+ this.props.onChangePixelRatio(+target.value);
+ },
+
+ render() {
+ let {
+ devices,
+ displayPixelRatio,
+ selectedDevice,
+ selectedPixelRatio,
+ } = this.props;
+
+ let hiddenOptions = [];
+
+ for (let type of devices.types) {
+ for (let device of devices[type]) {
+ if (device.displayed &&
+ !hiddenOptions.includes(device.pixelRatio) &&
+ !PIXEL_RATIO_PRESET.includes(device.pixelRatio)) {
+ hiddenOptions.push(device.pixelRatio);
+ }
+ }
+ }
+
+ if (!PIXEL_RATIO_PRESET.includes(displayPixelRatio)) {
+ hiddenOptions.push(displayPixelRatio);
+ }
+
+ let state = devices.listState;
+ let isDisabled = (state !== Types.deviceListState.LOADED) || (selectedDevice !== "");
+ let selectorClass = "";
+ let title;
+
+ if (isDisabled) {
+ selectorClass += " disabled";
+ title = getFormatStr("responsive.autoDPR", selectedDevice);
+ } else {
+ title = getStr("responsive.devicePixelRatio");
+
+ if (selectedPixelRatio.value) {
+ selectorClass += " selected";
+ }
+ }
+
+ if (this.state.isFocused) {
+ selectorClass += " focused";
+ }
+
+ let listContent = PIXEL_RATIO_PRESET.map(createVisibleOption);
+
+ if (state == Types.deviceListState.LOADED) {
+ listContent = listContent.concat(hiddenOptions.map(createHiddenOption));
+ }
+
+ return dom.label(
+ {
+ id: "global-dpr-selector",
+ className: selectorClass,
+ title,
+ },
+ "DPR",
+ dom.select(
+ {
+ value: selectedPixelRatio.value || displayPixelRatio,
+ disabled: isDisabled,
+ onChange: this.onSelectChange,
+ onFocus: this.onFocusChange,
+ onBlur: this.onFocusChange,
+ },
+ ...listContent
+ )
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/global-toolbar.js b/devtools/client/responsive.html/components/global-toolbar.js
new file mode 100644
index 0000000000..6c31fa338f
--- /dev/null
+++ b/devtools/client/responsive.html/components/global-toolbar.js
@@ -0,0 +1,101 @@
+/* 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 { DOM: dom, createClass, createFactory, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+
+const { getStr } = require("../utils/l10n");
+const Types = require("../types");
+const DPRSelector = createFactory(require("./dpr-selector"));
+const NetworkThrottlingSelector = createFactory(require("./network-throttling-selector"));
+
+module.exports = createClass({
+ displayName: "GlobalToolbar",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ displayPixelRatio: Types.pixelRatio.value.isRequired,
+ networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ selectedPixelRatio: PropTypes.shape(Types.pixelRatio).isRequired,
+ touchSimulation: PropTypes.shape(Types.touchSimulation).isRequired,
+ onChangeNetworkThrottling: PropTypes.func.isRequired,
+ onChangePixelRatio: PropTypes.func.isRequired,
+ onChangeTouchSimulation: PropTypes.func.isRequired,
+ onExit: PropTypes.func.isRequired,
+ onScreenshot: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ render() {
+ let {
+ devices,
+ displayPixelRatio,
+ networkThrottling,
+ screenshot,
+ selectedDevice,
+ selectedPixelRatio,
+ touchSimulation,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onExit,
+ onScreenshot,
+ } = this.props;
+
+ let touchButtonClass = "toolbar-button devtools-button";
+ if (touchSimulation.enabled) {
+ touchButtonClass += " active";
+ }
+
+ return dom.header(
+ {
+ id: "global-toolbar",
+ className: "container",
+ },
+ dom.span(
+ {
+ className: "title",
+ },
+ getStr("responsive.title")
+ ),
+ NetworkThrottlingSelector({
+ networkThrottling,
+ onChangeNetworkThrottling,
+ }),
+ DPRSelector({
+ devices,
+ displayPixelRatio,
+ selectedDevice,
+ selectedPixelRatio,
+ onChangePixelRatio,
+ }),
+ dom.button({
+ id: "global-touch-simulation-button",
+ className: touchButtonClass,
+ title: (touchSimulation.enabled ?
+ getStr("responsive.disableTouch") : getStr("responsive.enableTouch")),
+ onClick: () => onChangeTouchSimulation(!touchSimulation.enabled),
+ }),
+ dom.button({
+ id: "global-screenshot-button",
+ className: "toolbar-button devtools-button",
+ title: getStr("responsive.screenshot"),
+ onClick: onScreenshot,
+ disabled: screenshot.isCapturing,
+ }),
+ dom.button({
+ id: "global-exit-button",
+ className: "toolbar-button devtools-button",
+ title: getStr("responsive.exit"),
+ onClick: onExit,
+ })
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/moz.build b/devtools/client/responsive.html/components/moz.build
new file mode 100644
index 0000000000..4ad36f9927
--- /dev/null
+++ b/devtools/client/responsive.html/components/moz.build
@@ -0,0 +1,19 @@
+# -*- 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(
+ 'browser.js',
+ 'device-modal.js',
+ 'device-selector.js',
+ 'dpr-selector.js',
+ 'global-toolbar.js',
+ 'network-throttling-selector.js',
+ 'resizable-viewport.js',
+ 'viewport-dimension.js',
+ 'viewport-toolbar.js',
+ 'viewport.js',
+ 'viewports.js',
+)
diff --git a/devtools/client/responsive.html/components/network-throttling-selector.js b/devtools/client/responsive.html/components/network-throttling-selector.js
new file mode 100644
index 0000000000..fa9f5c6a06
--- /dev/null
+++ b/devtools/client/responsive.html/components/network-throttling-selector.js
@@ -0,0 +1,92 @@
+/* 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 { DOM: dom, createClass, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const { getStr } = require("../utils/l10n");
+const throttlingProfiles = require("devtools/client/shared/network-throttling-profiles");
+
+module.exports = createClass({
+
+ displayName: "NetworkThrottlingSelector",
+
+ propTypes: {
+ networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
+ onChangeNetworkThrottling: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ onSelectChange({ target }) {
+ let {
+ onChangeNetworkThrottling,
+ } = this.props;
+
+ if (target.value == getStr("responsive.noThrottling")) {
+ onChangeNetworkThrottling(false, "");
+ return;
+ }
+
+ for (let profile of throttlingProfiles) {
+ if (profile.id === target.value) {
+ onChangeNetworkThrottling(true, profile.id);
+ return;
+ }
+ }
+ },
+
+ render() {
+ let {
+ networkThrottling,
+ } = this.props;
+
+ let selectClass = "";
+ let selectedProfile;
+ if (networkThrottling.enabled) {
+ selectClass += " selected";
+ selectedProfile = networkThrottling.profile;
+ } else {
+ selectedProfile = getStr("responsive.noThrottling");
+ }
+
+ let listContent = [
+ dom.option(
+ {
+ key: "disabled",
+ },
+ getStr("responsive.noThrottling")
+ ),
+ dom.option(
+ {
+ key: "divider",
+ className: "divider",
+ disabled: true,
+ }
+ ),
+ throttlingProfiles.map(profile => {
+ return dom.option(
+ {
+ key: profile.id,
+ },
+ profile.id
+ );
+ }),
+ ];
+
+ return dom.select(
+ {
+ id: "global-network-throttling-selector",
+ className: selectClass,
+ value: selectedProfile,
+ onChange: this.onSelectChange,
+ },
+ ...listContent
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/resizable-viewport.js b/devtools/client/responsive.html/components/resizable-viewport.js
new file mode 100644
index 0000000000..1d94cd052e
--- /dev/null
+++ b/devtools/client/responsive.html/components/resizable-viewport.js
@@ -0,0 +1,195 @@
+/* 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/. */
+
+/* global window */
+
+"use strict";
+
+const { DOM: dom, createClass, createFactory, PropTypes } =
+ require("devtools/client/shared/vendor/react");
+
+const Constants = require("../constants");
+const Types = require("../types");
+const Browser = createFactory(require("./browser"));
+const ViewportToolbar = createFactory(require("./viewport-toolbar"));
+
+const VIEWPORT_MIN_WIDTH = Constants.MIN_VIEWPORT_DIMENSION;
+const VIEWPORT_MIN_HEIGHT = Constants.MIN_VIEWPORT_DIMENSION;
+
+module.exports = createClass({
+
+ displayName: "ResizableViewport",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ location: Types.location.isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ swapAfterMount: PropTypes.bool.isRequired,
+ viewport: PropTypes.shape(Types.viewport).isRequired,
+ onBrowserMounted: PropTypes.func.isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onContentResize: PropTypes.func.isRequired,
+ onRemoveDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ onRotateViewport: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ getInitialState() {
+ return {
+ isResizing: false,
+ lastClientX: 0,
+ lastClientY: 0,
+ ignoreX: false,
+ ignoreY: false,
+ };
+ },
+
+ onResizeStart({ target, clientX, clientY }) {
+ window.addEventListener("mousemove", this.onResizeDrag, true);
+ window.addEventListener("mouseup", this.onResizeStop, true);
+
+ this.setState({
+ isResizing: true,
+ lastClientX: clientX,
+ lastClientY: clientY,
+ ignoreX: target === this.refs.resizeBarY,
+ ignoreY: target === this.refs.resizeBarX,
+ });
+ },
+
+ onResizeStop() {
+ window.removeEventListener("mousemove", this.onResizeDrag, true);
+ window.removeEventListener("mouseup", this.onResizeStop, true);
+
+ this.setState({
+ isResizing: false,
+ lastClientX: 0,
+ lastClientY: 0,
+ ignoreX: false,
+ ignoreY: false,
+ });
+ },
+
+ onResizeDrag({ clientX, clientY }) {
+ if (!this.state.isResizing) {
+ return;
+ }
+
+ let { lastClientX, lastClientY, ignoreX, ignoreY } = this.state;
+ // the viewport is centered horizontally, so horizontal resize resizes
+ // by twice the distance the mouse was dragged - on left and right side.
+ let deltaX = 2 * (clientX - lastClientX);
+ let deltaY = (clientY - lastClientY);
+
+ if (ignoreX) {
+ deltaX = 0;
+ }
+ if (ignoreY) {
+ deltaY = 0;
+ }
+
+ let width = this.props.viewport.width + deltaX;
+ let height = this.props.viewport.height + deltaY;
+
+ if (width < VIEWPORT_MIN_WIDTH) {
+ width = VIEWPORT_MIN_WIDTH;
+ } else {
+ lastClientX = clientX;
+ }
+
+ if (height < VIEWPORT_MIN_HEIGHT) {
+ height = VIEWPORT_MIN_HEIGHT;
+ } else {
+ lastClientY = clientY;
+ }
+
+ // Update the viewport store with the new width and height.
+ this.props.onResizeViewport(width, height);
+ // Change the device selector back to an unselected device
+ // TODO: Bug 1332754: Logic like this probably belongs in the action creator.
+ if (this.props.viewport.device) {
+ // In bug 1329843 and others, we may eventually stop this approach of removing the
+ // the properties of the device on resize. However, at the moment, there is no
+ // way to edit dPR when a device is selected, and there is no UI at all for editing
+ // UA, so it's important to keep doing this for now.
+ this.props.onRemoveDevice();
+ }
+
+ this.setState({
+ lastClientX,
+ lastClientY
+ });
+ },
+
+ render() {
+ let {
+ devices,
+ location,
+ screenshot,
+ swapAfterMount,
+ viewport,
+ onBrowserMounted,
+ onChangeDevice,
+ onContentResize,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ let resizeHandleClass = "viewport-resize-handle";
+ if (screenshot.isCapturing) {
+ resizeHandleClass += " hidden";
+ }
+
+ let contentClass = "viewport-content";
+ if (this.state.isResizing) {
+ contentClass += " resizing";
+ }
+
+ return dom.div(
+ {
+ className: "resizable-viewport",
+ },
+ ViewportToolbar({
+ devices,
+ selectedDevice: viewport.device,
+ onChangeDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ }),
+ dom.div(
+ {
+ className: contentClass,
+ style: {
+ width: viewport.width + "px",
+ height: viewport.height + "px",
+ },
+ },
+ Browser({
+ location,
+ swapAfterMount,
+ onBrowserMounted,
+ onContentResize,
+ })
+ ),
+ dom.div({
+ className: resizeHandleClass,
+ onMouseDown: this.onResizeStart,
+ }),
+ dom.div({
+ ref: "resizeBarX",
+ className: "viewport-horizontal-resize-handle",
+ onMouseDown: this.onResizeStart,
+ }),
+ dom.div({
+ ref: "resizeBarY",
+ className: "viewport-vertical-resize-handle",
+ onMouseDown: this.onResizeStart,
+ })
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/viewport-dimension.js b/devtools/client/responsive.html/components/viewport-dimension.js
new file mode 100644
index 0000000000..a359cecf76
--- /dev/null
+++ b/devtools/client/responsive.html/components/viewport-dimension.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 { DOM: dom, createClass, PropTypes } =
+ require("devtools/client/shared/vendor/react");
+
+const Constants = require("../constants");
+const Types = require("../types");
+
+module.exports = createClass({
+ displayName: "ViewportDimension",
+
+ propTypes: {
+ viewport: PropTypes.shape(Types.viewport).isRequired,
+ onRemoveDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ },
+
+ getInitialState() {
+ let { width, height } = this.props.viewport;
+
+ return {
+ width,
+ height,
+ isEditing: false,
+ isInvalid: false,
+ };
+ },
+
+ componentWillReceiveProps(nextProps) {
+ let { width, height } = nextProps.viewport;
+
+ this.setState({
+ width,
+ height,
+ });
+ },
+
+ validateInput(value) {
+ let isInvalid = true;
+
+ // Check the value is a number and greater than MIN_VIEWPORT_DIMENSION
+ if (/^\d{3,4}$/.test(value) &&
+ parseInt(value, 10) >= Constants.MIN_VIEWPORT_DIMENSION) {
+ isInvalid = false;
+ }
+
+ this.setState({
+ isInvalid,
+ });
+ },
+
+ onInputBlur() {
+ let { width, height } = this.props.viewport;
+
+ if (this.state.width != width || this.state.height != height) {
+ this.onInputSubmit();
+ }
+
+ this.setState({
+ isEditing: false,
+ inInvalid: false,
+ });
+ },
+
+ onInputChange({ target }) {
+ if (target.value.length > 4) {
+ return;
+ }
+
+ if (this.refs.widthInput == target) {
+ this.setState({ width: target.value });
+ this.validateInput(target.value);
+ }
+
+ if (this.refs.heightInput == target) {
+ this.setState({ height: target.value });
+ this.validateInput(target.value);
+ }
+ },
+
+ onInputFocus() {
+ this.setState({
+ isEditing: true,
+ });
+ },
+
+ onInputKeyUp({ target, keyCode }) {
+ // On Enter, submit the input
+ if (keyCode == 13) {
+ this.onInputSubmit();
+ }
+
+ // On Esc, blur the target
+ if (keyCode == 27) {
+ target.blur();
+ }
+ },
+
+ onInputSubmit() {
+ if (this.state.isInvalid) {
+ let { width, height } = this.props.viewport;
+
+ this.setState({
+ width,
+ height,
+ isInvalid: false,
+ });
+
+ return;
+ }
+
+ // Change the device selector back to an unselected device
+ // TODO: Bug 1332754: Logic like this probably belongs in the action creator.
+ if (this.props.viewport.device) {
+ this.props.onRemoveDevice();
+ }
+ this.props.onResizeViewport(parseInt(this.state.width, 10),
+ parseInt(this.state.height, 10));
+ },
+
+ render() {
+ let editableClass = "viewport-dimension-editable";
+ let inputClass = "viewport-dimension-input";
+
+ if (this.state.isEditing) {
+ editableClass += " editing";
+ inputClass += " editing";
+ }
+
+ if (this.state.isInvalid) {
+ editableClass += " invalid";
+ }
+
+ return dom.div(
+ {
+ className: "viewport-dimension",
+ },
+ dom.div(
+ {
+ className: editableClass,
+ },
+ dom.input({
+ ref: "widthInput",
+ className: inputClass,
+ size: 4,
+ value: this.state.width,
+ onBlur: this.onInputBlur,
+ onChange: this.onInputChange,
+ onFocus: this.onInputFocus,
+ onKeyUp: this.onInputKeyUp,
+ }),
+ dom.span({
+ className: "viewport-dimension-separator",
+ }, "×"),
+ dom.input({
+ ref: "heightInput",
+ className: inputClass,
+ size: 4,
+ value: this.state.height,
+ onBlur: this.onInputBlur,
+ onChange: this.onInputChange,
+ onFocus: this.onInputFocus,
+ onKeyUp: this.onInputKeyUp,
+ })
+ )
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/viewport-toolbar.js b/devtools/client/responsive.html/components/viewport-toolbar.js
new file mode 100644
index 0000000000..7cbc73f67f
--- /dev/null
+++ b/devtools/client/responsive.html/components/viewport-toolbar.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { DOM: dom, createClass, createFactory, PropTypes, addons } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const DeviceSelector = createFactory(require("./device-selector"));
+
+module.exports = createClass({
+ displayName: "ViewportToolbar",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ onRotateViewport: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ mixins: [ addons.PureRenderMixin ],
+
+ render() {
+ let {
+ devices,
+ selectedDevice,
+ onChangeDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ return dom.div(
+ {
+ className: "viewport-toolbar container",
+ },
+ DeviceSelector({
+ devices,
+ selectedDevice,
+ onChangeDevice,
+ onResizeViewport,
+ onUpdateDeviceModalOpen,
+ }),
+ dom.button({
+ className: "viewport-rotate-button toolbar-button devtools-button",
+ onClick: onRotateViewport,
+ })
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/viewport.js b/devtools/client/responsive.html/components/viewport.js
new file mode 100644
index 0000000000..fe41b41ee1
--- /dev/null
+++ b/devtools/client/responsive.html/components/viewport.js
@@ -0,0 +1,114 @@
+/* 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 { DOM: dom, createClass, createFactory, PropTypes } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const ResizableViewport = createFactory(require("./resizable-viewport"));
+const ViewportDimension = createFactory(require("./viewport-dimension"));
+
+module.exports = createClass({
+
+ displayName: "Viewport",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ location: Types.location.isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ swapAfterMount: PropTypes.bool.isRequired,
+ viewport: PropTypes.shape(Types.viewport).isRequired,
+ onBrowserMounted: PropTypes.func.isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onContentResize: PropTypes.func.isRequired,
+ onRemoveDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ onRotateViewport: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ onChangeDevice(device) {
+ let {
+ viewport,
+ onChangeDevice,
+ } = this.props;
+
+ onChangeDevice(viewport.id, device);
+ },
+
+ onRemoveDevice() {
+ let {
+ viewport,
+ onRemoveDevice,
+ } = this.props;
+
+ onRemoveDevice(viewport.id);
+ },
+
+ onResizeViewport(width, height) {
+ let {
+ viewport,
+ onResizeViewport,
+ } = this.props;
+
+ onResizeViewport(viewport.id, width, height);
+ },
+
+ onRotateViewport() {
+ let {
+ viewport,
+ onRotateViewport,
+ } = this.props;
+
+ onRotateViewport(viewport.id);
+ },
+
+ render() {
+ let {
+ devices,
+ location,
+ screenshot,
+ swapAfterMount,
+ viewport,
+ onBrowserMounted,
+ onContentResize,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ let {
+ onChangeDevice,
+ onRemoveDevice,
+ onRotateViewport,
+ onResizeViewport,
+ } = this;
+
+ return dom.div(
+ {
+ className: "viewport",
+ },
+ ViewportDimension({
+ viewport,
+ onRemoveDevice,
+ onResizeViewport,
+ }),
+ ResizableViewport({
+ devices,
+ location,
+ screenshot,
+ swapAfterMount,
+ viewport,
+ onBrowserMounted,
+ onChangeDevice,
+ onContentResize,
+ onRemoveDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ })
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/components/viewports.js b/devtools/client/responsive.html/components/viewports.js
new file mode 100644
index 0000000000..b305d1e075
--- /dev/null
+++ b/devtools/client/responsive.html/components/viewports.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";
+
+const { DOM: dom, createClass, createFactory, PropTypes } =
+ require("devtools/client/shared/vendor/react");
+
+const Types = require("../types");
+const Viewport = createFactory(require("./viewport"));
+
+module.exports = createClass({
+
+ displayName: "Viewports",
+
+ propTypes: {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ location: Types.location.isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
+ onBrowserMounted: PropTypes.func.isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onContentResize: PropTypes.func.isRequired,
+ onRemoveDevice: PropTypes.func.isRequired,
+ onResizeViewport: PropTypes.func.isRequired,
+ onRotateViewport: PropTypes.func.isRequired,
+ onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+ },
+
+ render() {
+ let {
+ devices,
+ location,
+ screenshot,
+ viewports,
+ onBrowserMounted,
+ onChangeDevice,
+ onContentResize,
+ onRemoveDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ } = this.props;
+
+ return dom.div(
+ {
+ id: "viewports",
+ },
+ viewports.map((viewport, i) => {
+ return Viewport({
+ key: viewport.id,
+ devices,
+ location,
+ screenshot,
+ swapAfterMount: i == 0,
+ viewport,
+ onBrowserMounted,
+ onChangeDevice,
+ onContentResize,
+ onRemoveDevice,
+ onResizeViewport,
+ onRotateViewport,
+ onUpdateDeviceModalOpen,
+ });
+ })
+ );
+ },
+
+});
diff --git a/devtools/client/responsive.html/constants.js b/devtools/client/responsive.html/constants.js
new file mode 100644
index 0000000000..b848515ea2
--- /dev/null
+++ b/devtools/client/responsive.html/constants.js
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// The minimum viewport width and height
+exports.MIN_VIEWPORT_DIMENSION = 280;
diff --git a/devtools/client/responsive.html/docs/browser-swap.md b/devtools/client/responsive.html/docs/browser-swap.md
new file mode 100644
index 0000000000..75055ad4ef
--- /dev/null
+++ b/devtools/client/responsive.html/docs/browser-swap.md
@@ -0,0 +1,146 @@
+# Overview
+
+The RDM tool uses several forms of tab and browser swapping to integrate the
+tool UI cleanly into the browser UI. The high level steps of this process are
+documented at `/devtools/docs/responsive-design-mode.md`.
+
+This document contains a random assortment of low level notes about the steps
+the browser goes through when swapping browsers between tabs.
+
+# Connections between Browsers and Tabs
+
+Link between tab and browser (`gBrowser._linkBrowserToTab`):
+
+```
+aTab.linkedBrowser = browser;
+gBrowser._tabForBrowser.set(browser, aTab);
+```
+
+# Swapping Browsers between Tabs
+
+## Legend
+
+* (R): remote browsers only
+* (!R): non-remote browsers only
+
+## Functions Called
+
+When you call `gBrowser.swapBrowsersAndCloseOther` to move tab content from a
+browser in one tab to a browser in another tab, here are all the code paths
+involved:
+
+* `gBrowser.swapBrowsersAndCloseOther`
+ * `gBrowser._beginRemoveTab`
+ * `gBrowser.tabContainer.updateVisibility`
+ * Emit `TabClose`
+ * `browser.webProgress.removeProgressListener`
+ * `filter.removeProgressListener`
+ * `listener.destroy`
+ * `gBrowser._swapBrowserDocShells`
+ * `ourBrowser.webProgress.removeProgressListener`
+ * `filter.removeProgressListener`
+ * `gBrowser._swapRegisteredOpenURIs`
+ * `ourBrowser.swapDocShells(aOtherBrowser)`
+ * Emit `SwapDocShells`
+ * `PopupNotifications._swapBrowserNotifications`
+ * `browser.detachFormFill` (!R)
+ * `browser.swapFrameLoaders`
+ * `browser.attachFormFill` (!R)
+ * `browser._remoteWebNavigationImpl.swapBrowser(browser)` (R)
+ * `browser._remoteWebProgressManager.swapBrowser(browser)` (R)
+ * `browser._remoteFinder.swapBrowser(browser)` (R)
+ * Emit `EndSwapDocShells`
+ * `gBrowser.mTabProgressListener`
+ * `filter.addProgressListener`
+ * `ourBrowser.webProgress.addProgressListener`
+ * `gBrowser._endRemoveTab`
+ * `gBrowser.tabContainer._fillTrailingGap`
+ * `gBrowser._blurTab`
+ * `gBrowser._tabFilters.delete`
+ * `gBrowser._tabListeners.delete`
+ * `gBrowser._outerWindowIDBrowserMap.delete`
+ * `browser.destroy`
+ * `gBrowser.tabContainer.removeChild`
+ * `gBrowser.tabContainer.adjustTabstrip`
+ * `gBrowser.tabContainer._setPositionalAttributes`
+ * `browser.parentNode.removeChild(browser)`
+ * `gBrowser._tabForBrowser.delete`
+ * `gBrowser.mPanelContainer.removeChild`
+ * `gBrowser.setTabTitle` / `gBrowser.setTabTitleLoading`
+ * `browser.currentURI.spec`
+ * `gBrowser._tabAttrModified`
+ * `gBrowser.updateTitlebar`
+ * `gBrowser.updateCurrentBrowser`
+ * `browser.docShellIsActive` (!R)
+ * `gBrowser.showTab`
+ * `gBrowser._appendStatusPanel`
+ * `gBrowser._callProgressListeners` with `onLocationChange`
+ * `gBrowser._callProgressListeners` with `onSecurityChange`
+ * `gBrowser._callProgressListeners` with `onUpdateCurrentBrowser`
+ * `gBrowser._recordTabAccess`
+ * `gBrowser.updateTitlebar`
+ * `gBrowser._callProgressListeners` with `onStateChange`
+ * `gBrowser._setCloseKeyState`
+ * Emit `TabSelect`
+ * `gBrowser._tabAttrModified`
+ * `browser.getInPermitUnload`
+ * `gBrowser.tabContainer._setPositionalAttributes`
+ * `gBrowser._tabAttrModified`
+
+## Browser State
+
+When calling `gBrowser.swapBrowsersAndCloseOther`, the browser is not actually
+moved from one tab to the other. Instead, various properties _on_ each of the
+browsers are swapped.
+
+Browser attributes `gBrowser.swapBrowsersAndCloseOther` transfers between
+browsers:
+
+* `usercontextid`
+
+Tab attributes `gBrowser.swapBrowsersAndCloseOther` transfers between tabs:
+
+* `usercontextid`
+* `muted`
+* `soundplaying`
+* `busy`
+
+Browser properties `gBrowser.swapBrowsersAndCloseOther` transfers between
+browsers:
+
+* `mIconURL`
+* `getFindBar(aOurTab)._findField.value`
+
+Browser properties `gBrowser._swapBrowserDocShells` transfers between browsers:
+
+* `outerWindowID` in `gBrowser._outerWindowIDBrowserMap`
+* `_outerWindowID` on the browser (R)
+* `docShellIsActive`
+* `permanentKey`
+* `registeredOpenURI`
+
+Browser properties `browser.swapDocShells` transfers between browsers:
+
+* `_docShell`
+* `_webBrowserFind`
+* `_contentWindow`
+* `_webNavigation`
+* `_remoteWebNavigation` (R)
+* `_remoteWebNavigationImpl` (R)
+* `_remoteWebProgressManager` (R)
+* `_remoteWebProgress` (R)
+* `_remoteFinder` (R)
+* `_securityUI` (R)
+* `_documentURI` (R)
+* `_documentContentType` (R)
+* `_contentTitle` (R)
+* `_characterSet` (R)
+* `_contentPrincipal` (R)
+* `_imageDocument` (R)
+* `_fullZoom` (R)
+* `_textZoom` (R)
+* `_isSyntheticDocument` (R)
+* `_innerWindowID` (R)
+* `_manifestURI` (R)
+
+`browser.swapFrameLoaders` swaps the actual page content.
diff --git a/devtools/client/responsive.html/images/close.svg b/devtools/client/responsive.html/images/close.svg
new file mode 100644
index 0000000000..9a491fcae4
--- /dev/null
+++ b/devtools/client/responsive.html/images/close.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="#0b0b0b">
+ <path d="M6.7 8l3.6-3.6c.2-.2.2-.5 0-.7-.2-.2-.5-.2-.7 0L6 7.3 2.4 3.7c-.2-.2-.5-.2-.7 0-.2.2-.2.5 0 .7L5.3 8l-3.6 3.6c-.2.2-.2.5 0 .7.2.2.5.2.7 0L6 8.7l3.6 3.6c.2.2.5.2.7 0 .2-.2.2-.5 0-.7L6.7 8z"/>
+</svg>
diff --git a/devtools/client/responsive.html/images/grippers.svg b/devtools/client/responsive.html/images/grippers.svg
new file mode 100644
index 0000000000..91db83af9d
--- /dev/null
+++ b/devtools/client/responsive.html/images/grippers.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#A5A5A5">
+ <path d="M16 3.2L3.1 16h1.7L16 4.9zM16 7.2L7.1 16h1.8L16 8.9zM16 11.1L11.1 16h1.8l3.1-3.1z" />
+</svg>
diff --git a/devtools/client/responsive.html/images/moz.build b/devtools/client/responsive.html/images/moz.build
new file mode 100644
index 0000000000..bbce6d6c29
--- /dev/null
+++ b/devtools/client/responsive.html/images/moz.build
@@ -0,0 +1,14 @@
+# -*- 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(
+ 'close.svg',
+ 'grippers.svg',
+ 'rotate-viewport.svg',
+ 'screenshot.svg',
+ 'select-arrow.svg',
+ 'touch-events.svg',
+)
diff --git a/devtools/client/responsive.html/images/rotate-viewport.svg b/devtools/client/responsive.html/images/rotate-viewport.svg
new file mode 100644
index 0000000000..494e47e90e
--- /dev/null
+++ b/devtools/client/responsive.html/images/rotate-viewport.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#0b0b0b">
+ <path d="M3.8 13.4c-1.2 0-3.4-.6-3.7-2.8s1.3-3.3 2.1-3.5c.2-.1.4.1.5.3.1.2-.1.4-.3.5-.1 0-1.8.6-1.6 2.7.2 1.5 1.6 1.9 2.4 2l-.7-2.4c0-.2.2-.5.4-.5.2-.1.4 0 .5.2l.9 3c0 .1 0 .3-.1.4-.1.1-.2.1-.4.1zM12.3 1.7c1.2 0 3.4.6 3.7 2.8.3 2.2-1.3 3.3-2.1 3.5-.2.1-.4-.1-.5-.3s.1-.4.3-.5c.1 0 1.8-.6 1.6-2.7-.2-1.5-1.6-1.9-2.4-2l.7 2.4c.1.2-.1.4-.3.5s-.4-.1-.5-.3l-.9-3c0-.1 0-.3.1-.4h.3zM9.6 2.5L4.3 4.1c-.2.1-.4.4-.3.8l2.5 8c.1.3.4.6.8.5l5.2-1.6c.3-.1.6-.5.4-.8l-2.5-8c0-.1-.7-.6-.8-.5zm2.5 8.6l-5 1.5-.6-1.9 5-1.5.6 1.9zm-.8-2.6l-5 1.5-1.6-5.3 5-1.5 1.6 5.3z"/>
+</svg>
diff --git a/devtools/client/responsive.html/images/screenshot.svg b/devtools/client/responsive.html/images/screenshot.svg
new file mode 100644
index 0000000000..306d40f935
--- /dev/null
+++ b/devtools/client/responsive.html/images/screenshot.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="#0b0b0b">
+ <path d="M14.4,4.1h-3l0-1.3c0-0.9-1.2-1.6-2.1-1.6H6.6c-0.9,0-1.9,0.7-1.9,1.6l0,1.3h-3c-0.9,0-1.6,0.7-1.6,1.6v7.4c0,0.9,0.7,1.3,1.6,1.3h12.7c0.9,0,1.6-0.3,1.6-1.3V5.7C16,4.8,15.3,4.1,14.4,4.1z M14.8,13.2H1.2v-8h4.5l0-3h4.5l0,3h4.4L14.8,13.2z"/>
+ <path d="M8,6.7c-1.3,0-2.4,1.1-2.4,2.4s1.1,2.4,2.4,2.4s2.4-1.1,2.4-2.4S9.3,6.7,8,6.7z M8,10.2c-0.7,0-1.2-0.5-1.2-1.1S7.3,8,8,8s1.2,0.5,1.2,1.1S8.7,10.2,8,10.2z"/>
+</svg>
diff --git a/devtools/client/responsive.html/images/select-arrow.svg b/devtools/client/responsive.html/images/select-arrow.svg
new file mode 100644
index 0000000000..c9165a2063
--- /dev/null
+++ b/devtools/client/responsive.html/images/select-arrow.svg
@@ -0,0 +1,37 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
+ <defs>
+ <style>
+ use:not(:target) {
+ display: none;
+ }
+ #light {
+ fill: #999797;
+ }
+ #light-hovered {
+ fill: #393f4c; /* --theme-body-color */
+ }
+ #light-selected {
+ fill: #3b3b3b;
+ }
+ #dark {
+ fill: #c6ccd0;
+ }
+ #dark-hovered {
+ fill: #dde1e4;
+ }
+ #dark-selected {
+ fill: #fcfcfc;
+ }
+ </style>
+ <path id="base-path" d="M7.9 16.3c-.3 0-.6-.1-.8-.4l-4-4.8c-.2-.3-.3-.5-.1-.8.1-.3.5-.3.9-.3h8c.4 0 .7 0 .9.3.2.4.1.6-.1.9l-4 4.8c-.2.3-.5.3-.8.3zM7.8 0c.3 0 .6.1.7.4L12.4 5c.2.3.3.4.1.7-.1.4-.5.3-.8.3H3.9c-.4 0-.8.1-.9-.2-.2-.4-.1-.6.1-.9L7 .3c.2-.3.5-.3.8-.3z"/>
+ </defs>
+ <use xlink:href="#base-path" id="light"/>
+ <use xlink:href="#base-path" id="light-hovered"/>
+ <use xlink:href="#base-path" id="light-selected"/>
+ <use xlink:href="#base-path" id="dark"/>
+ <use xlink:href="#base-path" id="dark-hovered"/>
+ <use xlink:href="#base-path" id="dark-selected"/>
+</svg>
diff --git a/devtools/client/responsive.html/images/touch-events.svg b/devtools/client/responsive.html/images/touch-events.svg
new file mode 100644
index 0000000000..18aa3c66d8
--- /dev/null
+++ b/devtools/client/responsive.html/images/touch-events.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="#0b0b0b">
+ <path d="M12.5 5.3c-.2 0-.4 0-.6.1-.2-.6-.8-1-1.4-1-.3 0-.5.1-.8.2C9.4 4.2 9 4 8.6 4h-.4V1.5C8.2.7 7.5 0 6.7 0S5.2.7 5.2 1.5v6.6l-.7-.6c-.6-.6-1.6-.6-2.2 0-.5.6-.5 1.4-.1 2.1.3.4.6 1.1 1 1.8C4.2 13.6 5.3 16 7 16h3.9s3.1-1 3.1-4V6.7c.1-.8-.7-1.4-1.5-1.4zm.6 6.7c0 2-2.1 3-2.4 3H7c-1 0-2.1-2.4-2.9-4-.3-.8-.7-1.6-1-2-.2-.3-.2-.5-.1-.7.1-.1.2-.1.3-.1.1 0 .2 0 .3.1l1.5 1.5c.3.2.6.2.7.1.1 0 .4-.2.4-.5V1.5c0-.2.2-.4.5-.4s.5.2.5.4v5.3c0 .3.2.5.5.5s.5-.2.5-.5V5.5c0-.4.2-.5.5-.5.2 0 .5.2.5.4v2c-.1.3.2.6.4.6.3 0 .5-.2.5-.5V5.8c0-.2.2-.4.5-.4s.5.2.5.4v2.3c0 .3.2.5.5.5s.5-.2.5-.5V6.7c0-.2.2-.4.5-.4s.5.2.5.4V12z"/>
+</svg>
diff --git a/devtools/client/responsive.html/index.css b/devtools/client/responsive.html/index.css
new file mode 100644
index 0000000000..c88f957776
--- /dev/null
+++ b/devtools/client/responsive.html/index.css
@@ -0,0 +1,521 @@
+/* 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
+ */
+
+.theme-light {
+ --rdm-box-shadow: 0 4px 4px 0 rgba(155, 155, 155, 0.26);
+ --submit-button-active-background-color: rgba(0,0,0,0.12);
+ --submit-button-active-color: var(--theme-body-color);
+ --viewport-color: #999797;
+ --viewport-hover-color: var(--theme-body-color);
+ --viewport-active-color: #3b3b3b;
+ --viewport-selection-arrow: url("./images/select-arrow.svg#light");
+ --viewport-selection-arrow-hovered:
+ url("./images/select-arrow.svg#light-hovered");
+ --viewport-selection-arrow-selected:
+ url("./images/select-arrow.svg#light-selected");
+}
+
+.theme-dark {
+ --rdm-box-shadow: 0 4px 4px 0 rgba(105, 105, 105, 0.26);
+ --submit-button-active-background-color: var(--toolbar-tab-hover-active);
+ --submit-button-active-color: var(--theme-selection-color);
+ --viewport-color: #c6ccd0;
+ --viewport-hover-color: #dde1e4;
+ --viewport-active-color: #fcfcfc;
+ --viewport-selection-arrow: url("./images/select-arrow.svg#dark");
+ --viewport-selection-arrow-hovered:
+ url("./images/select-arrow.svg#dark-hovered");
+ --viewport-selection-arrow-selected:
+ url("./images/select-arrow.svg#dark-selected");
+}
+
+* {
+ box-sizing: border-box;
+}
+
+#root,
+html, body {
+ height: 100%;
+ margin: 0;
+}
+
+#app {
+ /* Center the viewports container */
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ padding-top: 15px;
+ padding-bottom: 1%;
+ position: relative;
+ height: 100%;
+}
+
+/**
+ * Common styles for shared components
+ */
+
+.container {
+ background-color: var(--theme-toolbar-background);
+ border: 1px solid var(--theme-splitter-color);
+}
+
+.toolbar-button {
+ margin: 1px 3px;
+ width: 16px;
+ height: 16px;
+ /* Reset styles from .devtools-button */
+ min-width: initial;
+ min-height: initial;
+ align-self: center;
+}
+
+.toolbar-button:active::before {
+ filter: url("chrome://devtools/skin/images/filters.svg#checked-icon-state");
+}
+
+select {
+ -moz-appearance: none;
+ background-color: var(--theme-toolbar-background);
+ background-image: var(--viewport-selection-arrow);
+ background-position: 100% 50%;
+ background-repeat: no-repeat;
+ background-size: 7px;
+ border: none;
+ color: var(--viewport-color);
+ height: 100%;
+ padding: 0 8px;
+ text-align: center;
+ text-overflow: ellipsis;
+ font-size: 11px;
+}
+
+select.selected {
+ background-image: var(--viewport-selection-arrow-selected);
+ color: var(--viewport-active-color);
+}
+
+select:not(:disabled):hover {
+ background-image: var(--viewport-selection-arrow-hovered);
+ color: var(--viewport-hover-color);
+}
+
+/* This is (believed to be?) separate from the identical select.selected rule
+ set so that it overrides select:hover because of file ordering once the
+ select is focused. It's unclear whether the visual effect that results here
+ is intentional and desired. */
+select:focus {
+ background-image: var(--viewport-selection-arrow-selected);
+ color: var(--viewport-active-color);
+}
+
+select > option {
+ text-align: left;
+ padding: 5px 10px;
+}
+
+select > option,
+select > option:hover {
+ color: var(--viewport-active-color);
+}
+
+select > option.divider {
+ border-top: 1px solid var(--theme-splitter-color);
+ height: 0px;
+ padding: 0;
+ font-size: 0px;
+}
+
+/**
+ * Global Toolbar
+ */
+
+#global-toolbar {
+ color: var(--theme-body-color-alt);
+ border-radius: 2px;
+ box-shadow: var(--rdm-box-shadow);
+ margin: 0 0 15px 0;
+ padding: 4px 5px;
+ display: inline-flex;
+ -moz-user-select: none;
+}
+
+#global-toolbar > .title {
+ border-right: 1px solid var(--theme-splitter-color);
+ padding: 1px 6px 0 2px;
+}
+
+#global-toolbar .toolbar-button {
+ margin: 0 0 0 5px;
+ padding: 0;
+}
+
+#global-toolbar .toolbar-button,
+#global-toolbar .toolbar-button::before {
+ width: 12px;
+ height: 12px;
+}
+
+#global-touch-simulation-button::before {
+ background-image: url("./images/touch-events.svg");
+ margin: -6px 0 0 -6px;
+}
+
+#global-touch-simulation-button.active::before {
+ filter: url("chrome://devtools/skin/images/filters.svg#checked-icon-state");
+}
+
+#global-screenshot-button::before {
+ background-image: url("./images/screenshot.svg");
+ margin: -6px 0 0 -6px;
+}
+
+#global-exit-button::before {
+ background-image: url("./images/close.svg");
+ margin: -6px 0 0 -6px;
+}
+
+#global-screenshot-button:disabled {
+ filter: url("chrome://devtools/skin/images/filters.svg#checked-icon-state");
+ opacity: 1 !important;
+}
+
+#global-network-throttling-selector {
+ height: 15px;
+ padding-left: 0;
+ width: 103px;
+}
+
+#global-dpr-selector > select {
+ padding: 0 8px 0 0;
+ margin-left: 2px;
+}
+
+#global-dpr-selector {
+ margin: 0 8px;
+ -moz-user-select: none;
+ color: var(--viewport-color);
+ font-size: 11px;
+ height: 15px;
+}
+
+#global-dpr-selector.focused,
+#global-dpr-selector:not(.disabled):hover {
+ color: var(--viewport-hover-color);
+}
+
+#global-dpr-selector:not(.disabled):hover > select {
+ background-image: var(--viewport-selection-arrow-hovered);
+ color: var(--viewport-hover-color);
+}
+
+#global-dpr-selector:focus > select {
+ background-image: var(--viewport-selection-arrow-selected);
+ color: var(--viewport-active-color);
+}
+
+#global-dpr-selector.selected,
+#global-dpr-selector.selected > select {
+ color: var(--viewport-active-color);
+}
+
+#global-dpr-selector > select > option {
+ padding: 5px;
+}
+
+#viewports {
+ /* Make sure left-most viewport is visible when there's horizontal overflow.
+ That is, when the horizontal space become smaller than the viewports and a
+ scrollbar appears, then the first viewport will still be visible */
+ position: sticky;
+ left: 0;
+ /* Individual viewports are inline elements, make sure they stay on a single
+ line */
+ white-space: nowrap;
+}
+
+/**
+ * Viewport Container
+ */
+
+.viewport {
+ display: inline-block;
+ /* Align all viewports to the top */
+ vertical-align: top;
+}
+
+.resizable-viewport {
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: var(--rdm-box-shadow);
+ position: relative;
+}
+
+/**
+ * Viewport Toolbar
+ */
+
+.viewport-toolbar {
+ border-width: 0;
+ border-bottom-width: 1px;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ height: 18px;
+}
+
+.viewport-rotate-button {
+ position: absolute;
+ right: 0;
+}
+
+.viewport-rotate-button::before {
+ background-image: url("./images/rotate-viewport.svg");
+}
+
+/**
+ * Viewport Content
+ */
+
+.viewport-content.resizing {
+ pointer-events: none;
+}
+
+/**
+ * Viewport Browser
+ */
+
+.browser-container {
+ width: inherit;
+ height: inherit;
+}
+
+.browser {
+ display: block;
+ border: 0;
+ -moz-user-select: none;
+}
+
+.browser:-moz-focusring {
+ outline: none;
+}
+
+/**
+ * Viewport Resize Handles
+ */
+
+.viewport-resize-handle {
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ bottom: 0;
+ right: 0;
+ background-image: url("./images/grippers.svg");
+ background-position: bottom right;
+ padding: 0 1px 1px 0;
+ background-repeat: no-repeat;
+ background-origin: content-box;
+ cursor: se-resize;
+}
+
+.viewport-resize-handle.hidden {
+ display: none;
+}
+
+.viewport-horizontal-resize-handle {
+ position: absolute;
+ width: 5px;
+ height: calc(100% - 16px);
+ right: -4px;
+ top: 0;
+ cursor: e-resize;
+}
+
+.viewport-vertical-resize-handle {
+ position: absolute;
+ width: calc(100% - 16px);
+ height: 5px;
+ left: 0;
+ bottom: -4px;
+ cursor: s-resize;
+}
+
+/**
+ * Viewport Dimension Label
+ */
+
+.viewport-dimension {
+ display: flex;
+ justify-content: center;
+ font: 10px sans-serif;
+ margin-bottom: 10px;
+}
+
+.viewport-dimension-editable {
+ border-bottom: 1px solid transparent;
+}
+
+.viewport-dimension-editable,
+.viewport-dimension-input {
+ color: var(--theme-body-color-inactive);
+ transition: all 0.25s ease;
+}
+
+.viewport-dimension-editable.editing,
+.viewport-dimension-input.editing {
+ color: var(--viewport-active-color);
+}
+
+.viewport-dimension-editable.editing {
+ border-bottom: 1px solid var(--theme-selection-background);
+}
+
+.viewport-dimension-editable.editing.invalid {
+ border-bottom: 1px solid #d92215;
+}
+
+.viewport-dimension-input {
+ background: transparent;
+ border: none;
+ text-align: center;
+}
+
+.viewport-dimension-separator {
+ -moz-user-select: none;
+}
+
+/**
+ * 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 {
+ border-radius: 2px;
+ box-shadow: var(--rdm-box-shadow);
+ display: none;
+ position: absolute;
+ margin: auto;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 642px;
+ height: 612px;
+ z-index: 1;
+}
+
+/* Handles the opening/closing of the modal */
+#device-modal-wrapper.opened .device-modal {
+ animation: fade-in-and-up 0.3s ease;
+ animation-fill-mode: forwards;
+ display: block;
+}
+
+#device-modal-wrapper.closed .device-modal {
+ animation: fade-down-and-out 0.3s ease;
+ animation-fill-mode: forwards;
+ display: block;
+}
+
+#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: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ overflow: auto;
+ height: 550px;
+ width: 600px;
+ margin: 20px;
+}
+
+#device-close-button,
+#device-close-button::before {
+ position: absolute;
+ top: 5px;
+ right: 2px;
+ width: 12px;
+ height: 12px;
+}
+
+#device-close-button::before {
+ background-image: url("./images/close.svg");
+ margin: -6px 0 0 -6px;
+}
+
+.device-type {
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+}
+
+.device-header {
+ font-size: 11px;
+ font-weight: bold;
+ text-transform: capitalize;
+ padding: 0 0 3px 23px;
+}
+
+.device-label {
+ font-size: 11px;
+ padding-bottom: 3px;
+ display: flex;
+ align-items: center;
+}
+
+.device-input-checkbox {
+ margin-right: 5px;
+}
+
+#device-submit-button {
+ background-color: var(--theme-tab-toolbar-background);
+ border-width: 1px 0 0 0;
+ border-top-width: 1px;
+ border-top-style: solid;
+ border-top-color: var(--theme-splitter-color);
+ color: var(--theme-body-color);
+ width: 100%;
+ height: 20px;
+}
+
+#device-submit-button:hover {
+ background-color: var(--toolbar-tab-hover);
+}
+
+#device-submit-button:hover:active {
+ background-color: var(--submit-button-active-background-color);
+ color: var(--submit-button-active-color);
+}
diff --git a/devtools/client/responsive.html/index.js b/devtools/client/responsive.html/index.js
new file mode 100644
index 0000000000..7e8f8aeac2
--- /dev/null
+++ b/devtools/client/responsive.html/index.js
@@ -0,0 +1,166 @@
+/* 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 { utils: Cu } = Components;
+const { BrowserLoader } =
+ Cu.import("resource://devtools/client/shared/browser-loader.js", {});
+const { require } = BrowserLoader({
+ baseURI: "resource://devtools/client/responsive.html/",
+ window
+});
+const { Task } = require("devtools/shared/task");
+const Telemetry = require("devtools/client/shared/telemetry");
+const { loadSheet } = require("sdk/stylesheet/utils");
+
+const { createFactory, createElement } =
+ require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+const { Provider } = require("devtools/client/shared/vendor/react-redux");
+
+const message = require("./utils/message");
+const App = createFactory(require("./app"));
+const Store = require("./store");
+const { changeLocation } = require("./actions/location");
+const { changeDisplayPixelRatio } = require("./actions/display-pixel-ratio");
+const { addViewport, resizeViewport } = require("./actions/viewports");
+const { loadDevices } = require("./actions/devices");
+
+// Exposed for use by tests
+window.require = require;
+
+let bootstrap = {
+
+ telemetry: new Telemetry(),
+
+ store: null,
+
+ init: Task.async(function* () {
+ // Load a special UA stylesheet to reset certain styles such as dropdown
+ // lists.
+ loadSheet(window,
+ "resource://devtools/client/responsive.html/responsive-ua.css",
+ "agent");
+ this.telemetry.toolOpened("responsive");
+ let store = this.store = Store();
+ let provider = createElement(Provider, { store }, App());
+ ReactDOM.render(provider, document.querySelector("#root"));
+ message.post(window, "init:done");
+ }),
+
+ destroy() {
+ this.store = null;
+ this.telemetry.toolClosed("responsive");
+ 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;
+ }
+ 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()));
+
+window.addEventListener("unload", function onUnload() {
+ window.removeEventListener("unload", onUnload);
+ 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.
+function onDPRChange() {
+ let dpr = window.devicePixelRatio;
+ let mql = window.matchMedia(`(resolution: ${dpr}dppx)`);
+
+ function listener() {
+ bootstrap.dispatch(changeDisplayPixelRatio(window.devicePixelRatio));
+ mql.removeListener(listener);
+ onDPRChange();
+ }
+
+ mql.addListener(listener);
+}
+
+/**
+ * Called by manager.js to add the initial viewport based on the original page.
+ */
+window.addInitialViewport = contentURI => {
+ try {
+ onDPRChange();
+ bootstrap.dispatch(changeLocation(contentURI));
+ bootstrap.dispatch(changeDisplayPixelRatio(window.devicePixelRatio));
+ bootstrap.dispatch(addViewport());
+ } catch (e) {
+ console.error(e);
+ }
+};
+
+/**
+ * Called by manager.js when tests want to check the viewport size.
+ */
+window.getViewportSize = () => {
+ let { width, height } = bootstrap.store.getState().viewports[0];
+ return { width, height };
+};
+
+/**
+ * Called by manager.js to set viewport size from tests, GCLI, etc.
+ */
+window.setViewportSize = ({ width, height }) => {
+ try {
+ bootstrap.dispatch(resizeViewport(0, width, height));
+ } 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 = () => {
+ let browser = document.querySelector("iframe.browser");
+ if (!browser.messageManager) {
+ Object.defineProperty(browser, "messageManager", {
+ get() {
+ return this.frameLoader.messageManager;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+ return browser;
+};
diff --git a/devtools/client/responsive.html/index.xhtml b/devtools/client/responsive.html/index.xhtml
new file mode 100644
index 0000000000..72fe2f0f76
--- /dev/null
+++ b/devtools/client/responsive.html/index.xhtml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+ <link rel="stylesheet" type="text/css"
+ href="resource://devtools/client/responsive.html/index.css"/>
+ <script type="application/javascript;version=1.8"
+ src="chrome://devtools/content/shared/theme-switching.js"></script>
+ <script type="application/javascript;version=1.8"
+ src="./index.js"></script>
+ </head>
+ <body class="theme-body" role="application">
+ <div id="root"/>
+ </body>
+</html>
diff --git a/devtools/client/responsive.html/manager.js b/devtools/client/responsive.html/manager.js
new file mode 100644
index 0000000000..a3fbed3661
--- /dev/null
+++ b/devtools/client/responsive.html/manager.js
@@ -0,0 +1,597 @@
+/* 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 { Ci } = require("chrome");
+const promise = require("promise");
+const { Task } = require("devtools/shared/task");
+const EventEmitter = require("devtools/shared/event-emitter");
+const { getOwnerWindow } = require("sdk/tabs/utils");
+const { startup } = require("sdk/window/helpers");
+const message = require("./utils/message");
+const { swapToInnerBrowser } = require("./browser/swap");
+const { EmulationFront } = require("devtools/shared/fronts/emulation");
+const { getStr } = require("./utils/l10n");
+
+const TOOL_URL = "chrome://devtools/content/responsive.html/index.xhtml";
+
+loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true);
+loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true);
+loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true);
+loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
+loader.lazyRequireGetter(this, "throttlingProfiles",
+ "devtools/client/shared/network-throttling-profiles");
+
+/**
+ * ResponsiveUIManager is the external API for the browser UI, etc. to use when
+ * opening and closing the responsive UI.
+ *
+ * While the HTML UI is in an experimental stage, the older ResponsiveUIManager
+ * from devtools/client/responsivedesign/responsivedesign.jsm delegates to this
+ * object when the pref "devtools.responsive.html.enabled" is true.
+ */
+const ResponsiveUIManager = exports.ResponsiveUIManager = {
+ activeTabs: new Map(),
+
+ /**
+ * 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:
+ * - `command`: Whether initiated via GCLI command bar or toolbox button
+ * @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) {
+ let action = this.isActiveForTab(tab) ? "close" : "open";
+ let completed = this[action + "IfNeeded"](window, tab, options);
+ completed.catch(console.error);
+ return completed;
+ },
+
+ /**
+ * 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:
+ * - `command`: Whether initiated via GCLI command bar or toolbox button
+ * @return Promise
+ * Resolved to the ResponsiveUI instance for this tab when opening is
+ * complete.
+ */
+ openIfNeeded: Task.async(function* (window, tab, options) {
+ if (!tab.linkedBrowser.isRemoteBrowser) {
+ this.showRemoteOnlyNotification(window, tab, options);
+ return promise.reject(new Error("RDM only available for remote tabs."));
+ }
+ // Remove this once we support this case in bug 1306975.
+ if (tab.linkedBrowser.hasAttribute("usercontextid")) {
+ this.showNoContainerTabsNotification(window, tab, options);
+ return promise.reject(new Error("RDM not available for container tabs."));
+ }
+ if (!this.isActiveForTab(tab)) {
+ this.initMenuCheckListenerFor(window);
+
+ let ui = new ResponsiveUI(window, tab);
+ this.activeTabs.set(tab, ui);
+ yield this.setMenuCheckFor(tab, window);
+ yield ui.inited;
+ this.emit("on", { tab });
+ }
+
+ return this.getResponsiveUIForTab(tab);
+ }),
+
+ /**
+ * 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:
+ * - `command`: Whether initiated via GCLI command bar or toolbox button
+ * - `reason`: String detailing the specific cause for closing
+ * @return Promise
+ * Resolved (with no value) when closing is complete.
+ */
+ closeIfNeeded: Task.async(function* (window, tab, options) {
+ if (this.isActiveForTab(tab)) {
+ let ui = this.activeTabs.get(tab);
+ let destroyed = yield 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 });
+ yield this.setMenuCheckFor(tab, window);
+ }
+ }),
+
+ /**
+ * 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 => getOwnerWindow(t) === 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);
+ },
+
+ /**
+ * Handle GCLI commands.
+ *
+ * @param window
+ * The main browser chrome window.
+ * @param tab
+ * The browser tab.
+ * @param command
+ * The GCLI command name.
+ * @param args
+ * The GCLI command arguments.
+ */
+ handleGcliCommand(window, tab, command, args) {
+ let completed;
+ switch (command) {
+ case "resize to":
+ completed = this.openIfNeeded(window, tab, { command: true });
+ this.activeTabs.get(tab).setViewportSize(args);
+ break;
+ case "resize on":
+ completed = this.openIfNeeded(window, tab, { command: true });
+ break;
+ case "resize off":
+ completed = this.closeIfNeeded(window, tab, { command: true });
+ break;
+ case "resize toggle":
+ completed = this.toggle(window, tab, { command: true });
+ break;
+ default:
+ }
+ completed.catch(e => console.error(e));
+ },
+
+ handleMenuCheck({target}) {
+ ResponsiveUIManager.setMenuCheckFor(target);
+ },
+
+ initMenuCheckListenerFor(window) {
+ let { tabContainer } = window.gBrowser;
+ tabContainer.addEventListener("TabSelect", this.handleMenuCheck);
+ },
+
+ removeMenuCheckListenerFor(window) {
+ if (window && window.gBrowser && window.gBrowser.tabContainer) {
+ let { tabContainer } = window.gBrowser;
+ tabContainer.removeEventListener("TabSelect", this.handleMenuCheck);
+ }
+ },
+
+ setMenuCheckFor: Task.async(function* (tab, window = getOwnerWindow(tab)) {
+ yield startup(window);
+
+ let menu = window.document.getElementById("menu_responsiveUI");
+ if (menu) {
+ menu.setAttribute("checked", this.isActiveForTab(tab));
+ }
+ }),
+
+ showRemoteOnlyNotification(window, tab, options) {
+ this.showErrorNotification(window, tab, options, getStr("responsive.remoteOnly"));
+ },
+
+ showNoContainerTabsNotification(window, tab, options) {
+ this.showErrorNotification(window, tab, options,
+ getStr("responsive.noContainerTabs"));
+ },
+
+ showErrorNotification(window, tab, { command } = {}, msg) {
+ // Default to using the browser's per-tab notification box
+ let nbox = window.gBrowser.getNotificationBox(tab.linkedBrowser);
+
+ // If opening was initiated by GCLI command bar or toolbox button, check for an open
+ // toolbox for the tab. If one exists, use the toolbox's notification box so that the
+ // message is placed closer to the action taken by the user.
+ if (command) {
+ let target = TargetFactory.forTab(tab);
+ let toolbox = gDevTools.getToolbox(target);
+ if (toolbox) {
+ nbox = toolbox.notificationBox;
+ }
+ }
+
+ let value = "devtools-responsive-error";
+ if (nbox.getNotificationWithValue(value)) {
+ // Notification already displayed
+ return;
+ }
+
+ nbox.appendNotification(
+ msg,
+ value,
+ null,
+ nbox.PRIORITY_CRITICAL_MEDIUM,
+ []);
+ },
+};
+
+// GCLI commands in ../responsivedesign/resize-commands.js listen for events
+// from this object to know when the UI for a tab has opened or closed.
+EventEmitter.decorate(ResponsiveUIManager);
+
+/**
+ * ResponsiveUI manages the responsive design tool for a specific tab. The
+ * actual tool itself lives in a separate chrome:// document that is loaded into
+ * the tab upon opening responsive design. This object acts a helper to
+ * integrate the tool into the surrounding browser UI as needed.
+ */
+function ResponsiveUI(window, tab) {
+ this.browserWindow = window;
+ this.tab = tab;
+ this.inited = this.init();
+}
+
+ResponsiveUI.prototype = {
+
+ /**
+ * The main browser chrome window (that holds many tabs).
+ */
+ browserWindow: null,
+
+ /**
+ * The specific browser tab this responsive instance is for.
+ */
+ tab: null,
+
+ /**
+ * Promise resovled when the UI init has completed.
+ */
+ inited: null,
+
+ /**
+ * Flag set when destruction has begun.
+ */
+ destroying: false,
+
+ /**
+ * Flag set when destruction has ended.
+ */
+ destroyed: false,
+
+ /**
+ * A window reference for the chrome:// document that displays the responsive
+ * design tool. It is safe to reference this window directly even with e10s,
+ * as the tool UI is always loaded in the parent process. The web content
+ * contained *within* the tool UI on the other hand is loaded in the child
+ * process.
+ */
+ toolWindow: null,
+
+ /**
+ * Open RDM while preserving the state of the page. We use `swapFrameLoaders`
+ * to ensure all in-page state is preserved, just like when you move a tab to
+ * a new window.
+ *
+ * For more details, see /devtools/docs/responsive-design-mode.md.
+ */
+ init: Task.async(function* () {
+ let ui = this;
+
+ // Watch for tab close and window close so we can clean up RDM synchronously
+ this.tab.addEventListener("TabClose", this);
+ this.browserWindow.addEventListener("unload", this);
+
+ // Swap page content from the current tab into a viewport within RDM
+ this.swap = swapToInnerBrowser({
+ tab: this.tab,
+ containerURL: TOOL_URL,
+ getInnerBrowser: Task.async(function* (containerBrowser) {
+ let toolWindow = ui.toolWindow = containerBrowser.contentWindow;
+ toolWindow.addEventListener("message", ui);
+ yield message.request(toolWindow, "init");
+ toolWindow.addInitialViewport("about:blank");
+ yield message.wait(toolWindow, "browser-mounted");
+ return ui.getViewportBrowser();
+ })
+ });
+ yield this.swap.start();
+
+ this.tab.addEventListener("BeforeTabRemotenessChange", this);
+
+ // Notify the inner browser to start the frame script
+ yield message.request(this.toolWindow, "start-frame-script");
+
+ // Get the protocol ready to speak with emulation actor
+ yield this.connectToServer();
+
+ // Non-blocking message to tool UI to start any delayed init activities
+ message.post(this.toolWindow, "post-init");
+ }),
+
+ /**
+ * Close RDM and restore page content back into a regular tab.
+ *
+ * @param object
+ * Destroy options, which currently includes a `reason` string.
+ * @return boolean
+ * Whether this call is actually destroying. False means destruction
+ * was already in progress.
+ */
+ destroy: Task.async(function* (options) {
+ if (this.destroying) {
+ return false;
+ }
+ this.destroying = true;
+
+ // If our tab is about to be closed, there's not enough time to exit
+ // gracefully, but that shouldn't be a problem since the tab will go away.
+ // So, skip any yielding when we're about to close the tab.
+ let isWindowClosing = options && options.reason === "unload";
+ let isTabContentDestroying =
+ isWindowClosing || (options && (options.reason === "TabClose" ||
+ options.reason === "BeforeTabRemotenessChange"));
+
+ // Ensure init has finished before starting destroy
+ if (!isTabContentDestroying) {
+ yield this.inited;
+ }
+
+ this.tab.removeEventListener("TabClose", this);
+ this.tab.removeEventListener("BeforeTabRemotenessChange", this);
+ this.browserWindow.removeEventListener("unload", this);
+ this.toolWindow.removeEventListener("message", this);
+
+ if (!isTabContentDestroying) {
+ // Notify the inner browser to stop the frame script
+ yield message.request(this.toolWindow, "stop-frame-script");
+ }
+
+ // Destroy local state
+ let swap = this.swap;
+ this.browserWindow = null;
+ this.tab = null;
+ this.inited = null;
+ this.toolWindow = null;
+ this.swap = null;
+
+ // Close the debugger client used to speak with emulation actor.
+ // The actor handles clearing any overrides itself, so it's not necessary to clear
+ // anything on shutdown client side.
+ let clientClosed = this.client.close();
+ if (!isTabContentDestroying) {
+ yield clientClosed;
+ }
+ this.client = this.emulationFront = null;
+
+ if (!isWindowClosing) {
+ // Undo the swap and return the content back to a normal tab
+ swap.stop();
+ }
+
+ this.destroyed = true;
+
+ return true;
+ }),
+
+ connectToServer: Task.async(function* () {
+ if (!DebuggerServer.initialized) {
+ DebuggerServer.init();
+ DebuggerServer.addBrowserActors();
+ }
+ this.client = new DebuggerClient(DebuggerServer.connectPipe());
+ yield this.client.connect();
+ let { tab } = yield this.client.getTab();
+ this.emulationFront = EmulationFront(this.client, tab);
+ }),
+
+ handleEvent(event) {
+ let { browserWindow, tab } = this;
+
+ switch (event.type) {
+ case "message":
+ this.handleMessage(event);
+ break;
+ case "BeforeTabRemotenessChange":
+ case "TabClose":
+ case "unload":
+ ResponsiveUIManager.closeIfNeeded(browserWindow, tab, {
+ reason: event.type,
+ });
+ break;
+ }
+ },
+
+ handleMessage(event) {
+ if (event.origin !== "chrome://devtools") {
+ return;
+ }
+
+ switch (event.data.type) {
+ case "change-device":
+ this.onChangeDevice(event);
+ break;
+ case "change-network-throtting":
+ this.onChangeNetworkThrottling(event);
+ break;
+ case "change-pixel-ratio":
+ this.onChangePixelRatio(event);
+ break;
+ case "change-touch-simulation":
+ this.onChangeTouchSimulation(event);
+ break;
+ case "content-resize":
+ this.onContentResize(event);
+ break;
+ case "exit":
+ this.onExit();
+ break;
+ case "remove-device":
+ this.onRemoveDevice(event);
+ break;
+ }
+ },
+
+ onChangeDevice: Task.async(function* (event) {
+ let { userAgent, pixelRatio, touch } = event.data.device;
+ yield this.updateUserAgent(userAgent);
+ yield this.updateDPPX(pixelRatio);
+ yield this.updateTouchSimulation(touch);
+ // Used by tests
+ this.emit("device-changed");
+ }),
+
+ onChangeNetworkThrottling: Task.async(function* (event) {
+ let { enabled, profile } = event.data;
+ yield this.updateNetworkThrottling(enabled, profile);
+ // Used by tests
+ this.emit("network-throttling-changed");
+ }),
+
+ onChangePixelRatio(event) {
+ let { pixelRatio } = event.data;
+ this.updateDPPX(pixelRatio);
+ },
+
+ onChangeTouchSimulation(event) {
+ let { enabled } = event.data;
+ this.updateTouchSimulation(enabled);
+ },
+
+ onContentResize(event) {
+ let { width, height } = event.data;
+ this.emit("content-resize", {
+ width,
+ height,
+ });
+ },
+
+ onExit() {
+ let { browserWindow, tab } = this;
+ ResponsiveUIManager.closeIfNeeded(browserWindow, tab);
+ },
+
+ onRemoveDevice: Task.async(function* (event) {
+ yield this.updateUserAgent();
+ yield this.updateDPPX();
+ yield this.updateTouchSimulation();
+ // Used by tests
+ this.emit("device-removed");
+ }),
+
+ updateDPPX: Task.async(function* (dppx) {
+ if (!dppx) {
+ yield this.emulationFront.clearDPPXOverride();
+ return;
+ }
+ yield this.emulationFront.setDPPXOverride(dppx);
+ }),
+
+ updateNetworkThrottling: Task.async(function* (enabled, profile) {
+ if (!enabled) {
+ yield this.emulationFront.clearNetworkThrottling();
+ return;
+ }
+ let data = throttlingProfiles.find(({ id }) => id == profile);
+ let { download, upload, latency } = data;
+ yield this.emulationFront.setNetworkThrottling({
+ downloadThroughput: download,
+ uploadThroughput: upload,
+ latency,
+ });
+ }),
+
+ updateUserAgent: Task.async(function* (userAgent) {
+ if (!userAgent) {
+ yield this.emulationFront.clearUserAgentOverride();
+ return;
+ }
+ yield this.emulationFront.setUserAgentOverride(userAgent);
+ }),
+
+ updateTouchSimulation: Task.async(function* (enabled) {
+ if (!enabled) {
+ yield this.emulationFront.clearTouchEventsOverride();
+ return;
+ }
+ let reloadNeeded = yield this.emulationFront.setTouchEventsOverride(
+ Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED
+ );
+ if (reloadNeeded) {
+ this.getViewportBrowser().reload();
+ }
+ }),
+
+ /**
+ * Helper for tests. Assumes a single viewport for now.
+ */
+ getViewportSize() {
+ return this.toolWindow.getViewportSize();
+ },
+
+ /**
+ * Helper for tests, GCLI, etc. Assumes a single viewport for now.
+ */
+ setViewportSize: Task.async(function* (size) {
+ yield this.inited;
+ this.toolWindow.setViewportSize(size);
+ }),
+
+ /**
+ * Helper for tests/reloading the viewport. Assumes a single viewport for now.
+ */
+ getViewportBrowser() {
+ return this.toolWindow.getViewportBrowser();
+ },
+
+ /**
+ * Helper for contacting the viewport content. Assumes a single viewport for now.
+ */
+ getViewportMessageManager() {
+ return this.getViewportBrowser().messageManager;
+ },
+
+};
+
+EventEmitter.decorate(ResponsiveUI.prototype);
diff --git a/devtools/client/responsive.html/moz.build b/devtools/client/responsive.html/moz.build
new file mode 100644
index 0000000000..79fbf3ae46
--- /dev/null
+++ b/devtools/client/responsive.html/moz.build
@@ -0,0 +1,28 @@
+# -*- 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',
+ 'browser',
+ 'components',
+ 'images',
+ 'reducers',
+ 'utils',
+]
+
+DevToolsModules(
+ 'app.js',
+ 'constants.js',
+ 'index.css',
+ 'manager.js',
+ 'reducers.js',
+ 'responsive-ua.css',
+ 'store.js',
+ 'types.js',
+)
+
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
diff --git a/devtools/client/responsive.html/reducers.js b/devtools/client/responsive.html/reducers.js
new file mode 100644
index 0000000000..f36cd509a2
--- /dev/null
+++ b/devtools/client/responsive.html/reducers.js
@@ -0,0 +1,13 @@
+/* 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("./reducers/devices");
+exports.displayPixelRatio = require("./reducers/display-pixel-ratio");
+exports.location = require("./reducers/location");
+exports.networkThrottling = require("./reducers/network-throttling");
+exports.screenshot = require("./reducers/screenshot");
+exports.touchSimulation = require("./reducers/touch-simulation");
+exports.viewports = require("./reducers/viewports");
diff --git a/devtools/client/responsive.html/reducers/devices.js b/devtools/client/responsive.html/reducers/devices.js
new file mode 100644
index 0000000000..e78632b24d
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/devices.js
@@ -0,0 +1,86 @@
+/* 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,
+ LOAD_DEVICE_LIST_START,
+ LOAD_DEVICE_LIST_ERROR,
+ LOAD_DEVICE_LIST_END,
+ UPDATE_DEVICE_DISPLAYED,
+ UPDATE_DEVICE_MODAL_OPEN,
+} = require("../actions/index");
+
+const Types = require("../types");
+
+const INITIAL_DEVICES = {
+ types: [],
+ isModalOpen: false,
+ listState: Types.deviceListState.INITIALIZED,
+};
+
+let reducers = {
+
+ [ADD_DEVICE](devices, { device, deviceType }) {
+ return Object.assign({}, devices, {
+ [deviceType]: [...devices[deviceType], device],
+ });
+ },
+
+ [ADD_DEVICE_TYPE](devices, { deviceType }) {
+ return Object.assign({}, devices, {
+ types: [...devices.types, deviceType],
+ [deviceType]: [],
+ });
+ },
+
+ [UPDATE_DEVICE_DISPLAYED](devices, { device, deviceType, displayed }) {
+ let newDevices = devices[deviceType].map(d => {
+ if (d == device) {
+ d.displayed = displayed;
+ }
+
+ return d;
+ });
+
+ return Object.assign({}, devices, {
+ [deviceType]: newDevices,
+ });
+ },
+
+ [LOAD_DEVICE_LIST_START](devices, action) {
+ return Object.assign({}, devices, {
+ listState: Types.deviceListState.LOADING,
+ });
+ },
+
+ [LOAD_DEVICE_LIST_ERROR](devices, action) {
+ return Object.assign({}, devices, {
+ listState: Types.deviceListState.ERROR,
+ });
+ },
+
+ [LOAD_DEVICE_LIST_END](devices, action) {
+ return Object.assign({}, devices, {
+ listState: Types.deviceListState.LOADED,
+ });
+ },
+
+ [UPDATE_DEVICE_MODAL_OPEN](devices, { isOpen }) {
+ return Object.assign({}, devices, {
+ isModalOpen: isOpen,
+ });
+ },
+
+};
+
+module.exports = function (devices = INITIAL_DEVICES, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return devices;
+ }
+ return reducer(devices, action);
+};
diff --git a/devtools/client/responsive.html/reducers/display-pixel-ratio.js b/devtools/client/responsive.html/reducers/display-pixel-ratio.js
new file mode 100644
index 0000000000..3f127c2066
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/display-pixel-ratio.js
@@ -0,0 +1,26 @@
+/* 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 { CHANGE_DISPLAY_PIXEL_RATIO } = require("../actions/index");
+const INITIAL_DISPLAY_PIXEL_RATIO = 0;
+
+let reducers = {
+
+ [CHANGE_DISPLAY_PIXEL_RATIO](_, action) {
+ return action.displayPixelRatio;
+ },
+
+};
+
+module.exports = function (displayPixelRatio = INITIAL_DISPLAY_PIXEL_RATIO, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return displayPixelRatio;
+ }
+ return reducer(displayPixelRatio, action);
+};
diff --git a/devtools/client/responsive.html/reducers/location.js b/devtools/client/responsive.html/reducers/location.js
new file mode 100644
index 0000000000..2063c97768
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/location.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { CHANGE_LOCATION } = require("../actions/index");
+
+const INITIAL_LOCATION = "about:blank";
+
+let reducers = {
+
+ [CHANGE_LOCATION](_, action) {
+ return action.location;
+ },
+
+};
+
+module.exports = function (location = INITIAL_LOCATION, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return location;
+ }
+ return reducer(location, action);
+};
diff --git a/devtools/client/responsive.html/reducers/moz.build b/devtools/client/responsive.html/reducers/moz.build
new file mode 100644
index 0000000000..f1e9668f03
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/moz.build
@@ -0,0 +1,15 @@
+# -*- 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',
+ 'display-pixel-ratio.js',
+ 'location.js',
+ 'network-throttling.js',
+ 'screenshot.js',
+ 'touch-simulation.js',
+ 'viewports.js',
+)
diff --git a/devtools/client/responsive.html/reducers/network-throttling.js b/devtools/client/responsive.html/reducers/network-throttling.js
new file mode 100644
index 0000000000..f892553c12
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/network-throttling.js
@@ -0,0 +1,33 @@
+/* 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_NETWORK_THROTTLING,
+} = require("../actions/index");
+
+const INITIAL_NETWORK_THROTTLING = {
+ enabled: false,
+ profile: "",
+};
+
+let reducers = {
+
+ [CHANGE_NETWORK_THROTTLING](throttling, { enabled, profile }) {
+ return {
+ enabled,
+ profile,
+ };
+ },
+
+};
+
+module.exports = function (throttling = INITIAL_NETWORK_THROTTLING, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return throttling;
+ }
+ return reducer(throttling, action);
+};
diff --git a/devtools/client/responsive.html/reducers/screenshot.js b/devtools/client/responsive.html/reducers/screenshot.js
new file mode 100644
index 0000000000..9d24d8c5b2
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/screenshot.js
@@ -0,0 +1,31 @@
+/* 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("../actions/index");
+
+const INITIAL_SCREENSHOT = { isCapturing: false };
+
+let reducers = {
+
+ [TAKE_SCREENSHOT_END](screenshot, action) {
+ return Object.assign({}, screenshot, { isCapturing: false });
+ },
+
+ [TAKE_SCREENSHOT_START](screenshot, action) {
+ return Object.assign({}, screenshot, { isCapturing: true });
+ },
+};
+
+module.exports = function (screenshot = INITIAL_SCREENSHOT, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return screenshot;
+ }
+ return reducer(screenshot, action);
+};
diff --git a/devtools/client/responsive.html/reducers/touch-simulation.js b/devtools/client/responsive.html/reducers/touch-simulation.js
new file mode 100644
index 0000000000..b3203b6447
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/touch-simulation.js
@@ -0,0 +1,31 @@
+/* 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_TOUCH_SIMULATION,
+} = require("../actions/index");
+
+const INITIAL_TOUCH_SIMULATION = {
+ enabled: false,
+};
+
+let reducers = {
+
+ [CHANGE_TOUCH_SIMULATION](touchSimulation, { enabled }) {
+ return Object.assign({}, touchSimulation, {
+ enabled,
+ });
+ },
+
+};
+
+module.exports = function (touchSimulation = INITIAL_TOUCH_SIMULATION, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return touchSimulation;
+ }
+ return reducer(touchSimulation, action);
+};
diff --git a/devtools/client/responsive.html/reducers/viewports.js b/devtools/client/responsive.html/reducers/viewports.js
new file mode 100644
index 0000000000..ee130ceaf5
--- /dev/null
+++ b/devtools/client/responsive.html/reducers/viewports.js
@@ -0,0 +1,118 @@
+/* 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,
+ REMOVE_DEVICE,
+ RESIZE_VIEWPORT,
+ ROTATE_VIEWPORT,
+} = require("../actions/index");
+
+let nextViewportId = 0;
+
+const INITIAL_VIEWPORTS = [];
+const INITIAL_VIEWPORT = {
+ id: nextViewportId++,
+ device: "",
+ width: 320,
+ height: 480,
+ pixelRatio: {
+ value: 0,
+ },
+};
+
+let reducers = {
+
+ [ADD_VIEWPORT](viewports) {
+ // For the moment, there can be at most one viewport.
+ if (viewports.length === 1) {
+ return viewports;
+ }
+ return [...viewports, Object.assign({}, INITIAL_VIEWPORT)];
+ },
+
+ [CHANGE_DEVICE](viewports, { id, device }) {
+ return viewports.map(viewport => {
+ if (viewport.id !== id) {
+ return viewport;
+ }
+
+ return Object.assign({}, viewport, {
+ device,
+ });
+ });
+ },
+
+ [CHANGE_PIXEL_RATIO](viewports, { id, pixelRatio }) {
+ return viewports.map(viewport => {
+ if (viewport.id !== id) {
+ return viewport;
+ }
+
+ return Object.assign({}, viewport, {
+ pixelRatio: {
+ value: pixelRatio
+ },
+ });
+ });
+ },
+
+ [REMOVE_DEVICE](viewports, { id }) {
+ return viewports.map(viewport => {
+ if (viewport.id !== id) {
+ return viewport;
+ }
+
+ return Object.assign({}, viewport, {
+ device: "",
+ });
+ });
+ },
+
+ [RESIZE_VIEWPORT](viewports, { id, width, height }) {
+ return viewports.map(viewport => {
+ if (viewport.id !== id) {
+ return viewport;
+ }
+
+ if (!width) {
+ width = viewport.width;
+ }
+ if (!height) {
+ height = viewport.height;
+ }
+
+ return Object.assign({}, viewport, {
+ width,
+ height,
+ });
+ });
+ },
+
+ [ROTATE_VIEWPORT](viewports, { id }) {
+ return viewports.map(viewport => {
+ if (viewport.id !== id) {
+ return viewport;
+ }
+
+ return Object.assign({}, viewport, {
+ width: viewport.height,
+ height: viewport.width,
+ });
+ });
+ },
+
+};
+
+module.exports = function (viewports = INITIAL_VIEWPORTS, action) {
+ let reducer = reducers[action.type];
+ if (!reducer) {
+ return viewports;
+ }
+ return reducer(viewports, action);
+};
diff --git a/devtools/client/responsive.html/responsive-ua.css b/devtools/client/responsive.html/responsive-ua.css
new file mode 100644
index 0000000000..6d442b1bb0
--- /dev/null
+++ b/devtools/client/responsive.html/responsive-ua.css
@@ -0,0 +1,6 @@
+@namespace url(http://www.w3.org/1999/xhtml);
+
+/* Reset default UA styles for dropdown options */
+*|*::-moz-dropdown-list {
+ border: 0 !important;
+}
diff --git a/devtools/client/responsive.html/store.js b/devtools/client/responsive.html/store.js
new file mode 100644
index 0000000000..0e32819b32
--- /dev/null
+++ b/devtools/client/responsive.html/store.js
@@ -0,0 +1,33 @@
+/* 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 { combineReducers } = require("devtools/client/shared/vendor/redux");
+const createStore = require("devtools/client/shared/redux/create-store");
+const reducers = require("./reducers");
+const flags = require("devtools/shared/flags");
+
+module.exports = function () {
+ let shouldLog = false;
+ let history;
+
+ // If testing, store the action history in an array
+ // we'll later attach to the store
+ if (flags.testing) {
+ history = [];
+ shouldLog = true;
+ }
+
+ let store = createStore({
+ log: shouldLog,
+ history
+ })(combineReducers(reducers), {});
+
+ if (history) {
+ store.history = history;
+ }
+
+ return store;
+};
diff --git a/devtools/client/responsive.html/test/browser/.eslintrc.js b/devtools/client/responsive.html/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..698ae9181a
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the shared list of defined globals for mochitests.
+ "extends": "../../../../.eslintrc.mochitests.js"
+};
diff --git a/devtools/client/responsive.html/test/browser/browser.ini b/devtools/client/responsive.html/test/browser/browser.ini
new file mode 100644
index 0000000000..71cf6d9b66
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser.ini
@@ -0,0 +1,44 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+# !e10s: RDM only works for remote tabs
+skip-if = !e10s
+support-files =
+ devices.json
+ doc_page_state.html
+ geolocation.html
+ head.js
+ touch.html
+ !/devtools/client/commandline/test/helpers.js
+ !/devtools/client/framework/test/shared-head.js
+ !/devtools/client/framework/test/shared-redux-head.js
+ !/devtools/client/inspector/test/shared-head.js
+ !/devtools/client/shared/test/test-actor.js
+ !/devtools/client/shared/test/test-actor-registry.js
+
+[browser_device_change.js]
+[browser_device_modal_error.js]
+[browser_device_modal_exit.js]
+[browser_device_modal_submit.js]
+[browser_device_width.js]
+[browser_dpr_change.js]
+[browser_exit_button.js]
+[browser_frame_script_active.js]
+[browser_menu_item_01.js]
+[browser_menu_item_02.js]
+[browser_mouse_resize.js]
+[browser_navigation.js]
+[browser_network_throttling.js]
+[browser_page_state.js]
+[browser_permission_doorhanger.js]
+[browser_resize_cmd.js]
+[browser_screenshot_button.js]
+[browser_tab_close.js]
+[browser_tab_remoteness_change.js]
+[browser_toolbox_computed_view.js]
+[browser_toolbox_rule_view.js]
+[browser_toolbox_swap_browsers.js]
+[browser_touch_device.js]
+[browser_touch_simulation.js]
+[browser_viewport_basics.js]
+[browser_window_close.js]
diff --git a/devtools/client/responsive.html/test/browser/browser_device_change.js b/devtools/client/responsive.html/test/browser/browser_device_change.js
new file mode 100644
index 0000000000..b88f735220
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_change.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests changing viewport device
+const TEST_URL = "data:text/html;charset=utf-8,Device list test";
+
+const DEFAULT_DPPX = window.devicePixelRatio;
+const DEFAULT_UA = Cc["@mozilla.org/network/protocol;1?name=http"]
+ .getService(Ci.nsIHttpProtocolHandler)
+ .userAgent;
+
+const Types = require("devtools/client/responsive.html/types");
+
+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_URL, function* ({ ui, manager }) {
+ let { store } = ui.toolWindow;
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+
+ // Test defaults
+ testViewportDimensions(ui, 320, 480);
+ yield testUserAgent(ui, DEFAULT_UA);
+ yield testDevicePixelRatio(ui, DEFAULT_DPPX);
+ yield testTouchEventsOverride(ui, false);
+ testViewportDeviceSelectLabel(ui, "no device selected");
+
+ // Test device with custom properties
+ yield selectDevice(ui, "Fake Phone RDM Test");
+ yield waitForViewportResizeTo(ui, testDevice.width, testDevice.height);
+ yield testUserAgent(ui, testDevice.userAgent);
+ yield testDevicePixelRatio(ui, testDevice.pixelRatio);
+ yield testTouchEventsOverride(ui, true);
+
+ // Test resetting device when resizing viewport
+ let deviceRemoved = once(ui, "device-removed");
+ yield testViewportResize(ui, ".viewport-vertical-resize-handle",
+ [-10, -10], [testDevice.width, testDevice.height - 10], [0, -10], ui);
+ yield deviceRemoved;
+ yield testUserAgent(ui, DEFAULT_UA);
+ yield testDevicePixelRatio(ui, DEFAULT_DPPX);
+ yield testTouchEventsOverride(ui, false);
+ testViewportDeviceSelectLabel(ui, "no device selected");
+
+ // Test device with generic properties
+ yield selectDevice(ui, "Laptop (1366 x 768)");
+ yield waitForViewportResizeTo(ui, 1366, 768);
+ yield testUserAgent(ui, DEFAULT_UA);
+ yield testDevicePixelRatio(ui, 1);
+ yield testTouchEventsOverride(ui, false);
+});
+
+function testViewportDimensions(ui, w, h) {
+ let viewport = ui.toolWindow.document.querySelector(".viewport-content");
+
+ is(ui.toolWindow.getComputedStyle(viewport).getPropertyValue("width"),
+ `${w}px`, `Viewport should have width of ${w}px`);
+ is(ui.toolWindow.getComputedStyle(viewport).getPropertyValue("height"),
+ `${h}px`, `Viewport should have height of ${h}px`);
+}
+
+function* testUserAgent(ui, expected) {
+ let ua = yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ return content.navigator.userAgent;
+ });
+ is(ua, expected, `UA should be set to ${expected}`);
+}
+
+function* testDevicePixelRatio(ui, expected) {
+ let dppx = yield getViewportDevicePixelRatio(ui);
+ is(dppx, expected, `devicePixelRatio should be set to ${expected}`);
+}
+
+function* getViewportDevicePixelRatio(ui) {
+ return yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ return content.devicePixelRatio;
+ });
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_device_modal_error.js b/devtools/client/responsive.html/test/browser/browser_device_modal_error.js
new file mode 100644
index 0000000000..d9308eb6c1
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_modal_error.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test to check that RDM can handle properly an error in the device list
+
+const TEST_URL = "data:text/html;charset=utf-8,";
+const Types = require("devtools/client/responsive.html/types");
+const { getStr } = require("devtools/client/responsive.html/utils/l10n");
+
+// Set a wrong URL for the device list file
+add_task(function* () {
+ yield SpecialPowers.pushPrefEnv({
+ set: [["devtools.devices.url", TEST_URI_ROOT + "wrong_devices_file.json"]],
+ });
+});
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ let { store, document } = ui.toolWindow;
+ let select = document.querySelector(".viewport-device-selector");
+
+ // Wait until the viewport has been added and the device list state indicates
+ // an error
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.ERROR);
+
+ // The device selector placeholder should be set accordingly
+ let placeholder = select.options[select.selectedIndex].innerHTML;
+ ok(placeholder == getStr("responsive.deviceListError"),
+ "Device selector indicates an error");
+
+ // The device selector should be disabled
+ ok(select.disabled, "Device selector is disabled");
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_device_modal_exit.js b/devtools/client/responsive.html/test/browser/browser_device_modal_exit.js
new file mode 100644
index 0000000000..30d057ebe4
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_modal_exit.js
@@ -0,0 +1,45 @@
+/* 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,";
+const Types = require("devtools/client/responsive.html/types");
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ let { store, document } = ui.toolWindow;
+ let modal = document.querySelector("#device-modal-wrapper");
+ let closeButton = document.querySelector("#device-close-button");
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+
+ openDeviceModal(ui);
+
+ let preferredDevicesBefore = _loadPreferredDevices();
+
+ info("Check the first unchecked device and exit the modal.");
+ let uncheckedCb = [...document.querySelectorAll(".device-input-checkbox")]
+ .filter(cb => !cb.checked)[0];
+ let value = uncheckedCb.value;
+ uncheckedCb.click();
+ closeButton.click();
+
+ ok(modal.classList.contains("closed") && !modal.classList.contains("opened"),
+ "The device modal is closed on exit.");
+
+ info("Check that the device list remains unchanged after exitting.");
+ let preferredDevicesAfter = _loadPreferredDevices();
+
+ is(preferredDevicesBefore.added.size, preferredDevicesAfter.added.size,
+ "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.");
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_device_modal_submit.js b/devtools/client/responsive.html/test/browser/browser_device_modal_submit.js
new file mode 100644
index 0000000000..90f364ce7d
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_modal_submit.js
@@ -0,0 +1,146 @@
+/* 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("devtools/client/shared/devices");
+
+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,";
+const Types = require("devtools/client/responsive.html/types");
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ let { store, document } = ui.toolWindow;
+ let modal = document.querySelector("#device-modal-wrapper");
+ let select = document.querySelector(".viewport-device-selector");
+ let submitButton = document.querySelector("#device-submit-button");
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+
+ openDeviceModal(ui);
+
+ info("Checking displayed device checkboxes are checked in the device modal.");
+ let checkedCbs = [...document.querySelectorAll(".device-input-checkbox")]
+ .filter(cb => cb.checked);
+
+ let remoteList = yield getDevices();
+
+ let featuredCount = remoteList.TYPES.reduce((total, type) => {
+ return total + remoteList[type].reduce((subtotal, device) => {
+ return subtotal + ((device.os != "fxos" && device.featured) ? 1 : 0);
+ }, 0);
+ }, 0);
+
+ is(featuredCount, checkedCbs.length,
+ "Got expected number of displayed devices.");
+
+ for (let 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.");
+ let uncheckedCb = [...document.querySelectorAll(".device-input-checkbox")]
+ .filter(cb => !cb.checked)[0];
+ let value = uncheckedCb.value;
+ uncheckedCb.click();
+ submitButton.click();
+
+ ok(modal.classList.contains("closed") && !modal.classList.contains("opened"),
+ "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.");
+ let options = [...select.options];
+ is(options.length - 2, featuredCount + 1,
+ "Got expected number of devices in device selector.");
+ ok(options.filter(o => o.value === value)[0],
+ value + " added to the device selector.");
+
+ info("Reopen device modal and check new device is correctly checked");
+ openDeviceModal(ui);
+ ok([...document.querySelectorAll(".device-input-checkbox")]
+ .filter(cb => cb.checked && cb.value === value)[0],
+ 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");
+ let checkedCb = [...document.querySelectorAll(".device-input-checkbox")]
+ .filter(cb => cb.checked && cb.value != value)[0];
+ let checkedVal = checkedCb.value;
+ checkedCb.click();
+ submitButton.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.");
+ options = [...select.options];
+ is(options.length - 2, featuredCount,
+ "Got expected number of devices in device selector.");
+ ok(!options.filter(o => o.value === checkedVal)[0],
+ checkedVal + " removed from the device selector.");
+
+ info("Reopen device modal and check device is correctly unchecked");
+ 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);
+});
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ let { store, document } = ui.toolWindow;
+ let select = document.querySelector(".viewport-device-selector");
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+
+ openDeviceModal(ui);
+
+ let remoteList = yield getDevices();
+ let featuredCount = remoteList.TYPES.reduce((total, type) => {
+ return total + remoteList[type].reduce((subtotal, device) => {
+ return subtotal + ((device.os != "fxos" && device.featured) ? 1 : 0);
+ }, 0);
+ }, 0);
+ let 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.");
+ let options = [...select.options];
+ is(options.length - 2, featuredCount
+ - preferredDevices.removed.size + preferredDevices.added.size,
+ "Got expected number of devices in device selector.");
+
+ ok(options.filter(o => o.value === addedDevice.name)[0],
+ "dummy device added to the device selector.");
+
+ ok(options.filter(o => preferredDevices.added.has(o.value))[0],
+ "device added by user still in the device selector.");
+
+ ok(!options.filter(o => preferredDevices.removed.has(o.value))[0],
+ "device removed by user not in the device selector.");
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_device_width.js b/devtools/client/responsive.html/test/browser/browser_device_width.js
new file mode 100644
index 0000000000..9489d8f0bb
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_width.js
@@ -0,0 +1,66 @@
+/* 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, function* ({ ui, manager }) {
+ ok(ui, "An instance of the RDM should be attached to the tab.");
+ yield setViewportSize(ui, manager, 110, 500);
+
+ info("Checking initial width/height properties.");
+ yield doInitialChecks(ui);
+
+ info("Changing the RDM size");
+ yield setViewportSize(ui, manager, 90, 500);
+
+ info("Checking for screen props");
+ yield checkScreenProps(ui);
+
+ info("Setting docShell.deviceSizeIsPageSize to false");
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ let docShell = content.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShell);
+ docShell.deviceSizeIsPageSize = false;
+ });
+
+ info("Checking for screen props once again.");
+ yield checkScreenProps2(ui);
+});
+
+function* doInitialChecks(ui) {
+ let { innerWidth, matchesMedia } = yield grabContentInfo(ui);
+ is(innerWidth, 110, "initial width should be 110px");
+ ok(!matchesMedia, "media query shouldn't match.");
+}
+
+function* checkScreenProps(ui) {
+ let { matchesMedia, screen } = yield 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");
+}
+
+function* checkScreenProps2(ui) {
+ let { matchesMedia, screen } = yield grabContentInfo(ui);
+ ok(!matchesMedia, "media query should be re-evaluated.");
+ is(window.screen.width, screen.width,
+ "screen.width should be the size of the screen.");
+}
+
+function grabContentInfo(ui) {
+ return ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ return {
+ screen: {
+ width: content.screen.width,
+ height: content.screen.height
+ },
+ innerWidth: content.innerWidth,
+ matchesMedia: content.matchMedia("(max-device-width:100px)").matches
+ };
+ });
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_dpr_change.js b/devtools/client/responsive.html/test/browser/browser_dpr_change.js
new file mode 100644
index 0000000000..4c70087bff
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_dpr_change.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests changing viewport DPR
+const TEST_URL = "data:text/html;charset=utf-8,DPR list test";
+const DEFAULT_DPPX = window.devicePixelRatio;
+const VIEWPORT_DPPX = DEFAULT_DPPX + 2;
+const Types = require("devtools/client/responsive.html/types");
+
+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, function* ({ ui, manager }) {
+ yield waitStartup(ui);
+
+ yield testDefaults(ui);
+ yield testChangingDevice(ui);
+ yield testResetWhenResizingViewport(ui);
+ yield testChangingDPR(ui);
+});
+
+function* waitStartup(ui) {
+ let { store } = ui.toolWindow;
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+}
+
+function* testDefaults(ui) {
+ info("Test Defaults");
+
+ yield testDevicePixelRatio(ui, window.devicePixelRatio);
+ testViewportDPRSelect(ui, {value: window.devicePixelRatio, disabled: false});
+ testViewportDeviceSelectLabel(ui, "no device selected");
+}
+
+function* testChangingDevice(ui) {
+ info("Test Changing Device");
+
+ let waitPixelRatioChange = onceDevicePixelRatioChange(ui);
+
+ yield selectDevice(ui, testDevice.name);
+ yield waitForViewportResizeTo(ui, testDevice.width, testDevice.height);
+ yield waitPixelRatioChange;
+ yield testDevicePixelRatio(ui, testDevice.pixelRatio);
+ testViewportDPRSelect(ui, {value: testDevice.pixelRatio, disabled: true});
+ testViewportDeviceSelectLabel(ui, testDevice.name);
+}
+
+function* testResetWhenResizingViewport(ui) {
+ info("Test reset when resizing the viewport");
+
+ let waitPixelRatioChange = onceDevicePixelRatioChange(ui);
+
+ let deviceRemoved = once(ui, "device-removed");
+ yield testViewportResize(ui, ".viewport-vertical-resize-handle",
+ [-10, -10], [testDevice.width, testDevice.height - 10], [0, -10], ui);
+ yield deviceRemoved;
+
+ yield waitPixelRatioChange;
+ yield testDevicePixelRatio(ui, window.devicePixelRatio);
+
+ testViewportDPRSelect(ui, {value: window.devicePixelRatio, disabled: false});
+ testViewportDeviceSelectLabel(ui, "no device selected");
+}
+
+function* testChangingDPR(ui) {
+ info("Test changing device pixel ratio");
+
+ let waitPixelRatioChange = onceDevicePixelRatioChange(ui);
+
+ yield selectDPR(ui, VIEWPORT_DPPX);
+ yield waitPixelRatioChange;
+ yield testDevicePixelRatio(ui, VIEWPORT_DPPX);
+ testViewportDPRSelect(ui, {value: VIEWPORT_DPPX, disabled: false});
+ testViewportDeviceSelectLabel(ui, "no device selected");
+}
+
+function testViewportDPRSelect(ui, expected) {
+ info("Test viewport's DPR Select");
+
+ let select = ui.toolWindow.document.querySelector("#global-dpr-selector > select");
+ is(select.value, expected.value,
+ `DPR Select value should be: ${expected.value}`);
+ is(select.disabled, expected.disabled,
+ `DPR Select should be ${expected.disabled ? "disabled" : "enabled"}.`);
+}
+
+function* testDevicePixelRatio(ui, expected) {
+ info("Test device pixel ratio");
+
+ let dppx = yield getViewportDevicePixelRatio(ui);
+ is(dppx, expected, `devicePixelRatio should be: ${expected}`);
+}
+
+function* getViewportDevicePixelRatio(ui) {
+ return yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ return content.devicePixelRatio;
+ });
+}
+
+function onceDevicePixelRatioChange(ui) {
+ return ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ info(`Listening for a pixel ratio change (current: ${content.devicePixelRatio}dppx)`);
+
+ let pixelRatio = content.devicePixelRatio;
+ let mql = content.matchMedia(`(resolution: ${pixelRatio}dppx)`);
+
+ return new Promise(resolve => {
+ const onWindowCreated = () => {
+ if (pixelRatio !== content.devicePixelRatio) {
+ resolve();
+ }
+ };
+
+ addEventListener("DOMWindowCreated", onWindowCreated, {once: true});
+
+ mql.addListener(function listener() {
+ mql.removeListener(listener);
+ removeEventListener("DOMWindowCreated", onWindowCreated, {once: true});
+ resolve();
+ });
+ });
+ });
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_exit_button.js b/devtools/client/responsive.html/test/browser/browser_exit_button.js
new file mode 100644
index 0000000000..62e6522748
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_exit_button.js
@@ -0,0 +1,70 @@
+/* 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, function* (...args) {
+ yield testExitButton(...args);
+});
+
+// Test global exit button on detached tab.
+// See Bug 1262806
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+ let { ui, manager } = yield openRDM(tab);
+
+ yield waitBootstrap(ui);
+
+ let waitTabIsDetached = Promise.all([
+ once(tab, "TabClose"),
+ once(tab.linkedBrowser, "SwapDocShells")
+ ]);
+
+ // Detach the tab with RDM open.
+ let newWindow = gBrowser.replaceTabWithWindow(tab);
+
+ // Waiting the tab is detached.
+ yield waitTabIsDetached;
+
+ // 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.
+ yield testExitButton(yield openRDM(tab));
+ yield BrowserTestUtils.closeWindow(newWindow);
+});
+
+function* waitBootstrap(ui) {
+ let { toolWindow, tab } = ui;
+ let { store } = toolWindow;
+ let url = String(tab.linkedBrowser.currentURI.spec);
+
+ // Wait until the viewport has been added.
+ yield waitUntilState(store, state => state.viewports.length == 1);
+
+ // Wait until the document has been loaded.
+ yield waitForFrameLoad(ui, url);
+}
+
+function* testExitButton({ui, manager}) {
+ yield waitBootstrap(ui);
+
+ let exitButton = ui.toolWindow.document.getElementById("global-exit-button");
+
+ ok(manager.isActiveForTab(ui.tab),
+ "Responsive Design Mode active for the tab");
+
+ exitButton.click();
+
+ yield once(manager, "off");
+
+ ok(!manager.isActiveForTab(ui.tab),
+ "Responsive Design Mode is not active for the tab");
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_frame_script_active.js b/devtools/client/responsive.html/test/browser/browser_frame_script_active.js
new file mode 100644
index 0000000000..81449a340b
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_frame_script_active.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Verify frame script is active when expected.
+
+const e10s = require("devtools/client/responsive.html/utils/e10s");
+
+const TEST_URL = "http://example.com/";
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let { ui } = yield openRDM(tab);
+
+ let mm = ui.getViewportBrowser().messageManager;
+ let { active } = yield e10s.request(mm, "IsActive");
+ is(active, true, "Frame script is active");
+
+ yield closeRDM(tab);
+
+ // Must re-get the messageManager on each run since it changes when RDM opens
+ // or closes due to the design of swapFrameLoaders. Also, we only have access
+ // to a valid `ui` instance while RDM is open.
+ mm = tab.linkedBrowser.messageManager;
+ ({ active } = yield e10s.request(mm, "IsActive"));
+ is(active, false, "Frame script is active");
+
+ // Try another round as well to be sure there is no state saved anywhere
+ ({ ui } = yield openRDM(tab));
+
+ mm = ui.getViewportBrowser().messageManager;
+ ({ active } = yield e10s.request(mm, "IsActive"));
+ is(active, true, "Frame script is active");
+
+ yield closeRDM(tab);
+
+ // Must re-get the messageManager on each run since it changes when RDM opens
+ // or closes due to the design of swapFrameLoaders. Also, we only have access
+ // to a valid `ui` instance while RDM is open.
+ mm = tab.linkedBrowser.messageManager;
+ ({ active } = yield e10s.request(mm, "IsActive"));
+ is(active, false, "Frame script is active");
+
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_menu_item_01.js b/devtools/client/responsive.html/test/browser/browser_menu_item_01.js
new file mode 100644
index 0000000000..8e1c1c4cdb
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_menu_item_01.js
@@ -0,0 +1,62 @@
+/* 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 tabUtils = require("sdk/tabs/utils");
+const { startup } = require("sdk/window/helpers");
+
+const activateTab = (tab) => new Promise(resolve => {
+ let { tabContainer } = tabUtils.getOwnerWindow(tab).gBrowser;
+
+ tabContainer.addEventListener("TabSelect", function listener({type}) {
+ tabContainer.removeEventListener(type, listener);
+ resolve();
+ });
+
+ tabUtils.activateTab(tab);
+});
+
+const isMenuChecked = () => {
+ let menu = document.getElementById("menu_responsiveUI");
+ return menu.getAttribute("checked") === "true";
+};
+
+add_task(function* () {
+ yield startup(window);
+
+ ok(!isMenuChecked(),
+ "RDM menu item is unchecked by default");
+
+ const tab = yield addTab(TEST_URL);
+
+ ok(!isMenuChecked(),
+ "RDM menu item is unchecked for new tab");
+
+ yield openRDM(tab);
+
+ ok(isMenuChecked(),
+ "RDM menu item is checked with RDM open");
+
+ const tab2 = yield addTab(TEST_URL);
+
+ ok(!isMenuChecked(),
+ "RDM menu item is unchecked for new tab");
+
+ yield activateTab(tab);
+
+ ok(isMenuChecked(),
+ "RDM menu item is checked for the tab where RDM is open");
+
+ yield closeRDM(tab);
+
+ ok(!isMenuChecked(),
+ "RDM menu item is unchecked after RDM is closed");
+
+ yield removeTab(tab);
+ yield removeTab(tab2);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_menu_item_02.js b/devtools/client/responsive.html/test/browser/browser_menu_item_02.js
new file mode 100644
index 0000000000..166ecb8ae5
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_menu_item_02.js
@@ -0,0 +1,49 @@
+/* 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 { getMostRecentBrowserWindow } = require("sdk/window/utils");
+
+const isMenuCheckedFor = ({document}) => {
+ let menu = document.getElementById("menu_responsiveUI");
+ return menu.getAttribute("checked") === "true";
+};
+
+add_task(function* () {
+ const window1 = yield BrowserTestUtils.openNewBrowserWindow();
+ let { gBrowser } = window1;
+
+ yield BrowserTestUtils.withNewTab({ gBrowser, url: TEST_URL },
+ function* (browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ is(window1, getMostRecentBrowserWindow(),
+ "The new window is the active one");
+
+ ok(!isMenuCheckedFor(window1),
+ "RDM menu item is unchecked by default");
+
+ yield openRDM(tab);
+
+ ok(isMenuCheckedFor(window1),
+ "RDM menu item is checked with RDM open");
+
+ yield closeRDM(tab);
+
+ ok(!isMenuCheckedFor(window1),
+ "RDM menu item is unchecked with RDM closed");
+ });
+
+ yield BrowserTestUtils.closeWindow(window1);
+
+ is(window, getMostRecentBrowserWindow(),
+ "The original window is the active one");
+
+ ok(!isMenuCheckedFor(window),
+ "RDM menu item is unchecked");
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_mouse_resize.js b/devtools/client/responsive.html/test/browser/browser_mouse_resize.js
new file mode 100644
index 0000000000..98ccdab694
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_mouse_resize.js
@@ -0,0 +1,27 @@
+/* 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, function* ({ ui, manager }) {
+ let store = ui.toolWindow.store;
+
+ // Wait until the viewport has been added
+ yield waitUntilState(store, state => state.viewports.length == 1);
+
+ yield setViewportSize(ui, manager, 300, 300);
+
+ // Do horizontal + vertical resize
+ yield testViewportResize(ui, ".viewport-resize-handle",
+ [10, 10], [320, 310], [10, 10]);
+
+ // Do horizontal resize
+ yield testViewportResize(ui, ".viewport-horizontal-resize-handle",
+ [-10, 10], [300, 310], [-10, 0]);
+
+ // Do vertical resize
+ yield testViewportResize(ui, ".viewport-vertical-resize-handle",
+ [-10, -10], [300, 300], [0, -10], ui);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_navigation.js b/devtools/client/responsive.html/test/browser/browser_navigation.js
new file mode 100644
index 0000000000..2c9f0027f1
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_navigation.js
@@ -0,0 +1,98 @@
+/* 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 = "http://example.com/";
+const TEST_URL = `${URL_ROOT}doc_page_state.html`;
+const DUMMY_2_URL = "http://example.com/browser/";
+const DUMMY_3_URL = "http://example.com/browser/devtools/";
+
+add_task(function* () {
+ // Load up a sequence of pages:
+ // 0. DUMMY_1_URL
+ // 1. TEST_URL
+ // 2. DUMMY_2_URL
+ let tab = yield addTab(DUMMY_1_URL);
+ let browser = tab.linkedBrowser;
+ yield load(browser, TEST_URL);
+ yield load(browser, DUMMY_2_URL);
+
+ // Check session history state
+ let history = yield getSessionHistory(browser);
+ is(history.index, 2, "At page 2 in history");
+ is(history.entries.length, 3, "3 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+ is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+ // Go back one so we're at the test page
+ yield back(browser);
+
+ // Check session history state
+ history = yield getSessionHistory(browser);
+ is(history.index, 1, "At page 1 in history");
+ is(history.entries.length, 3, "3 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+ is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+ yield openRDM(tab);
+
+ 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");
+
+ yield 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");
+
+ yield back(browser);
+ yield 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");
+
+ let receivedStatusChanges = new Promise(resolve => {
+ let statusChangesSeen = 0;
+ let statusChangesExpected = 2;
+ let progressListener = {
+ onStatusChange(webProgress, request, status, message) {
+ info(message);
+ if (++statusChangesSeen == statusChangesExpected) {
+ gBrowser.removeProgressListener(progressListener);
+ ok(true, `${statusChangesExpected} status changes while loading`);
+ resolve();
+ }
+ }
+ };
+ gBrowser.addProgressListener(progressListener);
+ });
+ yield load(browser, DUMMY_3_URL);
+ yield receivedStatusChanges;
+
+ 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");
+
+ yield closeRDM(tab);
+
+ // Check session history state
+ history = yield getSessionHistory(browser);
+ is(history.index, 1, "At page 1 in history");
+ is(history.entries.length, 2, "2 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, DUMMY_3_URL, "Page 1 URL matches");
+
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_network_throttling.js b/devtools/client/responsive.html/test/browser/browser_network_throttling.js
new file mode 100644
index 0000000000..18c4a90ed7
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_network_throttling.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const throttlingProfiles = require("devtools/client/shared/network-throttling-profiles");
+
+// Tests changing network throttling
+const TEST_URL = "data:text/html;charset=utf-8,Network throttling test";
+
+addRDMTask(TEST_URL, function* ({ ui, manager }) {
+ let { store } = ui.toolWindow;
+
+ // Wait until the viewport has been added
+ yield waitUntilState(store, state => state.viewports.length == 1);
+
+ // Test defaults
+ testNetworkThrottlingSelectorLabel(ui, "No throttling");
+ yield testNetworkThrottlingState(ui, null);
+
+ // Test a fast profile
+ yield testThrottlingProfile(ui, "Wi-Fi");
+
+ // Test a slower profile
+ yield testThrottlingProfile(ui, "Regular 3G");
+
+ // Test switching back to no throttling
+ yield selectNetworkThrottling(ui, "No throttling");
+ testNetworkThrottlingSelectorLabel(ui, "No throttling");
+ yield testNetworkThrottlingState(ui, null);
+});
+
+function testNetworkThrottlingSelectorLabel(ui, expected) {
+ let selector = "#global-network-throttling-selector";
+ let select = ui.toolWindow.document.querySelector(selector);
+ is(select.selectedOptions[0].textContent, expected,
+ `Select label should be changed to ${expected}`);
+}
+
+var testNetworkThrottlingState = Task.async(function* (ui, expected) {
+ let state = yield ui.emulationFront.getNetworkThrottling();
+ Assert.deepEqual(state, expected, "Network throttling state should be " +
+ JSON.stringify(expected, null, 2));
+});
+
+var testThrottlingProfile = Task.async(function* (ui, profile) {
+ yield selectNetworkThrottling(ui, profile);
+ testNetworkThrottlingSelectorLabel(ui, profile);
+ let data = throttlingProfiles.find(({ id }) => id == profile);
+ let { download, upload, latency } = data;
+ yield testNetworkThrottlingState(ui, {
+ downloadThroughput: download,
+ uploadThroughput: upload,
+ latency,
+ });
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_page_state.js b/devtools/client/responsive.html/test/browser/browser_page_state.js
new file mode 100644
index 0000000000..3069005356
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_page_state.js
@@ -0,0 +1,76 @@
+/* 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 = "http://example.com/";
+const TEST_URL = `${URL_ROOT}doc_page_state.html`;
+const DUMMY_2_URL = "http://example.com/browser/";
+
+add_task(function* () {
+ // Load up a sequence of pages:
+ // 0. DUMMY_1_URL
+ // 1. TEST_URL
+ // 2. DUMMY_2_URL
+ let tab = yield addTab(DUMMY_1_URL);
+ let browser = tab.linkedBrowser;
+ yield load(browser, TEST_URL);
+ yield load(browser, DUMMY_2_URL);
+
+ // Check session history state
+ let history = yield getSessionHistory(browser);
+ is(history.index, 2, "At page 2 in history");
+ is(history.entries.length, 3, "3 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+ is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+ // Go back one so we're at the test page
+ yield back(browser);
+
+ // Check session history state
+ history = yield getSessionHistory(browser);
+ is(history.index, 1, "At page 1 in history");
+ is(history.entries.length, 3, "3 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+ is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+ // Click on content to set an altered state that would be lost on reload
+ yield BrowserTestUtils.synthesizeMouseAtCenter("body", {}, browser);
+
+ let { ui } = yield openRDM(tab);
+
+ // Check color inside the viewport
+ let color = yield spawnViewportTask(ui, {}, function* () {
+ // eslint-disable-next-line mozilla/no-cpows-in-tests
+ return content.getComputedStyle(content.document.body)
+ .getPropertyValue("background-color");
+ });
+ is(color, "rgb(0, 128, 0)",
+ "Content is still modified from click in viewport");
+
+ yield closeRDM(tab);
+
+ // Check color back in the browser tab
+ color = yield ContentTask.spawn(browser, {}, function* () {
+ // eslint-disable-next-line mozilla/no-cpows-in-tests
+ 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 = yield getSessionHistory(browser);
+ is(history.index, 1, "At page 1 in history");
+ is(history.entries.length, 3, "3 pages in history");
+ is(history.entries[0].uri, DUMMY_1_URL, "Page 0 URL matches");
+ is(history.entries[1].uri, TEST_URL, "Page 1 URL matches");
+ is(history.entries[2].uri, DUMMY_2_URL, "Page 2 URL matches");
+
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_permission_doorhanger.js b/devtools/client/responsive.html/test/browser/browser_permission_doorhanger.js
new file mode 100644
index 0000000000..68b5945099
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_permission_doorhanger.js
@@ -0,0 +1,52 @@
+/* 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`;
+
+function waitForGeolocationPrompt(win, browser) {
+ return new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener("popupshown", function popupShown() {
+ let notification = win.PopupNotifications.getNotification("geolocation", browser);
+ if (notification) {
+ win.PopupNotifications.panel.removeEventListener("popupshown", popupShown);
+ resolve();
+ }
+ });
+ });
+}
+
+add_task(function* () {
+ let tab = yield addTab(DUMMY_URL);
+ let browser = tab.linkedBrowser;
+ let win = browser.ownerGlobal;
+
+ let waitPromptPromise = waitForGeolocationPrompt(win, browser);
+
+ // Checks if a geolocation permission doorhanger appears when openning a page
+ // requesting geolocation
+ yield load(browser, TEST_URL);
+ yield waitPromptPromise;
+
+ ok(true, "Permission doorhanger appeared without RDM enabled");
+
+ // Lets switch back to the dummy website and enable RDM
+ yield load(browser, DUMMY_URL);
+ let { ui } = yield openRDM(tab);
+ let newBrowser = ui.getViewportBrowser();
+
+ waitPromptPromise = waitForGeolocationPrompt(win, newBrowser);
+
+ // Checks if the doorhanger appeared again when reloading the geolocation
+ // page inside RDM
+ yield load(browser, TEST_URL);
+ yield waitPromptPromise;
+
+ ok(true, "Permission doorhanger appeared inside RDM");
+
+ yield closeRDM(tab);
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_resize_cmd.js b/devtools/client/responsive.html/test/browser/browser_resize_cmd.js
new file mode 100644
index 0000000000..7e96e866c3
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_resize_cmd.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* global ResponsiveUIManager */
+/* eslint key-spacing: 0 */
+
+add_task(function* () {
+ let manager = ResponsiveUIManager;
+ let done;
+
+ function isOpen() {
+ return ResponsiveUIManager.isActiveForTab(gBrowser.selectedTab);
+ }
+
+ const TEST_URL = "data:text/html;charset=utf-8,hi";
+ yield helpers.addTabWithToolbar(TEST_URL, (options) => {
+ return helpers.audit(options, [
+ {
+ setup() {
+ done = once(manager, "on");
+ return helpers.setInput(options, "resize toggle");
+ },
+ check: {
+ input: "resize toggle",
+ hints: "",
+ markup: "VVVVVVVVVVVVV",
+ status: "VALID"
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(isOpen(), "responsive mode is open");
+ }),
+ },
+ {
+ setup() {
+ done = once(manager, "off");
+ return helpers.setInput(options, "resize toggle");
+ },
+ check: {
+ input: "resize toggle",
+ hints: "",
+ markup: "VVVVVVVVVVVVV",
+ status: "VALID"
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(!isOpen(), "responsive mode is closed");
+ }),
+ },
+ ]);
+ });
+ yield helpers.addTabWithToolbar(TEST_URL, (options) => {
+ return helpers.audit(options, [
+ {
+ setup() {
+ done = once(manager, "on");
+ return helpers.setInput(options, "resize on");
+ },
+ check: {
+ input: "resize on",
+ hints: "",
+ markup: "VVVVVVVVV",
+ status: "VALID"
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(isOpen(), "responsive mode is open");
+ }),
+ },
+ {
+ setup() {
+ done = once(manager, "off");
+ return helpers.setInput(options, "resize off");
+ },
+ check: {
+ input: "resize off",
+ hints: "",
+ markup: "VVVVVVVVVV",
+ status: "VALID"
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(!isOpen(), "responsive mode is closed");
+ }),
+ },
+ ]);
+ });
+ yield helpers.addTabWithToolbar(TEST_URL, (options) => {
+ return helpers.audit(options, [
+ {
+ setup() {
+ done = once(manager, "on");
+ return helpers.setInput(options, "resize to 400 400");
+ },
+ check: {
+ input: "resize to 400 400",
+ hints: "",
+ markup: "VVVVVVVVVVVVVVVVV",
+ status: "VALID",
+ args: {
+ width: { value: 400 },
+ height: { value: 400 },
+ }
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(isOpen(), "responsive mode is open");
+ }),
+ },
+ {
+ setup() {
+ done = once(manager, "off");
+ return helpers.setInput(options, "resize off");
+ },
+ check: {
+ input: "resize off",
+ hints: "",
+ markup: "VVVVVVVVVV",
+ status: "VALID"
+ },
+ exec: {
+ output: ""
+ },
+ post: Task.async(function* () {
+ yield done;
+ ok(!isOpen(), "responsive mode is closed");
+ }),
+ },
+ ]);
+ });
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_screenshot_button.js b/devtools/client/responsive.html/test/browser/browser_screenshot_button.js
new file mode 100644
index 0000000000..60605c33b6
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_screenshot_button.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test global exit button
+
+const TEST_URL = "data:text/html;charset=utf-8,";
+
+const { OS } = require("resource://gre/modules/osfile.jsm");
+
+function* waitUntilScreenshot() {
+ return new Promise(Task.async(function* (resolve) {
+ let { Downloads } = require("resource://gre/modules/Downloads.jsm");
+ let list = yield Downloads.getList(Downloads.ALL);
+
+ let view = {
+ onDownloadAdded: download => {
+ download.whenSucceeded().then(() => {
+ resolve(download.target.path);
+ list.removeView(view);
+ });
+ }
+ };
+
+ yield list.addView(view);
+ }));
+}
+
+addRDMTask(TEST_URL, function* ({ ui: {toolWindow} }) {
+ let { store, document } = toolWindow;
+
+ // Wait until the viewport has been added
+ yield waitUntilState(store, state => state.viewports.length == 1);
+
+ info("Click the screenshot button");
+ let screenshotButton = document.getElementById("global-screenshot-button");
+ screenshotButton.click();
+
+ let whenScreenshotSucceeded = waitUntilScreenshot();
+
+ let filePath = yield whenScreenshotSucceeded;
+ let image = new Image();
+ image.src = OS.Path.toFileURI(filePath);
+
+ yield once(image, "load");
+
+ // We have only one viewport at the moment
+ let viewport = store.getState().viewports[0];
+ let 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");
+
+ yield OS.File.remove(filePath);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_tab_close.js b/devtools/client/responsive.html/test/browser/browser_tab_close.js
new file mode 100644
index 0000000000..1c5ed7c913
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_tab_close.js
@@ -0,0 +1,43 @@
+/* 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/";
+
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let { ui } = yield openRDM(tab);
+ let clientClosed = waitForClientClose(ui);
+
+ closeRDM(tab, {
+ reason: "TabClose",
+ });
+
+ // This flag is set at the end of `ResponsiveUI.destroy`. If it is true
+ // without yielding on `closeRDM` above, then we must have closed
+ // synchronously.
+ is(ui.destroyed, true, "RDM closed synchronously");
+
+ yield clientClosed;
+ yield removeTab(tab);
+});
+
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let { ui } = yield openRDM(tab);
+ let clientClosed = waitForClientClose(ui);
+
+ yield removeTab(tab);
+
+ // This flag is set at the end of `ResponsiveUI.destroy`. If it is true without
+ // yielding on `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");
+
+ yield clientClosed;
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_tab_remoteness_change.js b/devtools/client/responsive.html/test/browser/browser_tab_remoteness_change.js
new file mode 100644
index 0000000000..7ce32ff284
--- /dev/null
+++ b/devtools/client/responsive.html/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 RDM closes synchronously when tabs change remoteness.
+
+const TEST_URL = "http://example.com/";
+
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let { ui } = yield openRDM(tab);
+ let clientClosed = waitForClientClose(ui);
+
+ closeRDM(tab, {
+ reason: "BeforeTabRemotenessChange",
+ });
+
+ // This flag is set at the end of `ResponsiveUI.destroy`. If it is true
+ // without yielding on `closeRDM` above, then we must have closed
+ // synchronously.
+ is(ui.destroyed, true, "RDM closed synchronously");
+
+ yield clientClosed;
+ yield removeTab(tab);
+});
+
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let { ui } = yield openRDM(tab);
+ let clientClosed = waitForClientClose(ui);
+
+ // Load URL that requires the main process, forcing a remoteness flip
+ yield load(tab.linkedBrowser, "about:robots");
+
+ // This flag is set at the end of `ResponsiveUI.destroy`. If it is true without
+ // yielding on `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");
+
+ yield clientClosed;
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_toolbox_computed_view.js b/devtools/client/responsive.html/test/browser/browser_toolbox_computed_view.js
new file mode 100644
index 0000000000..b0b51aa42f
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_toolbox_computed_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 computed-view refreshes.
+
+const TEST_URI = "data:text/html;charset=utf-8,<html><style>" +
+ "div {" +
+ " width: 500px;" +
+ " height: 10px;" +
+ " background: purple;" +
+ "} " +
+ "@media screen and (max-width: 200px) {" +
+ " div { " +
+ " width: 100px;" +
+ " }" +
+ "};" +
+ "</style><div></div></html>";
+
+addRDMTask(TEST_URI, function* ({ ui, manager }) {
+ info("Open the responsive design mode and set its size to 500x500 to start");
+ yield setViewportSize(ui, manager, 500, 500);
+
+ info("Open the inspector, computed-view and select the test node");
+ let { inspector, view } = yield openComputedView();
+ yield selectNode("div", inspector);
+
+ info("Try shrinking the viewport and checking the applied styles");
+ yield testShrink(view, inspector, ui, manager);
+
+ info("Try growing the viewport and checking the applied styles");
+ yield testGrow(view, inspector, ui, manager);
+
+ yield closeToolbox();
+});
+
+function* testShrink(computedView, inspector, ui, manager) {
+ is(computedWidth(computedView), "500px", "Should show 500px initially.");
+
+ let onRefresh = inspector.once("computed-view-refreshed");
+ yield setViewportSize(ui, manager, 100, 100);
+ yield onRefresh;
+
+ is(computedWidth(computedView), "100px", "Should be 100px after shrinking.");
+}
+
+function* testGrow(computedView, inspector, ui, manager) {
+ let onRefresh = inspector.once("computed-view-refreshed");
+ yield setViewportSize(ui, manager, 500, 500);
+ yield onRefresh;
+
+ is(computedWidth(computedView), "500px", "Should be 500px after growing.");
+}
+
+function computedWidth(computedView) {
+ for (let prop of computedView.propertyViews) {
+ if (prop.name === "width") {
+ return prop.valueNode.textContent;
+ }
+ }
+ return null;
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_toolbox_rule_view.js b/devtools/client/responsive.html/test/browser/browser_toolbox_rule_view.js
new file mode 100644
index 0000000000..7cf012c44e
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_toolbox_rule_view.js
@@ -0,0 +1,78 @@
+/* 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 = "data:text/html;charset=utf-8,<html><style>" +
+ "div {" +
+ " width: 500px;" +
+ " height: 10px;" +
+ " background: purple;" +
+ "} " +
+ "@media screen and (max-width: 200px) {" +
+ " div { " +
+ " width: 100px;" +
+ " }" +
+ "};" +
+ "</style><div></div></html>";
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("devtools.toolbox.splitconsoleEnabled");
+});
+
+addRDMTask(TEST_URI, function* ({ ui, manager }) {
+ info("Open the responsive design mode and set its size to 500x500 to start");
+ yield setViewportSize(ui, manager, 500, 500);
+
+ info("Open the inspector, rule-view and select the test node");
+ let { inspector, view } = yield openRuleView();
+ yield selectNode("div", inspector);
+
+ info("Try shrinking the viewport and checking the applied styles");
+ yield testShrink(view, ui, manager);
+
+ info("Try growing the viewport and checking the applied styles");
+ yield testGrow(view, ui, manager);
+
+ info("Check that ESC still opens the split console");
+ yield testEscapeOpensSplitConsole(inspector);
+
+ yield closeToolbox();
+});
+
+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");
+ let onRefresh = ruleView.once("ruleview-refreshed");
+ yield setViewportSize(ui, manager, 100, 100);
+ yield onRefresh;
+
+ is(numberOfRules(ruleView), 3, "Should have three rules after shrinking.");
+}
+
+function* testGrow(ruleView, ui, manager) {
+ info("Resize to 500x500 and wait for the rule-view to update");
+ let onRefresh = ruleView.once("ruleview-refreshed");
+ yield setViewportSize(ui, manager, 500, 500);
+ yield onRefresh;
+
+ is(numberOfRules(ruleView), 2, "Should have two rules after growing.");
+}
+
+function* testEscapeOpensSplitConsole(inspector) {
+ ok(!inspector._toolbox._splitConsole, "Console is not split.");
+
+ info("Press escape");
+ let onSplit = inspector._toolbox.once("split-console");
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ yield 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.html/test/browser/browser_toolbox_swap_browsers.js b/devtools/client/responsive.html/test/browser/browser_toolbox_swap_browsers.js
new file mode 100644
index 0000000000..8f7afaf010
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_toolbox_swap_browsers.js
@@ -0,0 +1,128 @@
+/* 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 ContentTask.spawn(browser, {}, function* () {
+ const Cu = Components.utils;
+ const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+ const { DebuggerServer } = require("devtools/server/main");
+ if (!DebuggerServer._connections) {
+ return 0;
+ }
+ return Object.getOwnPropertyNames(DebuggerServer._connections);
+ });
+}
+
+let checkServerConnectionCount = Task.async(function* (browser, expected, msg) {
+ let conns = yield getServerConnections(browser);
+ is(conns.length || 0, expected, "Server connection count: " + msg);
+});
+
+let checkToolbox = Task.async(function* (tab, location) {
+ let target = TargetFactory.forTab(tab);
+ ok(!!gDevTools.getToolbox(target), `Toolbox exists ${location}`);
+});
+
+add_task(function* setup() {
+ yield SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount", 1]]
+ });
+});
+
+add_task(function* () {
+ let tab = yield addTab(TEST_URL);
+
+ let 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
+ yield checkServerConnectionCount(tab.linkedBrowser, 0,
+ "0: No DevTools connections yet");
+ let { toolbox } = yield openInspector();
+ if (tabsInDifferentProcesses) {
+ // 1: Two tabs open, but only one per content process
+ yield 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)
+ yield checkServerConnectionCount(tab.linkedBrowser, 2,
+ "2: One for each tab (starting tab plus the one we opened)");
+ }
+ yield checkToolbox(tab, "outside RDM");
+ let { ui } = yield openRDM(tab);
+ if (tabsInDifferentProcesses) {
+ // 2: RDM UI adds an extra connection, 1 + 1 = 2
+ yield checkServerConnectionCount(ui.getViewportBrowser(), 2,
+ "2: RDM UI uses an extra connection");
+ } else {
+ // 3: RDM UI adds an extra connection, 2 + 1 = 3
+ yield checkServerConnectionCount(ui.getViewportBrowser(), 3,
+ "3: RDM UI uses an extra connection");
+ }
+ yield checkToolbox(tab, "after opening RDM");
+ yield closeRDM(tab);
+ if (tabsInDifferentProcesses) {
+ // 1: RDM UI closed, return to previous connection count
+ yield checkServerConnectionCount(tab.linkedBrowser, 1,
+ "1: RDM UI closed, return to previous connection count");
+ } else {
+ // 2: RDM UI closed, return to previous connection count
+ yield checkServerConnectionCount(tab.linkedBrowser, 2,
+ "2: RDM UI closed, return to previous connection count");
+ }
+ yield checkToolbox(tab, tab.linkedBrowser, "after closing RDM");
+ yield toolbox.destroy();
+ // 0: All DevTools usage closed
+ yield checkServerConnectionCount(tab.linkedBrowser, 0,
+ "0: All DevTools usage closed");
+ }
+
+ info("Open toolbox inside RDM");
+ {
+ // 0: No DevTools connections yet
+ yield checkServerConnectionCount(tab.linkedBrowser, 0,
+ "0: No DevTools connections yet");
+ let { ui } = yield openRDM(tab);
+ // 1: RDM UI uses an extra connection
+ yield checkServerConnectionCount(ui.getViewportBrowser(), 1,
+ "1: RDM UI uses an extra connection");
+ let { toolbox } = yield openInspector();
+ if (tabsInDifferentProcesses) {
+ // 2: Two tabs open, but only one per content process
+ yield 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)
+ yield checkServerConnectionCount(ui.getViewportBrowser(), 3,
+ "3: One for each tab (starting tab plus the one we opened)");
+ }
+ yield checkToolbox(tab, ui.getViewportBrowser(), "inside RDM");
+ yield closeRDM(tab);
+ if (tabsInDifferentProcesses) {
+ // 1: RDM UI closed, one less connection
+ yield checkServerConnectionCount(tab.linkedBrowser, 1,
+ "1: RDM UI closed, one less connection");
+ } else {
+ // 2: RDM UI closed, one less connection
+ yield checkServerConnectionCount(tab.linkedBrowser, 2,
+ "2: RDM UI closed, one less connection");
+ }
+ yield checkToolbox(tab, tab.linkedBrowser, "after closing RDM");
+ yield toolbox.destroy();
+ // 0: All DevTools usage closed
+ yield checkServerConnectionCount(tab.linkedBrowser, 0,
+ "0: All DevTools usage closed");
+ }
+
+ yield removeTab(tab);
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_touch_device.js b/devtools/client/responsive.html/test/browser/browser_touch_device.js
new file mode 100644
index 0000000000..aea6de2c4b
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_touch_device.js
@@ -0,0 +1,77 @@
+/* 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("devtools/client/responsive.html/types");
+
+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, function* ({ ui, manager }) {
+ yield waitStartup(ui);
+
+ yield testDefaults(ui);
+ yield testChangingDevice(ui);
+ yield testResizingViewport(ui, true, false);
+ yield testEnableTouchSimulation(ui);
+ yield testResizingViewport(ui, false, true);
+});
+
+function* waitStartup(ui) {
+ let { store } = ui.toolWindow;
+
+ // Wait until the viewport has been added and the device list has been loaded
+ yield waitUntilState(store, state => state.viewports.length == 1
+ && state.devices.listState == Types.deviceListState.LOADED);
+}
+
+function* testDefaults(ui) {
+ info("Test Defaults");
+
+ yield testTouchEventsOverride(ui, false);
+ testViewportDeviceSelectLabel(ui, "no device selected");
+}
+
+function* testChangingDevice(ui) {
+ info("Test Changing Device");
+
+ yield selectDevice(ui, testDevice.name);
+ yield waitForViewportResizeTo(ui, testDevice.width, testDevice.height);
+ yield testTouchEventsOverride(ui, true);
+ testViewportDeviceSelectLabel(ui, testDevice.name);
+}
+
+function* testResizingViewport(ui, device, expected) {
+ info(`Test resizing the viewport, device ${device}, expected ${expected}`);
+
+ let deviceRemoved = once(ui, "device-removed");
+ yield testViewportResize(ui, ".viewport-vertical-resize-handle",
+ [-10, -10], [testDevice.width, testDevice.height - 10], [0, -10], ui);
+ if (device) {
+ yield deviceRemoved;
+ }
+ yield testTouchEventsOverride(ui, expected);
+ testViewportDeviceSelectLabel(ui, "no device selected");
+}
+
+function* testEnableTouchSimulation(ui) {
+ info("Test enabling touch simulation via button");
+
+ yield enableTouchSimulation(ui);
+ yield testTouchEventsOverride(ui, true);
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_touch_simulation.js b/devtools/client/responsive.html/test/browser/browser_touch_simulation.js
new file mode 100644
index 0000000000..12a7183060
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_touch_simulation.js
@@ -0,0 +1,228 @@
+/* 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}touch.html`;
+const PREF_DOM_META_VIEWPORT_ENABLED = "dom.meta-viewport.enabled";
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ yield waitBootstrap(ui);
+ yield testWithNoTouch(ui);
+ yield enableTouchSimulation(ui);
+ yield testWithTouch(ui);
+ yield testWithMetaViewportEnabled(ui);
+ yield testWithMetaViewportDisabled(ui);
+ testTouchButton(ui);
+});
+
+function* testWithNoTouch(ui) {
+ yield injectEventUtils(ui);
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ let { EventUtils } = content;
+
+ let div = content.document.querySelector("div");
+ let x = 0, y = 0;
+
+ info("testWithNoTouch: Initial test parameter and mouse mouse outside div");
+ x = -1; y = -1;
+ yield 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");
+ yield EventUtils.synthesizeMouseAtCenter(div,
+ { type: "mousemove", isSynthesized: false }, content);
+ is(div.style.backgroundColor, "red", "mouseenter or mouseover should work");
+
+ info("testWithNoTouch: Drag the div element");
+ yield EventUtils.synthesizeMouseAtCenter(div,
+ { type: "mousedown", isSynthesized: false }, content);
+ x = 100; y = 100;
+ yield EventUtils.synthesizeMouse(div, x, y,
+ { type: "mousemove", isSynthesized: false }, content);
+ is(div.style.transform, "none", "touchmove shouldn't work");
+ yield EventUtils.synthesizeMouse(div, x, y,
+ { type: "mouseup", isSynthesized: false }, content);
+
+ info("testWithNoTouch: Move mouse out of the div element");
+ x = -1; y = -1;
+ yield 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");
+ yield EventUtils.synthesizeClick(div);
+ is(div.dataset.isDelay, "false",
+ "300ms delay between touch events and mouse events should not work");
+ });
+}
+
+function* testWithTouch(ui) {
+ yield injectEventUtils(ui);
+
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ let { EventUtils } = content;
+
+ let div = content.document.querySelector("div");
+ let x = 0, y = 0;
+
+ info("testWithTouch: Initial test parameter and mouse mouse outside div");
+ x = -1; y = -1;
+ yield 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");
+ yield 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");
+ yield EventUtils.synthesizeMouseAtCenter(div,
+ { type: "mousedown", isSynthesized: false }, content);
+ x = 100; y = 100;
+ yield EventUtils.synthesizeMouse(div, x, y,
+ { type: "mousemove", isSynthesized: false }, content);
+ isnot(div.style.transform, "none", "touchmove should work");
+ yield EventUtils.synthesizeMouse(div, x, y,
+ { type: "mouseup", isSynthesized: false }, content);
+
+ info("testWithTouch: Move mouse out of the div element");
+ x = -1; y = -1;
+ yield EventUtils.synthesizeMouse(div, x, y,
+ { type: "mousemove", isSynthesized: false }, content);
+ isnot(div.style.backgroundColor, "blue",
+ "mouseout or mouseleave should not work");
+ });
+}
+
+function* testWithMetaViewportEnabled(ui) {
+ yield SpecialPowers.pushPrefEnv({set: [[PREF_DOM_META_VIEWPORT_ENABLED, true]]});
+
+ yield injectEventUtils(ui);
+
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ let { synthesizeClick } = content.EventUtils;
+
+ let meta = content.document.querySelector("meta[name=viewport]");
+ let div = content.document.querySelector("div");
+ div.dataset.isDelay = "false";
+
+ info("testWithMetaViewportEnabled: " +
+ "click the div element with <meta name='viewport'>");
+ meta.content = "";
+ yield synthesizeClick(div);
+ is(div.dataset.isDelay, "true",
+ "300ms delay between touch events and mouse events should work");
+
+ info("testWithMetaViewportEnabled: " +
+ "click the div element with " +
+ "<meta name='viewport' content='user-scalable=no'>");
+ meta.content = "user-scalable=no";
+ yield synthesizeClick(div);
+ is(div.dataset.isDelay, "false",
+ "300ms delay between touch events and mouse events should not work");
+
+ info("testWithMetaViewportEnabled: " +
+ "click the div element with " +
+ "<meta name='viewport' content='minimum-scale=maximum-scale'>");
+ meta.content = "minimum-scale=maximum-scale";
+ yield synthesizeClick(div);
+ is(div.dataset.isDelay, "false",
+ "300ms delay between touch events and mouse events should not work");
+
+ info("testWithMetaViewportEnabled: " +
+ "click the div element with " +
+ "<meta name='viewport' content='width=device-width'>");
+ meta.content = "width=device-width";
+ yield synthesizeClick(div);
+ is(div.dataset.isDelay, "false",
+ "300ms delay between touch events and mouse events should not work");
+ });
+}
+
+function* testWithMetaViewportDisabled(ui) {
+ yield SpecialPowers.pushPrefEnv({set: [[PREF_DOM_META_VIEWPORT_ENABLED, false]]});
+
+ yield injectEventUtils(ui);
+
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ let { synthesizeClick } = content.EventUtils;
+
+ let meta = content.document.querySelector("meta[name=viewport]");
+ let div = content.document.querySelector("div");
+ div.dataset.isDelay = "false";
+
+ info("testWithMetaViewportDisabled: click the div with <meta name='viewport'>");
+ meta.content = "";
+ yield synthesizeClick(div);
+ is(div.dataset.isDelay, "true",
+ "300ms delay between touch events and mouse events should work");
+ });
+}
+
+function testTouchButton(ui) {
+ let { document } = ui.toolWindow;
+ let touchButton = document.querySelector("#global-touch-simulation-button");
+
+ ok(touchButton.classList.contains("active"),
+ "Touch simulation is active at end of test.");
+
+ touchButton.click();
+
+ ok(!touchButton.classList.contains("active"),
+ "Touch simulation is stopped on click.");
+
+ touchButton.click();
+
+ ok(touchButton.classList.contains("active"),
+ "Touch simulation is started on click.");
+}
+
+function* waitBootstrap(ui) {
+ let { store } = ui.toolWindow;
+
+ yield waitUntilState(store, state => state.viewports.length == 1);
+ yield waitForFrameLoad(ui, TEST_URL);
+}
+
+function* injectEventUtils(ui) {
+ yield ContentTask.spawn(ui.getViewportBrowser(), {}, function* () {
+ if ("EventUtils" in content) {
+ return;
+ }
+
+ let EventUtils = content.EventUtils = {};
+
+ EventUtils.window = {};
+ EventUtils.parent = EventUtils.window;
+ /* eslint-disable camelcase */
+ EventUtils._EU_Ci = Components.interfaces;
+ EventUtils._EU_Cc = Components.classes;
+ /* eslint-enable camelcase */
+ // EventUtils' `sendChar` function relies on the navigator to synthetize events.
+ EventUtils.navigator = content.navigator;
+ EventUtils.KeyboardEvent = content.KeyboardEvent;
+
+ EventUtils.synthesizeClick = element => new Promise(resolve => {
+ element.addEventListener("click", function onClick() {
+ element.removeEventListener("click", onClick);
+ resolve();
+ });
+
+ EventUtils.synthesizeMouseAtCenter(element,
+ { type: "mousedown", isSynthesized: false }, content);
+ EventUtils.synthesizeMouseAtCenter(element,
+ { type: "mouseup", isSynthesized: false }, content);
+ });
+
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", EventUtils);
+ });
+}
diff --git a/devtools/client/responsive.html/test/browser/browser_viewport_basics.js b/devtools/client/responsive.html/test/browser/browser_viewport_basics.js
new file mode 100644
index 0000000000..86fc41da92
--- /dev/null
+++ b/devtools/client/responsive.html/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 = "http://example.org/";
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+ let store = ui.toolWindow.store;
+
+ // Wait until the viewport has been added
+ yield waitUntilState(store, state => state.viewports.length == 1);
+
+ // A single viewport of default size appeared
+ let viewport = ui.toolWindow.document.querySelector(".viewport-content");
+
+ is(ui.toolWindow.getComputedStyle(viewport).getPropertyValue("width"),
+ "320px", "Viewport has default width");
+ is(ui.toolWindow.getComputedStyle(viewport).getPropertyValue("height"),
+ "480px", "Viewport has default height");
+
+ // Browser's location should match original tab
+ yield waitForFrameLoad(ui, TEST_URL);
+ let location = yield spawnViewportTask(ui, {}, function* () {
+ return content.location.href; // eslint-disable-line
+ });
+ is(location, TEST_URL, "Viewport location matches");
+});
diff --git a/devtools/client/responsive.html/test/browser/browser_window_close.js b/devtools/client/responsive.html/test/browser/browser_window_close.js
new file mode 100644
index 0000000000..29d9d1e344
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_window_close.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(function* () {
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow();
+ window.open("data:text/html;charset=utf-8,", "_blank");
+ let newWindow = yield newWindowPromise;
+
+ newWindow.focus();
+ yield once(newWindow.gBrowser, "load", true);
+
+ let tab = newWindow.gBrowser.selectedTab;
+ yield openRDM(tab);
+
+ // Close the window on a tab with an active responsive design UI and
+ // wait for the UI to gracefully shutdown. This has leaked the window
+ // in the past.
+ ok(ResponsiveUIManager.isActiveForTab(tab),
+ "ResponsiveUI should be active for tab when the window is closed");
+ let offPromise = once(ResponsiveUIManager, "off");
+ yield BrowserTestUtils.closeWindow(newWindow);
+ yield offPromise;
+});
diff --git a/devtools/client/responsive.html/test/browser/devices.json b/devtools/client/responsive.html/test/browser/devices.json
new file mode 100644
index 0000000000..c3f2bb363b
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/devices.json
@@ -0,0 +1,651 @@
+{
+ "TYPES": [ "phones", "tablets", "laptops", "televisions", "consoles", "watches" ],
+ "phones": [
+ {
+ "name": "Firefox OS Flame",
+ "width": 320,
+ "height": 570,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Alcatel One Touch Fire",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Alcatel One Touch Fire C",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch4019X; rv:28.0) Gecko/28.0 Firefox/28.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Alcatel One Touch Fire E",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Mobile; ALCATELOneTouch6015X; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Apple iPhone 4",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "Apple iPhone 5",
+ "width": 320,
+ "height": 568,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X; en-us) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "Apple iPhone 5s",
+ "width": 320,
+ "height": 568,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_2_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13D15 Safari/601.1",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios",
+ "featured": true
+ },
+ {
+ "name": "Apple iPhone 6",
+ "width": 375,
+ "height": 667,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "Apple iPhone 6 Plus",
+ "width": 414,
+ "height": 736,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios",
+ "featured": true
+ },
+ {
+ "name": "Apple iPhone 6s",
+ "width": 375,
+ "height": 667,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios",
+ "featured": true
+ },
+ {
+ "name": "Apple iPhone 6s Plus",
+ "width": 414,
+ "height": 736,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "BlackBerry Z30",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "blackberryos"
+ },
+ {
+ "name": "Geeksphone Keon",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Geeksphone Peak, Revolution",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Google Nexus S",
+ "width": 320,
+ "height": 533,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Nexus S Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Google Nexus 4",
+ "width": 384,
+ "height": 640,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.4.4; en-us; Nexus 4 Build/JOP40D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Google Nexus 5",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.4.4; en-us; Nexus 5 Build/JOP40D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Google Nexus 6",
+ "width": 412,
+ "height": 732,
+ "pixelRatio": 3.5,
+ "userAgent": "Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.23 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Intex Cloud Fx",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "KDDI Fx0",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Mobile; LGL25; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "LG Fireweb",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; LG-D300; rv:18.1) Gecko/18.1 Firefox/18.1",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "LG Optimus L70",
+ "width": 384,
+ "height": 640,
+ "pixelRatio": 1.25,
+ "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.1599.103 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Nokia Lumia 520",
+ "width": 320,
+ "height": 533,
+ "pixelRatio": 1.4,
+ "userAgent": "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Nokia N9",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "OnePlus One",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Android 5.1.1; Mobile; rv:43.0) Gecko/43.0 Firefox/43.0",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Galaxy S3",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Galaxy S4",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Galaxy S5",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Samsung Galaxy S6",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 4,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Sony Xperia Z3",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Spice Fire One Mi-FX1",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Symphony GoFox F15",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:30.0) Gecko/30.0 Firefox/30.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "ZTE Open",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; ZTEOPEN; rv:18.1) Gecko/18.0 Firefox/18.1",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "ZTE Open II",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; OPEN2; rv:28.0) Gecko/28.0 Firefox/28.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "ZTE Open C",
+ "width": 320,
+ "height": 450,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; OPENC; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ },
+ {
+ "name": "Zen Fire 105",
+ "width": 320,
+ "height": 480,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ }
+ ],
+ "tablets": [
+ {
+ "name": "Amazon Kindle Fire HDX 8.9",
+ "width": 1280,
+ "height": 800,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "fireos",
+ "featured": true
+ },
+ {
+ "name": "Apple iPad",
+ "width": 1024,
+ "height": 768,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "Apple iPad Air 2",
+ "width": 1024,
+ "height": 768,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios",
+ "featured": true
+ },
+ {
+ "name": "Apple iPad Mini",
+ "width": 1024,
+ "height": 768,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios"
+ },
+ {
+ "name": "Apple iPad Mini 2",
+ "width": 1024,
+ "height": 768,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "ios",
+ "featured": true
+ },
+ {
+ "name": "BlackBerry PlayBook",
+ "width": 1024,
+ "height": 600,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "blackberryos"
+ },
+ {
+ "name": "Foxconn InFocus",
+ "width": 1280,
+ "height": 800,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Tablet; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Google Nexus 7",
+ "width": 960,
+ "height": 600,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Google Nexus 10",
+ "width": 1280,
+ "height": 800,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2307.2 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Galaxy Note 2",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 2,
+ "userAgent": "Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Galaxy Note 3",
+ "width": 360,
+ "height": 640,
+ "pixelRatio": 3,
+ "userAgent": "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "android",
+ "featured": true
+ },
+ {
+ "name": "Tesla Model S",
+ "width": 1200,
+ "height": 1920,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (X11; Linux) AppleWebKit/534.34 (KHTML, like Gecko) QtCarBrowser Safari/534.34",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "linux"
+ },
+ {
+ "name": "VIA Vixen",
+ "width": 1024,
+ "height": 600,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Tablet; rv:32.0) Gecko/32.0 Firefox/32.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ }
+ ],
+ "laptops": [
+ {
+ "name": "Laptop (1366 x 768)",
+ "width": 1366,
+ "height": 768,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": false,
+ "firefoxOS": false,
+ "os": "windows",
+ "featured": true
+ },
+ {
+ "name": "Laptop (1920 x 1080)",
+ "width": 1280,
+ "height": 720,
+ "pixelRatio": 1.5,
+ "userAgent": "",
+ "touch": false,
+ "firefoxOS": false,
+ "os": "windows",
+ "featured": true
+ },
+ {
+ "name": "Laptop (1920 x 1080) with touch",
+ "width": 1280,
+ "height": 720,
+ "pixelRatio": 1.5,
+ "userAgent": "",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "windows"
+ }
+ ],
+ "televisions": [
+ {
+ "name": "720p HD Television",
+ "width": 1280,
+ "height": 720,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": false,
+ "firefoxOS": true,
+ "os": "custom"
+ },
+ {
+ "name": "1080p Full HD Television",
+ "width": 1920,
+ "height": 1080,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": false,
+ "firefoxOS": true,
+ "os": "custom"
+ },
+ {
+ "name": "4K Ultra HD Television",
+ "width": 3840,
+ "height": 2160,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": false,
+ "firefoxOS": true,
+ "os": "custom"
+ }
+ ],
+ "consoles": [
+ {
+ "name": "Nintendo 3DS",
+ "width": 320,
+ "height": 240,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Nintendo 3DS; U; ; en) Version/1.7585.EU",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "nintendo"
+ },
+ {
+ "name": "Nintendo Wii U Gamepad",
+ "width": 854,
+ "height": 480,
+ "pixelRatio": 0.87,
+ "userAgent": "Mozilla/5.0 (Nintendo WiiU) AppleWebKit/536.28 (KHTML, like Gecko) NX/3.0.3.12.15 NintendoBrowser/4.1.1.9601.EU",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "nintendo"
+ },
+ {
+ "name": "Sony PlayStation Vita",
+ "width": 960,
+ "height": 544,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Playstation Vita 1.61) AppleWebKit/531.22.8 (KHTML, like Gecko) Silk/3.2",
+ "touch": true,
+ "firefoxOS": false,
+ "os": "playstation"
+ }
+ ],
+ "watches": [
+ {
+ "name": "LG G Watch",
+ "width": 280,
+ "height": 280,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "LG G Watch R",
+ "width": 320,
+ "height": 320,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Motorola Moto 360",
+ "width": 320,
+ "height": 290,
+ "pixelRatio": 1,
+ "userAgent": "Mozilla/5.0 (Linux; Android 5.0.1; Moto 360 Build/LWX48T) AppleWebkit/537.36 (KHTML, like Gecko) Chrome/19.77.34.5 Mobile Safari/537.36",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ },
+ {
+ "name": "Samsung Gear Live",
+ "width": 320,
+ "height": 320,
+ "pixelRatio": 1,
+ "userAgent": "",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "android"
+ }
+ ]
+}
diff --git a/devtools/client/responsive.html/test/browser/doc_page_state.html b/devtools/client/responsive.html/test/browser/doc_page_state.html
new file mode 100644
index 0000000000..fb4d2acf01
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/doc_page_state.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html>
+ <head>
+ <title>Page State Test</title>
+ <style>
+ body {
+ height: 100vh;
+ background: red;
+ }
+ body.modified {
+ background: green;
+ }
+ </style>
+ </head>
+ <body onclick="this.classList.add('modified')"/>
+</html>
diff --git a/devtools/client/responsive.html/test/browser/geolocation.html b/devtools/client/responsive.html/test/browser/geolocation.html
new file mode 100644
index 0000000000..03d105a191
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/geolocation.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Geolocation permission test</title>
+ </head>
+ <body>
+ <script type="text/javascript">
+ "use strict";
+ navigator.geolocation.getCurrentPosition(function (pos) {});
+ </script>
+ </body>
+</html> \ No newline at end of file
diff --git a/devtools/client/responsive.html/test/browser/head.js b/devtools/client/responsive.html/test/browser/head.js
new file mode 100644
index 0000000000..3be69b0afb
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -0,0 +1,401 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+/* import-globals-from ../../../framework/test/shared-head.js */
+/* import-globals-from ../../../framework/test/shared-redux-head.js */
+/* import-globals-from ../../../commandline/test/helpers.js */
+/* import-globals-from ../../../inspector/test/shared-head.js */
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js",
+ this);
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/framework/test/shared-redux-head.js",
+ this);
+
+// Import the GCLI test helper
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/commandline/test/helpers.js",
+ this);
+
+// Import helpers registering the test-actor in remote targets
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/test-actor-registry.js",
+ this);
+
+// Import helpers for the inspector that are also shared with others
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
+ this);
+
+const E10S_MULTI_ENABLED = Services.prefs.getIntPref("dom.ipc.processCount") > 1;
+const TEST_URI_ROOT = "http://example.com/browser/devtools/client/responsive.html/test/browser/";
+const OPEN_DEVICE_MODAL_VALUE = "OPEN_DEVICE_MODAL";
+
+const { _loadPreferredDevices } = require("devtools/client/responsive.html/actions/devices");
+const { getOwnerWindow } = require("sdk/tabs/utils");
+const asyncStorage = require("devtools/shared/async-storage");
+const { addDevice, removeDevice } = require("devtools/client/shared/devices");
+
+SimpleTest.requestCompleteLog();
+SimpleTest.waitForExplicitFinish();
+
+// Toggling the RDM UI involves several docShell swap operations, which are somewhat slow
+// on debug builds. Usually we are just barely over the limit, so a blanket factor of 2
+// should be enough.
+requestLongerTimeout(2);
+
+flags.testing = true;
+Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList");
+Services.prefs.setCharPref("devtools.devices.url",
+ TEST_URI_ROOT + "devices.json");
+Services.prefs.setBoolPref("devtools.responsive.html.enabled", true);
+
+registerCleanupFunction(() => {
+ flags.testing = false;
+ Services.prefs.clearUserPref("devtools.devices.url");
+ Services.prefs.clearUserPref("devtools.responsive.html.enabled");
+ Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList");
+ asyncStorage.removeItem("devtools.devices.url_cache");
+});
+
+// This depends on the "devtools.responsive.html.enabled" pref
+const { ResponsiveUIManager } = require("resource://devtools/client/responsivedesign/responsivedesign.jsm");
+
+/**
+ * Open responsive design mode for the given tab.
+ */
+var openRDM = Task.async(function* (tab) {
+ info("Opening responsive design mode");
+ let manager = ResponsiveUIManager;
+ let ui = yield manager.openIfNeeded(getOwnerWindow(tab), tab);
+ info("Responsive design mode opened");
+ return { ui, manager };
+});
+
+/**
+ * Close responsive design mode for the given tab.
+ */
+var closeRDM = Task.async(function* (tab, options) {
+ info("Closing responsive design mode");
+ let manager = ResponsiveUIManager;
+ yield manager.closeIfNeeded(getOwnerWindow(tab), tab, options);
+ info("Responsive design mode closed");
+});
+
+/**
+ * Adds a new test task that adds a tab with the given URL, opens responsive
+ * design mode, runs the given generator, closes responsive design mode, and
+ * removes the tab.
+ *
+ * Example usage:
+ *
+ * addRDMTask(TEST_URL, function*({ ui, manager }) {
+ * // Your tests go here...
+ * });
+ */
+function addRDMTask(url, generator) {
+ add_task(function* () {
+ const tab = yield addTab(url);
+ const results = yield openRDM(tab);
+
+ try {
+ yield* generator(results);
+ } catch (err) {
+ ok(false, "Got an error: " + DevToolsUtils.safeErrorString(err));
+ }
+
+ yield closeRDM(tab);
+ yield removeTab(tab);
+ });
+}
+
+function spawnViewportTask(ui, args, task) {
+ return ContentTask.spawn(ui.getViewportBrowser(), args, task);
+}
+
+function waitForFrameLoad(ui, targetURL) {
+ return spawnViewportTask(ui, { targetURL }, function* (args) {
+ if ((content.document.readyState == "complete" ||
+ content.document.readyState == "interactive") &&
+ content.location.href == args.targetURL) {
+ return;
+ }
+ yield ContentTaskUtils.waitForEvent(this, "DOMContentLoaded");
+ });
+}
+
+function waitForViewportResizeTo(ui, width, height) {
+ return new Promise(Task.async(function* (resolve) {
+ let isSizeMatching = (data) => data.width == width && data.height == height;
+
+ // If the viewport has already the expected size, we resolve the promise immediately.
+ let size = yield getContentSize(ui);
+ if (isSizeMatching(size)) {
+ resolve();
+ return;
+ }
+
+ // Otherwise, we'll listen to both content's resize event and browser's load end;
+ // since a racing condition can happen, where the content's listener is added after
+ // the resize, because the content's document was reloaded; therefore the test would
+ // hang forever. See bug 1302879.
+ let browser = ui.getViewportBrowser();
+
+ let onResize = (_, data) => {
+ if (!isSizeMatching(data)) {
+ return;
+ }
+ ui.off("content-resize", onResize);
+ browser.removeEventListener("mozbrowserloadend", onBrowserLoadEnd);
+ info(`Got content-resize to ${width} x ${height}`);
+ resolve();
+ };
+
+ let onBrowserLoadEnd = Task.async(function* () {
+ let data = yield getContentSize(ui);
+ onResize(undefined, data);
+ });
+
+ info(`Waiting for content-resize to ${width} x ${height}`);
+ ui.on("content-resize", onResize);
+ browser.addEventListener("mozbrowserloadend",
+ onBrowserLoadEnd, { once: true });
+ }));
+}
+
+var setViewportSize = Task.async(function* (ui, manager, width, height) {
+ let size = ui.getViewportSize();
+ info(`Current size: ${size.width} x ${size.height}, ` +
+ `set to: ${width} x ${height}`);
+ if (size.width != width || size.height != height) {
+ let resized = waitForViewportResizeTo(ui, width, height);
+ ui.setViewportSize({ width, height });
+ yield resized;
+ }
+});
+
+function getElRect(selector, win) {
+ let el = win.document.querySelector(selector);
+ return el.getBoundingClientRect();
+}
+
+/**
+ * Drag an element identified by 'selector' by [x,y] amount. Returns
+ * the rect of the dragged element as it was before drag.
+ */
+function dragElementBy(selector, x, y, win) {
+ let React = win.require("devtools/client/shared/vendor/react");
+ let { Simulate } = React.addons.TestUtils;
+ let rect = getElRect(selector, win);
+ let startPoint = {
+ clientX: rect.left + Math.floor(rect.width / 2),
+ clientY: rect.top + Math.floor(rect.height / 2),
+ };
+ let endPoint = [ startPoint.clientX + x, startPoint.clientY + y ];
+
+ let elem = win.document.querySelector(selector);
+
+ // mousedown is a React listener, need to use its testing tools to avoid races
+ Simulate.mouseDown(elem, startPoint);
+
+ // mousemove and mouseup are regular DOM listeners
+ EventUtils.synthesizeMouseAtPoint(...endPoint, { type: "mousemove" }, win);
+ EventUtils.synthesizeMouseAtPoint(...endPoint, { type: "mouseup" }, win);
+
+ return rect;
+}
+
+function* testViewportResize(ui, selector, moveBy,
+ expectedViewportSize, expectedHandleMove) {
+ let win = ui.toolWindow;
+ let resized = waitForViewportResizeTo(ui, ...expectedViewportSize);
+ let startRect = dragElementBy(selector, ...moveBy, win);
+ yield resized;
+
+ let endRect = getElRect(selector, win);
+ is(endRect.left - startRect.left, expectedHandleMove[0],
+ `The x move of ${selector} is as expected`);
+ is(endRect.top - startRect.top, expectedHandleMove[1],
+ `The y move of ${selector} is as expected`);
+}
+
+function openDeviceModal({ toolWindow }) {
+ let { document } = toolWindow;
+ let React = toolWindow.require("devtools/client/shared/vendor/react");
+ let { Simulate } = React.addons.TestUtils;
+ let select = document.querySelector(".viewport-device-selector");
+ let modal = document.querySelector("#device-modal-wrapper");
+
+ info("Checking initial device modal state");
+ ok(modal.classList.contains("closed") && !modal.classList.contains("opened"),
+ "The device modal is closed by default.");
+
+ info("Opening device modal through device selector.");
+ select.value = OPEN_DEVICE_MODAL_VALUE;
+ Simulate.change(select);
+ ok(modal.classList.contains("opened") && !modal.classList.contains("closed"),
+ "The device modal is displayed.");
+}
+
+function changeSelectValue({ toolWindow }, selector, value) {
+ info(`Selecting ${value} in ${selector}.`);
+
+ return new Promise(resolve => {
+ let select = toolWindow.document.querySelector(selector);
+ isnot(select, null, `selector "${selector}" should match an existing element.`);
+
+ let option = [...select.options].find(o => o.value === String(value));
+ isnot(option, undefined, `value "${value}" should match an existing option.`);
+
+ let event = new toolWindow.UIEvent("change", {
+ view: toolWindow,
+ bubbles: true,
+ cancelable: true
+ });
+
+ select.addEventListener("change", () => {
+ is(select.value, value,
+ `Select's option with value "${value}" should be selected.`);
+ resolve();
+ }, { once: true });
+
+ select.value = value;
+ select.dispatchEvent(event);
+ });
+}
+
+const selectDevice = (ui, value) => Promise.all([
+ once(ui, "device-changed"),
+ changeSelectValue(ui, ".viewport-device-selector", value)
+]);
+
+const selectDPR = (ui, value) =>
+ changeSelectValue(ui, "#global-dpr-selector > select", value);
+
+const selectNetworkThrottling = (ui, value) => Promise.all([
+ once(ui, "network-throttling-changed"),
+ changeSelectValue(ui, "#global-network-throttling-selector", value)
+]);
+
+function getSessionHistory(browser) {
+ return ContentTask.spawn(browser, {}, function* () {
+ /* eslint-disable no-undef */
+ let { interfaces: Ci } = Components;
+ let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
+ let sessionHistory = webNav.sessionHistory;
+ let result = {
+ index: sessionHistory.index,
+ entries: []
+ };
+
+ for (let i = 0; i < sessionHistory.count; i++) {
+ let entry = sessionHistory.getEntryAtIndex(i, false);
+ result.entries.push({
+ uri: entry.URI.spec,
+ title: entry.title
+ });
+ }
+
+ return result;
+ /* eslint-enable no-undef */
+ });
+}
+
+function getContentSize(ui) {
+ return spawnViewportTask(ui, {}, () => ({
+ width: content.screen.width,
+ height: content.screen.height
+ }));
+}
+
+function waitForPageShow(browser) {
+ let mm = browser.messageManager;
+ return new Promise(resolve => {
+ let onShow = message => {
+ if (message.target != browser) {
+ return;
+ }
+ mm.removeMessageListener("PageVisibility:Show", onShow);
+ resolve();
+ };
+ mm.addMessageListener("PageVisibility:Show", onShow);
+ });
+}
+
+function waitForViewportLoad(ui) {
+ return new Promise(resolve => {
+ let browser = ui.getViewportBrowser();
+ browser.addEventListener("mozbrowserloadend", () => {
+ resolve();
+ }, { once: true });
+ });
+}
+
+function load(browser, url) {
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ browser.loadURI(url, null, null);
+ return loaded;
+}
+
+function back(browser) {
+ let shown = waitForPageShow(browser);
+ browser.goBack();
+ return shown;
+}
+
+function forward(browser) {
+ let shown = waitForPageShow(browser);
+ browser.goForward();
+ return shown;
+}
+
+function addDeviceForTest(device) {
+ info(`Adding Test Device "${device.name}" to the list.`);
+ addDevice(device);
+
+ registerCleanupFunction(() => {
+ // Note that assertions in cleanup functions are not displayed unless they failed.
+ ok(removeDevice(device), `Removed Test Device "${device.name}" from the list.`);
+ });
+}
+
+function waitForClientClose(ui) {
+ return new Promise(resolve => {
+ info("Waiting for RDM debugger client to close");
+ ui.client.addOneTimeListener("closed", () => {
+ info("RDM's debugger client is now closed");
+ resolve();
+ });
+ });
+}
+
+function* testTouchEventsOverride(ui, expected) {
+ let { document } = ui.toolWindow;
+ let touchButton = document.querySelector("#global-touch-simulation-button");
+
+ let flag = yield ui.emulationFront.getTouchEventsOverride();
+ is(flag === Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED, expected,
+ `Touch events override should be ${expected ? "enabled" : "disabled"}`);
+ is(touchButton.classList.contains("active"), expected,
+ `Touch simulation button should be ${expected ? "" : "in"}active.`);
+}
+
+function testViewportDeviceSelectLabel(ui, expected) {
+ info("Test viewport's device select label");
+
+ let select = ui.toolWindow.document.querySelector(".viewport-device-selector");
+ is(select.selectedOptions[0].textContent, expected,
+ `Device Select value should be: ${expected}`);
+}
+
+function* enableTouchSimulation(ui) {
+ let { document } = ui.toolWindow;
+ let touchButton = document.querySelector("#global-touch-simulation-button");
+ let loaded = waitForViewportLoad(ui);
+ touchButton.click();
+ yield loaded;
+}
diff --git a/devtools/client/responsive.html/test/browser/touch.html b/devtools/client/responsive.html/test/browser/touch.html
new file mode 100644
index 0000000000..98aeac68fd
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/touch.html
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+
+<meta charset="utf-8" />
+<meta name="viewport" />
+<title>test</title>
+
+
+<style>
+ div {
+ border :1px solid red;
+ width: 100px; height: 100px;
+ }
+</style>
+
+<div data-is-delay="false"></div>
+
+<script type="text/javascript;version=1.8">
+ "use strict";
+ let div = document.querySelector("div");
+ let initX, initY;
+ let previousEvent = "", touchendTime = 0;
+ let updatePreviousEvent = function (e) {
+ previousEvent = e.type;
+ };
+
+ div.style.transform = "none";
+ div.style.backgroundColor = "";
+
+ div.addEventListener("touchstart", function (evt) {
+ let touch = evt.changedTouches[0];
+ initX = touch.pageX;
+ initY = touch.pageY;
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("touchmove", function (evt) {
+ let touch = evt.changedTouches[0];
+ let deltaX = touch.pageX - initX;
+ let deltaY = touch.pageY - initY;
+ div.style.transform = "translate(" + deltaX + "px, " + deltaY + "px)";
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("touchend", function (evt) {
+ if (!evt.touches.length) {
+ div.style.transform = "none";
+ }
+ touchendTime = performance.now();
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("mouseenter", function (evt) {
+ div.style.backgroundColor = "red";
+ updatePreviousEvent(evt);
+ }, true);
+ div.addEventListener("mouseover", function(evt) {
+ div.style.backgroundColor = "red";
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("mouseout", function (evt) {
+ div.style.backgroundColor = "blue";
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("mouseleave", function (evt) {
+ div.style.backgroundColor = "blue";
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("mousedown", function (evt) {
+ if (previousEvent === "touchend" && touchendTime !== 0) {
+ let now = performance.now();
+ div.dataset.isDelay = ((now - touchendTime) >= 300);
+ } else {
+ div.dataset.isDelay = false;
+ }
+ updatePreviousEvent(evt);
+ }, true);
+
+ div.addEventListener("mousemove", updatePreviousEvent, true);
+
+ div.addEventListener("mouseup", updatePreviousEvent, true);
+
+ div.addEventListener("click", updatePreviousEvent, true);
+</script>
diff --git a/devtools/client/responsive.html/test/unit/.eslintrc.js b/devtools/client/responsive.html/test/unit/.eslintrc.js
new file mode 100644
index 0000000000..f879b967bd
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the shared list of defined globals for xpcshell.
+ "extends": "../../../../.eslintrc.xpcshell.js"
+};
diff --git a/devtools/client/responsive.html/test/unit/head.js b/devtools/client/responsive.html/test/unit/head.js
new file mode 100644
index 0000000000..9c8dbffc46
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/head.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+const { utils: Cu } = Components;
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+
+const promise = require("promise");
+const { Task } = require("devtools/shared/task");
+const Store = require("devtools/client/responsive.html/store");
+
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+
+const flags = require("devtools/shared/flags");
+flags.testing = true;
+do_register_cleanup(() => {
+ flags.testing = false;
+});
diff --git a/devtools/client/responsive.html/test/unit/test_add_device.js b/devtools/client/responsive.html/test/unit/test_add_device.js
new file mode 100644
index 0000000000..0a16d3cf49
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_add_device.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding a new device.
+
+const {
+ addDevice,
+ addDeviceType,
+} = require("devtools/client/responsive.html/actions/devices");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ let device = {
+ "name": "Firefox OS Flame",
+ "width": 320,
+ "height": 570,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ };
+
+ dispatch(addDeviceType("phones"));
+ dispatch(addDevice(device, "phones"));
+
+ equal(getState().devices.phones.length, 1,
+ "Correct number of phones");
+ ok(getState().devices.phones.includes(device),
+ "Device phone list contains Firefox OS Flame");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_add_device_type.js b/devtools/client/responsive.html/test/unit/test_add_device_type.js
new file mode 100644
index 0000000000..1c8c65be32
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_add_device_type.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding a new device type.
+
+const { addDeviceType } =
+ require("devtools/client/responsive.html/actions/devices");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ dispatch(addDeviceType("phones"));
+
+ equal(getState().devices.types.length, 1, "Correct number of device types");
+ equal(getState().devices.phones.length, 0,
+ "Defaults to an empty array of phones");
+ ok(getState().devices.types.includes("phones"),
+ "Device types contain phones");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_add_viewport.js b/devtools/client/responsive.html/test/unit/test_add_viewport.js
new file mode 100644
index 0000000000..b2fc3613d5
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_add_viewport.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding viewports to the page.
+
+const { addViewport } =
+ require("devtools/client/responsive.html/actions/viewports");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ equal(getState().viewports.length, 0, "Defaults to no viewpots at startup");
+
+ dispatch(addViewport());
+ equal(getState().viewports.length, 1, "One viewport total");
+
+ // For the moment, there can be at most one viewport.
+ dispatch(addViewport());
+ equal(getState().viewports.length, 1, "One viewport total, again");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_change_device.js b/devtools/client/responsive.html/test/unit/test_change_device.js
new file mode 100644
index 0000000000..0e7a6c87af
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_device.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the viewport device.
+
+const {
+ addDevice,
+ addDeviceType,
+} = require("devtools/client/responsive.html/actions/devices");
+const {
+ addViewport,
+ changeDevice,
+} = require("devtools/client/responsive.html/actions/viewports");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ dispatch(addDeviceType("phones"));
+ dispatch(addDevice({
+ "name": "Firefox OS Flame",
+ "width": 320,
+ "height": 570,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ }, "phones"));
+ dispatch(addViewport());
+
+ let viewport = getState().viewports[0];
+ equal(viewport.device, "", "Default device is unselected");
+
+ dispatch(changeDevice(0, "Firefox OS Flame"));
+
+ viewport = getState().viewports[0];
+ equal(viewport.device, "Firefox OS Flame",
+ "Changed to Firefox OS Flame device");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_change_display_pixel_ratio.js b/devtools/client/responsive.html/test/unit/test_change_display_pixel_ratio.js
new file mode 100644
index 0000000000..d8d968c2d8
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_display_pixel_ratio.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the display pixel ratio.
+
+const { changeDisplayPixelRatio } =
+ require("devtools/client/responsive.html/actions/display-pixel-ratio");
+const NEW_PIXEL_RATIO = 5.5;
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ equal(getState().displayPixelRatio, 0,
+ "Defaults to 0 at startup");
+
+ dispatch(changeDisplayPixelRatio(NEW_PIXEL_RATIO));
+ equal(getState().displayPixelRatio, NEW_PIXEL_RATIO,
+ `Display Pixel Ratio changed to ${NEW_PIXEL_RATIO}`);
+});
diff --git a/devtools/client/responsive.html/test/unit/test_change_location.js b/devtools/client/responsive.html/test/unit/test_change_location.js
new file mode 100644
index 0000000000..d45ce5c7a6
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_location.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the location of the displayed page.
+
+const { changeLocation } =
+ require("devtools/client/responsive.html/actions/location");
+
+const TEST_URL = "http://example.com";
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ equal(getState().location, "about:blank",
+ "Defaults to about:blank at startup");
+
+ dispatch(changeLocation(TEST_URL));
+ equal(getState().location, TEST_URL, "Location changed to TEST_URL");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_change_network_throttling.js b/devtools/client/responsive.html/test/unit/test_change_network_throttling.js
new file mode 100644
index 0000000000..c20ae81338
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_network_throttling.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the network throttling state
+
+const {
+ changeNetworkThrottling,
+} = require("devtools/client/responsive.html/actions/network-throttling");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ ok(!getState().networkThrottling.enabled,
+ "Network throttling is disabled by default.");
+ equal(getState().networkThrottling.profile, "",
+ "Network throttling profile is empty by default.");
+
+ dispatch(changeNetworkThrottling(true, "Bob"));
+
+ ok(getState().networkThrottling.enabled,
+ "Network throttling is enabled.");
+ equal(getState().networkThrottling.profile, "Bob",
+ "Network throttling profile is set.");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_change_pixel_ratio.js b/devtools/client/responsive.html/test/unit/test_change_pixel_ratio.js
new file mode 100644
index 0000000000..b594caef55
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_change_pixel_ratio.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test changing the viewport pixel ratio.
+
+const { addViewport, changePixelRatio } =
+ require("devtools/client/responsive.html/actions/viewports");
+const NEW_PIXEL_RATIO = 5.5;
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ dispatch(addViewport());
+ dispatch(changePixelRatio(0, NEW_PIXEL_RATIO));
+
+ let viewport = getState().viewports[0];
+ equal(viewport.pixelRatio.value, NEW_PIXEL_RATIO,
+ `Viewport's pixel ratio changed to ${NEW_PIXEL_RATIO}`);
+});
diff --git a/devtools/client/responsive.html/test/unit/test_resize_viewport.js b/devtools/client/responsive.html/test/unit/test_resize_viewport.js
new file mode 100644
index 0000000000..4b85554bf4
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_resize_viewport.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test resizing the viewport.
+
+const { addViewport, resizeViewport } =
+ require("devtools/client/responsive.html/actions/viewports");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ dispatch(addViewport());
+ dispatch(resizeViewport(0, 500, 500));
+
+ let viewport = getState().viewports[0];
+ equal(viewport.width, 500, "Resized width of 500");
+ equal(viewport.height, 500, "Resized height of 500");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_rotate_viewport.js b/devtools/client/responsive.html/test/unit/test_rotate_viewport.js
new file mode 100644
index 0000000000..541fadaa74
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_rotate_viewport.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test rotating the viewport.
+
+const { addViewport, rotateViewport } =
+ require("devtools/client/responsive.html/actions/viewports");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ dispatch(addViewport());
+
+ let viewport = getState().viewports[0];
+ equal(viewport.width, 320, "Default width of 320");
+ equal(viewport.height, 480, "Default height of 480");
+
+ dispatch(rotateViewport(0));
+ viewport = getState().viewports[0];
+ equal(viewport.width, 480, "Rotated width of 480");
+ equal(viewport.height, 320, "Rotated height of 320");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_update_device_displayed.js b/devtools/client/responsive.html/test/unit/test_update_device_displayed.js
new file mode 100644
index 0000000000..34c59bb2a5
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_update_device_displayed.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test updating the device `displayed` property
+
+const {
+ addDevice,
+ addDeviceType,
+ updateDeviceDisplayed,
+} = require("devtools/client/responsive.html/actions/devices");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ let device = {
+ "name": "Firefox OS Flame",
+ "width": 320,
+ "height": 570,
+ "pixelRatio": 1.5,
+ "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+ "touch": true,
+ "firefoxOS": true,
+ "os": "fxos"
+ };
+
+ dispatch(addDeviceType("phones"));
+ dispatch(addDevice(device, "phones"));
+ dispatch(updateDeviceDisplayed(device, "phones", true));
+
+ equal(getState().devices.phones.length, 1,
+ "Correct number of phones");
+ ok(getState().devices.phones[0].displayed,
+ "Device phone list contains enabled Firefox OS Flame");
+});
diff --git a/devtools/client/responsive.html/test/unit/test_update_touch_simulation_enabled.js b/devtools/client/responsive.html/test/unit/test_update_touch_simulation_enabled.js
new file mode 100644
index 0000000000..f8ba2a4b6d
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/test_update_touch_simulation_enabled.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test updating the touch simulation `enabled` property
+
+const {
+ changeTouchSimulation,
+} = require("devtools/client/responsive.html/actions/touch-simulation");
+
+add_task(function* () {
+ let store = Store();
+ const { getState, dispatch } = store;
+
+ ok(!getState().touchSimulation.enabled,
+ "Touch simulation is disabled by default.");
+
+ dispatch(changeTouchSimulation(true));
+
+ ok(getState().touchSimulation.enabled,
+ "Touch simulation is enabled.");
+});
diff --git a/devtools/client/responsive.html/test/unit/xpcshell.ini b/devtools/client/responsive.html/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..06b5e49946
--- /dev/null
+++ b/devtools/client/responsive.html/test/unit/xpcshell.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+tags = devtools
+head = head.js ../../../framework/test/shared-redux-head.js
+tail =
+firefox-appdir = browser
+
+[test_add_device.js]
+[test_add_device_type.js]
+[test_add_viewport.js]
+[test_change_device.js]
+[test_change_display_pixel_ratio.js]
+[test_change_location.js]
+[test_change_network_throttling.js]
+[test_change_pixel_ratio.js]
+[test_resize_viewport.js]
+[test_rotate_viewport.js]
+[test_update_device_displayed.js]
+[test_update_touch_simulation_enabled.js]
diff --git a/devtools/client/responsive.html/types.js b/devtools/client/responsive.html/types.js
new file mode 100644
index 0000000000..2f03cdf65b
--- /dev/null
+++ b/devtools/client/responsive.html/types.js
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { PropTypes } = require("devtools/client/shared/vendor/react");
+const { createEnum } = require("./utils/enum");
+
+// React PropTypes are used to describe the expected "shape" of various common
+// objects that get passed down as props to components.
+
+/* GLOBAL */
+
+/**
+ * The location of the document displayed in the viewport(s).
+ */
+exports.location = PropTypes.string;
+
+/* DEVICE */
+
+/**
+ * A single device that can be displayed in the viewport.
+ */
+const device = {
+
+ // The name of the device
+ name: PropTypes.string,
+
+ // The width of the device
+ width: PropTypes.number,
+
+ // The height of the device
+ height: PropTypes.number,
+
+ // The pixel ratio of the device
+ pixelRatio: PropTypes.number,
+
+ // The user agent string of the device
+ userAgent: PropTypes.string,
+
+ // Whether or not it is a touch device
+ touch: PropTypes.bool,
+
+ // The operating system of the device
+ os: PropTypes.String,
+
+ // Whether or not the device is displayed in the device selector
+ displayed: PropTypes.bool,
+
+};
+
+/**
+ * An enum containing the possible values for the device list state
+ */
+exports.deviceListState = createEnum([
+ "INITIALIZED",
+ "LOADING",
+ "LOADED",
+ "ERROR",
+]);
+
+/**
+ * A list of devices and their types that can be displayed in the viewport.
+ */
+exports.devices = {
+
+ // An array of device types
+ types: PropTypes.arrayOf(PropTypes.string),
+
+ // An array of phone devices
+ phones: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // An array of tablet devices
+ tablets: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // An array of laptop devices
+ laptops: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // An array of television devices
+ televisions: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // An array of console devices
+ consoles: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // An array of watch devices
+ watches: PropTypes.arrayOf(PropTypes.shape(device)),
+
+ // Whether or not the device modal is open
+ isModalOpen: PropTypes.bool,
+
+ // Device list state, possible values are exported above in an enum
+ listState: PropTypes.oneOf(Object.keys(exports.deviceListState)),
+
+};
+
+/* VIEWPORT */
+
+/**
+ * Network throttling state for a given viewport.
+ */
+exports.networkThrottling = {
+
+ // Whether or not network throttling is enabled
+ enabled: PropTypes.bool,
+
+ // Name of the selected throttling profile
+ profile: PropTypes.string,
+
+};
+
+/**
+ * Device pixel ratio for a given viewport.
+ */
+const pixelRatio = exports.pixelRatio = {
+
+ // The device pixel ratio value
+ value: PropTypes.number,
+
+};
+
+/**
+ * Touch simulation state for a given viewport.
+ */
+exports.touchSimulation = {
+
+ // Whether or not touch simulation is enabled
+ enabled: PropTypes.bool,
+
+};
+
+/**
+ * A single viewport displaying a document.
+ */
+exports.viewport = {
+
+ // The id of the viewport
+ id: PropTypes.number,
+
+ // The currently selected device applied to the viewport
+ device: PropTypes.string,
+
+ // The width of the viewport
+ width: PropTypes.number,
+
+ // The height of the viewport
+ height: PropTypes.number,
+
+ // The devicePixelRatio of the viewport
+ pixelRatio: PropTypes.shape(pixelRatio),
+
+};
+
+/* ACTIONS IN PROGRESS */
+
+/**
+ * The progression of the screenshot.
+ */
+exports.screenshot = {
+
+ // Whether screenshot capturing is in progress
+ isCapturing: PropTypes.bool,
+
+};
diff --git a/devtools/client/responsive.html/utils/e10s.js b/devtools/client/responsive.html/utils/e10s.js
new file mode 100644
index 0000000000..f45add6b03
--- /dev/null
+++ b/devtools/client/responsive.html/utils/e10s.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 { defer } = require("promise");
+
+// The prefix used for RDM messages in content.
+// see: devtools/client/responsivedesign/responsivedesign-child.js
+const MESSAGE_PREFIX = "ResponsiveMode:";
+const REQUEST_DONE_SUFFIX = ":Done";
+
+/**
+ * Registers a message `listener` that is called every time messages of
+ * specified `message` is emitted on the given message manager.
+ * @param {nsIMessageListenerManager} mm
+ * The Message Manager
+ * @param {String} message
+ * The message. It will be prefixed with the constant `MESSAGE_PREFIX`
+ * @param {Function} listener
+ * The listener function that processes the message.
+ */
+function on(mm, message, listener) {
+ mm.addMessageListener(MESSAGE_PREFIX + message, listener);
+}
+exports.on = on;
+
+/**
+ * Removes a message `listener` for the specified `message` on the given
+ * message manager.
+ * @param {nsIMessageListenerManager} mm
+ * The Message Manager
+ * @param {String} message
+ * The message. It will be prefixed with the constant `MESSAGE_PREFIX`
+ * @param {Function} listener
+ * The listener function that processes the message.
+ */
+function off(mm, message, listener) {
+ mm.removeMessageListener(MESSAGE_PREFIX + message, listener);
+}
+exports.off = off;
+
+/**
+ * Resolves a promise the next time the specified `message` is sent over the
+ * given message manager.
+ * @param {nsIMessageListenerManager} mm
+ * The Message Manager
+ * @param {String} message
+ * The message. It will be prefixed with the constant `MESSAGE_PREFIX`
+ * @returns {Promise}
+ * A promise that is resolved when the given message is emitted.
+ */
+function once(mm, message) {
+ let { resolve, promise } = defer();
+
+ on(mm, message, function onMessage({data}) {
+ off(mm, message, onMessage);
+ resolve(data);
+ });
+
+ return promise;
+}
+exports.once = once;
+
+/**
+ * Asynchronously emit a `message` to the listeners of the given message
+ * manager.
+ *
+ * @param {nsIMessageListenerManager} mm
+ * The Message Manager
+ * @param {String} message
+ * The message. It will be prefixed with the constant `MESSAGE_PREFIX`.
+ * @param {Object} data
+ * A JSON object containing data to be delivered to the listeners.
+ */
+function emit(mm, message, data) {
+ mm.sendAsyncMessage(MESSAGE_PREFIX + message, data);
+}
+exports.emit = emit;
+
+/**
+ * Asynchronously send a "request" over the given message manager, and returns
+ * a promise that is resolved when the request is complete.
+ *
+ * @param {nsIMessageListenerManager} mm
+ * The Message Manager
+ * @param {String} message
+ * The message. It will be prefixed with the constant `MESSAGE_PREFIX`, and
+ * also suffixed with `REQUEST_DONE_SUFFIX` for the reply.
+ * @param {Object} data
+ * A JSON object containing data to be delivered to the listeners.
+ * @returns {Promise}
+ * A promise that is resolved when the request is done.
+ */
+function request(mm, message, data) {
+ let done = once(mm, message + REQUEST_DONE_SUFFIX);
+
+ emit(mm, message, data);
+
+ return done;
+}
+exports.request = request;
diff --git a/devtools/client/responsive.html/utils/enum.js b/devtools/client/responsive.html/utils/enum.js
new file mode 100644
index 0000000000..cab8ff1cee
--- /dev/null
+++ b/devtools/client/responsive.html/utils/enum.js
@@ -0,0 +1,21 @@
+/* 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";
+
+module.exports = {
+
+ /**
+ * Create a simple enum-like object with keys mirrored to values from an array.
+ * This makes comparison to a specfic value simpler without having to repeat and
+ * mis-type the value.
+ */
+ createEnum(array, target = {}) {
+ for (let key of array) {
+ target[key] = key;
+ }
+ return target;
+ }
+
+};
diff --git a/devtools/client/responsive.html/utils/l10n.js b/devtools/client/responsive.html/utils/l10n.js
new file mode 100644
index 0000000000..515182462e
--- /dev/null
+++ b/devtools/client/responsive.html/utils/l10n.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const STRINGS_URI = "devtools/client/locales/responsive.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+module.exports = {
+ getStr: (...args) => L10N.getStr(...args),
+ getFormatStr: (...args) => L10N.getFormatStr(...args),
+ getFormatStrWithNumbers: (...args) => L10N.getFormatStrWithNumbers(...args),
+ numberWithDecimals: (...args) => L10N.numberWithDecimals(...args),
+};
diff --git a/devtools/client/responsive.html/utils/message.js b/devtools/client/responsive.html/utils/message.js
new file mode 100644
index 0000000000..d5c5b012f8
--- /dev/null
+++ b/devtools/client/responsive.html/utils/message.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 promise = require("promise");
+
+const REQUEST_DONE_SUFFIX = ":done";
+
+function wait(win, type) {
+ let deferred = promise.defer();
+
+ let onMessage = event => {
+ if (event.data.type !== type) {
+ return;
+ }
+ win.removeEventListener("message", onMessage);
+ deferred.resolve();
+ };
+ win.addEventListener("message", onMessage);
+
+ return deferred.promise;
+}
+
+function post(win, type) {
+ win.postMessage({ type }, "*");
+}
+
+function request(win, type) {
+ let done = wait(win, type + REQUEST_DONE_SUFFIX);
+ post(win, type);
+ return done;
+}
+
+exports.wait = wait;
+exports.post = post;
+exports.request = request;
diff --git a/devtools/client/responsive.html/utils/moz.build b/devtools/client/responsive.html/utils/moz.build
new file mode 100644
index 0000000000..a716eae0cb
--- /dev/null
+++ b/devtools/client/responsive.html/utils/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(
+ 'e10s.js',
+ 'enum.js',
+ 'l10n.js',
+ 'message.js',
+)