UNPKG

@tapjs/core

Version:
235 lines 7.62 kB
import * as stack from '@tapjs/stack'; import { createTwoFilesPatch } from 'diff'; import { readFileSync } from 'node:fs'; import { format, strict } from 'tcompare'; import { extraFromError } from './extra-from-error.js'; const tryReadFile = (path) => { try { return readFileSync(path, 'utf8'); } catch (_) { return; } }; const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); /** * Prepare an object for printing to YAML diagnostics. * * Looks up source, calculates diffs of actual/expected values, and so on. */ export const cleanYamlObject = (obj, seen = new Set()) => { seen.add(obj); const res = { ...obj }; seen.add(res); if (hasOwn(res, 'stack') && !hasOwn(res, 'at')) { const st = Array.isArray(res.stack) ? res.stack.map(s => String(s).trimEnd() + '\n').join('') : String(res.stack); const p = stack.parseStack(st); res.at = p[0]; res.stack = p.map(c => String(c) + '\n').join(''); } if (typeof res.stack === 'string' && res.stack && !res.stack.endsWith('\n')) { res.stack = res.stack.trimEnd() + '\n'; } /* c8 ignore start - old pre-Error.cause custom decorator */ if (res.errorOrigin && typeof res.errorOrigin === 'object') { res.errorOrigin = cleanYamlObject(res.errorOrigin, seen); } /* c8 ignore stop */ if (res.cause && typeof res.cause === 'object' && !seen.has(res.cause)) { seen.add(res.cause); const { message } = res.cause; const ex = extraFromError(res.cause); const clean = cleanYamlObject(ex, seen); if (message !== undefined) clean.message = message; res.cause = clean; } if (Array.isArray(res.errors)) { const cleaned = []; for (const e of res.errors) { if (!!e && typeof e === 'object') { seen.add(e); const { message } = e; const ex = extraFromError(e); const clean = cleanYamlObject(ex, seen); if (message !== undefined) clean.message = message; cleaned.push(clean); } else { cleaned.push(e); } } res.errors = cleaned; } if (res.at && res.at instanceof stack.CallSiteLike && res.at.fileName && res.at.absoluteFileName && res.at.lineNumber && !res.source) { const file = res.at.absoluteFileName; const content = tryReadFile(file); if (content) { const lines = content.split('\n'); if (res.at.lineNumber <= lines.length) { const startLine = Math.max(res.at.lineNumber - 3, 0); const endLine = Math.min(res.at.lineNumber + 2, lines.length); const line = lines[res.at.lineNumber - 1]; const caret = (res.at.columnNumber && line && res.at.columnNumber <= line.length) ? ['-'.repeat(res.at.columnNumber - 1) + '^'] : []; const context = lines .slice(startLine, res.at.lineNumber) .concat(caret) .concat(lines.slice(res.at.lineNumber, endLine)); const csplit = context.join('\n').trimEnd(); if (csplit) res.source = csplit + '\n'; } } } if (res.at && res.at instanceof stack.CallSiteLike) { res.at = res.at.toJSON(); } // show a line by line string diff // diff the yaml, to make it more humane, especially // when strings or buffers are very large or multi-line // the shipped compare methods will generally supply // their own diff, which is much nicer. if ('found' in res && 'wanted' in res && res.found !== res.wanted && !res.diff) { const f = res.found; const w = res.wanted; if (typeof f === 'string' && typeof w === 'string') res.diff = createTwoFilesPatch('expected', 'actual', w + '\n', f + '\n').replace(/^=+\n/, ''); else if (f && w && typeof f === 'object' && typeof w === 'object') { const s = strict(f, w); if (!s.match) { res.diff = s.diff; } else { res.note = 'object identities differ'; } } else { // some mixed stringly bits const ff = format(f); const fw = format(w); const fs = (typeof f === 'string' ? f : ff) + '\n'; const ws = (typeof w === 'string' ? w : fw) + '\n'; if (fw !== ff) { res.diff = createTwoFilesPatch('expected', 'actual', ws, fs).replace(/^=+\n/, ''); } } if (res.diff === '--- expected\n+++ actual\n') { delete res.diff; } if (res.diff) { delete res.found; delete res.wanted; } } for (const [key, value] of Object.entries(res)) { if (shouldDeleteKey(key, value)) { delete res[key]; } } // if the 'message' is a string, then we print it on the // test point, so no need to repeat in the diags if (typeof res.message === 'string') delete res.message; // worker: remove inline code if (res.eval === true && typeof res.filename === 'string' && res.filename.includes('\n')) { res.filename = '<inline code>'; } // always put cause at the end. if (res.cause) { const { cause, ...props } = res; return { ...props, cause }; } return res; }; /** * Properties that are *always* removed from the diagnostics, either because * they are internal (eg, `time`), overly noisy (eg, `parent`), or captured * elsewhere in the TAP output (eg, `skip`). */ export const deleteAlways = new Set([ 'todo', 'time', 'childId', 'cb', 'name', 'indent', 'skip', 'bail', 'diagnostic', 'buffered', 'parent', // only relevant if activated, a failedTodo or failedSkip will be added 'failSkip', 'failTodo', 'failOnly', // TODO: keys added by plugins, but referenced here // How can this list be adjusted by plugins? 'grep', 'grepInvert', 'only', 'saveFixture', 'env', ]); /** * Fields on this list are removed from YAML diagnostics if they are empty * (ie, falsey, empty array, or object with no keys) */ export const deleteIfEmpty = new Set([ 'at', 'stack', 'context', 'debug', // TODO: keys added by plugins, but referenced here // How can this list be adjusted by plugins? 'runOnly', 'compareOptions', ]); /** * Fields are removed from YAML diagnostics if they match any of these * patterns. */ export const deleteIfMatch = [ /^_?tapChild/, /^tapStream/, // TODO: create a @tapjs/mocha plugin /^tapMochaTest/, ]; const shouldDeleteKey = (key, value) => deleteAlways.has(key) || (deleteIfEmpty.has(key) && isEmpty(value)) || deleteIfMatch.some(r => r.test(key)); // return true if object is empty, including inherited properties const isEmpty = (obj) => { if (!obj) { return true; } if (typeof obj !== 'object') { return false; } if (Array.isArray(obj)) { return obj.length === 0; } for (const _ in obj) { return false; } return true; }; //# sourceMappingURL=clean-yaml-object.js.map