Repository: wix/Detox
Pull request #2707 (2021-03-29 14:01:02)

feat(config): extends
## Description

- This pull request addresses the issue described here: #2550

Now you can use "extends" capability, similar to `.babelrc` or `tsconfig.json`:

```json
{
  "extends": "my-base-preset"
}
```

 - [x] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

 - [x] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.



Patch:
diff --git a/detox/index.d.ts b/detox/index.d.ts
index dfa0fffeb..bc82915ae 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -36,6 +36,11 @@ declare global {
         // region DetoxConfig
 
         interface DetoxConfig {
+            /**
+             * @example extends: './relative/detox.config'
+             * @example extends: '@my-org/detox-preset'
+             */
+            extends?: string;
             /**
              * @example testRunner: 'jest'
              * @example testRunner: 'mocha'
diff --git a/detox/src/configuration/__mocks__/configuration/extends/.detoxrc.js b/detox/src/configuration/__mocks__/configuration/extends/.detoxrc.js
new file mode 100644
index 000000000..b4085e200
--- /dev/null
+++ b/detox/src/configuration/__mocks__/configuration/extends/.detoxrc.js
@@ -0,0 +1,8 @@
+module.exports = {
+  extends: require.resolve('./middle'),
+  artifacts: {
+    plugins: {
+      video: 'all',
+    },
+  },
+}
diff --git a/detox/src/configuration/__mocks__/configuration/extends/badPointer.json b/detox/src/configuration/__mocks__/configuration/extends/badPointer.json
new file mode 100644
index 000000000..8969ce183
--- /dev/null
+++ b/detox/src/configuration/__mocks__/configuration/extends/badPointer.json
@@ -0,0 +1,3 @@
+{
+  "extends": "it-does-not-exist... really"
+}
diff --git a/detox/src/configuration/__mocks__/configuration/extends/base.json b/detox/src/configuration/__mocks__/configuration/extends/base.json
new file mode 100644
index 000000000..9b48f8a31
--- /dev/null
+++ b/detox/src/configuration/__mocks__/configuration/extends/base.json
@@ -0,0 +1,8 @@
+{
+  "artifacts": {
+    "rootDir": "base",
+    "plugins": {
+      "log": "all"
+    }
+  }
+}
diff --git a/detox/src/configuration/__mocks__/configuration/extends/middle.js b/detox/src/configuration/__mocks__/configuration/extends/middle.js
new file mode 100644
index 000000000..20f56a8a4
--- /dev/null
+++ b/detox/src/configuration/__mocks__/configuration/extends/middle.js
@@ -0,0 +1,9 @@
+module.exports = {
+  extends: require.resolve('./base.json'),
+  artifacts: {
+    rootDir: 'someRootDir',
+    plugins: {
+      screenshot: 'all',
+    },
+  },
+}
diff --git a/detox/src/configuration/loadExternalConfig.js b/detox/src/configuration/loadExternalConfig.js
index 40a5a27b8..148442815 100644
--- a/detox/src/configuration/loadExternalConfig.js
+++ b/detox/src/configuration/loadExternalConfig.js
@@ -1,3 +1,4 @@
+const _ = require('lodash');
 const fs = require('fs-extra');
 const path = require('path');
 const findUp = require('find-up');
@@ -52,6 +53,20 @@ async function resolveConfigPath(configPath, cwd) {
   return null;
 }
 
+async function tryExtendConfig({ config, errorComposer, cwd }) {
+  if (!config || !config.extends) {
+    return config;
+  }
+
+  const { config: baseConfig } = await loadExternalConfig({
+    configPath: config.extends,
+    errorComposer: errorComposer.clone().setExtends(true),
+    cwd,
+  });
+
+  return _.merge({}, baseConfig, config);
+}
+
 /**
  * @param {DetoxConfigErrorComposer} errorComposer
  * @param {string} configPath
@@ -64,15 +79,22 @@ async function loadExternalConfig({ errorComposer, configPath, cwd }) {
   if (resolvedConfigPath) {
     errorComposer.setDetoxConfigPath(resolvedConfigPath);
 
+    let result;
     try {
-      return await loadConfig(resolvedConfigPath);
+      result = await loadConfig(resolvedConfigPath);
     } catch (e) {
       throw errorComposer.failedToReadConfiguration(e);
     }
+
+    result.config = await tryExtendConfig({
+      config: result.config,
+      cwd,
+      errorComposer,
+    });
+
+    return result;
   } else if (configPath) {
-    throw errorComposer
-      .setDetoxConfigPath(configPath)
-      .noConfigurationAtGivenPath();
+    throw errorComposer.noConfigurationAtGivenPath(configPath);
   }
 
   return null;
diff --git a/detox/src/errors/DetoxConfigErrorComposer.js b/detox/src/errors/DetoxConfigErrorComposer.js
index c760ed005..93600b414 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.js
@@ -5,9 +5,18 @@ const J = s => JSON.stringify(s);
 
 class DetoxConfigErrorComposer {
   constructor() {
+    this.setConfigurationName();
     this.setDetoxConfigPath();
     this.setDetoxConfig();
-    this.setConfigurationName();
+    this.setExtends();
+  }
+
+  clone() {
+    return new DetoxConfigErrorComposer()
+      .setConfigurationName(this.configurationName)
+      .setDetoxConfigPath(this.filepath)
+      .setDetoxConfig(this.contents)
+      .setExtends(this._extends);
   }
 
   _atPath() {
@@ -73,6 +82,11 @@ class DetoxConfigErrorComposer {
     };
   }
 
+  setConfigurationName(configurationName) {
+    this.configurationName = configurationName || '';
+    return this;
+  }
+
   setDetoxConfigPath(filepath) {
     this.filepath = filepath || '';
     return this;
@@ -83,8 +97,8 @@ class DetoxConfigErrorComposer {
     return this;
   }
 
-  setConfigurationName(configurationName) {
-    this.configurationName = configurationName || '';
+  setExtends(value) {
+    this._extends = !!value;
     return this;
   }
 
@@ -99,17 +113,22 @@ class DetoxConfigErrorComposer {
     });
   }
 
-  noConfigurationAtGivenPath() {
-    return new DetoxConfigError({
-      message: 'Failed to find Detox config at:\n' + this.filepath,
-      hint: 'Make sure the specified path is correct.',
-    });
+  noConfigurationAtGivenPath(givenPath) {
+    const message = this._extends
+      ? `Failed to find the base Detox config specified in:\n{\n  "extends": ${J(givenPath)}\n}`
+      : `Failed to find Detox config at ${J(givenPath)}`;
+
+    const hint = this._extends
+      ? `Check your Detox config${this._atPath()}`
+      : 'Make sure the specified path is correct.';
+
+    return new DetoxConfigError({ message, hint });
   }
 
   failedToReadConfiguration(unknownError) {
     return new DetoxConfigError({
       message: 'An error occurred while trying to load Detox config from:\n' + this.filepath,
-      debugInfo: unknownError && unknownError.message,
+      debugInfo: unknownError,
     });
   }
 
diff --git a/detox/src/errors/DetoxRuntimeError.js b/detox/src/errors/DetoxRuntimeError.js
index b4a185991..f59f15916 100644
--- a/detox/src/errors/DetoxRuntimeError.js
+++ b/detox/src/errors/DetoxRuntimeError.js
@@ -21,7 +21,7 @@ class DetoxRuntimeError extends Error {
   /**
    * @param {*} err
    */
-  static format(err) {
+  static format(err, inspectOptions = { depth: 1 }) {
     if (err instanceof DetoxRuntimeError) {
       return err.message;
     }
@@ -34,7 +34,7 @@ class DetoxRuntimeError extends Error {
       return String(err.stack || err.message);
     }
 
-    return this.inspectObj(err, { depth: 1 })
+    return this.inspectObj(err, inspectOptions)
   }
 }
 
@@ -50,9 +50,9 @@ function formatOptions(options) {
     return _.compact([
       message,
       hint && `HINT: ${hint}`,
-      _.isObject(debugInfo)
-        ? DetoxRuntimeError.inspectObj(debugInfo, inspectOptions)
-        : debugInfo,
+      _.isString(debugInfo)
+        ? debugInfo
+        : DetoxRuntimeError.format(debugInfo, inspectOptions),
     ]).join('\n\n');
   }
 
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index 4b9a48d24..f2083b741 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -18,6 +18,20 @@ Option 6 means the configuration is available in `json` format inside the projec
 
 Please find the [Detox example app](/examples/demo-react-native/detox.config.js) as a working reference.
 
+### Extending configurations
+
+Since Detox [18.9.0](https://github.com/wix/Detox/releases/tag/18.9.0), you can also define base Detox configurations,
+distribute them as `npm` modules for reuse across multiple projects, e.g.:
+
+```js
+{
+  "extends": "@my-org/detox-preset",
+  "runnerConfig": "e2e/jest.config.some-override.js"
+}
+```
+
+Please note that `extends` has to be a valid Node module path.
+
 ### Individual Configurations
 
 > NOTE: The configuration format has been significantly updated since [18.3.1](https://github.com/wix/Detox/blob/18.3.1/docs/APIRef.Configuration.md) in a backward-compatible way.


Test patch:
diff --git a/detox/src/configuration/loadExternalConfig.test.js b/detox/src/configuration/loadExternalConfig.test.js
index 94f0ac1c4..ac932bcea 100644
--- a/detox/src/configuration/loadExternalConfig.test.js
+++ b/detox/src/configuration/loadExternalConfig.test.js
@@ -4,6 +4,7 @@ const os = require('os');
 describe('loadExternalConfig', () => {
   const DIR_PACKAGEJSON = path.join(__dirname, '__mocks__/configuration/packagejson');
   const DIR_PRIORITY = path.join(__dirname, '__mocks__/configuration/priority');
+  const DIR_EXTENDS = path.join(__dirname, '__mocks__/configuration/extends');
   const DIR_BADCONFIG = path.join(__dirname, '__mocks__/configuration/badconfig');
 
   let DetoxConfigErrorComposer;
@@ -56,11 +57,39 @@ describe('loadExternalConfig', () => {
     expect(logger.warn).not.toHaveBeenCalled();
   });
 
+  it('should merge in the base config from "extends" property', async () => {
+    const { filepath, config } = await loadExternalConfig({ cwd: DIR_EXTENDS });
+
+    expect(filepath).toBe(path.join(DIR_EXTENDS, '.detoxrc.js'))
+    expect(config).toEqual({
+      extends: path.join(DIR_EXTENDS, 'middle.js'),
+      artifacts: {
+        rootDir: 'someRootDir',
+        plugins: {
+          log: "all",
+          screenshot: "all",
+          video: "all",
+        },
+      },
+    });
+  });
+
   it('should throw noConfigurationAtGivenPath error if the explicitly given config is not found', async () => {
     const configPath = path.join(DIR_PRIORITY, 'non-existent.json');
 
     await expect(loadExternalConfig({ configPath })).rejects.toThrowError(
-      errorComposer.setDetoxConfigPath(configPath).noConfigurationAtGivenPath()
+      errorComposer.noConfigurationAtGivenPath(configPath)
+    );
+  });
+
+  it('should throw noConfigurationAtGivenPath error if the "extends" base config is not found', async () => {
+    const configPath = path.join(DIR_EXTENDS, 'badPointer.json');
+
+    await expect(loadExternalConfig({ configPath })).rejects.toThrowError(
+      errorComposer
+        .setDetoxConfigPath(configPath)
+        .setExtends(true)
+        .noConfigurationAtGivenPath(require(configPath).extends)
     );
   });
 
diff --git a/detox/src/errors/DetoxConfigErrorComposer.test.js b/detox/src/errors/DetoxConfigErrorComposer.test.js
index 6610ce122..091eb7861 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.test.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.test.js
@@ -76,7 +76,16 @@ describe('DetoxConfigErrorComposer', () => {
 
     describe('.noConfigurationAtGivenPath', () => {
       it('should create an error with the attempted config path', () => {
-        expect(builder.noConfigurationAtGivenPath()).toMatchSnapshot();
+        expect(builder.noConfigurationAtGivenPath('./some/detox-config.js')).toMatchSnapshot();
+      });
+
+      it('should create an error with the attempted "extends" path', () => {
+        expect(
+          builder
+            .setExtends(true)
+            .setDetoxConfigPath('package.json')
+            .noConfigurationAtGivenPath("some-detox-preset")
+        ).toMatchSnapshot();
       });
     });
 
@@ -87,6 +96,7 @@ describe('DetoxConfigErrorComposer', () => {
 
       it('should create a simple error, but with the original intercepted IO error', () => {
         const ioError = _.attempt(() => fs.readFileSync(os.homedir()));
+        delete ioError.stack;
         expect(builder.failedToReadConfiguration(ioError)).toMatchSnapshot();
       });
     });
diff --git a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
index da2fa0b1a..a44bb1181 100644
--- a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
+++ b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
@@ -997,9 +997,18 @@ exports[`DetoxConfigErrorComposer (from configuration/index) .failedToReadConfig
 EISDIR: illegal operation on a directory, read]
 `;
 
+exports[`DetoxConfigErrorComposer (from configuration/index) .noConfigurationAtGivenPath should create an error with the attempted "extends" path 1`] = `
+[DetoxConfigError: Failed to find the base Detox config specified in:
+{
+  "extends": "some-detox-preset"
+}
+
+HINT: Check your Detox config at path:
+package.json]
+`;
+
 exports[`DetoxConfigErrorComposer (from configuration/index) .noConfigurationAtGivenPath should create an error with the attempted config path 1`] = `
-[DetoxConfigError: Failed to find Detox config at:
-/home/detox/myproject/.detoxrc.json
+[DetoxConfigError: Failed to find Detox config at "./some/detox-config.js"
 
 HINT: Make sure the specified path is correct.]
 `;
diff --git a/detox/test/detox.config.js b/detox/test/e2e/detox.config.js
similarity index 100%
rename from detox/test/detox.config.js
rename to detox/test/e2e/detox.config.js
diff --git a/detox/test/e2e/global-setup.js b/detox/test/e2e/global-setup.js
index 2c79eaeee..9dbb3aee4 100644
--- a/detox/test/e2e/global-setup.js
+++ b/detox/test/e2e/global-setup.js
@@ -2,7 +2,7 @@ const fs = require('fs-extra');
 const { execSync } = require('child_process');
 
 function resolveSelectedConfiguration() {
-  const { configurations } = require('../detox.config.js');
+  const { configurations } = require('./detox.config.js');
   const configName = process.env.DETOX_CONFIGURATION;
   return configurations[configName];
 }
diff --git a/detox/test/package.json b/detox/test/package.json
index 748726ad6..441b024a5 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -53,5 +53,8 @@
     "ancestorSeparator": " > ",
     "classNameTemplate": "E2E Results.{classname}",
     "titleTemplate": "{classname} > {title}"
+  },
+  "detox": {
+    "extends": "./e2e/detox.config"
   }
 }
diff --git a/detox/test/tsconfig.json b/detox/test/tsconfig.json
index 641b0fa58..143f2d6bf 100644
--- a/detox/test/tsconfig.json
+++ b/detox/test/tsconfig.json
@@ -15,7 +15,7 @@
     "types": ["node", "detox"]
   },
   "files": [
-    "detox.config.js",
+    "e2e/detox.config.js",
     "types/detox-jest-circus-setup-tests.ts",
     "types/detox-global-tests.ts",
     "types/detox-module-tests.ts",
diff --git a/detox/test/types/detox-jest-circus-setup-tests.ts b/detox/test/types/detox-jest-circus-setup-tests.ts
index 528c30aac..c7913901a 100644
--- a/detox/test/types/detox-jest-circus-setup-tests.ts
+++ b/detox/test/types/detox-jest-circus-setup-tests.ts
@@ -20,6 +20,7 @@ detoxCircus.getEnv().addEventsListener(assignReporter);
 
 beforeAll(async () => {
   await detox.init({
+    extends: "@my-org/detox-preset/prod-e2e",
     testRunner: "nyc jest",
     runnerConfig: "e2e/config.js",
     specs: "e2e/*.test.js",