939 lines
35 KiB
JavaScript
939 lines
35 KiB
JavaScript
'use strict';
|
|
|
|
function m(mod) { return `${__dirname}/../../../src/${mod}`; }
|
|
|
|
const assert = require('assert').strict;
|
|
const hooks = require(m('static/js/pluginfw/hooks'));
|
|
const plugins = require(m('static/js/pluginfw/plugin_defs'));
|
|
const sinon = require(m('node_modules/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 () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
|
|
callHookFnSync(hook);
|
|
});
|
|
|
|
it('passes context', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
hook.hook_fn = () => { throw new Error('test exception'); };
|
|
assert.throws(() => callHookFnSync(hook), {message: 'test exception'});
|
|
});
|
|
|
|
it('callback returns undefined', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
|
|
callHookFnSync(hook);
|
|
});
|
|
|
|
it('checks for deprecation', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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(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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
|
|
hooks.callAll(hookName);
|
|
});
|
|
|
|
it('undefined context -> {}', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
|
|
hooks.callAll(hookName);
|
|
});
|
|
|
|
it('null context -> {}', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); };
|
|
hooks.callAll(hookName, null);
|
|
});
|
|
|
|
it('context unmodified', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
delete plugins.hooks.testHook;
|
|
assert.deepEqual(hooks.callAll(hookName), []);
|
|
});
|
|
|
|
it('no registered hooks (empty list) -> []', async function () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
assert.deepEqual(hooks.callAll(hookName), []);
|
|
});
|
|
|
|
it('flattens one level', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]));
|
|
assert.deepEqual(hooks.callAll(hookName), [2, [3]]);
|
|
});
|
|
|
|
it('preserves null', async function () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]]));
|
|
assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]);
|
|
});
|
|
|
|
it('all undefined -> []', async function () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
testHooks.push(makeHook(), makeHook());
|
|
assert.deepEqual(hooks.callAll(hookName), []);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('callHookFnAsync', function () {
|
|
const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand.
|
|
|
|
describe('basic behavior', function () {
|
|
it('passes hook name', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn) => { assert.equal(hn, hookName); };
|
|
await callHookFnAsync(hook);
|
|
});
|
|
|
|
it('passes context', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); };
|
|
await callHookFnAsync(hook);
|
|
});
|
|
|
|
it('checks for deprecation', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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(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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
hook.hook_fn = async (hn) => { assert.equal(hn, hookName); };
|
|
await hooks.aCallAll(hookName);
|
|
});
|
|
|
|
it('undefined context -> {}', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
|
|
await hooks.aCallAll(hookName);
|
|
});
|
|
|
|
it('null context -> {}', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); };
|
|
await hooks.aCallAll(hookName, null);
|
|
});
|
|
|
|
it('context unmodified', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); });
|
|
await assert.rejects(p, {message: 'test exception'});
|
|
});
|
|
|
|
it('propagates error on exception', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
await hooks.aCallAll(hookName, {}, (err) => {
|
|
assert(err == null, `got non-null error: ${err}`);
|
|
});
|
|
});
|
|
|
|
it('propagages results on success', async function () {
|
|
this.timeout(30);
|
|
hook.hook_fn = () => 'val';
|
|
await hooks.aCallAll(hookName, {}, (err, results) => {
|
|
assert.deepEqual(results, ['val']);
|
|
});
|
|
});
|
|
|
|
it('returns callback return value', async function () {
|
|
this.timeout(30);
|
|
assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val');
|
|
});
|
|
});
|
|
|
|
describe('result processing', function () {
|
|
it('no registered hooks (undefined) -> []', async function () {
|
|
this.timeout(30);
|
|
delete plugins.hooks[hookName];
|
|
assert.deepEqual(await hooks.aCallAll(hookName), []);
|
|
});
|
|
|
|
it('no registered hooks (empty list) -> []', async function () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
assert.deepEqual(await hooks.aCallAll(hookName), []);
|
|
});
|
|
|
|
it('flattens one level', async function () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
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 () {
|
|
this.timeout(30);
|
|
testHooks.length = 0;
|
|
testHooks.push(makeHook(), makeHook(Promise.resolve()));
|
|
assert.deepEqual(await hooks.aCallAll(hookName), []);
|
|
});
|
|
});
|
|
});
|
|
});
|