Repository: wix/Detox
Pull request #1729 (2019-11-04 08:21:02)

feat(config): add artifacts subsystem configuration via JSON 
- [x] This change has been discussed in issue #1306 and the solution has been agreed upon with maintainers (#1238).

---

**Description:**

This pull request should enable adding the `artifacts` section inside the global `detox` section in `package.json`. Alternatively, the `artifacts` section can be configured for any named Detox configuration in the `configurations` section. Here are a few examples:

```json
    {
      "detox": {
        "artifacts": {
          "rootDir": ".artifacts/ios/",
          "pathBuilder": "config/pathbuilder.js",
          "plugins": {
            "instruments": "none",
            "log": "all",
            "screenshot": "failing",
            "video": "none"
          }
        },
        "configurations": {
          "ios.sim.release": {
            "binaryPath": "/path/to/app",
            "device": {
              "type": "iPhone X"
            },
            "artifacts": {
              "plugins": { "instruments": "all" }
            }
          }
        }
      }
    }
```




Patch:
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index 6c7a1be43..a4bb63f77 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -23,12 +23,12 @@ const DEVICE_CLASSES = {
 };
 
 class Detox {
-  constructor({deviceConfig, session}) {
+  constructor({artifactsConfig, deviceConfig, session}) {
     this._deviceConfig = deviceConfig;
     this._userSession = deviceConfig.session || session;
     this._client = null;
     this._server = null;
-    this._artifactsManager = new ArtifactsManager();
+    this._artifactsManager = new ArtifactsManager(artifactsConfig);
 
     this.device = null;
   }
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index 701f92aba..a8c693997 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -64,8 +64,17 @@ class DetoxExportWrapper {
         throw new Error(`There are no device configurations in the detox config`);
       }
 
+      const deviceConfig = configuration.composeDeviceConfig(detoxConfig);
+      const configurationName = _.findKey(detoxConfig.configurations, (config) => config === deviceConfig);
+      const artifactsConfig = configuration.composeArtifactsConfig({
+        configurationName,
+        detoxConfig,
+        deviceConfig,
+      });
+
       instance = new Detox({
-        deviceConfig: DetoxExportWrapper._getDeviceConfig(detoxConfig),
+        deviceConfig,
+        artifactsConfig,
         session: detoxConfig.session,
       });
 
@@ -82,32 +91,6 @@ class DetoxExportWrapper {
     }
   }
 
-  static _getDeviceConfig({ configurations }) {
-    const configurationName = argparse.getArgValue('configuration');
-    const deviceOverride = argparse.getArgValue('device-name');
-
-    const deviceConfig = (!configurationName && _.size(configurations) === 1)
-      ? _.values(configurations)[0]
-      : configurations[configurationName];
-
-    if (!deviceConfig) {
-      throw new Error(`Cannot determine which configuration to use. use --configuration to choose one of the following:
-                        ${Object.keys(configurations)}`);
-    }
-
-    if (!deviceConfig.type) {
-      configuration.throwOnEmptyType();
-    }
-
-    deviceConfig.device = deviceOverride || deviceConfig.device || deviceConfig.name;
-    delete deviceConfig.name;
-
-    if (_.isEmpty(deviceConfig.device)) {
-      configuration.throwOnEmptyDevice();
-    }
-
-    return deviceConfig;
-  }
 }
 
 module.exports = DetoxExportWrapper;
diff --git a/detox/src/artifacts/ArtifactsManager.js b/detox/src/artifacts/ArtifactsManager.js
index 8d73f59d0..d9b249ba5 100644
--- a/detox/src/artifacts/ArtifactsManager.js
+++ b/detox/src/artifacts/ArtifactsManager.js
@@ -4,26 +4,39 @@ const path = require('path');
 const util = require('util');
 const FileArtifact = require('./templates/artifact/FileArtifact');
 const log = require('../utils/logger').child({ __filename });
