diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/extensions/child/ext-test.js | 371 |
1 files changed, 371 insertions, 0 deletions
diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js new file mode 100644 index 0000000000..98dca726bb --- /dev/null +++ b/toolkit/components/extensions/child/ext-test.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "isXpcshell", function () { + return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR"); +}); + +/** + * Checks whether the given error matches the given expectations. + * + * @param {*} error + * The error to check. + * @param {string | RegExp | Function | null} expectedError + * The expectation to check against. If this parameter is: + * + * - a string, the error message must exactly equal the string. + * - a regular expression, it must match the error message. + * - a function, it is called with the error object and its + * return value is returned. + * @param {BaseContext} context + * + * @returns {boolean} + * True if the error matches the expected error. + */ +const errorMatches = (error, expectedError, context) => { + if ( + typeof error === "object" && + error !== null && + !context.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + Cu.reportError("Error object belongs to the wrong scope."); + return false; + } + + if (typeof expectedError === "function") { + return context.runSafeWithoutClone(expectedError, error); + } + + if ( + typeof error !== "object" || + error == null || + typeof error.message !== "string" + ) { + return false; + } + + if (typeof expectedError === "string") { + return error.message === expectedError; + } + + try { + return expectedError.test(error.message); + } catch (e) { + Cu.reportError(e); + } + + return false; +}; + +// Checks whether |v| should use string serialization instead of JSON. +function useStringInsteadOfJSON(v) { + return ( + // undefined to string, or else it is omitted from object after stringify. + v === undefined || + // Values that would have become null. + (typeof v === "number" && (isNaN(v) || !isFinite(v))) + ); +} + +// A very strict deep equality comparator that throws for unsupported values. +// For context, see https://bugzilla.mozilla.org/show_bug.cgi?id=1782816#c2 +function deepEquals(a, b) { + // Some values don't have a JSON representation. To disambiguate from null or + // regular strings, we prepend this prefix instead. + const NON_JSON_PREFIX = "#NOT_JSON_SERIALIZABLE#"; + + function replacer(key, value) { + if (typeof value == "object" && value !== null && !Array.isArray(value)) { + const cls = ChromeUtils.getClassName(value); + if (cls === "Object") { + // Return plain object with keys sorted in a predictable order. + return Object.fromEntries( + Object.keys(value) + .sort() + .map(k => [k, value[k]]) + ); + } + // Just throw to avoid potentially inaccurate serializations (e.g. {}). + throw new ExtensionUtils.ExtensionError(`Unsupported obj type: ${cls}`); + } + + if (useStringInsteadOfJSON(value)) { + return `${NON_JSON_PREFIX}${value}`; + } + return value; + } + return JSON.stringify(a, replacer) === JSON.stringify(b, replacer); +} + +/** + * Serializes the given value for use in informative assertion messages. + * + * @param {*} value + * @returns {string} + */ +const toSource = value => { + function cannotJSONserialize(v) { + return ( + useStringInsteadOfJSON(v) || + // Not a plain object. E.g. [object X], /regexp/, etc. + (typeof v == "object" && + v !== null && + !Array.isArray(v) && + ChromeUtils.getClassName(v) !== "Object") + ); + } + try { + if (cannotJSONserialize(value)) { + return String(value); + } + + const replacer = (k, v) => (cannotJSONserialize(v) ? String(v) : v); + return JSON.stringify(value, replacer); + } catch (e) { + return "<unknown>"; + } +}; + +this.test = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + function getStack(savedFrame = null) { + if (savedFrame) { + return ChromeUtils.createError("", savedFrame).stack.replace( + /^/gm, + " " + ); + } + return new context.Error().stack.replace(/^/gm, " "); + } + + function assertTrue(value, msg) { + extension.emit( + "test-result", + Boolean(value), + String(msg), + getStack(context.getCaller()) + ); + } + + class TestEventManager extends EventManager { + constructor(...args) { + super(...args); + + // A map to keep track of the listeners wrappers being added in + // addListener (the wrapper will be needed to be able to remove + // the listener from this EventManager instance if the extension + // does call test.onMessage.removeListener). + this._listenerWrappers = new Map(); + context.callOnClose({ + close: () => this._listenerWrappers.clear(), + }); + } + + addListener(callback, ...args) { + const listenerWrapper = function (...args) { + try { + callback.call(this, ...args); + } catch (e) { + assertTrue(false, `${e}\n${e.stack}`); + } + }; + super.addListener(listenerWrapper, ...args); + this._listenerWrappers.set(callback, listenerWrapper); + } + + removeListener(callback) { + if (!this._listenerWrappers.has(callback)) { + return; + } + + super.removeListener(this._listenerWrappers.get(callback)); + this._listenerWrappers.delete(callback); + } + } + + if (!Cu.isInAutomation && !isXpcshell) { + return { test: {} }; + } + + return { + test: { + withHandlingUserInput(callback) { + // TODO(Bug 1598804): remove this once we don't expose anymore the + // entire test API namespace based on an environment variable. + if (!Cu.isInAutomation) { + // This dangerous method should only be available if the + // automation pref is set, which is the case in browser tests. + throw new ExtensionUtils.ExtensionError( + "withHandlingUserInput can only be called in automation" + ); + } + ExtensionCommon.withHandlingUserInput( + context.contentWindow, + callback + ); + }, + + sendMessage(...args) { + extension.emit("test-message", ...args); + }, + + notifyPass(msg) { + extension.emit("test-done", true, msg, getStack(context.getCaller())); + }, + + notifyFail(msg) { + extension.emit( + "test-done", + false, + msg, + getStack(context.getCaller()) + ); + }, + + log(msg) { + extension.emit("test-log", true, msg, getStack(context.getCaller())); + }, + + fail(msg) { + assertTrue(false, msg); + }, + + succeed(msg) { + assertTrue(true, msg); + }, + + assertTrue(value, msg) { + assertTrue(value, msg); + }, + + assertFalse(value, msg) { + assertTrue(!value, msg); + }, + + assertDeepEq(expected, actual, msg) { + // The bindings generated by Schemas.jsm accepts any input, but the + // WebIDL-generated binding expects a structurally cloneable input. + // To ensure consistent behavior regardless of which mechanism was + // used, verify that the inputs are structurally cloneable. + // These will throw if the values cannot be cloned. + function ensureStructurallyCloneable(v) { + if (typeof v == "object" && v !== null) { + // Waive xrays to unhide callable members, so that cloneInto will + // throw if needed. + v = ChromeUtils.waiveXrays(v); + } + new StructuredCloneHolder("test.assertEq", null, v, globalThis); + } + // When WebIDL bindings are used, the objects are already cloned + // structurally, so we don't need to check again. + if (!context.useWebIDLBindings) { + ensureStructurallyCloneable(expected); + ensureStructurallyCloneable(actual); + } + + extension.emit( + "test-eq", + deepEquals(actual, expected), + String(msg), + toSource(expected), + toSource(actual), + getStack(context.getCaller()) + ); + }, + + assertEq(expected, actual, msg) { + let equal = expected === actual; + + expected = String(expected); + actual = String(actual); + + if (!equal && expected === actual) { + actual += " (different)"; + } + extension.emit( + "test-eq", + equal, + String(msg), + expected, + actual, + getStack(context.getCaller()) + ); + }, + + assertRejects(promise, expectedError, msg) { + // Wrap in a native promise for consistency. + promise = Promise.resolve(promise); + + return promise.then( + result => { + let message = `Promise resolved, expected rejection '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + }, + error => { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Promise rejected, expecting rejection to match '${expected}', ${message}` + ); + } + ); + }, + + assertThrows(func, expectedError, msg) { + try { + func(); + + let message = `Function did not throw, expected error '${toSource( + expectedError + )}'`; + if (msg) { + message += `: ${msg}`; + } + assertTrue(false, message); + } catch (error) { + let expected = toSource(expectedError); + let message = `got '${toSource(error)}'`; + if (msg) { + message += `: ${msg}`; + } + + assertTrue( + errorMatches(error, expectedError, context), + `Function threw, expecting error to match '${expected}', ${message}` + ); + } + }, + + onMessage: new TestEventManager({ + context, + name: "test.onMessage", + register: fire => { + let handler = (event, ...args) => { + fire.async(...args); + }; + + extension.on("test-harness-message", handler); + return () => { + extension.off("test-harness-message", handler); + }; + }, + }).api(), + }, + }; + } +}; |