diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /testing/specialpowers | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/specialpowers')
17 files changed, 5509 insertions, 0 deletions
diff --git a/testing/specialpowers/Makefile.in b/testing/specialpowers/Makefile.in new file mode 100644 index 0000000000..e42d0650ef --- /dev/null +++ b/testing/specialpowers/Makefile.in @@ -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/. + +XPI_PKGNAME = specialpowers@mozilla.org diff --git a/testing/specialpowers/api.js b/testing/specialpowers/api.js new file mode 100644 index 0000000000..a0ffe42b32 --- /dev/null +++ b/testing/specialpowers/api.js @@ -0,0 +1,68 @@ +/* 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/. */ + +/* globals ExtensionAPI, Services, XPCOMUtils */ + +ChromeUtils.defineESModuleGetters(this, { + SpecialPowersParent: "resource://testing-common/SpecialPowersParent.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler" +); + +this.specialpowers = class extends ExtensionAPI { + onStartup() { + // Register special testing modules. + let manifest = Services.dirsvc.get("ProfD", Ci.nsIFile); + manifest.append("tests.manifest"); + Components.manager + .QueryInterface(Ci.nsIComponentRegistrar) + .autoRegister(manifest); + + { + let uri = Services.io.newURI("content/", null, this.extension.rootURI); + resProto.setSubstitutionWithFlags( + "specialpowers", + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + if (!resProto.hasSubstitution("testing-common")) { + let uri = Services.io.newURI("modules/", null, this.extension.rootURI); + resProto.setSubstitution( + "testing-common", + uri, + resProto.ALLOW_CONTENT_ACCESS + ); + } + + SpecialPowersParent.registerActor(); + + ChromeUtils.registerWindowActor("AppTestDelegate", { + parent: { + esModuleURI: "resource://specialpowers/AppTestDelegateParent.sys.mjs", + }, + child: { + esModuleURI: "resource://specialpowers/AppTestDelegateChild.sys.mjs", + events: { + DOMContentLoaded: { capture: true }, + load: { capture: true }, + }, + }, + allFrames: true, + includeChrome: true, + }); + } + + onShutdown() { + SpecialPowersParent.unregisterActor(); + ChromeUtils.unregisterWindowActor("AppTestDelegate"); + resProto.setSubstitution("specialpowers", null); + } +}; diff --git a/testing/specialpowers/content/AppTestDelegate.sys.mjs b/testing/specialpowers/content/AppTestDelegate.sys.mjs new file mode 100644 index 0000000000..e2c2b60daf --- /dev/null +++ b/testing/specialpowers/content/AppTestDelegate.sys.mjs @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file offers the test helpers that are directly exposed to mochitests. + * Their implementations are in app-specific "AppUiTestDelegate.sys.mjs" files. + * + * For documentation, see AppTestDelegateParent.sys.mjs. + * For documentation on the methods of AppUiTestDelegate, see below. + */ + +class AppTestDelegateImplementation { + actor(window) { + return window.windowGlobalChild.getActor("AppTestDelegate"); + } + + /** + * Click on the pageAction button, to open its popup, or to trigger + * pageAction.onClicked if there is no popup. + */ + clickPageAction(window, extension) { + return this.actor(window).sendQuery("clickPageAction", { + extensionId: extension.id, + }); + } + + /** + * Click on the browserAction button, to open its popup, or to trigger + * browserAction.onClicked if there is no popup. + */ + clickBrowserAction(window, extension) { + return this.actor(window).sendQuery("clickBrowserAction", { + extensionId: extension.id, + }); + } + + /** + * Close the browserAction popup, if any. + * Do not use this for pageAction popups, use closePageAction instead. + */ + closeBrowserAction(window, extension) { + return this.actor(window).sendQuery("closeBrowserAction", { + extensionId: extension.id, + }); + } + + /** + * Close the pageAction popup, if any. + * Do not use this for browserAction popups, use closeBrowserAction instead. + */ + closePageAction(window, extension) { + return this.actor(window).sendQuery("closePageAction", { + extensionId: extension.id, + }); + } + + /** + * Wait for the pageAction or browserAction/action popup panel to open. + * This must be called BEFORE any attempt to open the popup. + */ + awaitExtensionPanel(window, extension) { + return this.actor(window).sendQuery("awaitExtensionPanel", { + extensionId: extension.id, + }); + } + + /** + * Open a tab with the given url in the given window. + * Returns an opaque object that can be passed to AppTestDelegate.removeTab. + */ + async openNewForegroundTab(window, url, waitForLoad = true) { + const tabId = await this.actor(window).sendQuery("openNewForegroundTab", { + url, + waitForLoad, + }); + // Note: this id is only meaningful to AppTestDelegate and independent of + // any other concept of "tab id". + return { id: tabId }; + } + + /** + * Close a tab opened by AppTestDelegate.openNewForegroundTab. + */ + removeTab(window, tab) { + return this.actor(window).sendQuery("removeTab", { tabId: tab.id }); + } +} + +export var AppTestDelegate = new AppTestDelegateImplementation(); diff --git a/testing/specialpowers/content/AppTestDelegateChild.sys.mjs b/testing/specialpowers/content/AppTestDelegateChild.sys.mjs new file mode 100644 index 0000000000..1d78d41f5d --- /dev/null +++ b/testing/specialpowers/content/AppTestDelegateChild.sys.mjs @@ -0,0 +1,18 @@ +/* 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/. */ + +export class AppTestDelegateChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "DOMContentLoaded": + case "load": { + this.sendAsyncMessage(event.type, { + internalURL: event.target.documentURI, + visibleURL: event.target.location?.href, + }); + break; + } + } + } +} diff --git a/testing/specialpowers/content/AppTestDelegateParent.sys.mjs b/testing/specialpowers/content/AppTestDelegateParent.sys.mjs new file mode 100644 index 0000000000..b271dea3df --- /dev/null +++ b/testing/specialpowers/content/AppTestDelegateParent.sys.mjs @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This module provides the bridge between the "AppTestDelegate" helper in + * mochitests and the supporting implementations in AppUiTestDelegate.sys.mjs. + * + * "AppTestDelegate" is documented in AppTestDelegate.sys.mjs and enables + * mochitests to invoke common functionality whose implementation is different + * (e.g. in browser/ and mobile/ instead of toolkit/). + * Tests can use this common interface after importing AppTestDelegate.sys.mjs: + * + * // head.js, in the scope of a plain mochitest: + * var { AppTestDelegate } = SpecialPowers.ChromeUtils.importESModule( + * "resource://specialpowers/AppTestDelegate.sys.mjs" + * ); + * + * // test usage example: open and close a tab. + * let tab = await AppTestDelegate.openNewForegroundTab(window, url); + * await AppTestDelegate.removeTab(window, tab); + * + * ## Overview of files supporting "AppTestDelegate": + * + * MOZ_BUILD_APP-specific AppUiTestDelegate.sys.mjs implementations: + * - browser/components/extensions/test/AppUiTestDelegate.jsm + * - mobile/android/modules/test/AppUiTestDelegate.jsm + * - mail/components/extensions/test/AppUiTestDelegate.sys.mjs (in comm-central) + * + * Glue between AppUiTestDelegate.sys.mjs in parent and test code in child: + * - testing/specialpowers/content/AppTestDelegateParent.sys.mjs (this file) + * - testing/specialpowers/content/AppTestDelegateChild.sys.mjs + * - testing/specialpowers/content/AppTestDelegate.sys.mjs + * + * Setup for usage by test code in child (i.e. plain mochitests): + * - Import AppTestDelegate.sys.mjs (e.g. in head.js or the test) + * + * Note: some browser-chrome tests import AppUiTestDelegate.sys.mjs directly, + * but that is not part of this API contract. They merely reuse code. + * + * ## How to add new AppTestDelegate methods + * + * - Add the method to AppTestDelegate.sys.mjs + * - Add a message forwarder in AppTestDelegateChild.sys.mjs + * - Add a message handler in AppTestDelegateParent.sys.mjs + * - Add an implementation in AppUiTestDelegate.sys.mjs for each MOZ_BUILD_APP, + * by defining the method on the exported AppUiTestDelegate object. + * All AppUiTestDelegate implementations must be kept in sync to have the + * same interface! + * + * You should use the same method name across all of these files for ease of + * lookup and maintainability. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + // Each app needs to implement this - see above comment. + AppUiTestDelegate: "resource://testing-common/AppUiTestDelegate.sys.mjs", +}); + +export class AppTestDelegateParent extends JSWindowActorParent { + constructor() { + super(); + this._tabs = new Map(); + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + get window() { + return this.browser.ownerGlobal; + } + + async receiveMessage(message) { + const { extensionId, url, waitForLoad, tabId } = message.data; + switch (message.name) { + case "DOMContentLoaded": + case "load": { + return this.browser?.dispatchEvent( + new CustomEvent(`AppTestDelegate:${message.name}`, { + detail: { + browsingContext: this.browsingContext, + ...message.data, + }, + }) + ); + } + case "clickPageAction": + return lazy.AppUiTestDelegate.clickPageAction(this.window, extensionId); + case "clickBrowserAction": + return lazy.AppUiTestDelegate.clickBrowserAction( + this.window, + extensionId + ); + case "closePageAction": + return lazy.AppUiTestDelegate.closePageAction(this.window, extensionId); + case "closeBrowserAction": + return lazy.AppUiTestDelegate.closeBrowserAction( + this.window, + extensionId + ); + case "awaitExtensionPanel": + // The desktop delegate returns a <browser>, but that cannot be sent + // over IPC, so just ignore it. The promise resolves when the panel and + // its content is fully loaded. + await lazy.AppUiTestDelegate.awaitExtensionPanel( + this.window, + extensionId + ); + return null; + case "openNewForegroundTab": { + // We cannot send the tab object across process so let's store it with + // a unique ID here. + const uuid = Services.uuid.generateUUID().toString(); + const tab = await lazy.AppUiTestDelegate.openNewForegroundTab( + this.window, + url, + waitForLoad + ); + this._tabs.set(uuid, tab); + return uuid; + } + case "removeTab": { + const tab = this._tabs.get(tabId); + this._tabs.delete(tabId); + return lazy.AppUiTestDelegate.removeTab(tab); + } + + default: + throw new Error(`Unknown Test API: ${message.name}.`); + } + } +} diff --git a/testing/specialpowers/content/ContentTaskUtils.sys.mjs b/testing/specialpowers/content/ContentTaskUtils.sys.mjs new file mode 100644 index 0000000000..0d026d1e3b --- /dev/null +++ b/testing/specialpowers/content/ContentTaskUtils.sys.mjs @@ -0,0 +1,249 @@ +/* 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/. */ + +/* + * This module implements a number of utility functions that can be loaded + * into content scope. + * + * All asynchronous helper methods should return promises, rather than being + * callback based. + */ + +// Disable ownerGlobal use since that's not available on content-privileged elements. + +/* eslint-disable mozilla/use-ownerGlobal */ + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +export var ContentTaskUtils = { + /** + * Checks if a DOM element is hidden. + * + * @param {Element} element + * The element which is to be checked. + * + * @return {boolean} + */ + isHidden(element) { + let style = element.ownerDocument.defaultView.getComputedStyle(element); + if (style.display == "none") { + return true; + } + if (style.visibility != "visible") { + return true; + } + + // Hiding a parent element will hide all its children + if ( + element.parentNode != element.ownerDocument && + element.parentNode.nodeType != Node.DOCUMENT_FRAGMENT_NODE + ) { + return ContentTaskUtils.isHidden(element.parentNode); + } + + // Walk up the shadow DOM if we've reached the top of the shadow root + if (element.parentNode.host) { + return ContentTaskUtils.isHidden(element.parentNode.host); + } + + return false; + }, + + /** + * Checks if a DOM element is visible. + * + * @param {Element} element + * The element which is to be checked. + * + * @return {boolean} + */ + isVisible(element) { + return !this.isHidden(element); + }, + + /** + * Will poll a condition function until it returns true. + * + * @param condition + * A condition function that must return true or false. If the + * condition ever throws, this is also treated as a false. + * @param msg + * The message to use when the returned promise is rejected. + * This message will be extended with additional information + * about the number of tries or the thrown exception. + * @param interval + * The time interval to poll the condition function. Defaults + * to 100ms. + * @param maxTries + * The number of times to poll before giving up and rejecting + * if the condition has not yet returned true. Defaults to 50 + * (~5 seconds for 100ms intervals) + * @return Promise + * Resolves when condition is true. + * Rejects if timeout is exceeded or condition ever throws. + */ + async waitForCondition(condition, msg, interval = 100, maxTries = 50) { + let startTime = Cu.now(); + for (let tries = 0; tries < maxTries; ++tries) { + await new Promise(resolve => setTimeout(resolve, interval)); + + let conditionPassed = false; + try { + conditionPassed = await condition(); + } catch (e) { + msg += ` - threw exception: ${e}`; + ChromeUtils.addProfilerMarker( + "ContentTaskUtils", + { startTime, category: "Test" }, + `waitForCondition - ${msg}` + ); + throw msg; + } + if (conditionPassed) { + ChromeUtils.addProfilerMarker( + "ContentTaskUtils", + { startTime, category: "Test" }, + `waitForCondition succeeded after ${tries} retries - ${msg}` + ); + return conditionPassed; + } + } + + msg += ` - timed out after ${maxTries} tries.`; + ChromeUtils.addProfilerMarker( + "ContentTaskUtils", + { startTime, category: "Test" }, + `waitForCondition - ${msg}` + ); + throw msg; + }, + + /** + * Waits for an event to be fired on a specified element. + * + * Usage: + * let promiseEvent = ContentTasKUtils.waitForEvent(element, "eventName"); + * // Do some processing here that will cause the event to be fired + * // ... + * // Now yield until the Promise is fulfilled + * let receivedEvent = yield promiseEvent; + * + * @param {Element} subject + * The element that should receive the event. + * @param {string} eventName + * Name of the event to listen to. + * @param {bool} capture [optional] + * True to use a capturing listener. + * @param {function} checkFn [optional] + * Called with the Event object as argument, should return true if the + * event is the expected one, or false if it should be ignored and + * listening should continue. If not specified, the first event with + * the specified name resolves the returned promise. + * + * @note Because this function is intended for testing, any error in checkFn + * will cause the returned promise to be rejected instead of waiting for + * the next event, since this is probably a bug in the test. + * + * @returns {Promise} + * @resolves The Event object. + */ + waitForEvent(subject, eventName, capture, checkFn, wantsUntrusted = false) { + return new Promise((resolve, reject) => { + let startTime = Cu.now(); + subject.addEventListener( + eventName, + function listener(event) { + try { + if (checkFn && !checkFn(event)) { + return; + } + subject.removeEventListener(eventName, listener, capture); + setTimeout(() => { + ChromeUtils.addProfilerMarker( + "ContentTaskUtils", + { category: "Test", startTime }, + "waitForEvent - " + eventName + ); + resolve(event); + }, 0); + } catch (ex) { + try { + subject.removeEventListener(eventName, listener, capture); + } catch (ex2) { + // Maybe the provided object does not support removeEventListener. + } + setTimeout(() => reject(ex), 0); + } + }, + capture, + wantsUntrusted + ); + }); + }, + + /** + * Wait until DOM mutations cause the condition expressed in checkFn to pass. + * Intended as an easy-to-use alternative to waitForCondition. + * + * @param {Element} subject + * The element on which to observe mutations. + * @param {Object} options + * The options to pass to MutationObserver.observe(); + * @param {function} checkFn [optional] + * Function that returns true when it wants the promise to be resolved. + * If not specified, the first mutation will resolve the promise. + * + * @returns {Promise<void>} + */ + waitForMutationCondition(subject, options, checkFn) { + if (checkFn?.()) { + return Promise.resolve(); + } + return new Promise(resolve => { + let obs = new subject.ownerGlobal.MutationObserver(function () { + if (checkFn && !checkFn()) { + return; + } + obs.disconnect(); + resolve(); + }); + obs.observe(subject, options); + }); + }, + + /** + * Gets an instance of the `EventUtils` helper module for usage in + * content tasks. See https://searchfox.org/mozilla-central/source/testing/mochitest/tests/SimpleTest/EventUtils.js + * + * @param content + * The `content` global object from your content task. + * + * @returns an EventUtils instance. + */ + getEventUtils(content) { + if (content._EventUtils) { + return content._EventUtils; + } + + let EventUtils = (content._EventUtils = {}); + + EventUtils.window = {}; + EventUtils.setTimeout = setTimeout; + EventUtils.parent = EventUtils.window; + /* eslint-disable camelcase */ + EventUtils._EU_Ci = Ci; + EventUtils._EU_Cc = Cc; + /* eslint-enable camelcase */ + // EventUtils' `sendChar` function relies on the navigator to synthetize events. + EventUtils.navigator = content.navigator; + EventUtils.KeyboardEvent = content.KeyboardEvent; + + Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + EventUtils + ); + + return EventUtils; + }, +}; diff --git a/testing/specialpowers/content/MockColorPicker.sys.mjs b/testing/specialpowers/content/MockColorPicker.sys.mjs new file mode 100644 index 0000000000..1b4c4d3b3e --- /dev/null +++ b/testing/specialpowers/content/MockColorPicker.sys.mjs @@ -0,0 +1,123 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + WrapPrivileged: "resource://testing-common/WrapPrivileged.sys.mjs", +}); + +const Cm = Components.manager; + +const CONTRACT_ID = "@mozilla.org/colorpicker;1"; + +Cu.crashIfNotInAutomation(); + +var registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); +var oldClassID = ""; +var newClassID = Services.uuid.generateUUID(); +var newFactory = function (window) { + return { + createInstance(aIID) { + return new MockColorPickerInstance(window).QueryInterface(aIID); + }, + QueryInterface: ChromeUtils.generateQI(["nsIFactory"]), + }; +}; + +export var MockColorPicker = { + init(window) { + this.reset(); + this.factory = newFactory(window); + if (!registrar.isCIDRegistered(newClassID)) { + try { + oldClassID = registrar.contractIDToCID(CONTRACT_ID); + } catch (ex) { + oldClassID = ""; + dump( + "TEST-INFO | can't get colorpicker registered component, " + + "assuming there is none" + ); + } + registrar.registerFactory(newClassID, "", CONTRACT_ID, this.factory); + } + }, + + reset() { + this.returnColor = ""; + this.showCallback = null; + this.shown = false; + this.showing = false; + }, + + cleanup() { + var previousFactory = this.factory; + this.reset(); + this.factory = null; + + registrar.unregisterFactory(newClassID, previousFactory); + if (oldClassID != "") { + registrar.registerFactory(oldClassID, "", CONTRACT_ID, null); + } + }, +}; + +function MockColorPickerInstance(window) { + this.window = window; + this.showCallback = null; + this.showCallbackWrapped = null; +} +MockColorPickerInstance.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIColorPicker"]), + init(aParent, aTitle, aInitialColor, aDefaultColors) { + this.parent = aParent; + this.initialColor = aInitialColor; + this.defaultColors = aDefaultColors; + }, + initialColor: "", + parent: null, + open(aColorPickerShownCallback) { + MockColorPicker.showing = true; + MockColorPicker.shown = true; + + this.window.setTimeout(() => { + let result = ""; + try { + if (typeof MockColorPicker.showCallback == "function") { + if (MockColorPicker.showCallback != this.showCallback) { + this.showCallback = MockColorPicker.showCallback; + if (Cu.isXrayWrapper(this.window)) { + this.showCallbackWrapped = lazy.WrapPrivileged.wrapCallback( + MockColorPicker.showCallback, + this.window + ); + } else { + this.showCallbackWrapped = this.showCallback; + } + } + var updateCb = function (color) { + result = color; + aColorPickerShownCallback.update(color); + }; + let returnColor = this.showCallbackWrapped(this, updateCb); + if (typeof returnColor === "string") { + result = returnColor; + } + } else if (typeof MockColorPicker.returnColor === "string") { + result = MockColorPicker.returnColor; + } + } catch (ex) { + dump( + "TEST-UNEXPECTED-FAIL | Exception in MockColorPicker.sys.mjs open() " + + "method: " + + ex + + "\n" + ); + } + if (aColorPickerShownCallback) { + aColorPickerShownCallback.done(result); + } + }, 0); + }, +}; diff --git a/testing/specialpowers/content/MockFilePicker.sys.mjs b/testing/specialpowers/content/MockFilePicker.sys.mjs new file mode 100644 index 0000000000..ee2f11af75 --- /dev/null +++ b/testing/specialpowers/content/MockFilePicker.sys.mjs @@ -0,0 +1,318 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + WrapPrivileged: "resource://testing-common/WrapPrivileged.sys.mjs", +}); + +const Cm = Components.manager; + +const CONTRACT_ID = "@mozilla.org/filepicker;1"; + +if (import.meta.url.includes("specialpowers")) { + Cu.crashIfNotInAutomation(); +} + +var registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); +var oldClassID; +var newClassID = Services.uuid.generateUUID(); +var newFactory = function (window) { + return { + createInstance(aIID) { + return new MockFilePickerInstance(window).QueryInterface(aIID); + }, + QueryInterface: ChromeUtils.generateQI(["nsIFactory"]), + }; +}; + +export var MockFilePicker = { + returnOK: Ci.nsIFilePicker.returnOK, + returnCancel: Ci.nsIFilePicker.returnCancel, + returnReplace: Ci.nsIFilePicker.returnReplace, + + filterAll: Ci.nsIFilePicker.filterAll, + filterHTML: Ci.nsIFilePicker.filterHTML, + filterText: Ci.nsIFilePicker.filterText, + filterImages: Ci.nsIFilePicker.filterImages, + filterXML: Ci.nsIFilePicker.filterXML, + filterXUL: Ci.nsIFilePicker.filterXUL, + filterApps: Ci.nsIFilePicker.filterApps, + filterAllowURLs: Ci.nsIFilePicker.filterAllowURLs, + filterAudio: Ci.nsIFilePicker.filterAudio, + filterVideo: Ci.nsIFilePicker.filterVideo, + + window: null, + pendingPromises: [], + + init(window) { + this.window = window; + + this.reset(); + this.factory = newFactory(window); + if (!registrar.isCIDRegistered(newClassID)) { + oldClassID = registrar.contractIDToCID(CONTRACT_ID); + registrar.registerFactory(newClassID, "", CONTRACT_ID, this.factory); + } + }, + + reset() { + this.appendFilterCallback = null; + this.appendFiltersCallback = null; + this.displayDirectory = null; + this.displaySpecialDirectory = ""; + this.filterIndex = 0; + this.mode = null; + this.returnData = []; + this.returnValue = null; + this.showCallback = null; + this.afterOpenCallback = null; + this.shown = false; + this.showing = false; + }, + + cleanup() { + var previousFactory = this.factory; + this.reset(); + this.factory = null; + if (oldClassID) { + registrar.unregisterFactory(newClassID, previousFactory); + registrar.registerFactory(oldClassID, "", CONTRACT_ID, null); + } + }, + + internalFileData(obj) { + return { + nsIFile: "nsIFile" in obj ? obj.nsIFile : null, + domFile: "domFile" in obj ? obj.domFile : null, + domDirectory: "domDirectory" in obj ? obj.domDirectory : null, + }; + }, + + useAnyFile() { + var file = lazy.FileUtils.getDir("TmpD", []); + file.append("testfile"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + let promise = this.window.File.createFromNsIFile(file) + .then( + domFile => domFile, + () => null + ) + // domFile can be null. + .then(domFile => { + this.returnData = [this.internalFileData({ nsIFile: file, domFile })]; + }) + .then(() => file); + + this.pendingPromises = [promise]; + + // We return a promise in order to support some existing mochitests. + return promise; + }, + + useBlobFile() { + var blob = new this.window.Blob([]); + var file = new this.window.File([blob], "helloworld.txt", { + type: "plain/text", + }); + this.returnData = [this.internalFileData({ domFile: file })]; + this.pendingPromises = []; + }, + + useDirectory(aPath) { + var directory = new this.window.Directory(aPath); + var file = new lazy.FileUtils.File(aPath); + this.returnData = [ + this.internalFileData({ domDirectory: directory, nsIFile: file }), + ]; + this.pendingPromises = []; + }, + + setFiles(files) { + this.returnData = []; + this.pendingPromises = []; + + for (let file of files) { + if (this.window.File.isInstance(file)) { + this.returnData.push(this.internalFileData({ domFile: file })); + } else { + let promise = this.window.File.createFromNsIFile(file, { + existenceCheck: false, + }); + + promise.then(domFile => { + this.returnData.push( + this.internalFileData({ nsIFile: file, domFile }) + ); + }); + this.pendingPromises.push(promise); + } + } + }, + + getNsIFile() { + if (this.returnData.length >= 1) { + return this.returnData[0].nsIFile; + } + return null; + }, +}; + +function MockFilePickerInstance(window) { + this.window = window; + this.showCallback = null; + this.showCallbackWrapped = null; +} +MockFilePickerInstance.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIFilePicker"]), + init(aParent, aTitle, aMode) { + this.mode = aMode; + this.filterIndex = MockFilePicker.filterIndex; + this.parent = aParent; + }, + appendFilter(aTitle, aFilter) { + if (typeof MockFilePicker.appendFilterCallback == "function") { + MockFilePicker.appendFilterCallback(this, aTitle, aFilter); + } + }, + appendFilters(aFilterMask) { + if (typeof MockFilePicker.appendFiltersCallback == "function") { + MockFilePicker.appendFiltersCallback(this, aFilterMask); + } + }, + defaultString: "", + defaultExtension: "", + parent: null, + filterIndex: 0, + displayDirectory: null, + displaySpecialDirectory: "", + get file() { + if (MockFilePicker.returnData.length >= 1) { + return MockFilePicker.returnData[0].nsIFile; + } + + return null; + }, + + // We don't support directories here. + get domFileOrDirectory() { + if (MockFilePicker.returnData.length < 1) { + return null; + } + + if (MockFilePicker.returnData[0].domFile) { + return MockFilePicker.returnData[0].domFile; + } + + if (MockFilePicker.returnData[0].domDirectory) { + return MockFilePicker.returnData[0].domDirectory; + } + + return null; + }, + get fileURL() { + if ( + MockFilePicker.returnData.length >= 1 && + MockFilePicker.returnData[0].nsIFile + ) { + return Services.io.newFileURI(MockFilePicker.returnData[0].nsIFile); + } + + return null; + }, + *getFiles(asDOM) { + for (let d of MockFilePicker.returnData) { + if (asDOM) { + yield d.domFile || d.domDirectory; + } else if (d.nsIFile) { + yield d.nsIFile; + } else { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + } + } + }, + get files() { + return this.getFiles(false); + }, + get domFileOrDirectoryEnumerator() { + return this.getFiles(true); + }, + open(aFilePickerShownCallback) { + MockFilePicker.showing = true; + Services.tm.dispatchToMainThread(() => { + // Maybe all the pending promises are already resolved, but we want to be sure. + Promise.all(MockFilePicker.pendingPromises) + .then( + () => { + return Ci.nsIFilePicker.returnOK; + }, + () => { + return Ci.nsIFilePicker.returnCancel; + } + ) + .then(result => { + // Nothing else has to be done. + MockFilePicker.pendingPromises = []; + + if (result == Ci.nsIFilePicker.returnCancel) { + return result; + } + + MockFilePicker.displayDirectory = this.displayDirectory; + MockFilePicker.displaySpecialDirectory = this.displaySpecialDirectory; + MockFilePicker.shown = true; + if (typeof MockFilePicker.showCallback == "function") { + if (MockFilePicker.showCallback != this.showCallback) { + this.showCallback = MockFilePicker.showCallback; + if (Cu.isXrayWrapper(this.window)) { + this.showCallbackWrapped = lazy.WrapPrivileged.wrapCallback( + MockFilePicker.showCallback, + this.window + ); + } else { + this.showCallbackWrapped = this.showCallback; + } + } + try { + var returnValue = this.showCallbackWrapped(this); + if (typeof returnValue != "undefined") { + return returnValue; + } + } catch (ex) { + return Ci.nsIFilePicker.returnCancel; + } + } + + return MockFilePicker.returnValue; + }) + .then(result => { + // Some additional result file can be set by the callback. Let's + // resolve the pending promises again. + return Promise.all(MockFilePicker.pendingPromises).then( + () => { + return result; + }, + () => { + return Ci.nsIFilePicker.returnCancel; + } + ); + }) + .then(result => { + MockFilePicker.pendingPromises = []; + + if (aFilePickerShownCallback) { + aFilePickerShownCallback.done(result); + } + + if (typeof MockFilePicker.afterOpenCallback == "function") { + Services.tm.dispatchToMainThread(() => { + MockFilePicker.afterOpenCallback(this); + }); + } + }); + }); + }, +}; diff --git a/testing/specialpowers/content/MockPermissionPrompt.sys.mjs b/testing/specialpowers/content/MockPermissionPrompt.sys.mjs new file mode 100644 index 0000000000..615c786f2c --- /dev/null +++ b/testing/specialpowers/content/MockPermissionPrompt.sys.mjs @@ -0,0 +1,75 @@ +/* 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/. */ + +const Cm = Components.manager; + +const CONTRACT_ID = "@mozilla.org/content-permission/prompt;1"; + +var registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); +var oldClassID, oldFactory; +var newClassID = Services.uuid.generateUUID(); +var newFactory = { + createInstance(aIID) { + return new MockPermissionPromptInstance().QueryInterface(aIID); + }, + QueryInterface: ChromeUtils.generateQI(["nsIFactory"]), +}; + +export var MockPermissionPrompt = { + init() { + this.reset(); + if (!registrar.isCIDRegistered(newClassID)) { + try { + oldClassID = registrar.contractIDToCID(CONTRACT_ID); + oldFactory = Cm.getClassObject(Cc[CONTRACT_ID], Ci.nsIFactory); + } catch (ex) { + oldClassID = ""; + oldFactory = null; + dump( + "TEST-INFO | can't get permission prompt registered component, " + + "assuming there is none" + ); + } + if (oldFactory) { + registrar.unregisterFactory(oldClassID, oldFactory); + } + registrar.registerFactory(newClassID, "", CONTRACT_ID, newFactory); + } + }, + + reset() {}, + + cleanup() { + this.reset(); + if (oldFactory) { + registrar.unregisterFactory(newClassID, newFactory); + registrar.registerFactory(oldClassID, "", CONTRACT_ID, oldFactory); + } + }, +}; + +function MockPermissionPromptInstance() {} +MockPermissionPromptInstance.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionPrompt"]), + + promptResult: Ci.nsIPermissionManager.UNKNOWN_ACTION, + + prompt(request) { + let perms = request.types.QueryInterface(Ci.nsIArray); + for (let idx = 0; idx < perms.length; idx++) { + let perm = perms.queryElementAt(idx, Ci.nsIContentPermissionType); + if ( + Services.perms.testExactPermissionFromPrincipal( + request.principal, + perm.type + ) != Ci.nsIPermissionManager.ALLOW_ACTION + ) { + request.cancel(); + return; + } + } + + request.allow(); + }, +}; diff --git a/testing/specialpowers/content/SpecialPowersChild.sys.mjs b/testing/specialpowers/content/SpecialPowersChild.sys.mjs new file mode 100644 index 0000000000..20afe12d39 --- /dev/null +++ b/testing/specialpowers/content/SpecialPowersChild.sys.mjs @@ -0,0 +1,2333 @@ +/* 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/. */ +/* This code is loaded in every child process that is started by mochitest. + */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentTaskUtils: "resource://testing-common/ContentTaskUtils.sys.mjs", + MockColorPicker: "resource://testing-common/MockColorPicker.sys.mjs", + MockFilePicker: "resource://testing-common/MockFilePicker.sys.mjs", + MockPermissionPrompt: + "resource://testing-common/MockPermissionPrompt.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PerTestCoverageUtils: + "resource://testing-common/PerTestCoverageUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SpecialPowersSandbox: + "resource://testing-common/SpecialPowersSandbox.sys.mjs", + WrapPrivileged: "resource://testing-common/WrapPrivileged.sys.mjs", +}); +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +Cu.crashIfNotInAutomation(); + +function bindDOMWindowUtils(aWindow) { + return aWindow && lazy.WrapPrivileged.wrap(aWindow.windowUtils, aWindow); +} + +function defineSpecialPowers(sp) { + let window = sp.contentWindow; + window.SpecialPowers = sp; + if (window === window.wrappedJSObject) { + return; + } + // We can't use a generic |defineLazyGetter| because it does not + // allow customizing the re-definition behavior. + Object.defineProperty(window.wrappedJSObject, "SpecialPowers", { + get() { + let value = lazy.WrapPrivileged.wrap(sp, window); + // If we bind |window.wrappedJSObject| when defining the getter + // and use it here, it might become a dead wrapper. + // We have to retrieve |wrappedJSObject| again. + Object.defineProperty(window.wrappedJSObject, "SpecialPowers", { + configurable: true, + enumerable: true, + value, + writable: true, + }); + return value; + }, + configurable: true, + enumerable: true, + }); +} + +// SPConsoleListener reflects nsIConsoleMessage objects into JS in a +// tidy, XPCOM-hiding way. Messages that are nsIScriptError objects +// have their properties exposed in detail. It also auto-unregisters +// itself when it receives a "sentinel" message. +function SPConsoleListener(callback, contentWindow) { + this.callback = callback; + this.contentWindow = contentWindow; +} + +SPConsoleListener.prototype = { + // Overload the observe method for both nsIConsoleListener and nsIObserver. + // The topic will be null for nsIConsoleListener. + observe(msg, topic) { + let m = { + message: msg.message, + errorMessage: null, + cssSelectors: null, + sourceName: null, + sourceLine: null, + lineNumber: null, + columnNumber: null, + category: null, + windowID: null, + isScriptError: false, + isConsoleEvent: false, + isWarning: false, + }; + if (msg instanceof Ci.nsIScriptError) { + m.errorMessage = msg.errorMessage; + m.cssSelectors = msg.cssSelectors; + m.sourceName = msg.sourceName; + m.sourceLine = msg.sourceLine; + m.lineNumber = msg.lineNumber; + m.columnNumber = msg.columnNumber; + m.category = msg.category; + m.windowID = msg.outerWindowID; + m.innerWindowID = msg.innerWindowID; + m.isScriptError = true; + m.isWarning = (msg.flags & Ci.nsIScriptError.warningFlag) === 1; + } + + Object.freeze(m); + + // Run in a separate runnable since console listeners aren't + // supposed to touch content and this one might. + Services.tm.dispatchToMainThread(() => { + this.callback.call(undefined, Cu.cloneInto(m, this.contentWindow)); + }); + + if (!m.isScriptError && !m.isConsoleEvent && m.message === "SENTINEL") { + Services.console.unregisterListener(this); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener", "nsIObserver"]), +}; + +export class SpecialPowersChild extends JSWindowActorChild { + constructor() { + super(); + + this._windowID = null; + + this._encounteredCrashDumpFiles = []; + this._unexpectedCrashDumpFiles = {}; + this._crashDumpDir = null; + this._serviceWorkerRegistered = false; + this._serviceWorkerCleanUpRequests = new Map(); + Object.defineProperty(this, "Components", { + configurable: true, + enumerable: true, + value: Components, + }); + this._createFilesOnError = null; + this._createFilesOnSuccess = null; + + this._messageListeners = new ExtensionUtils.DefaultMap(() => new Set()); + + this._consoleListeners = []; + this._spawnTaskImports = {}; + this._encounteredCrashDumpFiles = []; + this._unexpectedCrashDumpFiles = {}; + this._crashDumpDir = null; + this._mfl = null; + this._asyncObservers = new WeakMap(); + this._xpcomabi = null; + this._os = null; + this._pu = null; + + this._nextExtensionID = 0; + this._extensionListeners = null; + + lazy.WrapPrivileged.disableAutoWrap( + this.unwrap, + this.isWrapper, + this.wrapCallback, + this.wrapCallbackObject, + this.setWrapped, + this.nondeterministicGetWeakMapKeys, + this.snapshotWindowWithOptions, + this.snapshotWindow, + this.snapshotRect, + this.getDOMRequestService + ); + } + + observe(aSubject, aTopic, aData) { + // Ignore the "{chrome/content}-document-global-created" event. It + // is only observed to force creation of the actor. + } + + actorCreated() { + this.attachToWindow(); + } + + attachToWindow() { + let window = this.contentWindow; + // We should not invoke the getter. + if (!("SpecialPowers" in window.wrappedJSObject)) { + this._windowID = window.windowGlobalChild.innerWindowId; + + defineSpecialPowers(this); + } + } + + get window() { + return this.contentWindow; + } + + // Hack around devtools sometimes trying to JSON stringify us. + toJSON() { + return {}; + } + + toString() { + return "[SpecialPowers]"; + } + sanityCheck() { + return "foo"; + } + + _addMessageListener(msgname, listener) { + this._messageListeners.get(msgname).add(listener); + } + + _removeMessageListener(msgname, listener) { + this._messageListeners.get(msgname).delete(listener); + } + + receiveMessage(message) { + if (this._messageListeners.has(message.name)) { + for (let listener of this._messageListeners.get(message.name)) { + try { + if (typeof listener === "function") { + listener(message); + } else { + listener.receiveMessage(message); + } + } catch (e) { + console.error(e); + } + } + } + + switch (message.name) { + case "SPProcessCrashService": + if (message.json.type == "crash-observed") { + for (let e of message.json.dumpIDs) { + this._encounteredCrashDumpFiles.push(e.id + "." + e.extension); + } + } + break; + + case "SPServiceWorkerRegistered": + this._serviceWorkerRegistered = message.data.registered; + break; + + case "SpecialPowers.FilesCreated": + var createdHandler = this._createFilesOnSuccess; + this._createFilesOnSuccess = null; + this._createFilesOnError = null; + if (createdHandler) { + createdHandler(Cu.cloneInto(message.data, this.contentWindow)); + } + break; + + case "SpecialPowers.FilesError": + var errorHandler = this._createFilesOnError; + this._createFilesOnSuccess = null; + this._createFilesOnError = null; + if (errorHandler) { + errorHandler(message.data); + } + break; + + case "Spawn": + let { task, args, caller, taskId, imports } = message.data; + return this._spawnTask(task, args, caller, taskId, imports); + + case "EnsureFocus": + // Ensure that the focus is in this child document. Returns a browsing + // context of a child frame if a subframe should be focused or undefined + // otherwise. + + // If a subframe node is focused, then the focus will actually + // be within that subframe's document. If blurSubframe is true, + // then blur the subframe so that this parent document is focused + // instead. If blurSubframe is false, then return the browsing + // context for that subframe. The parent process will then call back + // into this same code but in the process for that subframe. + let focusedNode = this.document.activeElement; + let subframeFocused = + ChromeUtils.getClassName(focusedNode) == "HTMLIFrameElement" || + ChromeUtils.getClassName(focusedNode) == "HTMLFrameElement" || + ChromeUtils.getClassName(focusedNode) == "XULFrameElement"; + if (subframeFocused) { + if (message.data.blurSubframe) { + Services.focus.clearFocus(this.contentWindow); + } else { + if (!this.document.hasFocus()) { + this.contentWindow.focus(); + } + return Promise.resolve(focusedNode.browsingContext); + } + } + + // A subframe is not focused, so if this document is + // not focused, focus it and wait for the focus event. + if (!this.document.hasFocus()) { + return new Promise(resolve => { + this.document.addEventListener( + "focus", + () => { + resolve(); + }, + { + capture: true, + once: true, + } + ); + this.contentWindow.focus(); + }); + } + break; + + case "Assert": + { + if ("info" in message.data) { + (this.xpcshellScope || this.SimpleTest).info(message.data.info); + break; + } + + // An assertion has been done in a mochitest chrome script + let { name, passed, stack, diag, expectFail } = message.data; + + let { SimpleTest } = this; + if (SimpleTest) { + let expected = expectFail ? "fail" : "pass"; + SimpleTest.record(passed, name, diag, stack, expected); + } else if (this.xpcshellScope) { + this.xpcshellScope.do_report_result(passed, name, stack); + } else { + // Well, this is unexpected. + dump(name + "\n"); + } + } + break; + } + return undefined; + } + + registerProcessCrashObservers() { + this.sendAsyncMessage("SPProcessCrashService", { op: "register-observer" }); + } + + unregisterProcessCrashObservers() { + this.sendAsyncMessage("SPProcessCrashService", { + op: "unregister-observer", + }); + } + + /* + * Privileged object wrapping API + * + * Usage: + * var wrapper = SpecialPowers.wrap(obj); + * wrapper.privilegedMethod(); wrapper.privilegedProperty; + * obj === SpecialPowers.unwrap(wrapper); + * + * These functions provide transparent access to privileged objects using + * various pieces of deep SpiderMagic. Conceptually, a wrapper is just an + * object containing a reference to the underlying object, where all method + * calls and property accesses are transparently performed with the System + * Principal. Moreover, objects obtained from the wrapper (including properties + * and method return values) are wrapped automatically. Thus, after a single + * call to SpecialPowers.wrap(), the wrapper layer is transitively maintained. + * + * Known Issues: + * + * - The wrapping function does not preserve identity, so + * SpecialPowers.wrap(foo) !== SpecialPowers.wrap(foo). See bug 718543. + * + * - The wrapper cannot see expando properties on unprivileged DOM objects. + * That is to say, the wrapper uses Xray delegation. + * + * - The wrapper sometimes guesses certain ES5 attributes for returned + * properties. This is explained in a comment in the wrapper code above, + * and shouldn't be a problem. + */ + wrap(obj) { + return obj; + } + unwrap(obj) { + return lazy.WrapPrivileged.unwrap(obj); + } + isWrapper(val) { + return lazy.WrapPrivileged.isWrapper(val); + } + + unwrapIfWrapped(obj) { + return lazy.WrapPrivileged.isWrapper(obj) + ? lazy.WrapPrivileged.unwrap(obj) + : obj; + } + + /* + * Wrap objects on a specified global. + */ + wrapFor(obj, win) { + return lazy.WrapPrivileged.wrap(obj, win); + } + + /* + * When content needs to pass a callback or a callback object to an API + * accessed over SpecialPowers, that API may sometimes receive arguments for + * whom it is forbidden to create a wrapper in content scopes. As such, we + * need a layer to wrap the values in SpecialPowers wrappers before they ever + * reach content. + */ + wrapCallback(func) { + return lazy.WrapPrivileged.wrapCallback(func, this.contentWindow); + } + wrapCallbackObject(obj) { + return lazy.WrapPrivileged.wrapCallbackObject(obj, this.contentWindow); + } + + /* + * Used for assigning a property to a SpecialPowers wrapper, without unwrapping + * the value that is assigned. + */ + setWrapped(obj, prop, val) { + if (!lazy.WrapPrivileged.isWrapper(obj)) { + throw new Error( + "You only need to use this for SpecialPowers wrapped objects" + ); + } + + obj = lazy.WrapPrivileged.unwrap(obj); + return Reflect.set(obj, prop, val); + } + + /* + * Create blank privileged objects to use as out-params for privileged functions. + */ + createBlankObject() { + return {}; + } + + /* + * Because SpecialPowers wrappers don't preserve identity, comparing with == + * can be hazardous. Sometimes we can just unwrap to compare, but sometimes + * wrapping the underlying object into a content scope is forbidden. This + * function strips any wrappers if they exist and compare the underlying + * values. + */ + compare(a, b) { + return lazy.WrapPrivileged.unwrap(a) === lazy.WrapPrivileged.unwrap(b); + } + + get MockFilePicker() { + return lazy.MockFilePicker; + } + + get MockColorPicker() { + return lazy.MockColorPicker; + } + + get MockPermissionPrompt() { + return lazy.MockPermissionPrompt; + } + + quit() { + this.sendAsyncMessage("SpecialPowers.Quit", {}); + } + + // fileRequests is an array of file requests. Each file request is an object. + // A request must have a field |name|, which gives the base of the name of the + // file to be created in the profile directory. If the request has a |data| field + // then that data will be written to the file. + createFiles(fileRequests, onCreation, onError) { + return this.sendQuery("SpecialPowers.CreateFiles", fileRequests).then( + files => onCreation(Cu.cloneInto(files, this.contentWindow)), + onError + ); + } + + // Remove the files that were created using |SpecialPowers.createFiles()|. + // This will be automatically called by |SimpleTest.finish()|. + removeFiles() { + this.sendAsyncMessage("SpecialPowers.RemoveFiles", {}); + } + + executeAfterFlushingMessageQueue(aCallback) { + return this.sendQuery("Ping").then(aCallback); + } + + async registeredServiceWorkers() { + // Please see the comment in SpecialPowersObserver.jsm above + // this._serviceWorkerListener's assignment for what this returns. + if (this._serviceWorkerRegistered) { + // This test registered at least one service worker. Send a synchronous + // call to the parent to make sure that it called unregister on all of its + // service workers. + let { workers } = await this.sendQuery("SPCheckServiceWorkers"); + return workers; + } + + return []; + } + + /* + * Load a privileged script that runs same-process. This is different from + * |loadChromeScript|, which will run in the parent process in e10s mode. + */ + loadPrivilegedScript(aFunction) { + var str = "(" + aFunction.toString() + ")();"; + let gGlobalObject = Cu.getGlobalForObject(this); + let sb = Cu.Sandbox(gGlobalObject); + var window = this.contentWindow; + var mc = new window.MessageChannel(); + sb.port = mc.port1; + let blob = new Blob([str], { type: "application/javascript" }); + let blobUrl = URL.createObjectURL(blob); + Services.scriptloader.loadSubScript(blobUrl, sb); + + return mc.port2; + } + + _readUrlAsString(aUrl) { + // Fetch script content as we can't use scriptloader's loadSubScript + // to evaluate http:// urls... + var scriptableStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].getService(Ci.nsIScriptableInputStream); + + var channel = lazy.NetUtil.newChannel({ + uri: aUrl, + loadUsingSystemPrincipal: true, + }); + var input = channel.open(); + scriptableStream.init(input); + + var str; + var buffer = []; + + while ((str = scriptableStream.read(4096))) { + buffer.push(str); + } + + var output = buffer.join(""); + + scriptableStream.close(); + input.close(); + + var status; + if (channel instanceof Ci.nsIHttpChannel) { + status = channel.responseStatus; + } + + if (status == 404) { + throw new Error( + `Error while executing chrome script '${aUrl}':\n` + + "The script doesn't exist. Ensure you have registered it in " + + "'support-files' in your mochitest.ini." + ); + } + + return output; + } + + loadChromeScript(urlOrFunction, sandboxOptions) { + // Create a unique id for this chrome script + let id = Services.uuid.generateUUID().toString(); + + // Tells chrome code to evaluate this chrome script + let scriptArgs = { id, sandboxOptions }; + if (typeof urlOrFunction == "function") { + scriptArgs.function = { + body: "(" + urlOrFunction.toString() + ")();", + name: urlOrFunction.name, + }; + } else { + // Note: We need to do this in the child since, even though + // `_readUrlAsString` pretends to be synchronous, its channel + // winds up spinning the event loop when loading HTTP URLs. That + // leads to unexpected out-of-order operations if the child sends + // a message immediately after loading the script. + scriptArgs.function = { + body: this._readUrlAsString(urlOrFunction), + }; + scriptArgs.url = urlOrFunction; + } + this.sendAsyncMessage("SPLoadChromeScript", scriptArgs); + + // Returns a MessageManager like API in order to be + // able to communicate with this chrome script + let listeners = []; + let chromeScript = { + addMessageListener: (name, listener) => { + listeners.push({ name, listener }); + }, + + promiseOneMessage: name => + new Promise(resolve => { + chromeScript.addMessageListener(name, function listener(message) { + chromeScript.removeMessageListener(name, listener); + resolve(message); + }); + }), + + removeMessageListener: (name, listener) => { + listeners = listeners.filter( + o => o.name != name || o.listener != listener + ); + }, + + sendAsyncMessage: (name, message) => { + this.sendAsyncMessage("SPChromeScriptMessage", { id, name, message }); + }, + + sendQuery: (name, message) => { + return this.sendQuery("SPChromeScriptMessage", { id, name, message }); + }, + + destroy: () => { + listeners = []; + this._removeMessageListener("SPChromeScriptMessage", chromeScript); + }, + + receiveMessage: aMessage => { + let messageId = aMessage.json.id; + let name = aMessage.json.name; + let message = aMessage.json.message; + if (this.contentWindow) { + message = new StructuredCloneHolder( + `SpecialPowers/receiveMessage/${name}`, + null, + message + ).deserialize(this.contentWindow); + } + // Ignore message from other chrome script + if (messageId != id) { + return null; + } + + let result; + if (aMessage.name == "SPChromeScriptMessage") { + for (let listener of listeners.filter(o => o.name == name)) { + result = listener.listener(message); + } + } + return result; + }, + }; + this._addMessageListener("SPChromeScriptMessage", chromeScript); + + return chromeScript; + } + + get Services() { + return Services; + } + + /* + * Convenient shortcuts to the standard Components abbreviations. + */ + get Cc() { + return Cc; + } + get Ci() { + return Ci; + } + get Cu() { + return Cu; + } + get Cr() { + return Cr; + } + + get ChromeUtils() { + return ChromeUtils; + } + + get isHeadless() { + return Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless; + } + + get addProfilerMarker() { + return ChromeUtils.addProfilerMarker; + } + + get DOMWindowUtils() { + return this.contentWindow.windowUtils; + } + + getDOMWindowUtils(aWindow) { + if (aWindow == this.contentWindow) { + return aWindow.windowUtils; + } + + return bindDOMWindowUtils(Cu.unwaiveXrays(aWindow)); + } + + async toggleMuteState(aMuted, aWindow) { + let actor = aWindow + ? aWindow.windowGlobalChild.getActor("SpecialPowers") + : this; + return actor.sendQuery("SPToggleMuteAudio", { mute: aMuted }); + } + + /* + * A method to get a DOMParser that can't parse XUL. + */ + getNoXULDOMParser() { + // If we create it with a system subject principal (so it gets a + // nullprincipal), it won't be able to parse XUL by default. + return new DOMParser(); + } + + get InspectorUtils() { + return InspectorUtils; + } + + get PromiseDebugging() { + return PromiseDebugging; + } + + async waitForCrashes(aExpectingProcessCrash) { + if (!aExpectingProcessCrash) { + return; + } + + var crashIds = this._encounteredCrashDumpFiles + .filter(filename => { + return filename.length === 40 && filename.endsWith(".dmp"); + }) + .map(id => { + return id.slice(0, -4); // Strip the .dmp extension to get the ID + }); + + await this.sendQuery("SPProcessCrashManagerWait", { + crashIds, + }); + } + + async removeExpectedCrashDumpFiles(aExpectingProcessCrash) { + var success = true; + if (aExpectingProcessCrash) { + var message = { + op: "delete-crash-dump-files", + filenames: this._encounteredCrashDumpFiles, + }; + if (!(await this.sendQuery("SPProcessCrashService", message))) { + success = false; + } + } + this._encounteredCrashDumpFiles.length = 0; + return success; + } + + async findUnexpectedCrashDumpFiles() { + var self = this; + var message = { + op: "find-crash-dump-files", + crashDumpFilesToIgnore: this._unexpectedCrashDumpFiles, + }; + var crashDumpFiles = await this.sendQuery("SPProcessCrashService", message); + crashDumpFiles.forEach(function (aFilename) { + self._unexpectedCrashDumpFiles[aFilename] = true; + }); + return crashDumpFiles; + } + + removePendingCrashDumpFiles() { + var message = { + op: "delete-pending-crash-dump-files", + }; + return this.sendQuery("SPProcessCrashService", message); + } + + _setTimeout(callback, delay = 0) { + // for mochitest-browser + if (typeof this.chromeWindow != "undefined") { + this.chromeWindow.setTimeout(callback, delay); + } + // for mochitest-plain + else { + this.contentWindow.setTimeout(callback, delay); + } + } + + promiseTimeout(delay) { + return new Promise(resolve => { + this._setTimeout(resolve, delay); + }); + } + + _delayCallbackTwice(callback) { + let delayedCallback = () => { + let delayAgain = aCallback => { + // Using this._setTimeout doesn't work here + // It causes failures in mochtests that use + // multiple pushPrefEnv calls + // For chrome/browser-chrome mochitests + this._setTimeout(aCallback); + }; + delayAgain(delayAgain.bind(this, callback)); + }; + return delayedCallback; + } + + /* apply permissions to the system and when the test case is finished (SimpleTest.finish()) + we will revert the permission back to the original. + + inPermissions is an array of objects where each object has a type, action, context, ex: + [{'type': 'SystemXHR', 'allow': 1, 'context': document}, + {'type': 'SystemXHR', 'allow': Ci.nsIPermissionManager.PROMPT_ACTION, 'context': document}] + + Allow can be a boolean value of true/false or ALLOW_ACTION/DENY_ACTION/PROMPT_ACTION/UNKNOWN_ACTION + */ + async pushPermissions(inPermissions, callback) { + let permissions = []; + for (let perm of inPermissions) { + let principal = this._getPrincipalFromArg(perm.context); + permissions.push({ + ...perm, + context: null, + principal, + }); + } + + await this.sendQuery("PushPermissions", permissions).then(callback); + await this.promiseTimeout(0); + } + + async popPermissions(callback = null) { + await this.sendQuery("PopPermissions").then(callback); + await this.promiseTimeout(0); + } + + async flushPermissions(callback = null) { + await this.sendQuery("FlushPermissions").then(callback); + await this.promiseTimeout(0); + } + + /* + * This function should be used when specialpowers is in content process but + * it want to get the notification from chrome space. + * + * This function will call Services.obs.addObserver in SpecialPowersObserver + * (that is in chrome process) and forward the data received to SpecialPowers + * via messageManager. + * You can use this._addMessageListener("specialpowers-YOUR_TOPIC") to fire + * the callback. + * + * To get the expected data, you should modify + * SpecialPowersObserver.prototype._registerObservers.observe. Or the message + * you received from messageManager will only contain 'aData' from Service.obs. + */ + registerObservers(topic) { + var msg = { + op: "add", + observerTopic: topic, + }; + return this.sendQuery("SPObserverService", msg); + } + + async pushPrefEnv(inPrefs, callback = null) { + let { requiresRefresh } = await this.sendQuery("PushPrefEnv", inPrefs); + if (callback) { + await callback(); + } + if (requiresRefresh) { + await this._promiseEarlyRefresh(); + } + } + + async popPrefEnv(callback = null) { + let { popped, requiresRefresh } = await this.sendQuery("PopPrefEnv"); + if (callback) { + await callback(popped); + } + if (requiresRefresh) { + await this._promiseEarlyRefresh(); + } + } + + async flushPrefEnv(callback = null) { + let { requiresRefresh } = await this.sendQuery("FlushPrefEnv"); + if (callback) { + await callback(); + } + if (requiresRefresh) { + await this._promiseEarlyRefresh(); + } + } + + /* + Collect a snapshot of all preferences in Firefox (i.e. about:prefs). + From this, store the results within specialpowers for later reference. + */ + async getBaselinePrefs(callback = null) { + await this.sendQuery("getBaselinePrefs"); + if (callback) { + await callback(); + } + } + + /* + This uses the stored prefs from getBaselinePrefs, collects a new snapshot + of preferences, then compares the new vs the baseline. If there are differences + they are recorded and returned as an array of preferences, in addition + all the changed preferences are reset to the value found in the baseline. + + ignorePrefs: array of strings which are preferences. If they end in *, + we do a partial match + */ + async comparePrefsToBaseline(ignorePrefs, callback = null) { + let retVal = await this.sendQuery("comparePrefsToBaseline", ignorePrefs); + if (callback) { + callback(retVal); + } + return retVal; + } + + _promiseEarlyRefresh() { + return new Promise(r => { + // for mochitest-browser + if (typeof this.chromeWindow != "undefined") { + this.chromeWindow.requestAnimationFrame(r); + } + // for mochitest-plain + else { + this.contentWindow.requestAnimationFrame(r); + } + }); + } + + _addObserverProxy(notification) { + if (notification in this._proxiedObservers) { + this._addMessageListener( + notification, + this._proxiedObservers[notification] + ); + } + } + _removeObserverProxy(notification) { + if (notification in this._proxiedObservers) { + this._removeMessageListener( + notification, + this._proxiedObservers[notification] + ); + } + } + + addObserver(obs, notification, weak) { + // Make sure the parent side exists, or we won't get any notifications. + this.sendAsyncMessage("Wakeup"); + + this._addObserverProxy(notification); + obs = Cu.waiveXrays(obs); + if ( + typeof obs == "object" && + obs.observe.name != "SpecialPowersCallbackWrapper" + ) { + obs.observe = lazy.WrapPrivileged.wrapCallback( + Cu.unwaiveXrays(obs.observe), + this.contentWindow + ); + } + Services.obs.addObserver(obs, notification, weak); + } + removeObserver(obs, notification) { + this._removeObserverProxy(notification); + Services.obs.removeObserver(Cu.waiveXrays(obs), notification); + } + notifyObservers(subject, topic, data) { + Services.obs.notifyObservers(subject, topic, data); + } + + /** + * An async observer is useful if you're listening for a + * notification that normally is only used by C++ code or chrome + * code (so it runs in the SystemGroup), but we need to know about + * it for a test (which runs as web content). If we used + * addObserver, we would assert when trying to enter web content + * from a runnabled labeled by the SystemGroup. An async observer + * avoids this problem. + */ + addAsyncObserver(obs, notification, weak) { + obs = Cu.waiveXrays(obs); + if ( + typeof obs == "object" && + obs.observe.name != "SpecialPowersCallbackWrapper" + ) { + obs.observe = lazy.WrapPrivileged.wrapCallback( + Cu.unwaiveXrays(obs.observe), + this.contentWindow + ); + } + let asyncObs = (...args) => { + Services.tm.dispatchToMainThread(() => { + if (typeof obs == "function") { + obs(...args); + } else { + obs.observe.call(undefined, ...args); + } + }); + }; + this._asyncObservers.set(obs, asyncObs); + Services.obs.addObserver(asyncObs, notification, weak); + } + removeAsyncObserver(obs, notification) { + let asyncObs = this._asyncObservers.get(Cu.waiveXrays(obs)); + Services.obs.removeObserver(asyncObs, notification); + } + + can_QI(obj) { + return obj.QueryInterface !== undefined; + } + do_QueryInterface(obj, iface) { + return obj.QueryInterface(Ci[iface]); + } + + call_Instanceof(obj1, obj2) { + obj1 = lazy.WrapPrivileged.unwrap(obj1); + obj2 = lazy.WrapPrivileged.unwrap(obj2); + return obj1 instanceof obj2; + } + + // Returns a privileged getter from an object. GetOwnPropertyDescriptor does + // not work here because xray wrappers don't properly implement it. + // + // This terribleness is used by dom/base/test/test_object.html because + // <object> and <embed> tags will spawn plugins if their prototype is touched, + // so we need to get and cache the getter of |hasRunningPlugin| if we want to + // call it without paradoxically spawning the plugin. + do_lookupGetter(obj, name) { + return Object.prototype.__lookupGetter__.call(obj, name); + } + + // Mimic the get*Pref API + getBoolPref(...args) { + return Services.prefs.getBoolPref(...args); + } + getIntPref(...args) { + return Services.prefs.getIntPref(...args); + } + getCharPref(...args) { + return Services.prefs.getCharPref(...args); + } + getComplexValue(prefName, iid) { + return Services.prefs.getComplexValue(prefName, iid); + } + getStringPref(...args) { + return Services.prefs.getStringPref(...args); + } + + getParentBoolPref(prefName, defaultValue) { + return this._getParentPref(prefName, "BOOL", { defaultValue }); + } + getParentIntPref(prefName, defaultValue) { + return this._getParentPref(prefName, "INT", { defaultValue }); + } + getParentCharPref(prefName, defaultValue) { + return this._getParentPref(prefName, "CHAR", { defaultValue }); + } + getParentStringPref(prefName, defaultValue) { + return this._getParentPref(prefName, "STRING", { defaultValue }); + } + + // Mimic the set*Pref API + setBoolPref(prefName, value) { + return this._setPref(prefName, "BOOL", value); + } + setIntPref(prefName, value) { + return this._setPref(prefName, "INT", value); + } + setCharPref(prefName, value) { + return this._setPref(prefName, "CHAR", value); + } + setComplexValue(prefName, iid, value) { + return this._setPref(prefName, "COMPLEX", value, iid); + } + setStringPref(prefName, value) { + return this._setPref(prefName, "STRING", value); + } + + // Mimic the clearUserPref API + clearUserPref(prefName) { + let msg = { + op: "clear", + prefName, + prefType: "", + }; + return this.sendQuery("SPPrefService", msg); + } + + // Private pref functions to communicate to chrome + async _getParentPref(prefName, prefType, { defaultValue, iid }) { + let msg = { + op: "get", + prefName, + prefType, + iid, // Only used with complex prefs + defaultValue, // Optional default value + }; + let val = await this.sendQuery("SPPrefService", msg); + if (val == null) { + throw new Error(`Error getting pref '${prefName}'`); + } + return val; + } + _getPref(prefName, prefType, { defaultValue }) { + switch (prefType) { + case "BOOL": + return Services.prefs.getBoolPref(prefName); + case "INT": + return Services.prefs.getIntPref(prefName); + case "CHAR": + return Services.prefs.getCharPref(prefName); + case "STRING": + return Services.prefs.getStringPref(prefName); + } + return undefined; + } + _setPref(prefName, prefType, prefValue, iid) { + let msg = { + op: "set", + prefName, + prefType, + iid, // Only used with complex prefs + prefValue, + }; + return this.sendQuery("SPPrefService", msg); + } + + _getMUDV(window) { + return window.docShell.docViewer; + } + // XXX: these APIs really ought to be removed, they're not e10s-safe. + // (also they're pretty Firefox-specific) + _getTopChromeWindow(window) { + return window.browsingContext.topChromeWindow; + } + _getAutoCompletePopup(window) { + return this._getTopChromeWindow(window).document.getElementById( + "PopupAutoComplete" + ); + } + addAutoCompletePopupEventListener(window, eventname, listener) { + this._getAutoCompletePopup(window).addEventListener(eventname, listener); + } + removeAutoCompletePopupEventListener(window, eventname, listener) { + this._getAutoCompletePopup(window).removeEventListener(eventname, listener); + } + getFormFillController(window) { + return Cc["@mozilla.org/satchel/form-fill-controller;1"].getService( + Ci.nsIFormFillController + ); + } + attachFormFillControllerTo(window) { + this.getFormFillController().attachPopupElementToDocument( + window.document, + this._getAutoCompletePopup(window) + ); + } + detachFormFillControllerFrom(window) { + this.getFormFillController().detachFromDocument(window.document); + } + isBackButtonEnabled(window) { + return !this._getTopChromeWindow(window) + .document.getElementById("Browser:Back") + .hasAttribute("disabled"); + } + // XXX end of problematic APIs + + addChromeEventListener(type, listener, capture, allowUntrusted) { + this.docShell.chromeEventHandler.addEventListener( + type, + listener, + capture, + allowUntrusted + ); + } + removeChromeEventListener(type, listener, capture) { + this.docShell.chromeEventHandler.removeEventListener( + type, + listener, + capture + ); + } + + async generateMediaControlKeyTestEvent(event) { + await this.sendQuery("SPGenerateMediaControlKeyTestEvent", { event }); + } + + // Note: each call to registerConsoleListener MUST be paired with a + // call to postConsoleSentinel; when the callback receives the + // sentinel it will unregister itself (_after_ calling the + // callback). SimpleTest.expectConsoleMessages does this for you. + // If you register more than one console listener, a call to + // postConsoleSentinel will zap all of them. + registerConsoleListener(callback) { + let listener = new SPConsoleListener(callback, this.contentWindow); + Services.console.registerListener(listener); + } + postConsoleSentinel() { + Services.console.logStringMessage("SENTINEL"); + } + resetConsole() { + Services.console.reset(); + } + + getFullZoom(window) { + return BrowsingContext.getFromWindow(window).fullZoom; + } + + getDeviceFullZoom(window) { + return this._getMUDV(window).deviceFullZoomForTest; + } + setFullZoom(window, zoom) { + BrowsingContext.getFromWindow(window).fullZoom = zoom; + } + getTextZoom(window) { + return BrowsingContext.getFromWindow(window).textZoom; + } + setTextZoom(window, zoom) { + BrowsingContext.getFromWindow(window).textZoom = zoom; + } + + emulateMedium(window, mediaType) { + BrowsingContext.getFromWindow(window).top.mediumOverride = mediaType; + } + + stopEmulatingMedium(window) { + BrowsingContext.getFromWindow(window).top.mediumOverride = ""; + } + + // Takes a snapshot of the given window and returns a <canvas> + // containing the image. When the window is same-process, the canvas + // is returned synchronously. When it is out-of-process (or when a + // BrowsingContext or FrameLoaderOwner is passed instead of a Window), + // a promise which resolves to such a canvas is returned instead. + snapshotWindowWithOptions(content, rect, bgcolor, options) { + function getImageData(rect, bgcolor, options) { + let el = content.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + if (rect === undefined) { + rect = { + top: content.scrollY, + left: content.scrollX, + width: content.innerWidth, + height: content.innerHeight, + }; + } + if (bgcolor === undefined) { + bgcolor = "rgb(255,255,255)"; + } + if (options === undefined) { + options = {}; + } + + el.width = rect.width; + el.height = rect.height; + let ctx = el.getContext("2d"); + + let flags = 0; + for (let option in options) { + flags |= options[option] && ctx[option]; + } + + ctx.drawWindow( + content, + rect.left, + rect.top, + rect.width, + rect.height, + bgcolor, + flags + ); + + return ctx.getImageData(0, 0, el.width, el.height); + } + + let toCanvas = imageData => { + let el = this.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + el.width = imageData.width; + el.height = imageData.height; + + if (ImageData.isInstance(imageData)) { + let ctx = el.getContext("2d"); + ctx.putImageData(imageData, 0, 0); + } + + return el; + }; + + if (!Cu.isRemoteProxy(content) && Window.isInstance(content)) { + // Hack around tests that try to snapshot 0 width or height + // elements. + if (rect && !(rect.width && rect.height)) { + return toCanvas(rect); + } + + // This is an in-process window. Snapshot it synchronously. + return toCanvas(getImageData(rect, bgcolor, options)); + } + + // This is a remote window or frame. Snapshot it asynchronously and + // return a promise for the result. Alas, consumers expect us to + // return a <canvas> element rather than an ImageData object, so we + // need to convert the result from the remote snapshot to a local + // canvas. + let promise = this.spawn( + content, + [rect, bgcolor, options], + getImageData + ).then(toCanvas); + if (Cu.isXrayWrapper(this.contentWindow)) { + return new this.contentWindow.Promise((resolve, reject) => { + promise.then(resolve, reject); + }); + } + return promise; + } + + snapshotWindow(win, withCaret, rect, bgcolor) { + return this.snapshotWindowWithOptions(win, rect, bgcolor, { + DRAWWINDOW_DRAW_CARET: withCaret, + }); + } + + snapshotRect(win, rect, bgcolor) { + return this.snapshotWindowWithOptions(win, rect, bgcolor); + } + + gc() { + this.contentWindow.windowUtils.garbageCollect(); + } + + forceGC() { + Cu.forceGC(); + } + + forceShrinkingGC() { + Cu.forceShrinkingGC(); + } + + forceCC() { + Cu.forceCC(); + } + + finishCC() { + Cu.finishCC(); + } + + ccSlice(budget) { + Cu.ccSlice(budget); + } + + // Due to various dependencies between JS objects and C++ objects, an ordinary + // forceGC doesn't necessarily clear all unused objects, thus the GC and CC + // needs to run several times and when no other JS is running. + // The current number of iterations has been determined according to massive + // cross platform testing. + exactGC(callback) { + let count = 0; + + function genGCCallback(cb) { + return function () { + Cu.forceCC(); + if (++count < 3) { + Cu.schedulePreciseGC(genGCCallback(cb)); + } else if (cb) { + cb(); + } + }; + } + + Cu.schedulePreciseGC(genGCCallback(callback)); + } + + nondeterministicGetWeakMapKeys(m) { + let keys = ChromeUtils.nondeterministicGetWeakMapKeys(m); + if (!keys) { + return undefined; + } + return this.contentWindow.Array.from(keys); + } + + getMemoryReports() { + try { + Cc["@mozilla.org/memory-reporter-manager;1"] + .getService(Ci.nsIMemoryReporterManager) + .getReports( + () => {}, + null, + () => {}, + null, + false + ); + } catch (e) {} + } + + setGCZeal(zeal) { + Cu.setGCZeal(zeal); + } + + isMainProcess() { + try { + return ( + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT + ); + } catch (e) {} + return true; + } + + get XPCOMABI() { + if (this._xpcomabi != null) { + return this._xpcomabi; + } + + var xulRuntime = Services.appinfo.QueryInterface(Ci.nsIXULRuntime); + + this._xpcomabi = xulRuntime.XPCOMABI; + return this._xpcomabi; + } + + // The optional aWin parameter allows the caller to specify a given window in + // whose scope the runnable should be dispatched. If aFun throws, the + // exception will be reported to aWin. + executeSoon(aFun, aWin) { + // Create the runnable in the scope of aWin to avoid running into COWs. + var runnable = {}; + if (aWin) { + runnable = Cu.createObjectIn(aWin); + } + runnable.run = aFun; + Cu.dispatch(runnable, aWin); + } + + get OS() { + if (this._os != null) { + return this._os; + } + + this._os = Services.appinfo.OS; + return this._os; + } + + get useRemoteSubframes() { + return this.docShell.nsILoadContext.useRemoteSubframes; + } + + ISOLATION_STRATEGY = { + IsolateNothing: 0, + IsolateEverything: 1, + IsolateHighValue: 2, + }; + + effectiveIsolationStrategy() { + // If remote subframes are disabled, we always use the IsolateNothing strategy. + if (!this.useRemoteSubframes) { + return this.ISOLATION_STRATEGY.IsolateNothing; + } + return this.getIntPref("fission.webContentIsolationStrategy"); + } + + addSystemEventListener(target, type, listener, useCapture) { + Services.els.addSystemEventListener(target, type, listener, useCapture); + } + removeSystemEventListener(target, type, listener, useCapture) { + Services.els.removeSystemEventListener(target, type, listener, useCapture); + } + + // helper method to check if the event is consumed by either default group's + // event listener or system group's event listener. + defaultPreventedInAnyGroup(event) { + // FYI: Event.defaultPrevented returns false in content context if the + // event is consumed only by system group's event listeners. + return event.defaultPrevented; + } + + getDOMRequestService() { + var serv = Services.DOMRequest; + var res = {}; + var props = [ + "createRequest", + "createCursor", + "fireError", + "fireSuccess", + "fireDone", + "fireDetailedError", + ]; + for (var i in props) { + let prop = props[i]; + res[prop] = function () { + return serv[prop].apply(serv, arguments); + }; + } + return Cu.cloneInto(res, this.contentWindow, { cloneFunctions: true }); + } + + addCategoryEntry(category, entry, value, persists, replace) { + Services.catMan.addCategoryEntry(category, entry, value, persists, replace); + } + + deleteCategoryEntry(category, entry, persists) { + Services.catMan.deleteCategoryEntry(category, entry, persists); + } + openDialog(win, args) { + return win.openDialog.apply(win, args); + } + // This is a blocking call which creates and spins a native event loop + spinEventLoop(win) { + // simply do a sync XHR back to our windows location. + var syncXHR = new win.XMLHttpRequest(); + syncXHR.open("GET", win.location, false); + syncXHR.send(); + } + + // :jdm gets credit for this. ex: getPrivilegedProps(window, 'location.href'); + getPrivilegedProps(obj, props) { + var parts = props.split("."); + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + if (obj[p] != undefined) { + obj = obj[p]; + } else { + return null; + } + } + return obj; + } + + _browsingContextForTarget(target) { + if (BrowsingContext.isInstance(target)) { + return target; + } + if (Element.isInstance(target)) { + return target.browsingContext; + } + + return BrowsingContext.getFromWindow(target); + } + + getBrowsingContextID(target) { + return this._browsingContextForTarget(target).id; + } + + *getGroupTopLevelWindows(target) { + let { group } = this._browsingContextForTarget(target); + for (let bc of group.getToplevels()) { + yield bc.window; + } + } + + /** + * Runs a task in the context of the given frame, and returns a + * promise which resolves to the return value of that task. + * + * The given frame may be in-process or out-of-process. Either way, + * the task will run asynchronously, in a sandbox with access to the + * frame's content window via its `content` global. Any arguments + * passed will be copied via structured clone, as will its return + * value. + * + * The sandbox also has access to an Assert object, as provided by + * Assert.sys.mjs. Any assertion methods called before the task resolves + * will be relayed back to the test environment of the caller. + * + * @param {BrowsingContext or FrameLoaderOwner or WindowProxy} target + * The target in which to run the task. This may be any element + * which implements the FrameLoaderOwner interface (including + * HTML <iframe> elements and XUL <browser> elements) or a + * WindowProxy (either in-process or remote). + * @param {Array<any>} args + * An array of arguments to pass to the task. All arguments + * must be structured clone compatible, and will be cloned + * before being passed to the task. + * @param {function} task + * The function to run in the context of the target. The + * function will be stringified and re-evaluated in the context + * of the target's content window. It may return any structured + * clone compatible value, or a Promise which resolves to the + * same, which will be returned to the caller. + * + * @returns {Promise<any>} + * A promise which resolves to the return value of the task, or + * which rejects if the task raises an exception. As this is + * being written, the rejection value will always be undefined + * in the cases where the task throws an error, though that may + * change in the future. + */ + spawn(target, args, task) { + let browsingContext = this._browsingContextForTarget(target); + + return this.sendQuery("Spawn", { + browsingContext, + args, + task: String(task), + caller: Cu.getFunctionSourceLocation(task), + hasHarness: + typeof this.SimpleTest === "object" || + typeof this.xpcshellScope === "object", + imports: this._spawnTaskImports, + }); + } + + /** + * Like `spawn`, but spawns a chrome task in the parent process, + * instead. The task additionally has access to `windowGlobalParent` + * and `browsingContext` globals corresponding to the window from + * which the task was spawned. + */ + spawnChrome(args, task) { + return this.sendQuery("SpawnChrome", { + args, + task: String(task), + caller: Cu.getFunctionSourceLocation(task), + imports: this._spawnTaskImports, + }); + } + + snapshotContext(target, rect, background, resetScrollPosition = false) { + let browsingContext = this._browsingContextForTarget(target); + + return this.sendQuery("Snapshot", { + browsingContext, + rect, + background, + resetScrollPosition, + }).then(imageData => { + return this.contentWindow.createImageBitmap(imageData); + }); + } + + getSecurityState(target) { + let browsingContext = this._browsingContextForTarget(target); + + return this.sendQuery("SecurityState", { + browsingContext, + }); + } + + _spawnTask(task, args, caller, taskId, imports) { + let sb = new lazy.SpecialPowersSandbox( + null, + data => { + this.sendAsyncMessage("ProxiedAssert", { taskId, data }); + }, + { imports } + ); + + sb.sandbox.SpecialPowers = this; + sb.sandbox.ContentTaskUtils = lazy.ContentTaskUtils; + for (let [global, prop] of Object.entries({ + content: "contentWindow", + docShell: "docShell", + })) { + Object.defineProperty(sb.sandbox, global, { + get: () => { + return this[prop]; + }, + enumerable: true, + }); + } + + return sb.execute(task, args, caller); + } + + /** + * Automatically imports the given symbol from the given sys.mjs for any + * task spawned by this SpecialPowers instance. + */ + addTaskImport(symbol, url) { + this._spawnTaskImports[symbol] = url; + } + + get SimpleTest() { + return this._SimpleTest || this.contentWindow.wrappedJSObject.SimpleTest; + } + set SimpleTest(val) { + this._SimpleTest = val; + } + + get xpcshellScope() { + return this._xpcshellScope; + } + set xpcshellScope(val) { + this._xpcshellScope = val; + } + + async evictAllDocumentViewers() { + if (Services.appinfo.sessionHistoryInParent) { + await this.sendQuery("EvictAllDocumentViewers"); + } else { + this.browsingContext.top.childSessionHistory.legacySHistory.evictAllDocumentViewers(); + } + } + + /** + * Sets this actor as the default assertion result handler for tasks + * which originate in a window without a test harness. + */ + setAsDefaultAssertHandler() { + this.sendAsyncMessage("SetAsDefaultAssertHandler"); + } + + getFocusedElementForWindow(targetWindow, aDeep) { + var outParam = {}; + Services.focus.getFocusedElementForWindow(targetWindow, aDeep, outParam); + return outParam.value; + } + + get focusManager() { + return Services.focus; + } + + activeWindow() { + return Services.focus.activeWindow; + } + + focusedWindow() { + return Services.focus.focusedWindow; + } + + clearFocus(aWindow) { + Services.focus.clearFocus(aWindow); + } + + focus(aWindow) { + // This is called inside TestRunner._makeIframe without aWindow, because of assertions in oop mochitests + // With aWindow, it is called in SimpleTest.waitForFocus to allow popup window opener focus switching + if (aWindow) { + aWindow.focus(); + } + + try { + let actor = aWindow + ? aWindow.windowGlobalChild.getActor("SpecialPowers") + : this; + actor.sendAsyncMessage("SpecialPowers.Focus", {}); + } catch (e) { + console.error(e); + } + } + + ensureFocus(aBrowsingContext, aBlurSubframe) { + return this.sendQuery("EnsureFocus", { + browsingContext: aBrowsingContext, + blurSubframe: aBlurSubframe, + }); + } + + getClipboardData(flavor, whichClipboard) { + if (whichClipboard === undefined) { + whichClipboard = Services.clipboard.kGlobalClipboard; + } + + var xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(this.docShell); + xferable.addDataFlavor(flavor); + Services.clipboard.getData( + xferable, + whichClipboard, + this.browsingContext.currentWindowContext + ); + var data = {}; + try { + xferable.getTransferData(flavor, data); + } catch (e) {} + data = data.value || null; + if (data == null) { + return ""; + } + + return data.QueryInterface(Ci.nsISupportsString).data; + } + + clipboardCopyString(str) { + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(str); + } + + supportsSelectionClipboard() { + return Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ); + } + + swapFactoryRegistration(cid, contractID, newFactory) { + newFactory = Cu.waiveXrays(newFactory); + + var componentRegistrar = Components.manager.QueryInterface( + Ci.nsIComponentRegistrar + ); + + var currentCID = componentRegistrar.contractIDToCID(contractID); + var currentFactory = Components.manager.getClassObject( + Cc[contractID], + Ci.nsIFactory + ); + if (cid) { + componentRegistrar.unregisterFactory(currentCID, currentFactory); + } else { + cid = Services.uuid.generateUUID(); + } + + // Restore the original factory. + componentRegistrar.registerFactory(cid, "", contractID, newFactory); + return { originalCID: currentCID }; + } + + _getElement(aWindow, id) { + return typeof id == "string" ? aWindow.document.getElementById(id) : id; + } + + dispatchEvent(aWindow, target, event) { + var el = this._getElement(aWindow, target); + return el.dispatchEvent(event); + } + + get isDebugBuild() { + return Cc["@mozilla.org/xpcom/debug;1"].getService(Ci.nsIDebug2) + .isDebugBuild; + } + assertionCount() { + var debugsvc = Cc["@mozilla.org/xpcom/debug;1"].getService(Ci.nsIDebug2); + return debugsvc.assertionCount; + } + + /** + * @param arg one of the following: + * - A URI string. + * - A document node. + * - A dictionary including a URL (`url`) and origin attributes (`attr`). + */ + _getPrincipalFromArg(arg) { + arg = lazy.WrapPrivileged.unwrap(Cu.unwaiveXrays(arg)); + + if (arg.nodePrincipal) { + // It's a document. + return arg.nodePrincipal; + } + + let secMan = Services.scriptSecurityManager; + if (typeof arg == "string") { + // It's a URL. + let uri = Services.io.newURI(arg); + return secMan.createContentPrincipal(uri, {}); + } + + let uri = Services.io.newURI(arg.url); + let attrs = arg.originAttributes || {}; + return secMan.createContentPrincipal(uri, attrs); + } + + async addPermission(type, allow, arg, expireType, expireTime) { + let principal = this._getPrincipalFromArg(arg); + if (principal.isSystemPrincipal) { + return; // nothing to do + } + + let permission = allow; + if (typeof permission === "boolean") { + permission = + Ci.nsIPermissionManager[allow ? "ALLOW_ACTION" : "DENY_ACTION"]; + } + + var msg = { + op: "add", + type, + permission, + principal, + expireType: typeof expireType === "number" ? expireType : 0, + expireTime: typeof expireTime === "number" ? expireTime : 0, + }; + + await this.sendQuery("SPPermissionManager", msg); + } + + /** + * @param type see nsIPermissionsManager::testPermissionFromPrincipal. + * @param arg one of the following: + * - A URI string. + * - A document node. + * - A dictionary including a URL (`url`) and origin attributes (`attr`). + */ + async removePermission(type, arg) { + let principal = this._getPrincipalFromArg(arg); + if (principal.isSystemPrincipal) { + return; // nothing to do + } + + var msg = { + op: "remove", + type, + principal, + }; + + await this.sendQuery("SPPermissionManager", msg); + } + + async hasPermission(type, arg) { + let principal = this._getPrincipalFromArg(arg); + if (principal.isSystemPrincipal) { + return true; // system principals have all permissions + } + + var msg = { + op: "has", + type, + principal, + }; + + return this.sendQuery("SPPermissionManager", msg); + } + + async testPermission(type, value, arg) { + let principal = this._getPrincipalFromArg(arg); + if (principal.isSystemPrincipal) { + return true; // system principals have all permissions + } + + var msg = { + op: "test", + type, + value, + principal, + }; + return this.sendQuery("SPPermissionManager", msg); + } + + isContentWindowPrivate(win) { + return lazy.PrivateBrowsingUtils.isContentWindowPrivate(win); + } + + async notifyObserversInParentProcess(subject, topic, data) { + if (subject) { + throw new Error("Can't send subject to another process!"); + } + if (this.isMainProcess()) { + this.notifyObservers(subject, topic, data); + return; + } + var msg = { + op: "notify", + observerTopic: topic, + observerData: data, + }; + await this.sendQuery("SPObserverService", msg); + } + + removeAllServiceWorkerData() { + return this.sendQuery("SPRemoveAllServiceWorkers", {}); + } + + removeServiceWorkerDataForExampleDomain() { + return this.sendQuery("SPRemoveServiceWorkerDataForExampleDomain", {}); + } + + cleanUpSTSData(origin, flags) { + return this.sendQuery("SPCleanUpSTSData", { origin }); + } + + async requestDumpCoverageCounters(cb) { + // We want to avoid a roundtrip between child and parent. + if (!lazy.PerTestCoverageUtils.enabled) { + return; + } + + await this.sendQuery("SPRequestDumpCoverageCounters", {}); + } + + async requestResetCoverageCounters(cb) { + // We want to avoid a roundtrip between child and parent. + if (!lazy.PerTestCoverageUtils.enabled) { + return; + } + await this.sendQuery("SPRequestResetCoverageCounters", {}); + } + + loadExtension(ext, handler) { + if (this._extensionListeners == null) { + this._extensionListeners = new Set(); + + this._addMessageListener("SPExtensionMessage", msg => { + for (let listener of this._extensionListeners) { + try { + listener(msg); + } catch (e) { + console.error(e); + } + } + }); + } + + // Note, this is not the addon is as used by the AddonManager etc, + // this is just an identifier used for specialpowers messaging + // between this content process and the chrome process. + let id = this._nextExtensionID++; + + handler = Cu.waiveXrays(handler); + ext = Cu.waiveXrays(ext); + + let sp = this; + let state = "uninitialized"; + let extension = { + get state() { + return state; + }, + + startup() { + state = "pending"; + return sp.sendQuery("SPStartupExtension", { id }).then( + () => { + state = "running"; + }, + () => { + state = "failed"; + sp._extensionListeners.delete(listener); + return Promise.reject("startup failed"); + } + ); + }, + + unload() { + state = "unloading"; + return sp.sendQuery("SPUnloadExtension", { id }).finally(() => { + sp._extensionListeners.delete(listener); + state = "unloaded"; + }); + }, + + sendMessage(...args) { + sp.sendAsyncMessage("SPExtensionMessage", { id, args }); + }, + + grantActiveTab(tabId) { + sp.sendAsyncMessage("SPExtensionGrantActiveTab", { id, tabId }); + }, + + terminateBackground(...args) { + return sp.sendQuery("SPExtensionTerminateBackground", { id, args }); + }, + + wakeupBackground() { + return sp.sendQuery("SPExtensionWakeupBackground", { id }); + }, + }; + + this.sendAsyncMessage("SPLoadExtension", { ext, id }); + + let listener = msg => { + if (msg.data.id == id) { + if (msg.data.type == "extensionSetId") { + extension.id = msg.data.args[0]; + extension.uuid = msg.data.args[1]; + } else if (msg.data.type in handler) { + handler[msg.data.type]( + ...Cu.cloneInto(msg.data.args, this.contentWindow) + ); + } else { + dump(`Unexpected: ${msg.data.type}\n`); + } + } + }; + + this._extensionListeners.add(listener); + return extension; + } + + invalidateExtensionStorageCache() { + this.notifyObserversInParentProcess( + null, + "extension-invalidate-storage-cache", + "" + ); + } + + allowMedia(window, enable) { + window.docShell.allowMedia = enable; + } + + createChromeCache(name, url) { + let principal = this._getPrincipalFromArg(url); + return new this.contentWindow.CacheStorage(name, principal); + } + + loadChannelAndReturnStatus(url, loadUsingSystemPrincipal) { + const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" + ); + + return new Promise(function (resolve) { + let listener = { + httpStatus: 0, + + onStartRequest(request) { + request.QueryInterface(Ci.nsIHttpChannel); + this.httpStatus = request.responseStatus; + }, + + onDataAvailable(request, stream, offset, count) { + new BinaryInputStream(stream).readByteArray(count); + }, + + onStopRequest(request, status) { + /* testing here that the redirect was not followed. If it was followed + we would see a http status of 200 and status of NS_OK */ + + let httpStatus = this.httpStatus; + resolve({ status, httpStatus }); + }, + }; + let uri = lazy.NetUtil.newURI(url); + let channel = lazy.NetUtil.newChannel({ uri, loadUsingSystemPrincipal }); + + channel.loadFlags |= Ci.nsIChannel.LOAD_DOCUMENT_URI; + channel.QueryInterface(Ci.nsIHttpChannelInternal); + channel.documentURI = uri; + channel.asyncOpen(listener); + }); + } + + get ParserUtils() { + if (this._pu != null) { + return this._pu; + } + + let pu = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils); + // We need to create and return our own wrapper. + this._pu = { + sanitize(src, flags) { + return pu.sanitize(src, flags); + }, + convertToPlainText(src, flags, wrapCol) { + return pu.convertToPlainText(src, flags, wrapCol); + }, + parseFragment(fragment, flags, isXML, baseURL, element) { + let baseURI = baseURL ? lazy.NetUtil.newURI(baseURL) : null; + return pu.parseFragment( + lazy.WrapPrivileged.unwrap(fragment), + flags, + isXML, + baseURI, + lazy.WrapPrivileged.unwrap(element) + ); + }, + }; + return this._pu; + } + + createDOMWalker(node, showAnonymousContent) { + node = lazy.WrapPrivileged.unwrap(node); + let walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"].createInstance( + Ci.inIDeepTreeWalker + ); + walker.showAnonymousContent = showAnonymousContent; + walker.init(node.ownerDocument, NodeFilter.SHOW_ALL); + walker.currentNode = node; + let contentWindow = this.contentWindow; + return { + get firstChild() { + return lazy.WrapPrivileged.wrap(walker.firstChild(), contentWindow); + }, + get lastChild() { + return lazy.WrapPrivileged.wrap(walker.lastChild(), contentWindow); + }, + }; + } + + /** + * Which commands are available can be determined by checking which commands + * are registered. See \ref + * nsIControllerCommandTable.registerCommand(in String, in nsIControllerCommand). + */ + doCommand(window, cmd, param) { + switch (cmd) { + case "cmd_align": + case "cmd_backgroundColor": + case "cmd_fontColor": + case "cmd_fontFace": + case "cmd_fontSize": + case "cmd_highlight": + case "cmd_insertImageNoUI": + case "cmd_insertLinkNoUI": + case "cmd_paragraphState": { + const params = Cu.createCommandParams(); + params.setStringValue("state_attribute", param); + return window.docShell.doCommandWithParams(cmd, params); + } + case "cmd_pasteTransferable": { + const params = Cu.createCommandParams(); + params.setISupportsValue("transferable", param); + return window.docShell.doCommandWithParams(cmd, params); + } + default: + return window.docShell.doCommand(cmd); + } + } + + isCommandEnabled(window, cmd) { + return window.docShell.isCommandEnabled(cmd); + } + + /** + * See \ref nsIDocumentViewerEdit.setCommandNode(in Node). + */ + setCommandNode(window, node) { + return window.docShell.docViewer + .QueryInterface(Ci.nsIDocumentViewerEdit) + .setCommandNode(node); + } + + /* Bug 1339006 Runnables of nsIURIClassifier.classify may be labeled by + * SystemGroup, but some test cases may run as web content. That would assert + * when trying to enter web content from a runnable labeled by the + * SystemGroup. To avoid that, we run classify from SpecialPowers which is + * chrome-privileged and allowed to run inside SystemGroup + */ + + doUrlClassify(principal, callback) { + let classifierService = Cc[ + "@mozilla.org/url-classifier/dbservice;1" + ].getService(Ci.nsIURIClassifier); + + let wrapCallback = (...args) => { + Services.tm.dispatchToMainThread(() => { + if (typeof callback == "function") { + callback(...args); + } else { + callback.onClassifyComplete.call(undefined, ...args); + } + }); + }; + + return classifierService.classify( + lazy.WrapPrivileged.unwrap(principal), + wrapCallback + ); + } + + // TODO: Bug 1353701 - Supports custom event target for labelling. + doUrlClassifyLocal(uri, tables, callback) { + let classifierService = Cc[ + "@mozilla.org/url-classifier/dbservice;1" + ].getService(Ci.nsIURIClassifier); + + let wrapCallback = results => { + Services.tm.dispatchToMainThread(() => { + if (typeof callback == "function") { + callback(lazy.WrapPrivileged.wrap(results, this.contentWindow)); + } else { + callback.onClassifyComplete.call( + undefined, + lazy.WrapPrivileged.wrap(results, this.contentWindow) + ); + } + }); + }; + + let feature = classifierService.createFeatureWithTables( + "test", + tables.split(","), + [] + ); + return classifierService.asyncClassifyLocalWithFeatures( + lazy.WrapPrivileged.unwrap(uri), + [feature], + Ci.nsIUrlClassifierFeature.blocklist, + wrapCallback + ); + } + + /* Content processes asynchronously receive child-to-parent transformations + * when they are launched. Until they are received, screen coordinates + * reported to JS are wrong. This is generally ok. It behaves as if the + * user repositioned the window. But if we want to test screen coordinates, + * we need to wait for the updated data. + */ + contentTransformsReceived(win) { + while (win) { + try { + return win.docShell.browserChild.contentTransformsReceived(); + } catch (ex) { + // browserChild getter throws on non-e10s rather than returning null. + } + if (win == win.parent) { + break; + } + win = win.parent; + } + return Promise.resolve(); + } +} + +SpecialPowersChild.prototype._proxiedObservers = { + "specialpowers-http-notify-request": function (aMessage) { + let uri = aMessage.json.uri; + Services.obs.notifyObservers( + null, + "specialpowers-http-notify-request", + uri + ); + }, + + "specialpowers-service-worker-shutdown": function (aMessage) { + Services.obs.notifyObservers(null, "specialpowers-service-worker-shutdown"); + }, + + "specialpowers-csp-on-violate-policy": function (aMessage) { + let subject = null; + + try { + subject = Services.io.newURI(aMessage.data.subject); + } catch (ex) { + // if it's not a valid URI it must be an nsISupportsCString + subject = Cc["@mozilla.org/supports-cstring;1"].createInstance( + Ci.nsISupportsCString + ); + subject.data = aMessage.data.subject; + } + Services.obs.notifyObservers( + subject, + "specialpowers-csp-on-violate-policy", + aMessage.data.data + ); + }, + + "specialpowers-xfo-on-violate-policy": function (aMessage) { + let subject = Services.io.newURI(aMessage.data.subject); + Services.obs.notifyObservers( + subject, + "specialpowers-xfo-on-violate-policy", + aMessage.data.data + ); + }, +}; + +SpecialPowersChild.prototype.EARLY_BETA_OR_EARLIER = + AppConstants.EARLY_BETA_OR_EARLIER; diff --git a/testing/specialpowers/content/SpecialPowersEventUtils.sys.mjs b/testing/specialpowers/content/SpecialPowersEventUtils.sys.mjs new file mode 100644 index 0000000000..68cec2b6e3 --- /dev/null +++ b/testing/specialpowers/content/SpecialPowersEventUtils.sys.mjs @@ -0,0 +1,35 @@ +/* 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/. */ + +/** + * Loads a stub copy of EventUtils.js which can be used by things like + * content tasks without holding any direct references to windows. + */ + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +export let EventUtils = { setTimeout, window: {}, _EU_Ci: Ci, _EU_Cc: Cc }; + +EventUtils.parent = EventUtils.window; + +EventUtils.synthesizeClick = element => + new Promise(resolve => { + element.addEventListener("click", () => resolve(), { once: true }); + + EventUtils.synthesizeMouseAtCenter( + element, + { type: "mousedown", isSynthesized: false }, + element.ownerGlobal + ); + EventUtils.synthesizeMouseAtCenter( + element, + { type: "mouseup", isSynthesized: false }, + element.ownerGlobal + ); + }); + +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + EventUtils +); diff --git a/testing/specialpowers/content/SpecialPowersParent.sys.mjs b/testing/specialpowers/content/SpecialPowersParent.sys.mjs new file mode 100644 index 0000000000..3ed660d71c --- /dev/null +++ b/testing/specialpowers/content/SpecialPowersParent.sys.mjs @@ -0,0 +1,1470 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + HiddenFrame: "resource://gre/modules/HiddenFrame.sys.mjs", + PerTestCoverageUtils: + "resource://testing-common/PerTestCoverageUtils.sys.mjs", + ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.sys.mjs", + SpecialPowersSandbox: + "resource://testing-common/SpecialPowersSandbox.sys.mjs", +}); + +class SpecialPowersError extends Error { + get name() { + return "SpecialPowersError"; + } +} + +const PREF_TYPES = { + [Ci.nsIPrefBranch.PREF_INVALID]: "INVALID", + [Ci.nsIPrefBranch.PREF_INT]: "INT", + [Ci.nsIPrefBranch.PREF_BOOL]: "BOOL", + [Ci.nsIPrefBranch.PREF_STRING]: "STRING", + number: "INT", + boolean: "BOOL", + string: "STRING", +}; + +// We share a single preference environment stack between all +// SpecialPowers instances, across all processes. +let prefUndoStack = []; +let inPrefEnvOp = false; + +let permissionUndoStack = []; + +function doPrefEnvOp(fn) { + if (inPrefEnvOp) { + throw new Error( + "Reentrant preference environment operations not supported" + ); + } + inPrefEnvOp = true; + try { + return fn(); + } finally { + inPrefEnvOp = false; + } +} + +async function createWindowlessBrowser({ isPrivate = false } = {}) { + const { promiseDocumentLoaded, promiseEvent, promiseObserved } = + ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" + ).ExtensionUtils; + + let windowlessBrowser = Services.appShell.createWindowlessBrowser(true); + + if (isPrivate) { + let loadContext = windowlessBrowser.docShell.QueryInterface( + Ci.nsILoadContext + ); + loadContext.usePrivateBrowsing = true; + } + + let chromeShell = windowlessBrowser.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + + const system = Services.scriptSecurityManager.getSystemPrincipal(); + chromeShell.createAboutBlankDocumentViewer(system, system); + windowlessBrowser.browsingContext.useGlobalHistory = false; + chromeShell.loadURI( + Services.io.newURI("chrome://extensions/content/dummy.xhtml"), + { + triggeringPrincipal: system, + } + ); + + await promiseObserved( + "chrome-document-global-created", + win => win.document == chromeShell.document + ); + + let chromeDoc = await promiseDocumentLoaded(chromeShell.document); + + let browser = chromeDoc.createXULElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("remote", "true"); + + let promise = promiseEvent(browser, "XULFrameLoaderCreated"); + chromeDoc.documentElement.appendChild(browser); + + await promise; + + return { windowlessBrowser, browser }; +} + +// Supplies the unique IDs for tasks created by SpecialPowers.spawn(), +// used to bounce assertion messages back down to the correct child. +let nextTaskID = 1; + +// The default actor to send assertions to if a task originated in a +// window without a test harness. +let defaultAssertHandler; + +export class SpecialPowersParent extends JSWindowActorParent { + constructor() { + super(); + + this._messageManager = Services.mm; + this._serviceWorkerListener = null; + + this._observer = this.observe.bind(this); + + this.didDestroy = this.uninit.bind(this); + + this._registerObservers = { + _self: this, + _topics: [], + _add(topic) { + if (!this._topics.includes(topic)) { + this._topics.push(topic); + Services.obs.addObserver(this, topic); + } + }, + observe(aSubject, aTopic, aData) { + var msg = { aData }; + switch (aTopic) { + case "csp-on-violate-policy": + // the subject is either an nsIURI or an nsISupportsCString + let subject = null; + if (aSubject instanceof Ci.nsIURI) { + subject = aSubject.asciiSpec; + } else if (aSubject instanceof Ci.nsISupportsCString) { + subject = aSubject.data; + } else { + throw new Error("Subject must be nsIURI or nsISupportsCString"); + } + msg = { + subject, + data: aData, + }; + this._self.sendAsyncMessage("specialpowers-" + aTopic, msg); + return; + case "xfo-on-violate-policy": + let uriSpec = null; + if (aSubject instanceof Ci.nsIURI) { + uriSpec = aSubject.asciiSpec; + } else { + throw new Error("Subject must be nsIURI"); + } + msg = { + subject: uriSpec, + data: aData, + }; + this._self.sendAsyncMessage("specialpowers-" + aTopic, msg); + return; + default: + this._self.sendAsyncMessage("specialpowers-" + aTopic, msg); + } + }, + }; + + this._basePrefs = null; + this.init(); + + this._crashDumpDir = null; + this._processCrashObserversRegistered = false; + this._chromeScriptListeners = []; + this._extensions = new Map(); + this._taskActors = new Map(); + } + + static registerActor() { + ChromeUtils.registerWindowActor("SpecialPowers", { + allFrames: true, + includeChrome: true, + child: { + esModuleURI: "resource://testing-common/SpecialPowersChild.sys.mjs", + observers: [ + "chrome-document-global-created", + "content-document-global-created", + ], + }, + parent: { + esModuleURI: "resource://testing-common/SpecialPowersParent.sys.mjs", + }, + }); + } + + static unregisterActor() { + ChromeUtils.unregisterWindowActor("SpecialPowers"); + } + + init() { + Services.obs.addObserver(this._observer, "http-on-modify-request"); + + // We would like to check that tests don't leave service workers around + // after they finish, but that information lives in the parent process. + // Ideally, we'd be able to tell the child processes whenever service + // workers are registered or unregistered so they would know at all times, + // but service worker lifetimes are complicated enough to make that + // difficult. For the time being, let the child process know when a test + // registers a service worker so it can ask, synchronously, at the end if + // the service worker had unregister called on it. + let swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + let self = this; + this._serviceWorkerListener = { + onRegister() { + self.onRegister(); + }, + + onUnregister() { + // no-op + }, + }; + swm.addListener(this._serviceWorkerListener); + + this.getBaselinePrefs(); + } + + uninit() { + if (defaultAssertHandler === this) { + defaultAssertHandler = null; + } + + var obs = Services.obs; + obs.removeObserver(this._observer, "http-on-modify-request"); + this._registerObservers._topics.splice(0).forEach(element => { + obs.removeObserver(this._registerObservers, element); + }); + this._removeProcessCrashObservers(); + + let swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + swm.removeListener(this._serviceWorkerListener); + } + + observe(aSubject, aTopic, aData) { + function addDumpIDToMessage(propertyName) { + try { + var id = aSubject.getPropertyAsAString(propertyName); + } catch (ex) { + id = null; + } + if (id) { + message.dumpIDs.push({ id, extension: "dmp" }); + message.dumpIDs.push({ id, extension: "extra" }); + } + } + + switch (aTopic) { + case "http-on-modify-request": + if (aSubject instanceof Ci.nsIChannel) { + let uri = aSubject.URI.spec; + this.sendAsyncMessage("specialpowers-http-notify-request", { uri }); + } + break; + + case "ipc:content-shutdown": + aSubject = aSubject.QueryInterface(Ci.nsIPropertyBag2); + if (!aSubject.hasKey("abnormal")) { + return; // This is a normal shutdown, ignore it + } + + var message = { type: "crash-observed", dumpIDs: [] }; + addDumpIDToMessage("dumpID"); + this.sendAsyncMessage("SPProcessCrashService", message); + break; + } + } + + _getCrashDumpDir() { + if (!this._crashDumpDir) { + this._crashDumpDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + this._crashDumpDir.append("minidumps"); + } + return this._crashDumpDir; + } + + _getPendingCrashDumpDir() { + if (!this._pendingCrashDumpDir) { + this._pendingCrashDumpDir = Services.dirsvc.get("UAppData", Ci.nsIFile); + this._pendingCrashDumpDir.append("Crash Reports"); + this._pendingCrashDumpDir.append("pending"); + } + return this._pendingCrashDumpDir; + } + + _deleteCrashDumpFiles(aFilenames) { + var crashDumpDir = this._getCrashDumpDir(); + if (!crashDumpDir.exists()) { + return false; + } + + var success = !!aFilenames.length; + aFilenames.forEach(function (crashFilename) { + var file = crashDumpDir.clone(); + file.append(crashFilename); + if (file.exists()) { + file.remove(false); + } else { + success = false; + } + }); + return success; + } + + _findCrashDumpFiles(aToIgnore) { + var crashDumpDir = this._getCrashDumpDir(); + var entries = crashDumpDir.exists() && crashDumpDir.directoryEntries; + if (!entries) { + return []; + } + + var crashDumpFiles = []; + while (entries.hasMoreElements()) { + var file = entries.nextFile; + var path = String(file.path); + if (path.match(/\.(dmp|extra)$/) && !aToIgnore[path]) { + crashDumpFiles.push(path); + } + } + return crashDumpFiles.concat(); + } + + _deletePendingCrashDumpFiles() { + var crashDumpDir = this._getPendingCrashDumpDir(); + var removed = false; + if (crashDumpDir.exists()) { + let entries = crashDumpDir.directoryEntries; + while (entries.hasMoreElements()) { + let file = entries.nextFile; + if (file.isFile()) { + file.remove(false); + removed = true; + } + } + } + return removed; + } + + _addProcessCrashObservers() { + if (this._processCrashObserversRegistered) { + return; + } + + Services.obs.addObserver(this._observer, "ipc:content-shutdown"); + this._processCrashObserversRegistered = true; + } + + _removeProcessCrashObservers() { + if (!this._processCrashObserversRegistered) { + return; + } + + Services.obs.removeObserver(this._observer, "ipc:content-shutdown"); + this._processCrashObserversRegistered = false; + } + + onRegister() { + this.sendAsyncMessage("SPServiceWorkerRegistered", { registered: true }); + } + + _getURI(url) { + return Services.io.newURI(url); + } + _notifyCategoryAndObservers(subject, topic, data) { + const serviceMarker = "service,"; + + // First create observers from the category manager. + + let observers = []; + + for (let { value: contractID } of Services.catMan.enumerateCategory( + topic + )) { + let factoryFunction; + if (contractID.substring(0, serviceMarker.length) == serviceMarker) { + contractID = contractID.substring(serviceMarker.length); + factoryFunction = "getService"; + } else { + factoryFunction = "createInstance"; + } + + try { + let handler = Cc[contractID][factoryFunction](); + if (handler) { + let observer = handler.QueryInterface(Ci.nsIObserver); + observers.push(observer); + } + } catch (e) {} + } + + // Next enumerate the registered observers. + for (let observer of Services.obs.enumerateObservers(topic)) { + if (observer instanceof Ci.nsIObserver && !observers.includes(observer)) { + observers.push(observer); + } + } + + observers.forEach(function (observer) { + try { + observer.observe(subject, topic, data); + } catch (e) {} + }); + } + + /* + Iterate through one atomic set of pref actions and perform sets/clears as appropriate. + All actions performed must modify the relevant pref. + + Returns whether we need to wait for a refresh driver tick for the pref to + have effect. This is only needed for ui. and font. prefs, which affect the + look and feel code and have some change-coalescing going on. + */ + _applyPrefs(actions) { + let requiresRefresh = false; + for (let pref of actions) { + // This logic should match PrefRequiresRefresh in reftest.sys.mjs + requiresRefresh = + requiresRefresh || + pref.name == "layout.css.prefers-color-scheme.content-override" || + pref.name.startsWith("ui.") || + pref.name.startsWith("browser.display.") || + pref.name.startsWith("font."); + if (pref.action == "set") { + this._setPref(pref.name, pref.type, pref.value, pref.iid); + } else if (pref.action == "clear") { + Services.prefs.clearUserPref(pref.name); + } + } + return requiresRefresh; + } + + /** + * Take in a list of pref changes to make, pushes their current values + * onto the restore stack, and makes the changes. When the test + * finishes, these changes are reverted. + * + * |inPrefs| must be an object with up to two properties: "set" and "clear". + * pushPrefEnv will set prefs as indicated in |inPrefs.set| and will unset + * the prefs indicated in |inPrefs.clear|. + * + * For example, you might pass |inPrefs| as: + * + * inPrefs = {'set': [['foo.bar', 2], ['magic.pref', 'baz']], + * 'clear': [['clear.this'], ['also.this']] }; + * + * Notice that |set| and |clear| are both an array of arrays. In |set|, each + * of the inner arrays must have the form [pref_name, value] or [pref_name, + * value, iid]. (The latter form is used for prefs with "complex" values.) + * + * In |clear|, each inner array should have the form [pref_name]. + * + * If you set the same pref more than once (or both set and clear a pref), + * the behavior of this method is undefined. + */ + pushPrefEnv(inPrefs) { + return doPrefEnvOp(() => { + let pendingActions = []; + let cleanupActions = []; + + for (let [action, prefs] of Object.entries(inPrefs)) { + for (let pref of prefs) { + let name = pref[0]; + let value = null; + let iid = null; + let type = PREF_TYPES[Services.prefs.getPrefType(name)]; + let originalValue = null; + + if (pref.length == 3) { + value = pref[1]; + iid = pref[2]; + } else if (pref.length == 2) { + value = pref[1]; + } + + /* If pref is not found or invalid it doesn't exist. */ + if (type !== "INVALID") { + if ( + (Services.prefs.prefHasUserValue(name) && action == "clear") || + action == "set" + ) { + originalValue = this._getPref(name, type); + } + } else if (action == "set") { + /* name doesn't exist, so 'clear' is pointless */ + if (iid) { + type = "COMPLEX"; + } + } + + if (type === "INVALID") { + type = PREF_TYPES[typeof value]; + } + if (type === "INVALID") { + throw new Error("Unexpected preference type for " + name); + } + + pendingActions.push({ action, type, name, value, iid }); + + /* Push original preference value or clear into cleanup array */ + var cleanupTodo = { type, name, value: originalValue, iid }; + if (originalValue == null) { + cleanupTodo.action = "clear"; + } else { + cleanupTodo.action = "set"; + } + cleanupActions.push(cleanupTodo); + } + } + + prefUndoStack.push(cleanupActions); + let requiresRefresh = this._applyPrefs(pendingActions); + return { requiresRefresh }; + }); + } + + async popPrefEnv() { + return doPrefEnvOp(() => { + let env = prefUndoStack.pop(); + if (env) { + let requiresRefresh = this._applyPrefs(env); + return { popped: true, requiresRefresh }; + } + return { popped: false, requiresRefresh: false }; + }); + } + + flushPrefEnv() { + let requiresRefresh = false; + while (prefUndoStack.length) { + requiresRefresh |= this.popPrefEnv().requiresRefresh; + } + return { requiresRefresh }; + } + + _setPref(name, type, value, iid) { + switch (type) { + case "BOOL": + return Services.prefs.setBoolPref(name, value); + case "INT": + return Services.prefs.setIntPref(name, value); + case "CHAR": + return Services.prefs.setCharPref(name, value); + case "COMPLEX": + return Services.prefs.setComplexValue(name, iid, value); + case "STRING": + return Services.prefs.setStringPref(name, value); + } + switch (typeof value) { + case "boolean": + return Services.prefs.setBoolPref(name, value); + case "number": + return Services.prefs.setIntPref(name, value); + case "string": + return Services.prefs.setStringPref(name, value); + } + throw new Error( + `Unexpected preference type: ${type} for ${name} with value ${value} and type ${typeof value}` + ); + } + + _getPref(name, type, defaultValue, iid) { + switch (type) { + case "BOOL": + if (defaultValue !== undefined) { + return Services.prefs.getBoolPref(name, defaultValue); + } + return Services.prefs.getBoolPref(name); + case "INT": + if (defaultValue !== undefined) { + return Services.prefs.getIntPref(name, defaultValue); + } + return Services.prefs.getIntPref(name); + case "CHAR": + if (defaultValue !== undefined) { + return Services.prefs.getCharPref(name, defaultValue); + } + return Services.prefs.getCharPref(name); + case "COMPLEX": + return Services.prefs.getComplexValue(name, iid); + case "STRING": + if (defaultValue !== undefined) { + return Services.prefs.getStringPref(name, defaultValue); + } + return Services.prefs.getStringPref(name); + } + throw new Error( + `Unexpected preference type: ${type} for preference ${name}` + ); + } + + getBaselinePrefs() { + this._basePrefs = this._getAllPreferences(); + } + + _comparePrefs(base, target, ignorePrefs, partialMatches) { + let failures = []; + for (const [key, value] of base) { + if (ignorePrefs.includes(key)) { + continue; + } + let partialFind = false; + partialMatches.forEach(pm => { + if (key.startsWith(pm)) { + partialFind = true; + } + }); + if (partialFind) { + continue; + } + + if (value === target.get(key)) { + continue; + } + if (!failures.includes(key)) { + failures.push(key); + } + } + return failures; + } + + comparePrefsToBaseline(ignorePrefs) { + let newPrefs = this._getAllPreferences(); + + // find all items in ignorePrefs that end in *, add to partialMatch + let partialMatch = []; + if (ignorePrefs === undefined) { + ignorePrefs = []; + } + ignorePrefs.forEach(pref => { + if (pref.endsWith("*")) { + partialMatch.push(pref.split("*")[0]); + } + }); + + // find all new prefs different than old + let rv1 = this._comparePrefs( + newPrefs, + this._basePrefs, + ignorePrefs, + partialMatch + ); + + // find all old prefs different than new (in case we delete) + let rv2 = this._comparePrefs( + this._basePrefs, + newPrefs, + ignorePrefs, + partialMatch + ); + + let failures = [...new Set([...rv1, ...rv2])]; + + // reset failures + failures.forEach(f => { + if (this._basePrefs.get(f)) { + this._setPref( + f, + PREF_TYPES[Services.prefs.getPrefType(f)], + this._basePrefs.get(f) + ); + } else { + Services.prefs.clearUserPref(f); + } + }); + + if (ignorePrefs.length > 1) { + return failures; + } + return []; + } + + _getAllPreferences() { + let names = new Map(); + for (let prefName of Services.prefs.getChildList("")) { + let prefType = PREF_TYPES[Services.prefs.getPrefType(prefName)]; + let prefValue = this._getPref(prefName, prefType); + names.set(prefName, prefValue); + } + return names; + } + + _toggleMuteAudio(aMuted) { + let browser = this.browsingContext.top.embedderElement; + if (aMuted) { + browser.mute(); + } else { + browser.unmute(); + } + } + + _permOp(perm) { + switch (perm.op) { + case "add": + Services.perms.addFromPrincipal( + perm.principal, + perm.type, + perm.permission, + perm.expireType, + perm.expireTime + ); + break; + case "remove": + Services.perms.removeFromPrincipal(perm.principal, perm.type); + break; + default: + throw new Error(`Unexpected permission op: ${perm.op}`); + } + } + + pushPermissions(inPermissions) { + let pendingPermissions = []; + let cleanupPermissions = []; + + for (let permission of inPermissions) { + let { principal } = permission; + if (principal.isSystemPrincipal) { + continue; + } + + let originalValue = Services.perms.testPermissionFromPrincipal( + principal, + permission.type + ); + + let perm = permission.allow; + if (typeof perm === "boolean") { + perm = Ci.nsIPermissionManager[perm ? "ALLOW_ACTION" : "DENY_ACTION"]; + } + + if (permission.remove) { + perm = Ci.nsIPermissionManager.UNKNOWN_ACTION; + } + + if (originalValue == perm) { + continue; + } + + let todo = { + op: "add", + type: permission.type, + permission: perm, + value: perm, + principal, + expireType: + typeof permission.expireType === "number" ? permission.expireType : 0, // default: EXPIRE_NEVER + expireTime: + typeof permission.expireTime === "number" ? permission.expireTime : 0, + }; + + var cleanupTodo = Object.assign({}, todo); + + if (permission.remove) { + todo.op = "remove"; + } + + pendingPermissions.push(todo); + + if (originalValue == Ci.nsIPermissionManager.UNKNOWN_ACTION) { + cleanupTodo.op = "remove"; + } else { + cleanupTodo.value = originalValue; + cleanupTodo.permission = originalValue; + } + cleanupPermissions.push(cleanupTodo); + } + + permissionUndoStack.push(cleanupPermissions); + + for (let perm of pendingPermissions) { + this._permOp(perm); + } + } + + popPermissions() { + if (permissionUndoStack.length) { + for (let perm of permissionUndoStack.pop()) { + this._permOp(perm); + } + } + } + + flushPermissions() { + while (permissionUndoStack.length) { + this.popPermissions(); + } + } + + _spawnChrome(task, args, caller, imports) { + let sb = new lazy.SpecialPowersSandbox( + null, + data => { + this.sendAsyncMessage("Assert", data); + }, + { imports } + ); + + for (let [global, prop] of Object.entries({ + windowGlobalParent: "manager", + browsingContext: "browsingContext", + })) { + Object.defineProperty(sb.sandbox, global, { + get: () => { + return this[prop]; + }, + enumerable: true, + }); + } + + return sb.execute(task, args, caller); + } + + /** + * messageManager callback function + * This will get requests from our API in the window and process them in chrome for it + **/ + // eslint-disable-next-line complexity + async receiveMessage(aMessage) { + let startTime = Cu.now(); + // Try block so we can use a finally statement to add a profiler marker + // despite all the return statements. + try { + // We explicitly return values in the below code so that this function + // doesn't trigger a flurry of warnings about "does not always return + // a value". + switch (aMessage.name) { + case "SPToggleMuteAudio": + return this._toggleMuteAudio(aMessage.data.mute); + + case "Ping": + return undefined; + + case "SpecialPowers.Quit": + if ( + !AppConstants.RELEASE_OR_BETA && + !AppConstants.DEBUG && + !AppConstants.MOZ_CODE_COVERAGE && + !AppConstants.ASAN && + !AppConstants.TSAN + ) { + if (Services.profiler.IsActive()) { + let filename = Services.env.get("MOZ_PROFILER_SHUTDOWN"); + if (filename) { + await Services.profiler.dumpProfileToFileAsync(filename); + await Services.profiler.StopProfiler(); + } + } + Cu.exitIfInAutomation(); + } else { + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + return undefined; + + case "EnsureFocus": + let bc = aMessage.data.browsingContext; + // Send a message to the child telling it to focus the window. + // If the message responds with a browsing context, then + // a child browsing context in a subframe should be focused. + // Iterate until nothing is returned and we get to the most + // deeply nested subframe that should be focused. + do { + let spParent = bc.currentWindowGlobal.getActor("SpecialPowers"); + if (spParent) { + bc = await spParent.sendQuery("EnsureFocus", { + blurSubframe: aMessage.data.blurSubframe, + }); + } + } while (bc && !aMessage.data.blurSubframe); + return undefined; + + case "SpecialPowers.Focus": + if (this.manager.rootFrameLoader) { + this.manager.rootFrameLoader.ownerElement.focus(); + } + return undefined; + + case "SpecialPowers.CreateFiles": + return (async () => { + let filePaths = []; + if (!this._createdFiles) { + this._createdFiles = []; + } + let createdFiles = this._createdFiles; + + let promises = []; + aMessage.data.forEach(function (request) { + const filePerms = 0o666; + let testFile = Services.dirsvc.get("ProfD", Ci.nsIFile); + if (request.name) { + testFile.appendRelativePath(request.name); + } else { + testFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, filePerms); + } + let outStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + outStream.init( + testFile, + 0x02 | 0x08 | 0x20, // PR_WRONLY | PR_CREATE_FILE | PR_TRUNCATE + filePerms, + 0 + ); + if (request.data) { + outStream.write(request.data, request.data.length); + } + outStream.close(); + promises.push( + File.createFromFileName(testFile.path, request.options).then( + function (file) { + filePaths.push(file); + } + ) + ); + createdFiles.push(testFile); + }); + + await Promise.all(promises); + return filePaths; + })().catch(e => { + console.error(e); + return Promise.reject(String(e)); + }); + + case "SpecialPowers.RemoveFiles": + if (this._createdFiles) { + this._createdFiles.forEach(function (testFile) { + try { + testFile.remove(false); + } catch (e) {} + }); + this._createdFiles = null; + } + return undefined; + + case "Wakeup": + return undefined; + + case "EvictAllDocumentViewers": + this.browsingContext.top.sessionHistory.evictAllDocumentViewers(); + return undefined; + + case "getBaselinePrefs": + return this.getBaselinePrefs(); + + case "comparePrefsToBaseline": + return this.comparePrefsToBaseline(aMessage.data); + + case "PushPrefEnv": + return this.pushPrefEnv(aMessage.data); + + case "PopPrefEnv": + return this.popPrefEnv(); + + case "FlushPrefEnv": + return this.flushPrefEnv(); + + case "PushPermissions": + return this.pushPermissions(aMessage.data); + + case "PopPermissions": + return this.popPermissions(); + + case "FlushPermissions": + return this.flushPermissions(); + + case "SPPrefService": { + let prefs = Services.prefs; + let prefType = aMessage.json.prefType.toUpperCase(); + let { prefName, prefValue, iid, defaultValue } = aMessage.json; + + if (aMessage.json.op == "get") { + if (!prefName || !prefType) { + throw new SpecialPowersError( + "Invalid parameters for get in SPPrefService" + ); + } + + // return null if the pref doesn't exist + if ( + defaultValue === undefined && + prefs.getPrefType(prefName) == prefs.PREF_INVALID + ) { + return null; + } + return this._getPref(prefName, prefType, defaultValue, iid); + } else if (aMessage.json.op == "set") { + if (!prefName || !prefType || prefValue === undefined) { + throw new SpecialPowersError( + "Invalid parameters for set in SPPrefService" + ); + } + + return this._setPref(prefName, prefType, prefValue, iid); + } else if (aMessage.json.op == "clear") { + if (!prefName) { + throw new SpecialPowersError( + "Invalid parameters for clear in SPPrefService" + ); + } + + prefs.clearUserPref(prefName); + } else { + throw new SpecialPowersError("Invalid operation for SPPrefService"); + } + + return undefined; // See comment at the beginning of this function. + } + + case "SPProcessCrashService": { + switch (aMessage.json.op) { + case "register-observer": + this._addProcessCrashObservers(); + break; + case "unregister-observer": + this._removeProcessCrashObservers(); + break; + case "delete-crash-dump-files": + return this._deleteCrashDumpFiles(aMessage.json.filenames); + case "find-crash-dump-files": + return this._findCrashDumpFiles( + aMessage.json.crashDumpFilesToIgnore + ); + case "delete-pending-crash-dump-files": + return this._deletePendingCrashDumpFiles(); + default: + throw new SpecialPowersError( + "Invalid operation for SPProcessCrashService" + ); + } + return undefined; // See comment at the beginning of this function. + } + + case "SPProcessCrashManagerWait": { + let promises = aMessage.json.crashIds.map(crashId => { + return Services.crashmanager.ensureCrashIsPresent(crashId); + }); + return Promise.all(promises); + } + + case "SPPermissionManager": { + let msg = aMessage.data; + switch (msg.op) { + case "add": + case "remove": + this._permOp(msg); + break; + case "has": + let hasPerm = Services.perms.testPermissionFromPrincipal( + msg.principal, + msg.type + ); + return hasPerm == Ci.nsIPermissionManager.ALLOW_ACTION; + case "test": + let testPerm = Services.perms.testPermissionFromPrincipal( + msg.principal, + msg.type + ); + return testPerm == msg.value; + default: + throw new SpecialPowersError( + "Invalid operation for SPPermissionManager" + ); + } + return undefined; // See comment at the beginning of this function. + } + + case "SPObserverService": { + let topic = aMessage.json.observerTopic; + switch (aMessage.json.op) { + case "notify": + let data = aMessage.json.observerData; + Services.obs.notifyObservers(null, topic, data); + break; + case "add": + this._registerObservers._add(topic); + break; + default: + throw new SpecialPowersError( + "Invalid operation for SPObserverervice" + ); + } + return undefined; // See comment at the beginning of this function. + } + + case "SPLoadChromeScript": { + let id = aMessage.json.id; + let scriptName; + + let jsScript = aMessage.json.function.body; + if (aMessage.json.url) { + scriptName = aMessage.json.url; + } else if (aMessage.json.function) { + scriptName = + aMessage.json.function.name || + "<loadChromeScript anonymous function>"; + } else { + throw new SpecialPowersError("SPLoadChromeScript: Invalid script"); + } + + // Setup a chrome sandbox that has access to sendAsyncMessage + // and {add,remove}MessageListener in order to communicate with + // the mochitest. + let sb = new lazy.SpecialPowersSandbox( + scriptName, + data => { + this.sendAsyncMessage("Assert", data); + }, + aMessage.data + ); + + Object.assign(sb.sandbox, { + createWindowlessBrowser, + sendAsyncMessage: (name, message) => { + this.sendAsyncMessage("SPChromeScriptMessage", { + id, + name, + message, + }); + }, + addMessageListener: (name, listener) => { + this._chromeScriptListeners.push({ id, name, listener }); + }, + removeMessageListener: (name, listener) => { + let index = this._chromeScriptListeners.findIndex(function (obj) { + return ( + obj.id == id && obj.name == name && obj.listener == listener + ); + }); + if (index >= 0) { + this._chromeScriptListeners.splice(index, 1); + } + }, + actorParent: this.manager, + }); + + // Evaluate the chrome script + try { + Cu.evalInSandbox(jsScript, sb.sandbox, "1.8", scriptName, 1); + } catch (e) { + throw new SpecialPowersError( + "Error while executing chrome script '" + + scriptName + + "':\n" + + e + + "\n" + + e.fileName + + ":" + + e.lineNumber + ); + } + return undefined; // See comment at the beginning of this function. + } + + case "SPChromeScriptMessage": { + let id = aMessage.json.id; + let name = aMessage.json.name; + let message = aMessage.json.message; + let result; + for (let listener of this._chromeScriptListeners) { + if (listener.name == name && listener.id == id) { + result = listener.listener(message); + } + } + return result; + } + + case "SPCleanUpSTSData": { + let origin = aMessage.data.origin; + let uri = Services.io.newURI(origin); + let sss = Cc["@mozilla.org/ssservice;1"].getService( + Ci.nsISiteSecurityService + ); + sss.resetState(uri); + return undefined; + } + + case "SPRequestDumpCoverageCounters": { + return lazy.PerTestCoverageUtils.afterTest(); + } + + case "SPRequestResetCoverageCounters": { + return lazy.PerTestCoverageUtils.beforeTest(); + } + + case "SPCheckServiceWorkers": { + let swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + let regs = swm.getAllRegistrations(); + + // XXX This code is shared with specialpowers.js. + let workers = new Array(regs.length); + for (let i = 0; i < regs.length; ++i) { + let { scope, scriptSpec } = regs.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + workers[i] = { scope, scriptSpec }; + } + return { workers }; + } + + case "SPLoadExtension": { + let id = aMessage.data.id; + let ext = aMessage.data.ext; + if (AppConstants.platform === "android") { + // Some extension APIs are partially implemented in Java, and the + // interface between the JS and Java side (GeckoViewWebExtension) + // expects extensions to be registered with the AddonManager. + // + // For simplicity, default to using an Addon Manager (if not null). + if (ext.useAddonManager === undefined) { + ext.useAddonManager = "android-only"; + } + } + // delayedStartup is only supported in xpcshell + if (ext.delayedStartup !== undefined) { + throw new Error( + `delayedStartup is only supported in xpcshell, use "useAddonManager".` + ); + } + + let extension = lazy.ExtensionTestCommon.generate(ext); + + let resultListener = (...args) => { + this.sendAsyncMessage("SPExtensionMessage", { + id, + type: "testResult", + args, + }); + }; + + let messageListener = (...args) => { + args.shift(); + this.sendAsyncMessage("SPExtensionMessage", { + id, + type: "testMessage", + args, + }); + }; + + // Register pass/fail handlers. + extension.on("test-result", resultListener); + extension.on("test-eq", resultListener); + extension.on("test-log", resultListener); + extension.on("test-done", resultListener); + + extension.on("test-message", messageListener); + + this._extensions.set(id, extension); + return undefined; + } + + case "SPStartupExtension": { + let id = aMessage.data.id; + // This is either an Extension, or (if useAddonManager is set) a MockExtension. + let extension = this._extensions.get(id); + extension.on("startup", (eventName, ext) => { + if (AppConstants.platform === "android") { + // We need a way to notify the embedding layer that a new extension + // has been installed, so that the java layer can be updated too. + Services.obs.notifyObservers(null, "testing-installed-addon", id); + } + // ext is always the "real" Extension object, even when "extension" + // is a MockExtension. + this.sendAsyncMessage("SPExtensionMessage", { + id, + type: "extensionSetId", + args: [ext.id, ext.uuid], + }); + }); + + // Make sure the extension passes the packaging checks when + // they're run on a bare archive rather than a running instance, + // as the add-on manager runs them. + let extensionData = new lazy.ExtensionData(extension.rootURI); + return extensionData + .loadManifest() + .then( + () => { + return extensionData.initAllLocales().then(() => { + if (extensionData.errors.length) { + return Promise.reject( + "Extension contains packaging errors" + ); + } + return undefined; + }); + }, + () => { + // loadManifest() will throw if we're loading an embedded + // extension, so don't worry about locale errors in that + // case. + } + ) + .then(async () => { + // browser tests do not call startup in ExtensionXPCShellUtils or MockExtension, + // in that case we have an ID here and we need to set the override. + if (extension.id) { + await lazy.ExtensionTestCommon.setIncognitoOverride(extension); + } + return extension.startup().then( + () => {}, + e => { + dump(`Extension startup failed: ${e}\n${e.stack}`); + throw e; + } + ); + }); + } + + case "SPExtensionMessage": { + let id = aMessage.data.id; + let extension = this._extensions.get(id); + extension.testMessage(...aMessage.data.args); + return undefined; + } + + case "SPExtensionGrantActiveTab": { + let { id, tabId } = aMessage.data; + let { tabManager } = this._extensions.get(id); + tabManager.addActiveTabPermission(tabManager.get(tabId).nativeTab); + return undefined; + } + + case "SPUnloadExtension": { + let id = aMessage.data.id; + let extension = this._extensions.get(id); + this._extensions.delete(id); + return extension.shutdown().then(() => { + return extension._uninstallPromise; + }); + } + + case "SPExtensionTerminateBackground": { + let id = aMessage.data.id; + let args = aMessage.data.args; + let extension = this._extensions.get(id); + return extension.terminateBackground(...args); + } + + case "SPExtensionWakeupBackground": { + let id = aMessage.data.id; + let extension = this._extensions.get(id); + return extension.wakeupBackground(); + } + + case "SetAsDefaultAssertHandler": { + defaultAssertHandler = this; + return undefined; + } + + case "Spawn": { + // Use a different variable for the profiler marker start time + // so that a marker isn't added when we return, but instead when + // our promise resolves. + let spawnStartTime = startTime; + startTime = undefined; + let { browsingContext, task, args, caller, hasHarness, imports } = + aMessage.data; + + let spParent = + browsingContext.currentWindowGlobal.getActor("SpecialPowers"); + + let taskId = nextTaskID++; + if (hasHarness) { + spParent._taskActors.set(taskId, this); + } + + return spParent + .sendQuery("Spawn", { task, args, caller, taskId, imports }) + .finally(() => { + ChromeUtils.addProfilerMarker( + "SpecialPowers", + { startTime: spawnStartTime, category: "Test" }, + aMessage.name + ); + return spParent._taskActors.delete(taskId); + }); + } + + case "SpawnChrome": { + let { task, args, caller, imports } = aMessage.data; + + return this._spawnChrome(task, args, caller, imports); + } + + case "Snapshot": { + let { browsingContext, rect, background, resetScrollPosition } = + aMessage.data; + + return browsingContext.currentWindowGlobal + .drawSnapshot(rect, 1.0, background, resetScrollPosition) + .then(async image => { + let hiddenFrame = new lazy.HiddenFrame(); + let win = await hiddenFrame.get(); + + let canvas = win.document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0); + + let data = ctx.getImageData(0, 0, image.width, image.height); + hiddenFrame.destroy(); + return data; + }); + } + + case "SecurityState": { + let { browsingContext } = aMessage.data; + return browsingContext.secureBrowserUI.state; + } + + case "ProxiedAssert": { + let { taskId, data } = aMessage.data; + + let actor = this._taskActors.get(taskId) || defaultAssertHandler; + actor.sendAsyncMessage("Assert", data); + + return undefined; + } + + case "SPRemoveAllServiceWorkers": { + return lazy.ServiceWorkerCleanUp.removeAll(); + } + + case "SPRemoveServiceWorkerDataForExampleDomain": { + return lazy.ServiceWorkerCleanUp.removeFromHost("example.com"); + } + + case "SPGenerateMediaControlKeyTestEvent": { + // eslint-disable-next-line no-undef + MediaControlService.generateMediaControlKey(aMessage.data.event); + return undefined; + } + + default: + throw new SpecialPowersError( + `Unrecognized Special Powers API: ${aMessage.name}` + ); + } + // This should be unreachable. If it ever becomes reachable, ESLint + // will produce an error about inconsistent return values. + } finally { + if (startTime) { + ChromeUtils.addProfilerMarker( + "SpecialPowers", + { startTime, category: "Test" }, + aMessage.name + ); + } + } + } +} diff --git a/testing/specialpowers/content/SpecialPowersSandbox.sys.mjs b/testing/specialpowers/content/SpecialPowersSandbox.sys.mjs new file mode 100644 index 0000000000..4f9e060595 --- /dev/null +++ b/testing/specialpowers/content/SpecialPowersSandbox.sys.mjs @@ -0,0 +1,141 @@ +/* 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/. */ + +/** + * This modules handles creating and provisioning Sandboxes for + * executing cross-process code from SpecialPowers. This allows all such + * sandboxes to have a similar environment, and in particular allows + * them to run test assertions in the target process and propagate + * results back to the caller. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Assert: "resource://testing-common/Assert.sys.mjs", +}); + +// Note: When updating the set of globals exposed to sandboxes by +// default, please also update the ESLint plugin rule defined in +// import-content-task-globals.js. +const SANDBOX_GLOBALS = [ + "Blob", + "ChromeUtils", + "FileReader", + "TextDecoder", + "TextEncoder", + "URL", +]; +const EXTRA_IMPORTS = { + EventUtils: "resource://testing-common/SpecialPowersEventUtils.sys.mjs", +}; + +let expectFail = false; +function expectingFail(fn) { + try { + expectFail = true; + fn(); + } finally { + expectFail = false; + } +} + +export class SpecialPowersSandbox { + constructor(name, reportCallback, opts = {}) { + this.name = name; + this.reportCallback = reportCallback; + + this._Assert = null; + + this.sandbox = Cu.Sandbox( + Cu.getGlobalForObject({}), + Object.assign( + { wantGlobalProperties: SANDBOX_GLOBALS }, + opts.sandboxOptions + ) + ); + + for (let prop of ["assert", "Assert"]) { + Object.defineProperty(this.sandbox, prop, { + get: () => { + return this.Assert; + }, + enumerable: true, + configurable: true, + }); + } + + let imports = { + ...EXTRA_IMPORTS, + ...opts.imports, + }; + // We explicitly want these directly in the sandbox, and we aren't going + // to be using the globals within this file. + // eslint-disable-next-line mozilla/lazy-getter-object-name + ChromeUtils.defineESModuleGetters(this.sandbox, imports); + + // Note: When updating the set of globals exposed to sandboxes by + // default, please also update the ESLint plugin rule defined in + // import-content-task-globals.js. + Object.assign(this.sandbox, { + BrowsingContext, + InspectorUtils, + ok: (...args) => { + this.Assert.ok(...args); + }, + is: (...args) => { + this.Assert.equal(...args); + }, + isnot: (...args) => { + this.Assert.notEqual(...args); + }, + todo: (...args) => { + expectingFail(() => this.Assert.ok(...args)); + }, + todo_is: (...args) => { + expectingFail(() => this.Assert.equal(...args)); + }, + info: info => { + this.reportCallback({ info }); + }, + }); + } + + get Assert() { + if (!this._Assert) { + this._Assert = new lazy.Assert((err, message, stack) => { + this.report(err, message, stack); + }); + } + return this._Assert; + } + + report(err, name, stack) { + let diag; + if (err) { + diag = + `got ${uneval(err.actual)}, expected ${uneval(err.expected)} ` + + `(operator ${err.operator})`; + } + + this.reportCallback({ + name, + diag, + passed: !err, + stack: stack && stack.formattedStack, + expectFail, + }); + } + + execute(task, args, caller) { + let func = Cu.evalInSandbox( + `(${task})`, + this.sandbox, + undefined, + caller.filename, + caller.lineNumber + ); + return func(...args); + } +} diff --git a/testing/specialpowers/content/WrapPrivileged.sys.mjs b/testing/specialpowers/content/WrapPrivileged.sys.mjs new file mode 100644 index 0000000000..5fe67a94e2 --- /dev/null +++ b/testing/specialpowers/content/WrapPrivileged.sys.mjs @@ -0,0 +1,385 @@ +/* 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/. */ + +/** + * This module handles wrapping privileged objects so that they can be exposed + * to unprivileged contexts. It is only to be used in automated tests. + * + * Its exact semantics are also liable to change at any time, so any callers + * relying on undocumented behavior or subtle platform features should expect + * breakage. Those callers should, wherever possible, migrate to fully + * chrome-privileged scripts when they need to interact with privileged APIs. + */ + +// XPCNativeWrapper is not defined globally in ESLint as it may be going away. +// See bug 1481337. +/* globals XPCNativeWrapper */ + +Cu.crashIfNotInAutomation(); + +let wrappedObjects = new WeakMap(); +let perWindowInfo = new WeakMap(); +let noAutoWrap = new WeakSet(); + +function isWrappable(x) { + if (typeof x === "object") { + return x !== null; + } + return typeof x === "function"; +} + +function isWrapper(x) { + try { + return isWrappable(x) && wrappedObjects.has(x); + } catch (e) { + // If `x` is a remote object proxy, trying to access an unexpected property + // on it will throw a security error, even though we're chrome privileged. + // However, remote proxies are not SpecialPowers wrappers, so: + return false; + } +} + +function unwrapIfWrapped(x) { + return isWrapper(x) ? unwrapPrivileged(x) : x; +} + +function wrapIfUnwrapped(x, w) { + return isWrapper(x) ? x : wrapPrivileged(x, w); +} + +function isObjectOrArray(obj) { + if (Object(obj) !== obj) { + return false; + } + let arrayClasses = [ + "Object", + "Array", + "Int8Array", + "Uint8Array", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "Uint8ClampedArray", + ]; + let className = Cu.getClassName(obj, true); + return arrayClasses.includes(className); +} + +// In general, we want Xray wrappers for content DOM objects, because waiving +// Xray gives us Xray waiver wrappers that clamp the principal when we cross +// compartment boundaries. However, there are some exceptions where we want +// to use a waiver: +// +// * Xray adds some gunk to toString(), which has the potential to confuse +// consumers that aren't expecting Xray wrappers. Since toString() is a +// non-privileged method that returns only strings, we can just waive Xray +// for that case. +// +// * We implement Xrays to pure JS [[Object]] and [[Array]] instances that +// filter out tricky things like callables. This is the right thing for +// security in general, but tends to break tests that try to pass object +// literals into SpecialPowers. So we waive [[Object]] and [[Array]] +// instances before inspecting properties. +// +// * When we don't have meaningful Xray semantics, we create an Opaque +// XrayWrapper for security reasons. For test code, we generally want to see +// through that sort of thing. +function waiveXraysIfAppropriate(obj, propName) { + if ( + propName == "toString" || + isObjectOrArray(obj) || + /Opaque/.test(Object.prototype.toString.call(obj)) + ) { + return XPCNativeWrapper.unwrap(obj); + } + return obj; +} + +// We can't call apply() directy on Xray-wrapped functions, so we have to be +// clever. +function doApply(fun, invocant, args) { + // We implement Xrays to pure JS [[Object]] instances that filter out tricky + // things like callables. This is the right thing for security in general, + // but tends to break tests that try to pass object literals into + // SpecialPowers. So we waive [[Object]] instances when they're passed to a + // SpecialPowers-wrapped callable. + // + // Note that the transitive nature of Xray waivers means that any property + // pulled off such an object will also be waived, and so we'll get principal + // clamping for Xrayed DOM objects reached from literals, so passing things + // like {l : xoWin.location} won't work. Hopefully the rabbit hole doesn't + // go that deep. + args = args.map(x => (isObjectOrArray(x) ? Cu.waiveXrays(x) : x)); + return Reflect.apply(fun, invocant, args); +} + +function wrapPrivileged(obj, win) { + // Primitives pass straight through. + if (!isWrappable(obj)) { + return obj; + } + + // No double wrapping. + if (isWrapper(obj)) { + throw new Error("Trying to double-wrap object!"); + } + + let { windowID, proxies, handler } = perWindowInfo.get(win) || {}; + // |windowUtils| is undefined if |win| is a non-window object + // such as a sandbox. + let currentID = win.windowGlobalChild + ? win.windowGlobalChild.innerWindowId + : 0; + // Values are dead objects if the inner window is changed. + if (windowID !== currentID) { + windowID = currentID; + proxies = new WeakMap(); + handler = Cu.cloneInto(SpecialPowersHandler, win, { + cloneFunctions: true, + }); + handler.wrapped = new win.WeakMap(); + perWindowInfo.set(win, { windowID, proxies, handler }); + } + + if (proxies.has(obj)) { + return proxies.get(obj).proxy; + } + + let className = Cu.getClassName(obj, true); + if (className === "ArrayBuffer") { + // Since |new Uint8Array(<proxy>)| doesn't work as expected, we have to + // return a real ArrayBuffer. + return obj instanceof win.ArrayBuffer ? obj : Cu.cloneInto(obj, win); + } + + let dummy; + if (typeof obj === "function") { + dummy = Cu.exportFunction(function () {}, win); + } else { + dummy = new win.Object(); + } + handler.wrapped.set(dummy, { obj }); + + let proxy = new win.Proxy(dummy, handler); + wrappedObjects.set(proxy, obj); + switch (className) { + case "AnonymousContent": + // Caching anonymous content will cause crashes (bug 1636015). + break; + case "CSS2Properties": + case "CSSStyleRule": + case "CSSStyleSheet": + // Caching these classes will cause memory leaks. + break; + default: + proxies.set(obj, { proxy }); + break; + } + return proxy; +} + +function unwrapPrivileged(x) { + // We don't wrap primitives, so sometimes we have a primitive where we'd + // expect to have a wrapper. The proxy pretends to be the type that it's + // emulating, so we can just as easily check isWrappable() on a proxy as + // we can on an unwrapped object. + if (!isWrappable(x)) { + return x; + } + + // If we have a wrappable type, make sure it's wrapped. + if (!isWrapper(x)) { + throw new Error("Trying to unwrap a non-wrapped object!"); + } + + // unwrapped. + return wrappedObjects.get(x); +} + +function wrapExceptions(global, fn) { + try { + return fn(); + } catch (e) { + throw wrapIfUnwrapped(e, global); + } +} + +let SpecialPowersHandler = { + construct(target, args) { + // The arguments may or may not be wrappers. Unwrap them if necessary. + var unwrappedArgs = Array.from(Cu.waiveXrays(args), x => + unwrapIfWrapped(Cu.unwaiveXrays(x)) + ); + + // We want to invoke "obj" as a constructor, but using unwrappedArgs as + // the arguments. + let global = Cu.getGlobalForObject(this); + return wrapExceptions(global, () => + wrapIfUnwrapped( + Reflect.construct(this.wrapped.get(target).obj, unwrappedArgs), + global + ) + ); + }, + + apply(target, thisValue, args) { + let wrappedObject = this.wrapped.get(target).obj; + let global = Cu.getGlobalForObject(this); + // The invocant and arguments may or may not be wrappers. Unwrap + // them if necessary. + var invocant = unwrapIfWrapped(thisValue); + + return wrapExceptions(global, () => { + if (noAutoWrap.has(wrappedObject)) { + args = Array.from(Cu.waiveXrays(args), x => Cu.unwaiveXrays(x)); + return doApply(wrappedObject, invocant, args); + } + + if (wrappedObject.name == "then") { + args = Array.from(Cu.waiveXrays(args), x => + wrapCallback(Cu.unwaiveXrays(x), global) + ); + } else { + args = Array.from(Cu.waiveXrays(args), x => + unwrapIfWrapped(Cu.unwaiveXrays(x)) + ); + } + + return wrapIfUnwrapped(doApply(wrappedObject, invocant, args), global); + }); + }, + + has(target, prop) { + return Reflect.has(this.wrapped.get(target).obj, prop); + }, + + get(target, prop, receiver) { + let global = Cu.getGlobalForObject(this); + return wrapExceptions(global, () => { + let obj = waiveXraysIfAppropriate(this.wrapped.get(target).obj, prop); + let val = Reflect.get(obj, prop); + return wrapIfUnwrapped(val, global); + }); + }, + + set(target, prop, val, receiver) { + return wrapExceptions(Cu.getGlobalForObject(this), () => { + let obj = waiveXraysIfAppropriate(this.wrapped.get(target).obj, prop); + return Reflect.set(obj, prop, unwrapIfWrapped(val)); + }); + }, + + delete(target, prop) { + return wrapExceptions(Cu.getGlobalForObject(this), () => { + return Reflect.deleteProperty(this.wrapped.get(target).obj, prop); + }); + }, + + defineProperty(target, prop, descriptor) { + throw new Error( + "Can't call defineProperty on SpecialPowers wrapped object" + ); + }, + + getOwnPropertyDescriptor(target, prop) { + let global = Cu.getGlobalForObject(this); + return wrapExceptions(global, () => { + let obj = waiveXraysIfAppropriate(this.wrapped.get(target).obj, prop); + let desc = Reflect.getOwnPropertyDescriptor(obj, prop); + + if (desc === undefined) { + return undefined; + } + + // Transitively maintain the wrapper membrane. + let wrapIfExists = key => { + if (key in desc) { + desc[key] = wrapIfUnwrapped(desc[key], global); + } + }; + + wrapIfExists("value"); + wrapIfExists("get"); + wrapIfExists("set"); + + // A trapping proxy's properties must always be configurable, but sometimes + // we come across non-configurable properties. Tell a white lie. + desc.configurable = true; + + return wrapIfUnwrapped(desc, global); + }); + }, + + ownKeys(target) { + let props = []; + + // Do the normal thing. + let wrappedObject = this.wrapped.get(target).obj; + let flt = a => !props.includes(a); + props = props.concat(Reflect.ownKeys(wrappedObject).filter(flt)); + + // If we've got an Xray wrapper, include the expandos as well. + if ("wrappedJSObject" in wrappedObject) { + props = props.concat( + Reflect.ownKeys(wrappedObject.wrappedJSObject).filter(flt) + ); + } + + return Cu.cloneInto(props, Cu.getGlobalForObject(this)); + }, + + preventExtensions(target) { + throw new Error( + "Can't call preventExtensions on SpecialPowers wrapped object" + ); + }, +}; + +function wrapCallback(cb, win) { + // Do not wrap if it is already privileged. + if (!isWrappable(cb) || Cu.getObjectPrincipal(cb).isSystemPrincipal) { + return cb; + } + return function SpecialPowersCallbackWrapper() { + var args = Array.from(arguments, obj => wrapIfUnwrapped(obj, win)); + let invocant = wrapIfUnwrapped(this, win); + return unwrapIfWrapped(cb.apply(invocant, args)); + }; +} + +function wrapCallbackObject(obj, win) { + // Do not wrap if it is already privileged. + if (!isWrappable(obj) || Cu.getObjectPrincipal(obj).isSystemPrincipal) { + return obj; + } + obj = Cu.waiveXrays(obj); + var wrapper = {}; + for (var i in obj) { + if (typeof obj[i] == "function") { + wrapper[i] = wrapCallback(Cu.unwaiveXrays(obj[i]), win); + } else { + wrapper[i] = obj[i]; + } + } + return wrapper; +} + +function disableAutoWrap(...objs) { + objs.forEach(x => noAutoWrap.add(x)); +} + +export var WrapPrivileged = { + wrap: wrapIfUnwrapped, + unwrap: unwrapIfWrapped, + + isWrapper, + + wrapCallback, + wrapCallbackObject, + + disableAutoWrap, +}; diff --git a/testing/specialpowers/manifest.json b/testing/specialpowers/manifest.json new file mode 100644 index 0000000000..c0ffce0005 --- /dev/null +++ b/testing/specialpowers/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 2, + "name": "Special Powers", + "version": "2018.6.27", + + "browser_specific_settings": { + "gecko": { + "id": "special-powers@mozilla.org" + } + }, + "permissions": [], + + "experiment_apis": { + "specialpowers": { + "schema": "schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "api.js", + "events": ["startup"] + } + } + } +} diff --git a/testing/specialpowers/moz.build b/testing/specialpowers/moz.build new file mode 100644 index 0000000000..7a2413e2a2 --- /dev/null +++ b/testing/specialpowers/moz.build @@ -0,0 +1,40 @@ +# -*- 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/. + +XPI_NAME = "specialpowers" + +USE_EXTENSION_MANIFEST = True + +FINAL_TARGET_FILES += [ + "api.js", + "manifest.json", + "schema.json", +] + +FINAL_TARGET_FILES.content += [ + "content/AppTestDelegate.sys.mjs", + "content/AppTestDelegateChild.sys.mjs", + "content/AppTestDelegateParent.sys.mjs", +] + +modules = [ + "content/ContentTaskUtils.sys.mjs", + "content/MockColorPicker.sys.mjs", + "content/MockFilePicker.sys.mjs", + "content/MockPermissionPrompt.sys.mjs", + "content/SpecialPowersChild.sys.mjs", + "content/SpecialPowersEventUtils.sys.mjs", + "content/SpecialPowersParent.sys.mjs", + "content/SpecialPowersSandbox.sys.mjs", + "content/WrapPrivileged.sys.mjs", +] + +TESTING_JS_MODULES += modules + +FINAL_TARGET_FILES.modules += modules + +with Files("**"): + BUG_COMPONENT = ("Testing", "Mochitest") diff --git a/testing/specialpowers/schema.json b/testing/specialpowers/schema.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/testing/specialpowers/schema.json @@ -0,0 +1 @@ +[] |