// @ts-check const Mocha = require("mocha"); const path = require("path"); const fs = require("fs"); const os = require("os"); const failingHookRegExp = /^(.*) "(before|after) (all|each)" hook$/; /** * .failed-tests reporter * * @typedef {Object} ReporterOptions * @property {string} [file] * @property {boolean} [keepFailed] * @property {string|Mocha.ReporterConstructor} [reporter] * @property {*} [reporterOptions] */ class FailedTestsReporter extends Mocha.reporters.Base { /** * @param {Mocha.Runner} runner * @param {{ reporterOptions?: ReporterOptions }} [options] */ constructor(runner, options) { super(runner, options); if (!runner) return; const reporterOptions = this.reporterOptions = options.reporterOptions || {}; if (reporterOptions.file === undefined) reporterOptions.file = ".failed-tests"; if (reporterOptions.keepFailed === undefined) reporterOptions.keepFailed = false; if (reporterOptions.reporter) { /** @type {Mocha.ReporterConstructor} */ let reporter; if (typeof reporterOptions.reporter === "function") { reporter = reporterOptions.reporter; } else if (Mocha.reporters[reporterOptions.reporter]) { reporter = Mocha.reporters[reporterOptions.reporter]; } else { try { reporter = require(reporterOptions.reporter); } catch (_) { reporter = require(path.resolve(process.cwd(), reporterOptions.reporter)); } } const newOptions = Object.assign({}, options, { reporterOptions: reporterOptions.reporterOptions || {} }); if (reporterOptions.reporter === "xunit") { newOptions.reporterOptions.output = "TEST-results.xml"; } this.reporter = new reporter(runner, newOptions); } /** @type {Mocha.Test[]} */ this.passes = []; /** @type {(Mocha.Test)[]} */ this.failures = []; runner.on("pass", test => this.passes.push(test)); runner.on("fail", test => this.failures.push(test)); } /** * @param {string} file * @param {ReadonlyArray} passes * @param {ReadonlyArray} failures * @param {boolean} keepFailed * @param {(err?: NodeJS.ErrnoException) => void} done */ static writeFailures(file, passes, failures, keepFailed, done) { const failingTests = new Set(fs.existsSync(file) ? readTests() : undefined); const possiblyPassingSuites = /**@type {Set}*/(new Set()); // Remove tests that are now passing and track suites that are now // possibly passing. if (failingTests.size > 0 && !keepFailed) { for (const test of passes) { failingTests.delete(test.fullTitle().trim()); possiblyPassingSuites.add(test.parent.fullTitle().trim()); } } // Add tests that are now failing. If a hook failed, track its // containing suite as failing. If the suite for a test or hook was // possibly passing then it is now definitely failing. for (const test of failures) { const suiteTitle = test.parent.fullTitle().trim(); if (test.type === "test") { failingTests.add(test.fullTitle().trim()); } else { failingTests.add(suiteTitle); } possiblyPassingSuites.delete(suiteTitle); } // Remove all definitely passing suites. for (const suite of possiblyPassingSuites) { failingTests.delete(suite); } if (failingTests.size > 0) { const failed = Array .from(failingTests) .sort() .join(os.EOL); fs.writeFile(file, failed, "utf8", done); } else if (!keepFailed && fs.existsSync(file)) { fs.unlink(file, done); } else { done(); } function readTests() { return fs.readFileSync(file, "utf8") .split(/\r?\n/g) .map(line => line.trim()) .filter(line => line.length > 0); } } /** * @param {number} failures * @param {(failures: number) => void} [fn] */ done(failures, fn) { FailedTestsReporter.writeFailures(this.reporterOptions.file, this.passes, this.failures, this.reporterOptions.keepFailed || this.stats.tests === 0, (err) => { const reporter = this.reporter; if (reporter && reporter.done) { reporter.done(failures, fn); } else if (fn) { fn(failures); } if (err) console.error(err); }); } } module.exports = FailedTestsReporter;