author | Luca Greco <lgreco@mozilla.com> |
Fri, 30 Nov 2018 16:10:58 +0000 | |
changeset 449019 | 541cb39b63231ebdd85159013b9f384d39a13b29 |
parent 449018 | 39b6008cb9cfb02228f4946463d458d4dee8a8df |
child 449020 | 911cad5eea69459f2fa6f7354476d196f614348e |
push id | 74106 |
push user | luca.greco@alcacoop.it |
push date | Fri, 30 Nov 2018 20:57:14 +0000 |
treeherder | autoland@911cad5eea69 [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | zombie, robwu |
bugs | 1509339 |
milestone | 65.0a1 |
first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- a/toolkit/components/extensions/ExtensionContent.jsm +++ b/toolkit/components/extensions/ExtensionContent.jsm @@ -33,17 +33,16 @@ ChromeUtils.import("resource://gre/modul ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm"); ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm"); XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]); const { DefaultMap, DefaultWeakMap, - ExtensionError, getInnerWindowID, getWinUtils, promiseDocumentIdle, promiseDocumentLoaded, promiseDocumentReady, } = ExtensionUtils; const { @@ -600,19 +599,20 @@ class UserScript extends Script { close: () => { // Destroy the userScript sandbox when the related ContentScriptContextChild instance // is being closed. this.sandboxes.delete(context); Cu.nukeSandbox(userScriptSandbox); }, }); - // Inject the custom API registered by the extension API script. + // Notify listeners subscribed to the userScripts.onBeforeScript API event, + // to allow extension API script to provide its custom APIs to the userScript. if (apiScript) { - this.injectUserScriptAPIs(userScriptSandbox, context); + context.userScriptsEvents.emit("on-before-script", this.scriptMetadata, userScriptSandbox); } for (let script of sandboxScripts) { script.executeInGlobal(userScriptSandbox); } } finally { ExtensionTelemetry.userScriptInjection.stopwatchFinish(extension, context); } @@ -640,98 +640,16 @@ class UserScript extends Script { metadata: { "inner-window-id": context.innerWindowID, addonId: this.extension.policy.id, }, }); return sandbox; } - - injectUserScriptAPIs(userScriptScope, context) { - const {extension, scriptMetadata} = this; - const {userScriptAPIs, cloneScope: apiScope} = context; - - if (!userScriptAPIs) { - return; - } - - let clonedMetadata; - - const UserScriptError = userScriptScope.Error; - const UserScriptPromise = userScriptScope.Promise; - - const wrappedFnMap = new WeakMap(); - - function safeReturnCloned(res) { - try { - return Cu.cloneInto(res, userScriptScope); - } catch (err) { - Cu.reportError( - `userScripts API method wrapper for ${extension.policy.debugName}: ${err}` - ); - throw new UserScriptError("Unable to clone object in the userScript sandbox"); - } - } - - function wrapUserScriptAPIMethod(fn, fnName) { - return Cu.exportFunction(function(...args) { - let fnArgs = Cu.cloneInto([], apiScope); - - try { - for (let arg of args) { - if (typeof arg === "function") { - if (!wrappedFnMap.has(arg)) { - wrappedFnMap.set(arg, Cu.exportFunction(arg, apiScope)); - } - fnArgs.push(wrappedFnMap.get(arg)); - } else { - fnArgs.push(Cu.cloneInto(arg, apiScope)); - } - } - } catch (err) { - Cu.reportError(`Error cloning userScriptAPIMethod parameters in ${fnName}: ${err}`); - throw new UserScriptError("Only serializable parameters are supported"); - } - - if (clonedMetadata === undefined) { - clonedMetadata = Cu.cloneInto(scriptMetadata, apiScope); - } - - const res = runSafeSyncWithoutClone(fn, fnArgs, clonedMetadata, userScriptScope); - - if (res instanceof context.Promise) { - return UserScriptPromise.resolve().then(async () => { - let value; - try { - value = await res; - } catch (err) { - if (err instanceof context.Error) { - throw new UserScriptError(err.message); - } else { - throw safeReturnCloned(err); - } - } - return safeReturnCloned(value); - }); - } - - return safeReturnCloned(res); - }, userScriptScope); - } - - for (let key of Object.keys(userScriptAPIs)) { - Schemas.exportLazyGetter(userScriptScope, key, () => { - // Wrap the custom API methods exported to the userScript sandbox. - return wrapUserScriptAPIMethod(userScriptAPIs[key], key); - }); - } - - context.userScriptsEvents.emit("on-before-script", clonedMetadata, userScriptScope); - } } var contentScripts = new DefaultWeakMap(matcher => { const extension = processScript.extensions.get(matcher.extension); if ("userScriptOptions" in matcher) { return new UserScript(extension, matcher); } @@ -843,19 +761,16 @@ class ContentScriptContextChild extends this.childManager.inject(chromeObj); return chromeObj; }); Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj); Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj); - // A set of exported API methods provided by the extension to the userScripts sandboxes. - this.userScriptAPIs = null; - // Keep track if the userScript API script has been already executed in this context // (e.g. because there are more then one UserScripts that match the related webpage // and so the UserScript apiScript has already been executed). this.hasUserScriptAPIs = false; // A lazy created EventEmitter related to userScripts-specific events. defineLazyGetter(this, "userScriptsEvents", () => { return new ExtensionCommon.EventEmitter(); @@ -873,24 +788,16 @@ class ContentScriptContextChild extends Schemas.exportLazyGetter(this.contentWindow, "chrome", () => this.chromeObj); } get cloneScope() { return this.sandbox; } - setUserScriptAPIs(extCustomAPIs) { - if (this.userScriptAPIs) { - throw new ExtensionError("userScripts APIs may only be set once"); - } - - this.userScriptAPIs = extCustomAPIs; - } - async executeAPIScript(apiScript) { // Execute the UserScript apiScript only once per context (e.g. more then one UserScripts // match the same webpage and the apiScript has already been executed). if (apiScript && !this.hasUserScriptAPIs) { this.hasUserScriptAPIs = true; apiScript.executeInGlobal(this.cloneScope); } }
--- a/toolkit/components/extensions/child/ext-toolkit.js +++ b/toolkit/components/extensions/child/ext-toolkit.js @@ -71,17 +71,16 @@ extensions.registerModules({ paths: [ ["userScripts"], ], }, userScriptsContent: { url: "chrome://extensions/content/child/ext-userScripts-content.js", scopes: ["content_child"], paths: [ - ["userScripts", "setScriptAPIs"], ["userScripts", "onBeforeScript"], ], }, webRequest: { url: "chrome://extensions/content/child/ext-webRequest.js", scopes: ["addon_child"], paths: [ ["webRequest"],
--- a/toolkit/components/extensions/child/ext-userScripts-content.js +++ b/toolkit/components/extensions/child/ext-userScripts-content.js @@ -1,40 +1,353 @@ /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set sts=2 sw=2 et tw=80: */ "use strict"; var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled"; var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`; +ChromeUtils.defineModuleGetter(this, "Schemas", "resource://gre/modules/Schemas.jsm"); + XPCOMUtils.defineLazyPreferenceGetter(this, "userScriptsEnabled", USERSCRIPT_PREFNAME, false); var { ExtensionError, } = ExtensionUtils; +const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"]; + +/** + * Represents a user script in the child content process. + * + * This class implements the API object that is passed as a parameter to the + * browser.userScripts.onBeforeScript API Event. + * + * @param {Object} params + * @param {ContentScriptContextChild} params.context + * The context which has registered the userScripts.onBeforeScript listener. + * @param {PlainJSONValue} params.metadata + * An opaque user script metadata value (as set in userScripts.register). + * @param {Sandbox} params.scriptSandbox + * The Sandbox object of the userScript. + */ +class UserScript { + constructor({context, metadata, scriptSandbox}) { + this.context = context; + this.extension = context.extension; + this.apiSandbox = context.cloneScope; + this.metadata = metadata; + this.scriptSandbox = scriptSandbox; + + this.ScriptError = scriptSandbox.Error; + this.ScriptPromise = scriptSandbox.Promise; + } + + /** + * Returns the API object provided to the userScripts.onBeforeScript listeners. + * + * @returns {Object} + * The API object with the properties and methods to export + * to the extension code. + */ + api() { + return { + metadata: this.metadata, + defineGlobals: (sourceObject) => this.defineGlobals(sourceObject), + export: (value) => this.export(value), + }; + } + + /** + * Define all the properties of a given plain object as lazy getters of the + * userScript global object. + * + * @param {Object} sourceObject + * A set of objects and methods to export into the userScript scope as globals. + * + * @throws {context.Error} + * Throws an apiScript error when sourceObject is not a plain object. + */ + defineGlobals(sourceObject) { + let className; + try { + className = ChromeUtils.getClassName(sourceObject, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className !== "Object") { + throw new this.context.Error("Invalid sourceObject type, plain object expected."); + } + + this.exportLazyGetters(sourceObject, this.scriptSandbox); + } + + /** + * Convert a given value to make it accessible to the userScript code. + * + * - any property value that is already accessible to the userScript code is returned unmodified by + * the lazy getter + * - any apiScript's Function is wrapped using the `wrapFunction` method + * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its + * properties). + * + * @param {any} valueToExport + * A value to convert into an object accessible to the userScript. + * + * @param {Object} privateOptions + * A set of options used when this method is called internally (not exposed in the + * api object exported to the onBeforeScript listeners). + * @param {Error} Error + * The Error constructor to use to report errors (defaults to the apiScript context's Error + * when missing). + * @param {Error} errorMessage + * A custom error message to report exporting error on values not allowed. + * + * @returns {any} + * The resulting userScript object. + * + * @throws {context.Error | privateOptions.Error} + * Throws an error when the value is not allowed and it can't be exported into an allowed one. + */ + export(valueToExport, privateOptions = {}) { + const ExportError = privateOptions.Error || this.context.Error; + + if (this.canAccess(valueToExport, this.scriptSandbox)) { + // Return the value unmodified if the userScript principal is already allowed + // to access it. + return valueToExport; + } + + let className; + + try { + className = ChromeUtils.getClassName(valueToExport, true); + } catch (e) { + // sourceObject is not an object; + } + + if (className === "Function") { + return this.wrapFunction(valueToExport); + } + + if (className === "Object") { + return this.exportLazyGetters(valueToExport); + } + + let valueType = className || typeof valueToExport; + throw new ExportError(privateOptions.errorMessage || + `${valueType} cannot be exported to the userScript`); + } + + /** + * Export all the properties of the `src` plain object as lazy getters on the `dest` object, + * or in a newly created userScript object if `dest` is `undefined`. + * + * @param {Object} src + * A set of properties to define on a `dest` object as lazy getters. + * @param {Object} [dest] + * An optional `dest` object (a new userScript object is created by default when not specified). + * + * @returns {Object} + * The resulting userScript object. + */ + exportLazyGetters(src, dest = undefined) { + dest = dest || Cu.createObjectIn(this.scriptSandbox); + + for (let [key, value] of this.shallowCloneEntries(src)) { + Schemas.exportLazyGetter(dest, key, () => { + return this.export(value, { + // Lazy properties will raise an error for properties with not allowed + // values to the userScript scope, and so we have to raise an userScript + // Error here. + Error: this.ScriptError, + errorMessage: `Error accessing disallowed property "${key}"`, + }); + }); + } + + return dest; + } + + /** + * Export and wrap an apiScript function to provide the following behaviors: + * - errors throws from an exported function are checked by `handleAPIScriptError` + * - returned apiScript's Promises (not accessible to the userScript) are converted into a + * userScript's Promise + * - check if the returned or resolved value is accessible to the userScript code + * (and raise a userScript error if it is not) + * + * @param {Function} fn + * The apiScript function to wrap + * + * @returns {Object} + * The resulting userScript function. + */ + wrapFunction(fn) { + return Cu.exportFunction((...args) => { + let res; + try { + // Checks that all the elements in the `...args` array are allowed to be + // received from the apiScript. + for (let arg of args) { + if (!this.canAccess(arg, this.apiSandbox)) { + throw new this.ScriptError(`Parameter not accessible to the userScript API`); + } + } + + res = fn(...args); + } catch (err) { + this.handleAPIScriptError(err); + } + + // Prevent execution of proxy traps while checking if the return value is a Promise. + if (!Cu.isProxy(res) && res instanceof this.context.Promise) { + return this.ScriptPromise.resolve().then(async () => { + let value; + + try { + value = await res; + } catch (err) { + this.handleAPIScriptError(err); + } + + return this.ensureAccessible(value); + }); + } + + return this.ensureAccessible(res); + }, this.scriptSandbox); + } + + /** + * Shallow clone the source object and iterate over its Object properties (or Array elements), + * which allow us to safely iterate over all its properties (including callable objects that + * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g. + * getters). + * + * @param {Object|Array} obj + * The Object or Array object to shallow clone and iterate over. + */ + * shallowCloneEntries(obj) { + const clonedObj = ChromeUtils.shallowClone(obj); + + for (let entry of Object.entries(clonedObj)) { + yield entry; + } + } + + /** + * Check if the given value is accessible to the targetScope. + * + * @param {any} val + * The value to check. + * @param {Sandbox} targetScope + * The targetScope that should be able to access the value. + * + * @returns {boolean} + */ + canAccess(val, targetScope) { + if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) { + return true; + } + + // Disallow objects that are coming from principals that are not + // subsumed by the targetScope's principal. + try { + const targetPrincipal = Cu.getObjectPrincipal(targetScope); + if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) { + return false; + } + } catch (err) { + Cu.reportError(err); + return false; + } + + return true; + } + + /** + * Check if the value returned (or resolved) from an apiScript method is accessible + * to the userScript code, and throw a userScript Error if it is not allowed. + * + * @param {any} res + * The value to return/resolve. + * + * @returns {any} + * The exported value. + * + * @throws {Error} + * Throws a userScript error when the value is not accessible to the userScript scope. + */ + ensureAccessible(res) { + if (this.canAccess(res, this.scriptSandbox)) { + return res; + } + + throw new this.ScriptError("Return value not accessible to the userScript"); + } + + /** + * Handle the error raised (and rejected promise returned) from apiScript functions exported to the + * userScript. + * + * @param {any} err + * The value to return/resolve. + * + * @throws {any} + * This method is expected to throw: + * - any value that is already accessible to the userScript code is forwarded unmodified + * - any value that is not accessible to the userScript code is logged in the console + * (to make it easier to investigate the underlying issue) and converted into a + * userScript Error (with the generic "An unexpected apiScript error occurred" error + * message accessible to the userScript) + */ + handleAPIScriptError(err) { + if (this.canAccess(err, this.scriptSandbox)) { + throw err; + } + + // Log the actual error on the console and raise a generic userScript Error + // on error objects that can't be accessed by the UserScript principal. + try { + const debugName = this.extension.policy.debugName; + Cu.reportError( + `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}`); + } catch (e) {} + + throw new this.ScriptError(`An unexpected apiScript error occurred`); + } +} + this.userScriptsContent = class extends ExtensionAPI { getAPI(context) { return { userScripts: { - setScriptAPIs(exportedAPIMethods) { - if (!userScriptsEnabled) { - throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); - } - - context.setUserScriptAPIs(exportedAPIMethods); - }, onBeforeScript: new EventManager({ context, name: "userScripts.onBeforeScript", register: fire => { - let handler = (event, userScriptMetadata, userScriptSandbox) => { - const apiObj = Cu.createObjectIn(context.cloneScope); - apiObj.metadata = userScriptMetadata; - apiObj.global = userScriptSandbox; + if (!userScriptsEnabled) { + throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG); + } + + let handler = (event, metadata, scriptSandbox, eventResult) => { + const us = new UserScript({ + context, metadata, scriptSandbox, + }); + + const apiObj = Cu.cloneInto(us.api(), context.cloneScope, {cloneFunctions: true}); + + Object.defineProperty(apiObj, "global", { + value: scriptSandbox, + enumerable: true, + configurable: true, + writable: true, + }); fire.raw(apiObj); }; context.userScriptsEvents.on("on-before-script", handler); return () => { context.userScriptsEvents.off("on-before-script", handler); };
--- a/toolkit/components/extensions/ext-toolkit.json +++ b/toolkit/components/extensions/ext-toolkit.json @@ -196,17 +196,16 @@ "paths": [ ["userScripts"] ] }, "userScriptsContent": { "schema": "chrome://extensions/content/schemas/user_scripts_content.json", "scopes": ["content_child"], "paths": [ - ["userScripts", "setScriptAPIs"], ["userScripts", "onBeforeScript"] ] }, "webNavigation": { "url": "chrome://extensions/content/parent/ext-webNavigation.js", "schema": "chrome://extensions/content/schemas/web_navigation.json", "scopes": ["addon_parent"], "paths": [
--- a/toolkit/components/extensions/schemas/user_scripts_content.json +++ b/toolkit/components/extensions/schemas/user_scripts_content.json @@ -2,39 +2,16 @@ * 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": "userScripts", "permissions": ["manifest:user_scripts"], "allowedContexts": ["content"], - "types": [ - { - "id": "ExportedAPIMethods", - "type": "object", - "description": "A set of API methods provided by the extensions to its userScripts", - "additionalProperties": { "type": "function" } - } - ], - "functions": [ - { - "name": "setScriptAPIs", - "permissions": ["manifest:user_scripts.api_script"], - "allowedContexts": ["content", "content_only"], - "type": "function", - "description": "Provides a set of custom API methods available to the registered userScripts", - "parameters": [ - { - "name": "exportedAPIMethods", - "$ref": "ExportedAPIMethods" - } - ] - } - ], "events": [ { "name": "onBeforeScript", "permissions": ["manifest:user_scripts.api_script"], "allowedContexts": ["content", "content_only"], "type": "function", "description": "Event called when a new userScript global has been created", "parameters": [ @@ -44,16 +21,41 @@ "properties": { "metadata": { "type": "any", "description": "The userScript metadata (as set in userScripts.register)" }, "global": { "type": "any", "description": "The userScript global" + }, + "defineGlobals": { + "type": "function", + "description": "Exports all the properties of a given plain object as userScript globals", + "parameters": [ + { + "type": "object", + "name": "sourceObject", + "description": "A plain object whose properties are exported as userScript globals" + } + ] + }, + "export": { + "type": "function", + "description": "Convert a given value to make it accessible to the userScript code", + "parameters": [ + { + "type": "any", + "name": "value", + "description": "A value to convert into an object accessible to the userScript" + } + ], + "returns": { + "type": "any" + } } } } ] } ] } ]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js @@ -334,37 +334,48 @@ async function test_userScript_APIMethod if (messageListener) { browser.runtime.onMessage.addListener(messageListener); } browser.test.sendMessage("background-ready"); } - function notifyFinish([failureReason]) { + function notifyFinish(failureReason) { browser.test.assertEq(undefined, failureReason, "should be completed without errors"); browser.test.sendMessage("test_userScript_APIMethod:done"); } + function assertTrue(val, message) { + browser.test.assertTrue(val, message); + if (!val) { + browser.test.sendMessage("test_userScript_APIMethod:done"); + throw message; + } + } + let extension = ExtensionTestUtils.loadExtension({ manifest: { permissions: [ "http://localhost/*/file_sample.html", ], user_scripts: { api_script: "api-script.js", }, }, // Defines a background script that receives all the needed test parameters. background: ` const metadata = ${JSON.stringify(userScriptMetadata)}; (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener}) `, files: { - "api-script.js": `(${apiScript})(${notifyFinish})`, + "api-script.js": `(${apiScript})({ + assertTrue: ${assertTrue}, + notifyFinish: ${notifyFinish} + })`, }, }); // Load a page in a content process, register the user script and then load a // new page in the existing content process. let url = `${BASE_URL}/file_sample.html`; let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); @@ -379,60 +390,65 @@ async function test_userScript_APIMethod await extension.awaitMessage("test_userScript_APIMethod:done"); await extension.unload(); await contentPage.close(); } add_task(async function test_apiScript_exports_simple_sync_method() { - function apiScript(notifyFinish) { - browser.userScripts.setScriptAPIs({ - notifyFinish, - testAPIMethod([param1, param2, arrayParam], scriptMetadata) { - browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name, - "Got the expected value for a string scriptMetadata property"); - browser.test.assertEq(null, scriptMetadata.nullProperty, - "Got the expected value for a null scriptMetadata property"); - browser.test.assertTrue(scriptMetadata.arrayProperty && - scriptMetadata.arrayProperty.length === 1 && - scriptMetadata.arrayProperty[0] === "el1", - "Got the expected value for an array scriptMetadata property"); - browser.test.assertTrue(scriptMetadata.objectProperty && - scriptMetadata.objectProperty.nestedProp === "nestedValue", - "Got the expected value for an object scriptMetadata property"); + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptMetadata = script.metadata; - browser.test.assertEq("param1", param1, "Got the expected parameter value"); - browser.test.assertEq("param2", param2, "Got the expected parameter value"); + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(stringParam, numberParam, boolParam, nullParam, undefinedParam, arrayParam) { + browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name, + "Got the expected value for a string scriptMetadata property"); + browser.test.assertEq(null, scriptMetadata.nullProperty, + "Got the expected value for a null scriptMetadata property"); + browser.test.assertTrue(scriptMetadata.arrayProperty && + scriptMetadata.arrayProperty.length === 1 && + scriptMetadata.arrayProperty[0] === "el1", + "Got the expected value for an array scriptMetadata property"); + browser.test.assertTrue(scriptMetadata.objectProperty && + scriptMetadata.objectProperty.nestedProp === "nestedValue", + "Got the expected value for an object scriptMetadata property"); - browser.test.assertEq(3, arrayParam.length, "Got the expected length on the array param"); - browser.test.assertTrue(arrayParam.includes(1), - "Got the expected result when calling arrayParam.includes"); + browser.test.assertEq("param1", stringParam, "Got the expected string parameter value"); + browser.test.assertEq(123, numberParam, "Got the expected number parameter value"); + browser.test.assertEq(true, boolParam, "Got the expected boolean parameter value"); + browser.test.assertEq(null, nullParam, "Got the expected null parameter value"); + browser.test.assertEq(undefined, undefinedParam, "Got the expected undefined parameter value"); - return "returned_value"; - }, + browser.test.assertEq(3, arrayParam.length, "Got the expected length on the array param"); + browser.test.assertTrue(arrayParam.includes(1), + "Got the expected result when calling arrayParam.includes"); + + return "returned_value"; + }, + }); }); } function userScript() { - const {testAPIMethod, notifyFinish} = this; + const {assertTrue, notifyFinish, testAPIMethod} = this; // Redefine the includes method on the Array prototype, to explicitly verify that the method // redefined in the userScript is not used when accessing arrayParam.includes from the API script. Array.prototype.includes = () => { // eslint-disable-line no-extend-native throw new Error("Unexpected prototype leakage"); }; const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor - const result = testAPIMethod("param1", "param2", arrayParam); + const result = testAPIMethod("param1", 123, true, null, undefined, arrayParam); - if (result !== "returned_value") { - notifyFinish(`userScript got an unexpected result value: ${result}`); - } else { - notifyFinish(); - } + assertTrue(result === "returned_value", `userScript got an unexpected result value: ${result}`); + + notifyFinish(); } const userScriptMetadata = { name: "test-user-script-exported-apis", arrayProperty: ["el1"], objectProperty: {nestedProp: "nestedValue"}, nullProperty: null, }; @@ -440,59 +456,58 @@ add_task(async function test_apiScript_e await test_userScript_APIMethod({ userScript, apiScript, userScriptMetadata, }); }); add_task(async function test_apiScript_async_method() { - function apiScript(notifyFinish) { - const {cloneInto} = this; + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(param, cb, cb2, objWithCb) { + browser.test.assertEq("function", typeof cb, "Got a callback function parameter"); + browser.test.assertTrue(cb === cb2, "Got the same cloned function for the same function parameter"); - browser.userScripts.setScriptAPIs({ - notifyFinish, - async testAPIMethod([param, cb, cb2], scriptMetadata, scriptGlobal) { - browser.test.assertEq("function", typeof cb, "Got a callback function parameter"); - browser.test.assertTrue(cb === cb2, "Got the same cloned function for the same function parameter"); + browser.runtime.sendMessage(param).then(bgPageRes => { + const cbResult = cb(script.export(bgPageRes)); + browser.test.sendMessage("user-script-callback-return", cbResult); + }); - browser.runtime.sendMessage(param).then(bgPageRes => { - const cbResult = cb(cloneInto(bgPageRes, scriptGlobal)); - browser.test.sendMessage("user-script-callback-return", cbResult); - }); - - return "resolved_value"; - }, + return "resolved_value"; + }, + }); }); } async function userScript() { // Redefine Promise to verify that it doesn't break the WebExtensions internals // that are going to use them. const {Promise} = this; Promise.resolve = function() { throw new Error("Promise.resolve poisoning"); }; this.Promise = function() { throw new Error("Promise constructor poisoning"); }; - const {testAPIMethod, notifyFinish} = this; + const {assertTrue, notifyFinish, testAPIMethod} = this; const cb = (cbParam) => { return `callback param: ${JSON.stringify(cbParam)}`; }; const cb2 = cb; const asyncAPIResult = await testAPIMethod("param3", cb, cb2); - if (asyncAPIResult !== "resolved_value") { - notifyFinish(`userScript got an unexpected resolved value: ${asyncAPIResult}`); - } else { - notifyFinish(); - } + assertTrue(asyncAPIResult === "resolved_value", + `userScript got an unexpected resolved value: ${asyncAPIResult}`); + + notifyFinish(); } async function runtimeMessageListener(param) { if (param !== "param3") { browser.test.fail(`Got an unexpected message: ${param}`); } return {bgPageReply: true}; @@ -505,25 +520,672 @@ add_task(async function test_apiScript_a async testFn({extension}) { const res = await extension.awaitMessage("user-script-callback-return"); equal(res, `callback param: ${JSON.stringify({bgPageReply: true})}`, "Got the expected userScript callback return value"); }, }); }); +add_task(async function test_apiScript_method_with_webpage_objects_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(windowParam, documentParam) { + browser.test.assertEq(window, windowParam, "Got a reference to the native window as first param"); + browser.test.assertEq(window.document, documentParam, + "Got a reference to the native document as second param"); + + // Return an uncloneable webpage object, which checks that if the returned object is from a principal + // that is subsumed by the userScript sandbox principal, it is returned without being cloned. + return windowParam; + }, + }); + }); + } + + async function userScript() { + const {assertTrue, notifyFinish, testAPIMethod} = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue(result === window, + `userScript got an unexpected returned value: ${result}`); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_got_param_with_methods() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobal = script.global; + const ScriptFunction = scriptGlobal.Function; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(objWithMethods) { + browser.test.assertEq("objPropertyValue", objWithMethods && objWithMethods.objProperty, + "Got the expected property on the object passed as a parameter"); + browser.test.assertEq(undefined, typeof objWithMethods && objWithMethods.objMethod, + "XrayWrapper should deny access to a callable property"); + + browser.test.assertTrue( + objWithMethods && objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod instanceof ScriptFunction.wrappedJSObject, + "The callable property is accessible on the wrappedJSObject"); + + browser.test.assertEq("objMethodResult: p1", objWithMethods && objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod("p1"), + "Got the expected result when calling the method on the wrappedJSObject"); + return true; + }, + }); + }); + } + + async function userScript() { + const {assertTrue, notifyFinish, testAPIMethod} = this; + + let result = testAPIMethod({ + objProperty: "objPropertyValue", + objMethod(param) { + return `objMethodResult: ${param}`; + }, + }); + + assertTrue(result === true, `userScript got an unexpected returned value: ${result}`); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_throws_errors() { + function apiScript({notifyFinish}) { + let proxyTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobals = { + Error: script.global.Error, + TypeError: script.global.TypeError, + Proxy: script.global.Proxy, + }; + + script.defineGlobals({ + notifyFinish, + testAPIMethod(errorTestName, returnRejectedPromise) { + let err; + + switch (errorTestName) { + case "apiScriptError": + err = new Error(`${errorTestName} message`); + break; + case "apiScriptThrowsPlainString": + err = `${errorTestName} message`; + break; + case "apiScriptThrowsNull": + err = null; + break; + case "userScriptError": + err = new scriptGlobals.Error(`${errorTestName} message`); + break; + case "userScriptTypeError": + err = new scriptGlobals.TypeError(`${errorTestName} message`); + break; + case "userScriptProxyObject": + let proxyTarget = script.export({ + name: "ProxyObject", message: "ProxyObject message", + }); + let proxyHandlers = script.export({ + get(target, prop) { + proxyTrapsCount++; + switch (prop) { + case "name": + return "ProxyObjectGetName"; + case "message": + return "ProxyObjectGetMessage"; + } + return undefined; + }, + getPrototypeOf() { + proxyTrapsCount++; + return scriptGlobals.TypeError; + }, + }); + err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers); + break; + default: + browser.test.fail(`Unknown ${errorTestName} error testname`); + return undefined; + } + + if (returnRejectedPromise) { + return Promise.reject(err); + } + + throw err; + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq(0, proxyTrapsCount, "Proxy traps should not be triggered"); + }, + resetProxyTrapCounter() { + proxyTrapsCount = 0; + }, + sendResults(results) { + browser.test.sendMessage("test-results", results); + }, + }); + }); + } + + async function userScript() { + const { + assertNoProxyTrapTriggered, + notifyFinish, + resetProxyTrapCounter, + sendResults, + testAPIMethod, + } = this; + + let apiThrowResults = {}; + let apiThrowTestCases = [ + "apiScriptError", + "apiScriptThrowsPlainString", + "apiScriptThrowsNull", + "userScriptError", + "userScriptTypeError", + "userScriptProxyObject", + ]; + for (let errorTestName of apiThrowTestCases) { + try { + testAPIMethod(errorTestName); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiThrowResults[errorTestName] = {name: err.name, message: err.message}; + } else { + apiThrowResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiThrowResults); + + resetProxyTrapCounter(); + + let apiRejectsResults = {}; + for (let errorTestName of apiThrowTestCases) { + try { + await testAPIMethod(errorTestName, true); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiRejectsResults[errorTestName] = {name: err.name, message: err.message}; + } else { + apiRejectsResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiRejectsResults); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + async testFn({extension}) { + const expectedResults = { + // Any error not explicitly raised as a userScript objects or error instance is + // expected to be turned into a generic error message. + "apiScriptError": {name: "Error", message: "An unexpected apiScript error occurred"}, + + // When the api script throws a primitive value, we expect to receive it unmodified on + // the userScript side. + "apiScriptThrowsPlainString": { + typeOf: "string", value: "apiScriptThrowsPlainString message", + name: undefined, message: undefined, + }, + "apiScriptThrowsNull": { + typeOf: "object", value: null, + name: undefined, message: undefined, + }, + + // Error messages that the apiScript has explicitly created as userScript's Error + // global instances are expected to be passing through unmodified. + "userScriptError": {name: "Error", message: "userScriptError message"}, + "userScriptTypeError": {name: "TypeError", message: "userScriptTypeError message"}, + + // Error raised from the apiScript as userScript proxy objects are expected to + // be passing through unmodified. + "userScriptProxyObject": { + typeOf: "object", name: "ProxyObjectGetName", message: "ProxyObjectGetMessage", + }, + }; + + info("Checking results from errors raised from an apiScript exported function"); + + const apiThrowResults = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual(apiThrowResults[key], expected, + `Got the expected error object for test case "${key}"`); + } + + Assert.deepEqual(Object.keys(expectedResults).sort(), + Object.keys(apiThrowResults).sort(), + "the expected and actual test case names matches"); + + info("Checking expected results from errors raised from an apiScript exported function"); + + // Verify expected results from rejected promises returned from an apiScript exported function. + const apiThrowRejections = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual(apiThrowRejections[key], expected, + `Got the expected rejected object for test case "${key}"`); + } + + Assert.deepEqual(Object.keys(expectedResults).sort(), + Object.keys(apiThrowRejections).sort(), + "the expected and actual test case names matches"); + }, + }); +}); + +add_task(async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(...args) { + // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object + // is supposed to be Object.prototype. + browser.test.assertEq( + script.global.Object.prototype, + Object.getPrototypeOf(args[0]), + "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap"); + + browser.test.assertTrue(Array.isArray(args[0]), + "Got an array object for the XrayWrapped proxy object param"); + browser.test.assertEq(undefined, args[0].length, + "XrayWrappers deny access to the length property"); + browser.test.assertEq(undefined, args[0][0], + "Got the expected item in the array object"); + return true; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethod, + } = this; + + let proxy = new Proxy(["expectedArrayValue"], { + getPrototypeOf() { + throw new Error("Proxy's getPrototypeOf trap"); + }, + get(target, prop, receiver) { + throw new Error("Proxy's get trap"); + }, + }); + + let result = testAPIMethod(proxy); + + assertTrue(result, `userScript got an unexpected returned value: ${result}`); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_return_proxy_object() { + function apiScript(sharedTestAPIMethods) { + const {cloneInto} = this; + let proxyTrapsCount = 0; + let scriptTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodError() { + return new Proxy(["expectedArrayValue"], { + getPrototypeOf(target) { + proxyTrapsCount++; + return Object.getPrototypeOf(target); + }, + }); + }, + testAPIMethodOk() { + return new script.global.Proxy( + cloneInto(["expectedArrayValue"], script.global), + cloneInto({ + getPrototypeOf(target) { + scriptTrapsCount++; + return script.global.Object.getPrototypeOf(target); + }, + }, script.global, {cloneFunctions: true})); + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq(0, proxyTrapsCount, "Proxy traps should not be triggered"); + }, + assertScriptProxyTrapsCount(expected) { + browser.test.assertEq(expected, scriptTrapsCount, "Script Proxy traps should have been triggered"); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + assertNoProxyTrapTriggered, + assertScriptProxyTrapsCount, + notifyFinish, + testAPIMethodError, + testAPIMethodOk, + } = this; + + let error; + try { + let result = testAPIMethodError(); + notifyFinish(`Unexpected returned value while expecting error: ${result}`); + return; + } catch (err) { + error = err; + } + + assertTrue(error && error.message.includes("Return value not accessible to the userScript"), + `Got an unexpected error message: ${error}`); + + error = undefined; + try { + let result = testAPIMethodOk(); + assertScriptProxyTrapsCount(0); + if (!(result instanceof Array)) { + notifyFinish(`Got an unexpected result: ${result}`); + return; + } + assertScriptProxyTrapsCount(1); + } catch (err) { + error = err; + } + + assertTrue(!error, `Got an unexpected error: ${error}`); + + assertNoProxyTrapTriggered(); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_returns_functions() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIReturnsFunction() { + // Return a function with provides the same kind of behavior + // of the API methods exported as globals. + return script.export(() => window); + }, + testAPIReturnsObjWithMethod() { + return script.export({ + getWindow() { + return window; + }, + }); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIReturnsFunction, + testAPIReturnsObjWithMethod, + } = this; + + let resultFn = testAPIReturnsFunction(); + assertTrue(typeof resultFn === "function", + `userScript got an unexpected returned value: ${typeof resultFn}`); + + let fnRes = resultFn(); + assertTrue(fnRes === window, + `Got an unexpected value from the returned function: ${fnRes}`); + + let resultObj = testAPIReturnsObjWithMethod(); + let actualTypeof = resultObj && typeof resultObj.getWindow; + assertTrue(actualTypeof === "function", + `Returned object does not have the expected getWindow method: ${actualTypeof}`); + + let methodRes = resultObj.getWindow(); + assertTrue(methodRes === window, + `Got an unexpected value from the returned method: ${methodRes}`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_clone_non_subsumed_returned_values() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnOk() { + return script.export({ + objKey1: { + nestedProp: "nestedvalue", + }, + window, + }); + }, + testAPIMethodExplicitlyClonedError() { + let result = script.export({apiScopeObject: undefined}); + + browser.test.assertThrows( + () => { + result.apiScopeObject = {disallowedProp: "disallowedValue"}; + }, + /Not allowed to define cross-origin object as property on .* XrayWrapper/, + "Assigning a property to a xRayWrapper is expected to throw"); + + // Let the exception to be raised, so that we check that the actual underlying + // error message is not leaking in the userScript (replaced by the generic + // "An unexpected apiScript error occurred" error message). + result.apiScopeObject = {disallowedProp: "disallowedValue"}; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnOk, + testAPIMethodExplicitlyClonedError, + } = this; + + let result = testAPIMethodReturnOk(); + + assertTrue(result && ("objKey1" in result) && result.objKey1.nestedProp === "nestedvalue", + `userScript got an unexpected returned value: ${result}`); + + assertTrue(result.window === window, + `userScript should have access to the window property: ${result.window}`); + + let error; + try { + result = testAPIMethodExplicitlyClonedError(); + notifyFinish(`Unexpected returned value while expecting error: ${result}`); + return; + } catch (err) { + error = err; + } + + // We expect the generic "unexpected apiScript error occurred" to be raised to the + // userScript code. + assertTrue(error && error.message.includes("An unexpected apiScript error occurred"), + `Got an unexpected error message: ${error}`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_export_primitive_types() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(typeToExport) { + switch (typeToExport) { + case "boolean": return script.export(true); + case "number": return script.export(123); + case "string": return script.export("a string"); + case "symbol": return script.export(Symbol("a symbol")); + } + return undefined; + }, + }); + }); + } + + async function userScript() { + const {assertTrue, notifyFinish, testAPIMethod} = this; + + let v = testAPIMethod("boolean"); + assertTrue(v === true, `Should export a boolean`); + + v = testAPIMethod("number"); + assertTrue(v === 123, `Should export a number`); + + v = testAPIMethod("string"); + assertTrue(v === "a string", `Should export a string`); + + v = testAPIMethod("symbol"); + assertTrue(typeof v === "symbol", `Should export a symbol`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_avoid_unnecessary_params_cloning() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnsParam(param) { + return param; + }, + testAPIMethodReturnsUnwrappedParam(param) { + return param.wrappedJSObject; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnsParam, + testAPIMethodReturnsUnwrappedParam, + } = this; + + let obj = {}; + + let result = testAPIMethodReturnsParam(obj); + + assertTrue(result === obj, + `Expect returned value to be strictly equal to the API method parameter`); + + result = testAPIMethodReturnsUnwrappedParam(obj); + + assertTrue(result === obj, + `Expect returned value to be strictly equal to the unwrapped API method parameter`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + // This test verify that a cached script is still able to catch the document // while it is still loading (when we do not block the document parsing as // we do for a non cached script). add_task(async function test_cached_userScript_on_document_start() { function apiScript() { - browser.userScripts.setScriptAPIs({ - sendTestMessage([name, params]) { - return browser.test.sendMessage(name, params); - }, + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + sendTestMessage(name, params) { + return browser.test.sendMessage(name, params); + }, + }); }); } async function background() { function userScript() { this.sendTestMessage("user-script-loaded", { url: window.location.href, documentReadyState: document.readyState, @@ -605,24 +1267,22 @@ add_task(async function test_userScripts /userScripts APIs are currently experimental/, "Got the expected error from userScripts.register when the userScripts API is disabled"); browser.test.sendMessage("background-page:done"); } async function contentScript() { let promise = (async () => { - browser.userScripts.setScriptAPIs({ - GM_apiMethod() {}, - }); + browser.userScripts.onBeforeScript.addListener(() => {}); })(); await browser.test.assertRejects( promise, /userScripts APIs are currently experimental/, - "Got the expected error from userScripts.setScriptAPIs when the userScripts API is disabled"); + "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled"); browser.test.sendMessage("content-script:done"); } let extension = ExtensionTestUtils.loadExtension({ background, manifest: { permissions: ["http://*/*/file_sample.html"], @@ -652,45 +1312,45 @@ add_task(async function test_userScripts await extension.unload(); await contentPage.close(); } await runWithPrefs([["extensions.webextensions.userScripts.enabled", false]], run_userScript_on_pref_disabled_test); }); -// This test verify that userScripts.setScriptAPIs is not available without +// This test verify that userScripts.onBeforeScript API Event is not available without // a "user_scripts.api_script" property in the manifest. add_task(async function test_user_script_api_script_required() { let extension = ExtensionTestUtils.loadExtension({ manifest: { content_scripts: [ { matches: ["http://localhost/*/file_sample.html"], js: ["content_script.js"], run_at: "document_start", }, ], user_scripts: {}, }, files: { "content_script.js": function() { - browser.test.assertEq(undefined, browser.userScripts && browser.userScripts.setScriptAPIs, - "Got an undefined setScriptAPIs as expected"); - browser.test.sendMessage("no-setScriptAPIs:done"); + browser.test.assertEq(undefined, browser.userScripts && browser.userScripts.onBeforeScript, + "Got an undefined onBeforeScript property as expected"); + browser.test.sendMessage("no-onBeforeScript:done"); }, }, }); await extension.startup(); let url = `${BASE_URL}/file_sample.html`; let contentPage = await ExtensionTestUtils.loadContentPage(url); - await extension.awaitMessage("no-setScriptAPIs:done"); + await extension.awaitMessage("no-onBeforeScript:done"); await extension.unload(); await contentPage.close(); }); add_task(async function test_scriptMetaData() { function getTestCases(isUserScriptsRegister) { return [ @@ -730,55 +1390,50 @@ add_task(async function test_scriptMetaD f.src = pageUrl; document.body.append(f); browser.test.sendMessage("background-page:done"); } function apiScript() { let testCases = getTestCases(false); let i = 0; - let j = 0; - let metadataOnFirstCall = []; - browser.userScripts.setScriptAPIs({ - checkMetadata(params, metadata, scriptGlobal) { - // We save the reference to the received metadata object, so that - // checkMetadataAgain can verify that the same object is received. - metadataOnFirstCall[i] = metadata; - let expectation = testCases[i]; - if (typeof expectation === "object" && expectation !== null) { - // Non-primitive values cannot be compared with assertEq, - // so serialize both and just verify that they are equal. - expectation = JSON.stringify(expectation); - metadata = JSON.stringify(metadata); - } - browser.test.assertEq(expectation, metadata, `Expected metadata at call ${i}`); - ++i; - }, - checkMetadataAgain(params, metadata, scriptGlobal) { - browser.test.assertEq(metadataOnFirstCall[j], metadata, `Expected same metadata at call ${j}`); + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + checkMetadata() { + let expectation = testCases[i]; + let metadata = script.metadata; + if (typeof expectation === "object" && expectation !== null) { + // Non-primitive values cannot be compared with assertEq, + // so serialize both and just verify that they are equal. + expectation = JSON.stringify(expectation); + metadata = JSON.stringify(script.metadata); + } - if (++j === testCases.length) { - browser.test.sendMessage("apiscript:done"); - } - }, + browser.test.assertEq(expectation, metadata, + `Expected metadata at call ${i}`); + if (++i === testCases.length) { + browser.test.sendMessage("apiscript:done"); + } + }, + }); }); } let extension = ExtensionTestUtils.loadExtension({ background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`, manifest: { permissions: ["http://*/*/file_sample.html"], user_scripts: { api_script: "apiscript.js", }, }, files: { "apiscript.js": `${getTestCases};(${apiScript})()`, - "userscript.js": "checkMetadata();checkMetadataAgain();", + "userscript.js": "checkMetadata();", }, }); await extension.startup(); await extension.awaitMessage("background-page:done"); await extension.awaitMessage("apiscript:done");
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js @@ -7,20 +7,24 @@ const HISTOGRAM_KEYED = "WEBEXT_USER_SCR const server = createHttpServer(); server.registerDirectory("/data/", do_get_file("data")); const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; async function run_userScripts_telemetry_test() { function apiScript() { - browser.userScripts.setScriptAPIs({ - US_test_sendMessage([msg, data], scriptMetadata, scriptGlobal) { - browser.test.sendMessage(msg, {data, scriptMetadata}); - }, + browser.userScripts.onBeforeScript.addListener(userScript => { + const scriptMetadata = userScript.metadata; + + userScript.defineGlobals({ + US_test_sendMessage(msg, data) { + browser.test.sendMessage(msg, {data, scriptMetadata}); + }, + }); }); } async function background() { const code = ` US_test_sendMessage("userScript-run", {location: window.location.href}); `; await browser.userScripts.register({