UNPKG

just-tap

Version:

A simple tap test runner that can be used in any client/server javascript app.

472 lines (432 loc) 13.6 kB
import deepEqual from 'deep-equal'; import * as Diff from 'diff'; import waitUntil from './waitUntil.js'; const defaultOptions = { logger: console.log, formatInfo: text => `\x1b[96m${text}\x1b[0m`, formatSource: text => `\x1b[90m${text}\x1b[0m`, formatDanger: text => `\x1b[91m${text}\x1b[0m`, formatSuccess: text => `\x1b[92m${text}\x1b[0m`, formatValue: text => JSON.stringify(text), formatDiffNormal: text => `\x1b[90m${text}\x1b[0m`, formatDiffAdded: text => `\x1b[92m${text}\x1b[0m`, formatDiffRemoved: text => `\x1b[91m${text}\x1b[0m` }; Error.stackTraceLimit = Infinity; const getStack = (error) => { try { const lines = error.stack.split('\n'); const line = lines.find(line => line.includes('(eval at ')); const splitted = line.split('(eval at '); const anonymousSplits = line.split('<anonymous>'); const mixedPosition = anonymousSplits[anonymousSplits.length - 1]; const [, lineNumber, charNumber] = mixedPosition.slice(0, -1).split(':'); return `${splitted[0]}(<sandbox>:${lineNumber - 17}:${charNumber})`.trim(); } catch (error) { return '' } } function getSourcePath () { if (typeof process === 'undefined') { const error = new Error('for stack trace'); const stack = getStack(error); return stack; } const rootSourcePath = `file://${process.cwd()}`; try { const error = new Error('for stack trace'); const definePath = error.stack.split('\n').filter(definePath => { return !definePath.includes('(node:internal'); }).slice(-1)[0]; const sourcePath = definePath.split(' at ')[1].replace(rootSourcePath, '.'); const getFileFromSourcePath = sourcePath => { const [, ...parts] = sourcePath.split(' ('); return parts.join('').slice(0, -1); }; return sourcePath.includes(' (') ? getFileFromSourcePath(sourcePath) : sourcePath; } catch (error) { return 'unknown file path'; } } const runTest = async (context, test) => { const { options, stats } = context; let trackingPaused = false; let trackingLog = []; const track = (fn) => { if (trackingPaused) { trackingLog.push(fn); return; } fn(); }; const clearTrackingLog = () => { trackingLog = []; }; const pauseTracking = (paused) => { trackingPaused = paused; if (!trackingPaused) { trackingLog.forEach(fn => fn()); clearTrackingLog(); } }; let notOkTracker = 0; const local = { logData: [], log: line => { if (context.concurrency === 1 || context.remaining === 1) { local.logFlush(); options.logger(line); return; } local.logData.push(line); }, logFlush: () => { if (local.logData.length > 0) { options.logger(local.logData.join('\n')); local.logData = []; } }, failed: false, finished: false, planned: null, assertions: 0, timeout: 1000000000, notOk: (message) => { if (local.finished) { throw Object.assign(new Error('assertion was made on a finished test'), { testName: test.name }); } notOkTracker = notOkTracker + 1; track(() => { local.assertions = local.assertions + 1; stats.notOk = stats.notOk + 1; local.failed = true; local.log(`${options.formatDanger('not ok')} ${stats.ok + stats.notOk} - ${message}`); }); }, ok: (message) => { if (local.finished) { throw Object.assign(new Error('assertion was made on a finished test'), { testName: test.name }); } track(() => { local.assertions = local.assertions + 1; stats.ok = stats.ok + 1; local.log(`${options.formatSuccess('ok')} ${stats.ok + stats.notOk} - ${message}`); }); } }; local.log(options.formatInfo(`# ${test.name}:`)); local.log(test.sourcePath && options.formatSource('# ' + test.sourcePath)); const logComment = message => { local.log([ ' ---', ...message.split('\n') ].join('\n ')); }; const diffObjects = (a, b) => { const diff = Diff.diffJson(a, b); let debug = ''; diff.forEach((part, index, array) => { const lines = part.value.split('\n'); const isLastPart = index === array.length - 1; if (part.added) { lines.forEach(line => { if (line) { debug += options.formatDiffAdded('+ ' + line) + '\n'; } }); return; } if (part.removed) { lines.forEach(line => { if (line) { debug += options.formatDiffRemoved('- ' + line) + '\n'; } }); return; } lines.forEach((line, lineIndex) => { if (line) { debug += options.formatDiffNormal(' ' + line) + '\n'; } else if (isLastPart && lineIndex === lines.length - 1) { debug += options.formatDiffNormal('}') + '\n'; } }); }); return debug; }; const cleanup = await test.fn({ plan: (newPlan) => { local.planned = newPlan; }, timeout: (newTimeout) => { local.timeout = newTimeout; }, waitFor: (fn, timeout) => { if (!timeout) { throw new Error('waitFor must be given a timeout'); } const timeoutError = new Error('waitFor exceeded allowed timeout'); return waitUntil(async () => { notOkTracker = 0; pauseTracking(true); try { const result = await fn(); if (notOkTracker > 0) { clearTrackingLog(); return false; } return result || true; } catch (error) { clearTrackingLog(); return false; } finally { pauseTracking(false); } }, timeout).catch(async () => { logComment([ 'error: ' + timeoutError.message, 'stack: |\n' + timeoutError.stack.split('\n').slice(1).join('\n') ].join('\n')); local.notOk('waitFor exceeded timeout'); }); }, pass: (message) => { message = message || 'passed'; local.ok(message); }, fail: (message) => { message = message || 'failed'; local.notOk(message); }, throws: (fn, expected, message) => { let error; try { fn(); } catch (thrownError) { error = { message: thrownError.message, ...thrownError }; } const ok = deepEqual(error, expected, { strict: true }); const defaultMessage = 'expected error to be thrown'; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); logComment(diffObjects(error, expected)); } }, notThrows: (fn, message) => { let error; try { fn(); } catch (thrownError) { error = thrownError; } const ok = !error; const defaultMessage = 'expected error to not be thrown'; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, equal: (a, b, message) => { const ok = a === b; const defaultMessage = `expected ${options.formatValue(a, 'equal', ok)} to equal ${options.formatValue(b, 'equal', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, notEqual: (a, b, message) => { const ok = a !== b; const defaultMessage = `expected ${options.formatValue(a, 'notEqual', ok)} to not equal ${options.formatValue(b, 'notEqual', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, looseEqual: (a, b, message) => { const ok = a == b; const defaultMessage = `expected ${options.formatValue(a, 'looseEqual', ok)} to loose equal ${options.formatValue(b, 'looseEqual', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, notLooseEqual: (a, b, message) => { const ok = a != b; const defaultMessage = `expected ${options.formatValue(a, 'notLooseEqual', ok)} to not loose equal ${options.formatValue(b, 'notLooseEqual', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, deepEqual: (a, b, message) => { const ok = deepEqual(a, b, { strict: true }); const defaultMessage = 'expected objects to deeply equal'; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); logComment(diffObjects(a, b)); } }, notDeepEqual: (a, b, message) => { const ok = !deepEqual(a, b, { strict: true }); const defaultMessage = 'expected objects not to deeply equal'; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); logComment(diffObjects(a, b)); } }, ok: (result, message) => { const ok = !!result; const defaultMessage = `expected ${options.formatValue(result, 'ok', ok)} to be truthy`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, notOk: (result, message) => { const ok = !result; const defaultMessage = `expected ${options.formatValue(result, 'notOk', ok)} to be falsy`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, match: (str, regex, message) => { const ok = regex.test(str); const defaultMessage = `expected ${options.formatValue(str, 'match', ok)} to match ${options.formatValue(regex, 'match', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } }, notMatch: (str, regex, message) => { const ok = !regex.test(str); const defaultMessage = `expected ${options.formatValue(str, 'notMatch', ok)} to not match ${options.formatValue(regex, 'notMatch', ok)}`; if (ok) { local.ok(message || defaultMessage); } else { local.notOk((message ? message + ' - ' : '') + defaultMessage); } } }); try { local.planned && await waitUntil(() => { return local.assertions >= local.planned; }, local.timeout); if (local.planned && local.assertions !== local.planned) { local.notOk(`expected assertions to be as planned (planned="${local.planned}", assertions="${local.assertions}")`); } } catch (error) { local.notOk(`timed out waiting for assertions to be as planned (planned="${local.planned}", assertions="${local.assertions}")`); } finally { cleanup instanceof Function && await cleanup(); } if (local.failed) { stats.failed = stats.failed + 1; } else { stats.passed = stats.passed + 1; } local.finished = true; local.logFlush(); }; const run = async (context, { concurrency } = { concurrency: Infinity }) => { const options = context.options; options.logger('TAP version 14'); context.concurrency = concurrency; context.stats = { passed: 0, failed: 0, ok: 0, notOk: 0, skipped: context.skipped.length, todo: context.todo.length }; context.running = 0; context.tests = context.only.length > 0 ? context.only : context.tests; context.remaining = context.tests.length; const promises = []; for (const test of context.tests) { context.running = context.running + 1; promises.push( runTest(context, test).then(() => { context.remaining = context.remaining - 1; context.running = context.running - 1; }) ); await waitUntil(() => context.running < concurrency); } await Promise.all(promises); options.logger(''); options.logger('1..' + (context.stats.ok + context.stats.notOk)); options.logger('# tests ' + (context.stats.ok + context.stats.notOk)); options.logger('# pass ' + (context.stats.ok)); options.logger('# fail ' + (context.stats.notOk)); options.logger('# skip ' + (context.stats.skipped)); options.logger('# todo ' + (context.stats.todo)); context.stats.success = context.stats.notOk === 0 && context.stats.failed === 0; return context.stats; }; export default function createTestSuite (options = {}) { const context = { options: { ...defaultOptions, ...options }, tests: [], only: [], skipped: [], todo: [] }; const test = (name, fn) => { const sourcePath = getSourcePath(); context.tests.push({ name, fn, sourcePath }); }; test.skip = (name, fn) => { const sourcePath = getSourcePath(); context.skipped.push({ name, fn, sourcePath }); }; test.todo = (name, fn) => { const sourcePath = getSourcePath(); context.todo.push({ name, fn, sourcePath }); }; test.only = (name, fn) => { const sourcePath = getSourcePath(); context.only.push({ name, fn, sourcePath }); }; return { test, run: run.bind(null, context), context }; }