Repository: wix/Detox
Pull request #1715 (2019-10-27 10:18:58)

feat(artifacts): device.takeScreenshot() returns a temporary screenshot path
- [x] This change has been discussed in issue #1712 and #1362 and the solution has been agreed upon with maintainers.

---

**Description:**

**TODOs:**

- [x] fix unit tests
- [x] restore coverage
- [x] merge in documentation `--take-screenshots none` and `--take-screenshots manual` modes (`none` will behave like `manual` nowadays).
- [x] make a round of regression testing on Jest
- [x] make a round of regression testing on Mocha


rotemmiz: Why do we need to choose `--take-screenshots none` or `--take-screenshots manual`?
What blocks us from supporting both of the following?
1. `--take-screenshots all/none/failing` as we currently have, this is implicit for every test case.
2. `device.takeScreenshot` is explicit, and is totally disconnected from anything the user sets in cli params.
LeoNatan: @rotemmiz The problem was that in the default mode, `none`, manual screenshots were not being saved. 

I think manual screenshots should be tied up to the `take-screenshots` setting. That should only be for test life-cycle, while user screenshots should always be included.

> probably they will be merged, and `none` will behave like `manual` nowadays

This sounds like a good idea.
LeoNatan: Might be a good idea to note in docs that the returned path is temporary and will be invalid once the test is complete.
noomorph: @LeoNatan , this is already done in docs in this pull request.
LeoNatan: @noomorph Sorry, missed it.

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..536853e57 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');
@@ -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",