Files
tubestation/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
Shane Caraveo 49f496bdbc Bug 1511636: update incognito support to use pref and permissions r=rpl,aswan,kmag
This changes the policy to use the pref and permissions rather than a boolean flag.  Using permissions gets us proper settings on startup without introducing any new overhead.  Going this way flips our tests around so rather than testing an override to turn off private browsing support, we test overrides to enable private browsing support.

Differential Revision: https://phabricator.services.mozilla.com/D14482
2019-01-28 18:10:47 +00:00

855 lines
23 KiB
JavaScript

/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = ["ExtensionTestUtils"];
ChromeUtils.import("resource://gre/modules/ActorManagerParent.jsm");
ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
// Windowless browsers can create documents that rely on XUL Custom Elements:
ChromeUtils.import("resource://gre/modules/CustomElementsListener.jsm", null);
ChromeUtils.defineModuleGetter(this, "AddonManager",
"resource://gre/modules/AddonManager.jsm");
ChromeUtils.defineModuleGetter(this, "AddonTestUtils",
"resource://testing-common/AddonTestUtils.jsm");
ChromeUtils.defineModuleGetter(this, "ContentTask",
"resource://testing-common/ContentTask.jsm");
ChromeUtils.defineModuleGetter(this, "Extension",
"resource://gre/modules/Extension.jsm");
ChromeUtils.defineModuleGetter(this, "FileUtils",
"resource://gre/modules/FileUtils.jsm");
ChromeUtils.defineModuleGetter(this, "MessageChannel",
"resource://gre/modules/MessageChannel.jsm");
ChromeUtils.defineModuleGetter(this, "Schemas",
"resource://gre/modules/Schemas.jsm");
ChromeUtils.defineModuleGetter(this, "Services",
"resource://gre/modules/Services.jsm");
ChromeUtils.defineModuleGetter(this, "TestUtils",
"resource://testing-common/TestUtils.jsm");
ChromeUtils.defineModuleGetter(this, "ExtensionTestCommon",
"resource://testing-common/ExtensionTestCommon.jsm");
XPCOMUtils.defineLazyGetter(this, "Management", () => {
const {Management} = ChromeUtils.import("resource://gre/modules/Extension.jsm", {});
return Management;
});
Services.mm.loadFrameScript("chrome://global/content/browser-content.js", true, true);
ActorManagerParent.flush();
/* exported ExtensionTestUtils */
const {
promiseDocumentLoaded,
promiseEvent,
promiseObserved,
} = ExtensionUtils;
var REMOTE_CONTENT_SCRIPTS = false;
let BASE_MANIFEST = Object.freeze({
"applications": Object.freeze({
"gecko": Object.freeze({
"id": "test@web.ext",
}),
}),
"manifest_version": 2,
"name": "name",
"version": "0",
});
function frameScript() {
ChromeUtils.import("resource://gre/modules/MessageChannel.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
Services.obs.notifyObservers(this, "tab-content-frameloader-created");
const messageListener = {
async receiveMessage({target, messageName, recipient, data, name}) {
/* globals content */
let resp = await content.fetch(data.url, data.options);
return resp.text();
},
};
MessageChannel.addListener(this, "Test:Fetch", messageListener);
// eslint-disable-next-line mozilla/balanced-listeners, no-undef
addEventListener("MozHeapMinimize", () => {
Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
}, true, true);
}
let kungFuDeathGrip = new Set();
function promiseBrowserLoaded(browser, url, redirectUrl) {
return new Promise(resolve => {
const listener = {
QueryInterface: ChromeUtils.generateQI([Ci.nsISupportsWeakReference, Ci.nsIWebProgressListener]),
onStateChange(webProgress, request, stateFlags, statusCode) {
let requestUrl = request.originalURI ? request.originalURI.spec : webProgress.DOMWindow.location.href;
if (webProgress.isTopLevel &&
(requestUrl === url || requestUrl === redirectUrl) &&
(stateFlags & Ci.nsIWebProgressListener.STATE_STOP)) {
resolve();
kungFuDeathGrip.delete(listener);
browser.removeProgressListener(listener);
}
},
};
// addProgressListener only supports weak references, so we need to
// use one. But we also need to make sure it stays alive until we're
// done with it, so thunk away a strong reference to keep it alive.
kungFuDeathGrip.add(listener);
browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW);
});
}
class ContentPage {
constructor(remote = REMOTE_CONTENT_SCRIPTS, extension = null, privateBrowsing = false) {
this.remote = remote;
this.extension = extension;
this.privateBrowsing = privateBrowsing;
this.browserReady = this._initBrowser();
}
async _initBrowser() {
this.windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
if (this.privateBrowsing) {
let loadContext = this.windowlessBrowser.docShell
.QueryInterface(Ci.nsILoadContext);
loadContext.usePrivateBrowsing = true;
}
let system = Services.scriptSecurityManager.getSystemPrincipal();
let chromeShell = this.windowlessBrowser.docShell
.QueryInterface(Ci.nsIWebNavigation);
chromeShell.createAboutBlankContentViewer(system);
chromeShell.useGlobalHistory = false;
let loadURIOptions = {
triggeringPrincipal: system,
};
chromeShell.loadURI("chrome://extensions/content/dummy.xul", loadURIOptions);
await promiseObserved("chrome-document-global-created",
win => win.document == chromeShell.document);
let chromeDoc = await promiseDocumentLoaded(chromeShell.document);
let browser = chromeDoc.createElement("browser");
browser.setAttribute("type", "content");
browser.setAttribute("disableglobalhistory", "true");
if (this.extension && this.extension.remote) {
this.remote = true;
browser.setAttribute("remote", "true");
browser.setAttribute("remoteType", "extension");
browser.sameProcessAsFrameLoader = this.extension.groupFrameLoader;
}
let awaitFrameLoader = Promise.resolve();
if (this.remote) {
awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
browser.setAttribute("remote", "true");
}
chromeDoc.documentElement.appendChild(browser);
await awaitFrameLoader;
this.browser = browser;
this.loadFrameScript(frameScript);
return browser;
}
sendMessage(msg, data) {
return MessageChannel.sendMessage(this.browser.messageManager, msg, data);
}
loadFrameScript(func) {
let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`;
this.browser.messageManager.loadFrameScript(frameScript, true, true);
}
addFrameScriptHelper(func) {
let frameScript = `data:text/javascript,${encodeURI(func)}`;
this.browser.messageManager.loadFrameScript(frameScript, false, true);
}
async loadURL(url, redirectUrl = undefined) {
await this.browserReady;
this.browser.loadURI(url, {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
return promiseBrowserLoaded(this.browser, url, redirectUrl);
}
async fetch(url, options) {
return this.sendMessage("Test:Fetch", {url, options});
}
spawn(params, task) {
return ContentTask.spawn(this.browser, params, task);
}
async close() {
await this.browserReady;
let {messageManager} = this.browser;
this.browser = null;
this.windowlessBrowser.close();
this.windowlessBrowser = null;
await TestUtils.topicObserved("message-manager-disconnect",
subject => subject === messageManager);
}
}
class ExtensionWrapper {
constructor(testScope, extension = null) {
this.testScope = testScope;
this.extension = null;
this.handleResult = this.handleResult.bind(this);
this.handleMessage = this.handleMessage.bind(this);
this.state = "uninitialized";
this.testResolve = null;
this.testDone = new Promise(resolve => { this.testResolve = resolve; });
this.messageHandler = new Map();
this.messageAwaiter = new Map();
this.messageQueue = new Set();
this.testScope.registerCleanupFunction(() => {
this.clearMessageQueues();
if (this.state == "pending" || this.state == "running") {
this.testScope.equal(this.state, "unloaded", "Extension left running at test shutdown");
return this.unload();
} else if (this.state == "unloading") {
this.testScope.equal(this.state, "unloaded", "Extension not fully unloaded at test shutdown");
}
this.destroy();
});
if (extension) {
this.id = extension.id;
this.uuid = extension.uuid;
this.attachExtension(extension);
}
}
destroy() {
// This method should be implemented in subclasses which need to
// perform cleanup when destroyed.
}
attachExtension(extension) {
if (extension === this.extension) {
return;
}
if (this.extension) {
this.extension.off("test-eq", this.handleResult);
this.extension.off("test-log", this.handleResult);
this.extension.off("test-result", this.handleResult);
this.extension.off("test-done", this.handleResult);
this.extension.off("test-message", this.handleMessage);
this.clearMessageQueues();
}
this.extension = extension;
extension.on("test-eq", this.handleResult);
extension.on("test-log", this.handleResult);
extension.on("test-result", this.handleResult);
extension.on("test-done", this.handleResult);
extension.on("test-message", this.handleMessage);
this.testScope.info(`Extension attached`);
}
clearMessageQueues() {
if (this.messageQueue.size) {
let names = Array.from(this.messageQueue, ([msg]) => msg);
this.testScope.equal(JSON.stringify(names), "[]", "message queue is empty");
this.messageQueue.clear();
}
if (this.messageAwaiter.size) {
let names = Array.from(this.messageAwaiter.keys());
this.testScope.equal(JSON.stringify(names), "[]", "no tasks awaiting on messages");
for (let promise of this.messageAwaiter.values()) {
promise.reject();
}
this.messageAwaiter.clear();
}
}
handleResult(kind, pass, msg, expected, actual) {
switch (kind) {
case "test-eq":
this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`);
break;
case "test-log":
this.testScope.info(msg);
break;
case "test-result":
this.testScope.ok(pass, msg);
break;
case "test-done":
this.testScope.ok(pass, msg);
this.testResolve(msg);
break;
}
}
handleMessage(kind, msg, ...args) {
let handler = this.messageHandler.get(msg);
if (handler) {
handler(...args);
} else {
this.messageQueue.add([msg, ...args]);
this.checkMessages();
}
}
awaitStartup() {
return this.startupPromise;
}
async startup() {
if (this.state != "uninitialized") {
throw new Error("Extension already started");
}
this.state = "pending";
await ExtensionTestCommon.setIncognitoOverride(this.extension);
this.startupPromise = this.extension.startup().then(
result => {
this.state = "running";
return result;
},
error => {
this.state = "failed";
return Promise.reject(error);
});
return this.startupPromise;
}
async unload() {
if (this.state != "running") {
throw new Error("Extension not running");
}
this.state = "unloading";
if (this.addon) {
await this.addon.uninstall();
} else {
await this.extension.shutdown();
}
this.state = "unloaded";
}
/*
* This method marks the extension unloading without actually calling
* shutdown, since shutting down a MockExtension causes it to be uninstalled.
*
* Normally you shouldn't need to use this unless you need to test something
* that requires a restart, such as updates.
*/
markUnloaded() {
if (this.state != "running") {
throw new Error("Extension not running");
}
this.state = "unloaded";
return Promise.resolve();
}
sendMessage(...args) {
this.extension.testMessage(...args);
}
awaitFinish(msg) {
return this.testDone.then(actual => {
if (msg) {
this.testScope.equal(actual, msg, "test result correct");
}
return actual;
});
}
checkMessages() {
for (let message of this.messageQueue) {
let [msg, ...args] = message;
let listener = this.messageAwaiter.get(msg);
if (listener) {
this.messageQueue.delete(message);
this.messageAwaiter.delete(msg);
listener.resolve(...args);
return;
}
}
}
checkDuplicateListeners(msg) {
if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
throw new Error("only one message handler allowed");
}
}
awaitMessage(msg) {
return new Promise((resolve, reject) => {
this.checkDuplicateListeners(msg);
this.messageAwaiter.set(msg, {resolve, reject});
this.checkMessages();
});
}
onMessage(msg, callback) {
this.checkDuplicateListeners(msg);
this.messageHandler.set(msg, callback);
}
}
class AOMExtensionWrapper extends ExtensionWrapper {
constructor(testScope) {
super(testScope);
this.onEvent = this.onEvent.bind(this);
Management.on("ready", this.onEvent);
Management.on("shutdown", this.onEvent);
Management.on("startup", this.onEvent);
AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
AddonTestUtils.on("addon-manager-started", this.onEvent);
AddonManager.addAddonListener(this);
}
destroy() {
this.id = null;
this.addon = null;
Management.off("ready", this.onEvent);
Management.off("shutdown", this.onEvent);
Management.off("startup", this.onEvent);
AddonTestUtils.off("addon-manager-shutdown", this.onEvent);
AddonTestUtils.off("addon-manager-started", this.onEvent);
AddonManager.removeAddonListener(this);
}
setRestarting() {
if (this.state !== "restarting") {
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
}).then(async result => {
await this.addonPromise;
return result;
});
}
this.state = "restarting";
}
onEnabling(addon) {
if (addon.id === this.id) {
this.setRestarting();
}
}
onInstalling(addon) {
if (addon.id === this.id) {
this.setRestarting();
}
}
onInstalled(addon) {
if (addon.id === this.id) {
this.addon = addon;
}
}
onUninstalled(addon) {
if (addon.id === this.id) {
this.destroy();
}
}
onEvent(kind, ...args) {
switch (kind) {
case "addon-manager-started":
this.addonPromise = AddonManager.getAddonByID(this.id).then(addon => {
this.addon = addon;
});
// FALLTHROUGH
case "addon-manager-shutdown":
this.addon = null;
this.setRestarting();
break;
case "startup": {
let [extension] = args;
this.maybeSetID(extension.rootURI, extension.id);
if (extension.id === this.id) {
this.attachExtension(extension);
this.state = "pending";
}
break;
}
case "shutdown": {
let [extension] = args;
if (extension.id === this.id && this.state !== "restarting") {
this.state = "unloaded";
}
break;
}
case "ready": {
let [extension] = args;
if (extension.id === this.id) {
this.state = "running";
this.resolveStartup(extension);
}
break;
}
}
}
async _flushCache() {
if (this.extension && this.extension.rootURI instanceof Ci.nsIJARURI) {
let file = this.extension.rootURI.JARFile.QueryInterface(Ci.nsIFileURL).file;
await Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});
}
}
get version() {
return this.addon && this.addon.version;
}
async unload() {
await this._flushCache();
return super.unload();
}
async upgrade(data) {
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
this.state = "restarting";
await this._flushCache();
let xpiFile = Extension.generateXPI(data);
this.cleanupFiles.push(xpiFile);
return this._install(xpiFile);
}
}
class InstallableWrapper extends AOMExtensionWrapper {
constructor(testScope, xpiFile, installType, installTelemetryInfo) {
super(testScope);
this.file = xpiFile;
this.installType = installType;
this.installTelemetryInfo = installTelemetryInfo;
this.cleanupFiles = [xpiFile];
}
destroy() {
super.destroy();
for (let file of this.cleanupFiles.splice(0)) {
try {
Services.obs.notifyObservers(file, "flush-cache-entry");
file.remove(false);
} catch (e) {
Cu.reportError(e);
}
}
}
maybeSetID(uri, id) {
if (!this.id && uri instanceof Ci.nsIJARURI &&
uri.JARFile.QueryInterface(Ci.nsIFileURL)
.file.equals(this.file)) {
this.id = id;
}
}
_install(xpiFile) {
if (this.installType === "temporary") {
return AddonManager.installTemporaryAddon(xpiFile).then(addon => {
this.id = addon.id;
this.addon = addon;
return this.startupPromise;
}).catch(e => {
this.state = "unloaded";
return Promise.reject(e);
});
} else if (this.installType === "permanent") {
return AddonManager.getInstallForFile(xpiFile, null, this.installTelemetryInfo).then(install => {
let listener = {
onInstallFailed: () => {
this.state = "unloaded";
this.resolveStartup(Promise.reject(new Error("Install failed")));
},
onInstallEnded: (install, newAddon) => {
this.id = newAddon.id;
this.addon = newAddon;
},
};
install.addListener(listener);
install.install();
return this.startupPromise;
});
}
}
startup() {
if (this.state != "uninitialized") {
throw new Error("Extension already started");
}
this.state = "pending";
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
return this._install(this.file);
}
}
class ExternallyInstalledWrapper extends AOMExtensionWrapper {
constructor(testScope, id) {
super(testScope);
this.id = id;
this.startupPromise = new Promise(resolve => {
this.resolveStartup = resolve;
});
this.state = "restarting";
}
maybeSetID(uri, id) { }
}
var ExtensionTestUtils = {
BASE_MANIFEST,
async normalizeManifest(manifest, manifestType = "manifest.WebExtensionManifest",
baseManifest = BASE_MANIFEST) {
await Management.lazyInit();
let errors = [];
let context = {
url: null,
logError: error => {
errors.push(error);
},
preprocessors: {},
};
manifest = Object.assign({}, baseManifest, manifest);
let normalized = Schemas.normalize(manifest, manifestType, context);
normalized.errors = errors;
return normalized;
},
currentScope: null,
profileDir: null,
init(scope) {
this.currentScope = scope;
this.profileDir = scope.do_get_profile();
this.fetchScopes = new Map();
// We need to load at least one frame script into every message
// manager to ensure that the scriptable wrapper for its global gets
// created before we try to access it externally. If we don't, we
// fail sanity checks on debug builds the first time we try to
// create a wrapper, because we should never have a global without a
// cached wrapper.
Services.mm.loadFrameScript("data:text/javascript,//", true, true);
let tmpD = this.profileDir.clone();
tmpD.append("tmp");
tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
let dirProvider = {
getFile(prop, persistent) {
persistent.value = false;
if (prop == "TmpD") {
return tmpD.clone();
}
return null;
},
QueryInterface: ChromeUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
};
Services.dirsvc.registerProvider(dirProvider);
scope.registerCleanupFunction(() => {
try {
tmpD.remove(true);
} catch (e) {
Cu.reportError(e);
}
Services.dirsvc.unregisterProvider(dirProvider);
this.currentScope = null;
return Promise.all(Array.from(this.fetchScopes.values(),
promise => promise.then(scope => scope.close())));
});
},
addonManagerStarted: false,
mockAppInfo() {
const {updateAppInfo} = ChromeUtils.import("resource://testing-common/AppInfo.jsm", {});
updateAppInfo({
ID: "xpcshell@tests.mozilla.org",
name: "XPCShell",
version: "48",
platformVersion: "48",
});
},
startAddonManager() {
if (this.addonManagerStarted) {
return;
}
this.addonManagerStarted = true;
this.mockAppInfo();
let manager = Cc["@mozilla.org/addons/integration;1"].getService(Ci.nsIObserver)
.QueryInterface(Ci.nsITimerCallback);
manager.observe(null, "addons-startup", null);
},
loadExtension(data) {
if (data.useAddonManager) {
let xpiFile = Extension.generateXPI(data);
return this.loadExtensionXPI(xpiFile, data.useAddonManager, data.amInstallTelemetryInfo);
}
let extension = Extension.generate(data);
return new ExtensionWrapper(this.currentScope, extension);
},
loadExtensionXPI(xpiFile, useAddonManager = "temporary", installTelemetryInfo) {
return new InstallableWrapper(this.currentScope, xpiFile, useAddonManager, installTelemetryInfo);
},
// Create a wrapper for a webextension that will be installed
// by some external process (e.g., Normandy)
expectExtension(id) {
return new ExternallyInstalledWrapper(this.currentScope, id);
},
get remoteContentScripts() {
return REMOTE_CONTENT_SCRIPTS;
},
set remoteContentScripts(val) {
REMOTE_CONTENT_SCRIPTS = !!val;
},
async fetch(origin, url, options) {
let fetchScopePromise = this.fetchScopes.get(origin);
if (!fetchScopePromise) {
fetchScopePromise = this.loadContentPage(origin);
this.fetchScopes.set(origin, fetchScopePromise);
}
let fetchScope = await fetchScopePromise;
return fetchScope.sendMessage("Test:Fetch", {url, options});
},
/**
* Loads a content page into a hidden docShell.
*
* @param {string} url
* The URL to load.
* @param {object} [options = {}]
* @param {ExtensionWrapper} [options.extension]
* If passed, load the URL as an extension page for the given
* extension.
* @param {boolean} [options.remote]
* If true, load the URL in a content process. If false, load
* it in the parent process.
* @param {string} [options.redirectUrl]
* An optional URL that the initial page is expected to
* redirect to.
*
* @returns {ContentPage}
*/
loadContentPage(url, {extension = undefined, remote = undefined, redirectUrl = undefined, privateBrowsing = false} = {}) {
ContentTask.setTestScope(this.currentScope);
let contentPage = new ContentPage(remote, extension && extension.extension, privateBrowsing);
return contentPage.loadURL(url, redirectUrl).then(() => {
return contentPage;
});
},
};