diff options
Diffstat (limited to 'devtools/client/responsive.html')
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 000000000..b06134450 --- /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 000000000..ff3343bb5 --- /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 000000000..06cc8d1a5 --- /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 000000000..565825e5e --- /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 000000000..8f44c7118 --- /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 000000000..e92fb995c --- /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 000000000..8d660d74f --- /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 000000000..8f98101e7 --- /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 000000000..7e51ada4a --- /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 000000000..739d32b0e --- /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 000000000..f99bbc443 --- /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 000000000..7ab028065 --- /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 000000000..fdbfe8918 --- /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 000000000..4519df0bd --- /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 000000000..f2902905b --- /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, "&"); + + 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 000000000..d28b97472 --- /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 000000000..3215ce5fb --- /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 000000000..31b8db1c2 --- /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 000000000..6c31fa338 --- /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 000000000..4ad36f992 --- /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 000000000..fa9f5c6a0 --- /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 000000000..1d94cd052 --- /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 000000000..a359cecf7 --- /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 000000000..7cbc73f67 --- /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 000000000..fe41b41ee --- /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 000000000..b305d1e07 --- /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 000000000..b848515ea --- /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 000000000..75055ad4e --- /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 000000000..9a491fcae --- /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 000000000..91db83af9 --- /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 000000000..bbce6d6c2 --- /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 000000000..494e47e90 --- /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 000000000..306d40f93 --- /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 000000000..c9165a206 --- /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 000000000..18aa3c66d --- /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 000000000..c88f95777 --- /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 000000000..7e8f8aeac --- /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 000000000..72fe2f0f7 --- /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 000000000..a3fbed366 --- /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 000000000..79fbf3ae4 --- /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 000000000..f36cd509a --- /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 000000000..e78632b24 --- /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 000000000..3f127c206 --- /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 000000000..2063c9776 --- /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 000000000..f1e9668f0 --- /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 000000000..f892553c1 --- /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 000000000..9d24d8c5b --- /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 000000000..b3203b644 --- /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 000000000..ee130ceaf --- /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 000000000..6d442b1bb --- /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 000000000..0e32819b3 --- /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 000000000..698ae9181 --- /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 000000000..71cf6d9b6 --- /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 000000000..b88f73522 --- /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 000000000..d9308eb6c --- /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 000000000..30d057ebe --- /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 000000000..90f364ce7 --- /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 000000000..9489d8f0b --- /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 000000000..4c70087bf --- /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 000000000..62e652274 --- /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 000000000..81449a340 --- /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 000000000..8e1c1c4cd --- /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 000000000..166ecb8ae --- /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 000000000..98ccdab69 --- /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 000000000..2c9f0027f --- /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 000000000..18c4a90ed --- /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 000000000..306900535 --- /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 000000000..68b594509 --- /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 000000000..7e96e866c --- /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 000000000..60605c33b --- /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 000000000..1c5ed7c91 --- /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 000000000..7ce32ff28 --- /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 000000000..b0b51aa42 --- /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 000000000..7cf012c44 --- /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 000000000..8f7afaf01 --- /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 000000000..aea6de2c4 --- /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 000000000..12a718306 --- /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 000000000..86fc41da9 --- /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 000000000..29d9d1e34 --- /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 000000000..c3f2bb363 --- /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 000000000..fb4d2acf0 --- /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 000000000..03d105a19 --- /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 000000000..3be69b0af --- /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 000000000..98aeac68f --- /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 000000000..f879b967b --- /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 000000000..9c8dbffc4 --- /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 000000000..0a16d3cf4 --- /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 000000000..1c8c65be3 --- /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 000000000..b2fc3613d --- /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 000000000..0e7a6c87a --- /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 000000000..d8d968c2d --- /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 000000000..d45ce5c7a --- /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 000000000..c20ae8133 --- /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 000000000..b594caef5 --- /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 000000000..4b85554bf --- /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 000000000..541fadaa7 --- /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 000000000..34c59bb2a --- /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 000000000..f8ba2a4b6 --- /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 000000000..06b5e4994 --- /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 000000000..2f03cdf65 --- /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 000000000..f45add6b0 --- /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 000000000..cab8ff1ce --- /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 000000000..515182462 --- /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 000000000..d5c5b012f --- /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 000000000..a716eae0c --- /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', +) |