Repository: wix/Detox
Pull request #4201 (2023-09-26 06:26:57)

chore: use node 16 for local development
Node 14 has been deprecated for too long already.
While we technically still support Node 14 in Detox 20, let's move to Node 16.x at least for the local development.


Patch:
diff --git a/.editorconfig b/.editorconfig
index 035c41a1d..ad7df8f92 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -26,6 +26,10 @@ tab_width = 2
 indent_size = 2
 tab_width = 2
 
+[{*.yaml,*.yml}]
+indent_size = 2
+tab_width = 2
+
 [{*.css,*.scss,*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}]
 indent_size = 2
 tab_width = 2
diff --git a/.github/workflows/broken-links-scan.yml b/.github/workflows/broken-links-scan.yml
index f8081700d..170177080 100644
--- a/.github/workflows/broken-links-scan.yml
+++ b/.github/workflows/broken-links-scan.yml
@@ -13,7 +13,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: 14
+          node-version-file: '.nvmrc'
       - uses: bahmutov/npm-install@v1
         with:
           useLockFile: false
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 81a875ff2..7f41ad2ee 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -32,7 +32,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: 14
+          node-version-file: '.nvmrc'
       - uses: bahmutov/npm-install@v1
         with:
           useLockFile: false
diff --git a/.github/workflows/surge-purge.yml b/.github/workflows/surge-purge.yml
index 96fb73a1a..f1d1a9926 100644
--- a/.github/workflows/surge-purge.yml
+++ b/.github/workflows/surge-purge.yml
@@ -15,7 +15,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: '16.x'
+          node-version-file: '.nvmrc'
 
       - name: Install surge
         run: npm -g install surge
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index e3d010aa2..01615cfa6 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -22,7 +22,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: '16.x'
+          node-version-file: '.nvmrc'
       - uses: dorny/paths-filter@v2
         id: changes
         with:
@@ -49,7 +49,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: '16.x'
+          node-version-file: '.nvmrc'
       - uses: webfactory/ssh-agent@v0.5.0
         with:
           ssh-private-key: ${{ secrets.GH_PAGES_DEPLOY }}
diff --git a/.npmrc b/.npmrc
index 43c97e719..bb57d0fc2 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,1 +1,3 @@
+legacy-peer-deps=true
 package-lock=false
+workspaces=false
diff --git a/.nvmrc b/.nvmrc
index 518633e16..53d838af2 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1,1 +1,1 @@
-lts/fermium
+lts/gallium
diff --git a/detox/internals.d.ts b/detox/internals.d.ts
index f79e22f66..c0a2fecad 100644
--- a/detox/internals.d.ts
+++ b/detox/internals.d.ts
@@ -113,7 +113,11 @@ declare global {
         /** Test suite name */
         name: string;
       }): Promise<void>;
-
+      /**
+       * Workaround for Jest exiting abruptly in --bail mode.
+       * Makes sure that all workers and their test environments are properly torn down.
+       */
+      unsafe_conductEarlyTeardown(): Promise<void>;
       /**
        * Reports to Detox CLI about passed and failed test files.
        * The failed test files might be re-run again if
@@ -215,6 +219,11 @@ declare global {
        * Randomly generated ID for the entire Detox test session, including retries.
        */
       id: string;
+      /**
+       * Signalizes that the test session is being torn down.
+       * Experimental feature for Jest --bail mode.
+       */
+      unsafe_earlyTeardown?: boolean;
       /**
        * Results of test file executions. Primarily used for Detox CLI retry mechanism.
        */
