From 6a93d47baa9b256a4ce57c6d034e7ec04b75f58c Mon Sep 17 00:00:00 2001
From: Michal Dorner <dorner.michal@gmail.com>
Date: Sun, 14 Mar 2021 22:57:57 +0100
Subject: [PATCH 1/2] Add output option to JSON reporter (#4131)

---
 docs/index.md               |   2 +
 lib/reporters/json.js       |  20 ++-
 test/reporters/json.spec.js | 254 ++++++++++++++++++++++--------------
 3 files changed, 178 insertions(+), 98 deletions(-)

diff --git a/docs/index.md b/docs/index.md
index 7300faf9e9..94fbe29089 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1933,6 +1933,8 @@ Alias: `JSON`, `json`
 
 The JSON reporter outputs a single large JSON object when the tests have completed (failures or not).
 
+By default, it will output to the console. To write directly to a file, use `--reporter-option output=filename.json`.
+
 ![json reporter](images/reporter-json.png?withoutEnlargement&resize=920,9999){:class="screenshot" loading="lazy"}
 
 ### JSON Stream
diff --git a/lib/reporters/json.js b/lib/reporters/json.js
index a314cd3805..ead181b4e8 100644
--- a/lib/reporters/json.js
+++ b/lib/reporters/json.js
@@ -7,6 +7,10 @@
  */
 
 var Base = require('./base');
+var fs = require('fs');
+var path = require('path');
+var errors = require('../errors');
+var createUnsupportedError = errors.createUnsupportedError;
 var constants = require('../runner').constants;
 var EVENT_TEST_PASS = constants.EVENT_TEST_PASS;
 var EVENT_TEST_FAIL = constants.EVENT_TEST_FAIL;
@@ -38,6 +42,14 @@ function JSONReporter(runner, options) {
   var pending = [];
   var failures = [];
   var passes = [];
+  var output;
+
+  if (options && options.reporterOptions && options.reporterOptions.output) {
+    if (!fs || !fs.writeFileSync) {
+      throw createUnsupportedError('file output not supported in browser');
+    }
+    output = options.reporterOptions.output;
+  }
 
   runner.on(EVENT_TEST_END, function(test) {
     tests.push(test);
@@ -66,7 +78,13 @@ function JSONReporter(runner, options) {
 
     runner.testResults = obj;
 
-    process.stdout.write(JSON.stringify(obj, null, 2));
+    var json = JSON.stringify(obj, null, 2);
+    if (output) {
+      fs.mkdirSync(path.dirname(output), {recursive: true});
+      fs.writeFileSync(output, json);
+    } else {
+      process.stdout.write(json);
+    }
   });
 }
 
diff --git a/test/reporters/json.spec.js b/test/reporters/json.spec.js
index 3eca1e9175..97f9019732 100644
--- a/test/reporters/json.spec.js
+++ b/test/reporters/json.spec.js
@@ -1,12 +1,15 @@
 'use strict';
 
+var fs = require('fs');
 var sinon = require('sinon');
+var JSONReporter = require('../../lib/reporters/json');
 var Mocha = require('../../');
 var Suite = Mocha.Suite;
 var Runner = Mocha.Runner;
 var Test = Mocha.Test;
 
 describe('JSON reporter', function() {
+  var mocha;
   var suite;
   var runner;
   var testTitle = 'json test 1';
@@ -14,131 +17,188 @@ describe('JSON reporter', function() {
   var noop = function() {};
 
   beforeEach(function() {
-    var mocha = new Mocha({
+    mocha = new Mocha({
       reporter: 'json'
     });
     suite = new Suite('JSON suite', 'root');
     runner = new Runner(suite);
-    var options = {};
-    /* eslint no-unused-vars: off */
-    var mochaReporter = new mocha._reporter(runner, options);
-  });
-
-  beforeEach(function() {
-    sinon.stub(process.stdout, 'write').callsFake(noop);
   });
 
   afterEach(function() {
     sinon.restore();
   });
 
-  it('should have 1 test failure', function(done) {
-    var error = {message: 'oh shit'};
+  describe('test results', function() {
+    beforeEach(function() {
+      var options = {};
+      /* eslint no-unused-vars: off */
+      var mochaReporter = new mocha._reporter(runner, options);
+    });
 
-    var test = new Test(testTitle, function(done) {
-      done(new Error(error.message));
+    beforeEach(function() {
+      sinon.stub(process.stdout, 'write').callsFake(noop);
     });
 
-    test.file = testFile;
-    suite.addTest(test);
-
-    runner.run(function(failureCount) {
-      sinon.restore();
-      expect(runner, 'to satisfy', {
-        testResults: {
-          failures: [
-            {
-              title: testTitle,
-              file: testFile,
-              err: {
-                message: error.message
+    it('should have 1 test failure', function(done) {
+      var error = {message: 'oh shit'};
+
+      var test = new Test(testTitle, function(done) {
+        done(new Error(error.message));
+      });
+
+      test.file = testFile;
+      suite.addTest(test);
+
+      runner.run(function(failureCount) {
+        sinon.restore();
+        expect(runner, 'to satisfy', {
+          testResults: {
+            failures: [
+              {
+                title: testTitle,
+                file: testFile,
+                err: {
+                  message: error.message
+                }
               }
-            }
-          ]
-        }
+            ]
+          }
+        });
+        expect(failureCount, 'to be', 1);
+        done();
       });
-      expect(failureCount, 'to be', 1);
-      done();
     });
-  });
 
-  it('should have 1 test pending', function(done) {
-    var test = new Test(testTitle);
-    test.file = testFile;
-    suite.addTest(test);
-
-    runner.run(function(failureCount) {
-      sinon.restore();
-      expect(runner, 'to satisfy', {
-        testResults: {
-          pending: [
-            {
-              title: testTitle,
-              file: testFile
-            }
-          ]
-        }
+    it('should have 1 test pending', function(done) {
+      var test = new Test(testTitle);
+      test.file = testFile;
+      suite.addTest(test);
+
+      runner.run(function(failureCount) {
+        sinon.restore();
+        expect(runner, 'to satisfy', {
+          testResults: {
+            pending: [
+              {
+                title: testTitle,
+                file: testFile
+              }
+            ]
+          }
+        });
+        expect(failureCount, 'to be', 0);
+        done();
       });
-      expect(failureCount, 'to be', 0);
-      done();
     });
-  });
 
-  it('should have 1 test pass', function(done) {
-    const test = new Test(testTitle, () => {});
-
-    test.file = testFile;
-    suite.addTest(test);
-
-    runner.run(function(failureCount) {
-      expect(runner, 'to satisfy', {
-        testResults: {
-          passes: [
-            {
-              title: testTitle,
-              file: testFile,
-              speed: /(slow|medium|fast)/
-            }
-          ]
-        }
+    it('should have 1 test pass', function(done) {
+      const test = new Test(testTitle, () => {});
+
+      test.file = testFile;
+      suite.addTest(test);
+
+      runner.run(function(failureCount) {
+        expect(runner, 'to satisfy', {
+          testResults: {
+            passes: [
+              {
+                title: testTitle,
+                file: testFile,
+                speed: /(slow|medium|fast)/
+              }
+            ]
+          }
+        });
+        expect(failureCount, 'to be', 0);
+        done();
+      });
+    });
+
+    it('should handle circular objects in errors', function(done) {
+      var testTitle = 'json test 1';
+      function CircleError() {
+        this.message = 'oh shit';
+        this.circular = this;
+      }
+      var error = new CircleError();
+
+      var test = new Test(testTitle, function(done) {
+        throw error;
+      });
+
+      test.file = testFile;
+      suite.addTest(test);
+
+      runner.run(function(failureCount) {
+        sinon.restore();
+        expect(runner, 'to satisfy', {
+          testResults: {
+            failures: [
+              {
+                title: testTitle,
+                file: testFile,
+                err: {
+                  message: error.message
+                }
+              }
+            ]
+          }
+        });
+        expect(failureCount, 'to be', 1);
+        done();
       });
-      expect(failureCount, 'to be', 0);
-      done();
     });
   });
 
-  it('should handle circular objects in errors', function(done) {
-    var testTitle = 'json test 1';
-    function CircleError() {
-      this.message = 'oh shit';
-      this.circular = this;
-    }
-    var error = new CircleError();
+  describe("when 'reporterOptions.output' is provided", function() {
+    var expectedDirName = 'reports';
+    var expectedFileName = 'reports/test-results.json';
+    var options = {
+      reporterOptions: {
+        output: expectedFileName
+      }
+    };
+
+    beforeEach(function() {
+      /* eslint no-unused-vars: off */
+      var mochaReporter = new mocha._reporter(runner, options);
+    });
 
-    var test = new Test(testTitle, function(done) {
-      throw error;
+    beforeEach(function() {
+      // Add one test to suite to avoid assertions against empty test results
+      var test = new Test(testTitle, () => {});
+      test.file = testFile;
+      suite.addTest(test);
     });
 
-    test.file = testFile;
-    suite.addTest(test);
-
-    runner.run(function(failureCount) {
-      sinon.restore();
-      expect(runner, 'to satisfy', {
-        testResults: {
-          failures: [
-            {
-              title: testTitle,
-              file: testFile,
-              err: {
-                message: error.message
-              }
-            }
-          ]
-        }
+    describe('when file can be created', function() {
+      it('should write test results to file', function(done) {
+        var fsMkdirSync = sinon.stub(fs, 'mkdirSync');
+        var fsWriteFileSync = sinon.stub(fs, 'writeFileSync');
+
+        fsWriteFileSync.callsFake(function(filename, content) {
+          var expectedJson = JSON.stringify(runner.testResults, null, 2);
+          expect(expectedFileName, 'to be', filename);
+          expect(content, 'to be', expectedJson);
+        });
+
+        runner.run(function() {
+          fsMkdirSync.calledWith(expectedDirName, {recursive: true});
+          expect(fsWriteFileSync.calledOnce, 'to be true');
+          done();
+        });
+      });
+    });
+
+    describe('when run in browser', function() {
+      it('should throw unsupported error', function() {
+        sinon.stub(fs, 'writeFileSync').value(false);
+        expect(
+          () => new JSONReporter(runner, options),
+          'to throw',
+          'file output not supported in browser'
+        );
       });
-      expect(failureCount, 'to be', 1);
-      done();
     });
   });
 });

From eff4daaddb44295895b5debca511d25b3c7fe6b0 Mon Sep 17 00:00:00 2001
From: juergba <filodron@gmail.com>
Date: Tue, 17 Aug 2021 20:14:31 +0200
Subject: [PATCH 2/2] try/catch plus test

---
 example/config/.mocharc.js  |  2 +-
 example/config/.mocharc.yml |  2 +-
 lib/reporters/base.js       |  2 +-
 lib/reporters/json.js       | 25 ++++++++-----
 test/reporters/json.spec.js | 73 ++++++++++++++++++++++++++-----------
 5 files changed, 70 insertions(+), 34 deletions(-)

diff --git a/example/config/.mocharc.js b/example/config/.mocharc.js
index b730b4d399..e40bea1b71 100644
--- a/example/config/.mocharc.js
+++ b/example/config/.mocharc.js
@@ -33,7 +33,7 @@ module.exports = {
   parallel: false,
   recursive: false,
   reporter: 'spec',
-  'reporter-option': ['foo=bar', 'baz=quux'],
+  'reporter-option': ['foo=bar', 'baz=quux'], // array, not object
   require: '@babel/register',
   retries: 1,
   slow: '75',
diff --git a/example/config/.mocharc.yml b/example/config/.mocharc.yml
index 456b351332..e132b7a99d 100644
--- a/example/config/.mocharc.yml
+++ b/example/config/.mocharc.yml
@@ -35,7 +35,7 @@ package: './package.json'
 parallel: false
 recursive: false
 reporter: 'spec'
-reporter-option:
+reporter-option: # array, not object
   - 'foo=bar'
   - 'baz=quux'
 require: '@babel/register'
diff --git a/lib/reporters/base.js b/lib/reporters/base.js
index 20d1ccec11..f2f2bc65fc 100644
--- a/lib/reporters/base.js
+++ b/lib/reporters/base.js
@@ -90,7 +90,7 @@ exports.colors = {
 
 exports.symbols = {
   ok: symbols.success,
-  err: symbols.err,
+  err: symbols.error,
   dot: '.',
   comma: ',',
   bang: '!'
diff --git a/lib/reporters/json.js b/lib/reporters/json.js
index ead181b4e8..05e6269361 100644
--- a/lib/reporters/json.js
+++ b/lib/reporters/json.js
@@ -9,14 +9,14 @@
 var Base = require('./base');
 var fs = require('fs');
 var path = require('path');
-var errors = require('../errors');
-var createUnsupportedError = errors.createUnsupportedError;
+const createUnsupportedError = require('../errors').createUnsupportedError;
+const utils = require('../utils');
 var constants = require('../runner').constants;
 var EVENT_TEST_PASS = constants.EVENT_TEST_PASS;
+var EVENT_TEST_PENDING = constants.EVENT_TEST_PENDING;
 var EVENT_TEST_FAIL = constants.EVENT_TEST_FAIL;
 var EVENT_TEST_END = constants.EVENT_TEST_END;
 var EVENT_RUN_END = constants.EVENT_RUN_END;
-var EVENT_TEST_PENDING = constants.EVENT_TEST_PENDING;
 
 /**
  * Expose `JSON`.
@@ -34,7 +34,7 @@ exports = module.exports = JSONReporter;
  * @param {Runner} runner - Instance triggers reporter actions.
  * @param {Object} [options] - runner options
  */
-function JSONReporter(runner, options) {
+function JSONReporter(runner, options = {}) {
   Base.call(this, runner, options);
 
   var self = this;
@@ -44,11 +44,11 @@ function JSONReporter(runner, options) {
   var passes = [];
   var output;
 
-  if (options && options.reporterOptions && options.reporterOptions.output) {
-    if (!fs || !fs.writeFileSync) {
+  if (options.reporterOption && options.reporterOption.output) {
+    if (utils.isBrowser()) {
       throw createUnsupportedError('file output not supported in browser');
     }
-    output = options.reporterOptions.output;
+    output = options.reporterOption.output;
   }
 
   runner.on(EVENT_TEST_END, function(test) {
@@ -80,8 +80,15 @@ function JSONReporter(runner, options) {
 
     var json = JSON.stringify(obj, null, 2);
     if (output) {
-      fs.mkdirSync(path.dirname(output), {recursive: true});
-      fs.writeFileSync(output, json);
+      try {
+        fs.mkdirSync(path.dirname(output), {recursive: true});
+        fs.writeFileSync(output, json);
+      } catch (err) {
+        console.error(
+          `${Base.symbols.err} [mocha] writing output to "${output}" failed: ${err.message}\n`
+        );
+        process.stdout.write(json);
+      }
     } else {
       process.stdout.write(json);
     }
diff --git a/test/reporters/json.spec.js b/test/reporters/json.spec.js
index 97f9019732..8a44bc4e2a 100644
--- a/test/reporters/json.spec.js
+++ b/test/reporters/json.spec.js
@@ -3,6 +3,7 @@
 var fs = require('fs');
 var sinon = require('sinon');
 var JSONReporter = require('../../lib/reporters/json');
+var utils = require('../../lib/utils');
 var Mocha = require('../../');
 var Suite = Mocha.Suite;
 var Runner = Mocha.Runner;
@@ -98,6 +99,7 @@ describe('JSON reporter', function() {
       suite.addTest(test);
 
       runner.run(function(failureCount) {
+        sinon.restore();
         expect(runner, 'to satisfy', {
           testResults: {
             passes: [
@@ -150,11 +152,11 @@ describe('JSON reporter', function() {
     });
   });
 
-  describe("when 'reporterOptions.output' is provided", function() {
+  describe('when "reporterOption.output" is provided', function() {
     var expectedDirName = 'reports';
     var expectedFileName = 'reports/test-results.json';
     var options = {
-      reporterOptions: {
+      reporterOption: {
         output: expectedFileName
       }
     };
@@ -171,34 +173,61 @@ describe('JSON reporter', function() {
       suite.addTest(test);
     });
 
-    describe('when file can be created', function() {
-      it('should write test results to file', function(done) {
-        var fsMkdirSync = sinon.stub(fs, 'mkdirSync');
-        var fsWriteFileSync = sinon.stub(fs, 'writeFileSync');
+    it('should write test results to file', function(done) {
+      const fsMkdirSync = sinon.stub(fs, 'mkdirSync');
+      const fsWriteFileSync = sinon.stub(fs, 'writeFileSync');
 
-        fsWriteFileSync.callsFake(function(filename, content) {
-          var expectedJson = JSON.stringify(runner.testResults, null, 2);
-          expect(expectedFileName, 'to be', filename);
-          expect(content, 'to be', expectedJson);
-        });
+      fsWriteFileSync.callsFake(function(filename, content) {
+        const expectedJson = JSON.stringify(runner.testResults, null, 2);
+        expect(expectedFileName, 'to be', filename);
+        expect(content, 'to be', expectedJson);
+      });
 
-        runner.run(function() {
-          fsMkdirSync.calledWith(expectedDirName, {recursive: true});
-          expect(fsWriteFileSync.calledOnce, 'to be true');
-          done();
-        });
+      runner.run(function() {
+        expect(
+          fsMkdirSync.calledWith(expectedDirName, {recursive: true}),
+          'to be true'
+        );
+        expect(fsWriteFileSync.calledOnce, 'to be true');
+        done();
       });
     });
 
-    describe('when run in browser', function() {
-      it('should throw unsupported error', function() {
-        sinon.stub(fs, 'writeFileSync').value(false);
+    it('should warn and write test results to console', function(done) {
+      const fsMkdirSync = sinon.stub(fs, 'mkdirSync');
+      const fsWriteFileSync = sinon.stub(fs, 'writeFileSync');
+
+      fsWriteFileSync.throws('unable to write file');
+
+      const outLog = [];
+      const fake = chunk => outLog.push(chunk);
+      sinon.stub(process.stderr, 'write').callsFake(fake);
+      sinon.stub(process.stdout, 'write').callsFake(fake);
+
+      runner.run(function() {
+        sinon.restore();
+        expect(
+          fsMkdirSync.calledWith(expectedDirName, {recursive: true}),
+          'to be true'
+        );
+        expect(fsWriteFileSync.calledOnce, 'to be true');
         expect(
-          () => new JSONReporter(runner, options),
-          'to throw',
-          'file output not supported in browser'
+          outLog[0],
+          'to contain',
+          `[mocha] writing output to "${expectedFileName}" failed:`
         );
+        expect(outLog[1], 'to match', /"fullTitle": "JSON suite json test 1"/);
+        done();
       });
     });
+
+    it('should throw "unsupported error" in browser', function() {
+      sinon.stub(utils, 'isBrowser').callsFake(() => true);
+      expect(
+        () => new JSONReporter(runner, options),
+        'to throw',
+        'file output not supported in browser'
+      );
+    });
   });
 });