diff options
Diffstat (limited to 'mobile/android/components/extensions')
54 files changed, 7023 insertions, 0 deletions
diff --git a/mobile/android/components/extensions/.eslintrc.js b/mobile/android/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..7726338490 --- /dev/null +++ b/mobile/android/components/extensions/.eslintrc.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: "../../../../toolkit/components/extensions/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs b/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs new file mode 100644 index 0000000000..fb4155f897 --- /dev/null +++ b/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs @@ -0,0 +1,59 @@ +/* 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 { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { ExtensionError } = ExtensionUtils; + +export class BrowsingDataDelegate { + constructor(extension) { + this.extension = extension; + } + + async sendRequestForResult(type, data) { + try { + const result = await lazy.EventDispatcher.instance.sendRequestForResult({ + type, + extensionId: this.extension.id, + ...data, + }); + return result; + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + } + + async settings() { + return this.sendRequestForResult("GeckoView:BrowsingData:GetSettings"); + } + + async sendClear(dataType, options) { + const { since } = options; + return this.sendRequestForResult("GeckoView:BrowsingData:Clear", { + dataType, + since, + }); + } + + // This method returns undefined for all data types that are _not_ handled by + // this delegate. + handleRemoval(dataType, options) { + switch (dataType) { + case "downloads": + case "formData": + case "history": + case "passwords": + return this.sendClear(dataType, options); + + default: + return undefined; + } + } +} diff --git a/mobile/android/components/extensions/ext-android.js b/mobile/android/components/extensions/ext-android.js new file mode 100644 index 0000000000..a417811c8c --- /dev/null +++ b/mobile/android/components/extensions/ext-android.js @@ -0,0 +1,630 @@ +/* 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"; + +/** + * NOTE: If you change the globals in this file, you must check if the globals + * list in mobile/android/.eslintrc.js also needs updating. + */ + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +var { EventDispatcher } = ChromeUtils.importESModule( + "resource://gre/modules/Messaging.sys.mjs" +); + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +var { defineLazyGetter } = ExtensionCommon; + +const BrowserStatusFilter = Components.Constructor( + "@mozilla.org/appshell/component/browser-status-filter;1", + "nsIWebProgress", + "addProgressListener" +); + +const WINDOW_TYPE = "navigator:geckoview"; + +// We need let to break cyclic dependency +/* eslint-disable-next-line prefer-const */ +let windowTracker; + +/** + * A nsIWebProgressListener for a specific XUL browser, which delegates the + * events that it receives to a tab progress listener, and prepends the browser + * to their arguments list. + * + * @param {XULElement} browser + * A XUL browser element. + * @param {object} listener + * A tab progress listener object. + * @param {integer} flags + * The web progress notification flags with which to filter events. + */ +class BrowserProgressListener { + constructor(browser, listener, flags) { + this.listener = listener; + this.browser = browser; + this.filter = new BrowserStatusFilter(this, flags); + this.browser.addProgressListener(this.filter, flags); + } + + /** + * Destroy the listener, and perform any necessary cleanup. + */ + destroy() { + this.browser.removeProgressListener(this.filter); + this.filter.removeProgressListener(this); + } + + /** + * Calls the appropriate listener in the wrapped tab progress listener, with + * the wrapped XUL browser object as its first argument, and the additional + * arguments in `args`. + * + * @param {string} method + * The name of the nsIWebProgressListener method which is being + * delegated. + * @param {*} args + * The arguments to pass to the delegated listener. + * @private + */ + delegate(method, ...args) { + if (this.listener[method]) { + this.listener[method](this.browser, ...args); + } + } + + onLocationChange(webProgress, request, locationURI, flags) { + const window = this.browser.ownerGlobal; + // GeckoView windows can become popups at any moment, so we need to check + // here + if (!windowTracker.isBrowserWindow(window)) { + return; + } + + this.delegate("onLocationChange", webProgress, request, locationURI, flags); + } + onStateChange(webProgress, request, stateFlags, status) { + this.delegate("onStateChange", webProgress, request, stateFlags, status); + } +} + +const PROGRESS_LISTENER_FLAGS = + Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION; + +class ProgressListenerWrapper { + constructor(window, listener) { + this.listener = new BrowserProgressListener( + window.browser, + listener, + PROGRESS_LISTENER_FLAGS + ); + } + + destroy() { + this.listener.destroy(); + } +} + +class WindowTracker extends WindowTrackerBase { + constructor(...args) { + super(...args); + + this.progressListeners = new DefaultWeakMap(() => new WeakMap()); + } + + getCurrentWindow(context) { + // In GeckoView the popup is on a separate window so getCurrentWindow for + // the popup should return whatever is the topWindow. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + return this.topWindow; + } + return super.getCurrentWindow(context); + } + + get topWindow() { + return mobileWindowTracker.topWindow; + } + + get topNonPBWindow() { + return mobileWindowTracker.topNonPBWindow; + } + + isBrowserWindow(window) { + const { documentElement } = window.document; + return documentElement.getAttribute("windowtype") === WINDOW_TYPE; + } + + addProgressListener(window, listener) { + const listeners = this.progressListeners.get(window); + if (!listeners.has(listener)) { + const wrapper = new ProgressListenerWrapper(window, listener); + listeners.set(listener, wrapper); + } + } + + removeProgressListener(window, listener) { + const listeners = this.progressListeners.get(window); + const wrapper = listeners.get(listener); + if (wrapper) { + wrapper.destroy(); + listeners.delete(listener); + } + } +} + +/** + * Helper to create an event manager which listens for an event in the Android + * global EventDispatcher, and calls the given listener function whenever the + * event is received. That listener function receives a `fire` object, + * which it can use to dispatch events to the extension, and an object + * detailing the EventDispatcher event that was received. + * + * @param {BaseContext} context + * The extension context which the event manager belongs to. + * @param {string} name + * The API name of the event manager, e.g.,"runtime.onMessage". + * @param {string} event + * The name of the EventDispatcher event to listen for. + * @param {Function} listener + * The listener function to call when an EventDispatcher event is + * recieved. + * + * @returns {object} An injectable api for the new event. + */ +global.makeGlobalEvent = function makeGlobalEvent( + context, + name, + event, + listener +) { + return new EventManager({ + context, + name, + register: fire => { + const listener2 = { + onEvent(event, data, callback) { + listener(fire, data); + }, + }; + + EventDispatcher.instance.registerListener(listener2, [event]); + return () => { + EventDispatcher.instance.unregisterListener(listener2, [event]); + }; + }, + }).api(); +}; + +class TabTracker extends TabTrackerBase { + init() { + if (this.initialized) { + return; + } + this.initialized = true; + + windowTracker.addOpenListener(window => { + const nativeTab = window.tab; + this.emit("tab-created", { nativeTab }); + }); + + windowTracker.addCloseListener(window => { + const { tab: nativeTab, browser } = window; + const { windowId, tabId } = this.getBrowserData(browser); + this.emit("tab-removed", { + nativeTab, + tabId, + windowId, + // In GeckoView, it is not meaningful to speak of "window closed", because a tab is a window. + // Until we have a meaningful way to group tabs (and close multiple tabs at once), + // let's use isWindowClosing: false + isWindowClosing: false, + }); + }); + } + + getId(nativeTab) { + return nativeTab.id; + } + + getTab(id, default_ = undefined) { + const windowId = GeckoViewTabBridge.tabIdToWindowId(id); + const window = windowTracker.getWindow(windowId, null, false); + + if (window) { + const { tab } = window; + if (tab) { + return tab; + } + } + + if (default_ !== undefined) { + return default_; + } + throw new ExtensionError(`Invalid tab ID: ${id}`); + } + + getBrowserData(browser) { + const window = browser.ownerGlobal; + const { tab } = window; + if (!tab) { + return { + tabId: -1, + windowId: -1, + }; + } + + const windowId = windowTracker.getId(window); + + if (!windowTracker.isBrowserWindow(window)) { + return { + windowId, + tabId: -1, + }; + } + + return { + windowId, + tabId: this.getId(tab), + }; + } + + get activeTab() { + const window = windowTracker.topWindow; + if (window) { + return window.tab; + } + return null; + } +} + +windowTracker = new WindowTracker(); +const tabTracker = new TabTracker(); + +Object.assign(global, { tabTracker, windowTracker }); + +class Tab extends TabBase { + get _favIconUrl() { + return undefined; + } + + get attention() { + return false; + } + + get audible() { + return this.nativeTab.playingAudio; + } + + get browser() { + return this.nativeTab.browser; + } + + get discarded() { + return this.browser.getAttribute("pending") === "true"; + } + + get cookieStoreId() { + return getCookieStoreIdForTab(this, this.nativeTab); + } + + get height() { + return this.browser.clientHeight; + } + + get incognito() { + return PrivateBrowsingUtils.isBrowserPrivate(this.browser); + } + + get index() { + return 0; + } + + get mutedInfo() { + return { muted: false }; + } + + get lastAccessed() { + return this.nativeTab.lastTouchedAt; + } + + get pinned() { + return false; + } + + get active() { + return this.nativeTab.getActive(); + } + + get highlighted() { + return this.active; + } + + get status() { + if (this.browser.webProgress.isLoadingDocument) { + return "loading"; + } + return "complete"; + } + + get successorTabId() { + return -1; + } + + get width() { + return this.browser.clientWidth; + } + + get window() { + return this.browser.ownerGlobal; + } + + get windowId() { + return windowTracker.getId(this.window); + } + + // TODO: Just return false for these until properly implemented on Android. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1402924 + get isArticle() { + return false; + } + + get isInReaderMode() { + return false; + } + + get hidden() { + return false; + } + + get sharingState() { + return { + screen: undefined, + microphone: false, + camera: false, + }; + } +} + +// Manages tab-specific context data and dispatches tab select and close events. +class TabContext extends EventEmitter { + constructor(getDefaultPrototype) { + super(); + + windowTracker.addListener("progress", this); + + this.getDefaultPrototype = getDefaultPrototype; + this.tabData = new Map(); + } + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (!webProgress.isTopLevel) { + // Only pageAction and browserAction are consuming the "location-change" event + // to update their per-tab status, and they should only do so in response of + // location changes related to the top level frame (See Bug 1493470 for a rationale). + return; + } + const { tab } = browser.ownerGlobal; + // fromBrowse will be false in case of e.g. a hash change or history.pushState + const fromBrowse = !( + flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + this.emit( + "location-change", + { + id: tab.id, + linkedBrowser: browser, + // TODO: we don't support selected so we just alway say we are + selected: true, + }, + fromBrowse + ); + } + + get(tabId) { + if (!this.tabData.has(tabId)) { + const data = Object.create(this.getDefaultPrototype(tabId)); + this.tabData.set(tabId, data); + } + + return this.tabData.get(tabId); + } + + clear(tabId) { + this.tabData.delete(tabId); + } + + shutdown() { + windowTracker.removeListener("progress", this); + } +} + +class Window extends WindowBase { + get focused() { + return this.window.document.hasFocus(); + } + + isCurrentFor(context) { + // In GeckoView the popup is on a separate window so the current window for + // the popup is whatever is the topWindow. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + return mobileWindowTracker.topWindow == this.window; + } + return super.isCurrentFor(context); + } + + get top() { + return this.window.screenY; + } + + get left() { + return this.window.screenX; + } + + get width() { + return this.window.outerWidth; + } + + get height() { + return this.window.outerHeight; + } + + get incognito() { + return PrivateBrowsingUtils.isWindowPrivate(this.window); + } + + get alwaysOnTop() { + return false; + } + + get isLastFocused() { + return this.window === windowTracker.topWindow; + } + + get state() { + return "fullscreen"; + } + + *getTabs() { + yield this.activeTab; + } + + *getHighlightedTabs() { + yield this.activeTab; + } + + get activeTab() { + const { tabManager } = this.extension; + return tabManager.getWrapper(this.window.tab); + } + + getTabAtIndex(index) { + if (index == 0) { + return this.activeTab; + } + } +} + +Object.assign(global, { Tab, TabContext, Window }); + +class TabManager extends TabManagerBase { + get(tabId, default_ = undefined) { + const nativeTab = tabTracker.getTab(tabId, default_); + + if (nativeTab) { + return this.getWrapper(nativeTab); + } + return default_; + } + + addActiveTabPermission(nativeTab = tabTracker.activeTab) { + return super.addActiveTabPermission(nativeTab); + } + + revokeActiveTabPermission(nativeTab = tabTracker.activeTab) { + return super.revokeActiveTabPermission(nativeTab); + } + + canAccessTab(nativeTab) { + return ( + this.extension.privateBrowsingAllowed || + !PrivateBrowsingUtils.isBrowserPrivate(nativeTab.browser) + ); + } + + wrapTab(nativeTab) { + return new Tab(this.extension, nativeTab, nativeTab.id); + } +} + +class WindowManager extends WindowManagerBase { + get(windowId, context) { + const window = windowTracker.getWindow(windowId, context); + + return this.getWrapper(window); + } + + *getAll(context) { + for (const window of windowTracker.browserWindows()) { + if (!this.canAccessWindow(window, context)) { + continue; + } + const wrapped = this.getWrapper(window); + if (wrapped) { + yield wrapped; + } + } + } + + wrapWindow(window) { + return new Window(this.extension, window, windowTracker.getId(window)); + } +} + +// eslint-disable-next-line mozilla/balanced-listeners +extensions.on("startup", (type, extension) => { + defineLazyGetter(extension, "tabManager", () => new TabManager(extension)); + defineLazyGetter( + extension, + "windowManager", + () => new WindowManager(extension) + ); +}); + +/* eslint-disable mozilla/balanced-listeners */ +extensions.on("page-shutdown", (type, context) => { + if (context.viewType == "tab") { + const window = context.xulBrowser.ownerGlobal; + GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + } +}); +/* eslint-enable mozilla/balanced-listeners */ + +global.openOptionsPage = async extension => { + const { options_ui } = extension.manifest; + const extensionId = extension.id; + + if (options_ui.open_in_tab) { + // Delegate new tab creation and open the options page in the new tab. + const tab = await GeckoViewTabBridge.createNewTab({ + extensionId, + createProperties: { + url: options_ui.page, + active: true, + }, + }); + + const { browser } = tab; + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + + browser.fixupAndLoadURIString(options_ui.page, { + flags, + triggeringPrincipal: extension.principal, + }); + + const newWindow = browser.ownerGlobal; + mobileWindowTracker.setTabActive(newWindow, true); + return; + } + + // Delegate option page handling to the app. + return GeckoViewTabBridge.openOptionsPage(extensionId); +}; diff --git a/mobile/android/components/extensions/ext-android.json b/mobile/android/components/extensions/ext-android.json new file mode 100644 index 0000000000..987dcc14b3 --- /dev/null +++ b/mobile/android/components/extensions/ext-android.json @@ -0,0 +1,31 @@ +{ + "browserAction": { + "url": "chrome://geckoview/content/ext-browserAction.js", + "schema": "chrome://extensions/content/schemas/browser_action.json", + "scopes": ["addon_parent"], + "manifest": ["browser_action", "action"], + "paths": [["browserAction"], ["action"]] + }, + "browsingData": { + "url": "chrome://extensions/content/parent/ext-browsingData.js", + "schema": "chrome://extensions/content/schemas/browsing_data.json", + "scopes": ["addon_parent"], + "paths": [["browsingData"]] + }, + "pageAction": { + "url": "chrome://geckoview/content/ext-pageAction.js", + "schema": "chrome://extensions/content/schemas/page_action.json", + "scopes": ["addon_parent"], + "manifest": ["page_action"], + "paths": [["pageAction"]] + }, + "tabs": { + "url": "chrome://geckoview/content/ext-tabs.js", + "schema": "chrome://geckoview/content/schemas/tabs.json", + "scopes": ["addon_parent"], + "paths": [["tabs"]] + }, + "geckoViewAddons": { + "schema": "chrome://geckoview/content/schemas/gecko_view_addons.json" + } +} diff --git a/mobile/android/components/extensions/ext-browserAction.js b/mobile/android/components/extensions/ext-browserAction.js new file mode 100644 index 0000000000..f8c3c3ce74 --- /dev/null +++ b/mobile/android/components/extensions/ext-browserAction.js @@ -0,0 +1,191 @@ +/* -*- 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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const { BrowserActionBase } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionActions.sys.mjs" +); + +const BROWSER_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "badgeBackgroundColor", + "badgeTextColor", + "enabled", + "patternMatching", +]; + +class BrowserAction extends BrowserActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: BROWSER_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:BrowserAction:Update", + }); + } + + openPopup(tab, openPopupWithoutUserInteraction = false) { + const popupUri = openPopupWithoutUserInteraction + ? this.getPopupUrl(tab) + : this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:BrowserAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + getWindow(windowId) { + return this.helper.getWindow(windowId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.browserAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.browserActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + this.action = new BrowserAction(extension, this); + await this.action.loadIconData(); + + GeckoViewWebExtension.browserActions.set(extension, this.action); + + // Notify the embedder of this action + this.action.updateOnChange(null); + } + + onShutdown() { + const { extension } = this; + this.action.onShutdown(); + GeckoViewWebExtension.browserActions.delete(extension); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + PERSISTENT_EVENTS = { + onClicked({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.sync(tabManager.convert(tab)); + } + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + const { action } = this; + const namespace = + extension.manifestVersion < 3 ? "browserAction" : "action"; + + return { + [namespace]: { + ...action.api(context), + + onClicked: new EventManager({ + context, + // module name is "browserAction" because it the name used in the + // ext-browser.json, independently from the manifest version. + module: "browserAction", + event: "onClicked", + // NOTE: Firefox Desktop event has inputHandling set to true here. + // inputHandling: true, + extensionApi: this, + }).api(), + + openPopup: options => { + const isHandlingUserInput = + context.callContextData?.isHandlingUserInput; + + if ( + !Services.prefs.getBoolPref( + "extensions.openPopupWithoutUserGesture.enabled" + ) && + !isHandlingUserInput + ) { + throw new ExtensionError("openPopup requires a user gesture"); + } + + const currentWindow = windowTracker.getCurrentWindow(context); + + const window = + typeof options?.windowId === "number" + ? windowTracker.getWindow(options.windowId, context) + : currentWindow; + + if (window !== currentWindow) { + throw new ExtensionError( + "Only the current window is supported on Android." + ); + } + + if (this.action.getPopupUrl(window.tab, true)) { + action.openPopup(window.tab, !isHandlingUserInput); + } + }, + }, + }; + } +}; + +global.browserActionFor = this.browserAction.for; diff --git a/mobile/android/components/extensions/ext-c-android.js b/mobile/android/components/extensions/ext-c-android.js new file mode 100644 index 0000000000..3f2392a9c2 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-android.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +extensions.registerModules({ + tabs: { + url: "chrome://geckoview/content/ext-c-tabs.js", + scopes: ["addon_child"], + paths: [["tabs"]], + }, +}); diff --git a/mobile/android/components/extensions/ext-c-tabs.js b/mobile/android/components/extensions/ext-c-tabs.js new file mode 100644 index 0000000000..cad1e29051 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-tabs.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.tabs = class extends ExtensionAPI { + getAPI(context) { + return { + tabs: { + connect(tabId, options) { + const { frameId = null, name = "" } = options || {}; + return context.messenger.connect({ name, tabId, frameId }); + }, + + sendMessage(tabId, message, options, callback) { + const arg = { tabId, frameId: options?.frameId, message, callback }; + return context.messenger.sendRuntimeMessage(arg); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-downloads.js b/mobile/android/components/extensions/ext-downloads.js new file mode 100644 index 0000000000..116f976cbf --- /dev/null +++ b/mobile/android/components/extensions/ext-downloads.js @@ -0,0 +1,310 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + DownloadTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +Cu.importGlobalProperties(["PathUtils"]); + +var { ignoreEvent } = ExtensionCommon; + +const REQUEST_DOWNLOAD_MESSAGE = "GeckoView:WebExtension:Download"; + +const FORBIDDEN_HEADERS = [ + "ACCEPT-CHARSET", + "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", + "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", + "CONTENT-LENGTH", + "COOKIE", + "COOKIE2", + "DATE", + "DNT", + "EXPECT", + "HOST", + "KEEP-ALIVE", + "ORIGIN", + "TE", + "TRAILER", + "TRANSFER-ENCODING", + "UPGRADE", + "VIA", +]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +const State = { + IN_PROGRESS: "in_progress", + INTERRUPTED: "interrupted", + COMPLETE: "complete", +}; + +const STATE_MAP = new Map([ + [0, State.IN_PROGRESS], + [1, State.INTERRUPTED], + [2, State.COMPLETE], +]); + +const INTERRUPT_REASON_MAP = new Map([ + [0, undefined], + [1, "FILE_FAILED"], + [2, "FILE_ACCESS_DENIED"], + [3, "FILE_NO_SPACE"], + [4, "FILE_NAME_TOO_LONG"], + [5, "FILE_TOO_LARGE"], + [6, "FILE_VIRUS_INFECTED"], + [7, "FILE_TRANSIENT_ERROR"], + [8, "FILE_BLOCKED"], + [9, "FILE_SECURITY_CHECK_FAILED"], + [10, "FILE_TOO_SHORT"], + [11, "NETWORK_FAILED"], + [12, "NETWORK_TIMEOUT"], + [13, "NETWORK_DISCONNECTED"], + [14, "NETWORK_SERVER_DOWN"], + [15, "NETWORK_INVALID_REQUEST"], + [16, "SERVER_FAILED"], + [17, "SERVER_NO_RANGE"], + [18, "SERVER_BAD_CONTENT"], + [19, "SERVER_UNAUTHORIZED"], + [20, "SERVER_CERT_PROBLEM"], + [21, "SERVER_FORBIDDEN"], + [22, "USER_CANCELED"], + [23, "USER_SHUTDOWN"], + [24, "CRASH"], +]); + +// TODO Bug 1247794: make id and extension info persistent +class DownloadItem { + /** + * Initializes an object that represents a download + * + * @param {object} downloadInfo - an object from Java when creating a download + * @param {object} options - an object passed in to download() function + * @param {Extension} extension - instance of an extension object + */ + constructor(downloadInfo, options, extension) { + this.id = downloadInfo.id; + this.url = options.url; + this.referrer = downloadInfo.referrer || ""; + this.filename = downloadInfo.filename || ""; + this.incognito = options.incognito; + this.danger = "safe"; // todo; not implemented in desktop either + this.mime = downloadInfo.mime || ""; + this.startTime = downloadInfo.startTime; + this.state = STATE_MAP.get(downloadInfo.state); + this.paused = downloadInfo.paused; + this.canResume = downloadInfo.canResume; + this.bytesReceived = downloadInfo.bytesReceived; + this.totalBytes = downloadInfo.totalBytes; + this.fileSize = downloadInfo.fileSize; + this.exists = downloadInfo.exists; + this.byExtensionId = extension?.id; + this.byExtensionName = extension?.name; + } + + /** + * This function updates the download item it was called on. + * + * @param {object} data that arrived from the app (Java) + * @returns {object | null} an object of <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged#downloaddelta>downloadDelta type</a> + */ + update(data) { + const { downloadItemId } = data; + const delta = {}; + + data.state = STATE_MAP.get(data.state); + data.error = INTERRUPT_REASON_MAP.get(data.error); + delete data.downloadItemId; + + let changed = false; + for (const prop in data) { + const current = data[prop] ?? null; + const previous = this[prop] ?? null; + if (current !== previous) { + delta[prop] = { current, previous }; + this[prop] = current; + changed = true; + } + } + + // Don't send empty onChange events + if (!changed) { + return null; + } + + delta.id = downloadItemId; + + return delta; + } +} + +this.downloads = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onChanged({ fire, context }, params) { + const listener = (eventName, event) => { + const { delta, downloadItem } = event; + const { extension } = this; + if (extension.privateBrowsingAllowed || !downloadItem.incognito) { + fire.async(delta); + } + }; + DownloadTracker.on("download-changed", listener); + + return { + unregister() { + DownloadTracker.off("download-changed", listener); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + return { + downloads: { + download(options) { + // the validation checks should be kept in sync with the toolkit implementation + const { filename } = options; + if (filename != null) { + if (!filename.length) { + return Promise.reject({ message: "filename must not be empty" }); + } + + if (PathUtils.isAbsolute(filename)) { + return Promise.reject({ + message: "filename must not be an absolute path", + }); + } + + const pathComponents = PathUtils.splitRelative(filename, { + allowEmpty: true, + allowCurrentDir: true, + allowParentDir: true, + }); + + if (pathComponents.some(component => component == "..")) { + return Promise.reject({ + message: "filename must not contain back-references (..)", + }); + } + + if ( + pathComponents.some(component => { + const sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + return component != sanitized; + }) + ) { + return Promise.reject({ + message: "filename must not contain illegal characters", + }); + } + } + + if (options.incognito && !context.privateBrowsingAllowed) { + return Promise.reject({ + message: "Private browsing access not allowed", + }); + } + + if (options.cookieStoreId != null) { + // https://bugzilla.mozilla.org/show_bug.cgi?id=1721460 + throw new ExtensionError("Not implemented"); + } + + if (options.headers) { + for (const { name } of options.headers) { + if ( + FORBIDDEN_HEADERS.includes(name.toUpperCase()) || + name.match(FORBIDDEN_PREFIXES) + ) { + return Promise.reject({ + message: "Forbidden request header name", + }); + } + } + } + + return EventDispatcher.instance + .sendRequestForResult({ + type: REQUEST_DOWNLOAD_MESSAGE, + options, + extensionId: extension.id, + }) + .then(value => { + const downloadItem = new DownloadItem(value, options, extension); + DownloadTracker.addDownloadItem(downloadItem); + return downloadItem.id; + }); + }, + + removeFile(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + search(query) { + throw new ExtensionError("Not implemented"); + }, + + pause(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + resume(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + cancel(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + showDefaultFolder() { + throw new ExtensionError("Not implemented"); + }, + + erase(query) { + throw new ExtensionError("Not implemented"); + }, + + open(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + show(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + getFileIcon(downloadId, options) { + throw new ExtensionError("Not implemented"); + }, + + onChanged: new EventManager({ + context, + module: "downloads", + event: "onChanged", + extensionApi: this, + }).api(), + + onCreated: ignoreEvent(context, "downloads.onCreated"), + + onErased: ignoreEvent(context, "downloads.onErased"), + + onDeterminingFilename: ignoreEvent( + context, + "downloads.onDeterminingFilename" + ), + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-pageAction.js b/mobile/android/components/extensions/ext-pageAction.js new file mode 100644 index 0000000000..a73d7dd11a --- /dev/null +++ b/mobile/android/components/extensions/ext-pageAction.js @@ -0,0 +1,153 @@ +/* -*- 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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const { PageActionBase } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionActions.sys.mjs" +); + +const PAGE_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "enabled", + "patternMatching", +]; + +class PageAction extends PageActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: PAGE_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + // The embedder only gets the override, not the full object + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:PageAction:Update", + }); + } + + openPopup() { + const tab = tabTracker.activeTab; + const popupUri = this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:PageAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.pageAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.pageActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + const action = new PageAction(extension, this); + await action.loadIconData(); + this.action = action; + + GeckoViewWebExtension.pageActions.set(extension, action); + + // Notify the embedder of this action + action.updateOnChange(null); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + onShutdown() { + const { extension, action } = this; + action.onShutdown(); + GeckoViewWebExtension.pageActions.delete(extension); + } + + PERSISTENT_EVENTS = { + onClicked({ fire }) { + const { extension } = this; + const { tabManager } = extension; + + const listener = async (_event, tab) => { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.async(tabManager.convert(tab)); + }; + + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire, _extContext) { + fire = newFire; + }, + }; + }, + }; + + getAPI(context) { + const { action } = this; + + return { + pageAction: { + ...action.api(context), + + onClicked: new EventManager({ + context, + module: "pageAction", + event: "onClicked", + extensionApi: this, + }).api(), + + openPopup() { + action.openPopup(); + }, + }, + }; + } +}; + +global.pageActionFor = this.pageAction.for; diff --git a/mobile/android/components/extensions/ext-tabs.js b/mobile/android/components/extensions/ext-tabs.js new file mode 100644 index 0000000000..3d964dd164 --- /dev/null +++ b/mobile/android/components/extensions/ext-tabs.js @@ -0,0 +1,584 @@ +/* -*- 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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const getBrowserWindow = window => { + return window.browsingContext.topChromeWindow; +}; + +const tabListener = { + tabReadyInitialized: false, + tabReadyPromises: new WeakMap(), + initializingTabs: new WeakSet(), + + initTabReady() { + if (!this.tabReadyInitialized) { + windowTracker.addListener("progress", this); + + this.tabReadyInitialized = true; + } + }, + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + const { tab } = browser.ownerGlobal; + + // Ignore initial about:blank + if (!request && this.initializingTabs.has(tab)) { + return; + } + + // Now we are certain that the first page in the tab was loaded. + this.initializingTabs.delete(tab); + + // browser.innerWindowID is now set, resolve the promises if any. + const deferred = this.tabReadyPromises.get(tab); + if (deferred) { + deferred.resolve(tab); + this.tabReadyPromises.delete(tab); + } + } + }, + + /** + * Returns a promise that resolves when the tab is ready. + * Tabs created via the `tabs.create` method are "ready" once the location + * changes to the requested URL. Other tabs are assumed to be ready once their + * inner window ID is known. + * + * @param {NativeTab} nativeTab The native tab object. + * @returns {Promise} Resolves with the given tab once ready. + */ + awaitTabReady(nativeTab) { + let deferred = this.tabReadyPromises.get(nativeTab); + if (!deferred) { + deferred = PromiseUtils.defer(); + if ( + !this.initializingTabs.has(nativeTab) && + (nativeTab.browser.innerWindowID || + nativeTab.browser.currentURI.spec === "about:blank") + ) { + deferred.resolve(nativeTab); + } else { + this.initTabReady(); + this.tabReadyPromises.set(nativeTab, deferred); + } + } + return deferred.promise; + }, +}; + +this.tabs = class extends ExtensionAPIPersistent { + tabEventRegistrar({ event, listener }) { + const { extension } = this; + const { tabManager } = extension; + return ({ fire }) => { + const listener2 = (eventName, eventData, ...args) => { + if (!tabManager.canAccessTab(eventData.nativeTab)) { + return; + } + + listener(fire, eventData, ...args); + }; + + tabTracker.on(event, listener2); + return { + unregister() { + tabTracker.off(event, listener2); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onActivated({ fire, context }, params) { + const listener = (eventName, event) => { + const { windowId, tabId, isPrivate } = event; + if (isPrivate && !context.privateBrowsingAllowed) { + return; + } + // In GeckoView each window has only one tab, so previousTabId is omitted. + fire.async({ windowId, tabId }); + }; + + mobileWindowTracker.on("tab-activated", listener); + return { + unregister() { + mobileWindowTracker.off("tab-activated", listener); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + onCreated: this.tabEventRegistrar({ + event: "tab-created", + listener: (fire, event) => { + const { tabManager } = this.extension; + fire.async(tabManager.convert(event.nativeTab)); + }, + }), + onRemoved: this.tabEventRegistrar({ + event: "tab-removed", + listener: (fire, event) => { + fire.async(event.tabId, { + windowId: event.windowId, + isWindowClosing: event.isWindowClosing, + }); + }, + }), + onUpdated({ fire, context }, params) { + const { tabManager } = this.extension; + const restricted = ["url", "favIconUrl", "title"]; + + function sanitize(tab, changeInfo) { + const result = {}; + let nonempty = false; + for (const prop in changeInfo) { + // In practice, changeInfo contains at most one property from + // restricted. Therefore it is not necessary to cache the value + // of tab.hasTabPermission outside the loop. + if (!restricted.includes(prop) || tab.hasTabPermission) { + nonempty = true; + result[prop] = changeInfo[prop]; + } + } + return [nonempty, result]; + } + + const fireForTab = (tab, changed) => { + const [needed, changeInfo] = sanitize(tab, changed); + if (needed) { + fire.async(tab.id, changeInfo, tab.convert()); + } + }; + + const listener = event => { + const needed = []; + let nativeTab; + switch (event.type) { + case "pagetitlechanged": { + const window = getBrowserWindow(event.target.ownerGlobal); + nativeTab = window.tab; + + needed.push("title"); + break; + } + + case "DOMAudioPlaybackStarted": + case "DOMAudioPlaybackStopped": { + const window = event.target.ownerGlobal; + nativeTab = window.tab; + needed.push("audible"); + break; + } + } + + if (!nativeTab) { + return; + } + + const tab = tabManager.getWrapper(nativeTab); + const changeInfo = {}; + for (const prop of needed) { + changeInfo[prop] = tab[prop]; + } + + fireForTab(tab, changeInfo); + }; + + const statusListener = ({ browser, status, url }) => { + const { tab } = browser.ownerGlobal; + if (tab) { + const changed = { status }; + if (url) { + changed.url = url; + } + + fireForTab(tabManager.wrapTab(tab), changed); + } + }; + + windowTracker.addListener("status", statusListener); + windowTracker.addListener("pagetitlechanged", listener); + + return { + unregister() { + windowTracker.removeListener("status", statusListener); + windowTracker.removeListener("pagetitlechanged", listener); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + const { tabManager } = extension; + const extensionApi = this; + const module = "tabs"; + + function getTabOrActive(tabId) { + if (tabId !== null) { + return tabTracker.getTab(tabId); + } + return tabTracker.activeTab; + } + + async function promiseTabWhenReady(tabId) { + let tab; + if (tabId !== null) { + tab = tabManager.get(tabId); + } else { + tab = tabManager.getWrapper(tabTracker.activeTab); + } + if (!tab) { + throw new ExtensionError( + tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}` + ); + } + + await tabListener.awaitTabReady(tab.nativeTab); + + return tab; + } + + function loadURIInTab(nativeTab, url) { + const { browser } = nativeTab; + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let { principal } = context; + const isAboutUrl = url.startsWith("about:"); + if ( + isAboutUrl || + (url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true })) + ) { + // Falling back to content here as about: requires it, however is safe. + principal = + Services.scriptSecurityManager.getLoadContextContentPrincipal( + Services.io.newURI(url), + browser.loadContext + ); + } + if (isAboutUrl) { + // Make sure things like about:blank and other about: URIs never + // inherit, and instead always get a NullPrincipal. + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; + } + + browser.fixupAndLoadURIString(url, { + flags, + triggeringPrincipal: principal, + }); + } + + return { + tabs: { + onActivated: new EventManager({ + context, + module, + event: "onActivated", + extensionApi, + }).api(), + + onCreated: new EventManager({ + context, + module, + event: "onCreated", + extensionApi, + }).api(), + + /** + * Since multiple tabs currently can't be highlighted, onHighlighted + * essentially acts an alias for tabs.onActivated but returns + * the tabId in an array to match the API. + * + * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted + */ + onHighlighted: makeGlobalEvent( + context, + "tabs.onHighlighted", + "Tab:Selected", + (fire, data) => { + const tab = tabManager.get(data.id); + + fire.async({ tabIds: [tab.id], windowId: tab.windowId }); + } + ), + + // Some events below are not be persisted because they are not implemented. + // They do not have an "extensionApi" property with an entry in + // PERSISTENT_EVENTS, but instead an empty "register" method. + onAttached: new EventManager({ + context, + name: "tabs.onAttached", + register: fire => { + return () => {}; + }, + }).api(), + + onDetached: new EventManager({ + context, + name: "tabs.onDetached", + register: fire => { + return () => {}; + }, + }).api(), + + onRemoved: new EventManager({ + context, + module, + event: "onRemoved", + extensionApi, + }).api(), + + onReplaced: new EventManager({ + context, + name: "tabs.onReplaced", + register: fire => { + return () => {}; + }, + }).api(), + + onMoved: new EventManager({ + context, + name: "tabs.onMoved", + register: fire => { + return () => {}; + }, + }).api(), + + onUpdated: new EventManager({ + context, + module, + event: "onUpdated", + extensionApi, + }).api(), + + async create({ + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + title, + url, + } = {}) { + if (active === null) { + active = true; + } + + tabListener.initTabReady(); + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + if (cookieStoreId) { + cookieStoreId = getUserContextIdForCookieStoreId( + extension, + cookieStoreId, + false // TODO bug 1372178: support creation of private browsing tabs + ); + } + cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined; + + const nativeTab = await GeckoViewTabBridge.createNewTab({ + extensionId: context.extension.id, + createProperties: { + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + url, + }, + }); + + // Make sure things like about:blank URIs never inherit, + // and instead always get a NullPrincipal. + if (url !== null) { + tabListener.initializingTabs.add(nativeTab); + } else { + url = "about:blank"; + } + + loadURIInTab(nativeTab, url); + + if (active) { + const newWindow = nativeTab.browser.ownerGlobal; + mobileWindowTracker.setTabActive(newWindow, true); + } + + return tabManager.convert(nativeTab); + }, + + async remove(tabs) { + if (!Array.isArray(tabs)) { + tabs = [tabs]; + } + + await Promise.all( + tabs.map(async tabId => { + const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId); + const window = windowTracker.getWindow(windowId, context, false); + if (!window) { + throw new ExtensionError(`Invalid tab ID ${tabId}`); + } + await GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + }) + ); + }, + + async update( + tabId, + { active, autoDiscardable, highlighted, muted, pinned, url } = {} + ) { + const nativeTab = getTabOrActive(tabId); + const window = nativeTab.browser.ownerGlobal; + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + await GeckoViewTabBridge.updateTab({ + window, + extensionId: context.extension.id, + updateProperties: { + active, + autoDiscardable, + highlighted, + muted, + pinned, + url, + }, + }); + + if (url !== null) { + loadURIInTab(nativeTab, url); + } + + // FIXME: openerTabId, successorTabId + if (active) { + mobileWindowTracker.setTabActive(window, true); + } + + return tabManager.convert(nativeTab); + }, + + async reload(tabId, reloadProperties) { + const nativeTab = getTabOrActive(tabId); + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (reloadProperties && reloadProperties.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + nativeTab.browser.reloadWithFlags(flags); + }, + + async get(tabId) { + return tabManager.get(tabId).convert(); + }, + + async getCurrent() { + if (context.tabId) { + return tabManager.get(context.tabId).convert(); + } + }, + + async query(queryInfo) { + return Array.from(tabManager.query(queryInfo, context), tab => + tab.convert() + ); + }, + + async captureTab(tabId, options) { + const nativeTab = getTabOrActive(tabId); + await tabListener.awaitTabReady(nativeTab); + + const { browser } = nativeTab; + const tab = tabManager.wrapTab(nativeTab); + return tab.capture(context, browser.fullZoom, options); + }, + + async captureVisibleTab(windowId, options) { + const window = + windowId == null + ? windowTracker.topWindow + : windowTracker.getWindow(windowId, context); + + const tab = tabManager.wrapTab(window.tab); + await tabListener.awaitTabReady(tab.nativeTab); + const zoom = window.browsingContext.fullZoom; + + return tab.capture(context, zoom, options); + }, + + async executeScript(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.executeScript(context, details); + }, + + async insertCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.insertCSS(context, details); + }, + + async removeCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.removeCSS(context, details); + }, + + goForward(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goForward(); + }, + + goBack(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goBack(); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/extensions-mobile.manifest b/mobile/android/components/extensions/extensions-mobile.manifest new file mode 100644 index 0000000000..34850bed8b --- /dev/null +++ b/mobile/android/components/extensions/extensions-mobile.manifest @@ -0,0 +1,5 @@ +# modules +category webextension-modules android chrome://geckoview/content/ext-android.json + +category webextension-scripts c-android chrome://geckoview/content/ext-android.js +category webextension-scripts-addon android chrome://geckoview/content/ext-c-android.js diff --git a/mobile/android/components/extensions/jar.mn b/mobile/android/components/extensions/jar.mn new file mode 100644 index 0000000000..29d4cd01cb --- /dev/null +++ b/mobile/android/components/extensions/jar.mn @@ -0,0 +1,14 @@ +# 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/. + +geckoview.jar: + content/ext-android.js + content/ext-android.json + content/ext-browserAction.js + content/ext-c-android.js + content/ext-c-tabs.js + content/ext-pageAction.js + content/ext-tabs.js + content/ext-downloads.js +% override chrome://extensions/content/parent/ext-downloads.js chrome://geckoview/content/ext-downloads.js diff --git a/mobile/android/components/extensions/moz.build b/mobile/android/components/extensions/moz.build new file mode 100644 index 0000000000..9c61bf0e94 --- /dev/null +++ b/mobile/android/components/extensions/moz.build @@ -0,0 +1,21 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ExtensionBrowsingData.sys.mjs", +] + +EXTRA_COMPONENTS += [ + "extensions-mobile.manifest", +] + +DIRS += ["schemas"] + +MOCHITEST_MANIFESTS += ["test/mochitest/mochitest.ini"] +MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] diff --git a/mobile/android/components/extensions/schemas/gecko_view_addons.json b/mobile/android/components/extensions/schemas/gecko_view_addons.json new file mode 100644 index 0000000000..b60d346d19 --- /dev/null +++ b/mobile/android/components/extensions/schemas/gecko_view_addons.json @@ -0,0 +1,16 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["geckoViewAddons", "nativeMessagingFromContent"] + } + ] + } + ] + } +] diff --git a/mobile/android/components/extensions/schemas/jar.mn b/mobile/android/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..9f15031cd1 --- /dev/null +++ b/mobile/android/components/extensions/schemas/jar.mn @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: + content/schemas/gecko_view_addons.json + content/schemas/tabs.json diff --git a/mobile/android/components/extensions/schemas/moz.build b/mobile/android/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/components/extensions/schemas/tabs.json b/mobile/android/components/extensions/schemas/tabs.json new file mode 100644 index 0000000000..6d304affa1 --- /dev/null +++ b/mobile/android/components/extensions/schemas/tabs.json @@ -0,0 +1,1395 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["activeTab"] + } + ] + }, + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["tabs"] + } + ] + } + ] + }, + { + "namespace": "tabs", + "description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.", + "types": [ + { + "id": "MutedInfoReason", + "type": "string", + "description": "An event that caused a muted state change.", + "enum": [ + { + "name": "user", + "description": "A user input action has set/overridden the muted state." + }, + { + "name": "capture", + "description": "Tab capture started, forcing a muted state change." + }, + { + "name": "extension", + "description": "An extension, identified by the extensionId field, set the muted state." + } + ] + }, + { + "id": "MutedInfo", + "type": "object", + "description": "Tab muted state and the reason for the last state change.", + "properties": { + "muted": { + "type": "boolean", + "description": "Whether the tab is prevented from playing sound (but hasn't necessarily recently produced sound). Equivalent to whether the muted audio indicator is showing." + }, + "reason": { + "$ref": "MutedInfoReason", + "optional": true, + "description": "The reason the tab was muted or unmuted. Not set if the tab's mute state has never been changed." + }, + "extensionId": { + "type": "string", + "optional": true, + "description": "The ID of the extension that changed the muted state. Not set if an extension was not the reason the muted state last changed." + } + } + }, + { + "id": "SharingState", + "type": "object", + "description": "Tab sharing state for screen, microphone and camera. Currently unsupported on Android.", + "properties": { + "screen": { + "type": "string", + "optional": true, + "description": "If the tab is sharing the screen the value will be one of \"Screen\", \"Window\", or \"Application\", or undefined if not screen sharing." + }, + "camera": { + "type": "boolean", + "description": "True if the tab is using the camera." + }, + "microphone": { + "type": "boolean", + "description": "True if the tab is using the microphone." + } + } + }, + { + "id": "Tab", + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": -1, + "optional": true, + "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a Tab may not be assigned an ID, for example when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to $(ref:tabs.TAB_ID_NONE) for apps and devtools windows." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The zero-based index of the tab within its window." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "The ID of the window the tab is contained within." + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists." + }, + "highlighted": { + "type": "boolean", + "description": "Whether the tab is highlighted. Works as an alias of active." + }, + "active": { + "type": "boolean", + "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)" + }, + "pinned": { + "type": "boolean", + "description": "Whether the tab is pinned." + }, + "lastAccessed": { + "type": "integer", + "optional": true, + "description": "The last time the tab was accessed as the number of milliseconds since epoch." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "Current tab muted state and the reason for the last state change." + }, + "url": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission." + }, + "title": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading." + }, + "status": { + "type": "string", + "optional": true, + "description": "Either <em>loading</em> or <em>complete</em>." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tab is not loaded with content." + }, + "incognito": { + "type": "boolean", + "description": "Whether the tab is in an incognito window." + }, + "width": { + "type": "integer", + "optional": true, + "description": "The width of the tab in pixels." + }, + "height": { + "type": "integer", + "optional": true, + "description": "The height of the tab in pixels." + }, + "hidden": { + "type": "boolean", + "optional": true, + "description": "True if the tab is hidden." + }, + "sessionId": { + "type": "string", + "optional": true, + "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The CookieStoreId used for the tab." + }, + "isArticle": { + "type": "boolean", + "optional": true, + "description": "Whether the document in the tab can be rendered in reader mode." + }, + "isInReaderMode": { + "type": "boolean", + "optional": true, + "description": "Whether the document in the tab is being rendered in reader mode." + }, + "sharingState": { + "$ref": "SharingState", + "optional": true, + "description": "Current tab sharing state for screen, microphone and camera." + }, + "attention": { + "type": "boolean", + "optional": true, + "description": "Whether the tab is drawing attention." + }, + "successorTabId": { + "type": "integer", + "optional": true, + "minimum": -1, + "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise." + } + } + }, + { + "id": "ZoomSettingsMode", + "type": "string", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "enum": [ + { + "name": "automatic", + "description": "Zoom changes are handled automatically by the browser." + }, + { + "name": "manual", + "description": "Overrides the automatic handling of zoom changes. The <code>onZoomChange</code> event will still be dispatched, and it is the responsibility of the extension to listen for this event and manually scale the page. This mode does not support <code>per-origin</code> zooming, and will thus ignore the <code>scope</code> zoom setting and assume <code>per-tab</code>." + }, + { + "name": "disabled", + "description": "Disables all zooming in the tab. The tab will revert to the default zoom level, and all attempted zoom changes will be ignored." + } + ] + }, + { + "id": "ZoomSettingsScope", + "type": "string", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "enum": [ + { + "name": "per-origin", + "description": "Zoom changes will persist in the zoomed page's origin, i.e. all other tabs navigated to that same origin will be zoomed as well. Moreover, <code>per-origin</code> zoom changes are saved with the origin, meaning that when navigating to other pages in the same origin, they will all be zoomed to the same zoom factor. The <code>per-origin</code> scope is only available in the <code>automatic</code> mode." + }, + { + "name": "per-tab", + "description": "Zoom changes only take effect in this tab, and zoom changes in other tabs will not affect the zooming of this tab. Also, <code>per-tab</code> zoom changes are reset on navigation; navigating a tab will always load pages with their <code>per-origin</code> zoom factors." + } + ] + }, + { + "id": "ZoomSettings", + "type": "object", + "description": "Defines how zoom changes in a tab are handled and at what scope.", + "properties": { + "mode": { + "$ref": "ZoomSettingsMode", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "optional": true + }, + "scope": { + "$ref": "ZoomSettingsScope", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "optional": true + }, + "defaultZoomFactor": { + "type": "number", + "optional": true, + "description": "Used to return the default zoom level for the current tab in calls to tabs.getZoomSettings." + } + } + }, + { + "id": "TabStatus", + "type": "string", + "enum": ["loading", "complete"], + "description": "Whether the tabs have completed loading." + }, + { + "id": "WindowType", + "type": "string", + "enum": ["normal", "popup", "panel", "app", "devtools"], + "description": "The type of window." + } + ], + "properties": { + "TAB_ID_NONE": { + "value": -1, + "description": "An ID which represents the absence of a browser tab." + } + }, + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "function", + "name": "callback", + "parameters": [{ "name": "tab", "$ref": "Tab" }] + } + ] + }, + { + "name": "getCurrent", + "type": "function", + "description": "Gets the tab that this script call is being made from. May be undefined if called from a non-tab context (for example: a background page or popup view).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true + } + ] + } + ] + }, + { + "name": "connect", + "type": "function", + "description": "Connects to the content script(s) in the specified tab. The $(ref:runtime.onConnect) event is fired in each content script running in the specified tab for the current extension. For more details, see $(topic:messaging)[Content Script Messaging].", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Will be passed into onConnect for content scripts that are listening for the connection event." + }, + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Open a port to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + } + ], + "returns": { + "$ref": "runtime.Port", + "description": "A port that can be used to communicate with the content scripts running in the specified tab. The port's $(ref:runtime.Port) event is fired if the tab closes or does not exist. " + } + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a single message to the content script(s) in the specified tab, with an optional callback to run when a response is sent back. The $(ref:runtime.onMessage) event is fired in each content script running in the specified tab for the current extension.", + "async": "responseCallback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "any", + "name": "message" + }, + { + "type": "object", + "name": "options", + "properties": { + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Send a message to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a new tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "createProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "The window to create the new tab in. Defaults to the $(topic:current-window)[current window]." + }, + "index": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The position the tab should take in the window. The provided value will be clamped to between zero and the number of tabs in the window." + }, + "url": { + "type": "string", + "optional": true, + "description": "The URL to navigate the tab to initially. Fully-qualified URLs must include a scheme (i.e. 'http://www.google.com', not 'www.google.com'). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should become the active tab in the window. Does not affect whether the window is focused (see $(ref:windows.update)). Defaults to <var>true</var>." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be pinned. Defaults to <var>false</var>" + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as the newly created tab." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The CookieStoreId for the tab that opened this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the created tab. Will contain the ID of the new tab." + } + ] + } + ] + }, + { + "name": "duplicate", + "unsupported": true, + "type": "function", + "description": "Duplicates a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The ID of the tab which is to be duplicated." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "optional": true, + "description": "Details about the duplicated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested.", + "$ref": "Tab" + } + ] + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "queryInfo", + "properties": { + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are active in their windows." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are pinned." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are audible." + }, + "muted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are muted." + }, + "highlighted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are highlighted. Works as an alias of active." + }, + "currentWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the $(topic:current-window)[current window]." + }, + "lastFocusedWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the last focused window." + }, + "status": { + "$ref": "TabStatus", + "optional": true, + "description": "Whether the tabs have completed loading." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tabs are not loaded with content." + }, + "title": { + "type": "string", + "optional": true, + "description": "Match page titles against a pattern." + }, + "url": { + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "optional": true, + "description": "Match tabs against one or more $(topic:match_patterns)[URL patterns]. Note that fragment identifiers are not matched." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": -2, + "description": "The ID of the parent window, or $(ref:windows.WINDOW_ID_CURRENT) for the $(topic:current-window)[current window]." + }, + "windowType": { + "$ref": "WindowType", + "optional": true, + "description": "The type of window the tabs are in." + }, + "index": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "The position of the tabs within their windows." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "The CookieStoreId used for the tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "array", + "items": { + "$ref": "Tab" + } + } + ] + } + ] + }, + { + "name": "highlight", + "type": "function", + "description": "Highlights the given tabs.", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "optional": true, + "description": "The window that contains the tabs.", + "minimum": -2 + }, + "tabs": { + "description": "One or more tab indices to highlight.", + "choices": [ + { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + { "type": "integer" } + ] + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "window", + "$ref": "windows.Window", + "description": "Contains details about the window whose tabs were highlighted." + } + ] + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Modifies the properties of a tab. Properties that are not specified in <var>updateProperties</var> are not modified.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the selected tab of the $(topic:current-window)[current window]." + }, + { + "type": "object", + "name": "updateProperties", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "A URL to navigate the tab to." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be active. Does not affect whether the window is focused (see $(ref:windows.update))." + }, + "highlighted": { + "unsupported": true, + "type": "boolean", + "optional": true, + "description": "Adds or removes the tab from the current selection." + }, + "pinned": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be pinned." + }, + "muted": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be muted." + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the updated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested." + } + ] + } + ] + }, + { + "name": "move", + "unsupported": true, + "type": "function", + "description": "Moves one or more tabs to a new position within its window, or to a new window. Note that tabs can only be moved to and from normal (window.type === \"normal\") windows.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to move.", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "type": "object", + "name": "moveProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "Defaults to the window the tab is currently in." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The position to move the window to. -1 will place the tab at the end of the window." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tabs", + "description": "Details about the moved tabs.", + "choices": [ + { "$ref": "Tab" }, + { "type": "array", "items": { "$ref": "Tab" } } + ] + } + ] + } + ] + }, + { + "name": "reload", + "type": "function", + "description": "Reload a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to reload; defaults to the selected tab of the current window." + }, + { + "type": "object", + "name": "reloadProperties", + "optional": true, + "properties": { + "bypassCache": { + "type": "boolean", + "optional": true, + "description": "Whether using any local cache. Default is false." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Closes one or more tabs.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to close.", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "detectLanguage", + "unsupported": true, + "type": "function", + "description": "Detects the primary language of the content in a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the active tab of the $(topic:current-window)[current window]." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "language", + "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. The 2nd to 4th columns will be checked and the first non-NULL value will be returned except for Simplified Chinese for which zh-CN will be returned. For an unknown language, <code>und</code> will be returned." + } + ] + } + ] + }, + { + "name": "captureTab", + "type": "function", + "description": "Captures an area of a specified tab. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The tab to capture. Defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + } + ] + }, + { + "name": "captureVisibleTab", + "type": "function", + "description": "Captures an area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": -2, + "optional": true, + "description": "The target window. Defaults to the $(topic:current-window)[current window]." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "dataUrl", + "description": "A data URL which encodes an image of the visible area of the captured tab. May be assigned to the 'src' property of an HTML Image element for display." + } + ] + } + ] + }, + { + "name": "executeScript", + "type": "function", + "max_manifest_version": 2, + "description": "Injects JavaScript code into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to run the script; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the script to run." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after all the JavaScript has been executed.", + "parameters": [ + { + "name": "result", + "optional": true, + "type": "array", + "items": { "type": "any" }, + "description": "The result of the script in every injected frame." + } + ] + } + ] + }, + { + "name": "insertCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Injects CSS into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to insert the CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to insert." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been inserted.", + "parameters": [] + } + ] + }, + { + "name": "removeCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Removes injected CSS from a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab from which to remove the injected CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to remove." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been removed.", + "parameters": [] + } + ] + }, + { + "name": "setZoom", + "unsupported": true, + "type": "function", + "description": "Zooms a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to zoom; defaults to the active tab of the current window." + }, + { + "type": "number", + "name": "zoomFactor", + "description": "The new zoom factor. Use a value of 0 here to set the tab to its current default zoom factor. Values greater than zero specify a (possibly non-default) zoom factor for the tab." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom factor has been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoom", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom factor of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to get the current zoom factor from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom factor after it has been fetched.", + "parameters": [ + { + "type": "number", + "name": "zoomFactor", + "description": "The tab's current zoom factor." + } + ] + } + ] + }, + { + "name": "setZoomSettings", + "unsupported": true, + "type": "function", + "description": "Sets the zoom settings for a specified tab, which define how zoom changes are handled. These settings are reset to defaults upon navigating the tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to change the zoom settings for; defaults to the active tab of the current window." + }, + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "Defines how zoom changes are handled and at what scope." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom settings have been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoomSettings", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom settings of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to get the current zoom settings from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom settings.", + "parameters": [ + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "The tab's current zoom settings." + } + ] + } + ] + }, + { + "name": "goForward", + "type": "function", + "description": "Navigate to next page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate forward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "goBack", + "type": "function", + "description": "Navigate to previous page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate backward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onCreated", + "type": "function", + "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "$ref": "Tab", + "name": "tab", + "description": "Details of the tab that was created." + } + ] + }, + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a tab is updated.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "changeInfo", + "description": "Lists the changes to the state of the tab that was updated.", + "properties": { + "status": { + "type": "string", + "optional": true, + "description": "The status of the tab. Can be either <em>loading</em> or <em>complete</em>." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tab is not loaded with content." + }, + "url": { + "type": "string", + "optional": true, + "description": "The tab's URL if it has changed." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "The tab's new pinned state." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "The tab's new audible state." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "The tab's new muted state and the reason for the change." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "description": "The tab's new favicon URL." + } + } + }, + { + "$ref": "Tab", + "name": "tab", + "description": "Gives the state of the tab that was updated." + } + ] + }, + { + "name": "onMoved", + "type": "function", + "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see $(ref:tabs.onDetached).", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "moveInfo", + "properties": { + "windowId": { "type": "integer", "minimum": 0 }, + "fromIndex": { "type": "integer", "minimum": 0 }, + "toIndex": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onActivated", + "type": "function", + "description": "Fires when the active tab in a window changes. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "type": "object", + "name": "activeInfo", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab that has become active." + }, + "previousTabId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that was previously active, if that tab is still open." + }, + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the window the active tab changed inside of." + } + } + } + ] + }, + { + "name": "onHighlighted", + "type": "function", + "description": "Fired when the highlighted or selected tabs in a window changes.", + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The window whose tabs changed." + }, + "tabIds": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "description": "All highlighted tabs in the window." + } + } + } + ] + }, + { + "name": "onDetached", + "type": "function", + "description": "Fired when a tab is detached from a window, for example because it is being moved between windows.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "detachInfo", + "properties": { + "oldWindowId": { "type": "integer", "minimum": 0 }, + "oldPosition": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onAttached", + "type": "function", + "description": "Fired when a tab is attached to a window, for example because it was moved between windows.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "attachInfo", + "properties": { + "newWindowId": { "type": "integer", "minimum": 0 }, + "newPosition": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when a tab is closed.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "removeInfo", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The window whose tab is closed." + }, + "isWindowClosing": { + "type": "boolean", + "description": "True when the tab is being closed because its window is being closed." + } + } + } + ] + }, + { + "name": "onReplaced", + "type": "function", + "description": "Fired when a tab is replaced with another tab due to prerendering or instant.", + "parameters": [ + { "type": "integer", "name": "addedTabId", "minimum": 0 }, + { "type": "integer", "name": "removedTabId", "minimum": 0 } + ] + }, + { + "name": "onZoomChange", + "unsupported": true, + "type": "function", + "description": "Fired when a tab is zoomed.", + "parameters": [ + { + "type": "object", + "name": "ZoomChangeInfo", + "properties": { + "tabId": { "type": "integer", "minimum": 0 }, + "oldZoomFactor": { "type": "number" }, + "newZoomFactor": { "type": "number" }, + "zoomSettings": { "$ref": "ZoomSettings" } + } + } + ] + } + ] + } +] diff --git a/mobile/android/components/extensions/test/mochitest/.eslintrc.js b/mobile/android/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..7d6fe2eb1a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + extends: + "../../../../../../toolkit/components/extensions/test/mochitest/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/test/mochitest/chrome.ini b/mobile/android/components/extensions/test/mochitest/chrome.ini new file mode 100644 index 0000000000..901c516d0a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/chrome.ini @@ -0,0 +1,7 @@ +[DEFAULT] +support-files = + head.js + ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js +tags = webextensions + +[test_ext_options_ui.html] diff --git a/mobile/android/components/extensions/test/mochitest/context.html b/mobile/android/components/extensions/test/mochitest/context.html new file mode 100644 index 0000000000..1e25c6e851 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context.html @@ -0,0 +1,24 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + <img src="ctxmenu-image.png" id="img1"> + + <p> + <a href="some-link" id="link1">Some link</a> + </p> + + <p> + <a href="image-around-some-link"> + <img src="ctxmenu-image.png" id="img-wrapped-in-link"> + </a> + </p> + + <p> + <input type="text" id="edit-me"><br> + <input type="password" id="password"> + </p> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html new file mode 100644 index 0000000000..1e2afec6fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html @@ -0,0 +1,22 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test iframe</h3> + <script> + "use strict"; + + window.onload = function() { + window.onhashchange = function() { + window.parent.postMessage("updated-iframe-url", "*"); + }; + // NOTE: without the this setTimeout the location change is not fired + // even without the "fire only for top level windows" fix + setTimeout(function() { + window.location.hash = "updated-iframe-url"; + }, 0); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html new file mode 100644 index 0000000000..3fa93979fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html @@ -0,0 +1,21 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test page</h3> + <iframe src="about:blank"></iframe> + <script> + "use strict"; + + window.onmessage = function(evt) { + if (evt.data === "updated-iframe-url") { + window.postMessage("frame-updated", "*"); + } + }; + window.onload = function() { + document.querySelector("iframe").setAttribute("src", "context_tabs_onUpdated_iframe.html"); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs new file mode 100644 index 0000000000..eed8a6ef49 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } +} diff --git a/mobile/android/components/extensions/test/mochitest/file_dummy.html b/mobile/android/components/extensions/test/mochitest/file_dummy.html new file mode 100644 index 0000000000..49ad37128d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_dummy.html @@ -0,0 +1,10 @@ +<html> +<head> +<meta charset="utf-8"> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_iframe_document.html b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html new file mode 100644 index 0000000000..3bb2bd5dcf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title></title> +</head> +<body> + <iframe src="/"></iframe> + <iframe src="about:blank"></iframe> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs new file mode 100644 index 0000000000..3816cf045b --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay one second before completing the request. + +const nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(`<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`); + } + response.write(`</body></html>`); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/mobile/android/components/extensions/test/mochitest/head.js b/mobile/android/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..c240edd765 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/head.js @@ -0,0 +1,73 @@ +"use strict"; + +/* exported assertPersistentListeners, AppConstants, TEST_ICON_ARRAYBUFFER */ + +var { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var TEST_ICON_DATA = + "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC4klEQVRYhdWXLWzbQBSADQtDAwsHC1tUhUxqfL67lk2tdn+OJg0ODU0rLByqgqINBY6tmlbn7LMTJ5FaFVVBk1G0oUGjG2jT2Y7jxmmcbU/6iJ+f36fz+e5sGP9riCGm9hB37RG+scd4Yo/wsDXCZyIE2xuXsce4bY+wXkAsQtzYmExrfFgvkJkRbkzo1ehoxx5iXcgI/9iYUGt8WH9MqDXEcmNChmEYrRCf2SHWeYgQx3x0tLNRIeKQLTtEFyJEep4NTuhk8BC+yMrwEE3+iozo42d8gK7FAOkMsRiiN8QhW2ttSK5QTfRRV4QoymVeJMvPvDp7gCZigD613MN6yRFA3SWarow9QB9LCfG+NeF9qCtjAKOSQjCqVKhfVsiHEQ+grgx/lRGqUihAc1uL8EFD+KCRO+GrF4J61phcoRoPoEzkYhZYpykh5sMb7kOdIeY+jHKur4QI4Feh4AFX1nVeLxrAvQchGsBz5ls6wa2QdwcvIcE2863bTH79KOvsz/uUYJsp+J0pSzNlDckVqqVGUAF+n6uS7txcOl6wot4JVy70ufDLy4pWLUQVPE81pRI0mGe9oxLMHSeohHvMs/STUNaUK6vDPCvOyxMFDx4achehRDJmHnydnkPww5OFfLxrGIZBFDyYl4LpMzlTQFIP6AQx86w2UeYBccFpJrcKv5L9eGDtUAU6RIELqsB74uynjy/UBRF1gS5BTFxwQT1wTiXoUg9MH7m/3NZRRoi5IJytUbMgzv4Wc832+oQkiKgEehmyMkkpKsFkQV11QsRJL5rJYBLItQgRaUZEmnoZXsomz3vGiWw+I9KMF9SVFOqZEemZekli1jN3U/UOqhHHvC6oWWGElhfSpGdOk6+O9prdwvtLj5BjRsQxdRnot+Zeifpy/2/0stktKTRNLmbk0mwXyl8253fyojj+8rxOHNAhjjm5n0/5OOCGOKBzkrMO0Z75lvSAzKlrF32Z/3z8BqLAn+yMV7VhAAAAAElFTkSuQmCC"; + +var TEST_ICON_ARRAYBUFFER = Uint8Array.from(atob(TEST_ICON_DATA), byte => + byte.charCodeAt(0) +).buffer; + +async function assertPersistentListeners( + extWrapper, + apiNs, + apiEvents, + expected +) { + const stringErr = await SpecialPowers.spawnChrome( + [extWrapper.id, apiNs, apiEvents, expected], + async (id, apiNs, apiEvents, expected) => { + try { + const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" + ); + const ext = { id }; + for (const event of apiEvents) { + ExtensionTestCommon.testAssertions.assertPersistentListeners( + ext, + apiNs, + event, + { + primed: expected.primed, + persisted: expected.persisted, + primedListenersCount: expected.primedListenersCount, + } + ); + } + } catch (err) { + return String(err); + } + } + ); + ok( + stringErr == undefined, + stringErr ? stringErr : `Found expected primed and persistent listeners` + ); +} + +{ + const chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + const results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} diff --git a/mobile/android/components/extensions/test/mochitest/mochitest.ini b/mobile/android/components/extensions/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..a8cadf7b5d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/mochitest.ini @@ -0,0 +1,41 @@ +[DEFAULT] +support-files = + ../../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js + ../../../../../../toolkit/components/extensions/test/mochitest/file_sample.html + ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js + context.html + context_tabs_onUpdated_iframe.html + context_tabs_onUpdated_page.html + file_bypass_cache.sjs + file_dummy.html + file_iframe_document.html + file_slowed_document.sjs + head.js +tags = webextensions +prefs = + javascript.options.asyncstack_capture_debuggee_only=false + +[test_ext_all_apis.html] +[test_ext_downloads_event_page.html] +[test_ext_tab_runtimeConnect.html] +[test_ext_tabs_create.html] +[test_ext_tabs_events.html] +skip-if = + fission # Bug 1827754 +[test_ext_tabs_executeScript.html] +[test_ext_tabs_executeScript_bad.html] +[test_ext_tabs_executeScript_no_create.html] +[test_ext_tabs_executeScript_runAt.html] +[test_ext_tabs_get.html] +[test_ext_tabs_getCurrent.html] +[test_ext_tabs_goBack_goForward.html] +[test_ext_tabs_insertCSS.html] +[test_ext_tabs_lastAccessed.html] +skip-if = true # tab.lastAccessed not implemented +[test_ext_tabs_reload.html] +[test_ext_tabs_reload_bypass_cache.html] +[test_ext_tabs_onUpdated.html] +[test_ext_tabs_query.html] +[test_ext_tabs_sendMessage.html] +[test_ext_tabs_update_url.html] +[test_ext_webNavigation_onCommitted.html] diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html new file mode 100644 index 0000000000..c1220ff4a5 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; +/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */ +let expectedContentApisTargetSpecific = []; + +let expectedBackgroundApisTargetSpecific = [ + "tabs.MutedInfoReason", + "tabs.TAB_ID_NONE", + "tabs.TabStatus", + "tabs.WindowType", + "tabs.ZoomSettingsMode", + "tabs.ZoomSettingsScope", + "tabs.connect", + "tabs.create", + "tabs.executeScript", + "tabs.get", + "tabs.getCurrent", + "tabs.goBack", + "tabs.goForward", + "tabs.insertCSS", + "tabs.onActivated", + "tabs.onAttached", + "tabs.onCreated", + "tabs.onDetached", + "tabs.onHighlighted", + "tabs.onMoved", + "tabs.onRemoved", + "tabs.onReplaced", + "tabs.onUpdated", + "tabs.query", + "tabs.reload", + "tabs.remove", + "tabs.removeCSS", + "tabs.sendMessage", + "tabs.update", +]; +</script> +<script src="test_ext_all_apis.js"></script> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html b/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html new file mode 100644 index 0000000000..78691114df --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Downloads Events Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_downloads_event_page() { + const apiEvents = ["onChanged"]; + const apiNs = "downloads"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@downloads" } }, + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + browser.downloads.onChanged.addListener(() => { + browser.test.sendMessage("onChanged"); + browser.test.notifyPass("downloads-events"); + }); + browser.test.sendMessage("ready"); + }, + }); + + // on startup, onChanged event listener should not be primed + await extension.startup(); + info("Wait for event page to be started"); + await extension.awaitMessage("ready"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + // when the extension is killed, onChanged event listener should be primed + info("Terminate event page"); + await extension.terminateBackground(); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: true }); + + // fire download-changed event and onChanged event listener should not be primed + info("Wait for download-changed to be emitted"); + await SpecialPowers.spawnChrome([], async () => { + const { DownloadTracker } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewWebExtension.sys.mjs" + ); + + const delta = { + filename: "test.gif", + id: 4, + mime: "image/gif", + totalBytes: 5, + }; + + // Mocks DownloadItem from mobile/android/components/extensions/ext-downloads.js + const downloadItem = { + byExtensionId: "download-onChanged@tests.mozilla.org", + byExtensionName: "Download", + bytesReceived: 0, + canResume: false, + danger: "safe", + exists: false, + fileSize: -1, + filename: "test.gif", + id: 4, + incognito: false, + mime: "image/gif", + paused: false, + referrer: "", + startTime: 1680818149350, + state: "in_progress", + totalBytes: 5, + url: "http://localhost:4245/assets/www/images/test.gif", + }; + + // WebExtension.DownloadDelegate has not been overridden in + // TestRunnerActivity (used by mochitests), so the downloads API + // does not actually work. In this test, we are only interested in + // whether or not dispatching an event would wake up the event page, + // so we artificially trigger a fake onChanged event to test that. + DownloadTracker.emit("download-changed", { delta, downloadItem }); + }); + + info("Triggered download change, expecting downloads.onChanged event"); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onChanged"); + await extension.awaitFinish("downloads-events"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html new file mode 100644 index 0000000000..138bb054a9 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html @@ -0,0 +1,498 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>PageAction Test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function waitAboutAddonsRendered(addonId) { + await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + }, `wait Addon Item for ${addonId} to be rendered`); +} + +async function navigateToAddonDetails(addonId) { + const item = content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + const rect = item.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const domWinUtils = content.window.windowUtils; + + domWinUtils.sendMouseEventToWindow("mousedown", x, y, 0, 1, 0); + domWinUtils.sendMouseEventToWindow("mouseup", x, y, 0, 1, 0); +} + +async function waitAddonOptionsPage([addonId, expectedText]) { + await ContentTaskUtils.waitForCondition(() => { + const optionsIframe = content.document.querySelector(`#addon-options`); + return optionsIframe && optionsIframe.contentDocument.readyState === "complete" && + optionsIframe.contentDocument.body.innerText.includes(expectedText); + }, `wait Addon Options ${expectedText} for ${addonId} to be loaded`); + + const optionsIframe = content.document.querySelector(`#addon-options`); + + return { + iframeHeight: optionsIframe.style.height, + documentHeight: optionsIframe.contentDocument.documentElement.scrollHeight, + bodyHeight: optionsIframe.contentDocument.body.scrollHeight, + }; +} + +async function clickOnLinkInOptionsPage(selector) { + const optionsIframe = content.document.querySelector(`#addon-options`); + optionsIframe.contentDocument.querySelector(selector).click(); +} + +async function clickAddonOptionButton() { + content.document.querySelector(`button#open-addon-options`).click(); +} + +async function navigateBack() { + content.window.history.back(); +} + +function waitDOMContentLoaded(checkUrlCb) { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + return new Promise(resolve => { + const listener = (event) => { + if (checkUrlCb(event.target.defaultView.location.href)) { + BrowserApp.deck.removeEventListener("DOMContentLoaded", listener); + resolve(); + } + }; + + BrowserApp.deck.addEventListener("DOMContentLoaded", listener); + }); +} + +function waitAboutAddonsLoaded() { + return waitDOMContentLoaded(url => url === "about:addons"); +} + +function clickAddonDisable() { + content.document.querySelector("#disable-btn").click(); +} + +function clickAddonEnable() { + content.document.querySelector("#enable-btn").click(); +} + +add_task(async function test_options_ui_iframe_height() { + const addonID = "test-options-ui@mozilla.org"; + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + // An option page with the document element bigger than the body. + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 500px; border: 1px solid black; } + body { height: 200px; } + </style> + </head> + <body> + <h1>Options page 1</h1> + <a href="options2.html">go to page 2</a> + </body> + </html> + `, + // A second option page with the body element bigger than the document. + "options2.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 200px; border: 1px solid black; } + body { height: 350px; } + </style> + </head> + <body> + <h1>Options page 2</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], waitAboutAddonsRendered); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], navigateToAddonDetails); + + const optionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + ok(parseInt(optionsSizes.iframeHeight, 10) >= 500, + "The addon options iframe is at least 500px"); + + is(optionsSizes.iframeHeight, optionsSizes.documentHeight + "px", + "The addon options iframe has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, ["a"], clickOnLinkInOptionsPage); + + const options2Sizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 2"]], waitAddonOptionsPage + ); + + // The second option page has a body bigger than the document element + // and we expect the iframe to be bigger than that. + ok(parseInt(options2Sizes.iframeHeight, 10) > 200, + `The iframe is bigger then 200px (${options2Sizes.iframeHeight})`); + + // The second option page has a body smaller than the document element of the first + // page and we expect the iframe to be smaller than for the previous options page. + ok(parseInt(options2Sizes.iframeHeight, 10) < 500, + `The iframe is smaller then 500px (${options2Sizes.iframeHeight})`); + + is(options2Sizes.iframeHeight, options2Sizes.documentHeight + "px", + "The second addon options page has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [], navigateBack); + + const backToOptionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + // After going back to the first options page, + // we expect the iframe to have the same size of the previous load. + is(backToOptionsSizes.iframeHeight, optionsSizes.iframeHeight, + `When navigating back, the old iframe size is restored (${backToOptionsSizes.iframeHeight})`); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_aboutaddons_details() { + const addonID = "test-options-ui-open-addon-details@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait runtime.openOptionsPage to open the about:addond details in the existent tab"); + extension.sendMessage("runtime.openOptionsPage"); + await extension.awaitMessage("options-page-loaded"); + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is still the currently selected tab once the options has been loaded"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_in_tab() { + const addonID = "test-options-ui@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open_in_tab Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + open_in_tab: true, + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.selectOrAddTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the Options button in the addon details"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonOptionButton); + + info("Waiting that the addon options are loaded in a new tab"); + await onceAddonOptionsLoaded; + + const addonOptionsTab = BrowserApp.selectedTab; + + ok(aboutAddonsTab.id !== addonOptionsTab.id, + "The Addon Options page has been loaded in a new tab"); + + let optionsURL = await extension.awaitMessage("options-page-loaded"); + + is(addonOptionsTab.currentURI.spec, optionsURL, + "Got the expected extension url opened in the addon options tab"); + + const waitTabClosed = (nativeTab) => { + return new Promise(resolve => { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + const expectedBrowser = nativeTab.browser; + + const tabCloseListener = (event) => { + const browser = event.target; + if (browser !== expectedBrowser) { + return; + } + + BrowserApp.deck.removeEventListener("TabClose", tabCloseListener); + resolve(); + }; + + BrowserApp.deck.addEventListener("TabClose", tabCloseListener); + }); + }; + + const onceOptionsTabClosed = waitTabClosed(addonOptionsTab); + const onceAboutAddonsClosed = waitTabClosed(aboutAddonsTab); + + info("Close the opened about:addons and options tab"); + BrowserApp.closeTab(addonOptionsTab); + BrowserApp.closeTab(aboutAddonsTab); + + info("Wait the tabs to be closed"); + await Promise.all([onceOptionsTabClosed, onceAboutAddonsClosed]); + + const oldSelectedTab = BrowserApp.selectedTab; + info("Call runtime.openOptionsPage"); + extension.sendMessage("runtime.openOptionsPage"); + + info("Wait runtime.openOptionsPage to open the options in a new tab"); + optionsURL = await extension.awaitMessage("options-page-loaded"); + is(BrowserApp.selectedTab.currentURI.spec, optionsURL, + "runtime.openOptionsPage has opened the expected extension page"); + ok(BrowserApp.selectedTab !== oldSelectedTab, + "runtime.openOptionsPage has opened a new tab"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_on_disable_and_enable() { + // Temporarily disabled for races. + /* eslint-disable no-unreachable */ + return; + + const addonID = "test-options-ui-disable-enable@mozilla.org"; + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait the addon details to have been loaded"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + info("Wait the addon options page to have been loaded"); + await extension.awaitMessage("options-page-loaded"); + + info("Click the addon disable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonDisable); + + // NOTE: Currently after disabling the addon the extension.awaitMessage seems + // to fail be able to receive events coming from the browser.test.sendMessage API + // (nevertheless `await extension.unload()` seems to be able to remove the extension), + // falling back to wait for the options page to be loaded here. + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the addon enable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonEnable); + + info("Wait the addon options page to have been loaded after clicking the addon enable button"); + await onceAddonOptionsLoaded; + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html new file mode 100644 index 0000000000..48904c2990 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs runtimeConnect Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const win = window.open("http://mochi.test:8888/"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background: function() { + const messages_received = []; + + let tabId; + + browser.runtime.onConnect.addListener((port) => { + browser.test.assertTrue(!!port, "tab to background port received"); + browser.test.assertEq("tab-connection-name", port.name, "port name should be defined and equal to connectInfo.name"); + browser.test.assertTrue(!!port.sender.tab, "port.sender.tab should be defined"); + browser.test.assertEq(tabId, port.sender.tab.id, "port.sender.tab.id should be equal to the expected tabId"); + + port.onMessage.addListener((msg) => { + messages_received.push(msg); + + if (messages_received.length == 1) { + browser.test.assertEq("tab to background port message", msg, "'tab to background' port message received"); + port.postMessage("background to tab port message"); + } + + if (messages_received.length == 2) { + browser.test.assertTrue(!!msg.tabReceived, "'background to tab' reply port message received"); + browser.test.assertEq("background to tab port message", msg.tabReceived, "reply port content contains the message received"); + + browser.test.notifyPass("tabRuntimeConnect.pass"); + } + }); + }); + + browser.tabs.create({url: "tab.html"}, + (tab) => { tabId = tab.id; }); + }, + + files: { + "tab.js": function() { + const port = browser.runtime.connect({name: "tab-connection-name"}); + port.postMessage("tab to background port message"); + port.onMessage.addListener((msg) => { + port.postMessage({tabReceived: msg}); + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <title>test tab extension page</title> + <meta charset="utf-8"> + <script src="tab.js" async><\/script> + </head> + <body> + <h1>test tab extension page</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabRuntimeConnect.pass"); + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html new file mode 100644 index 0000000000..027b231fa7 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html @@ -0,0 +1,153 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.userContext.enabled", true], + ["dom.security.https_first", false], + ], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "cookies"], + + "background": {"page": "bg/background.html"}, + }, + + files: { + "bg/blank.html": `<html><head><meta charset="utf-8"></head></html>`, + + "bg/background.html": `<html><head> + <meta charset="utf-8"> + <script src="background.js"><\/script> + </head></html>`, + + "bg/background.js": function() { + let activeTab; + + function runTests() { + const DEFAULTS = { + active: true, + url: "about:blank", + }; + + const tests = [ + { + create: {url: "http://example.com/"}, + result: {url: "http://example.com/"}, + }, + { + create: {url: "blank.html"}, + result: {url: browser.runtime.getURL("bg/blank.html")}, + }, + { + create: {}, + }, + { + create: {active: false}, + result: {active: false}, + }, + { + create: {active: true}, + result: {active: true}, + }, + { + create: {cookieStoreId: null}, + result: {cookieStoreId: "firefox-default"}, + }, + { + create: {cookieStoreId: "firefox-container-1"}, + result: {cookieStoreId: "firefox-container-1"}, + }, + ]; + + async function nextTest() { + if (!tests.length) { + browser.test.notifyPass("tabs.create"); + return; + } + + const test = tests.shift(); + const expected = Object.assign({}, DEFAULTS, test.result); + + browser.test.log(`Testing tabs.create(${JSON.stringify(test.create)}), expecting ${JSON.stringify(test.result)}`); + + const updatedPromise = new Promise(resolve => { + const onUpdated = (changedTabId, changed) => { + // Loading an extension page causes two `about:blank` messages + // because of the process switch + if (changed.url && (expected.url == "about:blank" || changed.url != "about:blank")) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({tabId: changedTabId, url: changed.url}); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + const createdPromise = new Promise(resolve => { + const onCreated = tab => { + browser.test.assertTrue("id" in tab, `Expected tabs.onCreated callback to receive tab object`); + resolve(); + }; + browser.tabs.onCreated.addListener(onCreated); + }); + + const [tab] = await Promise.all([ + browser.tabs.create(test.create), + createdPromise, + ]); + const tabId = tab.id; + + for (const key of Object.keys(expected)) { + if (key === "url") { + // FIXME: This doesn't get updated until later in the load cycle. + continue; + } + + browser.test.assertEq(expected[key], tab[key], `Expected value for tab.${key}`); + } + + const updated = await updatedPromise; + browser.test.assertEq(tabId, updated.tabId, `Expected value for tab.id`); + browser.test.assertEq(expected.url, updated.url, `Expected value for tab.url`); + + await browser.tabs.remove(tabId); + await browser.tabs.update(activeTab, {active: true}); + + nextTest(); + } + + nextTest(); + } + + browser.tabs.query({active: true, currentWindow: true}, tabs => { + activeTab = tabs[0].id; + + runTests(); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.create"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html new file mode 100644 index 0000000000..cd708d942a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html @@ -0,0 +1,302 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs Events Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testTabEvents() { + async function background() { + const events = []; + let eventPromise; + const checkEvents = () => { + if (eventPromise && events.length >= eventPromise.names.length) { + eventPromise.resolve(); + } + }; + + browser.tabs.onCreated.addListener(tab => { + events.push({type: "onCreated", tab}); + checkEvents(); + }); + + browser.tabs.onAttached.addListener((tabId, info) => { + events.push(Object.assign({type: "onAttached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onDetached.addListener((tabId, info) => { + events.push(Object.assign({type: "onDetached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onRemoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onRemoved", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onMoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onMoved", tabId}, info)); + checkEvents(); + }); + + async function expectEvents(names) { + browser.test.log(`Expecting events: ${names.join(", ")}`); + + await new Promise(resolve => { + eventPromise = {names, resolve}; + checkEvents(); + }); + + browser.test.assertEq(names.length, events.length, "Got expected number of events"); + for (const [i, name] of names.entries()) { + browser.test.assertEq(name, i in events && events[i].type, + `Got expected ${name} event`); + } + return events.splice(0); + } + + try { + browser.test.log("Create tab"); + const tab = await browser.tabs.create({url: "about:blank"}); + const oldIndex = tab.index; + + const [created] = await expectEvents(["onCreated"]); + browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID"); + browser.test.assertEq(oldIndex, created.tab.index, "Got expected tab index"); + + + browser.test.log("Remove tab"); + await browser.tabs.remove(tab.id); + const [removed] = await expectEvents(["onRemoved"]); + + browser.test.assertEq(tab.id, removed.tabId, "Expected removed tab ID"); + browser.test.assertEq(tab.windowId, removed.windowId, "Expected removed tab window ID"); + // Note: We want to test for the actual boolean value false here. + browser.test.assertEq(false, removed.isWindowClosing, "Expected isWindowClosing value"); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabRemovalEvent() { + async function background() { + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + chrome.tabs.onRemoved.addListener((tabId, info) => { + browser.test.log("Make sure the removed tab is not available in the tabs.query callback."); + chrome.tabs.query({}, tabs => { + for (const tab of tabs) { + browser.test.assertTrue(tab.id != tabId, "Tab query should not include removed tabId"); + } + browser.test.notifyPass("tabs-events"); + }); + }); + + try { + const url = "http://example.com/mochitest/mobile/android/components/extensions/test/mochitest/context.html"; + const tab = await browser.tabs.create({url: url}); + await awaitLoad(tab.id); + + await browser.tabs.remove(tab.id); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabActivationEvent() { + // TODO bug 1565536: tabs.onActivated is not supported in GeckoView. + if (true) { + todo(false, "skipping testTabActivationEvent"); + return; + } + async function background() { + function makeExpectable() { + let expectation = null, resolver = null; + const expectable = param => { + if (expectation === null) { + browser.test.fail("unexpected call to expectable"); + } else { + try { + resolver(expectation(param)); + } catch (e) { + resolver(Promise.reject(e)); + } finally { + expectation = null; + } + } + }; + expectable.expect = e => { + expectation = e; + return new Promise(r => { resolver = r; }); + }; + return expectable; + } + try { + const listener = makeExpectable(); + browser.tabs.onActivated.addListener(listener); + + const [tab0] = await browser.tabs.query({active: true}); + const [, tab1] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab0.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + const [, tab2] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(tab2.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.update(tab1.id, {active: true}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab2.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(undefined, info.previousTabId, "previousTabId should not be defined when previous tab was closed"); + }), + browser.tabs.remove(tab1.id), + ]); + + browser.tabs.onActivated.removeListener(listener); + await browser.tabs.remove(tab2.id); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function test_tabs_event_page() { + function background() { + const EVENTS = [ + "onActivated", + "onRemoved", + "onUpdated", + ]; + browser.tabs.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + for (const event of EVENTS) { + browser.tabs[event].addListener(() => { + }); + } + browser.test.onMessage.addListener(async msg => { + if (msg === "createTab") { + await browser.tabs.create({url: "about:blank"}); + } + }); + browser.test.sendMessage("ready"); + } + + const apiEvents = ["onActivated", "onCreated", "onRemoved", "onUpdated"]; + const apiNs = "tabs"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@tabs" } }, + "permissions": ["tabs"], + background: { persistent: false }, + }, + background, + }); + + await extension.startup(); + info("Wait for event page to be started"); + await extension.awaitMessage("ready"); + // Sanity check + info("Wait for tabs.onCreated listener call"); + extension.sendMessage("createTab"); + await extension.awaitMessage("onCreated"); + + // on startup, all event listeners should not be primed + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + // when the extension is killed, all event listeners should be primed + info("Terminate event page"); + await extension.terminateBackground({ disableResetIdleForTest: true }); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: true }); + + // on start up again, all event listeners should not be primed + info("Wake up event page on a new tabs.onCreated event"); + const newWin = window.open(); + info("Wait for event page to be restarted"); + await extension.awaitMessage("ready"); + info("Wait for the primed tabs.onCreated to be received by the event page"); + await extension.awaitMessage("onCreated"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + await extension.unload(); + newWin.close(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html new file mode 100644 index 0000000000..09e42d73cf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html @@ -0,0 +1,252 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + + const win = window.open(URL); + await new Promise(resolve => win.addEventListener("load", resolve, {once: true})); + + async function background() { + try { + const [tab] = await browser.tabs.query({active: true, currentWindow: true}); + const frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + + browser.test.log(`FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n`); + await Promise.all([ + browser.tabs.executeScript({ + code: "42", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(42, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script.js", + code: "42", + }).then(result => { + browser.test.fail("Expected not to be able to execute a script with both file and code"); + }, error => { + browser.test.assertTrue(/a 'code' or a 'file' property, but not both/.test(error.message), + "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(undefined, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script2.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(27, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + matchAboutBlank: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(3, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + browser.test.assertEq("about:blank", result[2], "Thirds result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected callback result"); + browser.test.assertEq("string", typeof result[0], "Result is a string"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "Result is correct"); + }), + + browser.tabs.executeScript({ + code: "window", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(window)", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script3.js", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + const expected = /Script '.*script3.js' result is non-structured-clonable data/; + browser.test.assertTrue(expected.test(error.message), "Got expected error"); + browser.test.assertTrue(error.fileName.endsWith("script3.js"), "Got expected fileName"); + }), + + browser.tabs.executeScript({ + frameId: Number.MAX_SAFE_INTEGER, + code: "42", + }).then(result => { + browser.test.fail("Expected error when specifying invalid frame ID"); + }, error => { + browser.test.assertEq( + `Invalid frame IDs: [${Number.MAX_SAFE_INTEGER}].`, + error.message, + "Got expected error" + ); + }), + + browser.tabs.create({url: "http://example.net/", active: false}).then(async tab => { + await browser.tabs.executeScript(tab.id, { + code: "42", + }).then(result => { + browser.test.fail("Expected error when trying to execute on invalid domain"); + }, error => { + browser.test.assertEq(`Missing host permission for the tab`, + error.message, "Got expected error"); + }); + + await browser.tabs.remove(tab.id); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(42)", + }).then(result => { + browser.test.assertEq(42, result[0], "Got expected promise resolution value as result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[0].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), `Result for frameId[0] is correct: ${result[0]}`); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[1].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertEq("http://mochi.test:8888/", result[0], "Result for frameId[1] is correct"); + }), + + browser.tabs.create({url: "http://example.com/"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href"}); + + browser.test.assertEq("http://example.com/", result[0], "Script executed correctly in new tab"); + + await browser.tabs.remove(tab.id); + }), + + // This currently does not work on Android. + /* + browser.tabs.create({url: "about:blank"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href", matchAboutBlank: true}); + browser.test.assertEq("about:blank", result[0], "Script executed correctly in new tab"); + await browser.tabs.remove(tab.id); + }), + */ + + new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq("script ran", message, "Expected runtime message"); + resolve(); + }); + }), + ]); + + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "http://example.com/", "webNavigation"], + }, + + background, + + files: { + "script.js": function() { + browser.runtime.sendMessage("script ran"); + }, + + "script2.js": "27", + + "script3.js": "window", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html new file mode 100644 index 0000000000..da645ef738 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html @@ -0,0 +1,151 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Bad Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function* testHasNoPermission(params) { + const contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "execute-script"); + + await browser.test.assertRejects(browser.tabs.executeScript({ + file: "script.js", + }), /Missing host permission for the tab/); + + browser.test.notifyPass("executeScript"); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "script.js": function() { + browser.runtime.sendMessage("first script ran"); + }, + }, + }); + + yield extension.startup(); + yield extension.awaitMessage("ready"); + + if (params.setup) { + yield params.setup(extension); + } + + extension.sendMessage("execute-script"); + + yield extension.awaitFinish("executeScript"); + yield extension.unload(); +} + +add_task(async function testBadPermissions() { + const win = window.open("http://mochi.test:8888/"); + + await new Promise(resolve => setTimeout(resolve, 0)); + + info("Test no special permissions"); + await testHasNoPermission({ + manifest: {"permissions": []}, + }); + + info("Test tabs permissions"); + await testHasNoPermission({ + manifest: {"permissions": ["tabs"]}, + }); + + win.close(); +}); + +add_task(async function testBadURL() { + async function background() { + const promises = [ + new Promise(resolve => { + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }, result => { + browser.test.assertEq(undefined, result, "Result value"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + resolve(); + }); + }), + + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }).catch(error => { + browser.test.assertTrue(error instanceof Error, "Error is Error"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + error && error.message, + "error value"); + }), + ]; + + await Promise.all(promises); + + browser.test.notifyPass("executeScript-lastError"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["<all_urls>"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-lastError"); + + await extension.unload(); +}); + +// TODO bug 1435100: Test that |executeScript| fails if the tab has navigated +// to a new page, and no longer matches our expected state. This involves +// intentionally trying to trigger a race condition. +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html new file mode 100644 index 0000000000..fa25568619 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript noCreate Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScriptAtOnUpdated() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + // This is a regression test for bug 1325830. + // The bug (executeScript not completing any more) occurred when executeScript + // was called early at the onUpdated event, unless the tabs.create method is + // called. So this test does not use tabs.create to open new tabs. + // Note that if this test is run together with other tests that do call + // tabs.create, then this test case does not properly test the conditions of + // the regression any more. To verify that the regression has been resolved, + // this test must be run in isolation. + + function background() { + // Using variables to prevent listeners from running more than once, instead + // of removing the listener. This is to minimize any IPC, since the bug that + // is being tested is sensitive to timing. + let ignore = false; + let url; + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (url && changeInfo.status === "loading" && tab.url === url && !ignore) { + ignore = true; + browser.tabs.executeScript(tabId, { + code: "document.URL", + }).then(results => { + browser.test.assertEq(url, results[0], "Content script should run"); + browser.test.notifyPass("executeScript-at-onUpdated"); + }, error => { + browser.test.fail(`Unexpected error: ${error} :: ${error.stack}`); + browser.test.notifyFail("executeScript-at-onUpdated"); + }); + // (running this log call after executeScript to minimize IPC between + // onUpdated and executeScript.) + browser.test.log(`Found expected navigation to ${url}`); + } else { + // The bug occurs when executeScript is called before a tab is + // initialized. + browser.tabs.executeScript(tabId, {code: ""}); + } + }); + browser.test.onMessage.addListener(testUrl => { + url = testUrl; + browser.test.sendMessage("open-test-tab"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage(URL); + await extension.awaitMessage("open-test-tab"); + + const tab = window.open(URL); + await extension.awaitFinish("executeScript-at-onUpdated"); + + await extension.unload(); + + tab.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html new file mode 100644 index 0000000000..2e82320f8c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html @@ -0,0 +1,128 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript runAt Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/** + * These tests ensure that the runAt argument to tabs.executeScript delays + * script execution until the document has reached the correct state. + * + * Since tests of this nature are especially race-prone, it relies on a + * server-JS script to delay the completion of our test page's load cycle long + * enough for us to attempt to load our scripts in the earlies phase we support. + * + * And since we can't actually rely on that timing, it retries any attempts that + * fail to load as early as expected, but don't load at any illegal time. + */ + +add_task(async function testExecuteScript() { + const win = window.open("about:blank"); + + async function background(DEBUG) { + let tab; + + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_slowed_document.sjs"; + + const MAX_TRIES = 30; + + const onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + [tab] = await browser.tabs.query({active: true, currentWindow: true}); + + let success = false; + for (let tries = 0; !success && tries < MAX_TRIES; tries++) { + const url = `${URL}?with-iframe&r=${Math.random()}`; + + const loadingPromise = onUpdatedPromise(tab.id, url, "loading"); + const completePromise = onUpdatedPromise(tab.id, url, "complete"); + + // TODO: Test allFrames and frameId. + + await browser.tabs.update({url}); + await loadingPromise; + + const states = await Promise.all([ + // Send the executeScript requests in the reverse order that we expect + // them to execute in, to avoid them passing only because of timing + // races. + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_idle", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_end", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_start", + }), + ].reverse()); + + browser.test.log(`Got states: ${states}`); + + // Make sure that none of our scripts executed earlier than expected, + // regardless of retries. + browser.test.assertTrue(states[1] == "interactive" || states[1] == "complete", + `document_end state is valid: ${states[1]}`); + browser.test.assertTrue(states[2] == "interactive" || states[2] == "complete", + `document_idle state is valid: ${states[2]}`); + + // If we have the earliest valid states for each script, we're done. + // Otherwise, try again. + success = ((states[0] == "loading" || DEBUG) && + states[1] == "interactive" && + (states[2] == "interactive" || states[2] == "complete")); + + await completePromise; + } + + browser.test.assertTrue(success, "Got the earliest expected states at least once"); + + browser.test.notifyPass("executeScript-runAt"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-runAt"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background: `(${background})(${AppConstants.DEBUG})`, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-runAt"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html new file mode 100644 index 0000000000..109ab8f65c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs get Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + const tab1 = await browser.tabs.create({}); + const tab2 = await browser.tabs.create({}); + browser.test.assertEq(tab1.id, (await browser.tabs.get(tab1.id)).id, "tabs.get should return tab with given id"); + browser.test.assertEq(tab2.id, (await browser.tabs.get(tab2.id)).id, "tabs.get should return tab with given id"); + await browser.tabs.remove(tab1.id); + await browser.tabs.remove(tab2.id); + browser.test.notifyPass("tabs.get"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.get"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html new file mode 100644 index 0000000000..c32f93f44a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs getCurrent Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.js": function() { + const url = document.location.href; + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + // Activate the tab. + browser.tabs.onActivated.addListener(function listener({tabId}) { + if (tabId == currentTab.id) { + browser.tabs.onActivated.removeListener(listener); + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.id, tabId, "in active background tab"); + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + browser.test.sendMessage("tab-finished"); + }); + } + }); + browser.tabs.update(currentTab.id, {active: true}); + }); + }, + + "tab.html": `<head><meta charset="utf-8"><script src="tab.js"><\/script></head>`, + }, + + background: function() { + browser.tabs.getCurrent().then(tab => { + browser.test.assertEq(tab, undefined, "getCurrent in background script"); + browser.test.sendMessage("background-finished"); + }); + + browser.tabs.create({url: "tab.html", active: false}); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-finished"); + await extension.awaitMessage("tab-finished"); + + // The extension tab is automatically closed when the extension unloads. + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html new file mode 100644 index 0000000000..0d143e2ac6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html @@ -0,0 +1,134 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Tabs goBack and goForward Test</title> + <script + type="text/javascript" + src="/tests/SimpleTest/SimpleTest.js" + ></script> + <script + type="text/javascript" + src="/tests/SimpleTest/ExtensionTestUtils.js" + ></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> + </head> + <body> + <script type="text/javascript"> + "use strict"; + + add_task(async function test_tabs_goBack_goForward() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab1.html": `<head> + <meta charset=UTF-8"> + <title>tab1</title> + </head>`, + "tab2.html": `<head> + <meta charset=UTF-8"> + <title>tab2</title> + </head>`, + }, + + async background() { + let tabUpdatedCount = 0; + let tab = {}; + + browser.tabs.onUpdated.addListener( + async (tabId, changeInfo, tabInfo) => { + if ( + changeInfo.status !== "complete" || + tabId !== tab.id || + tabInfo.url === "about:blank" + ) { + return; + } + + tabUpdatedCount++; + switch (tabUpdatedCount) { + case 1: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab2.html" }); + break; + + case 2: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab1.html" }); + break; + + case 3: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.goBack(); + break; + + case 4: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating backward with empty parameter" + ); + browser.tabs.goBack(tabId); + break; + + case 5: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating backward with tabId as parameter" + ); + browser.tabs.goForward(); + break; + + case 6: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating forward with empty parameter" + ); + browser.tabs.goForward(tabId); + break; + + case 7: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating forward with tabId as parameter" + ); + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.goBack.goForward"); + break; + + default: + break; + } + } + ); + + tab = await browser.tabs.create({ url: "tab1.html", active: true }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.goBack.goForward"); + await extension.unload(); + }); + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html new file mode 100644 index 0000000000..718c2d6de4 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html @@ -0,0 +1,124 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + const win = window.open("http://mochi.test:8888/"); + + async function background() { + const tasks = [ + { + description: "CSS as moz-extension:// url", + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + { + description: "CSS as code snippet string", + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + { + description: "last of two author CSS wins", + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: async () => { + await browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "author", + }); + await browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + }); + }, + }, + { + description: "user CSS has higher priority", + background: "rgb(100, 100, 100)", + foreground: "rgb(0, 113, 4)", + promise: async () => { + // User has higher importance + await browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "user", + }); + await browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + }); + }, + }, + ]; + + function checkCSS() { + const computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (const {background, description, foreground, promise} of tasks) { + browser.test.log(`Run test case: ${description}`); + let result = await promise(); + + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + + browser.test.assertEq(background, result[0], "Expected background color"); + browser.test.assertEq(foreground, result[1], "Expected foreground color"); + } + + browser.test.notifyPass("insertCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("insertCSS"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html new file mode 100644 index 0000000000..5bb44ab645 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html @@ -0,0 +1,64 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs lastAccessed Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testLastAccessed() { + const past = Date.now(); + + window.open("https://example.com/?1"); + window.open("https://example.com/?2"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async function(msg, past) { + if (msg !== "past") { + return; + } + + const [tab1] = await browser.tabs.query({url: "https://example.com/?1"}); + const [tab2] = await browser.tabs.query({url: "https://example.com/?2"}); + + browser.test.assertTrue(tab1 && tab2, "Expected tabs were found"); + + const now = Date.now(); + + browser.test.assertTrue(typeof tab1.lastAccessed == "number", + "tab1 lastAccessed should be a number"); + + browser.test.assertTrue(typeof tab2.lastAccessed == "number", + "tab2 lastAccessed should be a number"); + + browser.test.assertTrue(past <= tab1.lastAccessed && + tab1.lastAccessed <= tab2.lastAccessed && + tab2.lastAccessed <= now, + "lastAccessed timestamps are recent and in the right order"); + + await browser.tabs.remove([tab1.id, tab2.id]); + + browser.test.notifyPass("tabs.lastAccessed"); + }); + }, + }); + + await extension.startup(); + await extension.sendMessage("past", past); + await extension.awaitFinish("tabs.lastAccessed"); + await extension.unload(); +}); +</script> + +</body> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html new file mode 100644 index 0000000000..8d96e79cc2 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html @@ -0,0 +1,187 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs onUpdated Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_onUpdated() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/context_tabs_onUpdated_page.html"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: function() { + const pageURL = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + + const expectedSequence = [ + {status: "loading"}, + {status: "loading", url: pageURL}, + {status: "complete"}, + ]; + + const collectedSequence = []; + + let tabId; + browser.tabs.onUpdated.addListener(function(tabId, updatedInfo) { + // onUpdated also fires with updatedInfo.faviconUrl, so explicitly + // check for updatedInfo.status before recording the event. + if ("status" in updatedInfo) { + collectedSequence.push(updatedInfo); + } + }); + + browser.runtime.onMessage.addListener(async msg => { + if (collectedSequence.length !== expectedSequence.length) { + browser.test.assertEq( + JSON.stringify(expectedSequence), + JSON.stringify(collectedSequence), + "got unexpected number of updateInfo data" + ); + } else { + for (let i = 0; i < expectedSequence.length; i++) { + browser.test.assertEq( + expectedSequence[i].status, + collectedSequence[i].status, + "check updatedInfo status" + ); + if (expectedSequence[i].url || collectedSequence[i].url) { + browser.test.assertEq( + expectedSequence[i].url, + collectedSequence[i].url, + "check updatedInfo url" + ); + } + } + } + + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.onUpdated"); + }); + + browser.tabs.create({url: pageURL}).then(tab => { + tabId = tab.id; + }); + }, + files: { + "content-script.js": ` + window.addEventListener("message", function(evt) { + if (evt.data == "frame-updated") { + browser.runtime.sendMessage("load-completed"); + } + }, true); + `, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("tabs.onUpdated"), + ]); + + await extension.unload(); +}); + +async function do_test_update(background, withPermissions = true) { + const manifest = {}; + if (withPermissions) { + manifest.permissions = ["tabs", "http://mochi.test/"]; + } + const extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + }); + + await extension.startup(); + await extension.awaitFinish("finish"); + + await extension.unload(); +} + +add_task(async function test_url() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({}, function(tab) { + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("url" in changeInfo) { + browser.test.assertEq("about:blank", changeInfo.url, + "Check changeInfo.url"); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, {url: "about:blank"}); + }); + }); +}); + +add_task(async function test_title() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + if ("title" in changeInfo && changeInfo.title === "New Message (1)") { + browser.test.log("changeInfo.title is correct"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + + browser.tabs.executeScript(tab.id, {code: "document.title = 'New Message (1)'"}); + }); +}); + +add_task(async function test_without_tabs_permission() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + let count = 0; + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + + browser.test.assertFalse("url" in changeInfo, "url should not be included without tabs permission"); + browser.test.assertFalse("favIconUrl" in changeInfo, "favIconUrl should not be included without tabs permission"); + browser.test.assertFalse("title" in changeInfo, "title should not be included without tabs permission"); + + if (changeInfo.status == "complete") { + count++; + if (count === 2) { + browser.test.log("Reload complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + } + }); + + browser.tabs.reload(tab.id); + }, false /* withPermissions */); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html new file mode 100644 index 0000000000..9a907f47de --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_query_index() { + const extension = ExtensionTestUtils.loadExtension({ + background: function() { + browser.tabs.onCreated.addListener(async function({index, windowId, id}) { + browser.test.assertThrows( + () => browser.tabs.query({index: -1}), + /-1 is too small \(must be at least 0\)/, + "tab indices must be non-negative"); + + let tabs = await browser.tabs.query({index, windowId}); + browser.test.assertEq(tabs.length, 1, `Got one tab at index ${index}`); + browser.test.assertEq(tabs[0].id, id, "The tab is the right one"); + + tabs = await browser.tabs.query({index: 1e5, windowId}); + browser.test.assertEq(tabs.length, 0, "There is no tab at this index"); + + browser.test.notifyPass("tabs.query"); + }); + }, + }); + + await extension.startup(); + const win = window.open("http://example.com"); + await extension.awaitFinish("tabs.query"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html new file mode 100644 index 0000000000..30379f02a1 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs reload Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + files: { + "tab.js": function() { + browser.runtime.sendMessage("tab-loaded"); + }, + "tab.html": + `<head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head>`, + }, + + async background() { + let tabLoadedCount = 0; + // eslint-disable-next-line prefer-const + let tab; + + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-loaded") { + tabLoadedCount++; + + if (tabLoadedCount == 1) { + // Reload the tab once passing no arguments. + return browser.tabs.reload(); + } + + if (tabLoadedCount == 2) { + // Reload the tab again with explicit arguments. + return browser.tabs.reload(tab.id, { + bypassCache: false, + }); + } + + if (tabLoadedCount == 3) { + browser.test.notifyPass("tabs.reload"); + } + } + }); + tab = await browser.tabs.create({url: "tab.html", active: true}); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html new file mode 100644 index 0000000000..87f90ad855 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript bypassCache Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "<all_urls>"], + }, + + async background() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_bypass_cache.sjs"; + + let tabId = null; + let loadPromise, resolveLoad; + function resetLoad() { + loadPromise = new Promise(resolve => { + resolveLoad = resolve; + }); + } + function awaitLoad() { + return loadPromise.then(() => { + resetLoad(); + }); + } + resetLoad(); + + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete" && tab.url == URL) { + resolveLoad(); + } + }); + + try { + const tab = await browser.tabs.create({url: URL}); + tabId = tab.id; + await awaitLoad(); + + await browser.tabs.reload(tab.id, {bypassCache: false}); + await awaitLoad(); + + let [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + browser.test.assertEq("", textContent, "`textContent` should be empty when bypassCache=false"); + + await browser.tabs.reload(tab.id, {bypassCache: true}); + await awaitLoad(); + + [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + + const [pragma, cacheControl] = textContent.split(":"); + browser.test.assertEq("no-cache", pragma, "`pragma` should be set to `no-cache` when bypassCache is true"); + browser.test.assertEq("no-cache", cacheControl, "`cacheControl` should be set to `no-cache` when bypassCache is true"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.reload_bypass_cache"); + } catch (error) { + browser.test.fail(`${error} :: ${error.stack}`); + browser.test.notifyFail("tabs.reload_bypass_cache"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload_bypass_cache"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..320ce4dde6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,277 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs sendMessage Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function tabsSendMessageReply() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + // eslint-disable-next-line prefer-const + let firstTab; + const promiseResponse = new Promise(resolve => { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "content-script-ready") { + const tabId = sender.tab.id; + + Promise.all([ + promiseResponse, + + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), + new Promise(resolve => browser.tabs.sendMessage(tabId, "respond-soon", resolve)), + browser.tabs.sendMessage(tabId, "respond-promise"), + browser.tabs.sendMessage(tabId, "respond-promise-false"), + browser.tabs.sendMessage(tabId, "respond-false"), + browser.tabs.sendMessage(tabId, "respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { resolve(response); }); + }), + + browser.tabs.sendMessage(tabId, "respond-error").catch(error => Promise.resolve({error})), + browser.tabs.sendMessage(tabId, "throw-error").catch(error => Promise.resolve({error})), + + browser.tabs.sendMessage(tabId, "respond-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-undefined").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "throw-undefined").catch(error => Promise.resolve({ error })), + + browser.tabs.sendMessage(firstTab, "no-listener").catch(error => Promise.resolve({error})), + ]).then(([response, respondNow, respondNow2, respondSoon, respondPromise, respondPromiseFalse, respondFalse, respondNever, respondNever2, respondError, throwError, respondUncloneable, rejectUncloneable, rejectUndefined, throwUndefined, noListener]) => { + browser.test.assertEq("expected-response", response, "Content script got the expected response"); + + browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response"); + browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener"); + browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response"); + browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response"); + browser.test.assertEq(false, respondPromiseFalse, "Got the expected false value as a promise result"); + browser.test.assertEq(undefined, respondFalse, "Got the expected no-response when onMessage returns false"); + browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution"); + browser.test.assertEq(undefined, respondNever2, "Got the expected no-response resolution"); + + browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response"); + browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", respondUncloneable.error.message, "An uncloneable response should be ignored"); + browser.test.assertEq("An unexpected error occurred", rejectUncloneable.error.message, "Got the expected error for a rejection with an uncloneable value"); + browser.test.assertEq("An unexpected error occurred", rejectUndefined.error.message, "Got the expected error for a void rejection"); + browser.test.assertEq("An unexpected error occurred", throwUndefined.error.message, "Got the expected error for a void throw"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response"); + + return browser.tabs.remove(tabId); + }).then(() => { + browser.test.notifyPass("sendMessage"); + }); + + return Promise.resolve("expected-response"); + } else if (msg[0] == "got-response") { + resolve(msg[1]); + } + }); + }); + + const tabs = await browser.tabs.query({currentWindow: true, active: true}); + firstTab = tabs[0].id; + browser.tabs.create({url: "http://example.com/"}); + }, + + files: { + "content-script.js": async function() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { respond(msg); }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + const response = await browser.runtime.sendMessage("content-script-ready"); + browser.runtime.sendMessage(["got-response", response]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("sendMessage"); + + await extension.unload(); +}); + + +add_task(async function tabsSendHidden() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/content*"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + let resolveContent; + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg[0] == "content-ready") { + resolveContent(msg[1]); + } + }); + + const awaitContent = url => { + return new Promise(resolve => { + resolveContent = resolve; + }).then(result => { + browser.test.assertEq(url, result, "Expected content script URL"); + }); + }; + + try { + const URL1 = "http://example.com/content1.html"; + const URL2 = "http://example.com/content2.html"; + + const tab = await browser.tabs.create({url: URL1}); + await awaitContent(URL1); + + let url = await browser.tabs.sendMessage(tab.id, URL1); + browser.test.assertEq(URL1, url, "Should get response from expected content window"); + + await browser.tabs.update(tab.id, {url: URL2}); + await awaitContent(URL2); + + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + // Repeat once just to be sure the first message was processed by all + // listeners before we exit the test. + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("contentscript-bfcache-window"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("contentscript-bfcache-window"); + } + }, + + files: { + "content-script.js": function() { + // Store this in a local variable to make sure we don't touch any + // properties of the possibly-hidden content window. + const href = window.location.href; + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(href, msg, "Should be in the expected content window"); + + return Promise.resolve(href); + }); + + browser.runtime.sendMessage(["content-ready", href]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("contentscript-bfcache-window"); + + await extension.unload(); +}); + + +add_task(async function tabsSendMessageNoExceptionOnNonExistentTab() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + async background() { + const url = "http://example.com/mochitest/tests/mobile/android/components/extensions/test/mochitest/file_dummy.html"; + const tab = await browser.tabs.create({url}); + + try { + browser.tabs.sendMessage(tab.id, "message"); + browser.tabs.sendMessage(tab.id + 100, "message"); + } catch (e) { + browser.test.fail("no exception should be raised on tabs.sendMessage to nonexistent tabs"); + } + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.sendMessage"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.sendMessage"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html new file mode 100644 index 0000000000..9332efd516 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html @@ -0,0 +1,125 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs update Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>tab page</h1> + </body> + </html> + `.trim(), + }, + background: function() { + browser.test.sendMessage("ready", browser.runtime.getURL("tab.html")); + + browser.test.onMessage.addListener(async (msg, tabsUpdateURL, isErrorExpected) => { + const tabs = await browser.tabs.query({lastFocusedWindow: true}); + + try { + const tab = await browser.tabs.update(tabs[0].id, {url: tabsUpdateURL}); + + browser.test.assertFalse(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should be rejected`); + browser.test.assertTrue(tab, "on success the tab should be defined"); + } catch (error) { + browser.test.assertTrue(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should not be rejected`); + browser.test.assertTrue(/^Illegal URL/.test(error.message), + "tabs.update should be rejected with the expected error message"); + } + + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + + const mozExtTabURL = await extension.awaitMessage("ready"); + + if (tabsUpdateURL == "self") { + tabsUpdateURL = mozExtTabURL; + } + + info(`tab.update URL "${tabsUpdateURL}" on tab with URL "${existentTabURL}"`); + + const tab1 = window.open(existentTabURL); + + extension.sendMessage("start", tabsUpdateURL, isErrorExpected); + await extension.awaitMessage("done"); + + tab1.close(); + await extension.unload(); +} + +add_task(async function() { + info("Start testing tabs.update on javascript URLs"); + + const dataURLPage = `data:text/html, + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>data url page</h1> + </body> + </html>`; + + const checkList = [ + { + tabsUpdateURL: "http://example.net", + isErrorExpected: false, + }, + { + tabsUpdateURL: "self", + isErrorExpected: false, + }, + { + tabsUpdateURL: "about:addons", + isErrorExpected: true, + }, + { + tabsUpdateURL: "javascript:console.log('tabs.update execute javascript')", + isErrorExpected: true, + }, + { + tabsUpdateURL: dataURLPage, + isErrorExpected: true, + }, + ]; + + const testCases = checkList + .map((check) => Object.assign({}, check, {existentTabURL: "about:blank"})); + + for (const {existentTabURL, tabsUpdateURL, isErrorExpected} of testCases) { + await testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected); + } + + info("done"); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html new file mode 100644 index 0000000000..33f178492d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>WebNavigation onCommitted Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "tabs"], + }, + async background() { + const url = "http://mochi.test:8888/"; + const [tab, tabDetails] = await Promise.all([ + browser.tabs.create({url}), + new Promise(resolve => { + browser.webNavigation.onCommitted.addListener(details => { + if (details.url === "about:blank") { + // skip initial about:blank + return; + } + resolve(details); + }); + }), + ]); + + browser.test.assertEq(url, tabDetails.url, "webNavigation.onCommitted detects correct url"); + browser.test.assertEq(tab.id, tabDetails.tabId, "webNavigation.onCommitted fire for proper tabId"); + await browser.tabs.remove(tab.id); + browser.test.notifyPass("webNavigation.onCommitted"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("webNavigation.onCommitted"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/xpcshell/.eslintrc.js b/mobile/android/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..2e6d214f4b --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + extends: + "../../../../../../toolkit/components/extensions/test/xpcshell/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/test/xpcshell/head.js b/mobile/android/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..e79781fba6 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/head.js @@ -0,0 +1,24 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", +}); + +// Remove this pref once bug 1535365 is fixed. +Services.prefs.setBoolPref("extensions.webextensions.remote", false); + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +ExtensionTestUtils.init(this); + +Services.io.offline = true; + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; diff --git a/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js new file mode 100644 index 0000000000..63f64b487e --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js @@ -0,0 +1,167 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dum", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +async function testNativeMessaging({ + isPrivileged = false, + permissions, + testBackground, + testContent, +}) { + async function runTest(testFn, completionMessage) { + try { + dump(`Running test before sending ${completionMessage}\n`); + await testFn(); + } catch (e) { + browser.test.fail(`Unexpected error: ${e}`); + } + browser.test.sendMessage(completionMessage); + } + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + background: `(${runTest})(${testBackground}, "background_done");`, + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + permissions, + }, + files: { + "test.js": `(${runTest})(${testContent}, "content_done");`, + }, + }); + + // Run background script. + await extension.startup(); + await extension.awaitMessage("background_done"); + + // Run content script. + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content_done"); + await page.close(); + + await extension.unload(); +} + +// Checks that unprivileged extensions cannot use any of the nativeMessaging +// APIs on Android. +add_task(async function test_nativeMessaging_unprivileged() { + function testScript() { + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "connectNative should not be available in unprivileged extensions" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "sendNativeMessage should not be available in unprivileged extensions" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: false, + permissions: [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + ], + testBackground: testScript, + testContent: testScript, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Invalid extension permission: geckoViewAddons/ }, + { message: /Invalid extension permission: nativeMessaging/ }, + { message: /Invalid extension permission: nativeMessagingFromContent/ }, + ], + }); +}); + +// Checks that privileged extensions can still not use native messaging without +// the geckoViewAddons permission. +add_task(async function test_geckoViewAddons_missing() { + const ERROR_NATIVE_MESSAGE_FROM_BACKGROUND = + "Native manifests are not supported on android"; + const ERROR_NATIVE_MESSAGE_FROM_CONTENT = + /^Native messaging not allowed: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + + async function testBackground() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND + "An unexpected error occurred", + "Background script cannot use nativeMessaging without geckoViewAddons" + ); + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT + "An unexpected error occurred", + "Content script cannot use nativeMessaging without geckoViewAddons" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["nativeMessaging", "nativeMessagingFromContent"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND }, + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT }, + ], + }); +}); + +// Checks that privileged extensions cannot use native messaging from content +// without the nativeMessagingFromContent permission. +add_task(async function test_nativeMessagingFromContent_missing() { + const ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM = + /^Unexpected messaging sender: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + function testBackground() { + // sendNativeMessage / connectNative are expected to succeed, but we + // are not testing that here because XpcshellTestRunnerService does not + // have a WebExtension.MessageDelegate that handles the message. + // There are plenty of mochitests that rely on connectNative, so we are + // not testing that here. + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM + "An unexpected error occurred", + "Trying to get through to native messaging but without luck" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["geckoViewAddons", "nativeMessaging"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM }], + }); +}); diff --git a/mobile/android/components/extensions/test/xpcshell/xpcshell.ini b/mobile/android/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..f004140076 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +tags = webextensions in-process-webextensions +skip-if = os != "android" + +[test_ext_native_messaging_permissions.js] |