diff --git a/detox/runners/jest/reporters/DetoxReporter.js b/detox/runners/jest/reporters/DetoxReporter.js
index 81b845270..b0d687cb4 100644
--- a/detox/runners/jest/reporters/DetoxReporter.js
+++ b/detox/runners/jest/reporters/DetoxReporter.js
@@ -4,17 +4,21 @@ const JestVerboseReporter = require(resolveFrom(process.cwd(), '@jest/reporters'
 /** @type {new (globalConfig: any) => import('@jest/reporters').SummaryReporter} */
 const SummaryReporter = require(resolveFrom(process.cwd(), '@jest/reporters')).SummaryReporter;
 
-const { config, reportTestResults } = require('../../../internals');
+const { config, reportTestResults, unsafe_conductEarlyTeardown, cleanup } = require('../../../internals');
+const Deferred = require('../../../src/utils/Deferred');
 
 class DetoxReporter extends JestVerboseReporter {
   constructor(globalConfig) {
     super(globalConfig);
-    /** @type {import('@jest/reporters').SummaryReporter | null} */
-    this._summaryReporter = this._initSummaryReporter();
+
+    /** @type {Deferred | null} */
+    this._lastRunComplete = null;
+    /** @type {Set<string>} */
+    this._pendingTestFiles = new Set();
     /** @type {Promise<any> | null} */
     this._runCompletePromise = null;
-    /** @type {[Set<import('@jest/reporters').TestContext>, import('@jest/reporters').AggregatedResult]} */
-    this._lastResults = [null, null];
+    /** @type {import('@jest/reporters').SummaryReporter | null} */
+    this._summaryReporter = this._initSummaryReporter();
   }
 
   onRunStart(aggregatedResults, options) {
@@ -25,38 +29,99 @@ class DetoxReporter extends JestVerboseReporter {
     }
   }
 
+  onTestFileStart(test) {
+    this._pendingTestFiles.add(test.path);
+
+    // @ts-ignore Precaution in case Jest migrates to the new signature
+    if (typeof super.onTestFileStart === 'function') {
+      // @ts-ignore
+      super.onTestFileStart(test);
+    } else {
+      super.onTestStart(test);
+    }
+  }
+
+  onTestFileResult(test, testResult, aggregatedResult) {
+    this._pendingTestFiles.delete(test.path);
+
+    // @ts-ignore Precaution in case Jest migrates to the new signature
+    if (typeof super.onTestFileResult === 'function') {
+      // @ts-ignore
+      super.onTestFileResult(test, testResult, aggregatedResult);
+    } else {
+      super.onTestResult(test, testResult, aggregatedResult);
+    }
+
+    if (this._lastRunComplete && this._pendingTestFiles.size === 0) {
+      this._lastRunComplete.resolve(aggregatedResult);
+    }
+  }
+
   /**
-   * @param {Set<import('@jest/reporters').TestContext>} contexts
-   * @param {import('@jest/reporters').AggregatedResult} results
+   * @param {Set<import('@jest/reporters').TestContext>} testContexts
+   * @param {import('@jest/reporters').AggregatedResult} aggregatedResult
    * @returns {Promise<any> | null}
    */
-  // @ts-ignore
-  onRunComplete(contexts, results) {
-    // Both `_lastResults` and `_runCompletePromise` are used to prevent
-    // a bug in Jest, where `onRunComplete` is called multiple times when
-    // Jest runs with `--bail` and multiple workers, and `onRunComplete`
-    // is implemented as an asynchronous long-running operation.
-    this._lastResults = [contexts, results];
+  // @ts-ignore We need to use the complete signature, not the one from the base class
+  onRunComplete(testContexts, aggregatedResult) {
     if (!this._runCompletePromise) {
-      // @ts-expect-error TS2554: Expected 0 arguments, but got 2.
-      super.onRunComplete(...this._lastResults);
-      this._runCompletePromise = this._onRunComplete();
+      // Both `_lastRunComplete` and `_runCompletePromise` are used to prevent
+      // a bug in Jest, where `onRunComplete` is called multiple times when
+      // Jest runs with `--bail` and multiple workers, and `onRunComplete`
+      // is implemented as an asynchronous long-running operation.
+      this._lastRunComplete = this._pendingTestFiles.size === 0
+        ? Deferred.resolved(aggregatedResult)
+        : new Deferred();
+
+      this._runCompletePromise = this._onRunComplete(testContexts, aggregatedResult).catch(err => {
+        console.error(err);
+        throw err;
+      });
     }
 
     return this._runCompletePromise;
   }
 
-  async _onRunComplete() {
-    let results = this._lastResults[1];
-    await reportTestResults(results.testResults.map(r => ({
+  /**
+   * @param {Set<import('@jest/reporters').TestContext>} testContexts
+   * @param {import('@jest/reporters').AggregatedResult} aggregatedResult
+   * @returns {Promise<any> | null}
+   */
+  async _onRunComplete(testContexts, { numFailedTests }) {
+    const bail = this._globalConfig.bail;
+    const earlyTeardown = bail > 0 && numFailedTests >= bail;
+    if (earlyTeardown) {
+      await unsafe_conductEarlyTeardown();
+    }
+
+    const aggregatedResult = await this._lastRunComplete.promise;
+    const lostTests = aggregatedResult.numTotalTestSuites - aggregatedResult.testResults.length;
+
+    // @ts-expect-error TS2554
+    super.onRunComplete(testContexts, aggregatedResult);
+
+    if (earlyTeardown && lostTests > 0 && config.testRunner.retries > 0) {
+      console.warn(
+        'Jest aborted the test execution before all scheduled test files have been reported.\n' +
+        'This brings us to a dilemma: retry the whole test run, or retry only known failed test files.\n' +
+        'Both options are bad in their own way, this is why we have decided to not support this edge case.\n' +
+        'If you want to retry the whole test run, please disable Jest\'s --bail option.'
+      );
+    }
+
+    await reportTestResults(aggregatedResult.testResults.map(r => ({
       success: !r.failureMessage,
       testFilePath: r.testFilePath,
       testExecError: r.testExecError,
-      isPermanentFailure: this._isPermanentFailure(r),
+      isPermanentFailure: lostTests > 0 || this._isPermanentFailure(r),
     })));
 
     if (this._summaryReporter) {
-      this._summaryReporter.onRunComplete(...this._lastResults);
+      this._summaryReporter.onRunComplete(testContexts, aggregatedResult);
+    }
+
+    if (earlyTeardown) {
+      await cleanup();
     }
   }
 
diff --git a/detox/src/ipc/IPCClient.js b/detox/src/ipc/IPCClient.js
index 63d268264..7f4abf712 100644
--- a/detox/src/ipc/IPCClient.js
+++ b/detox/src/ipc/IPCClient.js
@@ -70,6 +70,11 @@ class IPCClient {
     this._sessionState.patch(sessionState);
   }
 
+  async conductEarlyTeardown() {
+    const sessionState = await this._emit('conductEarlyTeardown', {});
+    this._sessionState.patch(sessionState);
+  }
+
   async _connectToServer() {
     const serverId = this.serverId;
 
diff --git a/detox/src/ipc/IPCServer.js b/detox/src/ipc/IPCServer.js
index 73c18c144..246184ff0 100644
--- a/detox/src/ipc/IPCServer.js
+++ b/detox/src/ipc/IPCServer.js
@@ -38,6 +38,7 @@ class IPCServer {
     await new Promise((resolve) => {
       // TODO: handle reject
       this._ipc.serve(() => resolve());
+      this._ipc.server.on('conductEarlyTeardown', this.onConductEarlyTeardown.bind(this));
       this._ipc.server.on('registerContext', this.onRegisterContext.bind(this));
       this._ipc.server.on('registerWorker', this.onRegisterWorker.bind(this));
       this._ipc.server.on('reportTestResults', this.onReportTestResults.bind(this));
@@ -83,6 +84,18 @@ class IPCServer {
     }
   }
 
+  onConductEarlyTeardown(_data = null, socket = null) {
+    // Note that we don't save `unsafe_earlyTeardown` in the primary session state
+    // because it's transient and needed only to make the workers quit early.
+    const newState = { unsafe_earlyTeardown: true };
+
+    if (socket) {
+      this._ipc.server.emit(socket, 'conductEarlyTeardownDone', newState);
+    }
+
+    this._ipc.server.broadcast('sessionStateUpdate', newState);
+  }
+
   onReportTestResults({ testResults }, socket = null) {
     const merged = uniqBy([
       ...testResults.map(r => serializeObjectWithError(r, 'testExecError')),
diff --git a/detox/src/ipc/SessionState.js b/detox/src/ipc/SessionState.js
index 8cfa8353a..649f53803 100644
--- a/detox/src/ipc/SessionState.js
+++ b/detox/src/ipc/SessionState.js
@@ -20,6 +20,7 @@ class SessionState {
     this.detoxIPCServer = detoxIPCServer;
     this.testResults = testResults;
     this.testSessionIndex = testSessionIndex;
+    this.unsafe_earlyTeardown = undefined;
     this.workersCount = workersCount;
   }
 
diff --git a/detox/src/realms/DetoxContext.js b/detox/src/realms/DetoxContext.js
index 786550d6a..266d83b3f 100644
--- a/detox/src/realms/DetoxContext.js
+++ b/detox/src/realms/DetoxContext.js
@@ -101,6 +101,8 @@ class DetoxContext {
   });
   /** @abstract */
   [symbols.reportTestResults](_testResults) {}
+  /** @abstract */
+  [symbols.conductEarlyTeardown]() {}
   /**
    * @abstract
    * @param {Partial<DetoxInternals.DetoxInitOptions>} _opts
diff --git a/detox/src/realms/DetoxInternalsFacade.js b/detox/src/realms/DetoxInternalsFacade.js
index 2dd784406..70e499f87 100644
--- a/detox/src/realms/DetoxInternalsFacade.js
+++ b/detox/src/realms/DetoxInternalsFacade.js
@@ -24,6 +24,7 @@ class DetoxInternalsFacade {
     this.resolveConfig = context[symbols.resolveConfig];
     this.session = context[symbols.session];
     this.tracing = context[symbols.tracing];
+    this.unsafe_conductEarlyTeardown = context[symbols.conductEarlyTeardown];
     this.worker = funpermaproxy(() => context[symbols.worker]);
   }
 }
diff --git a/detox/src/realms/DetoxPrimaryContext.js b/detox/src/realms/DetoxPrimaryContext.js
index c3c4f5046..c48499a95 100644
--- a/detox/src/realms/DetoxPrimaryContext.js
+++ b/detox/src/realms/DetoxPrimaryContext.js
@@ -51,6 +51,12 @@ class DetoxPrimaryContext extends DetoxContext {
     }
   }
 
+  [symbols.conductEarlyTeardown] = async () => {
+    if (this[_ipcServer]) {
+      await this[_ipcServer].onConductEarlyTeardown();
+    }
+  };
+
   async [symbols.resolveConfig](opts = {}) {
     const session = this[$sessionState];
     if (!session.detoxConfig) {
diff --git a/detox/src/realms/DetoxSecondaryContext.js b/detox/src/realms/DetoxSecondaryContext.js
index 8946f0d06..2e7fba8c1 100644
--- a/detox/src/realms/DetoxSecondaryContext.js
+++ b/detox/src/realms/DetoxSecondaryContext.js
@@ -33,6 +33,14 @@ class DetoxSecondaryContext extends DetoxContext {
     }
   }
 
+  [symbols.conductEarlyTeardown] = async () => {
+    if (this[_ipcClient]) {
+      await this[_ipcClient].conductEarlyTeardown();
+    } else {
+      throw new DetoxInternalError('Detected an attempt to report early teardown using a non-initialized context.');
+    }
+  };
+
   async [symbols.resolveConfig]() {
     return this[symbols.config];
   }
diff --git a/detox/src/realms/symbols.js b/detox/src/realms/symbols.js
index d4435834f..ec8180ec8 100644
--- a/detox/src/realms/symbols.js
+++ b/detox/src/realms/symbols.js
@@ -12,6 +12,7 @@
  *   readonly onTestDone: unique symbol;
  *   readonly onTestFnFailure: unique symbol;
  *   readonly onTestStart: unique symbol;
+ *   readonly conductEarlyTeardown: unique symbol;
  *   readonly reportTestResults: unique symbol;
  *   readonly resolveConfig: unique symbol;
  *   readonly session: unique symbol;
@@ -32,6 +33,7 @@ module.exports = {
 
   //#region IPC
   reportTestResults: Symbol('reportTestResults'),
+  conductEarlyTeardown: Symbol('conductEarlyTeardown'),
   //#endregion
 
   //#region Main
diff --git a/detox/src/utils/errorUtils.js b/detox/src/utils/errorUtils.js
index f96319842..8f3e50b69 100644
--- a/detox/src/utils/errorUtils.js
+++ b/detox/src/utils/errorUtils.js
@@ -41,7 +41,7 @@ function asError(error) {
 }
 
 function serializeObjectWithError(obj, errorKey) {
-  if (obj[errorKey]) {
+  if (obj[errorKey] instanceof Error) {
     return { ...obj, [errorKey]: serializeError(obj[errorKey]) };
   }
 
diff --git a/lerna.json b/lerna.json
index 490318868..096113c61 100644
--- a/lerna.json
+++ b/lerna.json
@@ -31,7 +31,8 @@
     },
     "bootstrap": {
       "npmClientArgs": [
-        "--no-package-lock"
+        "--no-package-lock",
+        "--legacy-peer-deps"
       ]
     }
   }


Test patch:
diff --git a/.github/workflows/rapid-test.yml b/.github/workflows/rapid-test.yml
index a75bd8c17..500b4b69a 100644
--- a/.github/workflows/rapid-test.yml
+++ b/.github/workflows/rapid-test.yml
@@ -4,15 +4,15 @@ on:
   push:
     branches: [master]
     paths:
-        - '.github/workflows/rapid-test.yml'
-        - 'detox/**'
-        - 'generation/**'
+      - '.github/workflows/rapid-test.yml'
+      - 'detox/**'
+      - 'generation/**'
   pull_request:
     branches: [master]
     paths:
-        - '.github/workflows/rapid-test.yml'
-        - 'detox/**'
-        - 'generation/**'
+      - '.github/workflows/rapid-test.yml'
+      - 'detox/**'
+      - 'generation/**'
 
 jobs:
 
@@ -23,7 +23,7 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions/setup-node@v3
         with:
-          node-version: 14
+          node-version-file: '.nvmrc'
       - name: Bootstrap Lerna
         run: npx lerna@3 bootstrap --no-ci
         env:
@@ -49,21 +49,21 @@ jobs:
         working-directory: generation
 
   windows:
-      name: Windows
-      runs-on: windows-latest
-      steps:
-          - uses: actions/checkout@v3
-          - uses: actions/setup-node@v3
-            with:
-                node-version: 14
-          - name: Bootstrap Lerna (Light)
-            run: npx lerna@3 bootstrap --no-ci --include-dependents --include-dependencies --scope=detox-test
-            env:
-                DETOX_DISABLE_POD_INSTALL: true
-                DETOX_DISABLE_POSTINSTALL: true
-          - name: Unit tests
-            run: npm run unit -- --coverageThreshold "{}"
-            working-directory: detox
-          - name: Integration tests
-            run: npm run integration
-            working-directory: detox/test
+    name: Windows
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version-file: '.nvmrc'
+      - name: Bootstrap Lerna (Light)
+        run: npx lerna@3 bootstrap --no-ci --include-dependents --include-dependencies --scope=detox-test
+        env:
+          DETOX_DISABLE_POD_INSTALL: true
+          DETOX_DISABLE_POSTINSTALL: true
+      - name: Unit tests
+        run: npm run unit -- --coverageThreshold "{}"
+        working-directory: detox
+      - name: Integration tests
+        run: npm run integration
+        working-directory: detox/test
diff --git a/detox/runners/jest/testEnvironment/index.js b/detox/runners/jest/testEnvironment/index.js
index 5dbcc2d9e..0fd37190d 100644
--- a/detox/runners/jest/testEnvironment/index.js
+++ b/detox/runners/jest/testEnvironment/index.js
@@ -70,7 +70,12 @@ class DetoxCircusEnvironment extends NodeEnvironment {
     await this.initDetox();
   }
 
+  // @ts-ignore
   async handleTestEvent(event, state) {
+    if (detox.session.unsafe_earlyTeardown) {
+      throw new Error('Detox halted test execution due to an early teardown request');
+    }
+
     this._timer.schedule(state.testTimeout != null ? state.testTimeout : this.setupTimeout);
 
     if (SYNC_CIRCUS_EVENTS.has(event.name)) {
diff --git a/detox/src/ipc/ipc.test.js b/detox/src/ipc/ipc.test.js
index 93db02a4c..6c21e070f 100644
--- a/detox/src/ipc/ipc.test.js
+++ b/detox/src/ipc/ipc.test.js
@@ -257,6 +257,27 @@ describe('IPC', () => {
         });
       });
 
+      describe('conductEarlyTeardown', () => {
+        it('should broadcast early teardown in all connected clients', async () => {
+          expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined }));
+          expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined }));
+
+          await ipcClient1.conductEarlyTeardown();
+          expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true }));
+          await sleep(10); // broadcasting might happen with a delay
+          expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true }));
+        });
+
+        it('should broadcast early teardown in all connected clients (from server)', async () => {
+          expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined }));
+          expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined }));
+          await ipcServer.onConductEarlyTeardown();
+          await sleep(10); // broadcasting might happen with a delay
+          expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true }));
+          expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true }));
+        });
+      });
+
       describe('registerWorker', () => {
         it('should register worker', async () => {
           await ipcClient1.registerWorker('foo');
diff --git a/detox/test/integration/bail-test.test.js b/detox/test/integration/bail-test.test.js
new file mode 100644
index 000000000..5282f3f2e
--- /dev/null
+++ b/detox/test/integration/bail-test.test.js
@@ -0,0 +1,22 @@
+const _ = require('lodash');
+const execa = require('execa');
+
+describe('jest --bail tests', () => {
+  test.each([
+    `detox test -c stub --config integration/e2e/config.js --bail --maxWorkers 2 --retries 1 flaky passing-simple`,
+    `cross-env DETOX_CONFIGURATION=stub jest --config integration/e2e/config.js --bail --runInBand passing-simple`,
+  ])('should pass: %s', async (cmd) => {
+    console.log(`Running: ${cmd}`);
+    const handle = execa.commandSync(cmd, { stdio: 'inherit', shell: true });
+    expect(handle.exitCode).toBe(0);
+  });
+
+  test.each([
+    `detox test -c stub --config integration/e2e/config.js --bail --runInBand --retries 0 flaky passing-simple`,
+    `cross-env DETOX_CONFIGURATION=stub jest --config integration/e2e/config.js --maxWorkers 4 flaky passing`,
+  ])('should fail: %s', async (cmd) => {
+    console.log(`Running: ${cmd}`);
+    const handle = _.attempt(() => execa.commandSync(cmd, { stdio: 'inherit', shell: true }));
+    expect(handle.exitCode).not.toBe(0);
+  });
+});