import os from 'node:os'; import path from 'node:path'; import indentString from 'indent-string'; import plur from 'plur'; import stripAnsi from 'strip-ansi'; import * as supertap from 'supertap'; import prefixTitle from './prefix-title.js'; function dumpError({ assertion, formattedDetails, formattedError, name, originalError, // A structured clone, so some details are missing. stack, type, }, sanitizeStackOutput) { if (type === 'unknown') { return { message: 'Non-native error', formatted: stripAnsi(formattedError), }; } originalError.name = name; // Restore the original name. if (type === 'ava') { if (assertion) { originalError.assertion = assertion; } if (formattedDetails.length > 0) { originalError.details = Object.fromEntries(formattedDetails.map(({label, formatted}) => [ stripAnsi(label), stripAnsi(formatted), ])); } } originalError.stack = sanitizeStackOutput?.(stack || originalError.stack) ?? (stack || originalError.stack); return originalError; } export default class TapReporter { constructor(options) { this.i = 0; this.extensions = options.extensions; this.stdStream = options.stdStream; this.reportStream = options.reportStream; this.sanitizeStackOutput = options.sanitizeStackOutput; this.crashCount = 0; this.filesWithMissingAvaImports = new Set(); this.prefixTitle = (testFile, title) => title; this.relativeFile = file => path.relative(options.projectDir, file); this.stats = null; } startRun(plan) { if (plan.files.length > 1) { this.prefixTitle = (testFile, title) => prefixTitle(this.extensions, plan.filePathPrefix, testFile, title); } plan.status.on('stateChange', evt => this.consumeStateChange(evt)); this.reportStream.write(supertap.start() + os.EOL); } endRun() { if (this.stats) { this.reportStream.write(supertap.finish({ crashed: this.crashCount, failed: this.stats.failedTests + this.stats.remainingTests, passed: this.stats.passedTests + this.stats.passedKnownFailingTests, skipped: this.stats.skippedTests, todo: this.stats.todoTests, }) + os.EOL); if (this.stats.parallelRuns) { const {currentFileCount, currentIndex, totalRuns} = this.stats.parallelRuns; this.reportStream.write(`# Ran ${currentFileCount} test ${plur('file', currentFileCount)} out of ${this.stats.files} for job ${currentIndex + 1} of ${totalRuns}` + os.EOL + os.EOL); } } else { this.reportStream.write(supertap.finish({ crashed: this.crashCount, failed: 0, passed: 0, skipped: 0, todo: 0, }) + os.EOL); } } writeTest(evt, flags) { this.reportStream.write(supertap.test(this.prefixTitle(evt.testFile, evt.title), { comment: evt.logs, error: evt.err ? dumpError(evt.err, this.sanitizeStackOutput) : null, index: ++this.i, passed: flags.passed, skip: flags.skip, todo: flags.todo, }) + os.EOL); } writeCrash(evt, title) { this.crashCount++; this.reportStream.write(supertap.test(title ?? evt.err.stack?.split('\n')[0].trim() ?? evt.err.message ?? evt.type, { comment: evt.logs, error: evt.err ? dumpError(evt.err, this.sanitizeStackOutput) : null, index: ++this.i, passed: false, skip: false, todo: false, }) + os.EOL); } writeComment(evt, {title = this.prefixTitle(evt.testFile, evt.title)}) { this.reportStream.write(`# ${stripAnsi(title)}${os.EOL}`); if (evt.logs) { for (const log of evt.logs) { const logLines = indentString(log, 4).replaceAll(/^ {4}/gm, '# '); this.reportStream.write(`${logLines}${os.EOL}`); } } } writeProcessExit(evt) { const error = new Error(`Exiting due to process.exit() when running ${this.relativeFile(evt.testFile)}`); error.stack = evt.stack; for (const [testFile, tests] of evt.pendingTests) { for (const title of tests) { this.writeTest({testFile, title, err: error}, {passed: false, todo: false, skip: false}); } } } writeTimeout(evt) { const error = new Error(`Exited because no new tests completed within the last ${evt.period}ms of inactivity`); for (const [testFile, tests] of evt.pendingTests) { for (const title of tests) { this.writeTest({testFile, title, err: error}, {passed: false, todo: false, skip: false}); } } } consumeStateChange(evt) { // eslint-disable-line complexity const fileStats = this.stats && evt.testFile ? this.stats.byFile.get(evt.testFile) : null; switch (evt.type) { case 'declared-test': { // Ignore break; } case 'hook-failed': { this.writeTest(evt, {passed: false, todo: false, skip: false}); break; } case 'hook-finished': { this.writeComment(evt, {}); break; } case 'internal-error': { this.writeCrash(evt); break; } case 'missing-ava-import': { this.filesWithMissingAvaImports.add(evt.testFile); this.writeCrash(evt, `No tests found in ${this.relativeFile(evt.testFile)}, make sure to import "ava" at the top of your test file`); break; } case 'process-exit': { this.writeProcessExit(evt); break; } case 'selected-test': { if (evt.skip) { this.writeTest(evt, {passed: true, todo: false, skip: true}); } else if (evt.todo) { this.writeTest(evt, {passed: false, todo: true, skip: false}); } break; } case 'stats': { this.stats = evt.stats; break; } case 'test-failed': { this.writeTest(evt, {passed: false, todo: false, skip: false}); break; } case 'test-passed': { this.writeTest(evt, {passed: true, todo: false, skip: false}); break; } case 'timeout': { this.writeTimeout(evt); break; } case 'uncaught-exception': { this.writeCrash(evt); break; } case 'unhandled-rejection': { this.writeCrash(evt); break; } case 'worker-failed': { if (!this.filesWithMissingAvaImports.has(evt.testFile)) { if (evt.nonZeroExitCode) { this.writeCrash(evt, `${this.relativeFile(evt.testFile)} exited with a non-zero exit code: ${evt.nonZeroExitCode}`); } else { this.writeCrash(evt, `${this.relativeFile(evt.testFile)} exited due to ${evt.signal}`); } } break; } case 'worker-finished': { if (!evt.forcedExit && !this.filesWithMissingAvaImports.has(evt.testFile)) { if (fileStats.declaredTests === 0) { this.writeCrash(evt, `No tests found in ${this.relativeFile(evt.testFile)}`); } else if (!this.failFastEnabled && fileStats.remainingTests > 0) { this.writeComment(evt, {title: `${fileStats.remainingTests} ${plur('test', fileStats.remainingTests)} remaining in ${this.relativeFile(evt.testFile)}`}); } } break; } case 'worker-stderr': case 'worker-stdout': { this.stdStream.write(evt.chunk); break; } default: { break; } } } }