diff options
Diffstat (limited to 'toolkit/components/asyncshutdown/tests/xpcshell')
6 files changed, 793 insertions, 0 deletions
diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/head.js b/toolkit/components/asyncshutdown/tests/xpcshell/head.js new file mode 100644 index 0000000000..6b8c756122 --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/head.js @@ -0,0 +1,180 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +var { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); + +var asyncShutdownService = Cc[ + "@mozilla.org/async-shutdown-service;1" +].getService(Ci.nsIAsyncShutdownService); + +Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + +/** + * Utility function used to provide the same API for various sources + * of async shutdown barriers. + * + * @param {string} kind One of + * - "phase" to test an AsyncShutdown phase; + * - "barrier" to test an instance of AsyncShutdown.Barrier; + * - "xpcom-barrier" to test an instance of nsIAsyncShutdownBarrier; + * - "xpcom-barrier-unwrapped" to test the field `jsclient` of a nsIAsyncShutdownClient. + * + * @return An object with the following methods: + * - addBlocker() - the same method as AsyncShutdown phases and barrier clients + * - wait() - trigger the resolution of the lock + */ +function makeLock(kind) { + if (kind == "phase") { + let topic = "test-Phase-" + ++makeLock.counter; + let phase = AsyncShutdown._getPhase(topic); + return { + addBlocker(...args) { + return phase.addBlocker(...args); + }, + removeBlocker(blocker) { + return phase.removeBlocker(blocker); + }, + wait() { + Services.obs.notifyObservers(null, topic); + return Promise.resolve(); + }, + get isClosed() { + return phase.isClosed; + }, + }; + } else if (kind == "barrier") { + let name = "test-Barrier-" + ++makeLock.counter; + let barrier = new AsyncShutdown.Barrier(name); + return { + addBlocker: barrier.client.addBlocker, + removeBlocker: barrier.client.removeBlocker, + wait() { + return barrier.wait(); + }, + get isClosed() { + return barrier.client.isClosed; + }, + }; + } else if (kind == "xpcom-barrier") { + let name = "test-xpcom-Barrier-" + ++makeLock.counter; + let barrier = asyncShutdownService.makeBarrier(name); + return { + addBlocker(blockerName, condition, state) { + if (condition == null) { + // Slight trick as `null` or `undefined` cannot be used as keys + // for `xpcomMap`. Note that this has no incidence on the result + // of the test as the XPCOM interface imposes that the condition + // is a method, so it cannot be `null`/`undefined`. + condition = "<this case can't happen with the xpcom interface>"; + } + let blocker = makeLock.xpcomMap.get(condition); + if (!blocker) { + blocker = { + name: blockerName, + state, + blockShutdown(aBarrierClient) { + return (async function () { + try { + if (typeof condition == "function") { + await Promise.resolve(condition()); + } else { + await Promise.resolve(condition); + } + } finally { + aBarrierClient.removeBlocker(blocker); + } + })(); + }, + }; + makeLock.xpcomMap.set(condition, blocker); + } + let { fileName, lineNumber, stack } = new Error(); + return barrier.client.addBlocker(blocker, fileName, lineNumber, stack); + }, + removeBlocker(condition) { + let blocker = makeLock.xpcomMap.get(condition); + if (!blocker) { + return; + } + barrier.client.removeBlocker(blocker); + }, + wait() { + return new Promise(resolve => { + barrier.wait(resolve); + }); + }, + get isClosed() { + return barrier.client.isClosed; + }, + }; + } else if ("unwrapped-xpcom-barrier") { + let name = "unwrapped-xpcom-barrier-" + ++makeLock.counter; + let barrier = asyncShutdownService.makeBarrier(name); + let client = barrier.client.jsclient; + return { + addBlocker: client.addBlocker, + removeBlocker: client.removeBlocker, + wait() { + return new Promise(resolve => { + barrier.wait(resolve); + }); + }, + get isClosed() { + return client.isClosed; + }, + }; + } + throw new TypeError("Unknown kind " + kind); +} +makeLock.counter = 0; +makeLock.xpcomMap = new Map(); // Note: Not a WeakMap as we wish to handle non-gc-able keys (e.g. strings) + +/** + * An asynchronous task that takes several ticks to complete. + * + * @param {*=} resolution The value with which the resulting promise will be + * resolved once the task is complete. This may be a rejected promise, + * in which case the resulting promise will itself be rejected. + * @param {object=} outResult An object modified by side-effect during the task. + * Initially, its field |isFinished| is set to |false|. Once the task is + * complete, its field |isFinished| is set to |true|. + * + * @return {promise} A promise fulfilled once the task is complete + */ +function longRunningAsyncTask(resolution = undefined, outResult = {}) { + outResult.isFinished = false; + if (!("countFinished" in outResult)) { + outResult.countFinished = 0; + } + return new Promise(resolve => { + do_timeout(100, function () { + ++outResult.countFinished; + outResult.isFinished = true; + resolve(resolution); + }); + }); +} + +function get_exn(f) { + try { + f(); + return null; + } catch (ex) { + return ex; + } +} + +function do_check_exn(exn, constructor) { + Assert.notEqual(exn, null); + if (exn.name == constructor) { + Assert.equal(exn.constructor.name, constructor); + return; + } + info("Wrong error constructor"); + info(exn.constructor.name); + info(exn.stack); + Assert.ok(false); +} diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js new file mode 100644 index 0000000000..27c3a26985 --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown.js @@ -0,0 +1,348 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function test_no_condition() { + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + info("Testing a barrier with no condition (" + kind + ")"); + let lock = makeLock(kind); + await lock.wait(); + info("Barrier with no condition didn't lock"); + } +}); + +add_task(async function test_phase_various_failures() { + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + info("Kind: " + kind); + // Testing with wrong arguments + let lock = makeLock(kind); + + Assert.throws( + () => lock.addBlocker(), + /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/ + ); + Assert.throws( + () => lock.addBlocker(null, true), + /TypeError|NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS/ + ); + + if (kind != "xpcom-barrier") { + // xpcom-barrier actually expects a string in that position + Assert.throws( + () => lock.addBlocker("Test 2", () => true, "not a function"), + /TypeError/ + ); + } + + if (kind == "xpcom-barrier") { + const blocker = () => true; + lock.addBlocker("Test 3", blocker); + Assert.throws( + () => lock.addBlocker("Test 3", blocker), + /We have already registered the blocker \(Test 3\)/ + ); + } + + // Attempting to add a blocker after we are done waiting + Assert.ok(!lock.isClosed, "Barrier is open"); + await lock.wait(); + Assert.throws(() => lock.addBlocker("Test 4", () => true), /is finished/); + Assert.ok(lock.isClosed, "Barrier is closed"); + } +}); + +add_task(async function test_reentrant() { + info("Ensure that we can call addBlocker from within a blocker"); + + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + info("Kind: " + kind); + let lock = makeLock(kind); + + let deferredOuter = Promise.withResolvers(); + let deferredInner = Promise.withResolvers(); + let deferredBlockInner = Promise.withResolvers(); + + lock.addBlocker("Outer blocker", () => { + info("Entering outer blocker"); + deferredOuter.resolve(); + lock.addBlocker("Inner blocker", () => { + info("Entering inner blocker"); + deferredInner.resolve(); + return deferredBlockInner.promise; + }); + }); + + // Note that phase-style locks spin the event loop and do not return from + // `lock.wait()` until after all blockers have been resolved. Therefore, + // to be able to test them, we need to dispatch the following steps to the + // event loop before calling `lock.wait()`, which we do by forcing + // a Promise.resolve(). + // + let promiseSteps = (async function () { + await Promise.resolve(); + + info("Waiting until we have entered the outer blocker"); + await deferredOuter.promise; + + info("Waiting until we have entered the inner blocker"); + await deferredInner.promise; + + info("Allowing the lock to resolve"); + deferredBlockInner.resolve(); + })(); + + info("Starting wait"); + await lock.wait(); + + info("Waiting until all steps have been walked"); + await promiseSteps; + } +}); + +add_task(async function test_phase_removeBlocker() { + info( + "Testing that we can call removeBlocker before, during and after the call to wait()" + ); + + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + info("Switching to kind " + kind); + info("Attempt to add then remove a blocker before wait()"); + let lock = makeLock(kind); + let blocker = () => { + info("This promise will never be resolved"); + return Promise.withResolvers().promise; + }; + + lock.addBlocker("Wait forever", blocker); + let do_remove_blocker = function (aLock, aBlocker, aShouldRemove) { + info( + "Attempting to remove blocker " + + aBlocker + + ", expecting result " + + aShouldRemove + ); + if (kind == "xpcom-barrier") { + // The xpcom variant always returns `undefined`, so we can't + // check its result. + aLock.removeBlocker(aBlocker); + return; + } + Assert.equal(aLock.removeBlocker(aBlocker), aShouldRemove); + }; + do_remove_blocker(lock, blocker, true); + do_remove_blocker(lock, blocker, false); + info("Attempt to remove non-registered blockers before wait()"); + do_remove_blocker(lock, "foo", false); + do_remove_blocker(lock, null, false); + info("Waiting (should lift immediately)"); + await lock.wait(); + + info("Attempt to add a blocker then remove it during wait()"); + lock = makeLock(kind); + let blockers = [ + () => { + info("This blocker will self-destruct"); + do_remove_blocker(lock, blockers[0], true); + return Promise.withResolvers().promise; + }, + () => { + info("This blocker will self-destruct twice"); + do_remove_blocker(lock, blockers[1], true); + do_remove_blocker(lock, blockers[1], false); + return Promise.withResolvers().promise; + }, + () => { + info("Attempt to remove non-registered blockers during wait()"); + do_remove_blocker(lock, "foo", false); + do_remove_blocker(lock, null, false); + }, + ]; + for (let i in blockers) { + lock.addBlocker("Wait forever again: " + i, blockers[i]); + } + info("Waiting (should lift very quickly)"); + await lock.wait(); + do_remove_blocker(lock, blockers[0], false); + + info("Attempt to remove a blocker after wait"); + lock = makeLock(kind); + blocker = Promise.resolve.bind(Promise); + await lock.wait(); + do_remove_blocker(lock, blocker, false); + + info("Attempt to remove non-registered blocker after wait()"); + do_remove_blocker(lock, "foo", false); + do_remove_blocker(lock, null, false); + } +}); + +add_task(async function test_addBlocker_noDistinctNamesConstraint() { + info("Testing that we can add two distinct blockers with identical names"); + + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + info("Switching to kind " + kind); + let lock = makeLock(kind); + let deferred1 = Promise.withResolvers(); + let resolved1 = false; + let deferred2 = Promise.withResolvers(); + let resolved2 = false; + let blocker1 = () => { + info("Entering blocker1"); + return deferred1.promise; + }; + let blocker2 = () => { + info("Entering blocker2"); + return deferred2.promise; + }; + + info("Attempt to add two distinct blockers with identical names"); + lock.addBlocker("Blocker", blocker1); + lock.addBlocker("Blocker", blocker2); + + // Note that phase-style locks spin the event loop and do not return from + // `lock.wait()` until after all blockers have been resolved. Therefore, + // to be able to test them, we need to dispatch the following steps to the + // event loop before calling `lock.wait()`, which we do by forcing + // a Promise.resolve(). + // + let promiseSteps = (async () => { + info("Waiting for an event-loop spin"); + await Promise.resolve(); + + info("Resolving blocker1"); + deferred1.resolve(); + resolved1 = true; + + info("Waiting for an event-loop spin"); + await Promise.resolve(); + + info("Resolving blocker2"); + deferred2.resolve(); + resolved2 = true; + })(); + + info("Waiting for lock"); + await lock.wait(); + + Assert.ok(resolved1); + Assert.ok(resolved2); + await promiseSteps; + } +}); + +add_task(async function test_state() { + info("Testing information contained in `state`"); + + let BLOCKER_NAME = "test_state blocker " + Math.random(); + + // Set up the barrier. Note that we cannot test `barrier.state` + // immediately, as it initially contains "Not started" + let barrier = new AsyncShutdown.Barrier("test_filename"); + let deferred = Promise.withResolvers(); + let { filename, lineNumber } = Components.stack; + barrier.client.addBlocker(BLOCKER_NAME, function () { + return deferred.promise; + }); + + let promiseDone = barrier.wait(); + + // Now that we have called `wait()`, the state contains interesting things + info("State: " + JSON.stringify(barrier.state, null, "\t")); + let state = barrier.state[0]; + Assert.equal(state.filename, filename); + Assert.equal(state.lineNumber, lineNumber + 1); + Assert.equal(state.name, BLOCKER_NAME); + Assert.ok( + state.stack.some(x => x.includes("test_state")), + "The stack contains the caller function's name" + ); + Assert.ok( + state.stack.some(x => x.includes(filename)), + "The stack contains the calling file's name" + ); + + deferred.resolve(); + await promiseDone; +}); + +add_task(async function test_multistate() { + info("Testing information contained in multiple `state`"); + + let BLOCKER_NAMES = [ + "test_state blocker " + Math.random(), + "test_state blocker " + Math.random(), + ]; + + // Set up the barrier. Note that we cannot test `barrier.state` + // immediately, as it initially contains "Not started" + let barrier = asyncShutdownService.makeBarrier("test_filename"); + let deferred = Promise.withResolvers(); + let { filename, lineNumber } = Components.stack; + for (let name of BLOCKER_NAMES) { + barrier.client.jsclient.addBlocker(name, () => deferred.promise, { + fetchState: () => ({ progress: name }), + }); + } + + let promiseDone = new Promise(r => barrier.wait(r)); + + // Now that we have called `wait()`, the state contains interesting things. + Assert.ok( + barrier.state instanceof Ci.nsIPropertyBag, + "State is a PropertyBag" + ); + for (let i = 0; i < BLOCKER_NAMES.length; ++i) { + let state = barrier.state.getProperty(i.toString()); + Assert.equal(typeof state, "string", "state is a string"); + info("State: " + state + "\t"); + state = JSON.parse(state); + Assert.equal(state.filename, filename); + Assert.equal(state.lineNumber, lineNumber + 2); + Assert.equal(state.name, BLOCKER_NAMES[i]); + Assert.ok( + state.stack.some(x => x.includes("test_multistate")), + "The stack contains the caller function's name" + ); + Assert.ok( + state.stack.some(x => x.includes(filename)), + "The stack contains the calling file's name" + ); + Assert.equal( + state.state.progress, + BLOCKER_NAMES[i], + "The state contains the fetchState provided value" + ); + } + + deferred.resolve(); + await promiseDone; +}); + +add_task(async function () { + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); +}); diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js new file mode 100644 index 0000000000..669164f68e --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_blocker_error_annotations.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that when addBlocker fails, we store that failure internally + * and include its information in crash report annotation information. + */ +add_task(async function test_addBlockerFailureState() { + info("Testing addBlocker information reported to crash reporter"); + + let BLOCKER_NAME = "test_addBlocker_state blocker " + Math.random(); + + // Set up the barrier. Note that we cannot test `barrier.state` + // immediately, as it initially contains "Not started" + let barrier = new AsyncShutdown.Barrier("test_addBlocker_failure"); + let deferred = Promise.withResolvers(); + barrier.client.addBlocker(BLOCKER_NAME, function () { + return deferred.promise; + }); + + // Add a blocker and confirm that throws. + const THROWING_BLOCKER_NAME = "test_addBlocker_throws blocker"; + Assert.throws(() => { + barrier.client.addBlocker(THROWING_BLOCKER_NAME, Promise.resolve(), 5); + }, /object as third argument/); + + let promiseDone = barrier.wait(); + + // Now that we have called `wait()`, the state should match crash + // reporting info + let crashInfo = barrier._gatherCrashReportTimeoutData( + barrier._name, + barrier.state + ); + Assert.deepEqual( + crashInfo.conditions, + barrier.state, + "Barrier state should match crash info." + ); + Assert.equal( + crashInfo.brokenAddBlockers.length, + 1, + "Should have registered the broken addblocker call." + ); + Assert.stringMatches( + crashInfo.brokenAddBlockers?.[0] || "undefined", + THROWING_BLOCKER_NAME, + "Throwing call's blocker name should be listed in message." + ); + + deferred.resolve(); + await promiseDone; +}); diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js new file mode 100644 index 0000000000..b37416d7a7 --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_AsyncShutdown_leave_uncaught.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// +// This file contains tests that need to leave uncaught asynchronous +// errors. If your test catches all its asynchronous errors, please +// put it in another file. +// +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.thisTestLeaksUncaughtRejectionsAndShouldBeFixed(); + +add_task(async function test_phase_simple_async() { + info("Testing various combinations of a phase with a single condition"); + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + for (let arg of [undefined, null, "foo", 100, new Error("BOOM")]) { + for (let resolution of [arg, Promise.reject(arg)]) { + for (let success of [false, true]) { + for (let state of [ + [null], + [], + [() => "some state"], + [ + function () { + throw new Error("State BOOM"); + }, + ], + [ + function () { + return { + toJSON() { + throw new Error("State.toJSON BOOM"); + }, + }; + }, + ], + ]) { + // Asynchronous phase + info( + "Asynchronous test with " + arg + ", " + resolution + ", " + kind + ); + let lock = makeLock(kind); + let outParam = { isFinished: false }; + lock.addBlocker( + "Async test", + function () { + if (success) { + return longRunningAsyncTask(resolution, outParam); + } + throw resolution; + }, + ...state + ); + Assert.ok(!outParam.isFinished); + await lock.wait(); + Assert.equal(outParam.isFinished, success); + } + } + + // Synchronous phase - just test that we don't throw/freeze + info("Synchronous test with " + arg + ", " + resolution + ", " + kind); + let lock = makeLock(kind); + lock.addBlocker("Sync test", resolution); + await lock.wait(); + } + } + } +}); + +add_task(async function test_phase_many() { + info("Testing various combinations of a phase with many conditions"); + for (let kind of [ + "phase", + "barrier", + "xpcom-barrier", + "xpcom-barrier-unwrapped", + ]) { + let lock = makeLock(kind); + let outParams = []; + for (let arg of [undefined, null, "foo", 100, new Error("BOOM")]) { + for (let resolve of [true, false]) { + info("Testing with " + kind + ", " + arg + ", " + resolve); + let resolution = resolve ? arg : Promise.reject(arg); + let outParam = { isFinished: false }; + lock.addBlocker("Test " + Math.random(), () => + longRunningAsyncTask(resolution, outParam) + ); + } + } + Assert.ok(outParams.every(x => !x.isFinished)); + await lock.wait(); + Assert.ok(outParams.every(x => x.isFinished)); + } +}); + +add_task(async function () { + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); +}); diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js b/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js new file mode 100644 index 0000000000..1e4740ddc1 --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/test_converters.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Test conversion between nsIPropertyBag and JS values. + */ + +var PropertyBagConverter = + new asyncShutdownService.wrappedJSObject._propertyBagConverter(); + +function run_test() { + test_conversions(); +} + +function normalize(obj) { + if (obj === undefined) { + return null; + } + if (obj == null || typeof obj != "object") { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(normalize); + } + let result = {}; + for (let k of Object.keys(obj).sort()) { + result[k] = normalize(obj[k]); + } + return result; +} + +function test_conversions() { + const SAMPLES = [ + // Simple values + 1, + true, + "string", + null, + undefined, + // Object + { + a: 1, + b: true, + c: "string", + d: 0.5, + e: [2, false, "another string", 0.3], + f: [], + g: { + a2: 1, + b2: true, + c2: "string", + d2: 0.5, + e2: [2, false, "another string", 0.3], + f2: [], + g2: [ + { + a3: 1, + b3: true, + c3: "string", + d3: 0.5, + e3: [2, false, "another string", 0.3], + f3: [], + g3: {}, + }, + ], + h2: null, + }, + h: null, + }, + // Array + [1, 2, 3], + // Array of objects + [[1, 2], { a: 1, b: "string" }, null], + ]; + + for (let sample of SAMPLES) { + let stringified = JSON.stringify(normalize(sample), null, "\t"); + info("Testing conversions of " + stringified); + let rewrites = [sample]; + for (let i = 1; i < 3; ++i) { + let source = rewrites[i - 1]; + let bag = PropertyBagConverter.jsValueToPropertyBag(source); + Assert.ok(bag instanceof Ci.nsIPropertyBag, "The bag is a property bag"); + let dest = PropertyBagConverter.propertyBagToJsValue(bag); + let restringified = JSON.stringify(normalize(dest), null, "\t"); + info("Comparing"); + info(stringified); + info(restringified); + Assert.deepEqual(sample, dest, "Testing after " + i + " conversions"); + rewrites.push(dest); + } + } +} diff --git a/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml b/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..365af63451 --- /dev/null +++ b/toolkit/components/asyncshutdown/tests/xpcshell/xpcshell.toml @@ -0,0 +1,11 @@ +[DEFAULT] +head = "head.js" +skip-if = ["os == 'android'"] + +["test_AsyncShutdown.js"] + +["test_AsyncShutdown_blocker_error_annotations.js"] + +["test_AsyncShutdown_leave_uncaught.js"] + +["test_converters.js"] |