diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/webidl-api')
9 files changed, 2372 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js new file mode 100644 index 0000000000..3e8e094a20 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js @@ -0,0 +1,306 @@ +/* import-globals-from ../head.js */ + +/* exported getBackgroundServiceWorkerRegistration, waitForTerminatedWorkers, + * runExtensionAPITest */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +add_setup(function checkExtensionsWebIDLEnabled() { + equal( + AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED, + true, + "WebExtensions WebIDL bindings build time flag should be enabled" + ); +}); + +function getBackgroundServiceWorkerRegistration(extension) { + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const swRegs = swm.getAllRegistrations(); + const scope = `moz-extension://${extension.uuid}/`; + + for (let i = 0; i < swRegs.length; i++) { + let regInfo = swRegs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (regInfo.scope === scope) { + return regInfo; + } + } +} + +function waitForTerminatedWorkers(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + return TestUtils.waitForCondition(() => { + const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } = + swRegInfo; + return !( + evaluatingWorker || + installingWorker || + waitingWorker || + activeWorker + ); + }, `wait workers for scope ${swRegInfo.scope} to be terminated`); +} + +function unmockHandleAPIRequest(extPage) { + return extPage.spawn([], () => { + const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + + // Unmock ExtensionAPIRequestHandler. + if (ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler.handleAPIRequest = + ExtensionAPIRequestHandler._handleAPIRequest_orig; + delete ExtensionAPIRequestHandler._handleAPIRequest_orig; + } + }); +} + +function mockHandleAPIRequest(extPage, mockHandleAPIRequest) { + mockHandleAPIRequest = + mockHandleAPIRequest || + ((policy, request) => { + const ExtError = request.window?.Error || Error; + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new ExtError( + "mockHandleAPIRequest not defined by this test case" + ), + }; + }); + + return extPage.legacySpawn( + [ExtensionTestCommon.serializeFunction(mockHandleAPIRequest)], + mockFnText => { + const { ExtensionAPIRequestHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionProcessScript.sys.mjs" + ); + + mockFnText = `(() => { + return (${mockFnText}); + })();`; + // eslint-disable-next-line no-eval + const mockFn = eval(mockFnText); + + // Mock ExtensionAPIRequestHandler. + if (!ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler._handleAPIRequest_orig = + ExtensionAPIRequestHandler.handleAPIRequest; + } + + ExtensionAPIRequestHandler.handleAPIRequest = function (policy, request) { + if (request.apiNamespace === "test") { + return this._handleAPIRequest_orig(policy, request); + } + return mockFn.call(this, policy, request); + }; + } + ); +} + +/** + * An helper function used to run unit test that are meant to test the + * Extension API webidl bindings helpers shared by all the webextensions + * API namespaces. + * + * @param {string} testDescription + * Brief description of the test. + * @param {object} [options] + * @param {Function} options.backgroundScript + * Test function running in the extension global. This function + * does receive a parameter of type object with the following + * properties: + * - testLog(message): log a message on the terminal + * - testAsserts: + * - isErrorInstance(err): throw if err is not an Error instance + * - isInstanceOf(value, globalContructorName): throws if value + * is not an instance of global[globalConstructorName] + * - equal(val, exp, msg): throw an error including msg if + * val is not strictly equal to exp. + * @param {Function} options.assertResults + * Function to be provided to assert the result returned by + * `backgroundScript`, or assert the error if it did throw. + * This function does receive a parameter of type object with + * the following properties: + * - testResult: the result returned (and resolved if the return + * value was a promise) from the call to `backgroundScript` + * - testError: the error raised (or rejected if the return value + * value was a promise) from the call to `backgroundScript` + * - extension: the extension wrapper created by this helper. + * @param {Function} options.mockAPIRequestHandler + * Function to be used to mock mozIExtensionAPIRequestHandler.handleAPIRequest + * for the purpose of the test. + * This function received the same parameter that are listed in the idl + * definition (mozIExtensionAPIRequestHandling.webidl). + * @param {string} [options.extensionId] + * Optional extension id for the test extension. + */ +async function runExtensionAPITest( + testDescription, + { + backgroundScript, + assertResults, + mockAPIRequestHandler, + extensionId = "test-ext-api-request-forward@mochitest", + } +) { + // Wraps the `backgroundScript` function to be execute in the target + // extension global (currently only in a background service worker, + // in follow-ups the same function should also be execute in + // other supported extension globals, e.g. an extension page and + // a content script). + // + // The test wrapper does also provide to `backgroundScript` some + // helpers to be used as part of the test, these tests are meant to + // only cover internals shared by all webidl API bindings through a + // mock API namespace only available in tests (and so none of the tests + // written with this helpers should be using the browser.test API namespace). + function backgroundScriptWrapper(testParams, testFn) { + const testLog = msg => { + // console messages emitted by workers are not visible in the test logs if not + // explicitly collected, and so this testLog helper method does use dump for now + // (this way the logs will be visibile as part of the test logs). + dump(`"${testParams.extensionId}": ${msg}\n`); + }; + + const testAsserts = { + isErrorInstance(err) { + if (!(err instanceof Error)) { + throw new Error("Unexpected error: not an instance of Error"); + } + return true; + }, + isInstanceOf(value, globalConstructorName) { + if (!(value instanceof self[globalConstructorName])) { + throw new Error( + `Unexpected error: expected instance of ${globalConstructorName}` + ); + } + return true; + }, + equal(val, exp, msg) { + if (val !== exp) { + throw new Error( + `Unexpected error: expected ${exp} but got ${val}. ${msg}` + ); + } + }, + }; + + testLog(`Evaluating - test case "${testParams.testDescription}"`); + self.onmessage = async evt => { + testLog(`Running test case "${testParams.testDescription}"`); + + let testError = null; + let testResult; + try { + testResult = await testFn({ testLog, testAsserts }); + } catch (err) { + testError = { message: err.message, stack: err.stack }; + testLog(`Unexpected test error: ${err} :: ${err.stack}\n`); + } + + evt.ports[0].postMessage({ success: !testError, testError, testResult }); + + testLog(`Test case "${testParams.testDescription}" executed`); + }; + testLog(`Wait onmessage event - test case "${testParams.testDescription}"`); + } + + async function assertTestResult(result) { + if (assertResults) { + await assertResults(result); + } else { + equal(result.testError, undefined, "Expect no errors"); + ok(result.success, "Test completed successfully"); + } + } + + async function runTestCaseInWorker({ page, extension }) { + info(`*** Run test case in an extension service worker`); + const result = await page.legacySpawn([], async () => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = evt => resolve(evt.data); + active.postMessage("run-test", [port2]); + }); + }); + info(`*** Assert test case results got from extension service worker`); + await assertTestResult({ ...result, extension }); + } + + // NOTE: prefixing this with `function ` is needed because backgroundScript + // is an object property and so it is going to be stringified as + // `backgroundScript() { ... }` (which would be detected as a syntax error + // on the worker script evaluation phase). + const scriptFnParam = ExtensionTestCommon.serializeFunction(backgroundScript); + const testOptsParam = `${JSON.stringify({ testDescription, extensionId })}`; + + const testExtData = { + useAddonManager: "temporary", + manifest: { + version: "1", + background: { + service_worker: "test-sw.js", + }, + browser_specific_settings: { + gecko: { id: extensionId }, + }, + }, + files: { + "page.html": `<!DOCTYPE html> + <head><meta charset="utf-8"></head> + <body> + <script src="test-sw.js"></script> + </body>`, + "test-sw.js": ` + (${backgroundScriptWrapper})(${testOptsParam}, ${scriptFnParam}); + `, + }, + }; + + let cleanupCalled = false; + let extension; + let page; + let swReg; + + async function testCleanup() { + if (cleanupCalled) { + return; + } + + cleanupCalled = true; + await unmockHandleAPIRequest(page); + await page.close(); + await extension.unload(); + await waitForTerminatedWorkers(swReg); + } + + info(`Start test case "${testDescription}"`); + extension = ExtensionTestUtils.loadExtension(testExtData); + await extension.startup(); + + swReg = getBackgroundServiceWorkerRegistration(extension); + ok(swReg, "Extension background.service_worker should be registered"); + + page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension } + ); + + registerCleanupFunction(testCleanup); + + await mockHandleAPIRequest(page, mockAPIRequestHandler); + await runTestCaseInWorker({ page, extension }); + await testCleanup(); + info(`End test case "${testDescription}"`); +} diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js new file mode 100644 index 0000000000..489cc3a754 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js @@ -0,0 +1,486 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_ext_context_does_have_webidl_bindings() { + await runExtensionAPITest("should have a browser global object", { + backgroundScript() { + const { browser, chrome } = self; + + return { + hasExtensionAPI: !!browser, + hasExtensionMockAPI: !!browser?.mockExtensionAPI, + hasChromeCompatGlobal: !!chrome, + hasChromeMockAPI: !!chrome?.mockExtensionAPI, + }; + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual( + testResult, + { + hasExtensionAPI: true, + hasExtensionMockAPI: true, + hasChromeCompatGlobal: true, + hasChromeMockAPI: true, + }, + "browser and browser.test WebIDL API bindings found" + ); + }, + }); +}); + +add_task(async function test_propagated_extension_error() { + await runExtensionAPITest( + "should throw an extension error on ResultType::EXTENSION_ERROR", + { + backgroundScript({ testAsserts }) { + try { + const api = self.browser.mockExtensionAPI; + api.methodSyncWithReturn("arg0", 1, { value: "arg2" }); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Extension Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Extension Error"); + }, + } + ); +}); + +add_task(async function test_system_errors_donot_leak() { + function assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + } + + function mockAPIRequestHandler(policy, request) { + throw new Error("Fake handleAPIRequest exception"); + } + + const msg = + "should throw an unexpected error occurred if handleAPIRequest throws"; + + await runExtensionAPITest(`sync method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`async method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`no return method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); +}); + +add_task(async function test_call_sync_function_result() { + await runExtensionAPITest( + "sync API methods should support structured clonable return values", + { + backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: api.methodSyncWithReturn("string-result"), + nested_prop: api.methodSyncWithReturn({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodSyncWithReturn") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: request.args[0], + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_sync_fn_missing_return() { + await runExtensionAPITest( + "should throw an unexpected error occurred on missing return value", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + }, + } + ); +}); + +add_task(async function test_call_async_throw_extension_error() { + await runExtensionAPITest( + "an async function can throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_async_reject_error() { + await runExtensionAPITest( + "an async function rejected promise should propagate extension errors", + { + async backgroundScript({ testAsserts }) { + try { + await self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject(new Error("Fake API rejected error object")), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake API rejected error object"); + }, + } + ); +}); + +add_task(async function test_call_async_function_result() { + await runExtensionAPITest( + "async API methods should support structured clonable resolved values", + { + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: await api.methodAsync("string-result"), + nested_prop: await api.methodAsync({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodAsync") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args[0]), + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_no_return_throw_extension_error() { + await runExtensionAPITest( + "no return function call throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_no_return_without_errors() { + await runExtensionAPITest( + "handleAPIHandler can return undefined on api calls to methods with no return", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); + +add_task(async function test_async_method_chrome_compatible_callback() { + function mockAPIRequestHandler(policy, request) { + if (request.args[0] === "fake-async-method-failure") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject("this-should-not-be-passed-to-cb-as-parameter"), + }; + } + + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args), + }; + } + + await runExtensionAPITest( + "async method should support an optional chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + { prop: "fake-async-method-success" }, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [[{ prop: "fake-async-method-success" }]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); + + await runExtensionAPITest( + "async method with ambiguous args called with a chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "arg0", + { prop: "arg1" }, + 3, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [["arg0", { prop: "arg1" }, 3]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); +}); + +add_task(async function test_get_property() { + await runExtensionAPITest( + "getProperty API request does return a value synchrously", + { + backgroundScript() { + return self.browser.mockExtensionAPI.propertyAsString; + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "property-value", + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + "property-value", + "Got the expected result" + ); + }, + } + ); + + await runExtensionAPITest( + "getProperty API request can return an error object", + { + backgroundScript({ testAsserts }) { + const errObj = self.browser.mockExtensionAPI.propertyAsErrorObject; + testAsserts.isErrorInstance(errObj); + testAsserts.equal(errObj.message, "fake extension error"); + }, + mockAPIRequestHandler(policy, request) { + let savedFrame = request.calledSavedFrame; + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: ChromeUtils.createError("fake extension error", savedFrame), + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js new file mode 100644 index 0000000000..576ec760d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js @@ -0,0 +1,575 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from ../head_service_worker.js */ + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_api_event_manager_methods() { + await runExtensionAPITest("extension event manager methods", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + const listener = () => {}; + + function assertHasListener(expect) { + testAsserts.equal( + api.onTestEvent.hasListeners(), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + testAsserts.equal( + api.onTestEvent.hasListener(listener), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + } + + assertHasListener(false); + api.onTestEvent.addListener(listener); + assertHasListener(true); + api.onTestEvent.removeListener(listener); + assertHasListener(false); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + }); +}); + +add_task(async function test_api_event_eventListener_call() { + await runExtensionAPITest( + "extension event eventListener wrapper does forward calls parameters", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (...args) => { + testLog("onTestEvent"); + // Make sure the extension code can access the arguments. + try { + testAsserts.equal(args[1], "arg1"); + resolve(args); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + if (request.requestType === "addListener") { + let args = [{ arg: 0 }, "arg1"]; + request.eventListener.callListener(args); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [{ arg: 0 }, "arg1"], + "Got the expected result" + ); + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_call_with_result() { + await runExtensionAPITest( + "extension event eventListener wrapper forwarded call result", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, value) => { + testLog(`onTestEvent received: ${msg}`); + switch (msg) { + case "test-result-value": + return value; + case "test-promise-resolve": + return Promise.resolve(value); + case "test-promise-reject": + return Promise.reject(new Error("test-reject")); + case "test-done": + resolve(value); + break; + default: + reject(new Error(`Unexpected onTestEvent message: ${msg}`)); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult?.resSync, + { prop: "retval" }, + "Got result from eventListener returning a plain return value" + ); + Assert.deepEqual( + testResult?.resAsync, + { prop: "promise" }, + "Got result from eventListener returning a resolved promise" + ); + Assert.deepEqual( + testResult?.resAsyncReject, + { + isInstanceOfError: true, + errorMessage: "test-reject", + }, + "got result from eventListener returning a rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + try { + dump(`calling listener, expect a plain return value\n`); + const resSync = await request.eventListener.callListener([ + "test-result-value", + { prop: "retval" }, + ]); + + dump( + `calling listener, expect a resolved promise return value\n` + ); + const resAsync = await request.eventListener.callListener([ + "test-promise-resolve", + { prop: "promise" }, + ]); + + dump( + `calling listener, expect a rejected promise return value\n` + ); + const resAsyncReject = await request.eventListener + .callListener(["test-promise-reject"]) + .catch(err => err); + + // call API listeners once more to complete the test + let args = { + resSync, + resAsync, + resAsyncReject: { + isInstanceOfError: resAsyncReject instanceof Error, + errorMessage: resAsyncReject?.message, + }, + }; + request.eventListener.callListener(["test-done", args]); + } catch (err) { + dump(`Unexpected error: ${err} :: ${err.stack}\n`); + throw err; + } + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_result_rejected() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(arg1); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + isPromise: true, + rejectIsError: true, + errorMessage: "FAKE eventListener exception", + }, + "Got the expected rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const promiseResult = request.eventListener.callListener([]); + const isPromise = promiseResult instanceof Promise; + const err = await promiseResult.catch(e => e); + const rejectIsError = err instanceof Error; + request.eventListener.callListener([ + "test-done", + { isPromise, rejectIsError, errorMessage: err?.message }, + ]); + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_throws_on_call() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + request.eventListener.callListener([]); + request.eventListener.callListener(["test-done"]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_eventListener() { + await runExtensionAPITest( + "extension event eventListener sendResponse eventListener argument", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const res = await request.eventListener.callListener( + ["call-sendResponse"], + { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + } + ); + request.eventListener.callListener([res]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_multiple_eventListener() { + await runExtensionAPITest("multiple extension event eventListeners", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listenerNoReply; + let listenerSendResponseReply; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listenerNoReply = (msg, sendResponse) => { + return false; + }; + listenerSendResponseReply = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listenerNoReply); + api.onTestEvent.addListener(listenerSendResponseReply); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + this._listeners = this._listeners || []; + this._listeners.push(request.eventListener); + if (this._listeners.length === 2) { + Promise.resolve().then(async () => { + const { _listeners } = this; + this._listeners = undefined; + + // Reference to the listener to which we should send the + // final message to complete the test. + const replyListener = _listeners[1]; + + const res = await Promise.race( + _listeners.map(l => + l.callListener(["call-sendResponse"], { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + }) + ) + ); + replyListener.callListener([res]); + }); + } + } + }, + }); +}); + +// Unit test nsIServiceWorkerManager.wakeForExtensionAPIEvent method. +add_task(async function test_serviceworkermanager_wake_for_api_event_helper() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { + gecko: { id: "test-bg-sw-wakeup@mochi.test" }, + }, + }, + files: { + "sw.js": ` + dump("Background ServiceWorker - executing\\n"); + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + browser.test.onMessage.addListener(msg => { + if (msg === "bgsw-getSWEvents") { + browser.test.sendMessage("bgsw-gotSWEvents", lifecycleEvents); + return; + } + + browser.test.fail("Got unexpected test message: " + msg); + }); + + const fakeListener01 = () => {}; + const fakeListener02 = () => {}; + + // Adding and removing the same listener, and so we expect + // ExtensionEventWakeupMap to not have any wakeup listener + // for the runtime.onInstalled event. + browser.runtime.onInstalled.addListener(fakeListener01); + browser.runtime.onInstalled.removeListener(fakeListener01); + // Removing the same listener more than ones should make any + // difference, and it shouldn't trigger any assertion in + // debug builds. + browser.runtime.onInstalled.removeListener(fakeListener01); + + browser.runtime.onStartup.addListener(fakeListener02); + // Removing an unrelated listener, runtime.onStartup is expected to + // still have one wakeup listener tracked by ExtensionEventWakeupMap. + browser.runtime.onStartup.removeListener(fakeListener01); + + browser.test.sendMessage("bgsw-executed"); + dump("Background ServiceWorker - executed\\n"); + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher("../data"); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + await extension.awaitMessage("bgsw-executed"); + + extension.sendMessage("bgsw-getSWEvents"); + let lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + ["install", "activate"], + "Got install and activate lifecycle events as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + const extensionBaseURL = extension.extension.baseURI.spec; + + async function testWakeupOnAPIEvent(eventName, expectedResult) { + const result = await testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + eventName + ); + equal( + result, + expectedResult, + `Got expected result from wakeForExtensionAPIEvent for ${eventName}` + ); + info( + `Wait for the background service worker to be spawned for ${eventName}` + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + await extension.awaitMessage("bgsw-executed"); + } + + info("Wake up active worker for API event"); + // Extension API event listener has been added and removed synchronously by + // the worker script, and so we expect the promise to resolve successfully + // to `false`. + await testWakeupOnAPIEvent("onInstalled", false); + + extension.sendMessage("bgsw-getSWEvents"); + lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + [], + "No install and activate lifecycle events expected on spawning active worker" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info("Wakeup again with an API event that has been subscribed"); + // Extension API event listener has been added synchronously (and not removed) + // by the worker script, and so we expect the promise to resolve successfully + // to `true`. + await testWakeupOnAPIEvent("onStartup", true); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await extension.unload(); + + await Assert.rejects( + testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + "onStartup" + ), + /Not an extension principal or extension disabled/, + "Got the expected rejection on wakeForExtensionAPIEvent called for an uninstalled extension" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js new file mode 100644 index 0000000000..070a45fa95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js @@ -0,0 +1,443 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the local process +// (toolkit/components/extensions/child/ext-test.js). +add_task(async function test_sw_api_request_handling_local_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.test.onMessage.addListener(async msg => { + browser.test.succeed("call to test.succeed"); + browser.test.assertTrue(true, "call to test.assertTrue"); + browser.test.assertFalse(false, "call to test.assertFalse"); + // Smoke test assertEq (more complete coverage of the behavior expected + // by the test API will be introduced in test_ext_test.html as part of + // Bug 1723785). + const errorObject = new Error("fake_error_message"); + browser.test.assertEq( + errorObject, + errorObject, + "call to test.assertEq" + ); + + // Smoke test for assertThrows/assertRejects. + const errorMatchingTestCases = [ + ["expected error instance", errorObject], + ["expected error message string", "fake_error_message"], + ["expected regexp", /fake_error/], + ["matching function", error => errorObject === error], + ["matching Constructor", Error], + ]; + + browser.test.log("run assertThrows smoke tests"); + + const throwFn = () => { + throw errorObject; + }; + for (const [msg, expected] of errorMatchingTestCases) { + browser.test.assertThrows( + throwFn, + expected, + `call to assertThrow with ${msg}` + ); + } + + browser.test.log("run assertRejects smoke tests"); + + const rejectedPromise = Promise.reject(errorObject); + for (const [msg, expected] of errorMatchingTestCases) { + await browser.test.assertRejects( + rejectedPromise, + expected, + `call to assertRejects with ${msg}` + ); + } + + browser.test.notifyPass("test-completed"); + }); + browser.test.sendMessage("bgsw-ready"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgsw-ready"); + extension.sendMessage("test-message-ok"); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the main process +// (toolkit/components/extensions/parent/ext-alarms.js). +add_task(async function test_sw_api_request_handling_main_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: ["alarms"], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.alarms.create("test-alarm", { when: Date.now() + 2000000 }); + const all = await browser.alarms.getAll(); + if (all.length === 1 && all[0].name === "test-alarm") { + browser.test.succeed("Got the expected alarms"); + } else { + browser.test.fail( + `browser.alarms.create didn't create the expected alarm: ${JSON.stringify( + all + )}` + ); + } + + browser.alarms.onAlarm.addListener(alarm => { + if (alarm.name === "test-onAlarm") { + browser.test.succeed("Got the expected onAlarm event"); + } else { + browser.test.fail(`Got unexpected onAlarm event: ${alarm.name}`); + } + browser.test.sendMessage("test-completed"); + }); + + browser.alarms.create("test-onAlarm", { when: Date.now() + 1000 }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-completed"); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_onMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-on-message@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "extpage-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("extpage-send-message"); + }); + }, + "sw.js": async function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("bgsw-on-message", msg); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + extension.sendMessage("extpage-send-message"); + + const msg = await extension.awaitMessage("bgsw-on-message"); + equal(msg, "extpage-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_sendMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-sendMessage@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("extpage-on-message", msg); + }); + + browser.test.sendMessage("extpage-ready"); + }, + "sw.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "bgsw-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("bgsw-send-message"); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("extpage-ready"); + extension.sendMessage("bgsw-send-message"); + + const msg = await extension.awaitMessage("extpage-on-message"); + equal(msg, "bgsw-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests that +// returns a runtinme.Port API object. +add_task(async function test_sw_api_request_bgsw_connnect_runtime_port() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("page-got-port-from-sw"); + port.postMessage("page-to-sw"); + }); + browser.test.sendMessage("page-waiting-port"); + }, + "sw.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + if (!port) { + browser.test.fail("Got an undefined port"); + } + port.onMessage.addListener((msg, portArgument) => { + browser.test.assertTrue( + port === portArgument, + "Got the expected runtime.Port instance" + ); + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("sw-waiting-port-message"); + }); + + const portWithError = browser.runtime.connect(); + portWithError.onDisconnect.addListener(() => { + const portError = portWithError.error; + browser.test.sendMessage("port-error", { + isError: portError instanceof Error, + message: portError?.message, + }); + }); + + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + browser.test.sendMessage("ext-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extId = await extension.awaitMessage("ext-id"); + equal(extId, extension.id, "Got the expected extension id"); + + const lastError = await extension.awaitMessage("port-error"); + Assert.deepEqual( + lastError, + { + isError: true, + message: "Could not establish connection. Receiving end does not exist.", + }, + "Got the expected lastError value" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("page-waiting-port"); + + info("bgsw connect port"); + extension.sendMessage("connect-port"); + await extension.awaitMessage("sw-waiting-port-message"); + info("bgsw waiting port message"); + await extension.awaitMessage("page-got-port-from-sw"); + info("page got port from sw, wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "page-to-sw", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API events that should +// get a runtinme.Port API object as an event argument. +add_task(async function test_sw_api_request_bgsw_runtime_onConnect() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-onConnect@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("page-waiting-port-message"); + }); + }, + "sw.js": async function () { + try { + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("bgsw-got-port-from-page"); + port.postMessage("sw-to-page"); + }); + browser.test.sendMessage("bgsw-waiting-port"); + } catch (err) { + browser.test.fail(`Error on runtime.onConnect: ${err}`); + } + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + await extension.awaitMessage("bgsw-waiting-port"); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + info("ext page connect port"); + extension.sendMessage("connect-port"); + + await extension.awaitMessage("page-waiting-port-message"); + info("page waiting port message"); + await extension.awaitMessage("bgsw-got-port-from-page"); + info("bgsw got port from page, page wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "sw-to-page", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_runtime_lastError() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function () { + browser.runtime.sendMessage(() => { + const lastError = browser.runtime.lastError; + if (!(lastError instanceof Error)) { + browser.test.fail( + `lastError isn't an Error instance: ${lastError}` + ); + } + browser.test.sendMessage("test-lastError-completed"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-lastError-completed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js new file mode 100644 index 0000000000..d8684c1574 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js @@ -0,0 +1,202 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: () => { + return "some-value"; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "arg", + type: "string", + enum: ["THE_ONLY_VALUE_ALLOWED"], + }, + ], + }, + ], + }, +]; + +add_setup(async function () { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_schema_error_is_propagated_to_extension() { + await runExtensionAPITest("should throw an extension error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("UNEXPECTED_VALUE"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError }) { + Assert.ok( + /Invalid enumeration value "UNEXPECTED_VALUE"/.test(testError.message) + ); + }, + }); +}); + +add_task(async function test_schema_error_no_error_with_expected_value() { + await runExtensionAPITest("should not throw any error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("THE_ONLY_VALUE_ALLOWED"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual(testResult, "some-value"); + }, + }); +}); + +add_task(async function test_schema_data_not_found_or_unexpected_schema_type() { + const { Schemas } = ChromeUtils.importESModule( + "resource://gre/modules/Schemas.sys.mjs" + ); + + const mockSchemaExtContext = {}; + + const testSchemasErrorOnWebIDLRequest = testCase => { + if (testCase.expectedExceptions) { + const expectedExceptions = Array.isArray(testCase.expectedExceptions) + ? testCase.expectedExceptions + : [testCase.expectedExceptions]; + expectedExceptions.forEach(expectedException => + Assert.throws( + () => + Schemas.checkWebIDLRequestParameters( + testCase.mockSchemaExtContext, + testCase.mockWebIDLAPIRequest + ), + expectedException, + `Got the expected error on ${testCase.description}` + ) + ); + } else { + throw new Error( + `Test case ${testCase.description} is missing mandatory expectedExceptions test case property` + ); + } + }; + + const TEST_CASES = [ + { + description: + "callFunction API request for non existing nested API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.unknownNamespace", + apiName: "get", + requestType: "callFunction", + }, + expectedExceptions: + /API Schema not found for browserSettings\.unknownNamespace/, + }, + { + description: + "addListener API request for non existing API event property", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.nonExistingSetting", + apiName: "onChange", + requestType: "addListener", + }, + expectedExceptions: + /API Schema not found for browserSettings\.nonExistingSetting/, + }, + { + description: + "callFunction on non existing method from existing nested API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings.colorManagement.mode", + apiName: "nonExistingMethod", + requestType: "callFunction", + }, + expectedExceptions: [ + /API Schema for "nonExistingMethod" not found in browserSettings\.colorManagement\.mode/, + /\(browserSettings\.colorManagement\.mode type is SubModuleProperty\)/, + ], + }, + { + description: + "callFunction on non existing method from existing API namespace", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "browserSettings", + apiName: "nonExistingMethod", + requestType: "callFunction", + }, + expectedExceptions: + /API Schema not found for browserSettings\.nonExistingMethod/, + }, + { + description: + "callFunction on existing property but unexpected schema type", + mockSchemaExtContext, + mockWebIDLAPIRequest: { + apiNamespace: "tabs", + apiName: "TAB_ID_NONE", + requestType: "callFunction", + }, + expectedExceptions: [ + /Unexpected API Schema type for tabs.TAB_ID_NONE/, + /tabs.TAB_ID_NONE type is ValueProperty/, + ], + }, + ]; + + TEST_CASES.forEach(testSchemasErrorOnWebIDLRequest); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js new file mode 100644 index 0000000000..a7310f345e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js @@ -0,0 +1,99 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: files => { + return files; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "files", + type: "array", + items: { $ref: "manifest.ExtensionURL" }, + }, + ], + }, + ], + }, +]; + +add_setup(async function () { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_relative_urls() { + await runExtensionAPITest( + "should format arguments with the relativeUrl formatter", + { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync([ + "script-1.js", + "script-2.js", + ]); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testResult, testError, extension }) { + Assert.deepEqual( + testResult, + [ + `moz-extension://${extension.uuid}/script-1.js`, + `moz-extension://${extension.uuid}/script-2.js`, + ], + "expected correct url" + ); + Assert.deepEqual(testError, undefined, "expected no error"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js new file mode 100644 index 0000000000..0d88014f32 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js @@ -0,0 +1,220 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_method_return_runtime_port() { + await runExtensionAPITest("API method returns an ExtensionPort instance", { + backgroundScript({ testAsserts, testLog }) { + try { + browser.mockExtensionAPI.methodReturnsPort("port-create-error"); + throw new Error("methodReturnsPort should have raised an exception"); + } catch (err) { + testAsserts.equal( + err?.message, + "An unexpected error occurred", + "Got the expected error" + ); + } + const port = browser.mockExtensionAPI.methodReturnsPort( + "port-create-success" + ); + testAsserts.equal(!!port, true, "Got a port"); + testAsserts.equal( + typeof port.name, + "string", + "port.name should be a string" + ); + testAsserts.equal( + typeof port.sender, + "object", + "port.sender should be an object" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.postMessage method" + ); + testAsserts.equal( + typeof port.onDisconnect?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + testAsserts.equal( + typeof port.onMessage?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + return new Promise(resolve => { + let messages = []; + port.onDisconnect.addListener(() => resolve(messages)); + port.onMessage.addListener((...args) => { + messages.push(args); + }); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [ + [1, 2], + [3, 4], + [5, 6], + ], + "Got the expected results" + ); + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName == "methodReturnsPort") { + if (request.args[0] == "port-create-error") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "not-a-valid-port", + }; + } + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { + portId: "port-id-1", + name: "a-port-name", + }, + }; + } else if (request.requestType == "addListener") { + if (request.apiObjectType !== "Port") { + throw new Error(`Unexpected objectType ${request}`); + } + + switch (request.apiName) { + case "onDisconnect": + this._onDisconnectCb = request.eventListener; + return; + case "onMessage": + Promise.resolve().then(async () => { + await request.eventListener.callListener([1, 2]); + await request.eventListener.callListener([3, 4]); + await request.eventListener.callListener([5, 6]); + this._onDisconnectCb.callListener([]); + }); + return; + } + } else if ( + request.requestType == "getProperty" && + request.apiObjectType == "Port" && + request.apiName == "sender" + ) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { id: "fake-sender-id-prop" }, + }; + } + + throw new Error(`Unexpected request: ${request}`); + }, + }); +}); + +add_task(async function test_port_as_event_listener_eventListener_param() { + await runExtensionAPITest( + "API event eventListener received an ExtensionPort parameter", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = port => { + try { + testAsserts.equal(!!port, true, "Got a port parameter"); + testAsserts.equal( + port.name, + "a-port-name-2", + "Got expected port.name value" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.disconnect method" + ); + port.onMessage.addListener((msg, portArg) => { + if (msg === "test-done") { + testLog("Got a port.onMessage event"); + testAsserts.equal( + portArg?.name, + "a-port-name-2", + "Got port as last argument" + ); + testAsserts.equal( + portArg === port, + true, + "Got the same port instance as expected" + ); + resolve(); + } else { + reject( + new Error( + `port.onMessage got an unexpected message: ${msg}` + ) + ); + } + }); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if ( + request.requestType == "addListener" && + request.apiName == "onTestEvent" + ) { + request.eventListener.callListener(["arg0", "arg1"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + apiObjectPrepended: true, + }); + return; + } else if ( + request.requestType == "addListener" && + request.apiObjectType == "Port" && + request.apiObjectId == "port-id-2" + ) { + request.eventListener.callListener(["test-done"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + }); + return; + } + + throw new Error(`Unexpected request: ${request}`); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini new file mode 100644 index 0000000000..465f913917 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini @@ -0,0 +1,32 @@ +[DEFAULT] +head = ../head.js ../head_remote.js ../head_service_worker.js head_webidl_api.js +firefox-appdir = browser +tags = webextensions webextensions-webidl-api + +prefs = + # Enable support for the extension background service worker. + extensions.backgroundServiceWorker.enabled=true + # Enable Extensions API WebIDL bindings for extension windows. + extensions.webidl-api.enabled=true + # Enable ExtensionMockAPI WebIDL bindings used for unit tests + # related to the API request forwarding and not tied to a particular + # extension API. + extensions.webidl-api.expose_mock_interface=true + # Make sure that loading the default settings for url-classifier-skip-urls + # doesn't interfere with running our tests while IDB operations are in + # flight by overriding the remote settings server URL to + # ensure that the IDB database isn't created in the first place. + services.settings.server=data:,#remote-settings-dummy/v1 + +# NOTE: these tests seems to be timing out because it takes too much time to +# run all tests and then fully exiting the test. +skip-if = os == "android" && verify + +[test_ext_webidl_api.js] +[test_ext_webidl_api_event_callback.js] +skip-if = + os == "android" && processor == "x86_64" && debug # Bug 1716308 +[test_ext_webidl_api_request_handler.js] +[test_ext_webidl_api_schema_errors.js] +[test_ext_webidl_api_schema_formatters.js] +[test_ext_webidl_runtime_port.js] |