Repository: wix/Detox
Pull request #4729 (2025-03-07 09:40:39)

fix: CVE-2024-21538 by migrating to promisify-child-process #4658
## Description

**Thanks to the co-author, @matinzd for the initial implementation.**

Detox is using a vulnerable package described in [CVE-2024-21538](https://github.com/advisories/GHSA-3xgq-45jj-v275/dependabot).

- This pull request addresses the issue described here: #4657 and #4640

In this pull request, I have migrated away from unmaintained `child-promise-process` to `promisify-child-process` to fix the transitive vulnerability on `cross-spawn` package.



Patch:
diff --git a/detox/local-cli/utils/frameworkUtils.js b/detox/local-cli/utils/frameworkUtils.js
index 5b82978c8..33632a3f6 100644
--- a/detox/local-cli/utils/frameworkUtils.js
+++ b/detox/local-cli/utils/frameworkUtils.js
@@ -1,13 +1,12 @@
 const os = require('os');
 const path = require('path');
 
-const { spawn } = require('child-process-promise');
 const fs = require('fs-extra');
 
 const detox = require('../../internals');
+const { spawnAndLog } = require('../../src/utils/childProcess');
 const { getFrameworkDirPath, getXCUITestRunnerDirPath } = require('../../src/utils/environment');
 
-
 const frameworkBuildScript = '../../scripts/build_local_framework.ios.sh';
 const xcuitestBuildScript = '../../scripts/build_local_xcuitest.ios.sh';
 
@@ -26,7 +25,7 @@ async function execBuildScript(targetPath, scriptPath, descriptor) {
   const scriptFullPath = path.join(__dirname, scriptPath);
 
   try {
-    await spawn(scriptFullPath, [], { stdio: 'inherit' });
+    await spawnAndLog(scriptFullPath, [], { stdio: 'inherit' });
   } catch (error) {
     detox.log.error(`Error while building ${descriptor}:\n${error}`);
     throw error;
diff --git a/detox/package.json b/detox/package.json
index d831590ed..f7d787965 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -64,7 +64,7 @@
     "prettier": "^3.1.1",
     "react-native": "0.76.3",
     "react-native-codegen": "^0.0.8",
-    "typescript": "^5.3.3",
+    "typescript": "~5.3.3",
     "wtfnode": "^0.9.1"
   },
   "dependencies": {
@@ -73,7 +73,6 @@
     "bunyan-debug-stream": "^3.1.0",
     "caf": "^15.0.1",
     "chalk": "^4.0.0",
-    "child-process-promise": "^2.2.0",
     "detox-copilot": "^0.0.27",
     "execa": "^5.1.1",
     "find-up": "^5.0.0",
@@ -81,6 +80,7 @@
     "funpermaproxy": "^1.1.0",
     "glob": "^8.0.3",
     "ini": "^1.3.4",
+    "promisify-child-process": "^4.1.2",
     "jest-environment-emit": "^1.0.8",
     "json-cycle": "^1.3.0",
     "lodash": "^4.17.11",
diff --git a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js
index e14be76ac..1398d10a7 100644
--- a/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js
+++ b/detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js
@@ -31,23 +31,37 @@ function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand) {
 
   log = log.child({ child_pid: childProcessPromise.childProcess.pid });
 
-  adb.waitForDevice(emulatorLaunchCommand.adbName).then(() => childProcessPromise._cpResolve());
+  // Create a deferred promise that resolves when the device is ready
+  let resolveEmulatorReady;
+  const emulatorReadyPromise = new Promise(resolve => {
+    resolveEmulatorReady = resolve;
+  });
 
-  return childProcessPromise.then(() => true).catch((err) => {
-    detach();
+  // Wait for the device to be ready
+  adb.waitForDevice(emulatorLaunchCommand.adbName).then(() => {
+    resolveEmulatorReady();
+  });
 
-    if (childProcessOutput.includes(`There's another emulator instance running with the current AVD`)) {
-      return false;
-    }
+  // Use Promise.race to resolve with the first one to complete - either the emulator process exits
+  // or the emulator device is ready
+  return Promise.race([childProcessPromise, emulatorReadyPromise])
+    .then(() => true)
+    .catch((err) => {
+      detach();
 
-    log.error({ event: 'SPAWN_FAIL', error: true, err }, err.message);
-    log.error({ event: 'SPAWN_FAIL', stderr: true }, childProcessOutput);
-    throw err;
-  }).then((coldBoot) => {
-    detach();
-    log.debug({ event: 'SPAWN_SUCCESS', stdout: true }, childProcessOutput);
-    return coldBoot;
-  });
+      if (childProcessOutput && childProcessOutput.includes(`There's another emulator instance running with the current AVD`)) {
+        return false;
+      }
+
+      log.error({ event: 'SPAWN_FAIL', error: true, err }, err.message);
+      log.error({ event: 'SPAWN_FAIL', stderr: true }, childProcessOutput);
+      throw err;
+    })
+    .then((coldBoot) => {
+      detach();
+      log.debug({ event: 'SPAWN_SUCCESS', stdout: true }, childProcessOutput);
+      return coldBoot;
+    });
 }
 
 module.exports = { launchEmulatorProcess };
diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js
index 6d907199b..ab269f5ba 100644
--- a/detox/src/devices/common/drivers/android/exec/ADB.js
+++ b/detox/src/devices/common/drivers/android/exec/ADB.js
@@ -375,6 +375,7 @@ class ADB {
       ...this.defaultExecOptions,
       ...spawnOptions,
       capture: ['stdout'],
+      encoding: 'utf8',
     };
     return spawnWithRetriesAndLogs(this.adbBin, _flags, _spawnOptions);
   }
diff --git a/detox/src/devices/common/drivers/android/exec/BinaryExec.js b/detox/src/devices/common/drivers/android/exec/BinaryExec.js
index 209ec3a75..f55143396 100644
--- a/detox/src/devices/common/drivers/android/exec/BinaryExec.js
+++ b/detox/src/devices/common/drivers/android/exec/BinaryExec.js
@@ -1,7 +1,4 @@
-// @ts-nocheck
-const spawn = require('child-process-promise').spawn;
-
-const exec = require('../../../../../utils/childProcess').execWithRetriesAndLogs;
+const { execAsync, spawnAndLog } = require('../../../../../utils/childProcess');
 
 class ExecCommand {
   toString() {
@@ -27,15 +24,19 @@ class BinaryExec {
   }
 
   async exec(command) {
-    return (await exec(`"${this.binary}" ${command._getArgsString()}`)).stdout;
+    return await execAsync(`"${this.binary}" ${command._getArgsString()}`);
   }
 
   spawn(command, stdout, stderr) {
-    return spawn(this.binary, command._getArgs(), { detached: true, stdio: ['ignore', stdout, stderr] });
+    return spawnAndLog(this.binary, command._getArgs(), {
+      detached: true,
+      encoding: 'utf8',
+      stdio: ['ignore', stdout, stderr]
+    });
   }
 }
 
 module.exports = {
   ExecCommand,
-  BinaryExec,
+  BinaryExec
 };
diff --git a/detox/src/devices/runtime/drivers/ios/SimulatorDriver.js b/detox/src/devices/runtime/drivers/ios/SimulatorDriver.js
index 7bc241cd5..cce5497b2 100644
--- a/detox/src/devices/runtime/drivers/ios/SimulatorDriver.js
+++ b/detox/src/devices/runtime/drivers/ios/SimulatorDriver.js
@@ -1,13 +1,14 @@
 // @ts-nocheck
 const path = require('path');
 
-const exec = require('child-process-promise').exec;
 const _ = require('lodash');
 
+
 const temporaryPath = require('../../../../artifacts/utils/temporaryPath');
 const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
 const XCUITestRunner = require('../../../../ios/XCUITestRunner');
 const { assertTraceDescription } = require('../../../../utils/assertArgument');
+const { execAsync } = require('../../../../utils/childProcess');
 const getAbsoluteBinaryPath = require('../../../../utils/getAbsoluteBinaryPath');
 const { actionDescription } = require('../../../../utils/invocationTraceDescriptions');
 const log = require('../../../../utils/logger').child({ cat: 'device' });
@@ -16,7 +17,6 @@ const traceInvocationCall = require('../../../../utils/traceInvocationCall').bin
 
 const IosDriver = require('./IosDriver');
 
-
 /**
  * @typedef SimulatorDriverDeps { DeviceDriverDeps }
  * @property applesimutils { AppleSimUtils }
@@ -69,8 +69,7 @@ class SimulatorDriver extends IosDriver {
   async getBundleIdFromBinary(appPath) {
     appPath = getAbsoluteBinaryPath(appPath);
     try {
-      const result = await exec(`/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "${path.join(appPath, 'Info.plist')}"`);
-      const bundleId = _.trim(result.stdout);
+      const bundleId = await execAsync(`/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "${path.join(appPath, 'Info.plist')}"`);
       if (_.isEmpty(bundleId)) {
         throw new Error();
       }
@@ -141,7 +140,7 @@ class SimulatorDriver extends IosDriver {
     const xcuitestRunner = new XCUITestRunner({ runtimeDevice: { id: this.getExternalId(), _bundleId } });
     let x = point?.x ?? 100;
     let y = point?.y ?? 100;
-    let _pressDuration = pressDuration ? (pressDuration / 1000) : 1;
+    let _pressDuration = pressDuration ? pressDuration / 1000 : 1;
     const traceDescription = actionDescription.longPress({ x, y }, _pressDuration);
     return this.withAction(xcuitestRunner, 'coordinateLongPress', traceDescription, x.toString(), y.toString(), _pressDuration.toString());
   }
@@ -210,7 +209,7 @@ class SimulatorDriver extends IosDriver {
     await this.emitter.emit('createExternalArtifact', {
       pluginId: 'screenshot',
       artifactName: screenshotName || path.basename(tempPath, '.png'),
-      artifactPath: tempPath,
+      artifactPath: tempPath
     });
 
     return tempPath;
@@ -223,7 +222,7 @@ class SimulatorDriver extends IosDriver {
     await this.emitter.emit('createExternalArtifact', {
       pluginId: 'uiHierarchy',
       artifactName: artifactName,
-      artifactPath: viewHierarchyURL,
+      artifactPath: viewHierarchyURL
     });
 
     return viewHierarchyURL;
diff --git a/detox/src/utils/assertArgument.js b/detox/src/utils/assertArgument.js
index 2cf5b3c5e..9de9f5831 100644
--- a/detox/src/utils/assertArgument.js
+++ b/detox/src/utils/assertArgument.js
@@ -1,7 +1,9 @@
+const _ = require('lodash');
+
 const { DetoxInternalError, DetoxRuntimeError } = require('../errors');
 
 function firstEntry(obj) {
-  return Object.entries(obj)[0];
+  return _.entries(obj)[0] || ['value', obj];
 }
 
 function assertType(expectedType) {
diff --git a/detox/src/utils/childProcess/exec.js b/detox/src/utils/childProcess/exec.js
index ee0f8e69e..4685a4f7d 100644
--- a/detox/src/utils/childProcess/exec.js
+++ b/detox/src/utils/childProcess/exec.js
@@ -1,6 +1,6 @@
 // @ts-nocheck
-const { exec } = require('child-process-promise');
 const _ = require('lodash');
+const { exec } = require('promisify-child-process');
 
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const rootLogger = require('../logger').child({ cat: ['child-process', 'child-process-exec'] });
@@ -8,6 +8,12 @@ const retry = require('../retry');
 
 const execsCounter = require('./opsCounter');
 
+/**
+ * Executes a command with retries and logs
+ * @param {*} bin - command to execute
+ * @param {*} options - options
+ * @returns {Promise<import('promisify-child-process').Output>}
+ */
 async function execWithRetriesAndLogs(bin, options = {}) {
   const {
     retries = 9,
diff --git a/detox/src/utils/childProcess/spawn.js b/detox/src/utils/childProcess/spawn.js
index 5b52613a2..a252e2cfa 100644
--- a/detox/src/utils/childProcess/spawn.js
+++ b/detox/src/utils/childProcess/spawn.js
@@ -1,6 +1,6 @@
 // @ts-nocheck
-const { spawn } = require('child-process-promise');
 const _ = require('lodash');
+const { spawn } = require('promisify-child-process');
 
 const rootLogger = require('../logger').child({ cat: ['child-process', 'child-process-spawn'] });
 const { escape } = require('../pipeCommands');
@@ -8,15 +8,15 @@ const retry = require('../retry');
 
 const execsCounter = require('./opsCounter');
 
-function spawnAndLog(binary, flags, options) {
-  const command = _joinCommandAndFlags(binary, flags);
+function spawnAndLog(binary, args, options) {
+  const command = _joinCommandAndArgs(binary, args);
   const trackingId = execsCounter.inc();
   const logger = rootLogger.child({ fn: 'spawnAndLog', command, trackingId });
-  return _spawnAndLog(logger, binary, flags, command, options);
+  return _spawnAndLog(logger, binary, args, command, options);
 }
 
-async function spawnWithRetriesAndLogs(binary, flags, options = {}) {
-  const command = _joinCommandAndFlags(binary, flags);
+async function spawnWithRetriesAndLogs(binary, args, options = {}) {
+  const command = _joinCommandAndArgs(binary, args);
   const trackingId = execsCounter.inc();
   const logger = rootLogger.child({ fn: 'spawnWithRetriesAndLogs', command, trackingId });
   const _options = {
@@ -33,7 +33,8 @@ async function spawnWithRetriesAndLogs(binary, flags, options = {}) {
   let result;
   await retry({ retries, interval, backoff }, async (tryCount, lastError) => {
     _logSpawnRetrying(logger, tryCount, lastError);
-    result = await _spawnAndLog(logger, binary, flags, command, spawnOptions, tryCount);
+    result = _spawnAndLog(logger, binary, args, command, spawnOptions, tryCount);
+    await result;
   });
   return result;
 }
@@ -67,11 +68,23 @@ async function interruptProcess(childProcessPromise, schedule) {
   }
 }
 
-function _spawnAndLog(logger, binary, flags, command, options, tryCount) {
+function _spawnAndLog(logger, binary, args, command, options, tryCount) {
   const { logLevelPatterns, silent, ...spawnOptions } = { stdio: ['ignore', 'pipe', 'pipe'], ...options };
-  const cpPromise = spawn(binary, flags, spawnOptions);
+  const cpPromise = spawn(binary, args, spawnOptions);
+  const childProcess = cpPromise.childProcess = cpPromise;
+  const originalThen = cpPromise.then.bind(cpPromise);
+  const augmentPromise = (fn) => {
+    return typeof fn === 'function'
+      ? (result) => fn(Object.assign(result, {
+        childProcess,
+        exitCode: childProcess.exitCode,
+        pid: childProcess.pid
+      }))
+      : fn;
+  };
+  cpPromise.then = (onFulfilled, onRejected) => originalThen(augmentPromise(onFulfilled), augmentPromise(onRejected));
+  cpPromise.catch = (onRejected) => cpPromise.then(undefined, onRejected);
 
-  const { childProcess } = cpPromise;
   const { exitCode, stdout, stderr } = childProcess;
 
   const _logger = logger.child({ cpid: childProcess.pid });
@@ -86,8 +99,12 @@ function _spawnAndLog(logger, binary, flags, command, options, tryCount) {
     stderr && stderr.on('data', _spawnStderrLoggerFn(_logger, logLevelPatterns));
   }
 
+  /**
+   *
+   * @param {import('promisify-child-process').Output} resultOrErr
+   */
   function onEnd(resultOrErr) {
-    const signal = resultOrErr.childProcess.signalCode || '';
+    const signal = resultOrErr.signal || '';
     const { code } = resultOrErr;
     const action = signal ? `terminated with ${signal}` : `exited with code #${code}`;
 
@@ -98,11 +115,11 @@ function _spawnAndLog(logger, binary, flags, command, options, tryCount) {
   return cpPromise;
 }
 
-function _joinCommandAndFlags(command, flags) {
+function _joinCommandAndArgs(command, args) {
   let result = command;
 
-  for (const flag of flags.map(String)) {
-    result += ' ' + (flag.indexOf(' ') === -1 ? flag : `"${escape.inQuotedString(flag)}"`);
+  for (const arg of args.map(String)) {
+    result += ' ' + (arg.indexOf(' ') === -1 ? arg : `"${escape.inQuotedString(arg)}"`);
   }
 
   return result;
diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js
index aa53771ef..eeb30580a 100644
--- a/detox/src/utils/environment.js
+++ b/detox/src/utils/environment.js
@@ -3,7 +3,6 @@ const fs = require('fs');
 const os = require('os');
 const path = require('path');
 
-const exec = require('child-process-promise').exec;
 const ini = require('ini');
 const _ = require('lodash');
 const _which = require('which');
@@ -11,6 +10,7 @@ const _which = require('which');
 const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
 
 const appdatapath = require('./appdatapath');
+const { execAsync } = require('./childProcess');
 const fsext = require('./fsext');
 
 function which(executable, path) {
@@ -147,8 +147,7 @@ function throwMissingAvdINIError(avdName, avdIniPath) {
 
 function throwMissingAvdError(avdName, avdPath, avdIniPath) {
   throw new DetoxRuntimeError(
-    `Failed to find AVD ${avdName} directory at path: ${avdPath}\n` +
-    `Please verify "path" property in the INI file: ${avdIniPath}`
+    `Failed to find AVD ${avdName} directory at path: ${avdPath}\n` + `Please verify "path" property in the INI file: ${avdIniPath}`
   );
 }
 
@@ -169,7 +168,9 @@ function throwSdkIntegrityError(errMessage) {
 }
 
 function throwMissingGmsaasError() {
-  throw new DetoxRuntimeError(`Failed to locate Genymotion's gmsaas executable. Please add it to your $PATH variable!\nPATH is currently set to: ${process.env.PATH}`);
+  throw new DetoxRuntimeError(
+    `Failed to locate Genymotion's gmsaas executable. Please add it to your $PATH variable!\nPATH is currently set to: ${process.env.PATH}`
+  );
 }
 
 const getDetoxVersion = _.once(() => {
@@ -178,11 +179,9 @@ const getDetoxVersion = _.once(() => {
 
 const getBuildFolderName = _.once(async () => {
   const detoxVersion = getDetoxVersion();
-  const xcodeVersion = await exec('xcodebuild -version').then(result => result.stdout.trim());
+  const xcodeVersion = await execAsync('xcodebuild -version');
 
-  return crypto.createHash('sha1')
-      .update(`${detoxVersion}\n${xcodeVersion}\n`)
-      .digest('hex');
+  return crypto.createHash('sha1').update(`${detoxVersion}\n${xcodeVersion}\n`).digest('hex');
 });
 
 const getFrameworkDirPath = `${DETOX_LIBRARY_ROOT_PATH}/ios/framework`;
@@ -197,8 +196,8 @@ const getXCUITestRunnerDirPath = `${DETOX_LIBRARY_ROOT_PATH}/ios/xcuitest-runner
 const getXCUITestRunnerPath = _.once(async () => {
   const buildFolder = await getBuildFolderName();
   const derivedDataPath = `${getXCUITestRunnerDirPath}/${buildFolder}`;
-  const xctestrunPath = await exec(`find ${derivedDataPath} -name "*.xctestrun" -print -quit`)
-      .then(result => result.stdout.trim());
+  const command = `find ${derivedDataPath} -name "*.xctestrun" -print -quit`;
+  const xctestrunPath = await execAsync(command);
 
   if (!xctestrunPath) {
     throw new DetoxRuntimeError(`Failed to find .xctestrun file in ${derivedDataPath}`);
@@ -246,5 +245,5 @@ module.exports = {
   getDetoxLockFilePath,
   getDeviceRegistryPath,
   getLastFailedTestsPath,
-  getHomeDir,
+  getHomeDir
 };


Test patch:
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
index 510ab8b4c..c2d988184 100644
--- a/detox/src/android/AndroidExpect.test.js
+++ b/detox/src/android/AndroidExpect.test.js
@@ -427,6 +427,18 @@ describe('AndroidExpect', () => {
     });
 
     describe('WebElement Actions', () => {
+      let handle;
+
+      beforeEach(() => {
+        jest.useFakeTimers({ doNotFake: ['clearInterval', 'setInterval'] });
+        handle = setInterval(() => jest.runAllTimers(), 5);
+      });
+
+      afterEach(() => {
+        clearInterval(handle);
+        jest.useRealTimers();
+      });
+
       it('tap', async () => {
         await e.web.element(e.by.web.id('id')).tap();
         await e.web.element(e.by.web.className('className')).tap();
diff --git a/detox/src/devices/common/drivers/android/exec/ADB.test.js b/detox/src/devices/common/drivers/android/exec/ADB.test.js
index fa9d82e1d..752b0b820 100644
--- a/detox/src/devices/common/drivers/android/exec/ADB.test.js
+++ b/detox/src/devices/common/drivers/android/exec/ADB.test.js
@@ -133,6 +133,7 @@ describe('ADB', () => {
       retries: 3,
       timeout: 60000,
       capture: ['stdout'],
+      encoding: 'utf8',
     };
     await adb.install(deviceId, 'path/to/bin.apk');
 
@@ -248,6 +249,7 @@ describe('ADB', () => {
       retries: 3,
       timeout: 60000,
       capture: ['stdout'],
+      encoding: 'utf8',
     };
     const binaryPath = '/mock-path/filename.mock';
     await adb.install(deviceId, binaryPath);
diff --git a/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js b/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js
index 41791e149..8ced6d76f 100644
--- a/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js
+++ b/detox/src/devices/common/drivers/android/exec/BinaryExec.test.js
@@ -1,3 +1,4 @@
+/* eslint-disable node/no-extraneous-require */
 describe('BinaryExec', () => {
   const binaryPath = '/binary/mock';
 
@@ -6,16 +7,11 @@ describe('BinaryExec', () => {
   let binaryExec;
   beforeEach(() => {
     jest.mock('../../../../../utils/childProcess', () => ({
-      execWithRetriesAndLogs: jest.fn().mockResolvedValue({
-        stdout: '',
-      }),
+      execAsync: jest.fn().mockResolvedValue(''),
+      spawnAndLog: jest.fn()
     }));
-    exec = require('../../../../../utils/childProcess').execWithRetriesAndLogs;
-
-    jest.mock('child-process-promise', () => ({
-      spawn: jest.fn()
-    }));
-    spawn = require('child-process-promise').spawn;
+    exec = require('../../../../../utils/childProcess').execAsync;
+    spawn = require('../../../../../utils/childProcess').spawnAndLog;
 
     const { BinaryExec } = require('./BinaryExec');
     binaryExec = new BinaryExec(binaryPath);
@@ -55,17 +51,15 @@ describe('BinaryExec', () => {
       expect(exec).toHaveBeenCalledWith(`"${binaryPath}" -mock -argz`);
     });
 
-    it('should return content of resolved promise\'s stdout', async () => {
-      const execResult = {
-        stdout: 'mock stdout content'
-      };
+    it('should return content of resolved promise', async () => {
+      const execResult = 'mock stdout content';
       exec.mockResolvedValue(execResult);
 
       const { ExecCommand } = require('./BinaryExec');
       const command = new ExecCommand();
       const result = await binaryExec.exec(command);
 
-      expect(result).toEqual(execResult.stdout);
+      expect(result).toEqual(execResult);
     });
   });
 
@@ -87,14 +81,14 @@ describe('BinaryExec', () => {
       expect(spawn).toHaveBeenCalledWith(binaryPath, commandArgs, expect.anything());
     });
 
-    it('should chain-return child-process-promise from spawn', async () => {
-      const childProcessPromise = Promise.resolve('mock result');
-      spawn.mockReturnValue(childProcessPromise);
+    it('should chain-return spawn result', async () => {
+      const spawnResult = Promise.resolve('mock result');
+      spawn.mockReturnValue(spawnResult);
 
       const command = anEmptyCommand();
 
       const result = binaryExec.spawn(command);
-      expect(result).toEqual(childProcessPromise);
+      expect(result).toEqual(spawnResult);
     });
   });
 });
diff --git a/detox/src/ios/XCUITestRunner.js b/detox/src/ios/XCUITestRunner.js
index 04693fafd..6f9c317f2 100644
--- a/detox/src/ios/XCUITestRunner.js
+++ b/detox/src/ios/XCUITestRunner.js
@@ -1,6 +1,5 @@
-const { exec } = require('child-process-promise');
-
 const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
+const { execAsync } = require('../utils/childProcess');
 const environment = require('../utils/environment');
 const log = require('../utils/logger').child({ cat: 'xcuitest-runner' });
 
@@ -37,7 +36,7 @@ class XCUITestRunner {
           `--predicate 'process == "DetoxXCUITestRunner-Runner" && subsystem == "com.wix.DetoxXCUITestRunner.xctrunner"'`);
 
         try {
-            return await exec(`TEST_RUNNER_PARAMS="${base64InvocationParams}" TEST_RUNNER_BUNDLE_ID="${this.runtimeDevice._bundleId}" xcodebuild ${flags.join(' ')}`);
+            return await execAsync(`TEST_RUNNER_PARAMS="${base64InvocationParams}" TEST_RUNNER_BUNDLE_ID="${this.runtimeDevice._bundleId}" xcodebuild ${flags.join(' ')}`);
         } catch (e) {
             const stdout = e.stdout.toString();
             const innerError = this.findInnerError(stdout);
diff --git a/detox/src/ios/XCUITestRunner.test.js b/detox/src/ios/XCUITestRunner.test.js
index a6f5847ae..da328a5d9 100644
--- a/detox/src/ios/XCUITestRunner.test.js
+++ b/detox/src/ios/XCUITestRunner.test.js
@@ -1,12 +1,12 @@
 const XCUITestRunner = require('./XCUITestRunner');
 
-jest.mock('child-process-promise', () => {
+jest.mock('promisify-child-process', () => {
     return {
         exec: jest.fn(),
     };
 });
 
-const { exec } = jest.requireMock('child-process-promise');
+const { exec } = jest.requireMock('promisify-child-process');
 const environment = jest.requireMock('../utils/environment');
 
 jest.mock('../utils/environment');
diff --git a/detox/src/utils/__snapshots__/assertArgument.test.js.snap b/detox/src/utils/__snapshots__/assertArgument.test.js.snap
index 70c48a934..db43cc6ba 100644
--- a/detox/src/utils/__snapshots__/assertArgument.test.js.snap
+++ b/detox/src/utils/__snapshots__/assertArgument.test.js.snap
@@ -44,4 +44,4 @@ exports[`assertUndefined should throw for "str" 1`] = `"0 expected to be undefin
 
 exports[`assertUndefined should throw for {"key":"val"} 1`] = `"key expected to be undefined, but got val (string)"`;
 
-exports[`assertUndefined should throw for 1 1`] = `"undefined is not iterable (cannot read property Symbol(Symbol.iterator))"`;
+exports[`assertUndefined should throw for 1 1`] = `"value expected to be undefined, but got 1 (number)"`;
diff --git a/detox/src/utils/childProcess/exec.test.js b/detox/src/utils/childProcess/exec.test.js
index 70151f08f..173279a76 100644
--- a/detox/src/utils/childProcess/exec.test.js
+++ b/detox/src/utils/childProcess/exec.test.js
@@ -8,8 +8,8 @@ describe('Exec utils', () => {
     jest.mock('../logger');
     logger = require('../logger');
 
-    jest.mock('child-process-promise');
-    cpp = require('child-process-promise');
+    jest.mock('promisify-child-process');
+    cpp = require('promisify-child-process');
 
     const mockedRetryActual = jest.requireActual('../retry');
     const mockedRetry = jest.fn().mockReturnValue(undefined);
@@ -98,8 +98,8 @@ describe('Exec utils', () => {
       args: `--argument 123`,
       statusLogs: {
         trying: 'trying status log',
-        successful: 'successful status log',
-      },
+        successful: 'successful status log'
+      }
     };
     await execWithRetriesAndLogs('bin', options);
 
@@ -117,8 +117,8 @@ describe('Exec utils', () => {
     const options = {
       args: `--argument 123`,
       statusLogs: {
-        retrying: true,
-      },
+        retrying: true
+      }
     };
 
     logger.debug.mockClear();
@@ -252,20 +252,20 @@ describe('Exec utils', () => {
 });
 
 const returnSuccessfulWithValue = (value) => ({
-    stdout: JSON.stringify(value),
-    stderr: 'err',
-    childProcess: {
-      exitCode: 0
-    }
-  });
+  stdout: JSON.stringify(value),
+  stderr: 'err',
+  childProcess: {
+    exitCode: 0
+  }
+});
 
 const returnErrorWithValue = (value) => ({
-    stdout: 'out',
-    stderr: value,
-    childProcess: {
-      exitCode: 1
-    }
-  });
+  stdout: 'out',
+  stderr: value,
+  childProcess: {
+    exitCode: 1
+  }
+});
 
 function mockCppSuccessful(cpp) {
   const successfulResult = returnSuccessfulWithValue('successful result');
diff --git a/detox/src/utils/childProcess/spawn.test.js b/detox/src/utils/childProcess/spawn.test.js
index 6e3352c51..3fcb76434 100644
--- a/detox/src/utils/childProcess/spawn.test.js
+++ b/detox/src/utils/childProcess/spawn.test.js
@@ -3,7 +3,6 @@
 jest.retryTimes(2);
 
 describe('Spawn utils', () => {
-
   describe('spawning', () => {
     let retry;
     let log;
@@ -13,8 +12,8 @@ describe('Spawn utils', () => {
       jest.mock('../logger');
       log = require('../logger');
 
-      jest.mock('child-process-promise');
-      cpp = require('child-process-promise');
+      jest.mock('promisify-child-process');
+      cpp = require('promisify-child-process');
 
       jest.mock('../retry');
       retry = require('../retry');
@@ -25,21 +24,17 @@ describe('Spawn utils', () => {
       mockSpawnResult(0, {
         pid: 2018,
         stdout: toStream('hello'),
-        stderr: toStream('world'),
+        stderr: toStream('world')
       });
     });
 
-    const mockSpawnResult = (code, childProcess) => {
-      const cpPromise = Promise.resolve({ code, childProcess });
-      cpp.spawn.mockReturnValue(Object.assign(cpPromise, { childProcess }));
-    };
+    const mockSpawnResult = (code, childProcess, reset = true) => {
+      if (reset) {
+        cpp.spawn.mockReset();
+      }
 
-    const mockSpawnResults = (childProcess1, childProcess2) => {
-      const cpPromise1 = Promise.resolve({ childProcess: childProcess1 });
-      const cpPromise2 = Promise.resolve({ childProcess: childProcess2 });
-      cpp.spawn
-        .mockReturnValueOnce(Object.assign(cpPromise1, { childProcess: childProcess1 }))
-        .mockReturnValueOnce(Object.assign(cpPromise2, { childProcess: childProcess2 }));
+      const cpPromise = Promise.resolve({ code });
+      cpp.spawn.mockReturnValueOnce(Object.assign(cpPromise, childProcess));
     };
 
     const advanceOpsCounter = (count) => {
@@ -47,104 +42,115 @@ describe('Spawn utils', () => {
       for (let i = 0; i < count; i++) opsCounter.inc();
     };
 
-    [
-      'spawnAndLog',
-      'spawnWithRetriesAndLogs',
-    ].forEach((func) => {
-      describe(func, () => {
-        it('should spawn an attached command with ignored input and piped output', async () => {
-          const command = 'command';
-          const flags = ['--some', '--value', Math.random()];
+    describe.each([
+      ['spawnAndLog'],
+      ['spawnWithRetriesAndLogs']
+    ])('%s function', (func) => {
+      it('should spawn an attached command with ignored input and piped output', async () => {
+        const command = 'command';
+        const flags = ['--some', '--value', Math.random()];
 
-          await spawn[func](command, flags);
+        await spawn[func](command, flags);
 
-          expect(cpp.spawn).toBeCalledWith(command, flags, expect.objectContaining({
+        expect(cpp.spawn).toBeCalledWith(
+          command,
+          flags,
+          expect.objectContaining({
             stdio: ['ignore', 'pipe', 'pipe']
-          }));
-        });
+          })
+        );
+      });
 
-        it('should log spawn command upon child-process start and finish', async () => {
-          jest.spyOn(log, 'child');
-          await spawn[func]('mockCommand', []);
+      it('should log spawn command upon child-process start and finish', async () => {
+        jest.spyOn(log, 'child');
+        await spawn[func]('mockCommand', []);
 
-          expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
-          expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #0');
-        });
+        expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
+        expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #0');
+      });
 
-        it('should collect output and log it', async () => {
-          await spawn[func]('mockCommand', []);
-          await nextCycle();
+      it('should collect output and log it', async () => {
+        await spawn[func]('mockCommand', []);
+        await nextCycle();
 
-          expect(log.trace).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDOUT', stdout: true }), 'hello');
-          expect(log.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }), 'world');
-        });
+        expect(log.trace).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDOUT', stdout: true }), 'hello');
+        expect(log.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }), 'world');
+      });
 
-        it('should form and use a child-logger', async () => {
-          const trackingId = 7;
-          advanceOpsCounter(trackingId);
+      it('should form and use a child-logger', async () => {
+        const trackingId = 7;
+        advanceOpsCounter(trackingId);
 
-          jest.spyOn(log, 'child');
-          await spawn[func]('mockCommand', []);
+        jest.spyOn(log, 'child');
+        await spawn[func]('mockCommand', []);
 
-          expect(log.child).toHaveBeenCalledWith(expect.objectContaining({ trackingId, command: 'mockCommand', fn: func }));
-          expect(log.child).toHaveBeenCalledWith(expect.objectContaining({ cpid: 2018 }));
-        });
+        expect(log.child).toHaveBeenCalledWith(expect.objectContaining({ trackingId, command: 'mockCommand', fn: func }));
+        expect(log.child).toHaveBeenCalledWith(expect.objectContaining({ cpid: 2018 }));
+      });
 
-        it('should override log levels if configured', async () => {
-          jest.spyOn(log, 'child');
-          await spawn[func]('command', [], {
-            logLevelPatterns: {
-              debug: [/hello/],
-              warn: [/world/],
-            },
-          });
+      it('should override log levels if configured', async () => {
+        jest.spyOn(log, 'child');
+        await spawn[func]('command', [], {
+          logLevelPatterns: {
+            debug: [/hello/],
+            warn: [/world/]
+          }
+        });
 
-          await nextCycle();
+        await nextCycle();
 
-          expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDOUT' }), 'hello');
-          expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR' }), 'world');
-        });
+        expect(log.debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDOUT' }), 'hello');
+        expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR' }), 'world');
+      });
 
-        it('should not log output if silent: true', async () => {
-          await spawn[func]('mockCommand', [], { silent: true });
-          await nextCycle();
+      it('should not log output if silent: true', async () => {
+        await spawn[func]('mockCommand', [], { silent: true });
+        await nextCycle();
+
+        expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
+        expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #0');
+        expect(log.trace).not.toBeCalledWith(
+          expect.objectContaining(expect.objectContaining({ event: 'SPAWN_STDOUT' })),
+          expect.any(String)
+        );
+        expect(log.error).not.toBeCalledWith(
+          expect.objectContaining(expect.objectContaining({ event: 'SPAWN_STDERR' })),
+          expect.any(String)
+        );
+      });
 
-          expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
-          expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #0');
-          expect(log.trace).not.toBeCalledWith(expect.objectContaining(expect.objectContaining({ event: 'SPAWN_STDOUT' })), expect.any(String));
-          expect(log.error).not.toBeCalledWith(expect.objectContaining(expect.objectContaining({ event: 'SPAWN_STDERR' })), expect.any(String));
+      it('should log erroneously finished spawns', async () => {
+        mockSpawnResult(-2, {
+          pid: 8102,
+          stdout: toStream(''),
+          stderr: toStream('Some error.')
         });
 
-        it('should log erroneously finished spawns', async () => {
-          mockSpawnResult(-2, {
-            pid: 8102,
-            stdout: toStream(''),
-            stderr: toStream('Some error.'),
-          });
+        await spawn[func]('mockCommand', []).catch(() => {});
+        await nextCycle();
 
-          await spawn[func]('mockCommand', []).catch(() => {});
-          await nextCycle();
+        expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
+        expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #-2');
+        expect(log.error).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }), 'Some error.');
+      });
 
-          expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'mockCommand');
-          expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_END' }), 'mockCommand exited with code #-2');
-          expect(log.error).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }), 'Some error.');
+      it('should log immediate spawn errors', async () => {
+        mockSpawnResult(undefined, {
+          pid: null,
+          exitCode: -2,
+          stdout: toStream(''),
+          stderr: toStream('Command `command` not found.')
         });
 
-        it('should log immediate spawn errors', async () => {
-          mockSpawnResult(undefined, {
-            pid: null,
-            exitCode: -2,
-            stdout: toStream(''),
-            stderr: toStream('Command `command` not found.'),
-          });
+        await spawn[func]('command', []);
+        await nextCycle();
 
-          await spawn[func]('command', []);
-          await nextCycle();
-
-          expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'command');
-          expect(log.error).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_ERROR' }), 'command failed with code = -2');
-          expect(log.error).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }), 'Command `command` not found.');
-        });
+        expect(log.debug).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_CMD' }), 'command');
+        expect(log.error).toBeCalledWith(expect.objectContaining({ event: 'SPAWN_ERROR' }), 'command failed with code = -2');
+        expect(log.error).toBeCalledWith(
+          expect.objectContaining({ event: 'SPAWN_STDERR', stderr: true }),
+          'Command `command` not found.'
+        );
       });
     });
 
@@ -160,9 +166,13 @@ describe('Spawn utils', () => {
 
       it('should spawn an attached command with stderr capturing, by default', async () => {
         await spawn.spawnWithRetriesAndLogs(command, flags);
-        expect(cpp.spawn).toBeCalledWith(command, flags, expect.objectContaining({
-          capture: ['stderr'],
-        }));
+        expect(cpp.spawn).toBeCalledWith(
+          command,
+          flags,
+          expect.objectContaining({
+            capture: ['stderr']
+          })
+        );
       });
 
       it('should retry once, by default', async () => {
@@ -201,34 +211,39 @@ describe('Spawn utils', () => {
 
       it('should honor output-capturing options, but force stderr', async () => {
         await spawn.spawnWithRetriesAndLogs(command, flags, { capture: ['stdout'] });
-        expect(cpp.spawn).toBeCalledWith(command, flags, expect.objectContaining({
-          capture: ['stdout', 'stderr'],
-        }));
+        expect(cpp.spawn).toBeCalledWith(
+          command,
+          flags,
+          expect.objectContaining({
+            capture: ['stdout', 'stderr']
+          })
+        );
       });
 
       it('should return result of last retry attempt', async () => {
-        const childProcess1 = {
+        mockSpawnResult(1, {
           pid: 100,
           exitCode: 1,
-          stderr: toStream(''),
-        };
-        const childProcess2 = {
+          stderr: toStream('')
+        });
+        mockSpawnResult(0, {
           pid: 101,
           exitCode: 0,
-          stdout: toStream('okay great'),
-        };
-        mockSpawnResults(childProcess1, childProcess2);
+          stdout: toStream('okay great')
+        }, false);
 
-        retry.mockImplementation(async (opts, callback) => {
+        retry.mockImplementation(async (_opts, callback) => {
           await callback(1);
           await callback(2, spawnTryError('mocked stderr'));
         });
 
         const result = await spawn.spawnWithRetriesAndLogs(command, flags);
-        expect(result.childProcess).toEqual(expect.objectContaining({
-          pid: 101,
-          exitCode: 0,
-        }));
+        expect(result).toEqual(
+          expect.objectContaining({
+            pid: 101,
+            exitCode: 0
+          })
+        );
         expect(cpp.spawn).toHaveBeenCalledTimes(2);
       });
     });
@@ -243,17 +258,13 @@ describe('Spawn utils', () => {
     }, 500);
 
     it('should throw exception if child process exited with an error', async () => {
-      const script =
-        "process.on('SIGINT', () => {});" +
-        'setTimeout(()=>process.exit(1), 100);';
+      const script = "process.on('SIGINT', () => {});" + 'setTimeout(()=>process.exit(1), 100);';
 
       await interruptProcess(spawnAndLog('node', ['-e', script]));
     }, 1000);
 
     it('should SIGTERM a stuck process after specified time', async () => {
-      const script =
-        "process.on('SIGINT', () => {});" +
-        'setTimeout(()=>process.exit(1), 10000);';
+      const script = "process.on('SIGINT', () => {});" + 'setTimeout(()=>process.exit(1), 10000);';
 
       const theProcess = spawnAndLog('node', ['-e', script]);
       await interruptProcess(theProcess, {
@@ -264,7 +275,7 @@ describe('Spawn utils', () => {
 });
 
 function nextCycle() {
-  return new Promise(resolve => setTimeout(resolve));
+  return new Promise((resolve) => setTimeout(resolve));
 }
 
 function toStream(string) {