Repository: wix/Detox (js)

Issue:
Drop support for old Jest versions (<27.2.5)
## Description

Jest 27.2.5 has officially the fix (https://github.com/facebook/jest/pull/11054) for the buffered log messages in `verbose` mode (however, buffering is expected behavior by default). Therefore, it's time to say goodbye to our StreamlineReporter which has been faithfully monkey-patching VerboseReporter for such a long time – it's unsafe to keep it around as it was a last-resort solution in the world of hundreds of moving parts.

That means we'll limit our range of supported Jest versions to the modern 27.x and all 28.x.

Pull request description:
feat: drop support for jest<27.2.5
## Description

Resolves #3394


Patch:
diff --git a/detox/package.json b/detox/package.json
index b7457058d..4fa17db25 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -44,7 +44,6 @@
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-unicorn": "^39.0.0",
     "jest": "^27.0.0",
-    "jest-environment-node": "^27.0.0",
     "mockdate": "^2.0.1",
     "prettier": "1.7.0",
     "react-native": "0.67.2",
@@ -80,7 +79,7 @@
     "yargs-unparser": "^2.0.0"
   },
   "peerDependencies": {
-    "jest": "26.0.x - 26.4.x || ^26.5.2 || 27.x.x || 28.x.x"
+    "jest": "28.x.x || ^27.2.5"
   },
   "peerDependenciesMeta": {
     "jest": {
@@ -99,6 +98,7 @@
     "roots": [
       "node_modules",
       "src",
+      "runners",
       "local-cli"
     ],
     "testPathIgnorePatterns": [
@@ -146,7 +146,8 @@
       "src/utils/logger.js",
       "src/utils/pipeCommands.js",
       "src/utils/pressAnyKey.js",
-      "src/utils/shellUtils.js"
+      "src/utils/shellUtils.js",
+      "runners/jest-circus/utils/assertJestCircus27.js"
     ],
     "resetMocks": true,
     "resetModules": true,
diff --git a/detox/runners/jest-circus/environment.js b/detox/runners/jest-circus/environment.js
index 6eb9ad761..5f4cd0268 100644
--- a/detox/runners/jest-circus/environment.js
+++ b/detox/runners/jest-circus/environment.js
@@ -1,5 +1,5 @@
 // @ts-nocheck
-const maybeNodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-unpublished-require
+const maybeNodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-extraneous-require
 const NodeEnvironment = maybeNodeEnvironment.default || maybeNodeEnvironment;
 
 const DetoxError = require('../../src/errors/DetoxError');
@@ -8,7 +8,7 @@ const Timer = require('../../src/utils/Timer');
 const DetoxCoreListener = require('./listeners/DetoxCoreListener');
 const DetoxInitErrorListener = require('./listeners/DetoxInitErrorListener');
 const assertExistingContext = require('./utils/assertExistingContext');
-const assertJestCircus26 = require('./utils/assertJestCircus26');
+const { assertJestCircus27 } = require('./utils/assertJestCircus27');
 const wrapErrorWithNoopLifecycle = require('./utils/wrapErrorWithNoopLifecycle');
 
 const SYNC_CIRCUS_EVENTS = new Set([
@@ -24,7 +24,7 @@ const SYNC_CIRCUS_EVENTS = new Set([
  */
 class DetoxCircusEnvironment extends NodeEnvironment {
   constructor(config, context) {
-    super(assertJestCircus26(config), assertExistingContext(context));
+    super(assertJestCircus27(config), assertExistingContext(context));
 
     /** @private */
     this._timer = null;
diff --git a/detox/runners/jest-circus/reporters/DetoxReporter.js b/detox/runners/jest-circus/reporters/DetoxReporter.js
index 2e7de9c12..c080fb0c6 100644
--- a/detox/runners/jest-circus/reporters/DetoxReporter.js
+++ b/detox/runners/jest-circus/reporters/DetoxReporter.js
@@ -1,13 +1,15 @@
-const DetoxStreamlineJestReporter = require('./DetoxStreamlineJestReporter');
+const { VerboseReporter: JestVerboseReporter } = require('@jest/reporters'); // eslint-disable-line node/no-extraneous-require
+
 const FailingTestsReporter = require('./FailingTestsReporter');
 
-class DetoxReporter extends DetoxStreamlineJestReporter {
+class DetoxReporter extends JestVerboseReporter {
   constructor(globalConfig) {
     super(globalConfig);
     this._failingTestsReporter = new FailingTestsReporter();
   }
 
   async onRunComplete(contexts, results) {
+    // @ts-ignore
     await super.onRunComplete(contexts, results);
     await this._failingTestsReporter.onRunComplete(contexts, results);
   }
diff --git a/detox/runners/jest-circus/reporters/DetoxStreamlineJestReporter.js b/detox/runners/jest-circus/reporters/DetoxStreamlineJestReporter.js
deleted file mode 100644
index e31f22da6..000000000
--- a/detox/runners/jest-circus/reporters/DetoxStreamlineJestReporter.js
+++ /dev/null
@@ -1,97 +0,0 @@
-const { VerboseReporter: JestVerboseReporter } = require('@jest/reporters'); // eslint-disable-line node/no-extraneous-require
-
-const DetoxRuntimeError = require('../../../src/errors/DetoxRuntimeError');
-
-class DetoxStreamlineJestReporter extends JestVerboseReporter {
-
-  constructor(globalConfig) {
-    super(globalConfig);
-    this._assertConfig();
-  }
-
-  /**
-   * Monkey patch for _wrapStdio method of Jest's DefaultReporter class
-   * https://github.com/facebook/jest/blob/84466b7bb187d33ffd336bd9fc76111bba511fe6/packages/jest-reporters/src/default_reporter.ts#L47
-   *
-   * The official implementation does the following:
-   * - For the <b>stderr</b> stream, it overrides the 'write' method with a simple bulked output mechanism,
-   *   which aggregates output onto a buffer but flushes it immediately.
-   * - For the <b>stdout</b> stream, it overrides the 'write' method with a time-based bulked output mechanism,
-   *   which aggregates output onto a buffer and flushes only in 100ms intervals.
-   *
-   * This gives priority, to a certain extent, to stderr output, over stdout.
-   * See: https://github.com/facebook/jest/blob/84466b7bb187d33ffd336bd9fc76111bba511fe6/packages/jest-reporters/src/default_reporter.ts#L73
-   *
-   * Typically, user logs are sent to stdout, and Jest reporter's (e.g. test-suite summary) - to stderr.
-   *
-   * ---
-   * Our goal is to have these 3 types of output streamlined in real time:
-   *
-   * 1. Jest suite-level lifecycle logging, typically done by the super-class' impl.
-   *    Note: Jest does not notify spec-level events to reporters.
-   * 2. Jest Circus real-time, spec-level lifecycle logging.
-   * 3. User in-test logging (e.g. for debugging).
-   *
-   * It's easy to see that this cannot be done while stderr and stdout are not of equal priority.
-   * Therefore, this hack enforces immediate-flushing approach to <b>both</b> stderr and stdout.
-   */
-  _wrapStdio(stream) {
-    const originalWrite = stream.write;
-    let buffer = [];
-
-    const flushBufferedOutput = () => {
-      const string = buffer.join('');
-      buffer = []; // This is to avoid conflicts between random output and status text
-
-      this._clearStatus();
-
-      if (string) {
-        originalWrite.call(stream, string);
-      }
-
-      this._printStatus();
-
-      this._bufferedOutput.delete(flushBufferedOutput);
-    };
-
-    this._bufferedOutput.add(flushBufferedOutput);
-
-    stream.write = chunk => {
-      buffer.push(chunk);
-      flushBufferedOutput();
-      return true;
-    };
-  }
-
-  _assertConfig() {
-    if (!this._isVerboseEnabled()) {
-      // Non-verbose mode makes Jest swizzle 'console' with a buffered output impl, which prevents
-      // from showing logs (from the user and Detox) in real time.
-      throw new DetoxRuntimeError({
-        message: 'Cannot run properly unless Jest is in verbose mode',
-        hint: 'See https://jestjs.io/docs/en/configuration#verbose-boolean for more details',
-      });
-    }
-
-    if (this._hasDefaultReporter()) {
-      // This class overrides Jest's VerboseReporter, which is set by default. Can't have both.
-      throw new DetoxRuntimeError({
-        message: 'Cannot work alongside the default Jest reporter. Please remove it from the reporters list.',
-        hint: 'See https://jestjs.io/docs/en/configuration#reporters-array-modulename-modulename-options for more details',
-      });
-    }
-  }
-
-  _isVerboseEnabled() {
-    return !!this._globalConfig.verbose;
-  }
-
-  _hasDefaultReporter() {
-    return !!this._globalConfig.reporters.find(reporterDef => {
-      const [reporterName] = reporterDef;
-      return reporterName === 'default';
-    });
-  }
-}
-
-module.exports = DetoxStreamlineJestReporter;
diff --git a/detox/runners/jest-circus/utils/assertJestCircus26.js b/detox/runners/jest-circus/utils/assertJestCircus26.js
deleted file mode 100644
index cac5aa2dc..000000000
--- a/detox/runners/jest-circus/utils/assertJestCircus26.js
+++ /dev/null
@@ -1,39 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-
-const DetoxRuntimeError = require('../../../src/errors/DetoxRuntimeError');
-
-function assertJestCircus26(maybeProjectConfig) {
-  const projectConfig = maybeProjectConfig.projectConfig || maybeProjectConfig;
-
-  if (!/jest-circus/.test(projectConfig.testRunner)) {
-    throw new DetoxRuntimeError('Cannot run tests without "jest-circus" npm package, exiting.');
-  }
-
-  const circusPackageJson = path.join(path.dirname(projectConfig.testRunner), 'package.json');
-  if (!fs.existsSync(circusPackageJson)) {
-    throw new DetoxRuntimeError('Check that you have an installed copy of "jest-circus" npm package, exiting.');
-  }
-
-  const circusVersion = require(circusPackageJson).version || '';
-  const [major, minor, patch] = circusVersion.split('.');
-  if (major < 26) {
-    throw new DetoxRuntimeError(
-      `Cannot use older versions of "jest-circus", exiting.\n` +
-      `You have jest-circus@${circusVersion}. Update to ^26.0.0 or newer.`
-    );
-  }
-
-  if (major == 26 && minor == 5 && patch < 2) {
-    throw new DetoxRuntimeError(
-      `You have jest-circus@${circusVersion} currently installed.\n` +
-      `Unfortunately, it is incompatible with Detox due to this critical issue:\n\n` +
-      `https://github.com/wix/Detox/issues/2390\n\n` +
-      `Please update to jest-circus@^26.5.2 (or newer) to proceed.`
-    );
-  }
-
-  return maybeProjectConfig;
-}
-
-module.exports = assertJestCircus26;
diff --git a/detox/runners/jest-circus/utils/assertJestCircus27.js b/detox/runners/jest-circus/utils/assertJestCircus27.js
new file mode 100644
index 000000000..a5b60a5d2
--- /dev/null
+++ b/detox/runners/jest-circus/utils/assertJestCircus27.js
@@ -0,0 +1,56 @@
+const fs = require('fs');
+const path = require('path');
+
+const semver = require('semver');
+
+const detoxPackageJson = require('../../../package.json');
+const DetoxRuntimeError = require('../../../src/errors/DetoxRuntimeError');
+
+function assertJestCircus27(maybeProjectConfig) {
+  const projectConfig = maybeProjectConfig.projectConfig || maybeProjectConfig;
+
+  if (!/jest-circus/.test(projectConfig.testRunner)) {
+    throw new DetoxRuntimeError({
+      message: 'Cannot continue without Jest Circus test runner underneath, exiting.',
+      hint: 'Make sure that in your Jest config you have no "testRunner" property or it is explicitly set to "jest-circus/runner".',
+      debugInfo: `The test runner powering your configuration is:\n${projectConfig.testRunner}`,
+    });
+  }
+
+  const circusPackageJson = path.join(path.dirname(projectConfig.testRunner), 'package.json');
+  if (!fs.existsSync(circusPackageJson)) {
+    throw new DetoxRuntimeError({
+      message: 'Check that you have an installed copy of "jest-circus" npm package, exiting.',
+      debugInfo: `Its package.json file is missing: ${circusPackageJson}`,
+    });
+  }
+
+  const circusVersion = require(circusPackageJson).version;
+  if (!circusVersion) {
+    throw new DetoxRuntimeError({
+      message: 'Check that you have an valid copy of "jest-circus" npm package, exiting.',
+      debugInfo: `Its package.json file has no "version" property. See:\n` + circusPackageJson,
+    });
+  }
+
+  assertSupportedVersion(circusVersion);
+
+  return maybeProjectConfig;
+}
+
+function assertSupportedVersion(actualVersion) {
+  const supportedRange = detoxPackageJson.peerDependencies.jest;
+  const minSupportedVersion = semver.minVersion(supportedRange);
+
+  if (semver.lt(actualVersion, minSupportedVersion)) {
+    throw new DetoxRuntimeError({
+      message: `Detected an unsupported jest@${actualVersion} version.`,
+      hint: `Please upgrade your Jest test runner to the supported range: ${supportedRange}.`
+    });
+  }
+}
+
+module.exports = {
+  assertJestCircus27,
+  assertSupportedVersion,
+};
diff --git a/docs/Guide.Jest.md b/docs/Guide.Jest.md
index 258998b21..a661f588f 100644
--- a/docs/Guide.Jest.md
+++ b/docs/Guide.Jest.md
@@ -17,14 +17,12 @@ As already mentioned in the [Getting Started](Introduction.GettingStarted.md#set
 
 By the way, Jest itself — much like Detox, also does not effectively run any tests. Instead, it is more of a dispatcher and orchestrator of multiple instances of a delegated runner capable of running in parallel. For more info, refer to [this video](https://youtu.be/3YDiloj8_d0?t=2127) (source: [Jest architecture](https://jestjs.io/docs/en/architecture)).
 
-For its part, Detox supports only one Jest’s concrete runner, which is [`jest-circus`](https://www.npmjs.com/package/jest-circus) ([ships by default with Jest since 27.0.0](https://jestjs.io/blog/2021/05/25/jest-27)). The former runner, `jest-jasmine2`, is deprecated due to specific bugs in the past, and architectural limitations at present.
-
 ### Installation
 
 **Disclaimer:**
 
 1. Here we focus on installing Detox on _new projects_. If you’re migrating a project with an existing Detox installation, please apply some common sense while using this guide.
-1. These instructions are relevant for `jest@^27.0.0` (and `jest@^26.0.1 + jest-circus@^26.0.1`). They should likely work for the newer `jest` versions too, but for **the older ones** (25.x, 24.x) — **they will not, due to blocking issues.**
+1. These instructions are relevant for `jest@^27.2.5` and `jest@28.x.x`. They should likely work for the newer `jest` versions too, but for **the older ones** (26.x, 25.x) — **they will not, due to blocking issues.**
 
 #### 1. Install Jest
 
@@ -38,7 +36,6 @@ npm install -D "jest@>=27.2.5"
 
 **NOTE:** The command will install the _latest Jest version_. However, `@>=27.2.5` addendum is recommended just to be on the safe side in a common scenario, when a `package-lock.json`
 generated by an official React Native project template limits Jest version to a very old `26.x`, maybe due to some optimization mechanism.
-Generally we recommend not to stay on outdated Jest versions for too long, e.g. `jest@27.2.5` will be the minimal version supported by Detox 20.
 
 #### 2. Set up Test-code Scaffolds
 


Test patch:
diff --git a/detox/runners/jest-circus/utils/assertJestCircus27.test.js b/detox/runners/jest-circus/utils/assertJestCircus27.test.js
new file mode 100644
index 000000000..275722277
--- /dev/null
+++ b/detox/runners/jest-circus/utils/assertJestCircus27.test.js
@@ -0,0 +1,23 @@
+const { assertSupportedVersion } = require('./assertJestCircus27');
+
+describe('assertSupportedVersion', () => {
+  test.each([
+    ['27.2.5'],
+    ['27.2.6-prerelease.0'],
+    ['27.3.0'],
+    ['28.0.0-alpha.1'],
+    ['28.0.0'],
+    ['28.1.0'],
+    ['29.0.0-next.0'],
+    ['30.0.0'],
+  ])('should pass for %j', (version) => {
+    expect(() => assertSupportedVersion(version)).not.toThrow();
+  });
+
+  test.each([
+    ['26.0.0'],
+    ['27.2.4'],
+  ])('should throw an error for %j', (version) => {
+    expect(() => assertSupportedVersion(version)).toThrowError(/unsupported jest.*version/);
+  });
+});
diff --git a/detox/test/e2e/config.js b/detox/test/e2e/config.js
index c0c02ece5..1a3c31c90 100644
--- a/detox/test/e2e/config.js
+++ b/detox/test/e2e/config.js
@@ -7,8 +7,8 @@ module.exports = {
   'globalTeardown': './test/e2e/global-teardown.js',
   'testTimeout': 120000,
   'reporters': process.env.DISABLE_JUNIT_REPORTER === '1'
-    ? ['<rootDir>/runners/jest/streamlineReporter']
-    : ['<rootDir>/runners/jest/streamlineReporter', '<rootDir>/test/node_modules/jest-junit'],
+    ? ['<rootDir>/runners/jest-circus/reporter']
+    : ['<rootDir>/runners/jest-circus/reporter', '<rootDir>/test/node_modules/jest-junit'],
   'verbose': true,
   'bail': false,
   'maxWorkers': Number(process.env.DEMO_MAX_WORKERS || '1'),
diff --git a/examples/demo-plugin/e2e/config.json b/examples/demo-plugin/e2e/config.json
index 288fc0c93..7ab11c70e 100644
--- a/examples/demo-plugin/e2e/config.json
+++ b/examples/demo-plugin/e2e/config.json
@@ -2,6 +2,6 @@
     "testEnvironment": "./environment",
     "testTimeout": 120000,
     "testRegex": "\\.test\\.js$",
-    "reporters": ["detox/runners/jest/streamlineReporter"],
+    "reporters": ["detox/runners/jest-circus/reporter"],
     "verbose": true
 }
diff --git a/examples/demo-react-native-jest/e2e/config.json b/examples/demo-react-native-jest/e2e/config.json
index 11d109fce..1fa68ffa9 100644
--- a/examples/demo-react-native-jest/e2e/config.json
+++ b/examples/demo-react-native-jest/e2e/config.json
@@ -10,6 +10,6 @@
     "transform": {
       "\\.tsx?$": "ts-jest"
     },
-    "reporters": ["detox/runners/jest/streamlineReporter"],
+    "reporters": ["detox/runners/jest-circus/reporter"],
     "verbose": true
 }
diff --git a/examples/demo-react-native-jest/e2eExplicitRequire/config.json b/examples/demo-react-native-jest/e2eExplicitRequire/config.json
index 755884e57..de1a4c469 100644
--- a/examples/demo-react-native-jest/e2eExplicitRequire/config.json
+++ b/examples/demo-react-native-jest/e2eExplicitRequire/config.json
@@ -6,6 +6,6 @@
     "transform": {
       "\\.tsx?$": "ts-jest"
     },
-    "reporters": ["detox/runners/jest/streamlineReporter"],
+    "reporters": ["detox/runners/jest-circus/reporter"],
     "verbose": true
 }