node/lib/internal/test_runner/harness.js
cjihrig 6346bdc526
test_runner: run global after() hook earlier
This commit moves the global after() hook execution from the
'beforeExit' event to the point where all tests have finished
running. This gives the global after() a chance to clean up
handles that would otherwise prevent the 'beforeExit' event
from being emitted.

PR-URL: https://github.com/nodejs/node/pull/49059
Fixes: https://github.com/nodejs/node/issues/49056
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
2023-08-11 09:16:51 -04:00

266 lines
6.9 KiB
JavaScript

'use strict';
const {
ArrayPrototypeForEach,
FunctionPrototypeBind,
PromiseResolve,
SafeMap,
} = primordials;
const { getCallerLocation } = internalBinding('util');
const {
createHook,
executionAsyncId,
} = require('async_hooks');
const {
codes: {
ERR_TEST_FAILURE,
},
} = require('internal/errors');
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
const { kEmptyObject } = require('internal/util');
const { kCancelledByParent, Test, Suite } = require('internal/test_runner/test');
const {
parseCommandLine,
setupTestReporters,
} = require('internal/test_runner/utils');
const { bigint: hrtime } = process.hrtime;
const testResources = new SafeMap();
function createTestTree(options = kEmptyObject) {
return setup(new Test({ __proto__: null, ...options, name: '<root>' }));
}
function createProcessEventHandler(eventName, rootTest) {
return (err) => {
if (!rootTest.harness.bootstrapComplete) {
// Something went wrong during the asynchronous portion of bootstrapping
// the test runner. Since the test runner is not setup properly, we can't
// do anything but throw the error.
throw err;
}
// Check if this error is coming from a test. If it is, fail the test.
const test = testResources.get(executionAsyncId());
if (!test || test.finished) {
// If the test is already finished or the resource that created the error
// is not mapped to a Test, report this as a top level diagnostic.
let msg;
if (test) {
msg = `Warning: Test "${test.name}" generated asynchronous ` +
'activity after the test ended. This activity created the error ' +
`"${err}" and would have caused the test to fail, but instead ` +
`triggered an ${eventName} event.`;
} else {
msg = 'Warning: A resource generated asynchronous activity after ' +
`the test ended. This activity created the error "${err}" which ` +
`triggered an ${eventName} event, caught by the test runner.`;
}
rootTest.diagnostic(msg);
process.exitCode = kGenericUserError;
return;
}
test.fail(new ERR_TEST_FAILURE(err, eventName));
test.postRun();
};
}
function configureCoverage(rootTest, globalOptions) {
if (!globalOptions.coverage) {
return null;
}
const { setupCoverage } = require('internal/test_runner/coverage');
try {
return setupCoverage();
} catch (err) {
const msg = `Warning: Code coverage could not be enabled. ${err}`;
rootTest.diagnostic(msg);
process.exitCode = kGenericUserError;
}
}
function collectCoverage(rootTest, coverage) {
if (!coverage) {
return null;
}
let summary = null;
try {
summary = coverage.summary();
coverage.cleanup();
} catch (err) {
const op = summary ? 'clean up' : 'report';
const msg = `Warning: Could not ${op} code coverage. ${err}`;
rootTest.diagnostic(msg);
process.exitCode = kGenericUserError;
}
return summary;
}
function setup(root) {
if (root.startTime !== null) {
return root;
}
// Parse the command line options before the hook is enabled. We don't want
// global input validation errors to end up in the uncaughtException handler.
const globalOptions = parseCommandLine();
const hook = createHook({
__proto__: null,
init(asyncId, type, triggerAsyncId, resource) {
if (resource instanceof Test) {
testResources.set(asyncId, resource);
return;
}
const parent = testResources.get(triggerAsyncId);
if (parent !== undefined) {
testResources.set(asyncId, parent);
}
},
destroy(asyncId) {
testResources.delete(asyncId);
},
});
hook.enable();
const exceptionHandler =
createProcessEventHandler('uncaughtException', root);
const rejectionHandler =
createProcessEventHandler('unhandledRejection', root);
const coverage = configureCoverage(root, globalOptions);
const exitHandler = () => {
root.postRun(new ERR_TEST_FAILURE(
'Promise resolution is still pending but the event loop has already resolved',
kCancelledByParent));
hook.disable();
process.removeListener('unhandledRejection', rejectionHandler);
process.removeListener('uncaughtException', exceptionHandler);
};
const terminationHandler = () => {
exitHandler();
process.exit();
};
process.on('uncaughtException', exceptionHandler);
process.on('unhandledRejection', rejectionHandler);
process.on('beforeExit', exitHandler);
// TODO(MoLow): Make it configurable to hook when isTestRunner === false.
if (globalOptions.isTestRunner) {
process.on('SIGINT', terminationHandler);
process.on('SIGTERM', terminationHandler);
}
root.harness = {
__proto__: null,
bootstrapComplete: false,
coverage: FunctionPrototypeBind(collectCoverage, null, root, coverage),
counters: {
__proto__: null,
all: 0,
failed: 0,
passed: 0,
cancelled: 0,
skipped: 0,
todo: 0,
topLevel: 0,
suites: 0,
},
shouldColorizeTestFiles: false,
};
root.startTime = hrtime();
return root;
}
let globalRoot;
let reportersSetup;
function getGlobalRoot() {
if (!globalRoot) {
globalRoot = createTestTree();
globalRoot.reporter.on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
process.exitCode = kGenericUserError;
}
});
reportersSetup = setupTestReporters(globalRoot);
}
return globalRoot;
}
async function startSubtest(subtest) {
await reportersSetup;
getGlobalRoot().harness.bootstrapComplete = true;
await subtest.start();
}
function runInParentContext(Factory) {
function run(name, options, fn, overrides) {
const parent = testResources.get(executionAsyncId()) || getGlobalRoot();
const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
if (!(parent instanceof Suite)) {
return startSubtest(subtest);
}
return PromiseResolve();
}
const test = (name, options, fn) => {
const overrides = {
__proto__: null,
loc: getCallerLocation(),
};
return run(name, options, fn, overrides);
};
ArrayPrototypeForEach(['skip', 'todo', 'only'], (keyword) => {
test[keyword] = (name, options, fn) => {
const overrides = {
__proto__: null,
[keyword]: true,
loc: getCallerLocation(),
};
return run(name, options, fn, overrides);
};
});
return test;
}
function hook(hook) {
return (fn, options) => {
const parent = testResources.get(executionAsyncId()) || getGlobalRoot();
parent.createHook(hook, fn, {
__proto__: null,
...options,
parent,
hookType: hook,
loc: getCallerLocation(),
});
};
}
module.exports = {
createTestTree,
test: runInParentContext(Test),
describe: runInParentContext(Suite),
it: runInParentContext(Test),
before: hook('before'),
after: hook('after'),
beforeEach: hook('beforeEach'),
afterEach: hook('afterEach'),
};