1const assert = require("assert"); 2const Mocha = require("mocha"); 3const path = require("path"); 4const fs = require("fs"); 5const os = require("os"); 6 7/** @typedef {{ 8 file?: string; 9 keepFailed?: boolean; 10 reporter?: Mocha.ReporterConstructor | keyof Mocha.reporters; 11 reporterOptions?: any; // TODO(jakebailey): what? 12}} ReporterOptions */ 13 14/** 15 * .failed-tests reporter 16 * 17 * @property {string} [file] 18 * @property {boolean} [keepFailed] 19 * @property {string|Mocha.ReporterConstructor} [reporter] 20 * @property {*} [reporterOptions] 21 */ 22class FailedTestsReporter extends Mocha.reporters.Base { 23 /** 24 * @param {Mocha.Runner} runner 25 * @param {{ reporterOptions?: ReporterOptions }} [options] 26 */ 27 constructor(runner, options) { 28 super(runner, options); 29 if (!runner) return; 30 31 const reporterOptions = this.reporterOptions = options?.reporterOptions || {}; 32 if (reporterOptions.file === undefined) reporterOptions.file = ".failed-tests"; 33 if (reporterOptions.keepFailed === undefined) reporterOptions.keepFailed = false; 34 if (reporterOptions.reporter) { 35 /** @type {Mocha.ReporterConstructor} */ 36 let reporter; 37 if (typeof reporterOptions.reporter === "function") { 38 reporter = reporterOptions.reporter; 39 } 40 else if (Mocha.reporters[reporterOptions.reporter]) { 41 reporter = Mocha.reporters[reporterOptions.reporter]; 42 } 43 else { 44 try { 45 reporter = require(reporterOptions.reporter); 46 } 47 catch (_) { 48 reporter = require(path.resolve(process.cwd(), reporterOptions.reporter)); 49 } 50 } 51 52 const newOptions = { ...options, reporterOptions: reporterOptions.reporterOptions || {} }; 53 if (reporterOptions.reporter === "xunit") { 54 newOptions.reporterOptions.output = "TEST-results.xml"; 55 } 56 this.reporter = new reporter(runner, newOptions); 57 } 58 59 /** @type {Mocha.Test[]} */ 60 this.passes = []; 61 62 /** @type {(Mocha.Test)[]} */ 63 this.failures = []; 64 65 runner.on("pass", test => this.passes.push(test)); 66 runner.on("fail", test => this.failures.push(test)); 67 } 68 69 /** 70 * @param {string} file 71 * @param {ReadonlyArray<Mocha.Test>} passes 72 * @param {ReadonlyArray<Mocha.Test | Mocha.Hook>} failures 73 * @param {boolean} keepFailed 74 * @param {(err?: NodeJS.ErrnoException | null) => void} done 75 */ 76 static writeFailures(file, passes, failures, keepFailed, done) { 77 const failingTests = new Set(fs.existsSync(file) ? readTests() : undefined); 78 const possiblyPassingSuites = /**@type {Set<string>}*/(new Set()); 79 80 // Remove tests that are now passing and track suites that are now 81 // possibly passing. 82 if (failingTests.size > 0 && !keepFailed) { 83 for (const test of passes) { 84 failingTests.delete(test.fullTitle().trim()); 85 assert(test.parent); 86 possiblyPassingSuites.add(test.parent.fullTitle().trim()); 87 } 88 } 89 90 // Add tests that are now failing. If a hook failed, track its 91 // containing suite as failing. If the suite for a test or hook was 92 // possibly passing then it is now definitely failing. 93 for (const test of failures) { 94 assert(test.parent); 95 const suiteTitle = test.parent.fullTitle().trim(); 96 if (test.type === "test") { 97 failingTests.add(test.fullTitle().trim()); 98 } 99 else { 100 failingTests.add(suiteTitle); 101 } 102 possiblyPassingSuites.delete(suiteTitle); 103 } 104 105 // Remove all definitely passing suites. 106 for (const suite of possiblyPassingSuites) { 107 failingTests.delete(suite); 108 } 109 110 if (failingTests.size > 0) { 111 const failed = Array 112 .from(failingTests) 113 .sort() 114 .join(os.EOL); 115 fs.writeFile(file, failed, "utf8", done); 116 } 117 else if (!keepFailed && fs.existsSync(file)) { 118 fs.unlink(file, done); 119 } 120 else { 121 done(); 122 } 123 124 function readTests() { 125 return fs.readFileSync(file, "utf8") 126 .split(/\r?\n/g) 127 .map(line => line.trim()) 128 .filter(line => line.length > 0); 129 } 130 } 131 132 /** 133 * @param {number} failures 134 * @param {(failures: number) => void} [fn] 135 * @override 136 */ 137 done(failures, fn) { 138 assert(this.reporterOptions); 139 assert(this.reporterOptions.file); 140 FailedTestsReporter.writeFailures(this.reporterOptions.file, this.passes, this.failures, this.reporterOptions.keepFailed || this.stats.tests === 0, (err) => { 141 const reporter = this.reporter; 142 if (reporter && reporter.done) { 143 reporter.done(failures, fn); 144 } 145 else if (fn) { 146 fn(failures); 147 } 148 149 if (err) console.error(err); 150 }); 151 } 152} 153 154module.exports = FailedTestsReporter; 155