@tapjs/run
Version:
Command-line interface for the node-tap runner
510 lines (492 loc) • 15.9 kB
JavaScript
import { ProcessInfo } from '@tapjs/processinfo';
import chalk from 'chalk';
import { watch } from 'chokidar';
import { mkdirpSync } from 'mkdirp';
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { isAbsolute, relative, resolve } from 'node:path';
import { start } from 'node:repl';
import { fileURLToPath } from 'node:url';
import { resolveImportSync } from 'resolve-import/resolve-import-sync';
import { rimrafSync } from 'rimraf';
import { stringify } from 'tap-yaml';
import { Deferred } from 'trivial-deferred';
import { options } from './chokidar-options.js';
import { fileCompleter } from './file-completer.js';
import { filterCompletions } from './filter-completions.js';
import { processinfoCompletions } from './processinfo-completions.js';
import { Watch } from './watch.js';
const tapBin = fileURLToPath(resolveImportSync('../index.js', import.meta.url));
const KILL_TIMEOUT = Number(process.env._TAP_REPL_KILL_TIMEOUT) || 2000;
const commands = [
'r',
'u',
'n',
'f',
'f?',
'c',
'i',
'w',
'w?',
'cls',
'parse',
'plugin',
'build',
'version',
'versions',
'list',
'config',
'tap',
];
export const usage = `TAP Repl Commands
r [<filename>]
run the test suite, or just the specified test
u [<filename>]
update snapshots in the suite, or just the specified test
n
run files changed since the last run
f
run tests that previously failed, or all tests if there are no
failures from any previous runs
f?
show the list of tests that failed in the previous run
c [<style>]
run a coverage report for the most recent test run
i [<filename | uuid>]
print process info for the specified test file in the last run, or
show a list of process info keys if no id provided
w [ on | off ]
toggle the file watcher on/off
w?
show information about file watch status
cls
clear the screen
parse [<options>]
parse TAP from stdin and print results
see 'parse -h' for options
plugin [add <plugin> | rm <plugin> | list]
manage tap plugins (see tap --help for details)
build
build the tap Test class with the configured plugins
version
print the version of tap in use
versions
print the version of tap and all components and plugins
list
print the list of test files that will be run by default
config
show the config options currently in use
tap [<args>]
run any other arbitrary tap command (run 'tap help' for details)
('tap repl' is not allowed)
`;
export class Repl {
input;
output;
repl;
config;
dir;
saveFile;
// the process always inherits our stdio
proc;
processInfo;
watch;
#haveChanges = false;
#piReloading = false;
#piWatcher;
#queue = [];
#inputEnded = false;
constructor(config, input = process.stdin, output = process.stdout, processInfo = ProcessInfo.loadSync({
dir: resolve(config.projectRoot, '.tap/processinfo'),
})) {
this.input = input;
this.output = output;
this.config = config;
this.dir = resolve(config.projectRoot, '.tap');
this.processInfo = processInfo;
this.saveFile =
config.get('save') || resolve(this.dir, 'repl_failures');
this.watch = new Watch(this.processInfo, () => this.#onWatchChange());
this.#piWatcher = watch(resolve(this.dir, 'processinfo'), options);
this.#piWatcher.on('all', () => this.#piReload());
}
async #piReload() {
if (this.#piReloading)
return;
this.#piReloading = true;
await this.processInfo.load();
this.#piReloading = false;
}
start() {
this.repl = start({
useColors: chalk.level > 0,
input: this.input,
output: this.output,
prompt: 'TAP> ',
eval: (input, _, __, cb) => this.parseCommand(input).then((res) => {
cb(null, res);
while (this.#queue.length && !this.proc) {
const [input, d] = this.#queue[0];
this.#queue.shift();
d.resolve(this.parseCommand(input));
}
if (this.#inputEnded && !this.#queue.length && !this.proc) {
this.exit();
}
}),
/* c8 ignore start */
completer: (input) => this.completer(input),
/* c8 ignore stop */
writer: res => res === undefined ? ''
: typeof res === 'string' ? res
: stringify(res),
// we don't actually eval anything, save the CPU cycles
useGlobal: true,
});
Object.assign(this.repl, {
// doesn't really make sense to have all default Node.js repl commands
// since we're not parsing JavaScript
commands: {},
});
// ignore the callback, it's best-effort
/* c8 ignore start */
mkdirpSync(this.dir);
this.repl.setupHistory(resolve(this.dir, 'repl_history'), () => { });
/* c8 ignore stop */
this.input.on('end', () => this.#onInputEnd());
this.input.on('close', () => this.#onInputEnd());
const osi = () => this.#onSigint();
process.removeAllListeners('SIGINT');
process.on('SIGINT', osi);
// suppress the "press ^C again to exit" message
this.repl.removeAllListeners('SIGINT');
this.repl.on('SIGINT', osi);
}
#onInputEnd() {
this.#inputEnded = true;
if (!this.proc) {
this.exit();
}
}
#killProc(signal = 'SIGINT') {
if (!this.proc)
return;
this.proc.kill(signal);
const next = signal === 'SIGINT' ? 'SIGTERM'
: /* c8 ignore start */
signal === 'SIGTERM' ? 'SIGKILL'
: null;
/* c8 ignore stop */
if (next) {
setTimeout(() => this.#killProc(next), KILL_TIMEOUT)?.unref?.();
}
}
#onSigint() {
this.output.write(this.proc ? '\n' : '^C\n');
if (this.proc) {
this.#killProc('SIGINT');
}
else {
this.exit();
process.removeAllListeners('SIGINT');
process.kill(process.pid, 'SIGINT');
}
}
async parseCommand(input) {
if (this.proc) {
const d = new Deferred();
this.#queue.push([input, d]);
return d.promise;
}
if (!this.input.isTTY) {
this.output.write(input);
}
const words = input
.trim()
.split(' ')
.map(s => s.trim());
const cmd = words[0];
const args = words.slice(1);
switch (cmd) {
case 'r':
return this.runTests(args);
case 'n':
return this.runChanged(args);
case 'f':
return this.runFailed(args);
case 'f?':
return this.showFailed();
case 'u':
return this.updateSnapshots(args);
case 'w':
return this.toggleWatch(args);
case 'w?':
return this.showWatch();
case 'c':
return this.report(args);
case 'i':
return this.info(args);
case 'cls':
return this.cls();
case 'parse':
return this.spawnParser(args);
case 'tap':
return this.spawnTap(args);
case 'list':
case 'plugin':
case 'build':
case 'version':
case 'versions':
case 'config':
return this.spawnTap(words);
case 'exit':
return this.exit();
case '':
return;
case '-h':
case '--help':
case 'h':
case 'help':
case '?':
default:
return this.help();
}
}
async cls() {
return '\x1b[2J\x1b[H';
}
#setRawMode(mode) {
// ignored to avoid the ?., which is only there because the stream
// might not have this method.
/* c8 ignore start */
this.input.setRawMode?.(mode);
/* c8 ignore stop */
}
exit() {
this.#setRawMode(false);
this.showCursor();
this.repl?.close?.();
this.watch?.close?.();
this.#piWatcher?.close?.();
this.#killProc('SIGTERM');
}
showCursor() {
this.output.write('\x1b[?25h');
}
async help() {
return usage;
}
async #spawn(cmd, args, options = {}) {
/* c8 ignore start */
if (this.proc)
return 'command in progress, please wait';
try {
this.repl?.pause();
}
catch { }
/* c8 ignore stop */
// inherit environment except what is specified, if anything
// delete anything specified as undefined
const env = { ...process.env, ...(options.env || {}) };
// always save failures to our save file
env.TAP_SAVE = this.saveFile;
env._TAP_REPL = '1';
this.#setRawMode(false);
const proc = (this.proc = spawn(cmd, args, {
...options,
env,
stdio: 'inherit',
}));
return new Promise(res => {
proc.on('close', (code, signal) => res(this.#onSpawnClose(code, signal)));
});
}
async #onSpawnClose(code, signal) {
this.proc = undefined;
try {
this.repl?.resume();
this.#setRawMode(true);
this.showCursor();
/* c8 ignore start */
}
catch { }
/* c8 ignore stop */
if (this.#haveChanges && (await this.watch.validateChanges())) {
// A change occurred while the process was running, and we've validated
// that it wasn't made irrelevant by the test eventually running that
// file.
this.output.write(stringify({ code, signal }));
return this.runChanged([]);
}
else {
return { code, signal };
}
}
async #onWatchChange() {
if (!this.proc) {
this.output.write('change detected\n');
this.output.write(stringify(await this.runChanged([])) + '\n');
this.repl?.displayPrompt();
}
else {
this.#haveChanges = true;
}
}
// spawn the tap runner with the specified arguments
async spawnTap(args, options) {
const argv = [...process.execArgv, tapBin, ...args];
return this.#spawn(process.execPath, argv, options);
}
async runTests(args) {
// run all the tests in the suite like normal
// If we have any args, then use a saveFile so that we don't
// blow away our coverage and processinfo
rimrafSync(this.saveFile);
const env = args.length ?
{ TAP_CHANGED: '0', TAP_COVERAGE_ADD: '1' }
: process.env;
return this.spawnTap(['run', ...args], { env });
}
async runChanged(args) {
rimrafSync(this.saveFile);
return this.spawnTap(['run', ...args], {
env: { TAP_CHANGED: '1' },
});
}
async showFailed() {
const fails = (await readFile(this.saveFile, 'utf8').catch(() => '')).trim();
if (!fails)
return 'no failed tests from previous runs';
else
return fails.split('\n');
}
async runFailed(args) {
return this.spawnTap(['run', ...args]);
}
updateSnapshots(args) {
const TAP_COVERAGE_ADD = args.length ? '1' : '0';
rimrafSync(this.saveFile);
const env = {
TAP_CHANGED: '0',
TAP_SNAPSHOT: '1',
TAP_COVERAGE_ADD,
};
this.spawnTap(['run', ...args], { env });
}
async toggleWatch(args) {
const on = args[0] === 'on' ? true
: /* c8 ignore start */
args[1] === 'off' ? false
: /* c8 ignore stop */ !this.watch.watching;
this.watch[on ? 'start' : 'close']();
return this.showWatch();
}
async showWatch() {
const w = this.watch;
if (!w.watching)
return 'not watching files for changes';
const { watchedFiles } = w;
const localFiles = [];
let deps = 0;
const here = resolve(this.config.projectRoot);
for (const f of watchedFiles) {
const rel = relative(here, f);
if (rel.startsWith('..') ||
isAbsolute(rel) ||
/[\\\/]node_modules[\\\/]/.test(rel)) {
deps++;
}
else {
localFiles.push(rel);
}
}
const res = {
[`${localFiles.length} local files being watched`]: localFiles.sort((a, b) => a.localeCompare(b, 'en')),
'dependency files watched': deps,
};
return res;
}
async report(args) {
return this.spawnTap(['report', ...args]);
}
#printPIN(node, showParent = false) {
const args = [...node.execArgv, ...node.argv.slice(1)];
const pin = {
date: node.date,
command: node.argv[0],
args,
cwd: node.cwd,
pid: node.pid,
ppid: node.ppid,
};
if (showParent && node.parent)
pin.parent = node.parent.uuid;
if (node.children) {
pin.children = Object.fromEntries([...node.children]
// nondeterministic
/* c8 ignore start */
.sort(({ date: a }, { date: b }) => a.localeCompare(b, 'en'))
/* c8 ignore stop */
.map(c => [c.uuid, this.#printPIN(c)]));
}
if (node.code !== null)
pin.code = node.code;
/* c8 ignore start */
if (node.signal !== null)
pin.signal = node.signal;
/* c8 ignore stop */
if (node.runtime)
pin.runtime = node.runtime;
return pin;
}
async info(args) {
if (!args.length) {
return {
'Provide a test id to get information': [
...this.processInfo.externalIDs.keys(),
],
};
}
return Object.fromEntries(args.map(id => {
const pin = this.processInfo.externalIDs.get(id) ||
this.processInfo.uuids.get(id);
return [id, pin ? this.#printPIN(pin, true) : 'no data found'];
}));
}
async spawnParser(args) {
this.output.write('Parsing TAP from stdin. Press ^D to finish.\n');
this.#spawn('tap-parser', args);
}
completer(input) {
const words = input
.trimStart()
.split(' ')
.map(s => s.trim());
const cmd = words[0];
const args = words.slice(1);
switch (cmd) {
// resolve against filenames
case 'r':
case 'u':
case 'n':
case 'f':
return fileCompleter(args, input);
case 'f?':
case 'w':
case 'w?':
case 'cls':
case 'parse':
case 'build':
case 'version':
case 'versions':
case 'list':
case 'config':
return [[cmd], input];
case 'i':
return processinfoCompletions(this.processInfo, args, input);
default:
return [filterCompletions(commands, input), input];
}
}
}
//# sourceMappingURL=index.js.map