-const argparse = require('../utils/argparse');
 const ArtifactPathBuilder = require('./utils/ArtifactPathBuilder');
 
 class ArtifactsManager {
-  constructor(pathBuilder) {
+  constructor({ rootDir, pathBuilder, plugins } = {}) {
     this.onTerminate = _.once(this.onTerminate.bind(this));
 
+    this._pluginConfigs = plugins;
     this._idlePromise = Promise.resolve();
     this._idleCallbackRequests = [];
     this._activeArtifacts = [];
     this._artifactPlugins = {};
-    this._pathBuilder = pathBuilder || new ArtifactPathBuilder({
-      artifactsRootDir: argparse.getArgValue('artifacts-location') || 'artifacts',
-    });
+
+    this._pathBuilder = this._instantiatePathBuilder(pathBuilder, rootDir);
   }
 
-  _instantitateArtifactPlugin(pluginFactory) {
+  _instantiatePathBuilder(pathBuilderFactory, rootDir) {
+    if (typeof pathBuilderFactory === 'function') {
+      return pathBuilderFactory({ rootDir });
+    }
+
+    if (pathBuilderFactory) {
+      return pathBuilderFactory;
+    }
+
+    return new ArtifactPathBuilder({ rootDir });
+  }
+
+  _instantitateArtifactPlugin(pluginFactory, pluginUserConfig) {
     const artifactsApi = {
       plugin: null,
 
+      userConfig: { ...pluginUserConfig },
+
       preparePathForArtifact: async (artifactName, testSummary) => {
         const artifactPath = this._pathBuilder.buildPathForTestArtifact(artifactName, testSummary);
         const artifactDir = path.dirname(artifactPath);
@@ -70,7 +83,8 @@ class ArtifactsManager {
 
   registerArtifactPlugins(artifactPluginFactoriesMap = {}) {
     for (const [key, factory] of Object.entries(artifactPluginFactoriesMap)) {
-      this._artifactPlugins[key] = this._instantitateArtifactPlugin(factory);
+      const config = this._pluginConfigs[key];
+      this._artifactPlugins[key] = this._instantitateArtifactPlugin(factory, config);
     }
   }
 
diff --git a/detox/src/artifacts/__mocks__/FakePathBuilder.js b/detox/src/artifacts/__mocks__/FakePathBuilder.js
new file mode 100644
index 000000000..762912021
--- /dev/null
+++ b/detox/src/artifacts/__mocks__/FakePathBuilder.js
@@ -0,0 +1,7 @@
+class FakePathBuilder {
+  buildPathForTestArtifact(artifactName, testSummary) {
+    return (testSummary ? (testSummary.fullName + '/') : '') + artifactName;
+  }
+}
+
+module.exports = FakePathBuilder;
\ No newline at end of file
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
index 3e04c07ca..94bcaa245 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
@@ -1,16 +1,11 @@
-const fs = require('fs-extra');
 const temporaryPath = require('../utils/temporaryPath');
-const argparse = require('../../utils/argparse');
-const log = require('../../utils/logger').child({ __filename });
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
 const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
 
 class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
-  constructor(config) {
-    super(config);
-
-    this.client = config.client;
-    this.enabled = argparse.getArgValue('record-performance') === 'all';
+  constructor({ api, client }) {
+    super({ api });
+    this.client = client;
   }
 
   async onBeforeUninstallApp(event) {
@@ -64,6 +59,22 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
   async preparePathForTestArtifact(testSummary) {
     return this.api.preparePathForArtifact('test.dtxrec', testSummary);
   }
+
+  static parseConfig(config) {
+    switch (config) {
+      case 'all':
+        return {
+          enabled: true,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+      case 'none':
+      default:
+        return {
+          enabled: false,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+    }
+  }
 }
 
 module.exports = SimulatorInstrumentsPlugin;
diff --git a/detox/src/artifacts/log/LogArtifactPlugin.js b/detox/src/artifacts/log/LogArtifactPlugin.js
index cf5d9e927..c36c3f627 100644
--- a/detox/src/artifacts/log/LogArtifactPlugin.js
+++ b/detox/src/artifacts/log/LogArtifactPlugin.js
@@ -1,4 +1,5 @@
-const argparse = require('../../utils/argparse');
+const logger = require('../../utils/logger');
+const FileArtifact = require('../templates/artifact/FileArtifact');
 const StartupAndTestRecorderPlugin = require('../templates/plugin/StartupAndTestRecorderPlugin');
 const getTimeStampString = require('../utils/getTimeStampString');
 
@@ -6,13 +7,26 @@ const getTimeStampString = require('../utils/getTimeStampString');
  * @abstract
  */
 class LogArtifactPlugin extends StartupAndTestRecorderPlugin {
-  constructor(config) {
-    super(config);
+  constructor({ api }) {
+    super({ api });
+  }
+
+  async onAfterAll() {
+    await super.onAfterAll();
 
-    const recordLogs = argparse.getArgValue('record-logs');
+    if (this.shouldKeepArtifactOfSession()) {
+      this.api.requestIdleCallback(async () => {
+        const [jsonLogPath, plainLogPath] = await Promise.all([
+          this.api.preparePathForArtifact(`detox_pid_${process.pid}.json.log`),
+          this.api.preparePathForArtifact(`detox_pid_${process.pid}.log`),
+        ]);
 
-    this.enabled = recordLogs && recordLogs !== 'none';
-    this.keepOnlyFailedTestsArtifacts = recordLogs === 'failing';
+        await Promise.all([
+          new FileArtifact({ temporaryPath: logger.jsonFileStreamPath }).save(jsonLogPath, { append: true }),
+          new FileArtifact({ temporaryPath: logger.plainFileStreamPath }).save(plainLogPath, { append: true })
+        ]);
+      });
+    }
   }
 
   async onBeforeShutdownDevice(event) {
@@ -33,6 +47,27 @@ class LogArtifactPlugin extends StartupAndTestRecorderPlugin {
   async preparePathForTestArtifact(testSummary) {
     return this.api.preparePathForArtifact('process.log', testSummary);
   }
+
+  static parseConfig(config) {
+    switch (config) {
+      case 'failing':
+        return {
+          enabled: true,
+          keepOnlyFailedTestsArtifacts: true,
+        };
+      case 'all':
+        return {
+          enabled: true,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+      case 'none':
+      default:
+        return {
+          enabled: false,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+    }
+  }
 }
 
 module.exports = LogArtifactPlugin;
diff --git a/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
index 117fa3ef4..c2ef86994 100644
--- a/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
+++ b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
@@ -1,25 +1,46 @@
-const _ = require('lodash');
-const argparse = require('../../utils/argparse');
-const log = require('../../utils/logger').child({ __filename });
 const TwoSnapshotsPerTestPlugin = require('../templates/plugin/TwoSnapshotsPerTestPlugin');
 
 /***
  * @abstract
  */
 class ScreenshotArtifactPlugin extends TwoSnapshotsPerTestPlugin {
-  constructor(config) {
-    super(config);
-
-    const takeScreenshots = argparse.getArgValue('take-screenshots');
-
-    this.enabled = !takeScreenshots || takeScreenshots !== 'none';
-    this.shouldTakeAutomaticSnapshots = takeScreenshots === 'failing' || takeScreenshots === 'all';
-    this.keepOnlyFailedTestsArtifacts = takeScreenshots === 'failing';
+  constructor({ api }) {
+    super({ api });
   }
 
   async preparePathForSnapshot(testSummary, name) {
     return this.api.preparePathForArtifact(`${name}.png`, testSummary);
   }
+
+  static parseConfig(config) {
+    switch (config) {
+      case 'failing':
+        return {
+          enabled: true,
+          shouldTakeAutomaticSnapshots: true,
+          keepOnlyFailedTestsArtifacts: true,
+        };
+      case 'all':
+        return {
+          enabled: true,
+          shouldTakeAutomaticSnapshots: true,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+      case 'none':
+        return {
+          enabled: false,
+          shouldTakeAutomaticSnapshots: false,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+      case 'manual':
+      default:
+        return {
+          enabled: true,
+          shouldTakeAutomaticSnapshots: false,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+    }
+  }
 }
 
 module.exports = ScreenshotArtifactPlugin;
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.js b/detox/src/artifacts/templates/artifact/FileArtifact.js
index 17d53e83c..e898f6753 100644
--- a/detox/src/artifacts/templates/artifact/FileArtifact.js
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.js
@@ -1,5 +1,6 @@
 const fs = require('fs-extra');
 const Artifact = require('./Artifact');
+const appendFile = require('../../../utils/appendFile');
 
 class FileArtifact extends Artifact {
   constructor(template) {
@@ -11,23 +12,36 @@ class FileArtifact extends Artifact {
     }
   }
 
-  async doSave(artifactPath) {
-    await FileArtifact.moveTemporaryFile(this.logger, this.temporaryPath, artifactPath);
+  async doSave(artifactPath, options = {}) {
+    await FileArtifact.moveTemporaryFile(this.logger, this.temporaryPath, artifactPath, options.append);
   }
 
   async doDiscard() {
     await fs.remove(this.temporaryPath);
   }
 
-  static async moveTemporaryFile(logger, source, destination) {
-    if (await fs.exists(source)) {
-      logger.debug({ event: 'MOVE_FILE' }, `moving "${source}" to ${destination}`);
+  static async moveTemporaryFile(logger, source, destination, canAppend = false) {
+    if (!await fs.exists(source)) {
+      logger.warn({event: 'MOVE_FILE_MISSING'}, `did not find temporary file: ${source}`);
+      return false;
+    }
+
+    if (!await fs.exists(destination)) {
+      logger.debug({event: 'MOVE_FILE'}, `moving "${source}" to ${destination}`);
       await fs.move(source, destination);
       return true;
-    } else {
-      logger.warn({ event: 'MOVE_FILE_MISSING'} , `did not find temporary file: ${source}`);
-      return false;
     }
+
+    if (canAppend) {
+      logger.debug({event: 'MOVE_FILE'}, `moving "${source}" to ${destination} via appending`);
+      await appendFile(source, destination);
+      await fs.remove(source);
+      return true;
+    }
+
+    logger.warn({event: 'MOVE_FILE_EXISTS'}, `cannot overwrite: "${source}" => "${destination}"`);
+    await fs.remove(source);
+    return false;
   }
 }
 
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
index d04169ea1..0914ecbf2 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
@@ -12,8 +12,8 @@ class ArtifactPlugin {
   constructor({ api }) {
     this.api = api;
     this.context = {};
-    this.enabled = false;
-    this.keepOnlyFailedTestsArtifacts = false;
+    this.enabled = api.userConfig.enabled;
+    this.keepOnlyFailedTestsArtifacts = api.userConfig.keepOnlyFailedTestsArtifacts;
     this.priority = 16;
     this._disableReason = '';
     this._hasFailingTests = false;
diff --git a/detox/src/artifacts/templates/plugin/__mocks__/ArtifactsApi.mock.js b/detox/src/artifacts/templates/plugin/__mocks__/ArtifactsApi.mock.js
index 228d21fd4..75b9dc75c 100644
--- a/detox/src/artifacts/templates/plugin/__mocks__/ArtifactsApi.mock.js
+++ b/detox/src/artifacts/templates/plugin/__mocks__/ArtifactsApi.mock.js
@@ -1,5 +1,7 @@
 class ArtifactsApiMock {
-  constructor() {
+  constructor({ config }) {
+    this.userConfig = config;
+
     this.preparePathForArtifact = jest.fn();
     this.trackArtifact = jest.fn();
     this.untrackArtifact = jest.fn();
diff --git a/detox/src/artifacts/utils/ArtifactPathBuilder.js b/detox/src/artifacts/utils/ArtifactPathBuilder.js
index 69688092c..3da60a5b3 100644
--- a/detox/src/artifacts/utils/ArtifactPathBuilder.js
+++ b/detox/src/artifacts/utils/ArtifactPathBuilder.js
@@ -2,8 +2,8 @@ const path = require('path');
 const constructSafeFilename = require('../../utils/constructSafeFilename');
 
 class ArtifactPathBuilder {
-  constructor({ artifactsRootDir }) {
-    this._rootDir = artifactsRootDir;
+  constructor({ rootDir }) {
+    this._rootDir = rootDir;
   }
 
   get rootDir() {
diff --git a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js
index 709fc6ef6..72b68f8d8 100644
--- a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js
+++ b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.js
@@ -1,13 +1,14 @@
 const path = require('path');
 const getTimeStampString = require('./getTimeStampString');
 
-function buildDefaultRootForArtifactsRootDirpath(configuration, artifactsLocation) {
-  if (artifactsLocation.endsWith('/') || artifactsLocation.endsWith('\\')) {
-    return artifactsLocation;
+function buildDefaultRootForArtifactsRootDirpath(configuration, rootDir) {
+  if (rootDir.endsWith('/') || rootDir.endsWith('\\')) {
+    return rootDir;
   }
 
-  const subdir = `${configuration}.${getTimeStampString()}`;
-  return path.join(artifactsLocation, subdir);
+  const seed = Number(process.env.DETOX_START_TIMESTAMP || String(Date.now()));
+  const subdir = `${configuration}.${getTimeStampString(new Date(seed))}`;
+  return path.join(rootDir, subdir);
 }
 
 module.exports = buildDefaultRootForArtifactsRootDirpath;
diff --git a/detox/src/artifacts/video/VideoArtifactPlugin.js b/detox/src/artifacts/video/VideoArtifactPlugin.js
index 8f4be5bcb..c569c90e9 100644
--- a/detox/src/artifacts/video/VideoArtifactPlugin.js
+++ b/detox/src/artifacts/video/VideoArtifactPlugin.js
@@ -1,19 +1,34 @@
-const argparse = require('../../utils/argparse');
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
 
 class VideoArtifactPlugin extends WholeTestRecorderPlugin {
-  constructor(config) {
-    super(config);
-
-    const recordVideos = argparse.getArgValue('record-videos');
-
-    this.enabled = recordVideos && recordVideos !== 'none';
-    this.keepOnlyFailedTestsArtifacts = recordVideos === 'failing';
+  constructor({ api }) {
+    super({ api });
   }
 
   async preparePathForTestArtifact(testSummary) {
     return this.api.preparePathForArtifact('test.mp4', testSummary);
   }
+
+  static parseConfig(config) {
+    switch (config) {
+      case 'failing':
+        return {
+          enabled: true,
+          keepOnlyFailedTestsArtifacts: true,
+        };
+      case 'all':
+        return {
+          enabled: true,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+      case 'none':
+      default:
+        return {
+          enabled: false,
+          keepOnlyFailedTestsArtifacts: false,
+        };
+    }
+  }
 }
 
 module.exports = VideoArtifactPlugin;
diff --git a/detox/src/configuration.js b/detox/src/configuration.js
index 00647a0c3..f657cf3ca 100644
--- a/detox/src/configuration.js
+++ b/detox/src/configuration.js
@@ -1,6 +1,15 @@
+const _ = require('lodash');
+const path = require('path');
 const DetoxConfigError = require('./errors/DetoxConfigError');
 const uuid = require('./utils/uuid');
+const argparse = require('./utils/argparse');
 const getPort = require('get-port');
+const buildDefaultArtifactsRootDirpath = require('./artifacts/utils/buildDefaultArtifactsRootDirpath');
+
+const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
+const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
+const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
 
 async function defaultSession() {
   return {
@@ -35,10 +44,106 @@ function throwOnEmptyBinaryPath() {
   throw new DetoxConfigError(`'binaryPath' property is missing, should hold the app binary path`);
 }
 
+function composeDeviceConfig({ configurations }) {
+  const configurationName = argparse.getArgValue('configuration');
+  const deviceOverride = argparse.getArgValue('device-name');
+
+  const deviceConfig = (!configurationName && _.size(configurations) === 1)
+    ? _.values(configurations)[0]
+    : configurations[configurationName];
+
+  if (!deviceConfig) {
+    throw new Error(`Cannot determine which configuration to use. use --configuration to choose one of the following:
+                        ${Object.keys(configurations)}`);
+  }
+
+  if (!deviceConfig.type) {
+    throwOnEmptyType();
+  }
+
+  deviceConfig.device = deviceOverride || deviceConfig.device || deviceConfig.name;
+  delete deviceConfig.name;
+
+  if (_.isEmpty(deviceConfig.device)) {
+    throwOnEmptyDevice();
+  }
+
+  return deviceConfig;
+}
+
+function getArtifactsCliConfig() {
+  return {
+    artifactsLocation: argparse.getArgValue('artifacts-location'),
+    recordLogs: argparse.getArgValue('record-logs'),
+    takeScreenshots: argparse.getArgValue('take-screenshots'),
+    recordVideos: argparse.getArgValue('record-videos'),
+    recordPerformance: argparse.getArgValue('record-performance'),
+  };
+}
+
+function resolveModuleFromPath(modulePath) {
+  return path.isAbsolute(modulePath)
+    ? require(modulePath)
+    : require(path.join(process.cwd(), modulePath));
+}
+
+function composeArtifactsConfig({
+  configurationName,
+  deviceConfig,
+  detoxConfig,
+  cliConfig = getArtifactsCliConfig()
+}) {
+  const artifactsConfig = _.defaultsDeep(
+      {
+        rootDir: cliConfig.artifactsLocation,
+        plugins: {
+          log: cliConfig.recordLogs,
+          screenshot: cliConfig.takeScreenshots,
+          video: cliConfig.recordVideos,
+          instruments: cliConfig.recordPerformance,
+        },
+      },
+      deviceConfig.artifacts,
+      detoxConfig.artifacts,
+      {
+        rootDir: 'artifacts',
+        pathBuilder: null,
+        plugins: {
+          log: 'none',
+          screenshot: 'manual',
+          video: 'none',
+          instruments: 'none',
+        },
+      }
+  );
+
+  artifactsConfig.rootDir = buildDefaultArtifactsRootDirpath(
+    configurationName,
+    artifactsConfig.rootDir
+  );
+
+  if (typeof artifactsConfig.pathBuilder === 'string') {
+    artifactsConfig.pathBuilder = resolveModuleFromPath(artifactsConfig.pathBuilder);
+  }
+
+  artifactsConfig.plugins = _.mapValues(artifactsConfig.plugins, (value, key) => {
+    switch (key) {
+      case 'instruments': return SimulatorInstrumentsPlugin.parseConfig(value);
+      case 'log': return LogArtifactPlugin.parseConfig(value);
+      case 'screenshot': return ScreenshotArtifactPlugin.parseConfig(value);
+      case 'video': return VideoArtifactPlugin.parseConfig(value);
+    }
+  });
+
+  return artifactsConfig;
+}
+
 module.exports = {
   defaultSession,
   validateSession,
   throwOnEmptyDevice,
   throwOnEmptyType,
-  throwOnEmptyBinaryPath
+  throwOnEmptyBinaryPath,
+  composeDeviceConfig,
+  composeArtifactsConfig,
 };
diff --git a/detox/src/configurations.mock.js b/detox/src/configurations.mock.js
index ee9891e3b..e3e624924 100644
--- a/detox/src/configurations.mock.js
+++ b/detox/src/configurations.mock.js
@@ -1,3 +1,50 @@
+const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
+const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
+const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
+
+const defaultArtifactsConfiguration = {
+  rootDir: 'artifacts',
+  pathBuilder: null,
+  plugins: {
+    log: 'none',
+    screenshot: 'manual',
+    video: 'none',
+    instruments: 'none',
+  },
+};
+
+const allArtifactsConfiguration = {
+  rootDir: 'artifacts',
+  pathBuilder: null,
+  plugins: {
+    log: 'all',
+    screenshot: 'all',
+    video: 'all',
+    instruments: 'all',
+  },
+};
+
+const pluginsDefaultsResolved = {
+  log: LogArtifactPlugin.parseConfig('none'),
+  screenshot: ScreenshotArtifactPlugin.parseConfig('manual'),
+  video: VideoArtifactPlugin.parseConfig('none'),
+  instruments: SimulatorInstrumentsPlugin.parseConfig('none'),
+};
+
+const pluginsFailingResolved = {
+  log: LogArtifactPlugin.parseConfig('failing'),
+  screenshot: ScreenshotArtifactPlugin.parseConfig('failing'),
+  video: VideoArtifactPlugin.parseConfig('failing'),
+};
+
+const pluginsAllResolved = {
+  log: LogArtifactPlugin.parseConfig('all'),
+  screenshot: ScreenshotArtifactPlugin.parseConfig('all'),
+  video: VideoArtifactPlugin.parseConfig('all'),
+  instruments: SimulatorInstrumentsPlugin.parseConfig('all'),
+};
+
 const validOneDeviceNoSession = {
   "configurations": {
     "ios.sim.release": {
@@ -197,6 +244,11 @@ const deviceObjectEmulator = {
 };
 
 module.exports = {
+  allArtifactsConfiguration,
+  defaultArtifactsConfiguration,
+  pluginsAllResolved,
+  pluginsDefaultsResolved,
+  pluginsFailingResolved,
   validOneDeviceNoSession,
   validOneIosNoneDeviceNoSession,
   validTwoDevicesNoSession,
diff --git a/detox/src/utils/appendFile.js b/detox/src/utils/appendFile.js
new file mode 100644
index 000000000..1519f4e68
--- /dev/null
+++ b/detox/src/utils/appendFile.js
@@ -0,0 +1,17 @@
+const fs = require('fs');
+
+async function appendFile(src, dest) {
+  const writeStream = fs.createWriteStream(dest, {flags: 'a'});
+  const readStream = fs.createReadStream(src);
+
+  const promise = new Promise((resolve, reject) => {
+    readStream.on('error', e => reject(e));
+    writeStream.on('error', /* istanbul ignore next */ e => reject(e));
+    writeStream.on('close', () => resolve());
+  });
+
+  readStream.pipe(writeStream);
+  return promise;
+}
+
+module.exports = appendFile;
diff --git a/detox/src/utils/logger.js b/detox/src/utils/logger.js
index c352a6197..7929d1807 100644
--- a/detox/src/utils/logger.js
+++ b/detox/src/utils/logger.js
@@ -3,6 +3,7 @@ const path = require('path');
 const bunyan = require('bunyan');
 const bunyanDebugStream = require('bunyan-debug-stream');
 const argparse = require('./argparse');
+const temporaryPath = require('../artifacts/utils/temporaryPath');
 
 function adaptLogLevelName(level) {
   switch (level) {
@@ -61,26 +62,26 @@ function createPlainBunyanStream({ logPath, level }) {
 function init() {
   const levelFromArg = argparse.getArgValue('loglevel');
   const level = adaptLogLevelName(levelFromArg);
-  const logBaseFilename = path.join(argparse.getArgValue('artifacts-location') || '', `detox_pid_${process.pid}`);
-  const shouldRecordLogs = typeof DETOX_CLI === 'undefined' && ['failing', 'all'].indexOf(argparse.getArgValue('record-logs')) >= 0;
-
   const bunyanStreams = [createPlainBunyanStream({ level })];
-  if (shouldRecordLogs) {
-    const jsonFileStreamPath = logBaseFilename + '.json.log';
-    const plainFileStreamPath = logBaseFilename + '.log';
-
-    fs.ensureFileSync(jsonFileStreamPath);
-    fs.ensureFileSync(plainFileStreamPath);
-
-    bunyanStreams.push({
-      level,
-      path: jsonFileStreamPath,
-    });
 
-    bunyanStreams.push(createPlainBunyanStream({
-      level,
-      logPath: plainFileStreamPath,
-    }));
+  let jsonFileStreamPath, plainFileStreamPath;
+  if (!global.DETOX_CLI && !global.IS_RUNNING_DETOX_UNIT_TESTS) {
+    {
+      jsonFileStreamPath = temporaryPath.for.log();
+      fs.ensureFileSync(jsonFileStreamPath);
+      bunyanStreams.push({
+        level,
+        path: jsonFileStreamPath,
+      });
+    }
+    {
+      plainFileStreamPath = temporaryPath.for.log();
+      fs.ensureFileSync(plainFileStreamPath);
+      bunyanStreams.push(createPlainBunyanStream({
+        level,
+        logPath: plainFileStreamPath,
+      }));
+    }
   }
 
   const logger = bunyan.createLogger({
@@ -88,6 +89,14 @@ function init() {
     streams: bunyanStreams,
   });
 
+  if (jsonFileStreamPath) {
+    logger.jsonFileStreamPath = jsonFileStreamPath;
+  }
+
+  if (plainFileStreamPath) {
+    logger.plainFileStreamPath = plainFileStreamPath;
+  }
+
   return logger;
 }
 
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index b0d2b1aeb..83b1634a2 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -1,11 +1,11 @@
 # Configuration Options
 
-## Configuring package.json 
+## Configuring package.json
 
 ### Device Configuration
 
 `configurations` holds all the device configurations, if there is only one configuration in `configurations` `detox build` and `detox test` will default to it, to choose a specific configuration use `--configuration` param
-	
+
 |Configuration Params|Details|
 |---|---|
 |`type`| Device type, available options are `ios.simulator`, `ios.none`, `android.emulator`, and `android.attached`. |
@@ -13,7 +13,7 @@
 |`testBinaryPath`| (optional, Android only): relative path to the test app (apk) |
 |`device`| Device query, e.g. `{ "byType": "iPhone 11 Pro" }` for iOS simulator or `{ "avdName": "Nexus_5X_API_29" }` |
 |`build`| **[optional]** Build command (either `xcodebuild`, `react-native run-ios`, etc...), will be later available through detox CLI tool.|
-	
+
 **Example:**
 
 ```js
@@ -52,27 +52,62 @@
     }
   }
 }
-```	
-	
+```
+
+### Artifacts Configuration
+
+Detox can control artifacts collection via settings from `package.json`:
+
+```js
+{
+  "detox": {
+    "artifacts": {
+      "rootDir": ".artifacts",
+      "pathBuilder": "config/pathbuilder.js",
+      "plugins": {
+        "instruments": "none",
+        "log": "all",
+        "screenshot": "failing",
+        "video": "none"
+      }
+    },
+    "configurations": {
+      "ios.sim.release": {
+        "binaryPath": "/path/to/app",
+        "device": { /* ... */ },
+        "artifacts": {
+          "rootDir": ".artifacts/ios",
+          "plugins": { "instruments": "all" }
+        }
+      }
+    }
+  }
+}
+```
+
+As can be seen from the example above, in a specific configuration you may override individual properties from the default one.
+
+CLI arguments (e.g., `--artifacts-location`, `--record-logs`) still have the highest priority and override their counterparts from JSON.
+
 ### Server Configuration
 
 Detox can either initialize a server using a generated configuration, or can be overriden with a manual  configuration:
-	
+
 ```json
-	"detox": {
-	  ...
-	  "session": {
-		"server": "ws://localhost:8099",
-		"sessionId": "YourProjectSessionId"
-	  }
-	}
+  "detox": {
+    ...
+    "session": {
+    "server": "ws://localhost:8099",
+    "sessionId": "YourProjectSessionId"
+    }
+  }
 ```
 
 Session can also be set per configuration:
 
 ```json
   "detox": {
-	...
+  ...
     "configurations": {
       "ios.sim.debug": {
         ...
@@ -83,7 +118,7 @@ Session can also be set per configuration:
       }
     }
   }
-```	
+```
 
 ### Test Runner Configuration
 
@@ -91,11 +126,11 @@ Session can also be set per configuration:
 
 ##### Mocha
 ```json
-	"detox": {
-	  ...
-	  "test-runner": "mocha",
-	  "runner-config": "path/to/mocha.opts"
-	}
+  "detox": {
+    ...
+    "test-runner": "mocha",
+    "runner-config": "path/to/mocha.opts"
+  }
 ```
 
 `mocha.opts` refers to `--opts` in https://mochajs.org/#mochaopts
@@ -103,47 +138,47 @@ Session can also be set per configuration:
 ##### Jest
 
 ```json
-	"detox": {
-	  ...
-	  "test-runner": "jest"
-	  "runner-config": "path/to/config.json"
-	}
+  "detox": {
+    ...
+    "test-runner": "jest"
+    "runner-config": "path/to/config.json"
+  }
 ```
 
 `config.json` refers to `--config` in https://facebook.github.io/jest/docs/en/configuration.html
 
->NOTE: jest tests will run in band, as Detox does not currently supports parallelization. 
+>NOTE: jest tests will run in band, as Detox does not currently supports parallelization.
 
 ## detox-cli
 
 ### Build Configuration
 
-In your detox config (in `package.json`) paste your build command into the configuration's `build` field. 
-The build command will be triggered when running `detox build`.  
+In your detox config (in `package.json`) paste your build command into the configuration's `build` field.
+The build command will be triggered when running `detox build`.
 **This is only a convience method, to help you manage building multiple configurations of your app and couple them to your tests. You can also choose not to use it and provide a compiled `app` by yourself.**
 
 You can choose to build your project in any of these ways...
 
 * If there's only one configuration, you can simply use:
 
-	```sh
-	detox build
-	```
+  ```sh
+  detox build
+  ```
 
 * To choose a specific configuration:
-	
-	```sh
-	detox build --configuration yourConfiguration
-	```
+
+  ```sh
+  detox build --configuration yourConfiguration
+  ```
 * Building with xcodebuild:
 
-	```sh
-	xcodebuild -project ios/YourProject.xcodeproj -scheme YourProject -sdk iphonesimulator -derivedDataPath ios/build
-	```
-	
+  ```sh
+  xcodebuild -project ios/YourProject.xcodeproj -scheme YourProject -sdk iphonesimulator -derivedDataPath ios/build
+  ```
+
 * Building using React Native, this is the least suggested way of running your build, since it also starts a random simulator and installs the app on it.
-	
-  ```sh 
+
+  ```sh
   react-native run-ios
   ```
 
@@ -154,17 +189,17 @@ You can choose to build your project in any of these ways...
 
 * If there's only one configuration, you can simply use:
 
-	```sh
-	detox test ./e2e
-	```
+  ```sh
+  detox test ./e2e
+  ```
 
 where `./e2e` is the path to your Detox tests folder.
 
 * For multiple configurations, choose your configuration by passing `--configuration` param:
-	
-	```sh
-	detox test ./e2e --configuration yourConfiguration
-	```
+
+  ```sh
+  detox test ./e2e --configuration yourConfiguration
+  ```
 
 ### Faster test runs with app reuse
 
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index b6c077c30..0e5dff1ab 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -15,12 +15,16 @@
   "devDependencies": {
     "detox": "^14.6.1",
     "jest": "24.8.x",
-    "jest-circus": "24.8.x"
+    "jest-circus": "24.8.x",
+    "sanitize-filename": "^1.6.1"
   },
   "detox": {
     "test-runner": "jest",
     "configurations": {
       "ios.sim.release": {
+        "artifacts": {
+          "pathBuilder": "e2e/detox.pathbuilder.ios.js"
+        },
         "binaryPath": "../demo-react-native/ios/build/Build/Products/Release-iphonesimulator/example.app",
         "type": "ios.simulator",
         "device": {
@@ -28,6 +32,9 @@
         }
       },
       "android.emu.release": {
+        "artifacts": {
+          "pathBuilder": "e2e/detox.pathbuilder.android.js"
+        },
         "binaryPath": "../demo-react-native/android/app/build/outputs/apk/release/app-release.apk",
         "type": "android.emulator",
         "device": {


Test patch:
diff --git a/detox/__tests__/setupJest.js b/detox/__tests__/setupJest.js
index b64b7a83a..ec6a26419 100644
--- a/detox/__tests__/setupJest.js
+++ b/detox/__tests__/setupJest.js
@@ -28,3 +28,4 @@ function mockPackageJson(mockContent) {
 
 global.mockPackageJson = mockPackageJson;
 global.callCli = callCli;
+global.IS_RUNNING_DETOX_UNIT_TESTS = true;
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index 67daea6b3..ed4057b8e 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -3,7 +3,6 @@ const path = require('path');
 const cp = require('child_process');
 const fs = require('fs-extra');
 const environment = require('../src/utils/environment');
-const buildDefaultArtifactsRootDirpath = require('../src/artifacts/utils/buildDefaultArtifactsRootDirpath');
 const DetoxConfigError = require('../src/errors/DetoxConfigError');
 
 const log = require('../src/utils/logger').child({ __filename });
@@ -81,33 +80,28 @@ module.exports.builder = {
   a: {
     alias: 'artifacts-location',
     group: 'Debugging:',
-    describe: 'Artifacts (logs, screenshots, etc) root directory.',
-    default: 'artifacts'
+    describe: 'Artifacts (logs, screenshots, etc) root directory.'
   },
   'record-logs': {
     group: 'Debugging:',
     choices: ['failing', 'all', 'none'],
-    default: 'none',
     describe: 'Save logs during each test to artifacts directory. Pass "failing" to save logs of failing tests only.'
   },
   'take-screenshots': {
     group: 'Debugging:',
     choices: ['manual', 'failing', 'all', 'none'],
-    default: 'manual',
     describe:
       'Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only.'
   },
   'record-videos': {
     group: 'Debugging:',
     choices: ['failing', 'all', 'none'],
-    default: 'none',
     describe:
       'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.'
   },
   'record-performance': {
     group: 'Debugging:',
     choices: ['all', 'none'],
-    default: 'none',
     describe:
       '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.'
   },
@@ -150,8 +144,6 @@ module.exports.builder = {
 const collectExtraArgs = require('./utils/collectExtraArgs')(module.exports.builder);
 
 module.exports.handler = async function test(program) {
-  program.artifactsLocation = buildDefaultArtifactsRootDirpath(program.configuration, program.artifactsLocation);
-
   const config = getDetoxSection();
 
   if (!program.file && config.file) {
@@ -213,6 +205,19 @@ module.exports.handler = async function test(program) {
     return args.concat(fallbackTestFolder);
   }
 
+  function safeGuardArguments(args) {
+    if (_.last(args).includes(' ')) {
+      return args;
+    }
+
+    const safeArg = _.findLast(args, a => a.includes(' '));
+    if (!safeArg) {
+      return args;
+    }
+
+    return [..._.pull(args, safeArg), safeArg]
+  }
+
   function runMocha() {
     if (program.workers !== '1') {
       log.warn('Can not use -w, --workers. Parallel test execution is only supported with iOS and Jest');
@@ -220,22 +225,24 @@ module.exports.handler = async function test(program) {
 
     const command = _.compact([
       (path.join('node_modules', '.bin', runner)),
-      (runnerConfig ? `--opts ${runnerConfig}` : ''),
-      (program.configuration ? `--configuration ${program.configuration}` : ''),
-      (program.loglevel ? `--loglevel ${program.loglevel}` : ''),
-      (program.noColor ? '--no-colors' : ''),
-      (program.cleanup ? `--cleanup` : ''),
-      (program.reuse ? `--reuse` : ''),
-      (isFinite(program.debugSynchronization) ? `--debug-synchronization ${program.debugSynchronization}` : ''),
-      (platform ? `--grep ${getPlatformSpecificString()} --invert` : ''),
-      (program.headless ? `--headless` : ''),
-      (program.gpu ? `--gpu ${program.gpu}` : ''),
-      (hasCustomValue('record-logs') ? `--record-logs ${program.recordLogs}` : ''),
-      (hasCustomValue('take-screenshots') ? `--take-screenshots ${program.takeScreenshots}` : ''),
-      (hasCustomValue('record-videos') ? `--record-videos ${program.recordVideos}` : ''),
-      (hasCustomValue('record-performance') ? `--record-performance ${program.recordPerformance}` : ''),
-      (program.artifactsLocation ? `--artifacts-location "${program.artifactsLocation}"` : ''),
-      (program.deviceName ? `--device-name "${program.deviceName}"` : ''),
+      ...safeGuardArguments([
+        (runnerConfig ? `--opts ${runnerConfig}` : ''),
+        (program.configuration ? `--configuration ${program.configuration}` : ''),
+        (program.loglevel ? `--loglevel ${program.loglevel}` : ''),
+        (program.noColor ? '--no-colors' : ''),
+        (program.cleanup ? `--cleanup` : ''),
+        (program.reuse ? `--reuse` : ''),
+        (isFinite(program.debugSynchronization) ? `--debug-synchronization ${program.debugSynchronization}` : ''),
+        (platform ? `--grep ${getPlatformSpecificString()} --invert` : ''),
+        (program.headless ? `--headless` : ''),
+        (program.gpu ? `--gpu ${program.gpu}` : ''),
+        (hasCustomValue('record-logs') ? `--record-logs ${program.recordLogs}` : ''),
+        (hasCustomValue('take-screenshots') ? `--take-screenshots ${program.takeScreenshots}` : ''),
+        (hasCustomValue('record-videos') ? `--record-videos ${program.recordVideos}` : ''),
+        (hasCustomValue('record-performance') ? `--record-performance ${program.recordPerformance}` : ''),
+        (program.artifactsLocation ? `--artifacts-location "${program.artifactsLocation}"` : ''),
+        (program.deviceName ? `--device-name "${program.deviceName}"` : ''),
+      ]),
       ...getPassthroughArguments(),
     ]).join(' ');
 
@@ -267,31 +274,36 @@ module.exports.handler = async function test(program) {
 
     const command = _.compact([
       path.join('node_modules', '.bin', runner),
-      (runnerConfig ? `--config=${runnerConfig}` : ''),
-      (program.noColor ? ' --no-color' : ''),
-      `--maxWorkers=${program.workers}`,
-      (platform ? shellQuote(`--testNamePattern=^((?!${getPlatformSpecificString()}).)*$`) : ''),
+      ...safeGuardArguments([
+        (program.noColor ? ' --no-color' : ''),
+        (runnerConfig ? `--config ${runnerConfig}` : ''),
+        (platform ? shellQuote(`--testNamePattern=^((?!${getPlatformSpecificString()}).)*$`) : ''),
+        `--maxWorkers ${program.workers}`,
+      ]),
       ...getPassthroughArguments(),
     ]).join(' ');
 
-    const detoxEnvironmentVariables = _.pick(program, [
-      'configuration',
-      'loglevel',
-      'cleanup',
-      'reuse',
-      'debugSynchronization',
-      'gpu',
-      'headless',
-      'artifactsLocation',
-      'recordLogs',
-      'takeScreenshots',
-      'recordVideos',
-      'recordPerformance',
-      'deviceName',
-      'reportSpecs',
-      'readOnlyEmu',
-      'deviceLaunchArgs',
-    ]);
+    const detoxEnvironmentVariables = {
+      ..._.pick(program, [
+        'configuration',
+        'loglevel',
+        'cleanup',
+        'reuse',
+        'debugSynchronization',
+        'gpu',
+        'headless',
+        'artifactsLocation',
+        'recordLogs',
+        'takeScreenshots',
+        'recordVideos',
+        'recordPerformance',
+        'deviceName',
+        'reportSpecs',
+        'readOnlyEmu',
+        'deviceLaunchArgs',
+      ]),
+      DETOX_START_TIMESTAMP: Date.now(),
+    };
 
     launchTestRunner(command, detoxEnvironmentVariables);
   }
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 9b4fe0d88..187dbfe1e 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -45,7 +45,7 @@ describe('test', () => {
       await callCli('./test', 'test');
 
       expect(mockExec).toHaveBeenCalledWith(
-        expect.stringContaining(`${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --configuration only --grep :ios: --invert --artifacts-location "${normalize('artifacts/only.')}`),
+        expect.stringContaining(`${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --configuration only --grep :ios: --invert`),
         expect.anything()
       );
 
@@ -90,15 +90,11 @@ describe('test', () => {
 
       expect(mockExec).toHaveBeenCalledWith(
         expect.stringContaining(
-          `${normalize('node_modules/.bin/jest')} --config=e2e/config.json --maxWorkers=1 ${shellQuote('--testNamePattern=^((?!:ios:).)*$')} "e2e"`
+          `${normalize('node_modules/.bin/jest')} --config e2e/config.json ${shellQuote('--testNamePattern=^((?!:ios:).)*$')} --maxWorkers 1 "e2e"`
         ),
         expect.objectContaining({
           env: expect.objectContaining({
             configuration: 'only',
-            recordLogs: 'none',
-            takeScreenshots: 'manual',
-            recordVideos: 'none',
-            artifactsLocation: expect.stringContaining(normalize('artifacts/only.')),
           }),
         })
       );
@@ -137,7 +133,7 @@ describe('test', () => {
     );
 
     const expectWorkersArg = ({value}) => expect(mockExec).toHaveBeenCalledWith(
-      expect.stringContaining(`--maxWorkers=${value}`),
+      expect.stringContaining(`--maxWorkers ${value}`),
       expect.anything(),
     );
 
@@ -252,7 +248,7 @@ describe('test', () => {
     }
     expect(mockExec).toHaveBeenCalledWith(
       expect.stringContaining(
-        `${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --configuration only --debug-synchronization 3000 --grep :ios: --invert --artifacts-location "${normalize('artifacts/only.')}`
+        `${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --configuration only --debug-synchronization 3000 --grep :ios: --invert`
       ),
       expect.anything()
     );
diff --git a/detox/src/artifacts/ArtifactsManager.test.js b/detox/src/artifacts/ArtifactsManager.test.js
index 1fab21adc..996bf9d8f 100644
--- a/detox/src/artifacts/ArtifactsManager.test.js
+++ b/detox/src/artifacts/ArtifactsManager.test.js
@@ -3,14 +3,17 @@ const sleep = require('../utils/sleep');
 const testSummaries = require('./templates/plugin/__mocks__/testSummaries.mock');
 
 describe('ArtifactsManager', () => {
-  let proxy;
+  let proxy, FakePathBuilder;
 
   beforeEach(() => {
     jest.mock('fs-extra');
+    jest.mock('./__mocks__/FakePathBuilder');
     jest.mock('./utils/ArtifactPathBuilder');
     jest.mock('../utils/argparse');
     jest.mock('../utils/logger');
 
+    FakePathBuilder = require('./__mocks__/FakePathBuilder');
+
     proxy = {
       get ArtifactPathBuilder() {
         return require('./utils/ArtifactPathBuilder');
@@ -34,22 +37,37 @@ describe('ArtifactsManager', () => {
     let artifactsManager;
 
     beforeEach(() => {
-      proxy.argparse.getArgValue.mockImplementation((key) => {
-        return (key === 'artifacts-location') ? '/tmp' : '';
+      artifactsManager = new proxy.ArtifactsManager({
+        rootDir: '/tmp',
+        plugins: {},
       });
-
-      artifactsManager = new proxy.ArtifactsManager();
     });
 
     it('should provide artifacts location to path builder', async () => {
       expect(proxy.ArtifactPathBuilder).toHaveBeenCalledWith({
-        artifactsRootDir: '/tmp',
+        rootDir: '/tmp',
       });
     });
 
     it('should correctly terminate itself (without errors)', async () => {
       await artifactsManager.onTerminate();
     });
+
+    describe('with { pathBuilder } instance', () => {
+      it('should require that module as pathBuilder', () => {
+        const pathBuilder = new FakePathBuilder();
+        artifactsManager = new proxy.ArtifactsManager({ pathBuilder });
+        expect(artifactsManager._pathBuilder).toBe(pathBuilder);
+      });
+    })
+
+    describe('with { pathBuilder } function', () => {
+      it('should require that module as pathBuilder', () => {
+        const pathBuilder = jest.fn(() => new FakePathBuilder());
+        artifactsManager = new proxy.ArtifactsManager({ pathBuilder, rootDir: '/tmp/42' });
+        expect(pathBuilder).toHaveBeenCalledWith({ rootDir: '/tmp/42' });
+      });
+    })
   });
 
   describe('when plugin factory is registered', () => {
@@ -60,12 +78,18 @@ describe('ArtifactsManager', () => {
         onBeforeLaunchApp: jest.fn(),
       });
 
-      artifactsManager = new proxy.ArtifactsManager();
+      artifactsManager = new proxy.ArtifactsManager({
+        rootDir: '/tmp',
+        plugins: {
+          mock: { setting: 'value' },
+        },
+      });
       artifactsManager.registerArtifactPlugins({ mock: factory });
     });
 
     it('should get called immediately', () => {
       expect(factory).toHaveBeenCalledWith(expect.objectContaining({
+        userConfig: { setting: 'value' },
         preparePathForArtifact: expect.any(Function),
         trackArtifact: expect.any(Function),
         untrackArtifact: expect.any(Function),
@@ -86,6 +110,7 @@ describe('ArtifactsManager', () => {
 
         return (testPlugin = {
           name: 'testPlugin',
+          userConfig: api.userConfig,
           disable: jest.fn(),
           onBootDevice: jest.fn(),
           onBeforeShutdownDevice: jest.fn(),
@@ -103,14 +128,24 @@ describe('ArtifactsManager', () => {
         });
       };
 
-      pathBuilder = {
-        buildPathForTestArtifact: jest.fn(),
-      };
-
-      artifactsManager = new proxy.ArtifactsManager(pathBuilder);
+      pathBuilder = new FakePathBuilder();
+      artifactsManager = new proxy.ArtifactsManager({
+        pathBuilder,
+        plugins: {
+          testPlugin: {
+            lifecycle: 'all',
+          }
+        }
+      });
       artifactsManager.registerArtifactPlugins({ testPlugin: testPluginFactory });
     });
 
+    describe('.userConfig', () => {
+      it('should contain plugin config', async () => {
+        expect(artifactsApi.userConfig).toEqual({ lifecycle: 'all' });
+      });
+    });
+
     describe('.preparePathForArtifact()', () => {
       let argparse, fs;
 
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
new file mode 100644
index 000000000..b42164285
--- /dev/null
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
@@ -0,0 +1,20 @@
+const SimulatorInstrumentsPlugin = require('./SimulatorInstrumentsPlugin');
+
+describe('SimulatorInstrumentsPlugin', () => {
+    describe('static parseConfig(config)', () => {
+        const parseConfig = SimulatorInstrumentsPlugin.parseConfig;
+
+        const ENABLE_MODES = ['all'].map(x => [x]);
+        const DISABLE_MODES = ['none', 'manual', 'failing', { enabled: true }].map(x => [x]);
+        const INCLUSIVE_MODES = ['all', 'manual', 'none', 'failing', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
+
+        it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
+            expect(parseConfig(config).enabled).toBe(true));
+
+        it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
+            expect(parseConfig(config).enabled).toBe(false));
+
+        it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+            expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+    });
+});
diff --git a/detox/src/artifacts/log/LogArtifactPlugin.test.js b/detox/src/artifacts/log/LogArtifactPlugin.test.js
new file mode 100644
index 000000000..0abf12cff
--- /dev/null
+++ b/detox/src/artifacts/log/LogArtifactPlugin.test.js
@@ -0,0 +1,25 @@
+const LogArtifactPlugin = require('./LogArtifactPlugin');
+
+describe('LogArtifactPlugin', () => {
+    describe('static parseConfig(config)', () => {
+        const parseConfig = LogArtifactPlugin.parseConfig;
+
+        const ENABLE_MODES = ['all', 'failing'].map(x => [x]);
+        const DISABLE_MODES = ['none', { enabled: true }].map(x => [x]);
+
+        const INCLUSIVE_MODES = ['all', 'manual', 'none', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
+        const EXCLUSIVE_MODES = ['failing'].map(x => [x]);
+
+        it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
+            expect(parseConfig(config).enabled).toBe(true));
+
+        it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
+            expect(parseConfig(config).enabled).toBe(false));
+
+        it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+            expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+
+        it.each(EXCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+            expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(true));
+    });
+});
diff --git a/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js b/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
index 7ad46591e..f2221864e 100644
--- a/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
+++ b/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
@@ -65,7 +65,7 @@ describe('SimulatorLogPlugin', () => {
         },
       };
 
-      artifactsManager = new ArtifactsManager(fakePathBuilder);
+      artifactsManager = new ArtifactsManager({ pathBuilder: fakePathBuilder });
       artifactsManager.registerArtifactPlugins({
         log: (api) => new SimulatorLogPlugin({
           api,
diff --git a/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.test.js b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.test.js
new file mode 100644
index 000000000..8889e6662
--- /dev/null
+++ b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.test.js
@@ -0,0 +1,35 @@
+const ScreenshotArtifactPlugin = require('./ScreenshotArtifactPlugin');
+
+describe('ScreenshotArtifactPlugin', () => {
+  describe('static parseConfig(config)', () => {
+    const parseConfig = ScreenshotArtifactPlugin.parseConfig;
+
+    const ENABLE_MODES = ['all', 'manual', 'failing', { enabled: false }].map(x => [x]);
+    const DISABLE_MODES = ['none'].map(x => [x]);
+
+    const AUTOMATIC_MODES = ['all', 'failing'].map(x => [x]);
+    const MANUAL_MODES = ['manual', 'none', { shouldTakeAutomaticSnapshots: true }].map(x => [x]);
+
+    const INCLUSIVE_MODES = ['all', 'manual', 'none', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
+    const EXCLUSIVE_MODES = ['failing'].map(x => [x]);
+
+    it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
+        expect(parseConfig(config).enabled).toBe(true));
+
+    it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
+        expect(parseConfig(config).enabled).toBe(false));
+
+    it.each(AUTOMATIC_MODES)('should take automatic screenshots if config = %j', (config) =>
+        expect(parseConfig(config).shouldTakeAutomaticSnapshots).toBe(true));
+
+    it.each(MANUAL_MODES)('should not take automatic screenshots if config = %j', (config) =>
+        expect(parseConfig(config).shouldTakeAutomaticSnapshots).toBe(false));
+
+    it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+        expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+
+    it.each(EXCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+        expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(true));
+  });
+});
+
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.test.js b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
index cb4023bc0..263adb489 100644
--- a/detox/src/artifacts/templates/artifact/FileArtifact.test.js
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
@@ -5,7 +5,7 @@ const tempfile = require('tempfile');
 describe('FileArtifact', () => {
   let FileArtifact, fileArtifact, logger, temporaryPath, destinationPath;
 
-  beforeEach(() => {
+  beforeEach(async () => {
     jest.mock('../../../utils/logger');
     logger = require('../../../utils/logger');
     FileArtifact = require('./FileArtifact');
@@ -14,39 +14,44 @@ describe('FileArtifact', () => {
     destinationPath = tempfile('.artifact');
   });
 
-  afterEach(async () => await Promise.all([
-    fs.remove(temporaryPath),
-    fs.remove(destinationPath)
-  ]));
+  afterEach(async () => {
+    await Promise.all([
+      fs.remove(temporaryPath),
+      fs.remove(destinationPath)
+    ]);
+  });
 
   describe('save', () => {
-    describe('if temporary file exists', () => {
-      beforeEach(async () => {
-        await fs.ensureFile(temporaryPath);
-      });
-
-      it('should move file to the specified location and log a message', async () => {
-        fileArtifact = new FileArtifact({ temporaryPath });
-        await fileArtifact.save(destinationPath);
-
-        expect(await fs.exists(destinationPath)).toBe(true);
-        expect(await fs.exists(temporaryPath)).toBe(false);
+    beforeEach(() => {
+      jest.spyOn(FileArtifact, 'moveTemporaryFile').mockImplementation(_.noop);
+    });
 
-        expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+    describe('if temporary file is passed to constructor', () => {
+      beforeEach(() => {
+        fileArtifact = new FileArtifact({
+          name: 'CustomArtifact',
+          temporaryPath,
+        });
       });
-    });
 
-    describe('if temporary file does not exist', () => {
-      beforeEach(async () => {
-        await fs.remove(temporaryPath);
+      describe('when called simply', () => {
+        beforeEach(async () => {
+          await fileArtifact.save(destinationPath);
+        });
+
+        it('should call FileArtifact.moveTemporaryFile', async () => {
+          expect(FileArtifact.moveTemporaryFile).toHaveBeenCalledWith(logger, temporaryPath, destinationPath, undefined);
+        });
       });
 
-      it('should log a warning', async () => {
-        fileArtifact = new FileArtifact({ temporaryPath });
-        await fileArtifact.save(destinationPath);
+      describe('when called with { append: true }', () => {
+        beforeEach(async () => {
+          await fileArtifact.save(destinationPath, {append: true});
+        });
 
-        expect(await fs.exists(destinationPath)).toBe(false);
-        expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
+        it('should call FileArtifact.moveTemporaryFile with extra param', async () => {
+          expect(FileArtifact.moveTemporaryFile).toHaveBeenCalledWith(logger, temporaryPath, destinationPath, true);
+        });
       });
     });
 
@@ -61,26 +66,25 @@ describe('FileArtifact', () => {
         });
       });
 
-      describe('and start() was never called', () => {
-        it('should fail to find the file on save()', async () => {
+      describe('when save() is called without start()', () => {
+        beforeEach(async () => {
           await fileArtifact.save(destinationPath);
+        });
 
-          expect(await fs.exists(destinationPath)).toBe(false);
-          expect(await fs.exists(temporaryPath)).toBe(false);
+        it('should not call FileArtifact.moveTemporaryFile', async () => {
+          await fileArtifact.save(destinationPath);
+          expect(FileArtifact.moveTemporaryFile).not.toHaveBeenCalled();
         });
       });
 
       describe('and start() was called', () => {
         beforeEach(async () => {
           await fileArtifact.start();
-        });
-
-        it('should move file successfully and log', async () => {
           await fileArtifact.save(destinationPath);
+        });
 
-          expect(await fs.exists(destinationPath)).toBe(true);
-          expect(await fs.exists(temporaryPath)).toBe(false);
-          expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+        it('should call FileArtifact.moveTemporaryFile', async () => {
+          expect(FileArtifact.moveTemporaryFile).toHaveBeenCalledWith(logger, temporaryPath, destinationPath, undefined);
         });
       });
     });
@@ -115,19 +119,68 @@ describe('FileArtifact', () => {
 
   describe('static helper methods', () => {
     describe('.moveTemporaryFile', () => {
-      it('should move file from source to destination and log that', async () => {
-        await fs.ensureFile(temporaryPath);
-        await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
+      describe('if temporary file does not exist', () => {
+        beforeEach(async () => {
+          await fs.remove(temporaryPath);
+          await fs.writeFile(destinationPath, 'Hello');
+        });
+
+        it('should log a warning', async () => {
+          const result = await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
 
-        expect(await fs.exists(destinationPath)).toBe(true);
-        expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+          expect(result).toBe(false);
+          expect(await fs.readFile(destinationPath, 'utf8')).toBe('Hello');
+          expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
+        });
       });
 
-      it('should log error if source file does not exist', async () => {
-        await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
+      describe('if temporary file exists', () => {
+        beforeEach(async () => {
+          await fs.ensureFile(temporaryPath);
+        });
 
-        expect(await fs.exists(destinationPath)).toBe(false);
-        expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
+        describe('and the destination file does not exist', () => {
+          beforeEach(async () => {
+            await fs.remove(destinationPath);
+          });
+
+          it('should move file to the specified location and log a message', async () => {
+            const result = await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
+
+            expect(result).toBe(true);
+            expect(await fs.exists(destinationPath)).toBe(true);
+            expect(await fs.exists(temporaryPath)).toBe(false);
+
+            expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+          });
+        });
+
+        describe('and the destination file exists', () => {
+          beforeEach(async () => {
+            await fs.writeFile(destinationPath, 'My file');
+            await fs.writeFile(temporaryPath, ' and more to it');
+          });
+
+          it('should remove temporary file but refuse to append file', async () => {
+            const result = await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
+
+            expect(result).toBe(false);
+            expect(await fs.exists(temporaryPath)).toBe(false);
+            expect(await fs.readFile(destinationPath, 'utf8')).toBe('My file');
+
+            expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_EXISTS' }, expect.any(String));
+          });
+
+          it('should move temporary file via appending if canAppend = true', async () => {
+            const result = await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath, true);
+
+            expect(result).toBe(true);
+            expect(await fs.exists(temporaryPath)).toBe(false);
+            expect(await fs.readFile(destinationPath, 'utf8')).toBe('My file and more to it');
+
+            expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.stringContaining('appending'));
+          });
+        });
       });
     });
   });
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
index 59ccb1d93..c78678492 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
@@ -6,12 +6,18 @@ const testSummaries = require('./__mocks__/testSummaries.mock');
 
 class TestArtifactPlugin extends ArtifactPlugin {}
 
-describe(ArtifactPlugin, () => {
+describe('ArtifactPlugin', () => {
   let api;
   let plugin;
 
   beforeEach(() => {
-    api = {};
+    api = {
+      userConfig: {
+        enabled: false,
+        keepOnlyFailedTestsArtifacts: false,
+      },
+    };
+
     plugin = new TestArtifactPlugin({ api });
   });
 
diff --git a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
index 3437433f4..078c06589 100644
--- a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
@@ -9,7 +9,12 @@ describe('StartupAndTestRecorderPlugin', () => {
   let plugin;
 
   beforeEach(() => {
-    api = new ArtifactsApi();
+    api = new ArtifactsApi({
+      config: {
+        enabled: false,
+        keepOnlyFailedTestsArtifacts: false,
+      },
+    });
     plugin = new FakeStartupAndTestRecorderPlugin({ api });
   });
 
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
index 1696c23ff..90b2b2506 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
@@ -7,7 +7,8 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
   constructor({ api }) {
     super({ api });
 
-    this.shouldTakeAutomaticSnapshots = true;
+    this.shouldTakeAutomaticSnapshots = this.api.userConfig.shouldTakeAutomaticSnapshots;
+    this.keepOnlyFailedTestsArtifacts = this.api.userConfig.keepOnlyFailedTestsArtifacts;
     this.snapshots = {
       fromTest: {},
       fromSession: {},
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
index d6a1b74c3..1d722f736 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
@@ -9,7 +9,13 @@ describe('TwoSnapshotsPerTestPlugin', () => {
   let plugin;
 
   beforeEach(() => {
-    api = new ArtifactsApi();
+    api = new ArtifactsApi({
+      config: {
+        enabled: true,
+        shouldTakeAutomaticSnapshots: true,
+        keepOnlyFailedTestsArtifacts: false,
+      },
+    });
     plugin = new FakeTwoSnapshotsPerTestPlugin({ api });
   });
 
@@ -273,9 +279,8 @@ describe('TwoSnapshotsPerTestPlugin', () => {
 });
 
 class FakeTwoSnapshotsPerTestPlugin extends TwoSnapshotsPerTestPlugin {
-  constructor(...args) {
-    super(...args);
-    this.enabled = true;
+  constructor({ api }) {
+    super({ api });
     this.createTestArtifact = jest.fn(this.createTestArtifact.bind(this));
 
     const nonDeletable = { deleteProperty: () => true };
diff --git a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
index eaaa56152..fe7f513e0 100644
--- a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
@@ -8,7 +8,12 @@ describe('WholeTestRecorderPlugin', () => {
   let plugin;
 
   beforeEach(() => {
-    api = new ArtifactsApi();
+    api = new ArtifactsApi({
+      config: {
+        enabled: false,
+        keepOnlyFailedTestsArtifacts: false,
+      },
+    });
     plugin = new FakeWholeTestRecorderPlugin({ api });
   });
 
diff --git a/detox/src/artifacts/utils/ArtifactPathBuilder.test.js b/detox/src/artifacts/utils/ArtifactPathBuilder.test.js
index 6d28c9032..1f7a22746 100644
--- a/detox/src/artifacts/utils/ArtifactPathBuilder.test.js
+++ b/detox/src/artifacts/utils/ArtifactPathBuilder.test.js
@@ -8,7 +8,7 @@ describe(ArtifactPathBuilder, () => {
   describe('precise tests', () => {
     beforeEach(() => {
       pathBuilder = new ArtifactPathBuilder({
-        artifactsRootDir: '/tmp'
+        rootDir: '/tmp'
       });
     });
 
@@ -35,7 +35,7 @@ describe(ArtifactPathBuilder, () => {
   describe('snapshot tests', () => {
     beforeEach(() => {
       pathBuilder = new ArtifactPathBuilder({
-        artifactsRootDir: '/tmp/subdir',
+        rootDir: '/tmp/subdir',
       });
     });
 
diff --git a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.test.js b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.test.js
index a625df28d..5fb825f08 100644
--- a/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.test.js
+++ b/detox/src/artifacts/utils/buildDefaultArtifactsRootDirpath.test.js
@@ -1,15 +1,23 @@
 const path = require('path');
+const buildDefaultArtifactsRootDirpath = require('./buildDefaultArtifactsRootDirpath');
 
 describe('buildDefaultArtifactsRootDirpath', () => {
-  let buildDefaultArtifactsRootDirpath;
-
   beforeEach(() => {
-    jest.mock('./getTimeStampString', () => () => 'Today 1:23:45PM');
-    buildDefaultArtifactsRootDirpath = require('./buildDefaultArtifactsRootDirpath');
+    process.env.DETOX_START_TIMESTAMP = '1572951641499';
+  });
+
+  afterAll(() => {
+    delete process.env.DETOX_START_TIMESTAMP;
   });
 
   it('should append subdir if dir does not end with /', () => {
-    expect(buildDefaultArtifactsRootDirpath('iphone8', 'artifacts')).toBe(path.join('artifacts', 'iphone8.Today 1:23:45PM'));
+    expect(buildDefaultArtifactsRootDirpath('iphone8', 'artifacts')).toBe(path.join('artifacts', 'iphone8.2019-11-05 11-00-41Z'));
+  });
+
+  it('should fall back to Date.now if environment variable DETOX_START_TIMESTAMP is unset', () => {
+    delete process.env.DETOX_START_TIMESTAMP;
+    expect(buildDefaultArtifactsRootDirpath('iphone8', 'artifacts')).toMatch(/^artifacts[\\\/]iphone8\.\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}Z$/);
+    expect(buildDefaultArtifactsRootDirpath('iphone8', 'artifacts')).not.toBe(path.join('artifacts', 'iphone8.2019-11-05 11-00-41Z'));
   });
 
   it('should not append subdir if dir ends with /', () => {
diff --git a/detox/src/artifacts/video/VideoArtifactPlugin.test.js b/detox/src/artifacts/video/VideoArtifactPlugin.test.js
new file mode 100644
index 000000000..81ab3ff16
--- /dev/null
+++ b/detox/src/artifacts/video/VideoArtifactPlugin.test.js
@@ -0,0 +1,26 @@
+const VideoArtifactPlugin = require('./VideoArtifactPlugin');
+
+describe('VideoArtifactPlugin', () => {
+  describe('static parseConfig(config)', () => {
+    const parseConfig = VideoArtifactPlugin.parseConfig;
+
+    const ENABLE_MODES = ['all', 'failing'].map(x => [x]);
+    const DISABLE_MODES = ['none', { enabled: true }].map(x => [x]);
+
+    const INCLUSIVE_MODES = ['all', 'manual', 'none', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
+    const EXCLUSIVE_MODES = ['failing'].map(x => [x]);
+
+    it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
+        expect(parseConfig(config).enabled).toBe(true));
+
+    it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
+        expect(parseConfig(config).enabled).toBe(false));
+
+    it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+        expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+
+    it.each(EXCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+        expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(true));
+  });
+});
+
diff --git a/detox/src/configuration.test.js b/detox/src/configuration.test.js
index ef91f7794..867279901 100644
--- a/detox/src/configuration.test.js
+++ b/detox/src/configuration.test.js
@@ -1,5 +1,10 @@
 const _ = require('lodash');
+const path = require('path');
 const schemes = require('./configurations.mock');
+const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
+const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
+const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
 
 describe('configuration', () => {
   let configuration;
@@ -33,6 +38,155 @@ describe('configuration', () => {
     testFaultySession(schemes.invalidSessionNoSessionId.session);
   });
 
+  describe('composeArtifactsConfig', () => {
+    it('should produce a default config', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'abracadabra',
+        deviceConfig: {},
+        detoxConfig: {},
+      })).toEqual({
+        rootDir: expect.stringMatching(/^artifacts[\\\/]abracadabra\.\d{4}/),
+        pathBuilder: null,
+        plugins: schemes.pluginsDefaultsResolved,
+      });
+    });
+
+    it('should use artifacts config from the selected configuration', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'abracadabra',
+        deviceConfig: {
+          artifacts: {
+            ...schemes.allArtifactsConfiguration,
+            rootDir: 'otherPlace',
+            pathBuilder: _.noop,
+          }
+        },
+        detoxConfig: {},
+        cliConfig: {}
+      })).toEqual({
+        rootDir: expect.stringMatching(/^otherPlace[\\\/]abracadabra\.\d{4}/),
+        pathBuilder: _.noop,
+        plugins: schemes.pluginsAllResolved,
+      });
+    });
+
+    it('should use global artifacts config', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'abracadabra',
+        deviceConfig: {},
+        detoxConfig: {
+          artifacts: {
+            ...schemes.allArtifactsConfiguration,
+            rootDir: 'otherPlace',
+            pathBuilder: _.noop,
+          }
+        },
+        cliConfig: {}
+      })).toEqual({
+        rootDir: expect.stringMatching(/^otherPlace[\\\/]abracadabra\.\d{4}/),
+        pathBuilder: _.noop,
+        plugins: schemes.pluginsAllResolved,
+      });
+    });
+
+    it('should use CLI config', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'abracadabra',
+        deviceConfig: {},
+        detoxConfig: {},
+        cliConfig: {
+          artifactsLocation: 'otherPlace',
+          recordLogs: 'all',
+          takeScreenshots: 'all',
+          recordVideos: 'all',
+          recordPerformance: 'all',
+        }
+      })).toEqual({
+        rootDir: expect.stringMatching(/^otherPlace[\\\/]abracadabra\.\d{4}/),
+        pathBuilder: null,
+        plugins: schemes.pluginsAllResolved,
+      });
+    });
+
+    it('should prefer CLI config over selected configuration over global config', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'priority',
+        cliConfig: {
+          artifactsLocation: 'cli',
+        },
+        deviceConfig: {
+          artifacts: {
+            rootDir: 'configuration',
+            pathBuilder: _.identity,
+            plugins: {
+              log: 'failing',
+            },
+          },
+        },
+        detoxConfig: {
+          artifacts: {
+            rootDir: 'global',
+            pathBuilder: _.noop,
+            plugins: {
+              screenshot: 'all',
+            },
+          },
+        },
+      })).toEqual({
+        rootDir: expect.stringMatching(/^cli[\\\/]priority\.\d{4}/),
+        pathBuilder: _.identity,
+        plugins: {
+          log: schemes.pluginsFailingResolved.log,
+          screenshot: schemes.pluginsAllResolved.screenshot,
+          video: schemes.pluginsDefaultsResolved.video,
+          instruments: schemes.pluginsDefaultsResolved.instruments,
+        },
+      });
+    });
+
+    it('should resolve path builder from string (absolute path)', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'customization',
+        deviceConfig: {
+          artifacts: {
+            pathBuilder: path.join(__dirname, 'artifacts/__mocks__/FakePathBuilder')
+          },
+        },
+        detoxConfig: {},
+      })).toEqual(expect.objectContaining({
+        pathBuilder: require('./artifacts/__mocks__/FakePathBuilder'),
+      }));
+    });
+
+    it('should resolve path builder from string (relative path)', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'customization',
+        deviceConfig: {
+          artifacts: {
+            pathBuilder: 'package.json',
+          },
+        },
+        detoxConfig: {},
+      })).toEqual(expect.objectContaining({
+        pathBuilder: require(path.join(process.cwd(), 'package.json')),
+      }));
+    });
+
+    it('should not append configuration with timestamp if rootDir ends with slash', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'customization',
+        deviceConfig: {
+          artifacts: {
+            rootDir: '.artifacts/'
+          },
+        },
+        detoxConfig: {},
+      })).toEqual(expect.objectContaining({
+        rootDir: '.artifacts/',
+      }));
+    });
+  });
+
   function testFaultySession(config) {
     try {
       configuration.validateSession(config);
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index 522838500..1b1d3f2e8 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -71,6 +71,11 @@ describe('index', () => {
     await detox.init(schemes.validOneDeviceNoSession);
 
     expect(Detox).toHaveBeenCalledWith({
+      artifactsConfig: {
+        ...schemes.defaultArtifactsConfiguration,
+        plugins: schemes.pluginsDefaultsResolved,
+        rootDir: expect.stringMatching(/^artifacts[\\\/]ios\.sim\.release/),
+      },
       deviceConfig: schemes.validOneDeviceNoSession.configurations['ios.sim.release'],
       session: undefined,
     });
@@ -83,6 +88,11 @@ describe('index', () => {
     await detox.init(schemes.validTwoDevicesNoSession);
 
     expect(Detox).toHaveBeenCalledWith({
+      artifactsConfig: {
+        ...schemes.defaultArtifactsConfiguration,
+        plugins: schemes.pluginsDefaultsResolved,
+        rootDir: expect.stringMatching(/^artifacts[\\\/]ios\.sim\.debug/),
+      },
       deviceConfig: schemes.validTwoDevicesNoSession.configurations['ios.sim.debug'],
       session: undefined,
     });
@@ -113,6 +123,11 @@ describe('index', () => {
     }
 
     expect(Detox).toHaveBeenCalledWith({
+      artifactsConfig: {
+        ...schemes.defaultArtifactsConfiguration,
+        plugins: schemes.pluginsDefaultsResolved,
+        rootDir: expect.stringMatching(/^artifacts[\\\/]ios\.sim\.release/),
+      },
       deviceConfig: expectedConfig,
       session: undefined,
     });
diff --git a/detox/src/utils/appendFile.test.js b/detox/src/utils/appendFile.test.js
new file mode 100644
index 000000000..0b36b5e51
--- /dev/null
+++ b/detox/src/utils/appendFile.test.js
@@ -0,0 +1,35 @@
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+const appendFile = require('./appendFile');
+
+describe('appendFile', () => {
+  let src, dest;
+
+  beforeEach(() => {
+    src = tempfile();
+    dest = tempfile();
+  });
+
+  afterEach(async () => {
+    await Promise.all([src, dest].map(f => fs.remove(f)));
+  });
+
+  it('should throw error if source file does not exist', async () => {
+    await expect(appendFile(tempfile(), dest)).rejects.toThrowError(/ENOENT/);
+  });
+
+  it('should append source file contents to destination file contents', async () => {
+    await fs.writeFile(dest, 'Begin\n');
+    await fs.writeFile(src, 'End');
+
+    await appendFile(src, dest);
+    expect(await fs.readFile(dest, 'utf8')).toEqual('Begin\nEnd');
+  });
+
+  it('should create a new file in destination if it does not exist', async () => {
+    await fs.writeFile(src, 'Begin and End');
+
+    await appendFile(src, dest);
+    expect(await fs.readFile(dest, 'utf8')).toEqual('Begin and End');
+  });
+});
diff --git a/examples/demo-react-native-jest/e2e/detox.pathbuilder.android.js b/examples/demo-react-native-jest/e2e/detox.pathbuilder.android.js
new file mode 100644
index 000000000..6af0c372a
--- /dev/null
+++ b/examples/demo-react-native-jest/e2e/detox.pathbuilder.android.js
@@ -0,0 +1,4 @@
+const CustomPathBuilder = require('./detox.pathbuilder');
+module.exports = ({ rootDir }) => {
+  return new CustomPathBuilder({ rootDir, platform: 'android' });
+};
diff --git a/examples/demo-react-native-jest/e2e/detox.pathbuilder.ios.js b/examples/demo-react-native-jest/e2e/detox.pathbuilder.ios.js
new file mode 100644
index 000000000..a19d11f37
--- /dev/null
+++ b/examples/demo-react-native-jest/e2e/detox.pathbuilder.ios.js
@@ -0,0 +1,4 @@
+const CustomPathBuilder = require('./detox.pathbuilder');
+module.exports = ({ rootDir }) => {
+  return new CustomPathBuilder({ rootDir, platform: 'ios' });
+};
diff --git a/examples/demo-react-native-jest/e2e/detox.pathbuilder.js b/examples/demo-react-native-jest/e2e/detox.pathbuilder.js
new file mode 100644
index 000000000..039c5eb6e
--- /dev/null
+++ b/examples/demo-react-native-jest/e2e/detox.pathbuilder.js
@@ -0,0 +1,21 @@
+const path = require('path');
+const sanitizeFilename = require('sanitize-filename');
+
+const SANITIZE_OPTIONS = {replacement: '_'};
+const sanitize = (filename) => sanitizeFilename(filename, SANITIZE_OPTIONS);
+
+class CustomPathBuilder {
+  constructor({ platform, rootDir }) {
+    this.platform = platform;
+    this.rootDir = rootDir;
+  }
+
+  buildPathForTestArtifact(artifactName, testSummary = null) {
+    const fullName = testSummary && testSummary.fullName || '';
+    const segments = [this.rootDir, this.platform, sanitize(fullName), sanitize(artifactName)];
+
+    return path.join(...segments.filter(Boolean));
+  }
+}
+
+module.exports = CustomPathBuilder;