@tapjs/core
Version:
pluggable core of node-tap
426 lines • 15.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.tap = void 0;
/**
* The root TAP object singleton, the `t` you get when you
* `import t from 'tap'`
*
* Inherits from {@link @tapjs/test!index.Test}, with all plugins applied, and
* has additional functionality to automatically pipe to standard output, set
* the process exit code appropriately, and infer options from environment
* variables.
*
* @module
*
* @see {@link @tapjs/core!tap.TAP}
*/
const test_1 = require("@tapjs/test");
const async_hook_domain_1 = require("async-hook-domain");
const node_worker_threads_1 = require("node:worker_threads");
const signal_exit_1 = require("signal-exit");
const diags_js_1 = require("./diags.js");
const implicit_end_sigil_js_1 = require("./implicit-end-sigil.js");
const proc_js_1 = require("./proc.js");
const stdout = proc_js_1.proc?.stdout;
const privSym = Symbol.for('TAP private constructor');
const privateTAPCtor = {
[privSym]: true,
};
let instance = undefined;
const envFlag = (key) => proc_js_1.env[key] === undefined ? undefined : proc_js_1.env[key] === '1';
let piped = false;
let registered = false;
let autoend = false;
/**
* This is a singleton subclass of the {@link @tapjs/test!index.Test} base
* class.
*
* Instantiate it by calling the exported {@link @tapjs/core!tap.tap} method.
*
* It has all of the same plugins, fields, properties etc of a "normal"
* Test object, but with some additional characteristics to make it
* suitable for use as the root test runner.
*
* - The {@link @tapjs/core!tap.TAP#register} method will hook onto the process
* object, to set the exit code to 1 if there are test failures, and ignore
* any `EPIPE` errors that happen on stdout. (This is quite common in cases
* where a test aborts, and then attempts to write more data.)
*
* - A brief summary is printed at the end of the test run.
*
* - If piped to stdout, then `this.register()` will be called automatically.
*
* - If not piped anywhere else, the first time it writes any data, it will
* begin piping to stdout.
*
* - Options are set based on relevant environment variables, rather than
* taking an options object, since in normal cases, it will be instantiated
* automatically before any user code is run.
*
* - The test will automatically implicitly end when the process exits. If
* there are any unfinished tests at this time, they will be emitted as
* failures.
*
* - If a `teardown` function is added, then the test will automatically
* implicitly end if it is idle for 3 consecutive `setTimeout` deferrals.
* This is a bit of a kludge, but it allows tests to run servers or other
* things that would prevent a graceful process exit, and close them down
* in a teardown function.
*
* - Lastly, since test files are often spawned by the runner using
* `t.spawn()`, this class listens for the timeout signal, and attempts to
* print diagnostic information about currently active handles and requests,
* as these are usually the cause of a test hanging indefinitely.
*/
class TAP extends test_1.Test {
/**
* @internal
*/
constructor(priv, opts = {}) {
/* c8 ignore start */
if (priv !== privateTAPCtor) {
throw new Error('the TAP singleton should not be instantiated directly');
}
const timeoutEnv = proc_js_1.env.TAP_TIMEOUT || '30';
/* c8 ignore stop */
const timeout = Number(timeoutEnv) * 1000;
const options = {
name: 'TAP',
diagnostic: envFlag('TAP_DIAG'),
bail: envFlag('TAP_BAIL'),
debug: envFlag('TAP_DEBUG') || /\btap\b/i.test(proc_js_1.env['NODE_DEBUG'] || ''),
omitVersion: envFlag('TAP_OMIT_VERSION'),
preserveWhitespace: !envFlag('TAP_OMIT_WHITESPACE'),
timeout,
failTodo: envFlag('TAP_FAIL_TODO'),
failSkip: envFlag('TAP_FAIL_SKIP'),
failOnly: envFlag('TAP_FAIL_ONLY'),
passes: envFlag('TAP_PASSES'),
// these are always set in our tests.
/* c8 ignore start */
childId: Number(proc_js_1.env.TAP_CHILD_ID) || 0,
jobId: Number(proc_js_1.env.TAP_JOB_ID) || 0,
/* c8 ignore stop */
...opts,
};
// plugins get applied right here:
super(options);
instance = g[privSym] = this;
this.on('idle', () => maybeAutoend());
this.on('complete', (results) => this.#oncomplete(results));
// only attach the teardown autoend if we're using the teardown plugin
// we test in this convoluted manner rather than this.pluginLoaded
// because otherwise we have a cyclical dep link between `@tapjs/core`
// and `@tapjs/after` which prevents TS from being able to build properly
// from a cold start.
const td = this;
const { teardown } = td;
if (typeof teardown === 'function') {
td.teardown = (...args) => {
autoend = true;
td.teardown = teardown;
return td.teardown(...args);
};
}
this.runMain(() => { });
}
get registered() {
return registered;
}
/**
* register this tap instance as being in charge of the current process
* ignore epipe errors, set exit code, etc.
*
* Happens automatically if piped to stdout.
*/
register() {
if (registered)
return;
registered = true;
registerTimeoutListener(this);
ignoreEPIPE();
proc_js_1.proc?.once('beforeExit', () => {
;
this.end(implicit_end_sigil_js_1.IMPLICIT);
});
proc_js_1.proc?.once('exit', () => {
if (!this.results) {
this.endAll();
}
});
// create a root domain to handle throws that happen outside
// of any subtest.
const rootDomain = new async_hook_domain_1.Domain((er, type) => this.hookDomain.onerror(er, type));
this.hook.onDestroy = () => rootDomain.destroy();
}
onbail(reason) {
if (registered) {
this.debug('bailout, exit 1');
super.write(`Bail out!${reason ? ' ' + reason : ''}\n`);
}
super.onbail(reason);
// don't fast-exit in the runner, so we can print the reason for
// the bailout on test failure.
if (registered && this.context !== Symbol.for('tap.isRunner')) {
proc_js_1.proc?.exit(1);
}
}
/**
* Just the normal Minipass.pipe method, but automatically registers
* if the destination is stdout.
*/
pipe(dest, opts) {
piped = true;
if (stdout && dest === stdout) {
this.register();
}
return super.pipe(dest, opts);
}
/**
* Just the normal Minipass.write method, but automatically pipes
* to stdout if not piped anywhere else.
*/
write(chunk) {
if (!registered && !piped && stdout) {
this.pipe(stdout);
}
return super.write(chunk);
}
#oncomplete(results) {
// only print this added info in the root test, otherwise
// it's a bit extraneous.
if (!proc_js_1.env.TAP_CHILD_ID) {
this.comment(this.counts.toJSON());
this.comment(`time=${this.time}ms`);
}
if (registered && proc_js_1.proc && !results.ok) {
this.debug('TAP results not ok, setting exitCode', results);
proc_js_1.proc.exitCode = 1;
}
}
/**
* Similar to the normal {@link @tapjs/core!test-base.TestBase#timeout}, but
* with the added feature that it will kill the process with `SIGALRM` if it
* has been registered, and will decorate the diagnostics with some
* information about currently running handles and requests, as these may be
* the reason the process is not gracefully closing in time.
*
* The root test runner will time out if the process receives a `SIGALRM`
* signal, or if it receives a timeout message via IPC or worker thread
* channel.
*/
timeout(options = { expired: this.name, signal: null }) {
const occ = this.occupied;
const extra = Object.assign(getTimeoutExtra(options.signal), options);
super.timeout(extra);
if (occ)
this.emit('timeout', extra);
// don't stick around
// this is just a defense if the SIGALRM signal is caught, since
// we'll exit forcibly anyway.
/* c8 ignore start */
if (registered) {
setTimeout(() => {
didProcessTimeout = true;
alarmKill();
}, 10000)?.unref?.();
}
/* c8 ignore stop */
}
// tell our parent process about our intended timeout
setTimeout(n) {
const msg = {
setTimeout: n,
key: proc_js_1.env.TAP_CHILD_KEY,
child: proc_js_1.env.TAP_CHILD_ID,
};
proc_js_1.proc?.send?.(msg);
// workers can't generate coverage
/* c8 ignore start */
node_worker_threads_1.parentPort?.postMessage(msg);
/* c8 ignore stop */
return super.setTimeout(n);
}
}
const shouldAutoend = (instance) => !!autoend && !!instance?.idle;
let autoendTimer = undefined;
const maybeAutoend = () => {
clearTimeout(autoendTimer);
if (!shouldAutoend(instance))
return;
autoendTimer = setTimeout(() => {
clearTimeout(autoendTimer);
if (shouldAutoend(instance)) {
autoendTimer = setTimeout(() => {
clearTimeout(autoendTimer);
if (shouldAutoend(instance)) {
;
instance.end(implicit_end_sigil_js_1.IMPLICIT);
autoend = false;
}
});
}
});
};
// SIGALRM means being forcibly killed due to timeout
const registerTimeoutListener = (t) => {
const oe = (_, sig) => {
if (sig === 'SIGALRM' && !didProcessTimeout) {
onProcessTimeout(t, sig);
(0, signal_exit_1.onExit)(oe);
return true;
}
};
(0, signal_exit_1.onExit)(oe);
const onMessage = (msg) => {
if (msg &&
typeof msg === 'object' &&
msg.tapAbort === 'timeout' &&
msg.key === proc_js_1.env.TAP_CHILD_KEY &&
msg.child === proc_js_1.env.TAP_CHILD_ID) {
onProcessTimeout(t, 'SIGALRM');
}
};
// this is a bit of a handshake agreement between the root TAP object
// and the Spawn and Worker classes. Because Windows cannot catch and
// process posix signals, we have to use an IPC message to send the
// timeout signal.
// t.spawn() will always open an ipc channel on fd 3 for this purpose,
// and t.worker() will use the worker message port.
// The key and childId are just a basic gut check to ensure that we don't
// treat a message as a timeout unintentionally, though of course that
// would be extremely rare.
/* c8 ignore start */
proc_js_1.proc?.on('message', onMessage);
node_worker_threads_1.parentPort?.on('message', onMessage);
// We don't want the channel to keep the child running
//@ts-ignore
proc_js_1.proc?.channel?.unref();
node_worker_threads_1.parentPort?.unref();
/* c8 ignore stop */
};
const getTimeoutExtra = (signal = null) => {
const p = process;
/* c8 ignore start */
const handles = (p._getActiveHandles() || []).filter(
/* c8 ignore stop */
h => h !== proc_js_1.proc?.stdout && h !== proc_js_1.proc?.stdin && h !== proc_js_1.proc?.stderr);
const requests = p._getActiveRequests();
const extra = {
at: undefined,
signal,
};
// node 20 doesn't have requests in the same way as node 18
// we get different objects, handles vs requests, etc.
// it's all very "internal machinery", version specific and unstable
// there are tests to show that we can get SOMETHING in expected cases,
// but it'll be completely differerent across node versions.
/* c8 ignore start */
if (requests.length) {
extra.requests = requests.map(r => {
if (!r || typeof r !== 'object')
return r;
const ret = {
type: r.constructor.name,
};
if (r.context)
ret.context = r.context;
return ret;
});
}
if (handles.length) {
extra.handles = handles.map(h => {
if (!h || typeof h !== 'object')
return h;
const ret = {
type: h.constructor.name,
};
if (h.msecs)
ret.msecs = h.msecs;
if (h._events)
ret.events = Object.keys(h._events);
if (h._sockname)
ret.sockname = h._sockname;
if (h._connectionKey)
ret.connectionKey = h._connectionKey;
return ret;
});
}
/* c8 ignore stop */
return extra;
};
let didProcessTimeout = false;
const onProcessTimeout = (t, signal = null) => {
// pretty much impossible to do this, since we forcibly exit,
// but it is theoretically possible if SIGALRM is caught.
/* c8 ignore start */
if (didProcessTimeout)
return;
/* c8 ignore stop */
didProcessTimeout = true;
const extra = getTimeoutExtra(signal);
// ignore coverage here because it happens after everything
// must have been shut down.
/* c8 ignore start */
if (!t.results) {
t.timeout(extra);
}
else {
if (extra.handles || extra.requests) {
delete extra.signal;
if (!extra.at) {
delete extra.at;
}
}
console.error((0, diags_js_1.diags)(extra));
}
// defer to print the timeout failure before termination
setTimeout(() => alarmKill());
};
const alarmKill = () => {
// can only kill in main thread, worker threads will be terminated
if (!node_worker_threads_1.isMainThread)
return;
// SIGALRM isn't supported everywhere,
// and we won't be able to catch it on windows anyway.
/* c8 ignore start */
try {
proc_js_1.proc?.kill(proc_js_1.proc?.pid, 'SIGALRM');
}
catch {
proc_js_1.proc?.kill(proc_js_1.proc?.pid, 'SIGKILL');
}
const t = setTimeout(() => {
proc_js_1.proc?.kill(proc_js_1.proc?.pid, 'SIGKILL');
}, 500);
if (t.unref)
t.unref();
};
/* c8 ignore stop */
const ignoreEPIPE = () => {
/* c8 ignore start */
if (!stdout?.emit)
return;
/* c8 ignore stop */
const emit = stdout.emit;
stdout.emit = (ev, ...args) => {
const er = args[0];
if (ev === 'error' && er?.code === 'EPIPE') {
return false;
}
//@ts-ignore
return emit.call(stdout, ev, ...args);
};
};
const g = globalThis;
/**
* The exported function instantiates a {@link @tapjs/core!tap.TAP} object if
* we don't already have one, or return the one that was previously
* instantiated.
*
* Options may be provided, which will override the environment settings,
* but they are ignored if the instance was already created.
*/
const tap = (opts) => instance ?? g[privSym] ?? new TAP(privateTAPCtor, opts);
exports.tap = tap;
//# sourceMappingURL=tap.js.map