Repository: wix/Detox
Pull request #1727 (2019-11-03 13:56:56)

iOS: Move boot + bootstatus from critical section. 
**Description:**
Fixed non-optimal performance on multiple simulator boot, while every worker performed the boot inside the critical section, causing all the others to wait for it.
This fix moves everything besides the acquiring of devide udid outside the critical section.


Patch:
diff --git a/detox/src/artifacts/ArtifactsManager.js b/detox/src/artifacts/ArtifactsManager.js
index a912f606e..8d73f59d0 100644
--- a/detox/src/artifacts/ArtifactsManager.js
+++ b/detox/src/artifacts/ArtifactsManager.js
@@ -2,6 +2,7 @@ const _ = require('lodash');
 const fs = require('fs-extra');
 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');
@@ -13,7 +14,7 @@ class ArtifactsManager {
     this._idlePromise = Promise.resolve();
     this._idleCallbackRequests = [];
     this._activeArtifacts = [];
-    this._artifactPlugins = [];
+    this._artifactPlugins = {};
     this._pathBuilder = pathBuilder || new ArtifactPathBuilder({
       artifactsRootDir: argparse.getArgValue('artifacts-location') || 'artifacts',
     });
@@ -68,11 +69,9 @@ class ArtifactsManager {
   }
 
   registerArtifactPlugins(artifactPluginFactoriesMap = {}) {
-    const artifactPluginsFactories = Object.values(artifactPluginFactoriesMap);
-
-    this._artifactPlugins = artifactPluginsFactories.map((factory) => {
-      return this._instantitateArtifactPlugin(factory);
-    });
+    for (const [key, factory] of Object.entries(artifactPluginFactoriesMap)) {
+      this._artifactPlugins[key] = this._instantitateArtifactPlugin(factory);
+    }
   }
 
   subscribeToDeviceEvents(deviceEmitter) {
@@ -83,7 +82,7 @@ class ArtifactsManager {
     deviceEmitter.on('launchApp', this.onLaunchApp.bind(this));
     deviceEmitter.on('beforeUninstallApp', this.onBeforeUninstallApp.bind(this));
     deviceEmitter.on('beforeTerminateApp', this.onBeforeTerminateApp.bind(this));
-    deviceEmitter.on('userAction', this.onUserAction.bind(this));
+    deviceEmitter.on('createExternalArtifact', this.onCreateExternalArtifact.bind(this));
   }
 
   async onBootDevice(deviceInfo) {
@@ -114,8 +113,11 @@ class ArtifactsManager {
     await this._callPlugins('plain', 'onLaunchApp', appLaunchInfo);
   }
 
-  async onUserAction(actionInfo) {
-    await this._callPlugins('plain', 'onUserAction', actionInfo);
+  async onCreateExternalArtifact({ pluginId, artifactName, artifactPath }) {
+    await this._callSinglePlugin(pluginId, 'onCreateExternalArtifact', {
+      artifact: new FileArtifact({ temporaryPath: artifactPath }),
+      name: artifactName,
+    });
   }
 
   async onBeforeAll() {
@@ -136,7 +138,7 @@ class ArtifactsManager {
   }
 
   async onTerminate() {
-    if (this._artifactPlugins.length === 0) {
+    if (_.isEmpty(this._artifactPlugins)) {
       return;
     }
 
@@ -150,11 +152,26 @@ class ArtifactsManager {
 
     await Promise.all(this._activeArtifacts.map(artifact => artifact.discard()));
     await this._idlePromise;
-    this._artifactPlugins.splice(0);
+
+    for (const key of Object.keys(this._activeArtifacts)) {
+      delete this._artifactPlugins[key];
+    }
 
     log.info({ event: 'TERMINATE_SUCCESS' }, 'done.');
   }
 
+  async _callSinglePlugin(pluginId, methodName, ...args) {
+    const callSignature = this._composeCallSignature('artifactsManager', methodName, args);
+    log.trace(Object.assign({ event: 'LIFECYCLE', fn: methodName }, ...args), callSignature);
+
+    const plugin = this._artifactPlugins[pluginId];
+    try {
+      await plugin[methodName](...args);
+    } catch (e) {
+      this._unhandledPluginExceptionHandler(e, { plugin, methodName, args });
+    }
+  }
+
   async _callPlugins(strategy, methodName, ...args) {
     const callSignature = this._composeCallSignature('artifactsManager', methodName, args);
     log.trace(Object.assign({ event: 'LIFECYCLE', fn: methodName }, ...args), callSignature);
@@ -172,10 +189,11 @@ class ArtifactsManager {
 
   _groupPlugins(strategy) {
     if (strategy === 'plain') {
-      return [this._artifactPlugins];
+      return [_.values(this._artifactPlugins)];
     }
 
     const pluginsByPriority = _.chain(this._artifactPlugins)
+      .values()
       .groupBy('priority')
       .entries()
       .sortBy(([priority]) => Number(priority))
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
index a3381820b..3e04c07ca 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
@@ -1,5 +1,5 @@
 const fs = require('fs-extra');
-const tempfile = require('tempfile');
+const temporaryPath = require('../utils/temporaryPath');
 const argparse = require('../../utils/argparse');
 const log = require('../../utils/logger').child({ __filename });
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
@@ -57,7 +57,7 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
   createTestRecording() {
     return new SimulatorInstrumentsRecording({
       client: this.client,
-      temporaryRecordingPath: tempfile('.dtxrec'),
+      temporaryRecordingPath: temporaryPath.for.dtxrec(),
     });
   }
 
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js b/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
index 3558de042..ec1e2bf9d 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
@@ -1,6 +1,7 @@
 const _ = require('lodash');
 const log = require('../../utils/logger').child({ __filename });
 const Artifact = require('../templates/artifact/Artifact');
+const FileArtifact = require('../templates/artifact/FileArtifact');
 
 class SimulatorInstrumentsRecording extends Artifact {
   constructor({
@@ -34,7 +35,7 @@ class SimulatorInstrumentsRecording extends Artifact {
   }
 
   async doSave(artifactPath) {
-    const success = await Artifact.moveTemporaryFile(log, this.temporaryRecordingPath, artifactPath);
+    const success = await FileArtifact.moveTemporaryFile(log, this.temporaryRecordingPath, artifactPath);
     if (!success) {
       SimulatorInstrumentsRecording.hintAboutDetoxInstruments();
     }
diff --git a/detox/src/artifacts/log/ios/SimulatorLogPlugin.js b/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
index 81828eb49..25f1a438a 100644
--- a/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
+++ b/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
@@ -1,5 +1,5 @@
 const _ = require('lodash');
-const tempfile = require('tempfile');
+const temporaryPath = require('../../utils/temporaryPath');
 const LogArtifactPlugin = require('../LogArtifactPlugin');
 const SimulatorLogRecording = require('./SimulatorLogRecording');
 
@@ -32,7 +32,7 @@ class SimulatorLogPlugin extends LogArtifactPlugin {
       udid: this.context.deviceId,
       bundleId: this.context.bundleId,
       appleSimUtils: this.appleSimUtils,
-      temporaryLogPath: tempfile('.log'),
+      temporaryLogPath: temporaryPath.for.log(),
       config: {
         delayAfterStart: 50,
         delayBeforeStop: 50,
diff --git a/detox/src/artifacts/log/ios/SimulatorLogRecording.js b/detox/src/artifacts/log/ios/SimulatorLogRecording.js
index fdc84685f..781fddd38 100644
--- a/detox/src/artifacts/log/ios/SimulatorLogRecording.js
+++ b/detox/src/artifacts/log/ios/SimulatorLogRecording.js
@@ -3,6 +3,7 @@ const log = require('../../../utils/logger').child({ __filename });
 const sleep = require('../../../utils/sleep');
 const exec = require('../../../utils/exec');
 const Artifact = require('../../templates/artifact/Artifact');
+const FileArtifact = require('../../templates/artifact/FileArtifact');
 
 class SimulatorLogRecording extends Artifact {
   constructor({
@@ -65,7 +66,7 @@ class SimulatorLogRecording extends Artifact {
   }
 
   async doSave(artifactPath) {
-    await Artifact.moveTemporaryFile(log, this._logPath, artifactPath);
+    await FileArtifact.moveTemporaryFile(log, this._logPath, artifactPath);
   }
 
   async doDiscard() {
diff --git a/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
index 4d03cb0b8..117fa3ef4 100644
--- a/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
+++ b/detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js
@@ -12,55 +12,14 @@ class ScreenshotArtifactPlugin extends TwoSnapshotsPerTestPlugin {
 
     const takeScreenshots = argparse.getArgValue('take-screenshots');
 
-    this.enabled = takeScreenshots === 'all' || takeScreenshots === 'failing';
-    this.allowManualScreenshots = !takeScreenshots || takeScreenshots === 'manual';
+    this.enabled = !takeScreenshots || takeScreenshots !== 'none';
+    this.shouldTakeAutomaticSnapshots = takeScreenshots === 'failing' || takeScreenshots === 'all';
     this.keepOnlyFailedTestsArtifacts = takeScreenshots === 'failing';
-    this._warnAboutNotImplementedMode = _.once(this._warnAboutNotImplementedMode.bind(this));
-  }
-
-  async onBeforeEach(testSummary) {
-    this.context.testSummary = null;
-    await this._flushScreenshots();
-
-    await super.onBeforeEach(testSummary);
-  }
-
-  async onAfterEach(testSummary) {
-    await super.onAfterEach(testSummary);
-    await this._flushScreenshots();
-  }
-
-  async onAfterAll() {
-    await super.onAfterAll();
-    await this._flushScreenshots();
   }
 
   async preparePathForSnapshot(testSummary, name) {
     return this.api.preparePathForArtifact(`${name}.png`, testSummary);
   }
-
-  async onUserAction({ type, options }) {
-    if (this.enabled || this.allowManualScreenshots) {
-      switch (type) {
-        case 'takeScreenshot':
-          return this.keepOnlyFailedTestsArtifacts
-            ? this._warnAboutNotImplementedMode()
-            : this.takeSnapshot(options.name);
-      }
-    }
-  }
-
-  _warnAboutNotImplementedMode() {
-    const message = 'Taking screenshots on-demand is not supported yet for `--take-screenshots failing` mode.';
-    log.warn({ event: 'TAKE_SCREENSHOT_IGNORED' }, message);
-  }
-
-  async _flushScreenshots() {
-    for (const name of Object.keys(this.snapshots)) {
-      this.startSavingSnapshot(name);
-      delete this.snapshots[name];
-    }
-  }
 }
 
 module.exports = ScreenshotArtifactPlugin;
diff --git a/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js b/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js
index f8ce39526..e9ab5a789 100644
--- a/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js
+++ b/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js
@@ -1,8 +1,7 @@
 const _ = require('lodash');
-const fs = require('fs-extra');
 const log = require('../../utils/logger').child({ __filename });
-const tempfile = require('tempfile');
-const Artifact = require('../templates/artifact/Artifact');
+const temporaryPath = require('../utils/temporaryPath');
+const FileArtifact = require('../templates/artifact/FileArtifact');
 const ScreenshotArtifactPlugin = require('./ScreenshotArtifactPlugin');
 
 class SimulatorScreenshotter extends ScreenshotArtifactPlugin {
@@ -31,23 +30,13 @@ class SimulatorScreenshotter extends ScreenshotArtifactPlugin {
 
   createTestArtifact() {
     const { context, appleSimUtils } = this;
-    const temporaryFilePath = tempfile('.png');
 
-    return new Artifact({
+    return new FileArtifact({
       name: 'SimulatorScreenshot',
 
       async start() {
-        await appleSimUtils.takeScreenshot(context.deviceId, temporaryFilePath);
-      },
-
-      async save(artifactPath) {
-        log.debug({ event: 'MOVE_FILE' }, `moving file "${temporaryFilePath}" to "${artifactPath}"`);
-        await fs.move(temporaryFilePath, artifactPath);
-      },
-
-      async discard() {
-        log.debug({ event: 'REMOVE_FILE' }, `removing temp file: ${temporaryFilePath}`);
-        await fs.remove(temporaryFilePath);
+        this.temporaryPath = temporaryPath.for.png();
+        await appleSimUtils.takeScreenshot(context.deviceId, this.temporaryPath);
       },
     });
   }
diff --git a/detox/src/artifacts/templates/artifact/Artifact.js b/detox/src/artifacts/templates/artifact/Artifact.js
index 30d4a30fd..41c4b8536 100644
--- a/detox/src/artifacts/templates/artifact/Artifact.js
+++ b/detox/src/artifacts/templates/artifact/Artifact.js
@@ -1,29 +1,37 @@
-const fs = require('fs-extra');
 const log = require('../../../utils/logger').child({ __filename });
 
 class Artifact {
   constructor(template) {
-    this._startPromise = null;
-    this._stopPromise = null;
-    this._savePromise = null;
-    this._discardPromise = null;
+    defineNonEnumerableProperties(this, Artifact.nonEnumerableProperties);
 
     if (template) {
-      this._name = template.name || '';
+      const { name, start, stop, save, discard, ...misc } = template;
 
-      if (typeof template.start === 'function') {
-        this.doStart = template.start.bind(template);
+      Object.assign(this, misc);
+      this._name = name;
+
+      if (typeof start === 'function') {
+        this.doStart = start.bind(this);
       }
-      if (typeof template.stop === 'function') {
-        this.doStop = template.stop.bind(template);
+
+      if (typeof stop === 'function') {
+        this.doStop = stop.bind(template);
       }
-      if (typeof template.save === 'function') {
-        this.doSave = template.save.bind(template);
+
+      if (typeof save === 'function') {
+        this.doSave = save.bind(template);
       }
-      if (typeof template.discard === 'function') {
-        this.doDiscard = template.discard.bind(template);
+
+      if (typeof discard === 'function') {
+        this.doDiscard = discard.bind(template);
       }
     }
+
+    this._startPromise = null;
+    this._stopPromise = null;
+    this._savePromise = null;
+    this._discardPromise = null;
+    this.logger = log.child({ class: this.name });
   }
 
   get name() {
@@ -31,7 +39,7 @@ class Artifact {
   }
 
   start(...args) {
-    log.trace({ event: 'START', class: this.name }, `starting ${this.name}`, ...args);
+    this.logger.trace({ event: 'START' }, `starting ${this.name}`, ...args);
 
     if (this._savePromise) {
       this._startPromise = this._savePromise.then(() => this.doStart(...args));
@@ -49,7 +57,7 @@ class Artifact {
 
   stop(...args) {
     if (!this._stopPromise) {
-      log.trace({ event: 'STOP', class: this.name }, `stopping ${this.name}`, ...args);
+      this.logger.trace({ event: 'STOP' }, `stopping ${this.name}`, ...args);
 
       if (this._startPromise) {
         this._stopPromise = this._startPromise.then(() => this.doStop(...args));
@@ -63,10 +71,10 @@ class Artifact {
 
   save(artifactPath, ...args) {
     if (!this._savePromise) {
-      log.trace({ event: 'SAVE', class: this.name }, `saving ${this.name} to: ${artifactPath}`, ...args);
+      this.logger.trace({ event: 'SAVE' }, `saving ${this.name} to: ${artifactPath}`, ...args);
 
       if (this._discardPromise) {
-        log.warn({ event: 'SAVE_ERROR' }, `cannot save an already discarded artifact to: ${artifactPath}`);
+        this.logger.warn({ event: 'SAVE_ERROR' }, `cannot save an already discarded artifact to: ${artifactPath}`);
         this._savePromise = this._discardPromise;
       } else if (this._startPromise) {
         this._savePromise = this.stop().then(() => this.doSave(artifactPath, ...args));
@@ -80,7 +88,7 @@ class Artifact {
 
   discard(...args) {
     if (!this._discardPromise) {
-      log.trace({ event: 'DISCARD', class: this.name }, `discarding ${this.name}`, ...args);
+      this.logger.trace({ event: 'DISCARD' }, `discarding ${this.name}`, ...args);
 
       if (this._savePromise) {
         this._discardPromise = this._savePromise;
@@ -101,16 +109,28 @@ class Artifact {
   async doSave(artifactPath) {}
 
   async doDiscard() {}
+}
 
-  static async moveTemporaryFile(logger, source, destination) {
-    if (await fs.exists(source)) {
-      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;
-    }
+Artifact.nonEnumerableProperties = [
+  '_name',
+  '_startPromise',
+  '_stopPromise',
+  '_savePromise',
+  '_discardPromise',
+  'logger',
+  'doStart',
+  'doStop',
+  'doSave',
+  'doDiscard',
+];
+
+function defineNonEnumerableProperties(obj, props) {
+  for (const prop of props) {
+    Object.defineProperty(obj, prop, {
+      enumerable: false,
+      writable: true,
+      value: obj[prop],
+    });
   }
 }
 
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.js b/detox/src/artifacts/templates/artifact/FileArtifact.js
new file mode 100644
index 000000000..17d53e83c
--- /dev/null
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.js
@@ -0,0 +1,35 @@
+const fs = require('fs-extra');
+const Artifact = require('./Artifact');
+
+class FileArtifact extends Artifact {
+  constructor(template) {
+    super(template);
+
+    if (template.temporaryPath) {
+      this.start();
+      this.stop();
+    }
+  }
+
+  async doSave(artifactPath) {
+    await FileArtifact.moveTemporaryFile(this.logger, this.temporaryPath, artifactPath);
+  }
+
+  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}`);
+      await fs.move(source, destination);
+      return true;
+    } else {
+      logger.warn({ event: 'MOVE_FILE_MISSING'} , `did not find temporary file: ${source}`);
+      return false;
+    }
+  }
+}
+
+
+module.exports = FileArtifact;
diff --git a/detox/src/artifacts/templates/artifact/__mocks__/ArtifactMock.js b/detox/src/artifacts/templates/artifact/__mocks__/ArtifactMock.js
new file mode 100644
index 000000000..c05bd84bc
--- /dev/null
+++ b/detox/src/artifacts/templates/artifact/__mocks__/ArtifactMock.js
@@ -0,0 +1,21 @@
+const Artifact = require('../Artifact');
+
+class ArtifactMock extends Artifact {
+  constructor(type) {
+    super({
+      start: jest.fn(),
+      stop: jest.fn(),
+      save: jest.fn(),
+      discard: jest.fn(),
+    });
+
+    this.type = type;
+
+    jest.spyOn(this, 'start');
+    jest.spyOn(this, 'stop');
+    jest.spyOn(this, 'save');
+    jest.spyOn(this, 'discard');
+  }
+}
+
+module.exports = ArtifactMock;
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
index d3d5427eb..d04169ea1 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
@@ -16,6 +16,8 @@ class ArtifactPlugin {
     this.keepOnlyFailedTestsArtifacts = false;
     this.priority = 16;
     this._disableReason = '';
+    this._hasFailingTests = false;
+    this._finishedTests = false;
   }
 
   get name() {
@@ -161,16 +163,17 @@ class ArtifactPlugin {
   }
 
   /**
-   * Hook that is called on demand by some of user actions (e.g. device.takeScreeenshot())
+   * Hook that is supposed to be called when an artifact has been created indirectly,
+   * outside of lifecycle of the plugin.
    *
    * @protected
    * @async
-   * @param {Object} event - User action event object
-   * @param {string} event.type - Action type
-   * @param {string} event.options - Action options
-   * @return {Promise<any>} - appropriate result of the action
+   * @param {Object} event - Information about an indirectly created artifact
+   * @param {string} event.name - Target name for the artifact
+   * @param {Artifact} event.artifact - Artifact instance
+   * @return {Promise<void>} - when done
    */
-  async onUserAction(event) {}
+  async onCreateExternalArtifact(event) {}
 
   /**
    * Hook that is called before any test begins
@@ -181,6 +184,8 @@ class ArtifactPlugin {
    */
   async onBeforeAll() {
     this.context.testSummary = null;
+    this._hasFailingTests = false;
+    this._finishedTests = false;
   }
 
   /**
@@ -203,6 +208,10 @@ class ArtifactPlugin {
    */
   async onAfterEach(testSummary) {
     this.context.testSummary = testSummary;
+
+    if (testSummary.status === 'failed') {
+      this._hasFailingTests = true;
+    }
   }
 
   /**
@@ -214,6 +223,7 @@ class ArtifactPlugin {
    */
   async onAfterAll() {
     this.context.testSummary = null;
+    this._finishedTests = true;
     this._logDisableWarning();
   }
 
@@ -247,7 +257,31 @@ class ArtifactPlugin {
     }
   }
 
+  shouldKeepArtifactOfSession() {
+    if (!this.enabled) {
+      return false;
+    }
+
+    if (!this.keepOnlyFailedTestsArtifacts) {
+      return true;
+    }
+
+    if (this._hasFailingTests) {
+      return true;
+    }
+
+    if (this._finishedTests) {
+      return false;
+    }
+
+    return undefined;
+  }
+
   shouldKeepArtifactOfTest(testSummary) {
+    if (!this.enabled) {
+      return false;
+    }
+
     if (this.keepOnlyFailedTestsArtifacts && testSummary.status !== 'failed') {
       return false;
     }
diff --git a/detox/src/artifacts/utils/temporaryPath.js b/detox/src/artifacts/utils/temporaryPath.js
new file mode 100644
index 000000000..f4403bd81
--- /dev/null
+++ b/detox/src/artifacts/utils/temporaryPath.js
@@ -0,0 +1,12 @@
+const path = require('path');
+const tempfile = require('tempfile');
+
+module.exports = {
+  for: {
+    png: () => tempfile('.detox.png'),
+    log: () => tempfile('.detox.log'),
+    mp4: () => tempfile('.detox.mp4'),
+    dtxrec: () => tempfile('.detox.dtxrec'),
+  },
+  mask: () => path.join(tempfile(), '..') + path.sep + '*.detox.*',
+};
\ No newline at end of file
diff --git a/detox/src/artifacts/video/SimulatorRecordVideoPlugin.js b/detox/src/artifacts/video/SimulatorRecordVideoPlugin.js
index e2afef7e8..da34c4c2b 100644
--- a/detox/src/artifacts/video/SimulatorRecordVideoPlugin.js
+++ b/detox/src/artifacts/video/SimulatorRecordVideoPlugin.js
@@ -1,8 +1,9 @@
 const fs = require('fs-extra');
 const log = require('../../utils/logger').child({ __filename });
-const tempfile = require('tempfile');
+const temporaryPath = require('../utils/temporaryPath');
 const VideoArtifactPlugin = require('./VideoArtifactPlugin');
 const Artifact = require('../templates/artifact/Artifact');
+const FileArtifact = require('../templates/artifact/FileArtifact');
 const { interruptProcess } = require('../../utils/exec');
 
 class SimulatorRecordVideoPlugin extends VideoArtifactPlugin {
@@ -14,7 +15,7 @@ class SimulatorRecordVideoPlugin extends VideoArtifactPlugin {
 
   createTestRecording() {
     const { context, appleSimUtils } = this;
-    const temporaryFilePath = tempfile('.mp4');
+    const temporaryFilePath = temporaryPath.for.mp4();
     let processPromise = null;
 
     return new Artifact({
@@ -28,7 +29,7 @@ class SimulatorRecordVideoPlugin extends VideoArtifactPlugin {
         }
       },
       save: async (artifactPath) => {
-        await Artifact.moveTemporaryFile(log, temporaryFilePath, artifactPath);
+        await FileArtifact.moveTemporaryFile(log, temporaryFilePath, artifactPath);
       },
       discard: async () => {
         await fs.remove(temporaryFilePath);
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 3410cc997..368803c39 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -105,7 +105,7 @@ class Device {
       throw new Error('Cannot take a screenshot with an empty name.');
     }
 
-    return this.deviceDriver.takeScreenshot(name);
+    return this.deviceDriver.takeScreenshot(this._deviceId, name);
   }
 
   _isAppInBackground(params, _bundleId) {
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index e0a921905..a3924802b 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -16,6 +16,7 @@ const ADBLogcatPlugin = require('../../artifacts/log/android/ADBLogcatPlugin');
 const ADBScreencapPlugin = require('../../artifacts/screenshot/ADBScreencapPlugin');
 const ADBScreenrecorderPlugin = require('../../artifacts/video/ADBScreenrecorderPlugin');
 const AndroidDevicePathBuilder = require('../../artifacts/utils/AndroidDevicePathBuilder');
+const temporaryPath = require('../../artifacts/utils/temporaryPath');
 const sleep = require('../../utils/sleep');
 const retry = require('../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../utils/exec');
@@ -31,13 +32,13 @@ class AndroidDriver extends DeviceDriverBase {
 
     this.adb = new ADB();
     this.aapt = new AAPT();
+    this.devicePathBuilder = new AndroidDevicePathBuilder();
 
     this.pendingUrl = undefined;
   }
 
   declareArtifactPlugins() {
-    const adb = this.adb;
-    const devicePathBuilder = new AndroidDevicePathBuilder();
+    const { adb, devicePathBuilder } = this;
 
     return {
       log: (api) => new ADBLogcatPlugin({ api, adb, devicePathBuilder }),
@@ -163,6 +164,25 @@ class AndroidDriver extends DeviceDriverBase {
     await this.invocationManager.execute(call);
   }
 
+  async takeScreenshot(deviceId, screenshotName) {
+    const adb = this.adb;
+
+    const pathOnDevice = this.devicePathBuilder.buildTemporaryArtifactPath('.png');
+    await adb.screencap(deviceId, pathOnDevice);
+
+    const tempPath = temporaryPath.for.png();
+    await adb.pull(deviceId, pathOnDevice, tempPath);
+    await adb.rm(deviceId, pathOnDevice);
+
+    await this.emitter.emit('createExternalArtifact', {
+      pluginId: 'screenshot',
+      artifactName: screenshotName,
+      artifactPath: tempPath,
+    });
+
+    return tempPath;
+  }
+
   async setOrientation(deviceId, orientation) {
     const orientationMapping = {
       landscape: 1, // top at left side landscape
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index c43e4f3d0..13a1bbbd4 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -18,7 +18,7 @@ class DeviceDriverBase {
         'beforeUninstallApp',
         'beforeLaunchApp',
         'launchApp',
-        'userAction',
+        'createExternalArtifact',
       ],
       onError: this._onEmitError.bind(this),
     });
@@ -52,11 +52,8 @@ class DeviceDriverBase {
     return await Promise.resolve('');
   }
 
-  async takeScreenshot(name) {
-    await this.emitter.emit('userAction', {
-      type: 'takeScreenshot',
-      options: { name },
-    });
+  async takeScreenshot(deviceId, screenshotName) {
+    return await Promise.resolve('');
   }
 
   async sendToHome() {
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index 28164a11d..3d5fc709c 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -4,6 +4,7 @@ const path = require('path');
 const exec = require('child-process-promise').exec;
 const DeviceRegistry = require('../DeviceRegistry');
 const IosDriver = require('./IosDriver');
+const temporaryPath = require('../../artifacts/utils/temporaryPath');
 const configuration = require('../../configuration');
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const environment = require('../../utils/environment');
@@ -40,18 +41,18 @@ class SimulatorDriver extends IosDriver {
   }
 
   async acquireFreeDevice(deviceQuery) {
-    return this.deviceRegistry.allocateDevice(async () => {
-      const udid = await this._findOrCreateDevice(deviceQuery);
-      const deviceComment = this._commentDevice(deviceQuery);
+    const udid = await this.deviceRegistry.allocateDevice(async () => {
+      return await this._findOrCreateDevice(deviceQuery);
+    });
 
-      if (!udid) {
-        throw new Error(`Failed to find device matching ${deviceComment}`);
-      }
+    const deviceComment = this._commentDevice(deviceQuery);
+    if (!udid) {
+      throw new Error(`Failed to find device matching ${deviceComment}`);
+    }
 
-      await this._boot(udid);
-      this._name = `${udid} ${deviceComment}`;
-      return udid;
-    });
+    await this._boot(udid);
+    this._name = `${udid} ${deviceComment}`;
+    return udid;
   }
 
   async getBundleIdFromBinary(appPath) {
@@ -161,6 +162,19 @@ class SimulatorDriver extends IosDriver {
     return await this.client.waitForBackground();
   }
 
+  async takeScreenshot(udid, screenshotName) {
+    const tempPath = await temporaryPath.for.png();
+    await this.applesimutils.takeScreenshot(udid, tempPath);
+
+    await this.emitter.emit('createExternalArtifact', {
+      pluginId: 'screenshot',
+      artifactName: screenshotName,
+      artifactPath: tempPath,
+    });
+
+    return tempPath;
+  }
+
   /***
    * @private
    * @param {String | Object} rawDeviceQuery
diff --git a/docs/APIRef.Artifacts.md b/docs/APIRef.Artifacts.md
index a96089e19..feca7df64 100644
--- a/docs/APIRef.Artifacts.md
+++ b/docs/APIRef.Artifacts.md
@@ -15,8 +15,8 @@ Artifacts are disabled by default. Two things are required to enable them:
 * To record `.mp4` test run videos, add `--record-videos all` (or `--record-videos failing`, if you want to keep video recordings only for failing tests).
 * To record `.dtxrec` (Detox Instruments recordings) for each test, add `--record-performance all`. To open those recordings, you'll need [Detox Instruments](https://github.com/wix/DetoxInstruments). **NOTE:** only iOS is supported.
 * To take `.png` screenshots before and after each test, add `--take-screenshots all` (or `--take-screenshots failing`, if you want to keep only screenshots of failing tests).  
-Alternatively, you might leverage `await device.takeScreenshot('your screenshot name')` API to have manual control over taking the screenshots.  
-To disable screenshots subsystem forcibly, use `--take-screenshots none`.
+Alternatively, you might leverage the [device.takeScreenshot()](APIRef.DeviceObjectAPI.md#devicetakescreenshotname) API for manual control.
+
 * To change artifacts root directory location (by default it is `./artifacts`), add `--artifacts-location <path>`.  
 **NOTE:** There is a slightly obscure convention. If you want to create automatically a subdirectory with timestamp and configuration name (to avoid file overwrites upon consquent re-runs), specify a path to directory that does not end with a slash. Otherwise, if you want to put artifacts straight to the specified directory (in a case where you make a single run only, e.g. on CI), add a slash (or a backslash) to the end.
 
diff --git a/docs/APIRef.DeviceObjectAPI.md b/docs/APIRef.DeviceObjectAPI.md
index b6277a2a0..541101cd2 100644
--- a/docs/APIRef.DeviceObjectAPI.md
+++ b/docs/APIRef.DeviceObjectAPI.md
@@ -325,24 +325,34 @@ if (device.getPlatform() === 'ios') {
 ```
 
 ### `device.takeScreenshot(name)`
+
 Takes a screenshot on the device and schedules putting it to
 the [artifacts folder](APIRef.Artifacts.md#enabling-artifacts) upon
-completion of the current test. Consider the example below:
+completion of the current test.
+
+Returns a temporary path to the screenshot.
+
+**NOTE:** The returned path is guaranteed to be valid only during the test execution.
+Later on, the screenshot will be moved to the artifacts location.
+
+Consider the example below:
 
 ```js
 describe('Menu items', () => {
   it('should have Logout', async () => {
     // ...
-    await device.takeScreenshot('tap on menu');
+    const screenshotPath = await device.takeScreenshot('tap on menu');
     // ...
   });
 });
 ```
 
-* If the test passes, the screenshot will be put to `<artifacts-location>/✓ Menu items should have Logout/tap on menu.png`.
-* If the test fails, the screenshot will be put to `<artifacts-location>/✗ Menu items should have Logout/tap on menu.png`.
+In this example:
 
-> NOTE: At the moment, taking screenshots on-demand in `--take-screenshots failing` mode is not yet implemented.
+* If `--take-screenshots none` is set, the screenshot will be taken, but it won't be saved to `<artifacts-location>` after the test ends.
+* If `--take-screenshots failing` is set, and the test passes, the screenshot won't be saved to `<artifacts-location>` after the test ends.
+* In the other modes (`manual` and `all`), if the test passes, the screenshot will be put to `<artifacts-location>/✓ Menu items should have Logout/tap on menu.png`.
+* In the other modes (`manual` and `all`), if the test fails, the screenshot will be put to `<artifacts-location>/✗ Menu items should have Logout/tap on menu.png`.
 
 ### `device.shake()` **iOS Only**
 Simulate shake


Test patch:
diff --git a/detox/src/artifacts/ArtifactsManager.test.js b/detox/src/artifacts/ArtifactsManager.test.js
index a81d1dd06..1fab21adc 100644
--- a/detox/src/artifacts/ArtifactsManager.test.js
+++ b/detox/src/artifacts/ArtifactsManager.test.js
@@ -94,7 +94,7 @@ describe('ArtifactsManager', () => {
           onBeforeTerminateApp: jest.fn(),
           onBeforeLaunchApp: jest.fn(),
           onLaunchApp: jest.fn(),
-          onUserAction: jest.fn(),
+          onCreateExternalArtifact: jest.fn(),
           onBeforeAll: jest.fn(),
           onBeforeEach: jest.fn(),
           onAfterEach: jest.fn(),
@@ -108,7 +108,7 @@ describe('ArtifactsManager', () => {
       };
 
       artifactsManager = new proxy.ArtifactsManager(pathBuilder);
-      artifactsManager.registerArtifactPlugins({ testPluginFactory });
+      artifactsManager.registerArtifactPlugins({ testPlugin: testPluginFactory });
     });
 
     describe('.preparePathForArtifact()', () => {
@@ -261,11 +261,10 @@ describe('ArtifactsManager', () => {
           deviceId: 'testDeviceId',
         }));
 
-        itShouldCatchErrorsOnPhase('onUserAction', () => ({
-          type: 'takeScreenshot',
-          options: {
-            name: 'open app',
-          }
+        itShouldCatchErrorsOnPhase('onCreateExternalArtifact', () => ({
+          pluginId: 'testPlugin',
+          artifactName: 'example',
+          artifactPath: '/tmp/path/to/artifact',
         }));
 
         itShouldCatchErrorsOnPhase('onBeforeShutdownDevice', () => ({
@@ -406,17 +405,18 @@ describe('ArtifactsManager', () => {
       });
     });
 
-    describe('onUserAction', () => {
-      it('should call onUserAction in plugins', async () => {
-        const actionInfo = {
-          type: 'takeScreenshot',
-          options: {
-            name: 'open app',
-          },
-        };
+    describe('onCreateExternalArtifact', () => {
+      it('should call onCreateExternalArtifact in a specific plugin', async () => {
+        await artifactsManager.onCreateExternalArtifact({
+          pluginId: 'testPlugin',
+          artifactPath: '/tmp/path/to/artifact',
+          artifactName: 'Example',
+        });
 
-        await artifactsManager.onUserAction(actionInfo);
-        expect(testPlugin.onUserAction).toHaveBeenCalledWith(actionInfo);
+        expect(testPlugin.onCreateExternalArtifact).toHaveBeenCalledWith({
+          artifact: expect.any(Object),
+          name: 'Example',
+        });
       });
     });
 
diff --git a/detox/src/artifacts/templates/artifact/Artifact.test.js b/detox/src/artifacts/templates/artifact/Artifact.test.js
index 607eae2cc..9a33c17b7 100644
--- a/detox/src/artifacts/templates/artifact/Artifact.test.js
+++ b/detox/src/artifacts/templates/artifact/Artifact.test.js
@@ -1,11 +1,10 @@
 jest.mock('../../../utils/logger');
 
 const _ = require('lodash');
-const fs = require('fs-extra');
-const tempfile = require('tempfile');
+const util = require('util');
 const Artifact = require('./Artifact');
 
-describe(Artifact.name, () => {
+describe('Artifact', () => {
   let logger;
 
   beforeEach(() => {
@@ -478,32 +477,12 @@ describe(Artifact.name, () => {
     });
   });
 
-  describe('static helper methods', () => {
-    describe('.moveTemporaryFile', () => {
-      let source = '', destination = '';
-
-      beforeEach(() => {
-        source = tempfile('.tmp');
-      });
-
-      afterEach(async () => {
-        await fs.remove(source);
-        await fs.remove(destination);
-      });
-
-      it('should move file from source to destination and log that', async () => {
-        await fs.writeFile(source, 'dummy');
-        await Artifact.moveTemporaryFile(logger, source, destination = tempfile('.dest'));
-
-        expect(await fs.exists(destination)).toBe(true);
-        expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
-      });
-
-      it('should log error if source file does not exist', async () => {
-        await Artifact.moveTemporaryFile(logger, source, destination = tempfile('.dest'));
-        expect(await fs.exists(destination)).toBe(false);
-        expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
-      });
+  describe('util.inspect()', () => {
+    it('should print clean contents of Artifact instance', () => {
+      expect(util.inspect(new Artifact({
+        extraProp: 42,
+        start() {}
+      }))).toEqual(`Artifact { extraProp: 42 }`);
     });
   });
 });
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.test.js b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
new file mode 100644
index 000000000..cb4023bc0
--- /dev/null
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
@@ -0,0 +1,134 @@
+const _ = require('lodash');
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+
+describe('FileArtifact', () => {
+  let FileArtifact, fileArtifact, logger, temporaryPath, destinationPath;
+
+  beforeEach(() => {
+    jest.mock('../../../utils/logger');
+    logger = require('../../../utils/logger');
+    FileArtifact = require('./FileArtifact');
+    fileArtifact = null;
+    temporaryPath = tempfile('.artifact');
+    destinationPath = tempfile('.artifact');
+  });
+
+  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);
+
+        expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+      });
+    });
+
+    describe('if temporary file does not exist', () => {
+      beforeEach(async () => {
+        await fs.remove(temporaryPath);
+      });
+
+      it('should log a warning', async () => {
+        fileArtifact = new FileArtifact({ temporaryPath });
+        await fileArtifact.save(destinationPath);
+
+        expect(await fs.exists(destinationPath)).toBe(false);
+        expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
+      });
+    });
+
+    describe('if temporary file is created in start()', () => {
+      beforeEach(() => {
+        fileArtifact = new FileArtifact({
+          name: 'CustomArtifact',
+          async start() {
+            await fs.ensureFile(temporaryPath);
+            this.temporaryPath = temporaryPath;
+          }
+        });
+      });
+
+      describe('and start() was never called', () => {
+        it('should fail to find the file on save()', async () => {
+          await fileArtifact.save(destinationPath);
+
+          expect(await fs.exists(destinationPath)).toBe(false);
+          expect(await fs.exists(temporaryPath)).toBe(false);
+        });
+      });
+
+      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));
+        });
+      });
+    });
+  });
+
+  describe('discard', () => {
+    describe('if temporary file exists', () => {
+      beforeEach(async () => {
+        await fs.ensureFile(temporaryPath);
+      });
+
+      it('should remove the temporary file', async () => {
+        const fileArtifact = new FileArtifact({ temporaryPath });
+        await fileArtifact.discard(destinationPath);
+        expect(await fs.exists(temporaryPath)).toBe(false);
+      });
+    });
+
+    describe('if temporary file does not exist', () => {
+      beforeEach(async () => {
+        await fs.remove(temporaryPath);
+      });
+
+      it('should not log warnings', async () => {
+        const fileArtifact = new FileArtifact({ temporaryPath });
+        await fileArtifact.discard(destinationPath);
+
+        expect(logger.warn).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  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);
+
+        expect(await fs.exists(destinationPath)).toBe(true);
+        expect(logger.debug).toHaveBeenCalledWith({ event: 'MOVE_FILE' }, expect.any(String));
+      });
+
+      it('should log error if source file does not exist', async () => {
+        await FileArtifact.moveTemporaryFile(logger, temporaryPath, destinationPath);
+
+        expect(await fs.exists(destinationPath)).toBe(false);
+        expect(logger.warn).toHaveBeenCalledWith({ event: 'MOVE_FILE_MISSING' }, expect.any(String));
+      });
+    });
+  });
+});
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
index 89498d2ba..59ccb1d93 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
@@ -1,6 +1,7 @@
 jest.mock('../../../utils/logger');
 const logger = require('../../../utils/logger');
 const ArtifactPlugin = require('./ArtifactPlugin');
+const FileArtifact = require('../artifact/FileArtifact');
 const testSummaries = require('./__mocks__/testSummaries.mock');
 
 class TestArtifactPlugin extends ArtifactPlugin {}
@@ -176,8 +177,13 @@ describe(ArtifactPlugin, () => {
       });
     });
 
-    it('should have .onUserAction', async () => {
-      await expect(plugin.onUserAction()).resolves.toBe(void 0);
+    it('should have .onCreateExternalArtifact', async () => {
+      await expect(plugin.onCreateExternalArtifact({
+        name: 'The custom artifact',
+        artifact: new FileArtifact({
+          temporaryPath: '/tmp/path/to/artifact.file'
+        }),
+      })).resolves.toBe(void 0);
     });
 
     it('should have .onBeforeAll, which resets context.testSummary if called', async () => {
@@ -247,6 +253,14 @@ describe(ArtifactPlugin, () => {
     describe('if should not keep specifically failed test artifacts', () => {
       beforeEach(() => {
         plugin.keepOnlyFailedTestsArtifacts = false;
+        plugin.enabled = true;
+      });
+
+      it('should return false if it is disabled', () => {
+        plugin.enabled = false;
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.running())).toBe(false);
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.passed())).toBe(false);
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.failed())).toBe(false);
       });
 
       it('should return true for testSummary.status === running', () =>
@@ -262,6 +276,14 @@ describe(ArtifactPlugin, () => {
     describe('if should keep only failed test artifacts', () => {
       beforeEach(() => {
         plugin.keepOnlyFailedTestsArtifacts = true;
+        plugin.enabled = true;
+      });
+
+      it('should return false if it is disabled', () => {
+        plugin.enabled = false;
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.running())).toBe(false);
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.passed())).toBe(false);
+        expect(plugin.shouldKeepArtifactOfTest(testSummaries.failed())).toBe(false);
       });
 
       it('should return false for testSummary.status === running', () =>
diff --git a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
index 283d65559..88a918e3a 100644
--- a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
+++ b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
@@ -10,7 +10,6 @@ class StartupAndTestRecorderPlugin extends WholeTestRecorderPlugin {
     this.startupRecording = null;
     this._isInStartupPhase = true;
     this._isRecordingStartup = false;
-    this._hasFailingTests = false;
   }
 
   /***
@@ -47,20 +46,18 @@ class StartupAndTestRecorderPlugin extends WholeTestRecorderPlugin {
   }
 
   async onAfterEach(testSummary) {
-    if (testSummary.status === 'failed') {
-      this._hasFailingTests = true;
-    }
-
     await super.onAfterEach(testSummary);
 
     if (this.startupRecording) {
-      this._tryToFinalizeStartupRecording(false);
+      this._tryToFinalizeStartupRecording();
     }
   }
 
   async onAfterAll() {
+    await super.onAfterAll();
+
     if (this.startupRecording) {
-      this._finalizeStartupRecording();
+      this._tryToFinalizeStartupRecording();
     }
 
     await super.onAfterAll();
@@ -78,28 +75,20 @@ class StartupAndTestRecorderPlugin extends WholeTestRecorderPlugin {
    */
   async preparePathForStartupArtifact() {}
 
-  _shouldKeepStartupRecording() {
-    if (this.keepOnlyFailedTestsArtifacts && !this._hasFailingTests) {
-      return false;
-    }
+  _tryToFinalizeStartupRecording() {
+    const shouldKeep = this.shouldKeepArtifactOfSession();
 
-    return true;
-  }
-
-  _tryToFinalizeStartupRecording(isExiting) {
-    if (this._shouldKeepStartupRecording()) {
+    if (shouldKeep === true) {
       this._startSavingStartupRecording(this.startupRecording);
       this.startupRecording = null;
-    } else if (isExiting) {
+    }
+
+    if (shouldKeep === false) {
       this._startDiscardingStartupRecording(this.startupRecording);
       this.startupRecording = null;
     }
   }
 
-  _finalizeStartupRecording() {
-    this._tryToFinalizeStartupRecording(true);
-  }
-
   _startSavingStartupRecording(startupRecording) {
     this.api.requestIdleCallback(async () => {
       const artifactPath = await this.preparePathForStartupArtifact();
diff --git a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
index 7764067b8..3437433f4 100644
--- a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
@@ -2,6 +2,7 @@ jest.mock('../../../utils/logger.js');
 const StartupAndTestRecorderPlugin = require('./StartupAndTestRecorderPlugin');
 const ArtifactsApi = require('./__mocks__/ArtifactsApi.mock');
 const testSummaries = require('./__mocks__/testSummaries.mock');
+const ArtifactMock = require('../artifact/__mocks__/ArtifactMock');
 
 describe('StartupAndTestRecorderPlugin', () => {
   let api;
@@ -367,14 +368,7 @@ class FakeStartupAndTestRecorderPlugin extends StartupAndTestRecorderPlugin {
   }
 
   _createArtifactMock(type) {
-    const artifact = {
-      type,
-      start: jest.fn(),
-      stop: jest.fn(),
-      save: jest.fn(),
-      discard: jest.fn(),
-    };
-
+    const artifact = new ArtifactMock(type);
     this.createdArtifacts.push(artifact);
     return artifact;
   }
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
index 4052bf047..1696c23ff 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
@@ -6,10 +6,18 @@ const ArtifactPlugin = require('./ArtifactPlugin');
 class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
   constructor({ api }) {
     super({ api });
-    this.snapshots = {};
+
+    this.shouldTakeAutomaticSnapshots = true;
+    this.snapshots = {
+      fromTest: {},
+      fromSession: {},
+    };
   }
 
   async onBeforeEach(testSummary) {
+    this.context.testSummary = null;
+    this._flushSessionSnapshots();
+
     await super.onBeforeEach(testSummary);
     await this._takeAutomaticSnapshot('beforeEach');
   }
@@ -19,13 +27,26 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
 
     if (this.shouldKeepArtifactOfTest(testSummary)) {
       await this._takeAutomaticSnapshot('afterEach');
-      this.startSavingSnapshot('beforeEach');
-      this.startSavingSnapshot('afterEach');
+      this._startSavingSnapshots('fromTest');
     } else {
-      this.startDiscardingSnapshot('beforeEach');
+      this._startDiscardingSnapshots('fromTest');
+    }
+
+    this.context.testSummary = null;
+    this._flushSessionSnapshots();
+  }
+
+  async onAfterAll() {
+    await super.onAfterAll();
+    this._flushSessionSnapshots();
+  }
+
+  async onCreateExternalArtifact(e) {
+    if (!e.artifact) {
+      throw new Error('Internal error: expected Artifact instance in the event');
     }
 
-    this._clearAutomaticSnapshotReferences();
+    this._registerSnapshot(e.name, e.artifact);
   }
 
   /***
@@ -44,58 +65,65 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
    */
   createTestArtifact() {}
 
-  _clearAutomaticSnapshotReferences() {
-    delete this.snapshots.beforeEach;
-    delete this.snapshots.afterEach;
-  }
-
   async _takeAutomaticSnapshot(name) {
-    if (this.enabled) {
-      await this.takeSnapshot(name);
+    if (this.enabled && this.shouldTakeAutomaticSnapshots) {
+      await this._takeSnapshot(name);
     }
   }
 
-  /***
-   * @protected
-   */
-  async takeSnapshot(name) {
-    const snapshot = this.snapshots[name] = this.createTestArtifact();
+  async _takeSnapshot(name) {
+    const snapshot = this.createTestArtifact();
     await snapshot.start();
     await snapshot.stop();
 
+    this._registerSnapshot(name, snapshot);
+  }
+
+  _registerSnapshot(name, snapshot) {
+    const snapshots = this.context.testSummary
+      ? this.snapshots.fromTest
+      : this.snapshots.fromSession;
+
+    snapshots[name] = snapshot;
     this.api.trackArtifact(snapshot);
   }
 
-  /***
-   * @protected
-   */
-  startSavingSnapshot(name) {
-    const snapshot = this.snapshots[name];
-    if (!snapshot) {
-      return;
+  _startSavingSnapshots(where) {
+    const {testSummary} = this.context;
+    const snapshots = this.snapshots[where];
+
+    for (const [name, snapshot] of Object.entries(snapshots)) {
+      delete this.snapshots[name];
+
+      this.api.requestIdleCallback(async () => {
+        const snapshotArtifactPath = await this.preparePathForSnapshot(testSummary, name);
+        await snapshot.save(snapshotArtifactPath);
+        this.api.untrackArtifact(snapshot);
+      });
     }
+  }
 
-    const {testSummary} = this.context;
-    this.api.requestIdleCallback(async () => {
-      const snapshotArtifactPath = await this.preparePathForSnapshot(testSummary, name);
-      await snapshot.save(snapshotArtifactPath);
-      this.api.untrackArtifact(snapshot);
-    });
+  _startDiscardingSnapshots(where) {
+    const snapshots = this.snapshots[where];
+
+    for (const [name, snapshot] of Object.entries(snapshots)) {
+      delete this.snapshots[name];
+
+      this.api.requestIdleCallback(async () => {
+        await snapshot.discard();
+        this.api.untrackArtifact(snapshot);
+      });
+    }
   }
 
-  /***
-   * @protected
-   */
-  startDiscardingSnapshot(name) {
-    const snapshot = this.snapshots[name];
-    if (!snapshot) {
-      return;
+  _flushSessionSnapshots() {
+    if (this.shouldKeepArtifactOfSession() === true) {
+      this._startSavingSnapshots('fromSession');
     }
 
-    this.api.requestIdleCallback(async () => {
-      await snapshot.discard();
-      this.api.untrackArtifact(snapshot);
-    });
+    if (this.shouldKeepArtifactOfSession() === false) {
+      this._startDiscardingSnapshots('fromSession');
+    }
   }
 }
 
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
index 60644de48..d6a1b74c3 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
@@ -1,5 +1,6 @@
 jest.mock('../../../utils/logger.js');
 const TwoSnapshotsPerTestPlugin = require('./TwoSnapshotsPerTestPlugin');
+const ArtifactMock = require('../artifact/__mocks__/ArtifactMock');
 const ArtifactsApi = require('./__mocks__/ArtifactsApi.mock');
 const testSummaries = require('./__mocks__/testSummaries.mock');
 
@@ -61,14 +62,26 @@ describe('TwoSnapshotsPerTestPlugin', () => {
     });
 
     it('should start and stop recording in the artifact', () => {
-      const [createdArtifact] = plugin.createdArtifacts;
-      expect(createdArtifact.start).toHaveBeenCalledTimes(1);
-      expect(createdArtifact.stop).toHaveBeenCalledTimes(1);
+      expect(plugin.snapshots.fromTest['beforeEach'].start).toHaveBeenCalledTimes(1);
+      expect(plugin.snapshots.fromTest['beforeEach'].stop).toHaveBeenCalledTimes(1);
     });
 
     it('should put the artifact under tracking', () => {
-      const [createdArtifact] = plugin.createdArtifacts;
-      expect(api.trackArtifact).toHaveBeenCalledWith(createdArtifact);
+      expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['beforeEach']);
+    });
+  });
+
+  describe('onCreateExternalArtifact', () => {
+    it('should throw error if { artifact } is not defined', async () => {
+      await expect(plugin.onCreateExternalArtifact({ name: 'Hello'})).rejects.toThrowError();
+    });
+
+    it('should set snapshot in key-value map and track it', async () => {
+      const artifact = new ArtifactMock('test');
+      await plugin.onCreateExternalArtifact({ name: 'hello', artifact });
+
+      expect(plugin.snapshots.fromSession['hello']).toBe(artifact);
+      expect(api.trackArtifact).toHaveBeenCalledWith(artifact);
     });
   });
 
@@ -86,14 +99,12 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       });
 
       it('should start and stop the second test artifact', () => {
-        const [, secondArtifact] = plugin.createdArtifacts;
-        expect(secondArtifact.start).toHaveBeenCalledTimes(1);
-        expect(secondArtifact.stop).toHaveBeenCalledTimes(1);
+        expect(plugin.snapshots.fromTest['afterEach'].start).toHaveBeenCalledTimes(1);
+        expect(plugin.snapshots.fromTest['afterEach'].stop).toHaveBeenCalledTimes(1);
       });
 
       it('should put the second test artifact under tracking', () => {
-        const [, secondArtifact] = plugin.createdArtifacts;
-        expect(api.trackArtifact).toHaveBeenCalledWith(secondArtifact);
+        expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['afterEach']);
       });
 
       it('should schedule two saving operations and specify itself as an initiator', () => {
@@ -105,25 +116,100 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       it('should schedule to save and untrack the first artifact', async () => {
         const [saveRequest] = api.requestIdleCallback.mock.calls[0];
 
-        expect(plugin.createdArtifacts[0].save).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['beforeEach'].save).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await saveRequest();
 
-        expect(plugin.createdArtifacts[0].save).toBeCalledWith('test/beforeEach.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.createdArtifacts[0]);
+        expect(plugin.snapshots.fromTest['beforeEach'].save).toBeCalledWith('test/beforeEach.png');
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['beforeEach']);
       });
 
       it('should ultimately save and untrack the second artifact', async () => {
         const [saveRequest] = api.requestIdleCallback.mock.calls[1];
 
-        expect(plugin.createdArtifacts[1].save).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['afterEach'].save).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await saveRequest();
 
-        expect(plugin.createdArtifacts[1].save).toBeCalledWith('test/afterEach.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.createdArtifacts[1]);
+        expect(plugin.snapshots.fromTest['afterEach'].save).toBeCalledWith('test/afterEach.png');
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['afterEach']);
+      });
+    });
+
+    describe('when an external snapshot is created in the midst of a test', function() {
+      let artifact;
+
+      beforeEach(async () => {
+        artifact = new ArtifactMock('screenshot');
+
+        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onCreateExternalArtifact({
+          artifact,
+          name: 'final_name',
+        });
+        await plugin.onAfterEach(testSummaries.passed());
+      });
+
+      it('should be saved using the suggested name and untracked', async () => {
+        expect(artifact.save).not.toBeCalledWith('test/final_name.png');
+        expect(api.untrackArtifact).not.toHaveBeenCalled();
+
+        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+        expect(artifact.save).toBeCalledWith('test/final_name.png');
+        expect(api.untrackArtifact).toHaveBeenCalled();
+      });
+    });
+
+    describe('when an external snapshot is created before beforeEach', function() {
+      let artifact;
+
+      beforeEach(async () => {
+        artifact = new ArtifactMock('screenshot');
+
+        await plugin.onCreateExternalArtifact({
+          artifact,
+          name: 'final_name',
+        });
+        await plugin.onBeforeEach(testSummaries.running());
+      });
+
+      it('should be saved using the suggested name and untracked', async () => {
+        expect(artifact.save).not.toBeCalledWith('final_name.png');
+        expect(api.untrackArtifact).not.toHaveBeenCalled();
+
+        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+        expect(artifact.save).toBeCalledWith('final_name.png');
+        expect(api.untrackArtifact).toHaveBeenCalled();
+      });
+    });
+
+    describe('when an external snapshot is created before afterAll', function() {
+      let artifact;
+
+      beforeEach(async () => {
+        artifact = new ArtifactMock('screenshot');
+
+        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onCreateExternalArtifact({
+          artifact,
+          name: 'final_name',
+        });
+        await plugin.onAfterAll();
+      });
+
+      it('should be saved using the suggested name and untracked', async () => {
+        expect(artifact.save).not.toBeCalledWith('final_name.png');
+        expect(api.untrackArtifact).not.toHaveBeenCalled();
+
+        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+        expect(artifact.save).toBeCalledWith('final_name.png');
+        expect(api.untrackArtifact).toHaveBeenCalled();
       });
     });
   });
@@ -149,13 +235,38 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       it('should ultimately discard and untrack the first artifact', async () => {
         const [discardRequest] = api.requestIdleCallback.mock.calls[0];
 
-        expect(plugin.createdArtifacts[0].discard).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['beforeEach'].discard).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await discardRequest();
 
-        expect(plugin.createdArtifacts[0].discard).toHaveBeenCalledTimes(1);
-        expect(api.untrackArtifact).toBeCalledWith(plugin.createdArtifacts[0]);
+        expect(plugin.snapshots.fromTest['beforeEach'].discard).toHaveBeenCalledTimes(1);
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['beforeEach']);
+      });
+    });
+
+    describe('when an external snapshot is created in the midst of a test', function() {
+      let artifact;
+
+      beforeEach(async () => {
+        artifact = new ArtifactMock('screenshot');
+
+        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onCreateExternalArtifact({
+          artifact,
+          name: 'final_name',
+        });
+        await plugin.onAfterEach(testSummaries.passed());
+      });
+
+      it('should be discarded and untracked', async () => {
+        expect(artifact.discard).not.toHaveBeenCalled();
+        expect(api.untrackArtifact).not.toHaveBeenCalled();
+
+        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+        expect(artifact.discard).toHaveBeenCalled();
+        expect(api.untrackArtifact).toHaveBeenCalledWith(artifact);
       });
     });
   });
@@ -166,29 +277,28 @@ class FakeTwoSnapshotsPerTestPlugin extends TwoSnapshotsPerTestPlugin {
     super(...args);
     this.enabled = true;
     this.createTestArtifact = jest.fn(this.createTestArtifact.bind(this));
-    this.createdArtifacts = [];
-  }
 
-  configureToKeepArtifacts(shouldKeep) {
-    this.shouldKeepArtifactOfTest = () => shouldKeep;
+    const nonDeletable = { deleteProperty: () => true };
+    this.snapshots.fromSession = new Proxy(this.snapshots.fromSession, nonDeletable);
+    this.snapshots.fromTest = new Proxy(this.snapshots.fromTest, nonDeletable);
   }
 
-  preparePathForSnapshot(testSummary, index) {
-    super.preparePathForSnapshot(testSummary, index);
-    return `${testSummary.title}/${index}.png`;
+  configureToKeepArtifacts(shouldKeep) {
+    this.shouldKeepArtifactOfTest = this.shouldKeepArtifactOfSession = () => shouldKeep;
   }
 
   createTestArtifact() {
     super.createTestArtifact();
+    return new ArtifactMock('test');
+  }
 
-    const artifact = {
-      start: jest.fn(),
-      stop: jest.fn(),
-      save: jest.fn(),
-      discard: jest.fn(),
-    };
+  preparePathForSnapshot(testSummary, snapshotName) {
+    super.preparePathForSnapshot(testSummary, snapshotName);
 
-    this.createdArtifacts.push(artifact);
-    return artifact;
+    if (testSummary) {
+      return `${testSummary.title}/${snapshotName}.png`;
+    } else {
+      return `${snapshotName}.png`;
+    }
   }
 }
diff --git a/detox/src/artifacts/utils/temporaryPath.test.js b/detox/src/artifacts/utils/temporaryPath.test.js
new file mode 100644
index 000000000..fe75be973
--- /dev/null
+++ b/detox/src/artifacts/utils/temporaryPath.test.js
@@ -0,0 +1,21 @@
+const path = require('path');
+const tempfile = require('tempfile');
+const temporaryPath = require('./temporaryPath');
+
+describe('temporaryPath', () => {
+  it('should create a temporary file like *.detox.png for .png', expectTemporaryFile('png'));
+  it('should create a temporary file like *.detox.log for .log', expectTemporaryFile('log'));
+  it('should create a temporary file like *.detox.mp4 for .mp4', expectTemporaryFile('mp4'));
+  it('should create a temporary file like *.detox.dtxrec for .dtxrec', expectTemporaryFile('dtxrec'));
+
+  it('should generate a glob mask for those temporary files', () => {
+    expect(temporaryPath.mask()).toMatch(/\*\.detox\.\*$/);
+  });
+
+  function expectTemporaryFile(extension) {
+    return function () {
+      expect(path.dirname(temporaryPath.for[extension]())).toBe(path.dirname(tempfile()));
+      expect(temporaryPath.for[extension]()).toMatch(new RegExp(`.+\\.detox\\.${extension}$`));
+    };
+  }
+});
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index 229647ef2..97fb943b9 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -709,7 +709,7 @@ describe('Device', () => {
     device = validDevice();
 
     await device.takeScreenshot('name');
-    expect(device.deviceDriver.takeScreenshot).toHaveBeenCalledWith('name');
+    expect(device.deviceDriver.takeScreenshot).toHaveBeenCalledWith(device._deviceId, 'name');
   });
 
   async function launchAndTestBinaryPath(configuration) {
diff --git a/detox/test/e2e/21.artifacts.test.js b/detox/test/e2e/21.artifacts.test.js
index aa3013bca..ee8f05a5a 100644
--- a/detox/test/e2e/21.artifacts.test.js
+++ b/detox/test/e2e/21.artifacts.test.js
@@ -1,30 +1,34 @@
+const fs = require('fs');
+const path = require('path');
+const { PNG } = require('pngjs');
+
 describe('Artifacts', () => {
   beforeAll(async () => {
     await device.sendToHome();
-    await device.takeScreenshot('Artifacts/before all');
+    await device.takeScreenshot('Artifacts/before all').then(printDimensions);
     await device.launchApp({newInstance: true});
   });
 
   beforeEach(async () => {
     // implicitly taking screenshot - beforeEach.png
     await device.reloadReactNative();
-    await device.takeScreenshot('in main menu');
+    await device.takeScreenshot('in main menu').then(printDimensions);
 
     await element(by.text('Actions')).tap();
-    await device.takeScreenshot('Actions');
+    await device.takeScreenshot('Actions').then(printDimensions);
   });
 
   it('should take screenshots inside test', async () => {
     await element(by.id('UniqueId819')).tap();
-    await device.takeScreenshot('taps - 1');
+    await device.takeScreenshot('taps - 1').then(printDimensions);
 
     await element(by.id('UniqueId819')).tap();
-    await device.takeScreenshot('taps - 2');
+    await device.takeScreenshot('taps - 2').then(printDimensions);
   });
 
   afterEach(async () => {
     await element(by.text('Tap Me')).tap();
-    await device.takeScreenshot('tap working');
+    await device.takeScreenshot('tap working').then(printDimensions);
 
     await device.reloadReactNative();
     // implicitly taking screenshot - afterEach.png
@@ -32,7 +36,15 @@ describe('Artifacts', () => {
 
   afterAll(async () => {
     await device.sendToHome();
-    await device.takeScreenshot('Artifacts/after all');
+    await device.takeScreenshot('Artifacts/after all').then(printDimensions);
     await device.launchApp();
   });
+
+  function printDimensions(pathToScreenshot) {
+    const buffer = fs.readFileSync(pathToScreenshot);
+    const filename = path.basename(pathToScreenshot);
+    const {width, height} = PNG.sync.read(buffer);
+
+    console.log(`Took a screenshot: ${filename} (${width}x${height})`);
+  }
 });
diff --git a/detox/test/package.json b/detox/test/package.json
index ba9b717ff..66f2f164f 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -37,7 +37,8 @@
     "jest-circus": "24.9.x",
     "jest-junit": "^8.0.0",
     "lodash": "^4.14.1",
-    "nyc": "^14.0.0"
+    "nyc": "^14.0.0",
+    "pngjs": "^3.4.0"
   },
   "detox": {
     "test-runner": "jest",