UNPKG

@paulmillr/jsbt

Version:

JS Build Tools: build, benchmark, test libs and apps

424 lines (423 loc) 14.5 kB
/*! micro-should - MIT License (c) 2019 Paul Miller (paulmillr.com) */ /** * Micro testing framework with familiar syntax for browsers, node and others. * Supports fast mode (parallel), quiet mode (dot reporter), tree structures, CLI self-run auto-detection. * @module */ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) { if (typeof path === "string" && /^\.\.?\//.test(path)) { return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) { return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js"); }); } return path; }; const stack = [{ message: '', children: [] }]; const errorLog = []; let onlyStack; let isRunning = false; const isCli = 'process' in globalThis; // Dumb bundlers parse code and assume we have hard dependency on "process". We don't. // The trick (also import(mod) below) ensures parsers can't see it. // @ts-ignore const pr = globalThis['process']; const proc = isCli ? pr : undefined; const opts = { PRINT_TREE: true, PRINT_MULTILINE: true, STOP_ON_ERROR: true, QUIET: isCli && ['1', 'true'].includes(proc?.env?.MSHOULD_QUIET), FAST: parseFast(proc?.env?.MSHOULD_FAST), }; function parseFast(str) { if (!isCli) return 0; let val = str === 'true' ? 1 : Number.parseInt(str, 10); if (!Number.isSafeInteger(val) || val < 1 || val > 256) return 0; return val; } function imp(moduleName) { return import(__rewriteRelativeImportExtension(moduleName)); } // String formatting utils const _c = String.fromCharCode(27); // x1b, control code for terminal colors const c = { // colors red: _c + '[31m', green: _c + '[32m', reset: _c + '[0m', }; // We can write 'pending' test name and then overwrite it with actual result by using ${up}. // However, if test prints something to STDOUT, last line would get removed. // We can wrap process.stdout also, but in that case there can be issues with non-node.js env. // But we already use node modules for parallel cases, so maybe worth investigating. // const up = _c + '[A'; const LEAF_N = '├─'; const LEAF_E = '│ '; const LEAF_L = '└─'; const LEAF_S = ' '; // With padding // const LEAF_N = '├─ '; // const LEAF_E = '│ '; // const LEAF_L = '└─ '; // const LEAF_S = ' '; // Colorize string for terminal. function color(colorName, title) { return isCli ? `${c[colorName]}${title}${c.reset}` : title.toString(); } function log(...args) { if (opts.QUIET) return logQuiet(false); // @ts-ignore console.log(...args); } function logQuiet(fail = false) { if (fail) { const msg = color('red', '!'); if (isCli) proc.stderr.write(msg); else console.error(msg); } else { const msg = '.'; if (isCli) proc.stdout.write(msg); else console.log(msg); } } function addToErrorLog(title = '', error) { errorLog.push(`${title} ${error?.stack ? error.stack : error}`); // @ts-ignore if (!opts.QUIET) console.error(error); // loud = show error now. quiet = show in the end } function formatPrefix(depth, prefix, isLast) { if (depth === 0) return { prefix: '', childPrefix: '' }; return { prefix: `${prefix}${isLast ? LEAF_L : LEAF_N}`, childPrefix: `${prefix}${isLast ? LEAF_S : LEAF_E}`, }; } async function runTest(info, printTree = false, multiLine = false, stopAtError = true) { if (!printTree && multiLine) log(); let title = info.message; if (typeof info.test !== 'function') throw new Error('internal test error: invalid info.test'); let messages = []; let onlyStackToLog = []; let beforeEachFns = []; let afterEachFns = []; // will be reversed for (const parent of info.path) { messages.push(parent.message); if (printTree && info.only) onlyStackToLog.push(`${parent.prefix}${parent.message}`); if (parent.beforeEach) beforeEachFns.push(parent.beforeEach); if (parent.afterEach) afterEachFns.push(parent.afterEach); } afterEachFns.reverse(); if (onlyStackToLog.length) onlyStackToLog.forEach((l) => log(l)); const path = messages.slice().concat(title).join('/'); // Skip is always single-line if (multiLine && !info.skip && !opts.QUIET) { log(printTree ? `${info.prefix}${title}: ☆` : `☆ ${path}:`); } else if (info.skip) { log(printTree ? `${info.prefix}${title} (skip)` : `☆ ${path} (skip)`); return true; } // variables influencing state / print output: // fail = true | false // quiet = true | false // printTree = true | false (true when fast mode) // stopAtError = true | false function formatTaskDone(fail = false, suffix = '') { const symbol = fail ? '☓' : '✓'; const clr = fail ? 'red' : 'green'; const title_ = suffix ? [title, suffix].join('/') : title; const full_ = suffix ? [path, suffix].join('/') : path; return printTree ? `${info.childPrefix}` + color(clr, `${title_}: ${symbol}`) : color(clr, `${symbol} ${full_}`); } // Emit function logErrorStack(suffix) { if (opts.QUIET) { // when quiet, either stop & log trace; or log ! if (stopAtError) { // stop, log whole path and trace console.error(); console.error(formatTaskDone(true, suffix)); } else { // log !, continue logQuiet(true); } } else { // when loud, log (maybe formatted) tree structure console.error(formatTaskDone(true, suffix)); } } // Run beforeEach hooks from parent contexts for (const beforeFn of beforeEachFns) { try { await beforeFn(); } catch (cause) { logErrorStack('beforeEach'); // @ts-ignore if (stopAtError) throw cause; else addToErrorLog(`${path}/beforeEach`, cause); return false; } } // Run test task try { // possible to do let result = ... in the future to save test outputs await info.test(); } catch (cause) { logErrorStack(''); // @ts-ignore if (stopAtError) throw cause; else addToErrorLog(`${path}`, cause); return false; } // Run afterEach hooks from parent contexts (in reverse order) for (const afterFn of afterEachFns) { try { await afterFn(); } catch (cause) { logErrorStack('afterEach'); // @ts-ignore if (stopAtError) throw cause; else addToErrorLog(`${path}/afterEach`, cause); return false; } } log(formatTaskDone()); return true; } function stackTop() { return stack[stack.length - 1]; } function stackPop() { return stack.pop(); } function stackAdd(info) { const c = { ...info, children: [] }; stackTop().children.push(c); stack.push(c); } function stackFlatten(elm) { const out = []; const walk = (elm, depth = 0, isLast = false, prevPrefix = '', path = []) => { const { prefix, childPrefix } = formatPrefix(depth, prevPrefix, isLast); const newElm = { ...elm, prefix, childPrefix, path }; out.push(newElm); path = path.concat([newElm]); // Save prefixes so we can print path in 'only' case const chl = elm.children; for (let i = 0; i < chl.length; i++) walk(chl[i], depth + 1, i === chl.length - 1, childPrefix, path); }; // Skip root for (const child of elm.children) walk(child); return out; } const describe = (message, fn) => { stackAdd({ message }); fn(); // Run function in the context of current stack path stackPop(); }; function describeSkip(message, _fn) { stackAdd({ message, skip: true }); // fn(); stackPop(); } describe.skip = describeSkip; function beforeEach(fn) { stackTop().beforeEach = fn; } function afterEach(fn) { stackTop().afterEach = fn; } function register(info) { stackAdd(info); stackPop(); // remove from stack since there are no children } function cloneAndReset() { let items = stackFlatten(stack[0]).slice(); if (onlyStack) items = items.filter((i) => i.test === onlyStack.test); stack.splice(0, stack.length); stack.push({ message: '', children: [] }); onlyStack = undefined; return items; } // 123 tests (+quiet +fast-x8) started... function begin(total, workers) { const features = [opts.QUIET ? '+quiet' : '', workers ? `+fast-x${workers}` : ''].filter((a) => a); const modes = features.length ? `(${features.join(' ')}) ` : ''; const sfx = total > 1 ? 's' : ''; console.log(`${color('green', total.toString())} test${sfx} ${modes}started...`); } function finalize(total, startTime) { isRunning = false; console.log(); if (opts.QUIET) console.log(); const totalFailed = errorLog.length; const sec = Math.ceil((Date.now() - startTime) / 1000); const tdiff = sec < 60 ? `in ${sec} sec` : `in ${Math.floor(sec / 60)} min ${sec % 60} sec`; if (totalFailed) { if (opts.QUIET) { errorLog.forEach((err) => console.error(err)); } if (errorLog.length > 0) throw new Error(`${errorLog.length} of ${total} tests failed ${tdiff}`); } else { console.log(`${color('green', total)} tests passed ${tdiff}`); } return total; } async function runTests(forceSequential = false) { if (isRunning) throw new Error('should.run() has already been called, wait for end'); if (!forceSequential && opts.FAST) return runTestsInParallel(); isRunning = true; const tasks = cloneAndReset(); const total = tasks.filter((i) => !!i.test).length; begin(total); const startTime = Date.now(); for (const test of tasks) { if (opts.PRINT_TREE && !test.test) log(`${test.prefix}${test.message}`); if (!test.test) continue; await runTest(test, opts.PRINT_TREE, opts.PRINT_MULTILINE, opts.STOP_ON_ERROR); } return finalize(total, startTime); } async function runTestsWhen(importMetaUrl) { if (!isCli) return; // Ignore in browser // @ts-ignore const { pathToFileURL } = await imp('node:url'); return importMetaUrl === pathToFileURL(proc.argv[1]).href ? runTests() : undefined; } // Doesn't support tree and multiline // TODO: support beforeEach, afterEach async function runTestsInParallel() { if (!isCli) throw new Error('must run in cli'); if ('deno' in proc.versions) return runTests(true); const tasks = cloneAndReset().filter((i) => !!i.test); // Filter describe elements const total = tasks.length; const startTime = Date.now(); const runTestPar = (info) => runTest(info, false, false, opts.STOP_ON_ERROR); let cluster, err; let totalW = opts.FAST; try { // @ts-ignore cluster = (await imp('node:cluster')).default; // @ts-ignore if (totalW === 1) totalW = (await imp('node:os')).cpus().length; } catch (error) { err = error; } if (!cluster || !parseFast(totalW)) throw new Error('parallel tests are not supported: ' + err); // the code is ran in workers if (!cluster.isPrimary) { proc.on('error', (err) => console.log('internal error:', 'child crashed?', err)); let tasksDone = 0; const id = cluster.worker.id; const strId = 'W' + id; for (let i = 0; i < tasks.length; i++) { if (i % totalW !== id - 1) continue; await runTestPar(tasks[i]); tasksDone++; } proc.send({ name: 'parallelTests', worker: strId, tasksDone, errorLog }); proc.exit(); } // the code is ran in primary proc const pr = new Promise((resolve, reject) => { begin(total, totalW); console.log(); const workers = []; let tasksDone = 0; let workersDone = 0; cluster.on('exit', (worker, code) => { if (!code) return; const msg = `Worker W${worker.id} (pid: ${worker.process.pid}) crashed with code: ${code}`; workers.forEach((w) => w.kill()); // Shutdown other workers reject(new Error(msg)); }); for (let i = 0; i < totalW; i++) { const worker = cluster.fork(); workers.push(worker); worker.on('error', (err) => reject(err)); worker.on('message', (msg) => { if (!msg || msg.name !== 'parallelTests') return; workersDone++; tasksDone += msg.tasksDone; msg.errorLog.forEach((item) => errorLog.push(item)); if (workersDone !== totalW) return; if (tasksDone !== total) return reject(new Error('internal error: not all tasks have been completed')); // @ts-ignore globalThis.setTimeout(() => { resolve(finalize(total, startTime)); }, 0); }); } }); return pr.catch((err) => { console.error(); console.error(color('red', 'Tests failed: ' + err.message)); err.stack = ''; throw err; }); } /** * Registers test for future running. * Would not auto-run, needs `it.run()` to be called at some point. * See {@link TestFunction} for methods. * @param message test title * @param test function, may be async */ const it = (message, test) => register({ message, test, children: [] }); it.only = (message, test) => register((onlyStack = { message, test, children: [], only: true })); it.skip = (message, test) => register({ message, test, children: [], skip: true }); it.run = runTests; it.runWhen = runTestsWhen; it.runParallel = runTestsInParallel; it.opts = opts; export { afterEach, beforeEach, describe, it, it as should }; export default it;