• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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