diff options
Diffstat (limited to '')
10 files changed, 1287 insertions, 0 deletions
diff --git a/devtools/shared/commands/target-configuration/moz.build b/devtools/shared/commands/target-configuration/moz.build new file mode 100644 index 0000000000..5d497983f0 --- /dev/null +++ b/devtools/shared/commands/target-configuration/moz.build @@ -0,0 +1,10 @@ +# 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( + "target-configuration-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"] diff --git a/devtools/shared/commands/target-configuration/target-configuration-command.js b/devtools/shared/commands/target-configuration/target-configuration-command.js new file mode 100644 index 0000000000..24cde0acba --- /dev/null +++ b/devtools/shared/commands/target-configuration/target-configuration-command.js @@ -0,0 +1,125 @@ +/* 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 TargetConfigurationCommand should be used to populate the DevTools server + * with settings read from the client side but which impact the server. + * For instance, "disable cache" is a feature toggled via DevTools UI (client), + * but which should be communicated to the targets (server). + * + * See the TargetConfigurationActor for a list of supported configuration options. + */ +class TargetConfigurationCommand { + constructor({ commands, watcherFront }) { + this._commands = commands; + this._watcherFront = watcherFront; + } + + /** + * Return a promise that resolves to the related target configuration actor's front. + * + * @return {Promise<TargetConfigurationFront>} + */ + async getFront() { + const front = await this._watcherFront.getTargetConfigurationActor(); + + if (!this._configuration) { + // Retrieve initial data from the front + this._configuration = front.initialConfiguration; + } + + return front; + } + + _hasTargetWatcherSupport() { + return this._commands.targetCommand.hasTargetWatcherSupport(); + } + + /** + * Retrieve the current map of configuration options pushed to the server. + */ + get configuration() { + return this._configuration || {}; + } + + async updateConfiguration(configuration) { + if (this._hasTargetWatcherSupport()) { + const front = await this.getFront(); + const updatedConfiguration = await front.updateConfiguration( + configuration + ); + // Update the client-side copy of the DevTools configuration + this._configuration = updatedConfiguration; + } else { + await this._commands.targetCommand.targetFront.reconfigure({ + options: configuration, + }); + } + } + + async isJavascriptEnabled() { + // If we don't have target watcher support, we can't get this value, so just + // fall back to true. Only content tab targets can update javascriptEnabled + // and all should have watcher support. + if (!this._hasTargetWatcherSupport()) { + return true; + } + + const front = await this.getFront(); + return front.isJavascriptEnabled(); + } + + /** + * Reports if the given configuration key is supported by the server. + * If the debugged context doesn't support the watcher actor, + * we won't be using the target configuration actor and report all keys + * as not supported. + * + * @param {Object} configurationKey + * Name of the configuration you would like to set. + * @return {Promise<Boolean>} True, if this configuration can be set via this API. + */ + async supports(configurationKey) { + if (!this._hasTargetWatcherSupport()) { + return false; + } + const front = await this.getFront(); + return !!front.traits.supportedOptions[configurationKey]; + } + + /** + * Change orientation type and angle (that can be accessed through screen.orientation in + * the content page) and simulates the "orientationchange" event when the device screen + * was rotated. + * Note that this will only be effective if the Responsive Design Mode is enabled. + * + * @param {Object} options + * @param {String} options.type: The orientation type of the rotated device. + * @param {Number} options.angle: The rotated angle of the device. + * @param {Boolean} options.isViewportRotated: Whether or not screen orientation change + * is a result of rotating the viewport. If true, an "orientationchange" + * event will be dispatched in the content window. + */ + async simulateScreenOrientationChange({ type, angle, isViewportRotated }) { + // We need to call the method on the parent process + await this.updateConfiguration({ + rdmPaneOrientation: { type, angle }, + }); + + // Don't dispatch the "orientationchange" event if orientation change is a result + // of switching to a new device, location change, or opening RDM. + if (!isViewportRotated) { + return; + } + + const responsiveFront = await this._commands.targetCommand.targetFront.getFront( + "responsive" + ); + await responsiveFront.dispatchOrientationChangeEvent(); + } +} + +module.exports = TargetConfigurationCommand; diff --git a/devtools/shared/commands/target-configuration/tests/browser.ini b/devtools/shared/commands/target-configuration/tests/browser.ini new file mode 100644 index 0000000000..fd30b43d33 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser.ini @@ -0,0 +1,13 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + !/devtools/client/shared/test/shared-head.js + target_configuration_test_doc.sjs + head.js + +[browser_target_configuration_command_color_scheme.js] +[browser_target_configuration_command_custom_user_agent.js] +[browser_target_configuration_command_dppx.js] +[browser_target_configuration_command_touch_events.js] +[browser_target_configuration_command.js] diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js new file mode 100644 index 0000000000..60ee602d77 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the watcher's target-configuration actor API. + +add_task(async function() { + info("Setup the test page with workers of all types"); + const tab = await addTab("data:text/html;charset=utf-8,Configuration actor"); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + compareOptions( + targetConfigurationCommand.configuration, + {}, + "Initial configuration is empty" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: true, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true }, + "Option cacheDisabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true, javascriptEnabled: false }, + "Option javascriptEnabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: false, javascriptEnabled: false }, + "Option cacheDisabled was updated" + ); + + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + compareOptions( + targetConfigurationCommand.configuration, + { + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + }, + "Option colorSchemeSimulation was set, with a string value" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); + +function compareOptions(options, expected, message) { + is( + Object.keys(options).length, + Object.keys(expected).length, + message + " (wrong number of options)" + ); + + for (const key of Object.keys(expected)) { + is(options[key], expected[key], message + ` (wrong value for ${key})`); + } +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js new file mode 100644 index 0000000000..f9866c0909 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function() { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URI); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the content page when it loaded" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the content page by default" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the remote iframe when it loaded" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the remote iframe by default" + ); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Update configuration to enable dark mode simulation"); + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled after updating the configuration" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after reloading" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.loadURI( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after navigating to a new browsing context" + ); + + targetCommand.destroy(); + await commands.destroy(); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the content page after destroying the commands" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the remote iframe after destroying the commands" + ); +}); + +function matchPrefersDarkColorSchemeMedia(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +function matchPrefersDarkColorSchemeMediaAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesPrefersDarkColorScheme + ); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMedia() { + return matchPrefersDarkColorSchemeMedia(gBrowser.selectedBrowser); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + return matchPrefersDarkColorSchemeMediaAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function() { + // Ensure we've rendered the iframe so that the prefers-color-scheme + // value propagated from the embedder is up-to-date. + await new Promise(resolve => { + content.requestAnimationFrame(() => + content.requestAnimationFrame(resolve) + ); + }); + return content.document.querySelector("iframe").browsingContext; + }); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMedia() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMedia(iframeBC); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMediaAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js new file mode 100644 index 0000000000..891fd1a1f6 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js @@ -0,0 +1,309 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test setting custom user agent. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const initialUserAgent = await getTopLevelUserAgent(); + + info("Update configuration to change user agent"); + const CUSTOM_USER_AGENT = "<MY_BORING_CUSTOM_USER_AGENT>"; + + await targetConfigurationCommand.updateConfiguration({ + customUserAgent: CUSTOM_USER_AGENT, + }); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the top level document after updating the configuration" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the top level document" + ); + + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the iframe after updating the configuration" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the iframe" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after reloading" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after reloading" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after reloading" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.loadURI( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after navigating to a new browsing context" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframes after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the user agent" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + // wait for the target to be fully attached to avoid pending connection to the server + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we set the user agent in will reset the user agent" + ); + targetCommand.destroy(); + await commands.destroy(); + + // XXX: This is needed at the moment since Navigator.cpp retrieve the UserAgent from the + // headers (when there's no custom user agent). And here, since we reloaded the page once + // we set the custom user agent, the header was set accordingly and still holds the custom + // user agent value. This should be fixed by Bug 1705326. + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying the first commands instance. Bug 1705326 will fix that and make it equal to `initialUserAgent`" + ); + + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + is( + await getTopLevelUserAgent(), + initialUserAgent, + "The user agent was reset in the content page after destroying the commands" + ); + is( + await getIframeUserAgent(), + initialUserAgent, + "The user agent was reset in the remote iframe after destroying the commands" + ); + + // We need commands to retrieve the headers of the network request, and + // all those we created so far were destroyed; let's create new ones. + const newCommands = await CommandsFactory.forTab(tab); + await newCommands.targetCommand.startListening(); + is( + await getUserAgentForTopLevelRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources after destroying the commands" + ); + is( + await getUserAgentForIframeRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources on the remote iframe after destroying the commands" + ); +}); + +function getUserAgent(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.navigator.userAgent; + }); +} + +function getUserAgentAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialUserAgent + ); +} + +function getTopLevelUserAgent() { + return getUserAgent(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentUserAgentAtStartup() { + return getUserAgentAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeUserAgent() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgent(iframeBC); +} + +async function getIframeUserAgentAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgentAtStartup(iframeBC); +} + +async function getRequestUserAgent(commands, browserOrBrowsingContext) { + const url = `unknown?${Date.now()}`; + + // Wait for the resource and its headers to be available + const onAvailable = () => {}; + let onUpdated; + + const onResource = new Promise(resolve => { + onUpdated = updates => { + for (const { resource } of updates) { + if (resource.url.includes(url) && resource.requestHeadersAvailable) { + resolve(resource); + } + } + }; + + commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + }); + + info(`Fetch ${url}`); + SpecialPowers.spawn(browserOrBrowsingContext, [url], innerUrl => { + content.fetch(`./${innerUrl}`); + }); + info("waiting for matching resource…"); + const networkResource = await onResource; + + info("…got resource, retrieve headers"); + const packet = { + to: networkResource.actor, + type: "getRequestHeaders", + }; + + const { headers } = await commands.client.request(packet); + + commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + + return headers.find(header => header.name == "User-Agent")?.value; +} + +async function getUserAgentForTopLevelRequest(commands) { + return getRequestUserAgent(commands, gBrowser.selectedBrowser); +} + +async function getUserAgentForIframeRequest(commands) { + const iframeBC = await getIframeBrowsingContext(); + return getRequestUserAgent(commands, iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js new file mode 100644 index 0000000000..423bd85e27 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test device pixel ratio override. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const originalDpr = await getTopLevelDocumentDevicePixelRatio(); + + info("Update configuration to change device pixel ratio"); + const CUSTOM_DPR = 5.5; + + await targetConfigurationCommand.updateConfiguration({ + overrideDPPX: CUSTOM_DPR, + }); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the top level document after updating the configuration" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after reloading" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.loadURI( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the ratio" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we overrode the ratio in will reset the page ratio" + ); + targetCommand.destroy(); + await commands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the content page after destroying the commands" + ); + is( + await getIframeDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the remote iframe after destroying the commands" + ); +}); + +function getDevicePixelRatio(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.browsingContext.top.overrideDPPX || content.devicePixelRatio + ); +} + +function getDevicePixelRatioAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialDevicePixelRatio + ); +} + +function getTopLevelDocumentDevicePixelRatio() { + return getDevicePixelRatio(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentDevicePixelRatioAtStartup() { + return getDevicePixelRatioAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeDocumentDevicePixelRatio() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatio(iframeBC); +} + +async function getIframeDocumentDevicePixelRatioAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatioAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js new file mode 100644 index 0000000000..ba8981ece6 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js @@ -0,0 +1,267 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test touch event simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function() { + // Disable click hold and double tap zooming as it might interfere with the test + await pushPref("ui.click_hold_context_menus", false); + await pushPref("apz.allow_double_tap_zooming", false); + // We turn server-side target switching on so touch simulation is enabled when navigating + // to a different origin (See Bug 1704029). + await pushPref("devtools.target-switching.server.enabled", true); + + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Touch simulation is disabled at the beginning"); + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); + + info("Enable touch simulation"); + await targetConfigurationCommand.updateConfiguration({ + touchEventsOverride: "enabled", + }); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after reloading" + ); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after reloading" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Create another commands instance and check that destroying it won't reset the touch simulation" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + + await otherTargetCommand.startListening(); + // Watch targets so we wait for server communication to settle (e.g. attach calls), as + // this could cause intermittent failures. + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + BrowserTestUtils.loadURI( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onBrowserLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + await checkTopLevelDocumentTouchSimulation({ + enabled: true, + }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after navigating to a new browsing context" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Check that destroying the commands we enabled the simulation in will disable the simulation" + ); + targetCommand.destroy(); + await commands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); +}); + +function matchesCoarsePointer(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(pointer: coarse)").matches + ); +} + +function matchesCoarsePointerAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesCoarsePointer + ); +} + +async function isTouchEventEmitted(browserOrBrowsingContext) { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const onTouchEvent = SpecialPowers.spawn( + browserOrBrowsingContext, + [], + async () => { + content.touchStartController = new content.AbortController(); + const el = content.document.querySelector("button"); + + let gotTouchEndEvent = false; + + const promise = new Promise(resolve => { + el.addEventListener( + "touchend", + () => { + gotTouchEndEvent = true; + resolve(); + }, + { + signal: content.touchStartController.signal, + once: true, + } + ); + }); + + // For some reason, it might happen that the event is properly registered and transformed + // in the touch simulator, but not received by the event listener we set up just before. + // So here let's try to "tap" 3 times to give us more chance to catch the event. + for (let i = 0; i < 3; i++) { + if (gotTouchEndEvent) { + break; + } + + // Simulate a "tap" with mousedown and then mouseup. + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mousedown", isSynthesized: false }, + content + ); + + await new Promise(res => content.setTimeout(res, 10)); + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mouseup", isSynthesized: false }, + content + ); + await new Promise(res => content.setTimeout(res, 50)); + } + + return promise; + } + ); + + const result = await Promise.race([onTimeout, onTouchEvent]); + + // Remove the event listener + await SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + content.touchStartController.abort(); + delete content.touchStartController; + }); + + return result !== "TIMEOUT"; +} + +async function checkTopLevelDocumentTouchSimulation({ enabled }) { + is( + await matchesCoarsePointer(gBrowser.selectedBrowser), + enabled, + `The touch simulation is ${ + enabled ? "enabled" : "disabled" + } on the top level document` + ); + + is( + await isTouchEventEmitted(gBrowser.selectedBrowser), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the top level document` + ); +} + +function topLevelDocumentMatchesCoarsePointerAtStartup() { + return matchesCoarsePointerAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function checkIframeTouchSimulation({ enabled }) { + const iframeBC = await getIframeBrowsingContext(); + is( + await matchesCoarsePointer(iframeBC), + enabled, + `The touch simulation is ${enabled ? "enabled" : "disabled"} on the iframe` + ); + + is( + await isTouchEventEmitted(iframeBC), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the iframe` + ); +} + +async function iframeMatchesCoarsePointerAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchesCoarsePointerAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/head.js b/devtools/shared/commands/target-configuration/tests/head.js new file mode 100644 index 0000000000..227e8ae9d9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/head.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"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ../../../../client/shared/test/shared-head.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs new file mode 100644 index 0000000000..a10b67c2b9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs @@ -0,0 +1,101 @@ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "html", false); + + // Check the params and set the cross-origin-opener policy headers if needed + Cu.importGlobalProperties(["URLSearchParams"]); + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + + const IFRAME_HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <script> + globalThis.initialMatchesPrefersDarkColorScheme = + window.matchMedia("(prefers-color-scheme: dark)").matches; + globalThis.initialMatchesCoarsePointer = + window.matchMedia("(pointer: coarse)").matches; + globalThis.initialDevicePixelRatio = window.devicePixelRatio; + globalThis.initialUserAgent = navigator.userAgent; + </script> + <style> + html { background: cyan;} + + button { + font-size: 2em; + padding-inline: 1em; + } + + @media (prefers-color-scheme: dark) { + html {background: darkred;} + } + + </style> + </head> + <body> + <h1>Iframe</h1> + <button>Target</button> + </body> + </html>`; + + const HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <title>test</title> + <script type="application/javascript"> + "use strict"; + + /* + * Store the result of dark color-scheme match very early in the document loading process + * so we can assert in tests that the simulation starts early enough. + */ + globalThis.initialMatchesPrefersDarkColorScheme = + window.matchMedia("(prefers-color-scheme: dark)").matches; + globalThis.initialMatchesCoarsePointer = + window.matchMedia("(pointer: coarse)").matches; + globalThis.initialDevicePixelRatio = window.devicePixelRatio + globalThis.initialUserAgent = navigator.userAgent; + + + </script> + <style> + iframe { + display: block; + margin-top: 1em; + } + + button { + font-size: 2em; + padding-inline: 1em; + } + + @media (prefers-color-scheme: dark) { + html { + background-color: darkblue; + } + } + </style> + </head> + <body> + <h1>Test color-scheme simulation</h1> + <button>Target</button> + <iframe src='${iframeOrigin}/document-builder.sjs?html=${encodeURI( + IFRAME_HTML + )}'></iframe> + </body> + </html>`; + + response.write(HTML); +} |