--- a/dom/webidl/WebExtensionPolicy.webidl
+++ b/dom/webidl/WebExtensionPolicy.webidl
@@ -62,17 +62,17 @@ interface WebExtensionPolicy {
* extension's optional permissions.
*/
[Pure]
attribute MatchPatternSet allowedOrigins;
/**
* The set of content scripts active for this extension.
*/
- [Cached, Constant, Frozen]
+ [Cached, Frozen, Pure]
readonly attribute sequence<WebExtensionContentScript> contentScripts;
/**
* True if the extension is currently active, false otherwise. When active,
* the extension's moz-extension: protocol will point to the given baseURI,
* and the set of policies for this object will be active for its ID.
*
* Only one extension policy with a given ID or hostname may be active at a
@@ -117,16 +117,27 @@ interface WebExtensionPolicy {
DOMString localize(DOMString unlocalizedText);
/**
* Returns the moz-extension: URL for the given path.
*/
[Throws]
DOMString getURL(optional DOMString path = "");
+ /**
+ * Register a new content script programmatically.
+ */
+ [Throws]
+ void registerContentScript(WebExtensionContentScript script);
+
+ /**
+ * Unregister a content script.
+ */
+ [Throws]
+ void unregisterContentScript(WebExtensionContentScript script);
/**
* Returns the list of currently active extension policies.
*/
static sequence<WebExtensionPolicy> getActiveExtensions();
/**
* Returns the currently-active policy for the extension with the given ID,
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1268,16 +1268,17 @@ this.Extension = class extends Extension
id: this.id,
uuid: this.uuid,
name: this.name,
instanceId: this.instanceId,
manifest: this.manifest,
resourceURL: this.resourceURL,
baseURL: this.baseURI.spec,
contentScripts: this.contentScripts,
+ registeredContentScripts: new Map(),
webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern),
localeData: this.localeData.serialize(),
permissions: this.permissions,
principal: this.principal,
optionalPermissions: this.manifest.optional_permissions,
};
}
@@ -1329,17 +1330,25 @@ this.Extension = class extends Extension
promises.push(Management.asyncEmitManifestEntry(this, directive));
}
}
let data = Services.ppmm.initialProcessData;
if (!data["Extension:Extensions"]) {
data["Extension:Extensions"] = [];
}
+
let serial = this.serialize();
+
+ // Map of the programmatically registered content script definitions
+ // (by string scriptId), used in ext-contentScripts.js to propagate
+ // the registered content scripts to the child content processes
+ // (e.g. when a new content process starts after a content process crash).
+ this.registeredContentScripts = serial.registeredContentScripts;
+
data["Extension:Extensions"].push(serial);
return this.broadcast("Extension:Startup", serial).then(() => {
return Promise.all(promises);
});
}
callOnClose(obj) {
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -478,17 +478,22 @@ defineLazyGetter(ProxyContextParent.prot
return can;
});
defineLazyGetter(ProxyContextParent.prototype, "apiObj", function() {
return this.apiCan.root;
});
defineLazyGetter(ProxyContextParent.prototype, "sandbox", function() {
- return Cu.Sandbox(this.principal, {sandboxName: this.uri.spec});
+ // NOTE: the required Blob and URL globals are used in the ext-registerContentScript.js
+ // API module to convert JS and CSS data into blob URLs.
+ return Cu.Sandbox(this.principal, {
+ sandboxName: this.uri.spec,
+ wantGlobalProperties: ["Blob", "URL"],
+ });
});
/**
* The parent side of proxied API context for extension content script
* running in ExtensionContent.jsm.
*/
class ContentScriptContextParent extends ProxyContextParent {
}
--- a/toolkit/components/extensions/WebExtensionPolicy.cpp
+++ b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -207,16 +207,49 @@ WebExtensionPolicy::GetURL(const nsAStri
nsCOMPtr<nsIURI> uri;
MOZ_TRY(NS_NewURI(getter_AddRefs(uri), spec));
MOZ_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec));
return NS_ConvertUTF8toUTF16(spec);
}
+void
+WebExtensionPolicy::RegisterContentScript(WebExtensionContentScript& script,
+ ErrorResult& aRv)
+{
+ // Raise an "invalid argument" error if the script is not related to
+ // the expected extension or if it is already registered.
+ if (script.mExtension != this || mContentScripts.Contains(&script)) {
+ aRv.Throw(NS_ERROR_INVALID_ARG);
+ return;
+ }
+
+ RefPtr<WebExtensionContentScript> newScript = &script;
+
+ if (!mContentScripts.AppendElement(Move(newScript), fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+
+ WebExtensionPolicyBinding::ClearCachedContentScriptsValue(this);
+}
+
+void
+WebExtensionPolicy::UnregisterContentScript(const WebExtensionContentScript& script,
+ ErrorResult& aRv)
+{
+ if (script.mExtension != this || !mContentScripts.RemoveElement(&script)) {
+ aRv.Throw(NS_ERROR_INVALID_ARG);
+ return;
+ }
+
+ WebExtensionPolicyBinding::ClearCachedContentScriptsValue(this);
+}
+
/* static */ bool
WebExtensionPolicy::UseRemoteWebExtensions(GlobalObject& aGlobal)
{
return EPS().UseRemoteExtensions();
}
/* static */ bool
WebExtensionPolicy::IsExtensionProcess(GlobalObject& aGlobal)
--- a/toolkit/components/extensions/WebExtensionPolicy.h
+++ b/toolkit/components/extensions/WebExtensionPolicy.h
@@ -54,16 +54,22 @@ public:
{
MOZ_ALWAYS_SUCCEEDS(mBaseURI->GetSpec(aBaseURL));
}
void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const;
Result<nsString, nsresult> GetURL(const nsAString& aPath) const;
+ void RegisterContentScript(WebExtensionContentScript& script,
+ ErrorResult& aRv);
+
+ void UnregisterContentScript(const WebExtensionContentScript& script,
+ ErrorResult& aRv);
+
bool CanAccessURI(const URLInfo& aURI, bool aExplicit = false) const
{
return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit);
}
bool IsPathWebAccessible(const nsAString& aPath) const
{
return mWebAccessiblePaths.Matches(aPath);
@@ -95,17 +101,16 @@ public:
{
return mContentSecurityPolicy;
}
void GetContentSecurityPolicy(nsAString& aCSP) const
{
aCSP = mContentSecurityPolicy;
}
-
already_AddRefed<MatchPatternSet> AllowedOrigins()
{
return do_AddRef(mHostPermissions);
}
void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins)
{
mHostPermissions = &aAllowedOrigins;
}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-c-contentScripts.js
@@ -0,0 +1,69 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+var {
+ ExtensionError,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ * The extension context which has registered the content script.
+ * @param {string} scriptId
+ * An unique id that represents the registered content script
+ * (generated and used internally to identify it across the different processes).
+ */
+class ContentScriptChild {
+ constructor(context, scriptId) {
+ this.context = context;
+ this.scriptId = scriptId;
+ this.unregistered = false;
+ }
+
+ async unregister() {
+ if (this.unregistered) {
+ throw new ExtensionError("Content script already unregistered");
+ }
+
+ this.unregistered = true;
+
+ await this.context.childManager.callParentAsyncFunction(
+ "contentScripts.unregister", [this.scriptId]);
+
+ this.context = null;
+ }
+
+ api() {
+ const {context} = this;
+
+ // TODO(rpl): allow to read the options related to the registered content script?
+ return {
+ unregister: () => {
+ return context.wrapPromise(this.unregister());
+ },
+ };
+ }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ contentScripts: {
+ register(options) {
+ return context.cloneScope.Promise.resolve().then(async () => {
+ const scriptId = await context.childManager.callParentAsyncFunction(
+ "contentScripts.register", [options]);
+
+ const registeredScript = new ContentScriptChild(context, scriptId);
+
+ return Cu.cloneInto(registeredScript.api(), context.cloneScope,
+ {cloneFunctions: true});
+ });
+ },
+ },
+ };
+ }
+};
--- a/toolkit/components/extensions/ext-c-toolkit.js
+++ b/toolkit/components/extensions/ext-c-toolkit.js
@@ -37,16 +37,23 @@ extensions.registerModules({
url: "chrome://extensions/content/ext-c-backgroundPage.js",
scopes: ["addon_child"],
manifest: ["background"],
paths: [
["extension", "getBackgroundPage"],
["runtime", "getBackgroundPage"],
],
},
+ contentScripts: {
+ url: "chrome://extensions/content/ext-c-contentScripts.js",
+ scopes: ["addon_child"],
+ paths: [
+ ["contentScripts"],
+ ],
+ },
extension: {
url: "chrome://extensions/content/ext-c-extension.js",
scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"],
paths: [
["extension"],
],
},
i18n: {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ext-contentScripts.js
@@ -0,0 +1,194 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* exported registerContentScript, unregisterContentScript */
+/* global registerContentScript, unregisterContentScript */
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var {
+ ExtensionError,
+ getUniqueId,
+} = ExtensionUtils;
+
+/**
+ * Represents (in the main browser process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ProxyContextParent} context
+ * The parent proxy context related to the extension context which
+ * has registered the content script.
+ * @param {RegisteredContentScriptOptions} details
+ * The options object related to the registered content script
+ * (which has the properties described in the content_scripts.json
+ * JSON API schema file).
+ */
+class ContentScriptParent {
+ constructor({context, details}) {
+ this.context = context;
+ this.scriptId = getUniqueId();
+ this.blobURLs = new Set();
+
+ this.options = this._convertOptions(details);
+
+ context.callOnClose(this);
+ }
+
+ close() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (this.destroyed) {
+ throw new Error("Unable to destroy ContentScriptParent twice");
+ }
+
+ this.destroyed = true;
+
+ this.context.forgetOnClose(this);
+
+ for (const blobURL of this.blobURLs) {
+ this.context.cloneScope.URL.revokeObjectURL(blobURL);
+ }
+
+ this.blobURLs.clear();
+
+ this.context = null;
+ this.options = null;
+ }
+
+ _convertOptions(details) {
+ const {context} = this;
+
+ const options = {
+ matches: details.matches,
+ exclude_matches: details.excludeMatches,
+ include_globs: details.includeGlobs,
+ exclude_globs: details.excludeGlobs,
+ all_frames: details.allFrames,
+ match_about_blank: details.matchAboutBlank,
+ run_at: details.runAt,
+ js: [],
+ css: [],
+ };
+
+ const convertCodeToURL = (data, mime) => {
+ const blob = new context.cloneScope.Blob(data, {type: mime});
+ const blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+ this.blobURLs.add(blobURL);
+
+ return blobURL;
+ };
+
+ if (details.js && details.js.length > 0) {
+ options.js = details.js.map(data => {
+ if (data.file) {
+ return data.file;
+ }
+
+ return convertCodeToURL([data.code], "text/javascript");
+ });
+ }
+
+ if (details.css && details.css.length > 0) {
+ options.css = details.css.map(data => {
+ if (data.file) {
+ return data.file;
+ }
+
+ return convertCodeToURL([data.code], "text/css");
+ });
+ }
+
+ return options;
+ }
+
+ serialize() {
+ return this.options;
+ }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ const {extension} = context;
+
+ // Map of the content script registered from the extension context.
+ //
+ // Map<scriptId -> ContentScriptParent>
+ const parentScriptsMap = new Map();
+
+ // Unregister all the scriptId related to a context when it is closed.
+ context.callOnClose({
+ close() {
+ if (parentScriptsMap.size === 0) {
+ return;
+ }
+
+ const scriptIds = Array.from(parentScriptsMap.keys());
+
+ for (let scriptId of scriptIds) {
+ extension.registeredContentScripts.delete(scriptId);
+ }
+
+ extension.broadcast("Extension:UnregisterContentScripts", {
+ id: extension.id,
+ scriptIds,
+ });
+ },
+ });
+
+ return {
+ contentScripts: {
+ async register(details) {
+ for (let origin of details.matches) {
+ if (!extension.whiteListedHosts.subsumes(new MatchPattern(origin))) {
+ throw new ExtensionError(`Permission denied to register a content script for ${origin}`);
+ }
+ }
+
+ const contentScript = new ContentScriptParent({context, details});
+ const {scriptId} = contentScript;
+
+ parentScriptsMap.set(scriptId, contentScript);
+
+ const scriptOptions = contentScript.serialize();
+
+ await extension.broadcast("Extension:RegisterContentScript", {
+ id: extension.id,
+ options: scriptOptions,
+ scriptId,
+ });
+
+ extension.registeredContentScripts.set(scriptId, scriptOptions);
+
+ return scriptId;
+ },
+
+ // This method is not available to the extension code, the extension code
+ // doesn't have access to the internally used scriptId, on the contrary
+ // the extension code will call script.unregister on the script API object
+ // that is resolved from the register API method returned promise.
+ async unregister(scriptId) {
+ const contentScript = parentScriptsMap.get(scriptId);
+ if (!contentScript) {
+ Cu.reportError(new Error(`No such content script ID: ${scriptId}`));
+
+ return;
+ }
+
+ parentScriptsMap.delete(scriptId);
+ extension.registeredContentScripts.delete(scriptId);
+
+ contentScript.destroy();
+
+ await extension.broadcast("Extension:UnregisterContentScripts", {
+ id: extension.id,
+ scriptIds: [scriptId],
+ });
+ },
+ },
+ };
+ }
+};
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -27,16 +27,24 @@
"clipboard": {
"url": "chrome://extensions/content/ext-clipboard.js",
"schema": "chrome://extensions/content/schemas/clipboard.json",
"scopes": ["addon_parent"],
"paths": [
["clipboard"]
]
},
+ "contentScripts": {
+ "url": "chrome://extensions/content/ext-contentScripts.js",
+ "schema": "chrome://extensions/content/schemas/content_scripts.json",
+ "scopes": ["addon_parent"],
+ "paths": [
+ ["contentScripts"]
+ ]
+ },
"contextualIdentities": {
"url": "chrome://extensions/content/ext-contextualIdentities.js",
"schema": "chrome://extensions/content/schemas/contextual_identities.json",
"scopes": ["addon_parent"],
"events": ["startup"],
"permissions": ["contextualIdentities"],
"paths": [
["contextualIdentities"]
--- a/toolkit/components/extensions/extension-process-script.js
+++ b/toolkit/components/extensions/extension-process-script.js
@@ -272,22 +272,27 @@ DocumentManager = {
for (let global of this.globals.keys()) {
yield* this.enumerateWindows(global.docShell);
}
}
},
};
ExtensionManager = {
+ // WeakMap<WebExtensionPolicy, Map<string, WebExtensionContentScript>>
+ registeredContentScripts: new DefaultWeakMap((extension) => new Map()),
+
init() {
MessageChannel.setupMessageManagers([Services.cpmm]);
Services.cpmm.addMessageListener("Extension:Startup", this);
Services.cpmm.addMessageListener("Extension:Shutdown", this);
Services.cpmm.addMessageListener("Extension:FlushJarCache", this);
+ Services.cpmm.addMessageListener("Extension:RegisterContentScript", this);
+ Services.cpmm.addMessageListener("Extension:UnregisterContentScripts", this);
let procData = Services.cpmm.initialProcessData || {};
for (let data of procData["Extension:Extensions"] || []) {
this.initExtension(data);
}
if (isContentProcess) {
@@ -332,16 +337,30 @@ ExtensionManager = {
localizeCallback,
backgroundScripts: (extension.manifest.background &&
extension.manifest.background.scripts),
contentScripts: extension.contentScripts.map(parseScriptOptions),
});
+ // Register any existent dinamically registered content script for the extension
+ // when a content process is started for the first time (which also cover
+ // a content process that crashed and it has been recreated).
+ const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+ if (extension.registeredContentScripts) {
+ for (let [scriptId, options] of extension.registeredContentScripts) {
+ const parsedOptions = parseScriptOptions(options);
+ const script = new WebExtensionContentScript(policy, parsedOptions);
+ policy.registerContentScript(script);
+ registeredContentScripts.set(scriptId, script);
+ }
+ }
+
policy.active = true;
policy.initData = extension;
}
return policy;
},
initExtension(data) {
let policy = this.initExtensionPolicy(data);
@@ -391,16 +410,64 @@ ExtensionManager = {
this.schemaJSON;
}
for (let [url, schema] of data) {
this.schemaJSON.set(url, schema);
}
break;
}
+
+ case "Extension:RegisterContentScript": {
+ let policy = WebExtensionPolicy.getByID(data.id);
+
+ if (policy) {
+ const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+ if (registeredContentScripts.has(data.scriptId)) {
+ Cu.reportError(new Error(
+ `Registering content script ${data.scriptId} on ${data.id} more than once`));
+ } else {
+ try {
+ const parsedOptions = parseScriptOptions(data.options);
+ const script = new WebExtensionContentScript(policy, parsedOptions);
+ policy.registerContentScript(script);
+ registeredContentScripts.set(data.scriptId, script);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+
+ Services.cpmm.sendAsyncMessage("Extension:RegisterContentScriptComplete");
+ break;
+ }
+
+ case "Extension:UnregisterContentScripts": {
+ let policy = WebExtensionPolicy.getByID(data.id);
+
+ if (policy) {
+ const registeredContentScripts = this.registeredContentScripts.get(policy);
+
+ for (const scriptId of data.scriptIds) {
+ const script = registeredContentScripts.get(scriptId);
+ if (script) {
+ try {
+ policy.unregisterContentScript(script);
+ registeredContentScripts.delete(scriptId);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ }
+ }
+
+ Services.cpmm.sendAsyncMessage("Extension:UnregisterContentScriptsComplete");
+ break;
+ }
}
},
};
function ExtensionProcessScript() {
if (!ExtensionProcessScript.singleton) {
ExtensionProcessScript.singleton = this;
}
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -4,16 +4,17 @@
toolkit.jar:
% content extensions %content/extensions/
content/extensions/dummy.xul
content/extensions/ext-alarms.js
content/extensions/ext-backgroundPage.js
content/extensions/ext-browser-content.js
content/extensions/ext-browserSettings.js
+ content/extensions/ext-contentScripts.js
content/extensions/ext-contextualIdentities.js
content/extensions/ext-clipboard.js
content/extensions/ext-cookies.js
content/extensions/ext-downloads.js
content/extensions/ext-extension.js
content/extensions/ext-i18n.js
#ifndef ANDROID
content/extensions/ext-identity.js
@@ -32,16 +33,17 @@ toolkit.jar:
content/extensions/ext-toolkit.js
content/extensions/ext-toolkit.json
content/extensions/ext-topSites.js
content/extensions/ext-webRequest.js
content/extensions/ext-webNavigation.js
# Below is a separate group using the naming convention ext-c-*.js that run
# in the child process.
content/extensions/ext-c-backgroundPage.js
+ content/extensions/ext-c-contentScripts.js
content/extensions/ext-c-extension.js
#ifndef ANDROID
content/extensions/ext-c-identity.js
#endif
content/extensions/ext-c-runtime.js
content/extensions/ext-c-storage.js
content/extensions/ext-c-test.js
content/extensions/ext-c-toolkit.js
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/content_scripts.json
@@ -0,0 +1,109 @@
+/* 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/. */
+
+[
+ {
+ "namespace": "contentScripts",
+ "types": [
+ {
+ "id": "ExtensionFileOrCode",
+ "choices": [
+ {
+ "type": "object",
+ "properties": {
+ "file": {
+ "$ref": "manifest.ExtensionURL"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "id": "RegisteredContentScriptOptions",
+ "type": "object",
+ "description": "Details of a content script registered programmatically",
+ "additionalProperties": { "$ref": "UnrecognizedProperty" },
+ "properties": {
+ "matches": {
+ "type": "array",
+ "optional": false,
+ "minItems": 1,
+ "items": { "$ref": "manifest.MatchPattern" }
+ },
+ "excludeMatches": {
+ "type": "array",
+ "optional": true,
+ "minItems": 1,
+ "items": { "$ref": "manifest.MatchPattern" }
+ },
+ "includeGlobs": {
+ "type": "array",
+ "optional": true,
+ "items": { "type": "string" }
+ },
+ "excludeGlobs": {
+ "type": "array",
+ "optional": true,
+ "items": { "type": "string" }
+ },
+ "css": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of CSS files to inject",
+ "items": { "$ref": "ExtensionFileOrCode" }
+ },
+ "js": {
+ "type": "array",
+ "optional": true,
+ "description": "The list of JS files to inject",
+ "items": { "$ref": "ExtensionFileOrCode" }
+ },
+ "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
+ "matchAboutBlank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
+ "runAt": {
+ "$ref": "extensionTypes.RunAt",
+ "optional": true,
+ "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
+ }
+ }
+ },
+ {
+ "id": "RegisteredContentScript",
+ "type": "object",
+ "description": "An object that represents a content script registered programmatically",
+ "functions": [
+ {
+ "name": "unregister",
+ "type": "function",
+ "description": "Unregister a content script registered programmatically",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "register",
+ "type": "function",
+ "description": "Register a content script programmatically",
+ "async": true,
+ "parameters": [
+ {
+ "name": "contentScriptOptions",
+ "$ref": "RegisteredContentScriptOptions"
+ }
+ ]
+ }
+ ]
+ }
+]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -2,16 +2,17 @@
# 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/.
toolkit.jar:
% content extensions %content/extensions/
content/extensions/schemas/alarms.json
content/extensions/schemas/browser_settings.json
content/extensions/schemas/clipboard.json
+ content/extensions/schemas/content_scripts.json
content/extensions/schemas/contextual_identities.json
content/extensions/schemas/cookies.json
content/extensions/schemas/downloads.json
content/extensions/schemas/events.json
content/extensions/schemas/experiments.json
content/extensions/schemas/extension.json
content/extensions/schemas/extension_types.json
content/extensions/schemas/extension_protocol_handlers.json
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -533,17 +533,17 @@
"type": "array",
"optional": true,
"description": "The list of CSS files to inject",
"items": { "$ref": "ExtensionURL" }
},
"js": {
"type": "array",
"optional": true,
- "description": "The list of CSS files to inject",
+ "description": "The list of JS files to inject",
"items": { "$ref": "ExtensionURL" }
},
"all_frames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> and is only injected into the top frame."},
"match_about_blank": {"type": "boolean", "optional": true, "description": "If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is <code>false</code>."},
"run_at": {
"$ref": "extensionTypes.RunAt",
"optional": true,
"default": "document_idle",
--- a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -54,16 +54,17 @@ let expectedCommonApis = [
let expectedContentApis = [
...expectedCommonApis,
...expectedContentApisTargetSpecific,
];
let expectedBackgroundApis = [
...expectedCommonApis,
...expectedBackgroundApisTargetSpecific,
+ "contentScripts.register",
"extension.ViewType",
"extension.getBackgroundPage",
"extension.getViews",
"extension.isAllowedFileSchemeAccess",
"extension.isAllowedIncognitoAccess",
// Note: extensionTypes is not visible in Chrome.
"extensionTypes.CSSOrigin",
"extensionTypes.ImageFormat",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="registered-extension-url-style">Registered Extension URL style</div>
+<div id="registered-extension-text-style">Registered Extension Text style</div>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
@@ -1,15 +1,15 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
const {newURI} = Services.io;
-add_task(async function test_WebExtensinonPolicy() {
+add_task(async function test_WebExtensionPolicy() {
const id = "foo@bar.baz";
const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610";
const baseURL = "file:///foo/";
const mozExtURL = `moz-extension://${uuid}/`;
const mozExtURI = newURI(mozExtURL);
let policy = new WebExtensionPolicy({
@@ -133,8 +133,105 @@ add_task(async function test_WebExtensin
Assert.throws(() => { policy2.active = true; }, /NS_ERROR_UNEXPECTED/,
`Should not be able to activate conflicting policy: ${id} ${uuid}`);
}
policy.active = false;
}
});
+
+add_task(async function test_WebExtensionPolicy_registerContentScripts() {
+ const id = "foo@bar.baz";
+ const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f";
+
+ const id2 = "foo-2@bar.baz";
+ const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd";
+
+ const baseURL = "file:///foo/";
+
+ const mozExtURL = `moz-extension://${uuid}/`;
+ const mozExtURL2 = `moz-extension://${uuid2}/`;
+
+ let policy = new WebExtensionPolicy({
+ id,
+ mozExtensionHostname: uuid,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+
+ let policy2 = new WebExtensionPolicy({
+ id: id2,
+ mozExtensionHostname: uuid2,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+
+ let script1 = new WebExtensionContentScript(policy, {
+ run_at: "document_end",
+ js: [`${mozExtURL}/registered-content-script.js`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ let script2 = new WebExtensionContentScript(policy, {
+ run_at: "document_end",
+ css: [`${mozExtURL}/registered-content-style.css`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ let script3 = new WebExtensionContentScript(policy2, {
+ run_at: "document_end",
+ css: [`${mozExtURL2}/registered-content-style.css`],
+ matches: new MatchPatternSet(["http://localhost/data/*"]),
+ });
+
+ deepEqual(policy.contentScripts, [], "The policy contentScripts is initially empty");
+
+ policy.registerContentScript(script1);
+
+ deepEqual(policy.contentScripts, [script1],
+ "script1 has been added to the policy contentScripts");
+
+ Assert.throws(
+ () => policy.registerContentScript(script1),
+ (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once");
+
+ Assert.throws(
+ () => policy.registerContentScript(script3),
+ (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " +
+ "a different extension");
+
+ Assert.throws(
+ () => policy.unregisterContentScript(script3),
+ (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " +
+ "a different extension");
+
+ deepEqual(policy.contentScripts, [script1], "script1 has not been added twice");
+
+ policy.registerContentScript(script2);
+
+ deepEqual(policy.contentScripts, [script1, script2],
+ "script2 has the last item of the policy contentScripts array");
+
+ policy.unregisterContentScript(script1);
+
+ deepEqual(policy.contentScripts, [script2],
+ "script1 has been removed from the policy contentscripts");
+
+ Assert.throws(
+ () => policy.unregisterContentScript(script1),
+ (e) => e.result == Cr.NS_ERROR_ILLEGAL_VALUE,
+ "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once");
+
+ deepEqual(policy.contentScripts, [script2],
+ "the policy contentscripts is unmodified when unregistering an unknown contentScript");
+
+ policy.unregisterContentScript(script2);
+
+ deepEqual(policy.contentScripts, [], "script2 has been removed from the policy contentScripts");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
@@ -0,0 +1,481 @@
+"use strict";
+
+const {
+ createAppInfo,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function check_applied_styles() {
+ const urlElStyle = getComputedStyle(document.querySelector("#registered-extension-url-style"));
+ const blobElStyle = getComputedStyle(document.querySelector("#registered-extension-text-style"));
+
+ browser.test.sendMessage("registered-styles-results", {
+ registeredExtensionUrlStyleBG: urlElStyle["background-color"],
+ registeredExtensionBlobStyleBG: blobElStyle["background-color"],
+ });
+}
+
+add_task(async function test_contentscripts_register_css() {
+ async function background() {
+ let cssCode = `
+ #registered-extension-text-style {
+ background-color: blue;
+ }
+ `;
+
+ let fileScript = await browser.contentScripts.register({
+ css: [{file: "registered_ext_style.css"}],
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ runAt: "document_start",
+ });
+
+ let textScript = await browser.contentScripts.register({
+ css: [{code: cssCode}],
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ runAt: "document_start",
+ });
+
+ browser.test.onMessage.addListener(async (msg) => {
+ switch (msg) {
+ case "unregister-text":
+ await textScript.unregister().catch(err => {
+ browser.test.fail(`Unexpected exception while unregistering text style: ${err}`);
+ });
+
+ await browser.test.assertRejects(
+ textScript.unregister(),
+ /Content script already unregistered/,
+ "Got the expected rejection on calling script.unregister() multiple times"
+ );
+
+ browser.test.sendMessage("unregister-text:done");
+ break;
+ case "unregister-file":
+ await fileScript.unregister().catch(err => {
+ browser.test.fail(`Unexpected exception while unregistering url style: ${err}`);
+ });
+
+ await browser.test.assertRejects(
+ fileScript.unregister(),
+ /Content script already unregistered/,
+ "Got the expected rejection on calling script.unregister() multiple times"
+ );
+
+ browser.test.sendMessage("unregister-file:done");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample_registered_styles.html",
+ "<all_urls>",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ run_at: "document_idle",
+ js: ["check_applied_styles.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "check_applied_styles.js": check_applied_styles,
+ "registered_ext_style.css": `
+ #registered-extension-url-style {
+ background-color: red;
+ }
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background_ready");
+
+ // Ensure that a content page running in a content process and which has been
+ // started after the content scripts has been registered, it still receives
+ // and registers the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const registeredStylesResults = await extension.awaitMessage("registered-styles-results");
+
+ equal(registeredStylesResults.registeredExtensionUrlStyleBG, "rgb(255, 0, 0)",
+ "The expected style has been applied from the registered extension url style");
+ equal(registeredStylesResults.registeredExtensionBlobStyleBG, "rgb(0, 0, 255)",
+ "The expected style has been applied from the registered extension blob style");
+
+ extension.sendMessage("unregister-file");
+ await extension.awaitMessage("unregister-file:done");
+
+ contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredURLStylesResults = await extension.awaitMessage("registered-styles-results");
+
+ equal(unregisteredURLStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered");
+ equal(unregisteredURLStylesResults.registeredExtensionBlobStyleBG, "rgb(0, 0, 255)",
+ "The expected style has been applied from the registered extension blob style");
+
+ extension.sendMessage("unregister-text");
+ await extension.awaitMessage("unregister-text:done");
+
+ contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredBlobStylesResults = await extension.awaitMessage("registered-styles-results");
+
+ equal(unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered");
+ equal(unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension blob style has been unregistered");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_contentscripts_unregister_on_context_unload() {
+ async function background() {
+ const frame = document.createElement("iframe");
+ frame.setAttribute("src", "/background-frame.html");
+
+ document.body.appendChild(frame);
+
+ browser.test.onMessage.addListener((msg) => {
+ switch (msg) {
+ case "unload-frame":
+ frame.remove();
+ browser.test.sendMessage("unload-frame:done");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage("background_ready");
+ }
+
+ async function background_frame() {
+ await browser.contentScripts.register({
+ css: [{file: "registered_ext_style.css"}],
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ runAt: "document_start",
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample_registered_styles.html",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample_registered_styles.html"],
+ run_at: "document_idle",
+ js: ["check_applied_styles.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "background-frame.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="background-frame.js"></script>
+ </head>
+ <body>
+ </body>
+ </html>
+ `,
+ "background-frame.js": background_frame,
+ "check_applied_styles.js": check_applied_styles,
+ "registered_ext_style.css": `
+ #registered-extension-url-style {
+ background-color: red;
+ }
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background_ready");
+
+ // Ensure that a content page running in a content process and which has been
+ // started after the content scripts has been registered, it still receives
+ // and registers the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const registeredStylesResults = await extension.awaitMessage("registered-styles-results");
+
+ equal(registeredStylesResults.registeredExtensionUrlStyleBG, "rgb(255, 0, 0)",
+ "The expected style has been applied from the registered extension url style");
+
+ extension.sendMessage("unload-frame");
+ await extension.awaitMessage("unload-frame:done");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`);
+
+ const unregisteredURLStylesResults = await extension.awaitMessage("registered-styles-results");
+
+ equal(unregisteredURLStylesResults.registeredExtensionUrlStyleBG, "rgba(0, 0, 0, 0)",
+ "The expected style has been applied once extension url style has been unregistered");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_contentscripts_register_js() {
+ async function background() {
+ browser.runtime.onMessage.addListener(([msg, expectedStates, readyState], sender) => {
+ if (msg == "chrome-namespace-ok") {
+ browser.test.sendMessage(msg);
+ return;
+ }
+
+ browser.test.assertEq("script-run", msg, "message type is correct");
+ browser.test.assertTrue(expectedStates.includes(readyState),
+ `readyState "${readyState}" is one of [${expectedStates}]`);
+ browser.test.sendMessage("script-run-" + expectedStates[0]);
+ });
+
+ // Raise an exception when the content script cannot be registered
+ // because the extension has no permission to access the specified origin.
+
+ await browser.test.assertRejects(
+ browser.contentScripts.register({
+ matches: ["http://*/*"],
+ js: [{code: "browser.test.fail(\"content script with wrong matches should not run\")"}],
+ }),
+ /Permission denied to register a content script for/,
+ "The reject contains the expected error message"
+ );
+
+ // Register a content script from a JS code string.
+
+ function textScriptCodeStart() {
+ browser.runtime.sendMessage(["script-run", ["loading"], document.readyState]);
+ }
+ function textScriptCodeEnd() {
+ browser.runtime.sendMessage(["script-run", ["interactive", "complete"], document.readyState]);
+ }
+ function textScriptCodeIdle() {
+ browser.runtime.sendMessage(["script-run", ["complete"], document.readyState]);
+ }
+
+ // Register content scripts from both extension URLs and plain JS code strings.
+
+ const content_scripts = [
+ // Plain JS code strings.
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{code: `(${textScriptCodeStart})()`}],
+ runAt: "document_start",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{code: `(${textScriptCodeEnd})()`}],
+ runAt: "document_end",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{code: `(${textScriptCodeIdle})()`}],
+ runAt: "document_idle",
+ },
+
+ // Extension URLs.
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{file: "content_script_start.js"}],
+ runAt: "document_start",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{file: "content_script_end.js"}],
+ runAt: "document_end",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{file: "content_script_idle.js"}],
+ runAt: "document_idle",
+ },
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: [{file: "content_script.js"}],
+ runAt: "document_idle",
+ },
+ ];
+
+ const expectedAPIs = ["unregister"];
+
+ for (const scriptOptions of content_scripts) {
+ const script = await browser.contentScripts.register(scriptOptions);
+ const actualAPIs = Object.keys(script);
+
+ browser.test.assertEq(JSON.stringify(expectedAPIs),
+ JSON.stringify(actualAPIs),
+ `Got a script API object for ${scriptOptions.js[0]}`);
+ }
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function contentScriptStart() {
+ browser.runtime.sendMessage(["script-run", ["loading"], document.readyState]);
+ }
+ function contentScriptEnd() {
+ browser.runtime.sendMessage(["script-run", ["interactive", "complete"], document.readyState]);
+ }
+ function contentScriptIdle() {
+ browser.runtime.sendMessage(["script-run", ["complete"], document.readyState]);
+ }
+
+ function contentScript() {
+ let manifest = browser.runtime.getManifest();
+ void manifest.permissions;
+ browser.runtime.sendMessage(["chrome-namespace-ok"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample.html",
+ ],
+ },
+ background,
+
+ files: {
+ "content_script_start.js": contentScriptStart,
+ "content_script_end.js": contentScriptEnd,
+ "content_script_idle.js": contentScriptIdle,
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let loadingCount = 0;
+ let interactiveCount = 0;
+ let completeCount = 0;
+ extension.onMessage("script-run-loading", () => { loadingCount++; });
+ extension.onMessage("script-run-interactive", () => { interactiveCount++; });
+
+ let completePromise = new Promise(resolve => {
+ extension.onMessage("script-run-complete", () => { completeCount++; resolve(); });
+ });
+
+ let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok");
+
+ // Ensure that a content page running in a content process and which has been
+ // already loaded when the content scripts has been registered, it has received
+ // and registered the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+
+ await Promise.all([completePromise, chromeNamespacePromise]);
+
+ await contentPage.close();
+
+ // Expect two content scripts to run (one registered using an extension URL
+ // and one registered from plain JS code).
+ equal(loadingCount, 2, "document_start script ran exactly twice");
+ equal(interactiveCount, 2, "document_end script ran exactly twice");
+ equal(completeCount, 2, "document_idle script ran exactly twice");
+
+ await extension.unload();
+});
+
+// Test that the contentScript.register options are correctly translated
+// into the expected WebExtensionContentScript properties.
+add_task(async function test_contentscripts_register_all_options() {
+ async function background() {
+ await browser.contentScripts.register({
+ js: [{file: "content_script.js"}],
+ css: [{file: "content_style.css"}],
+ matches: ["http://localhost/*"],
+ excludeMatches: ["http://localhost/exclude/*"],
+ excludeGlobs: ["*_exclude.html"],
+ includeGlobs: ["*_include.html"],
+ allFrames: true,
+ matchAboutBlank: true,
+ runAt: "document_start",
+ });
+
+ browser.test.sendMessage("background-ready", window.location.origin);
+ }
+
+ const extensionData = {
+ manifest: {
+ permissions: [
+ "http://localhost/*",
+ ],
+ },
+ background,
+
+ files: {
+ "content_script.js": "",
+ "content_style.css": "",
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ const baseExtURL = await extension.awaitMessage("background-ready");
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+
+ ok(policy, "Got the WebExtensionPolicy for the test extension");
+ equal(policy.contentScripts.length, 1, "Got the expected number of registered content scripts");
+
+ const script = policy.contentScripts[0];
+ let {allFrames, cssPaths, jsPaths, matchAboutBlank, runAt} = script;
+
+ deepEqual({
+ allFrames,
+ cssPaths,
+ jsPaths,
+ matchAboutBlank,
+ runAt,
+ }, {
+ allFrames: true,
+ cssPaths: [`${baseExtURL}/content_style.css`],
+ jsPaths: [`${baseExtURL}/content_script.js`],
+ matchAboutBlank: true,
+ runAt: "document_start",
+ }, "Got the expected content script properties");
+
+ ok(script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")),
+ "matched and include globs should match");
+ ok(!script.matchesURI(Services.io.newURI("http://localhost/exclude/ok_include.html")),
+ "exclude matches should not match");
+ ok(!script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")),
+ "exclude globs should not match");
+
+ await extension.unload();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
@@ -2,9 +2,10 @@
skip-if = os == "android" || (os == "win" && debug)
[test_ext_i18n_css.js]
[test_ext_contentscript.js]
[test_ext_contentscript_scriptCreated.js]
skip-if = debug # Bug 1407501
[test_ext_contentscript_triggeringPrincipal.js]
skip-if = os == "android" && debug
[test_ext_contentscript_xrays.js]
+[test_ext_contentScripts_register.js]
[test_ext_adoption_with_xrays.js]