etherpad-lite/src/tests/backend/specs/hooks.js

1210 lines
45 KiB
JavaScript

'use strict';
const assert = require('assert').strict;
const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const sinon = require('sinon');
describe(__filename, function () {
const hookName = 'testHook';
const hookFnName = 'testPluginFileName:testHookFunctionName';
let testHooks; // Convenience shorthand for plugins.hooks[hookName].
let hook; // Convenience shorthand for plugins.hooks[hookName][0].
beforeEach(async function () {
// Make sure these are not already set so that we don't accidentally step on someone else's
// toes:
assert(plugins.hooks[hookName] == null);
assert(hooks.deprecationNotices[hookName] == null);
assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null);
// Many of the tests only need a single registered hook function. Set that up here to reduce
// boilerplate.
hook = makeHook();
plugins.hooks[hookName] = [hook];
testHooks = plugins.hooks[hookName];
});
afterEach(async function () {
sinon.restore();
delete plugins.hooks[hookName];
delete hooks.deprecationNotices[hookName];
delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName];
});
const makeHook = (ret) => ({
hook_name: hookName,
// Many tests will likely want to change this. Unfortunately, we can't use a convenience
// wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and
// change behavior depending on the number of parameters.
hook_fn: (hn, ctx, cb) => cb(ret),
hook_fn_name: hookFnName,
part: {plugin: 'testPluginName'},
});
// Hook functions that should work for both synchronous and asynchronous hooks.
const supportedSyncHookFunctions = [
{
name: 'return non-Promise value, with callback parameter',
fn: (hn, ctx, cb) => 'val',
want: 'val',
syncOk: true,
},
{
name: 'return non-Promise value, without callback parameter',
fn: (hn, ctx) => 'val',
want: 'val',
syncOk: true,
},
{
name: 'return undefined, without callback parameter',
fn: (hn, ctx) => {},
want: undefined,
syncOk: true,
},
{
name: 'pass non-Promise value to callback',
fn: (hn, ctx, cb) => { cb('val'); },
want: 'val',
syncOk: true,
},
{
name: 'pass undefined to callback',
fn: (hn, ctx, cb) => { cb(); },
want: undefined,
syncOk: true,
},
{
name: 'return the value returned from the callback',
fn: (hn, ctx, cb) => cb('val'),
want: 'val',
syncOk: true,
},
{
name: 'throw',
fn: (hn, ctx, cb) => { throw new Error('test exception'); },
wantErr: 'test exception',
syncOk: true,
},
];
describe('callHookFnSync', function () {
const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand.
describe('basic behavior', function () {
it('passes hook name', async function () {
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
callHookFnSync(hook);
});
it('passes context', async function () {
for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); };
callHookFnSync(hook, val);
}
});
it('returns the value provided to the callback', async function () {
for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx, cb) => { cb(ctx); };
assert.equal(callHookFnSync(hook, val), val);
}
});
it('returns the value returned by the hook function', async function () {
for (const val of ['value', null, undefined]) {
// Must not have the cb parameter otherwise returning undefined will error.
hook.hook_fn = (hn, ctx) => ctx;
assert.equal(callHookFnSync(hook, val), val);
}
});
it('does not catch exceptions', async function () {
hook.hook_fn = () => { throw new Error('test exception'); };
assert.throws(() => callHookFnSync(hook), {message: 'test exception'});
});
it('callback returns undefined', async function () {
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
callHookFnSync(hook);
});
it('checks for deprecation', async function () {
sinon.stub(console, 'warn');
hooks.deprecationNotices[hookName] = 'test deprecation';
callHookFnSync(hook);
assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true);
assert.equal(console.warn.callCount, 1);
assert.match(console.warn.getCall(0).args[0], /test deprecation/);
});
});
describe('supported hook function styles', function () {
for (const tc of supportedSyncHookFunctions) {
it(tc.name, async function () {
sinon.stub(console, 'warn');
sinon.stub(console, 'error');
hook.hook_fn = tc.fn;
const call = () => callHookFnSync(hook);
if (tc.wantErr) {
assert.throws(call, {message: tc.wantErr});
} else {
assert.equal(call(), tc.want);
}
assert.equal(console.warn.callCount, 0);
assert.equal(console.error.callCount, 0);
});
}
});
describe('bad hook function behavior (other than double settle)', function () {
const promise1 = Promise.resolve('val1');
const promise2 = Promise.resolve('val2');
const testCases = [
{
name: 'never settles -> buggy hook detected',
// Note that returning undefined without calling the callback is permitted if the function
// has 2 or fewer parameters, so this test function must have 3 parameters.
fn: (hn, ctx, cb) => {},
wantVal: undefined,
wantError: /UNSETTLED FUNCTION BUG/,
},
{
name: 'returns a Promise -> buggy hook detected',
fn: () => promise1,
wantVal: promise1,
wantError: /PROHIBITED PROMISE BUG/,
},
{
name: 'passes a Promise to cb -> buggy hook detected',
fn: (hn, ctx, cb) => cb(promise2),
wantVal: promise2,
wantError: /PROHIBITED PROMISE BUG/,
},
];
for (const tc of testCases) {
it(tc.name, async function () {
sinon.stub(console, 'error');
hook.hook_fn = tc.fn;
assert.equal(callHookFnSync(hook), tc.wantVal);
assert.equal(console.error.callCount, tc.wantError ? 1 : 0);
if (tc.wantError) assert.match(console.error.getCall(0).args[0], tc.wantError);
});
}
});
// Test various ways a hook might attempt to settle twice. (Examples: call the callback a second
// time, or call the callback and then return a value.)
describe('bad hook function behavior (double settle)', function () {
beforeEach(async function () {
sinon.stub(console, 'error');
});
// Each item in this array codifies a way to settle a synchronous hook function. Each of the
// test cases below combines two of these behaviors in a single hook function and confirms
// that callHookFnSync both (1) returns the result of the first settle attempt, and
// (2) detects the second settle attempt.
const behaviors = [
{
name: 'throw',
fn: (cb, err, val) => { throw err; },
rejects: true,
},
{
name: 'return value',
fn: (cb, err, val) => val,
},
{
name: 'immediately call cb(value)',
fn: (cb, err, val) => cb(val),
},
{
name: 'defer call to cb(value)',
fn: (cb, err, val) => { process.nextTick(cb, val); },
async: true,
},
];
for (const step1 of behaviors) {
// There can't be a second step if the first step is to return or throw.
if (step1.name.startsWith('return ') || step1.name === 'throw') continue;
for (const step2 of behaviors) {
// If step1 and step2 are both async then there would be three settle attempts (first an
// erroneous unsettled return, then async step 1, then async step 2). Handling triple
// settle would complicate the tests, and it is sufficient to test only double settles.
if (step1.async && step2.async) continue;
it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {
hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, new Error(ctx.ret1), ctx.ret1);
return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);
};
// Temporarily remove unhandled error listeners so that the errors we expect to see
// don't trigger a test failure (or terminate node).
const events = ['uncaughtException', 'unhandledRejection'];
const listenerBackups = {};
for (const event of events) {
listenerBackups[event] = process.rawListeners(event);
process.removeAllListeners(event);
}
// We should see an asynchronous error (either an unhandled Promise rejection or an
// uncaught exception) if and only if one of the two steps was asynchronous or there was
// a throw (in which case the double settle is deferred so that the caller sees the
// original error).
const wantAsyncErr = step1.async || step2.async || step2.rejects;
let tempListener;
let asyncErr;
try {
const seenErrPromise = new Promise((resolve) => {
tempListener = (err) => {
assert.equal(asyncErr, undefined);
asyncErr = err;
resolve();
};
if (!wantAsyncErr) resolve();
});
events.forEach((event) => process.on(event, tempListener));
const call = () => callHookFnSync(hook, {ret1: 'val1', ret2: 'val2'});
if (step2.rejects) {
assert.throws(call, {message: 'val2'});
} else if (!step1.async && !step2.async) {
assert.throws(call, {message: /DOUBLE SETTLE BUG/});
} else {
assert.equal(call(), step1.async ? 'val2' : 'val1');
}
await seenErrPromise;
} finally {
// Restore the original listeners.
for (const event of events) {
process.off(event, tempListener);
for (const listener of listenerBackups[event]) {
process.on(event, listener);
}
}
}
assert.equal(console.error.callCount, 1);
assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);
if (wantAsyncErr) {
assert(asyncErr instanceof Error);
assert.match(asyncErr.message, /DOUBLE SETTLE BUG/);
}
});
// This next test is the same as the above test, except the second settle attempt is for
// the same outcome. The two outcomes can't be the same if one step throws and the other
// doesn't, so skip those cases.
if (step1.rejects !== step2.rejects) continue;
it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {
const err = new Error('val');
hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, err, 'val');
return step2.fn(cb, err, 'val');
};
const errorLogged = new Promise((resolve) => console.error.callsFake(resolve));
const call = () => callHookFnSync(hook);
if (step2.rejects) {
assert.throws(call, {message: 'val'});
} else {
assert.equal(call(), 'val');
}
await errorLogged;
assert.equal(console.error.callCount, 1);
assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);
});
}
}
});
});
describe('hooks.callAll', function () {
describe('basic behavior', function () {
it('calls all in order', async function () {
testHooks.length = 0;
testHooks.push(makeHook(1), makeHook(2), makeHook(3));
assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]);
});
it('passes hook name', async function () {
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
hooks.callAll(hookName);
});
it('undefined context -> {}', async function () {
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callAll(hookName);
});
it('null context -> {}', async function () {
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
hooks.callAll(hookName, null);
});
it('context unmodified', async function () {
const wantContext = {};
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); };
hooks.callAll(hookName, wantContext);
});
});
describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () {
delete plugins.hooks.testHook;
assert.deepEqual(hooks.callAll(hookName), []);
});
it('no registered hooks (empty list) -> []', async function () {
testHooks.length = 0;
assert.deepEqual(hooks.callAll(hookName), []);
});
it('flattens one level', async function () {
testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]);
});
it('filters out undefined', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [2, [3]]);
});
it('preserves null', async function () {
testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]]));
assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]);
});
it('all undefined -> []', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook());
assert.deepEqual(hooks.callAll(hookName), []);
});
});
});
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.
describe('basic behavior', function () {
it('passes hook name', async function () {
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
await callHookFnAsync(hook);
});
it('passes context', async function () {
for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); };
await callHookFnAsync(hook, val);
}
});
it('returns the value provided to the callback', async function () {
for (const val of ['value', null, undefined]) {
hook.hook_fn = (hn, ctx, cb) => { cb(ctx); };
assert.equal(await callHookFnAsync(hook, val), val);
assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val);
}
});
it('returns the value returned by the hook function', async function () {
for (const val of ['value', null, undefined]) {
// Must not have the cb parameter otherwise returning undefined will never resolve.
hook.hook_fn = (hn, ctx) => ctx;
assert.equal(await callHookFnAsync(hook, val), val);
assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val);
}
});
it('rejects if it throws an exception', async function () {
hook.hook_fn = () => { throw new Error('test exception'); };
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
});
it('rejects if rejected Promise passed to callback', async function () {
hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception')));
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
});
it('rejects if rejected Promise returned', async function () {
hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception'));
await assert.rejects(callHookFnAsync(hook), {message: 'test exception'});
});
it('callback returns undefined', async function () {
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
await callHookFnAsync(hook);
});
it('checks for deprecation', async function () {
sinon.stub(console, 'warn');
hooks.deprecationNotices[hookName] = 'test deprecation';
await callHookFnAsync(hook);
assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true);
assert.equal(console.warn.callCount, 1);
assert.match(console.warn.getCall(0).args[0], /test deprecation/);
});
});
describe('supported hook function styles', function () {
const supportedHookFunctions = supportedSyncHookFunctions.concat([
{
name: 'legacy async cb',
fn: (hn, ctx, cb) => { process.nextTick(cb, 'val'); },
want: 'val',
},
// Already resolved Promises:
{
name: 'return resolved Promise, with callback parameter',
fn: (hn, ctx, cb) => Promise.resolve('val'),
want: 'val',
},
{
name: 'return resolved Promise, without callback parameter',
fn: (hn, ctx) => Promise.resolve('val'),
want: 'val',
},
{
name: 'pass resolved Promise to callback',
fn: (hn, ctx, cb) => { cb(Promise.resolve('val')); },
want: 'val',
},
// Not yet resolved Promises:
{
name: 'return unresolved Promise, with callback parameter',
fn: (hn, ctx, cb) => new Promise((resolve) => process.nextTick(resolve, 'val')),
want: 'val',
},
{
name: 'return unresolved Promise, without callback parameter',
fn: (hn, ctx) => new Promise((resolve) => process.nextTick(resolve, 'val')),
want: 'val',
},
{
name: 'pass unresolved Promise to callback',
fn: (hn, ctx, cb) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); },
want: 'val',
},
// Already rejected Promises:
{
name: 'return rejected Promise, with callback parameter',
fn: (hn, ctx, cb) => Promise.reject(new Error('test rejection')),
wantErr: 'test rejection',
},
{
name: 'return rejected Promise, without callback parameter',
fn: (hn, ctx) => Promise.reject(new Error('test rejection')),
wantErr: 'test rejection',
},
{
name: 'pass rejected Promise to callback',
fn: (hn, ctx, cb) => { cb(Promise.reject(new Error('test rejection'))); },
wantErr: 'test rejection',
},
// Not yet rejected Promises:
{
name: 'return unrejected Promise, with callback parameter',
fn: (hn, ctx, cb) => new Promise((resolve, reject) => {
process.nextTick(reject, new Error('test rejection'));
}),
wantErr: 'test rejection',
},
{
name: 'return unrejected Promise, without callback parameter',
fn: (hn, ctx) => new Promise((resolve, reject) => {
process.nextTick(reject, new Error('test rejection'));
}),
wantErr: 'test rejection',
},
{
name: 'pass unrejected Promise to callback',
fn: (hn, ctx, cb) => {
cb(new Promise((resolve, reject) => {
process.nextTick(reject, new Error('test rejection'));
}));
},
wantErr: 'test rejection',
},
]);
for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) {
it(tc.name, async function () {
sinon.stub(console, 'warn');
sinon.stub(console, 'error');
hook.hook_fn = tc.fn;
const p = callHookFnAsync(hook);
if (tc.wantErr) {
await assert.rejects(p, {message: tc.wantErr});
} else {
assert.equal(await p, tc.want);
}
assert.equal(console.warn.callCount, 0);
assert.equal(console.error.callCount, 0);
});
}
});
// Test various ways a hook might attempt to settle twice. (Examples: call the callback a second
// time, or call the callback and then return a value.)
describe('bad hook function behavior (double settle)', function () {
beforeEach(async function () {
sinon.stub(console, 'error');
});
// Each item in this array codifies a way to settle an asynchronous hook function. Each of the
// test cases below combines two of these behaviors in a single hook function and confirms
// that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2)
// detects the second settle attempt.
//
// The 'when' property specifies the relative time that two behaviors will cause the hook
// function to settle:
// * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then
// behavior1 will settle the hook function before behavior2.
// * Otherwise, behavior2 will settle the hook function before behavior1.
const behaviors = [
{
name: 'throw',
fn: (cb, err, val) => { throw err; },
rejects: true,
when: 0,
},
{
name: 'return value',
fn: (cb, err, val) => val,
// This behavior has a later relative settle time vs. the 'throw' behavior because 'throw'
// immediately settles the hook function, whereas the 'return value' case is settled by a
// .then() function attached to a Promise. EcmaScript guarantees that a .then() function
// attached to a Promise is enqueued on the event loop (not executed immediately) when the
// Promise settles.
when: 1,
},
{
name: 'immediately call cb(value)',
fn: (cb, err, val) => cb(val),
// This behavior has the same relative time as the 'return value' case because it too is
// settled by a .then() function attached to a Promise.
when: 1,
},
{
name: 'return resolvedPromise',
fn: (cb, err, val) => Promise.resolve(val),
// This behavior has the same relative time as the 'return value' case because the return
// value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees
// that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value),
// so returning an already resolved Promise vs. returning a non-Promise value are
// equivalent.
when: 1,
},
{
name: 'immediately call cb(resolvedPromise)',
fn: (cb, err, val) => cb(Promise.resolve(val)),
when: 1,
},
{
name: 'return rejectedPromise',
fn: (cb, err, val) => Promise.reject(err),
rejects: true,
when: 1,
},
{
name: 'immediately call cb(rejectedPromise)',
fn: (cb, err, val) => cb(Promise.reject(err)),
rejects: true,
when: 1,
},
{
name: 'return unresolvedPromise',
fn: (cb, err, val) => new Promise((resolve) => process.nextTick(resolve, val)),
when: 2,
},
{
name: 'immediately call cb(unresolvedPromise)',
fn: (cb, err, val) => cb(new Promise((resolve) => process.nextTick(resolve, val))),
when: 2,
},
{
name: 'return unrejectedPromise',
fn: (cb, err, val) => new Promise((resolve, reject) => process.nextTick(reject, err)),
rejects: true,
when: 2,
},
{
name: 'immediately call cb(unrejectedPromise)',
fn: (cb, err, val) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))),
rejects: true,
when: 2,
},
{
name: 'defer call to cb(value)',
fn: (cb, err, val) => { process.nextTick(cb, val); },
when: 2,
},
{
name: 'defer call to cb(resolvedPromise)',
fn: (cb, err, val) => { process.nextTick(cb, Promise.resolve(val)); },
when: 2,
},
{
name: 'defer call to cb(rejectedPromise)',
fn: (cb, err, val) => { process.nextTick(cb, Promise.reject(err)); },
rejects: true,
when: 2,
},
{
name: 'defer call to cb(unresolvedPromise)',
fn: (cb, err, val) => {
process.nextTick(() => {
cb(new Promise((resolve) => process.nextTick(resolve, val)));
});
},
when: 3,
},
{
name: 'defer call cb(unrejectedPromise)',
fn: (cb, err, val) => {
process.nextTick(() => {
cb(new Promise((resolve, reject) => process.nextTick(reject, err)));
});
},
rejects: true,
when: 3,
},
];
for (const step1 of behaviors) {
// There can't be a second step if the first step is to return or throw.
if (step1.name.startsWith('return ') || step1.name === 'throw') continue;
for (const step2 of behaviors) {
it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () {
hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, new Error(ctx.ret1), ctx.ret1);
return step2.fn(cb, new Error(ctx.ret2), ctx.ret2);
};
// Temporarily remove unhandled Promise rejection listeners so that the unhandled
// rejections we expect to see don't trigger a test failure (or terminate node).
const event = 'unhandledRejection';
const listenersBackup = process.rawListeners(event);
process.removeAllListeners(event);
let tempListener;
let asyncErr;
try {
const seenErrPromise = new Promise((resolve) => {
tempListener = (err) => {
assert.equal(asyncErr, undefined);
asyncErr = err;
resolve();
};
});
process.on(event, tempListener);
const step1Wins = step1.when <= step2.when;
const winningStep = step1Wins ? step1 : step2;
const winningVal = step1Wins ? 'val1' : 'val2';
const p = callHookFnAsync(hook, {ret1: 'val1', ret2: 'val2'});
if (winningStep.rejects) {
await assert.rejects(p, {message: winningVal});
} else {
assert.equal(await p, winningVal);
}
await seenErrPromise;
} finally {
// Restore the original listeners.
process.off(event, tempListener);
for (const listener of listenersBackup) {
process.on(event, listener);
}
}
assert.equal(console.error.callCount, 1,
`Got errors:\n${
console.error.getCalls().map((call) => call.args[0]).join('\n')}`);
assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);
assert(asyncErr instanceof Error);
assert.match(asyncErr.message, /DOUBLE SETTLE BUG/);
});
// This next test is the same as the above test, except the second settle attempt is for
// the same outcome. The two outcomes can't be the same if one step rejects and the other
// doesn't, so skip those cases.
if (step1.rejects !== step2.rejects) continue;
it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () {
const err = new Error('val');
hook.hook_fn = (hn, ctx, cb) => {
step1.fn(cb, err, 'val');
return step2.fn(cb, err, 'val');
};
const winningStep = (step1.when <= step2.when) ? step1 : step2;
const errorLogged = new Promise((resolve) => console.error.callsFake(resolve));
const p = callHookFnAsync(hook);
if (winningStep.rejects) {
await assert.rejects(p, {message: 'val'});
} else {
assert.equal(await p, 'val');
}
await errorLogged;
assert.equal(console.error.callCount, 1);
assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/);
});
}
}
});
});
describe('hooks.aCallAll', function () {
describe('basic behavior', function () {
it('calls all asynchronously, returns values in order', async function () {
testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it.
let nextIndex = 0;
const hookPromises = [];
const hookStarted = [];
const hookFinished = [];
const makeHook = () => {
const i = nextIndex++;
const entry = {};
hookStarted[i] = false;
hookFinished[i] = false;
hookPromises[i] = entry;
entry.promise = new Promise((resolve) => {
entry.resolve = () => {
hookFinished[i] = true;
resolve(i);
};
});
return {hook_fn: () => {
hookStarted[i] = true;
return entry.promise;
}};
};
testHooks.push(makeHook(), makeHook());
const p = hooks.aCallAll(hookName);
assert.deepEqual(hookStarted, [true, true]);
assert.deepEqual(hookFinished, [false, false]);
hookPromises[1].resolve();
await hookPromises[1].promise;
assert.deepEqual(hookFinished, [false, true]);
hookPromises[0].resolve();
assert.deepEqual(await p, [0, 1]);
});
it('passes hook name', async function () {
hook.hook_fn = async (hn) => { assert.equal(hn, hookName); };
await hooks.aCallAll(hookName);
});
it('undefined context -> {}', async function () {
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallAll(hookName);
});
it('null context -> {}', async function () {
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.aCallAll(hookName, null);
});
it('context unmodified', async function () {
const wantContext = {};
hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); };
await hooks.aCallAll(hookName, wantContext);
});
});
describe('aCallAll callback', function () {
it('exception in callback rejects', async function () {
const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); });
await assert.rejects(p, {message: 'test exception'});
});
it('propagates error on exception', async function () {
hook.hook_fn = () => { throw new Error('test exception'); };
await hooks.aCallAll(hookName, {}, (err) => {
assert(err instanceof Error);
assert.equal(err.message, 'test exception');
});
});
it('propagages null error on success', async function () {
await hooks.aCallAll(hookName, {}, (err) => {
assert(err == null, `got non-null error: ${err}`);
});
});
it('propagages results on success', async function () {
hook.hook_fn = () => 'val';
await hooks.aCallAll(hookName, {}, (err, results) => {
assert.deepEqual(results, ['val']);
});
});
it('returns callback return value', async function () {
assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val');
});
});
describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () {
delete plugins.hooks[hookName];
assert.deepEqual(await hooks.aCallAll(hookName), []);
});
it('no registered hooks (empty list) -> []', async function () {
testHooks.length = 0;
assert.deepEqual(await hooks.aCallAll(hookName), []);
});
it('flattens one level', async function () {
testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]);
});
it('filters out undefined', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]);
});
it('preserves null', async function () {
testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));
assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]);
});
it('all undefined -> []', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.aCallAll(hookName), []);
});
});
});
describe('hooks.callAllSerial', function () {
describe('basic behavior', function () {
it('calls all asynchronously, serially, in order', 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(i);
});
});
};
testHooks.push(hook);
}
assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]);
assert.deepEqual(gotCalls, [0, 1, 2]);
});
it('passes hook name', async function () {
hook.hook_fn = async (hn) => { assert.equal(hn, hookName); };
await hooks.callAllSerial(hookName);
});
it('undefined context -> {}', async function () {
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.callAllSerial(hookName);
});
it('null context -> {}', async function () {
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
await hooks.callAllSerial(hookName, null);
});
it('context unmodified', async function () {
const wantContext = {};
hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); };
await hooks.callAllSerial(hookName, wantContext);
});
});
describe('result processing', function () {
it('no registered hooks (undefined) -> []', async function () {
delete plugins.hooks[hookName];
assert.deepEqual(await hooks.callAllSerial(hookName), []);
});
it('no registered hooks (empty list) -> []', async function () {
testHooks.length = 0;
assert.deepEqual(await hooks.callAllSerial(hookName), []);
});
it('flattens one level', async function () {
testHooks.length = 0;
testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]]));
assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]);
});
it('filters out undefined', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]);
});
it('preserves null', async function () {
testHooks.length = 0;
testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null)));
assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]);
});
it('all undefined -> []', async function () {
testHooks.length = 0;
testHooks.push(makeHook(), makeHook(Promise.resolve()));
assert.deepEqual(await hooks.callAllSerial(hookName), []);
});
});
});
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!
});
});
});