Bug 1509339 - Implement a UserScript API object and remove the userScripts.setScriptAPIs method. r=zombie,robwu

Depends on D12676

Differential Revision: https://phabricator.services.mozilla.com/D12678

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Luca Greco 2018-11-30 16:10:58 +00:00
Родитель 3a91800a12
Коммит 2074f536c7
7 изменённых файлов: 1103 добавлений и 224 удалений

Просмотреть файл

@ -38,7 +38,6 @@ XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
const {
DefaultMap,
DefaultWeakMap,
ExtensionError,
getInnerWindowID,
getWinUtils,
promiseDocumentIdle,
@ -605,9 +604,10 @@ class UserScript extends Script {
},
});
// 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) {
@ -645,88 +645,6 @@ class UserScript extends Script {
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 => {
@ -848,9 +766,6 @@ class ContentScriptContextChild extends BaseContext {
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).
@ -878,14 +793,6 @@ class ContentScriptContextChild extends BaseContext {
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).

Просмотреть файл

@ -76,7 +76,6 @@ extensions.registerModules({
url: "chrome://extensions/content/child/ext-userScripts-content.js",
scopes: ["content_child"],
paths: [
["userScripts", "setScriptAPIs"],
["userScripts", "onBeforeScript"],
],
},

Просмотреть файл

@ -5,31 +5,344 @@
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);
};

Просмотреть файл

@ -201,7 +201,6 @@
"schema": "chrome://extensions/content/schemas/user_scripts_content.json",
"scopes": ["content_child"],
"paths": [
["userScripts", "setScriptAPIs"],
["userScripts", "onBeforeScript"]
]
},

Просмотреть файл

@ -7,29 +7,6 @@
"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",
@ -49,6 +26,31 @@
"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"
}
}
}
}

Просмотреть файл

@ -339,11 +339,19 @@ async function test_userScript_APIMethod({
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: [
@ -359,7 +367,10 @@ async function test_userScript_APIMethod({
(${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener})
`,
files: {
"api-script.js": `(${apiScript})(${notifyFinish})`,
"api-script.js": `(${apiScript})({
assertTrue: ${assertTrue},
notifyFinish: ${notifyFinish}
})`,
},
});
@ -384,36 +395,43 @@ async function test_userScript_APIMethod({
}
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.
@ -421,13 +439,11 @@ add_task(async function test_apiScript_exports_simple_sync_method() {
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 = {
@ -445,22 +461,22 @@ add_task(async function test_apiScript_exports_simple_sync_method() {
});
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";
},
});
});
}
@ -475,7 +491,7 @@ add_task(async function test_apiScript_async_method() {
throw new Error("Promise constructor poisoning");
};
const {testAPIMethod, notifyFinish} = this;
const {assertTrue, notifyFinish, testAPIMethod} = this;
const cb = (cbParam) => {
return `callback param: ${JSON.stringify(cbParam)}`;
@ -483,11 +499,10 @@ add_task(async function test_apiScript_async_method() {
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) {
@ -510,15 +525,662 @@ add_task(async function test_apiScript_async_method() {
});
});
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);
},
});
});
}
@ -610,14 +1272,12 @@ add_task(async function test_userScripts_pref_disabled() {
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");
}
@ -657,7 +1317,7 @@ add_task(async function test_userScripts_pref_disabled() {
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({
@ -673,9 +1333,9 @@ add_task(async function test_user_script_api_script_required() {
},
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");
},
},
});
@ -685,7 +1345,7 @@ add_task(async function test_user_script_api_script_required() {
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();
@ -735,31 +1395,26 @@ add_task(async function test_scriptMetaData() {
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");
}
},
});
});
}
@ -773,7 +1428,7 @@ add_task(async function test_scriptMetaData() {
},
files: {
"apiscript.js": `${getTestCases};(${apiScript})()`,
"userscript.js": "checkMetadata();checkMetadataAgain();",
"userscript.js": "checkMetadata();",
},
});

Просмотреть файл

@ -12,10 +12,14 @@ 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});
},
});
});
}