🏡 index : quine-global/ava.git

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;
			}
		}
	}
}