diff --git a/tests/backend/specs/hooks.js b/tests/backend/specs/hooks.js index 327b9bd1c..2ea7fac00 100644 --- a/tests/backend/specs/hooks.js +++ b/tests/backend/specs/hooks.js @@ -389,6 +389,90 @@ describe(__filename, function () { }); }); + describe('hooks.callFirst', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks.testHook; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + + it('passes hook name => {}', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hooks.callFirst(hookName); + }); + + it('undefined context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callFirst(hookName); + }); + + it('null context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callFirst(hookName, null); + }); + + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hooks.callFirst(hookName, wantContext); + }); + + it('predicate never satisfied -> calls all in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { gotCalls.push(i); }; + testHooks.push(hook); + } + assert.deepEqual(hooks.callFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it('stops when predicate is satisfied', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + + it('skips values that do not satisfy predicate (undefined)', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + + it('skips values that do not satisfy predicate (empty list)', async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + + it('null satisifes the predicate', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), [null]); + }); + + it('non-empty arrays are returned unmodified', async function () { + const want = ['val1']; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(['val2'])); + assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual! + }); + + it('value can be passed via callback', async function () { + const want = {}; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + const got = hooks.callFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); + }); + describe('callHookFnAsync', function () { const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand. @@ -883,4 +967,160 @@ describe(__filename, function () { }); }); }); + + describe('hooks.aCallFirst', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks.testHook; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + + it('passes hook name => {}', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + await hooks.aCallFirst(hookName); + }); + + it('undefined context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallFirst(hookName); + }); + + it('null context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallFirst(hookName, null); + }); + + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + await hooks.aCallFirst(hookName, wantContext); + }); + + it('default predicate: predicate never satisfied -> calls all in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { gotCalls.push(i); }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it('calls hook functions serially', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + + it('default predicate: stops when satisfied', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + + it('default predicate: skips values that do not satisfy (undefined)', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + + it('default predicate: skips values that do not satisfy (empty list)', async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + + it('default predicate: null satisifes', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), [null]); + }); + + it('custom predicate: called for each hook function', async function () { + testHooks.length = 0; + testHooks.push(makeHook(0), makeHook(1), makeHook(2)); + let got = 0; + await hooks.aCallFirst(hookName, null, null, (val) => { ++got; return false; }); + assert.equal(got, 3); + }); + + it('custom predicate: boolean false/true continues/stops iteration', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2; + }; + assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); + assert.equal(nCall, 2); + }); + + it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2 ? {} : null; + }; + assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); + assert.equal(nCall, 2); + }); + + it('custom predicate: array value passed unmodified to predicate', async function () { + const want = [0]; + hook.hook_fn = () => want; + const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it('custom predicate: normalized value passed to predicate (undefined)', async function () { + const predicate = (got) => { assert.deepEqual(got, []); }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it('custom predicate: normalized value passed to predicate (null)', async function () { + hook.hook_fn = () => null; + const predicate = (got) => { assert.deepEqual(got, [null]); }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + + it('non-empty arrays are returned unmodified', async function () { + const want = ['val1']; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(['val2'])); + assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual! + }); + + it('value can be passed via callback', async function () { + const want = {}; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + const got = await hooks.aCallFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); + }); });