Repository: wix/Detox
Pull request #1894 (2020-02-09 15:42:10)

Trace logs
Generation of the json to be displayed in chrome://tracing/ view.
What is still missing:

 - [x] some flag to optionally  enable this functionality for cli or ci.
 - [x] test coverage is not 100% and I've done my best to achieve it, but without success. Maybe @noomorph can help?
 - [ ] Make the core-trace implementation adhere to a more generic API, such that it would not be coupled with chrome-tracing's format (in particular, `startProcess()` and `startThread()` methods should be made internal, and be replaced with more generic external ones).

(currently it's impossible to create some kind of a deep link or anything to be able to open the json in the chrome://tracing magically. you still have to load the file to the chrome://tracing manually. why? because...)



Patch:
diff --git a/detox/runners/jest/DetoxAdapterCircus.js b/detox/runners/jest/DetoxAdapterCircus.js
index bcbc985f5..d5c1dd9d6 100644
--- a/detox/runners/jest/DetoxAdapterCircus.js
+++ b/detox/runners/jest/DetoxAdapterCircus.js
@@ -25,6 +25,14 @@ class DetoxAdapterCircus extends CircusTestEventListenerBase {
     await this._adapter.afterAll();
   }
 
+  async _onSuiteStart({describeBlock: {name, tests}}, state) {
+    if (tests.length) await this._adapter.suiteStart({name});
+  }
+
+  async _onSuiteEnd({describeBlock: {name, tests}}, state) {
+    if (tests.length) await this._adapter.suiteEnd({name});
+  }
+
   _onTestStart(event) {
     const { test } = event;
     if (test.mode === 'skip' || test.mode === 'todo') {
diff --git a/detox/runners/jest/DetoxAdapterImpl.js b/detox/runners/jest/DetoxAdapterImpl.js
index 573feea14..760177381 100644
--- a/detox/runners/jest/DetoxAdapterImpl.js
+++ b/detox/runners/jest/DetoxAdapterImpl.js
@@ -21,6 +21,14 @@ class DetoxAdapterImpl {
     await this._flush();
   }
 
+  async suiteStart({name}) {
+    this._enqueue(() => this.detox.suiteStart({name}));
+  }
+
+  async suiteEnd({name}) {
+    this._enqueue(() => this.detox.suiteEnd({name}));
+  }
+
   testStart({title, fullName, status}) {
     this._currentTest = {
       title,
diff --git a/detox/runners/jest/DetoxAdapterJasmine.js b/detox/runners/jest/DetoxAdapterJasmine.js
index 137eddf01..1788a978b 100644
--- a/detox/runners/jest/DetoxAdapterJasmine.js
+++ b/detox/runners/jest/DetoxAdapterJasmine.js
@@ -23,6 +23,14 @@ class DetoxAdapterJasmine /* extends JasmineReporter */ {
     await this._adapter.afterAll();
   }
 
+  async suiteStarted(result) {
+    await this._adapter.suiteStart({name: result.description});
+  }
+
+  async suiteDone(result) {
+    await this._adapter.suiteEnd({name: result.description});
+  }
+
   specStarted(result) {
     if (result.pendingReason) {
       return;
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index f3181ce28..03d3f8dad 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -82,6 +82,8 @@ class Detox {
       Object.assign(global, globalsToExport);
     }
 
+    await this._artifactsManager.onInit();
+
     return this;
   }
 
@@ -126,6 +128,14 @@ class Detox {
     });
   }
 
+  async suiteStart(suite) {
+    await this._artifactsManager.onSuiteStart(suite);
+  }
+
+  async suiteEnd(suite) {
+    await this._artifactsManager.onSuiteEnd(suite);
+  }
+
   _logTestRunCheckpoint(event, { status, fullName }) {
     log.trace({ event, status }, `${status} test: ${JSON.stringify(fullName)}`);
   }
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index 58e598d72..cae11ecf7 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -19,6 +19,8 @@ class DetoxExportWrapper {
 
     this._definePassthroughMethod('beforeEach');
     this._definePassthroughMethod('afterEach');
+    this._definePassthroughMethod('suiteStart');
+    this._definePassthroughMethod('suiteEnd');
 
     this._definePassthroughMethod('element');
     this._definePassthroughMethod('expect');
diff --git a/detox/src/artifacts/ArtifactsManager.js b/detox/src/artifacts/ArtifactsManager.js
index f2ad13825..5b63e2c7a 100644
--- a/detox/src/artifacts/ArtifactsManager.js
+++ b/detox/src/artifacts/ArtifactsManager.js
@@ -126,6 +126,18 @@ class ArtifactsManager {
     await this._callPlugins('descending', 'onTestDone', testSummary);
   }
 
+  async onSuiteStart(suite) {
+    await this._callPlugins('descending', 'onSuiteStart', suite);
+  }
+
+  async onSuiteEnd(suite) {
+    await this._callPlugins('descending', 'onSuiteEnd', suite);
+  }
+
+  async onInit() {
+    await this._callPlugins('descending', 'onInit');
+  }
+
   async onBeforeCleanup() {
     await this._callPlugins('descending', 'onBeforeCleanup');
     await this._idlePromise;
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
index 669b3ab56..f412d2050 100644
--- a/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
@@ -1,5 +1,4 @@
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
-const log = require('../../utils/logger').child({ __filename });
 
 class InstrumentsArtifactPlugin extends WholeTestRecorderPlugin {
   constructor({ api }) {
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.js b/detox/src/artifacts/templates/artifact/FileArtifact.js
index e898f6753..3a7b41b96 100644
--- a/detox/src/artifacts/templates/artifact/FileArtifact.js
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.js
@@ -6,20 +6,52 @@ class FileArtifact extends Artifact {
   constructor(template) {
     super(template);
 
-    if (template.temporaryPath) {
+    if (template.temporaryPath || template.temporaryData) {
       this.start();
       this.stop();
     }
   }
 
   async doSave(artifactPath, options = {}) {
-    await FileArtifact.moveTemporaryFile(this.logger, this.temporaryPath, artifactPath, options.append);
+    if (this.temporaryPath) {
+      await FileArtifact.moveTemporaryFile(this.logger, this.temporaryPath, artifactPath, options.append);
+    } else /* if (this.temporaryData) */ {
+      await FileArtifact.writeFile(this.logger, this.temporaryData, artifactPath, options.append);
+    }
   }
 
   async doDiscard() {
     await fs.remove(this.temporaryPath);
   }
 
+  static async writeFile(logger, data, destination, canAppend = false) {
+    if (!data) {
+      logger.warn({event: 'FILE_WRITE_EMPTY_DATA'}, `there is no data to write to "${destination}"`);
+
+      return false;
+    }
+
+    if (!await fs.exists(destination)) {
+      logger.debug({event: 'FILE_WRITE_CREATE'}, `creating file "${destination}"`)
+    } else if (!canAppend) {
+      logger.warn({event: 'FILE_WRITE_EXISTS'}, `cannot overwrite "${destination}"`);
+
+      return false;
+    }
+
+    if (canAppend) {
+      logger.debug({event: 'FILE_WRITE'}, `writing to "${destination}" via appending`);
+      await fs.appendFile(destination, data);
+
+      return true;
+    } else {
+      logger.debug({event: 'FILE_WRITE'}, `writing to "${destination}"`);
+      await fs.writeFile(destination, data);
+
+      return true;
+    }
+  }
+
   static async moveTemporaryFile(logger, source, destination, canAppend = false) {
     if (!await fs.exists(source)) {
       logger.warn({event: 'MOVE_FILE_MISSING'}, `did not find temporary file: ${source}`);
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
index ea80013d0..8e1f8550d 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
@@ -11,7 +11,7 @@ const log = require('../../../utils/logger').child({ __filename });
 class ArtifactPlugin {
   constructor({ api }) {
     this.api = api;
-    this.context = { testSummary: null };
+    this.context = { testSummary: null , suite: null};
     this.enabled = api.userConfig.enabled;
     this.keepOnlyFailedTestsArtifacts = api.userConfig.keepOnlyFailedTestsArtifacts;
     this.priority = 16;
@@ -196,6 +196,39 @@ class ArtifactPlugin {
     }
   }
 
+  /**
+   * Hook that is called when test suite starts
+   *
+   * @protected
+   * @async
+   * @param {Suite} suite - has name of currently running test suite
+   * @return {Promise<void>} - when done
+   */
+  async onSuiteStart(suite) {
+    this.context.suite = suite;
+  }
+
+  /**
+   * Hook that is called when test suite finishes
+   *
+   * @protected
+   * @async
+   * @param {Suite} suite - has name of currently running test suite
+   * @return {Promise<void>} - when done
+   */
+  async onSuiteEnd(suite) {
+    this.context.suite = null;
+  }
+
+  /**
+   * Hook that is called when detox.init() finishes
+   *
+   * @protected
+   * @async
+   * @return {Promise<void>} - when done
+   */
+  async onInit() {}
+
   /**
    * Hook that is called when detox.cleanup() just begins
    *
@@ -230,6 +263,9 @@ class ArtifactPlugin {
     this.onUserAction = _.noop;
     this.onTestStart = _.noop;
     this.onTestDone = _.noop;
+    this.onSuiteStart = _.noop;
+    this.onSuiteEnd = _.noop;
+    this.onInit = _.noop;
     this.onBeforeCleanup = _.noop;
   }
 
diff --git a/detox/src/artifacts/timeline/TimelineArtifactPlugin.js b/detox/src/artifacts/timeline/TimelineArtifactPlugin.js
new file mode 100644
index 000000000..720cc32be
--- /dev/null
+++ b/detox/src/artifacts/timeline/TimelineArtifactPlugin.js
@@ -0,0 +1,87 @@
+const _noop = require('lodash/noop');
+const fs = require('fs-extra');
+const ArtifactPlugin = require('../templates/plugin/ArtifactPlugin');
+const FileArtifact = require('../templates/artifact/FileArtifact');
+const Trace = require('./Trace');
+
+const traceStub = {
+  startProcess: (stubArgs) => _noop,
+  startThread: _noop,
+  beginEvent: _noop,
+  finishEvent: _noop,
+  traces: _noop,
+};
+
+class TimelineArtifactPlugin extends ArtifactPlugin {
+  constructor(config) {
+    super(config);
+
+    const {
+      pid = process.env.DETOX_START_TIMESTAMP,
+      processName = 'detox',
+    } = (config.timeline || {});
+
+    this._pid = pid;
+    this._deviceId = null;
+
+    this._trace = this.enabled ? new Trace() : traceStub;
+    this._trace.startProcess({id: this._pid, name: processName});
+  }
+
+  async onBootDevice(event) {
+    super.onBootDevice(event);
+    const {deviceId, type} = event;
+
+    this._deviceId = deviceId;
+
+    this._trace.startThread({id: deviceId, name: type});
+  }
+
+  async onSuiteStart(suite) {
+    super.onSuiteStart(suite);
+    this._trace.beginEvent(suite.name, {deviceId: this._deviceId});
+  }
+
+  async onSuiteEnd(suite) {
+    super.onSuiteEnd(suite);
+    this._trace.finishEvent(suite.name);
+  }
+
+  async onTestStart(testSummary) {
+    super.onTestStart(testSummary);
+
+    this._trace.beginEvent(testSummary.title);
+  }
+
+  async onTestDone(testSummary) {
+    super.onTestDone(testSummary);
+
+    this._trace.finishEvent(testSummary.title, {status: testSummary.status});
+  }
+
+  async onBeforeCleanup() {
+    this._deviceId = null;
+
+    if (!this.enabled) {
+      return;
+    }
+
+    const traceLogPath = await this.api.preparePathForArtifact(`detox_pid_${this._pid}.trace.json`);
+    const prefix = await this._logFileExists(traceLogPath) ? ',' : '[';
+
+    const fileArtifact = new FileArtifact({temporaryData: this._trace.traces({prefix})});
+    await fileArtifact.save(traceLogPath, {append: true});
+  }
+
+  async _logFileExists(traceLogPath) {
+    return fs.access(traceLogPath).then(() => true).catch(() => false);
+  }
+
+  static parseConfig(config) {
+    return {
+      enabled: config === 'all',
+    };
+  }
+}
+
+module.exports = TimelineArtifactPlugin;
diff --git a/detox/src/artifacts/timeline/Trace.js b/detox/src/artifacts/timeline/Trace.js
new file mode 100644
index 000000000..9dd6f942e
--- /dev/null
+++ b/detox/src/artifacts/timeline/Trace.js
@@ -0,0 +1,62 @@
+class Trace {
+  constructor({
+    getTimeStampFn = Date.now,
+    stringifyFn = JSON.stringify} = {}
+  ) {
+    this._getTimeStampFn = getTimeStampFn;
+    this._stringifyFn = stringifyFn;
+    this._process = {id: undefined, name: undefined};
+    this._thread = {id: undefined, name: undefined};
+    this._events = [];
+  }
+
+  startProcess({id, name}) {
+    this._process = {id, name};
+
+    return this;
+  }
+
+  startThread({id, name}) {
+    this._thread = {id, name};
+
+    this._events.push(
+      this._event('process_name', 'M', {name: this._process.name}),
+      this._event('thread_name', 'M', {name}),
+    );
+
+    return this;
+  }
+
+  beginEvent(name, args = {}) {
+    this._events.push(this._event(name, 'B', args));
+
+    return this;
+  }
+
+  finishEvent(name, args = {}) {
+    this._events.push(this._event(name, 'E', args));
+
+    return this;
+  }
+
+  json() {
+    return this._stringifyFn(this._events);
+  }
+
+  traces({prefix = '', suffix = ''} = {prefix: '', suffix: ''}) {
+    return `${prefix}${this.json().slice(1, -1)}${suffix}`;
+  }
+
+  _event(name, phase, args) {
+    return {
+      name,
+      pid: this._process.id,
+      tid: this._thread.id,
+      ts: this._getTimeStampFn(),
+      ph: phase,
+      args: {...args},
+    }
+  }
+}
+
+module.exports = Trace;
diff --git a/detox/src/configuration.js b/detox/src/configuration.js
index db138a714..0942ef473 100644
--- a/detox/src/configuration.js
+++ b/detox/src/configuration.js
@@ -1,11 +1,11 @@
 const _ = require('lodash');
-const path = require('path');
 const DetoxConfigError = require('./errors/DetoxConfigError');
 const uuid = require('./utils/uuid');
 const argparse = require('./utils/argparse');
 const getPort = require('get-port');
 const buildDefaultArtifactsRootDirpath = require('./artifacts/utils/buildDefaultArtifactsRootDirpath');
 
+const TimelineArtifactPlugin = require('./artifacts/timeline/TimelineArtifactPlugin');
 const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsArtifactPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
@@ -79,6 +79,7 @@ function getArtifactsCliConfig() {
     takeScreenshots: argparse.getArgValue('take-screenshots'),
     recordVideos: argparse.getArgValue('record-videos'),
     recordPerformance: argparse.getArgValue('record-performance'),
+    recordTimeline: argparse.getArgValue('record-timeline'),
   };
 }
 
@@ -101,6 +102,7 @@ function composeArtifactsConfig({
           screenshot: cliConfig.takeScreenshots,
           video: cliConfig.recordVideos,
           instruments: cliConfig.recordPerformance,
+          timeline: cliConfig.recordTimeline,
         },
       },
       deviceConfig.artifacts,
@@ -113,6 +115,7 @@ function composeArtifactsConfig({
           screenshot: 'manual',
           video: 'none',
           instruments: 'none',
+          timeline: 'none',
         },
       }
   );
@@ -130,6 +133,7 @@ function composeArtifactsConfig({
       case 'log': return LogArtifactPlugin.parseConfig(value);
       case 'screenshot': return ScreenshotArtifactPlugin.parseConfig(value);
       case 'video': return VideoArtifactPlugin.parseConfig(value);
+      case 'timeline': return TimelineArtifactPlugin.parseConfig(value);
     }
   });
 
diff --git a/detox/src/configurations.mock.js b/detox/src/configurations.mock.js
index 7353976b9..3053d80a2 100644
--- a/detox/src/configurations.mock.js
+++ b/detox/src/configurations.mock.js
@@ -2,6 +2,7 @@ const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsAr
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
+const TimelineArtifactPlugin = require('./artifacts/timeline/TimelineArtifactPlugin');
 
 const defaultArtifactsConfiguration = {
   rootDir: 'artifacts',
@@ -11,6 +12,7 @@ const defaultArtifactsConfiguration = {
     screenshot: 'manual',
     video: 'none',
     instruments: 'none',
+    timeline: 'none',
   },
 };
 
@@ -22,6 +24,7 @@ const allArtifactsConfiguration = {
     screenshot: 'all',
     video: 'all',
     instruments: 'all',
+    timeline: 'all',
   },
 };
 
@@ -30,6 +33,7 @@ const pluginsDefaultsResolved = {
   screenshot: ScreenshotArtifactPlugin.parseConfig('manual'),
   video: VideoArtifactPlugin.parseConfig('none'),
   instruments: InstrumentsArtifactPlugin.parseConfig('none'),
+  timeline: TimelineArtifactPlugin.parseConfig('none'),
 };
 
 const pluginsFailingResolved = {
@@ -43,6 +47,7 @@ const pluginsAllResolved = {
   screenshot: ScreenshotArtifactPlugin.parseConfig('all'),
   video: VideoArtifactPlugin.parseConfig('all'),
   instruments: InstrumentsArtifactPlugin.parseConfig('all'),
+  timeline: TimelineArtifactPlugin.parseConfig('all'),
 };
 
 const validOneDeviceNoSession = {
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index eb2756fef..ab2843e99 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -18,6 +18,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 TimelineArtifactPlugin = require('../../artifacts/timeline/TimelineArtifactPlugin');
 const temporaryPath = require('../../artifacts/utils/temporaryPath');
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const sleep = require('../../utils/sleep');
@@ -57,6 +58,7 @@ class AndroidDriver extends DeviceDriverBase {
       log: (api) => new ADBLogcatPlugin({ api, adb, devicePathBuilder }),
       screenshot: (api) => new ADBScreencapPlugin({ api, adb, devicePathBuilder }),
       video: (api) => new ADBScreenrecorderPlugin({ api, adb, devicePathBuilder }),
+      timeline: (api) => new TimelineArtifactPlugin({api, adb, devicePathBuilder}),
     };
   }
 
diff --git a/detox/src/devices/drivers/EmulatorDriver.js b/detox/src/devices/drivers/EmulatorDriver.js
index 5ec1acd25..6b44376da 100644
--- a/detox/src/devices/drivers/EmulatorDriver.js
+++ b/detox/src/devices/drivers/EmulatorDriver.js
@@ -92,7 +92,7 @@ class EmulatorDriver extends AndroidDriver {
     }
 
     await this._waitForBootToComplete(adbName);
-    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName });
+    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName, type: adbName });
   }
 
   async _validateAvd(avdName) {
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/IosDriver.js
index fb064f4bf..5746beb91 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/IosDriver.js
@@ -13,6 +13,7 @@ const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin')
 const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
 const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
 const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
+const TimelineArtifactPlugin = require('../../artifacts/timeline/TimelineArtifactPlugin');
 const IosExpect = require('../../ios/expect');
 
 class IosDriver extends DeviceDriverBase {
@@ -32,6 +33,7 @@ class IosDriver extends DeviceDriverBase {
       log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }),
       screenshot: (api) => new SimulatorScreenshotPlugin({ api, appleSimUtils }),
       video: (api) => new SimulatorRecordVideoPlugin({ api, appleSimUtils }),
+      timeline: (api) => new TimelineArtifactPlugin({api, appleSimUtils}),
     };
   }
 
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index 9022c907e..d64156dec 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -50,7 +50,7 @@ class SimulatorDriver extends IosDriver {
       throw new Error(`Failed to find device matching ${deviceComment}`);
     }
 
-    await this._boot(udid);
+    await this._boot(udid, deviceQuery.type || deviceQuery);
     this._name = `${udid} ${deviceComment}`;
     return udid;
   }
@@ -68,10 +68,10 @@ class SimulatorDriver extends IosDriver {
     }
   }
 
-  async _boot(deviceId) {
+  async _boot(deviceId, type) {
     const deviceLaunchArgs = argparse.getArgValue('deviceLaunchArgs');
     const coldBoot = await this.applesimutils.boot(deviceId, deviceLaunchArgs);
-    await this.emitter.emit('bootDevice', { coldBoot, deviceId });
+    await this.emitter.emit('bootDevice', { coldBoot, deviceId, type });
   }
 
   async installApp(deviceId, binaryPath) {
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index dcd93ad04..a5b059787 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -74,6 +74,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | --take-screenshots [manual/failing/all/none]  | Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only. The default value is **manual**. |
 | --record-videos [failing/all/none]            | Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only. The default value is **none**. |
 | --record-performance [all/none]               | [iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory. The default value is **none**. |
+| --record-timline [all/none] | [Jest Only] Record tests and events timeline, for visual display on the [chrome://tracing](chrome://tracing) tool. The default value is **none**. |
 | -r, --reuse                                   | Reuse existing installed app (do not delete + reinstall) for a faster run. |
 | -u, --cleanup                                 | Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue |
 | -w, --workers                                 | [iOS Only] Specifies number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest). *Note: For workers > 1, Jest's spec-level reporting is disabled, by default (can be overridden using --jest-report-specs).* |


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index e244500e0..bba624145 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -75,20 +75,22 @@ module.exports.builder = {
   'take-screenshots': {
     group: 'Debugging:',
     choices: ['manual', 'failing', 'all', 'none'],
-    describe:
-      'Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only.'
+    describe: 'Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only.'
   },
   'record-videos': {
     group: 'Debugging:',
     choices: ['failing', 'all', 'none'],
-    describe:
-      'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.'
+    describe: 'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.'
   },
   'record-performance': {
     group: 'Debugging:',
     choices: ['all', 'none'],
-    describe:
-      '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.'
+    describe: '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.'
+  },
+  'record-timeline': {
+    group: 'Debugging:',
+    choices: ['all', 'none'],
+    describe: '[Jest Only] Record tests and events timeline, for visual display on the chrome://tracing tool.'
   },
   w: {
     alias: 'workers',
@@ -277,6 +279,7 @@ module.exports.handler = async function test(program) {
         'takeScreenshots',
         'recordVideos',
         'recordPerformance',
+        'recordTimeline',
         'deviceName',
         'reportSpecs',
         'readOnlyEmu',
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index 556b231d1..b157119df 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -236,5 +236,21 @@ describe('Detox', () => {
       await detox.cleanup();
       expect(artifactsManager.onBeforeCleanup).toHaveBeenCalledTimes(1);
     });
+
+    it(`should trigger artifactsManager.onSuiteStart`, async() => {
+      const suite = {name: 'testSuiteName'};
+
+      await detox.suiteStart(suite);
+
+      expect(artifactsManager.onSuiteStart).toHaveBeenCalledWith(suite);
+    });
+
+    it(`should trigger artifactsManager.onSuiteEnd`, async() => {
+      const suite = {name: 'testSuiteName'};
+
+      await detox.suiteEnd(suite);
+
+      expect(artifactsManager.onSuiteEnd).toHaveBeenCalledWith(suite);
+    });
   });
 });
diff --git a/detox/src/artifacts/ArtifactsManager.test.js b/detox/src/artifacts/ArtifactsManager.test.js
index 3e6ce780a..4944446fe 100644
--- a/detox/src/artifacts/ArtifactsManager.test.js
+++ b/detox/src/artifacts/ArtifactsManager.test.js
@@ -1,6 +1,7 @@
 const path = require('path');
 const sleep = require('../utils/sleep');
 const testSummaries = require('./templates/plugin/__mocks__/testSummaries.mock');
+const testSuite = require('./templates/plugin/__mocks__/testSuite.mock');
 
 describe('ArtifactsManager', () => {
   let proxy, FakePathBuilder;
@@ -88,6 +89,9 @@ describe('ArtifactsManager', () => {
           onCreateExternalArtifact: jest.fn(),
           onTestStart: jest.fn(),
           onTestDone: jest.fn(),
+          onSuiteStart: jest.fn(),
+          onSuiteEnd: jest.fn(),
+          onInit: jest.fn(),
           onBeforeCleanup: jest.fn(),
         });
       };
@@ -219,6 +223,12 @@ describe('ArtifactsManager', () => {
 
         itShouldCatchErrorsOnPhase('onTestDone', () => testSummaries.passed());
 
+        itShouldCatchErrorsOnPhase('onSuiteStart', () => (testSuite.mock()));
+
+        itShouldCatchErrorsOnPhase('onSuiteEnd', () => (testSuite.mock()));
+
+        itShouldCatchErrorsOnPhase('onInit', () => undefined);
+
         itShouldCatchErrorsOnPhase('onBeforeCleanup', () => undefined);
 
         itShouldCatchErrorsOnPhase('onBootDevice', () => ({
@@ -287,6 +297,34 @@ describe('ArtifactsManager', () => {
         });
       });
 
+      describe('onSuiteStart', () => {
+        it('should call onSuiteStart in plugins with the passed argument', async () => {
+          const suite = testSuite.mock();
+
+          expect(testPlugin.onSuiteStart).not.toHaveBeenCalled();
+          await artifactsManager.onSuiteStart(suite);
+          expect(testPlugin.onSuiteStart).toHaveBeenCalledWith(suite);
+        });
+      });
+
+      describe('onSuiteEnd', () => {
+        it('should call onSuiteEnd in plugins with the passed argument', async () => {
+          const suite = testSuite.mock();
+
+          expect(testPlugin.onSuiteEnd).not.toHaveBeenCalled();
+          await artifactsManager.onSuiteEnd(suite);
+          expect(testPlugin.onSuiteEnd).toHaveBeenCalledWith(suite);
+        });
+      });
+
+      describe('onInit', () => {
+        it('should call onInit in plugins', async () => {
+          expect(testPlugin.onInit).not.toHaveBeenCalled();
+          await artifactsManager.onInit();
+          expect(testPlugin.onInit).toHaveBeenCalled();
+        });
+      });
+
       describe('onBeforeCleanup', () => {
         it('should call onBeforeCleanup in plugins', async () => {
           expect(testPlugin.onBeforeCleanup).not.toHaveBeenCalled();
diff --git a/detox/src/artifacts/templates/artifact/FileArtifact.test.js b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
index 263adb489..dfa80e7ed 100644
--- a/detox/src/artifacts/templates/artifact/FileArtifact.test.js
+++ b/detox/src/artifacts/templates/artifact/FileArtifact.test.js
@@ -3,7 +3,7 @@ const fs = require('fs-extra');
 const tempfile = require('tempfile');
 
 describe('FileArtifact', () => {
-  let FileArtifact, fileArtifact, logger, temporaryPath, destinationPath;
+  let FileArtifact, fileArtifact, logger, temporaryPath, temporaryData, destinationPath;
 
   beforeEach(async () => {
     jest.mock('../../../utils/logger');
@@ -12,6 +12,7 @@ describe('FileArtifact', () => {
     fileArtifact = null;
     temporaryPath = tempfile('.artifact');
     destinationPath = tempfile('.artifact');
+    temporaryData = 'Just a usual string to be saved to the file';
   });
 
   afterEach(async () => {
@@ -24,6 +25,7 @@ describe('FileArtifact', () => {
   describe('save', () => {
     beforeEach(() => {
       jest.spyOn(FileArtifact, 'moveTemporaryFile').mockImplementation(_.noop);
+      jest.spyOn(FileArtifact, 'writeFile').mockImplementation(_.noop);
     });
 
     describe('if temporary file is passed to constructor', () => {
@@ -88,6 +90,35 @@ describe('FileArtifact', () => {
         });
       });
     });
+
+    describe('if temporary data is passed to constructor', () => {
+      beforeEach(() => {
+        fileArtifact = new FileArtifact({
+          name: 'CustomArtifact',
+          temporaryData,
+        });
+      });
+
+      describe('when called simply', () => {
+        beforeEach(async () => {
+          await fileArtifact.save(destinationPath);
+        });
+
+        it('should call FileArtifact.writeFile', async () => {
+          expect(FileArtifact.writeFile).toHaveBeenCalledWith(logger, temporaryData, destinationPath, undefined);
+        });
+      });
+
+      describe('when called with { append: true }', () => {
+        beforeEach(async () => {
+          await fileArtifact.save(destinationPath, {append: true});
+        });
+
+        it('should call FileArtifact.moveTemporaryFile with extra param', async () => {
+          expect(FileArtifact.writeFile).toHaveBeenCalledWith(logger, temporaryData, destinationPath, true);
+        });
+      });
+    });
   });
 
   describe('discard', () => {
@@ -183,5 +214,70 @@ describe('FileArtifact', () => {
         });
       });
     });
+
+    describe('.writeFile', () => {
+      describe('if there is no temporary data', () => {
+        beforeEach(async () => {
+          await fs.writeFile(destinationPath, 'Hello');
+        });
+
+        it('should log a warning', async () => {
+          const result = await FileArtifact.writeFile(logger, undefined, destinationPath);
+
+          expect(result).toBe(false);
+          expect(await fs.readFile(destinationPath, 'utf8')).toBe('Hello');
+          expect(logger.warn).toHaveBeenCalledWith({event: 'FILE_WRITE_EMPTY_DATA'}, expect.any(String));
+        });
+      });
+
+      describe('if there is a data', () => {
+        describe('if there is no destination file', () => {
+          beforeEach(async () => {
+            await fs.remove(destinationPath);
+          });
+
+          it('should log a debug message', async () => {
+            const result = await FileArtifact.writeFile(logger, temporaryData, destinationPath);
+
+            expect(result).toBe(true);
+            expect(logger.debug).toHaveBeenCalledWith({event: 'FILE_WRITE_CREATE'}, expect.any(String));
+          });
+
+          it('should create the file', async () => {
+            const result = await FileArtifact.writeFile(logger, temporaryData, destinationPath);
+
+            expect(result).toBe(true);
+            expect(await fs.readFile(destinationPath, 'utf8')).toBe(temporaryData);
+          });
+        });
+
+        describe('if the destination file exists', () => {
+          const fileContent = 'Hello';
+
+          beforeEach(async () => {
+            await fs.writeFile(destinationPath, fileContent);
+          });
+
+          describe('usual mode', () => {
+            it('should refuse overwriting the file', async () => {
+              const result = await FileArtifact.writeFile(logger, temporaryData, destinationPath);
+
+              expect(result).toBe(false);
+              expect(logger.warn).toHaveBeenCalledWith({event: 'FILE_WRITE_EXISTS'}, expect.any(String));
+            });
+          });
+
+          describe('append mode', () => {
+            it('should append to the file', async () => {
+              const result = await FileArtifact.writeFile(logger, temporaryData, destinationPath, true);
+
+              expect(result).toBe(true);
+              expect(await fs.readFile(destinationPath, 'utf8')).toBe(fileContent + temporaryData);
+              expect(logger.debug).toHaveBeenCalledWith({event: 'FILE_WRITE'}, expect.any(String));
+            });
+          });
+        });
+      });
+    });
   });
 });
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
index c77c0b7e4..936c17d04 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
@@ -3,6 +3,7 @@ const logger = require('../../../utils/logger');
 const ArtifactPlugin = require('./ArtifactPlugin');
 const FileArtifact = require('../artifact/FileArtifact');
 const testSummaries = require('./__mocks__/testSummaries.mock');
+const testSuite = require('./__mocks__/testSuite.mock');
 
 class TestArtifactPlugin extends ArtifactPlugin {}
 
@@ -211,6 +212,24 @@ describe('ArtifactPlugin', () => {
       expect(plugin.context.testSummary).toBe(testSummary);
     });
 
+    it('should have .onSuiteStart, which updates context.suite if called', async () => {
+      const suite = testSuite.mock();
+      await plugin.onSuiteStart(suite);
+      expect(plugin.context.suite).toBe(suite);
+    });
+
+    it('should have .onSuiteEnd, which updates context.suite if called', async () => {
+      plugin.context.suite = testSuite.mock();
+      await plugin.onSuiteEnd();
+      expect(plugin.context.suite).toBe(null);
+    });
+
+    it('should have .onInit, which is doing nothing', async () => {
+      const prevContext = {...plugin.context};
+      await plugin.onInit();
+      expect(plugin.context).toEqual(prevContext);
+    });
+
     it('should have .onBeforeCleanup, which resets context.testSummary if called', async () => {
       plugin.context.testSummary = {};
       await plugin.onBeforeCleanup();
@@ -236,6 +255,9 @@ describe('ArtifactPlugin', () => {
         expect(plugin.onTerminateApp).toBe(plugin.onTerminate);
         expect(plugin.onTestStart).toBe(plugin.onTerminate);
         expect(plugin.onTestDone).toBe(plugin.onTerminate);
+        expect(plugin.onSuiteStart).toBe(plugin.onTerminate);
+        expect(plugin.onSuiteEnd).toBe(plugin.onTerminate);
+        expect(plugin.onInit).toBe(plugin.onTerminate);
         expect(plugin.onBeforeCleanup).toBe(plugin.onTerminate);
         expect(plugin.onUserAction).toBe(plugin.onTerminate);
       });
diff --git a/detox/src/artifacts/templates/plugin/__mocks__/testSuite.mock.js b/detox/src/artifacts/templates/plugin/__mocks__/testSuite.mock.js
new file mode 100644
index 000000000..3878cdfae
--- /dev/null
+++ b/detox/src/artifacts/templates/plugin/__mocks__/testSuite.mock.js
@@ -0,0 +1,3 @@
+const mock = () => ({name: 'testSuiteName'});
+
+module.exports = {mock};
diff --git a/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js b/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js
new file mode 100644
index 000000000..1ae639bed
--- /dev/null
+++ b/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js
@@ -0,0 +1,233 @@
+describe('TimelineArtifactPlugin', () => {
+  const pid = 'mockPid';
+  const processName = 'mockProcessName';
+  const configMock = ({enabled = true, pid, processName} = {}) => ({
+    api: {
+      userConfig: {
+        enabled,
+      },
+      preparePathForArtifact: async path => path,
+    },
+    timeline: {
+      pid,
+      processName,
+    },
+  });
+
+  class MockTraceClass {
+    constructor() {
+      Object.assign(this, traceMock);
+      this.startProcess.mockReturnValue(this);
+      this.startThread.mockReturnValue(this);
+      this.beginEvent.mockReturnValue(this);
+      this.finishEvent.mockReturnValue(this);
+    }
+  }
+
+  let traceMock;
+  beforeEach(() => {
+    const Trace = jest.genMockFromModule('./Trace');
+    traceMock = new Trace();
+    jest.mock('./Trace', () => MockTraceClass);
+  });
+
+  class MockFileArtifactClass {
+    constructor() {
+      fileArtifactMock.ctor(...arguments);
+      Object.assign(this, fileArtifactMock);
+    }
+  }
+
+  let fileArtifactMock;
+  beforeEach(() => {
+    const FileArtifacts = jest.genMockFromModule('../templates/artifact/FileArtifact');
+    fileArtifactMock = new FileArtifacts();
+    fileArtifactMock.ctor = jest.fn();
+    jest.mock('../templates/artifact/FileArtifact', () => MockFileArtifactClass);
+  });
+
+  let fs;
+  let TimelineArtifactPlugin;
+  beforeEach(() => {
+    jest.mock('fs-extra', () => ({
+      access: jest.fn(),
+    }));
+    fs = require('fs-extra');
+
+    TimelineArtifactPlugin = require('./TimelineArtifactPlugin');
+  });
+
+  describe('constructor', () => {
+    it('should start trace process', () => {
+      const config = configMock({pid, processName});
+      new TimelineArtifactPlugin(config);
+      expect(traceMock.startProcess).toHaveBeenCalledWith({id: pid, name: processName});
+    });
+
+    it('should not fail if no timeline config specified', () => {
+      const config = configMock({pid, processName});
+      delete config.timeline;
+
+      new TimelineArtifactPlugin(config);
+    });
+
+    it('should not start trace process if disabled', () => {
+      const config = configMock({enabled: false, pid, processName});
+      new TimelineArtifactPlugin(config);
+      expect(traceMock.startProcess).not.toHaveBeenCalled();
+    })
+  });
+
+  describe('onBootDevice', () => {
+    const deviceId = 'testDeviceId';
+    const type = 'testDeviceType';
+
+    it('should start trace thread', async () => {
+      const config = configMock();
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBootDevice({deviceId, type});
+
+      expect(traceMock.startThread).toHaveBeenCalledWith({id: deviceId, name: type});
+    });
+
+    it('should not start thread if disabled', async () => {
+      const config = configMock({enabled: false});
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBootDevice({deviceId, type});
+
+      expect(traceMock.startThread).not.toHaveBeenCalled();
+    })
+  });
+
+  describe('onSuiteStart', () => {
+    const deviceId = 'testDeviceId';
+    const name = 'testSuiteName';
+
+    it('should begin trace event', async () => {
+      const config = configMock();
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBootDevice({deviceId});
+      await timelineArtifactPlugin.onSuiteStart({name});
+
+      expect(traceMock.beginEvent).toHaveBeenCalledWith(name, {deviceId});
+    });
+
+    it('should not begin trace event if disabled', async () => {
+      const config = configMock({enabled: false});
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBootDevice({deviceId});
+      await timelineArtifactPlugin.onSuiteStart({name});
+
+      expect(traceMock.beginEvent).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('onSuiteEnd', () => {
+    it('should finish trace event', async () => {
+      const config = configMock();
+      const name = 'testSuiteName';
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onSuiteEnd({name});
+
+      expect(traceMock.finishEvent).toHaveBeenCalledWith(name);
+    });
+  });
+
+  describe('onTestStart', () => {
+    it('should begin trace event', async () => {
+      const config = configMock();
+      const title = 'testName';
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onTestStart({title});
+
+      expect(traceMock.beginEvent).toHaveBeenCalledWith(title);
+    });
+  });
+
+  describe('onTestDone', () => {
+    it('should finish trace event', () => {
+      const config = configMock();
+      const title = 'testName';
+      const status = 'testStatus';
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      timelineArtifactPlugin.onTestDone({title, status});
+
+      expect(traceMock.finishEvent).toHaveBeenCalledWith(title, {status});
+    });
+  });
+
+  describe('onBeforeCleanup', () => {
+    const mockArtifactFileNotExists = () => {
+      fs.access.mockImplementation(async () => {
+        throw new Error('Make uut think the file doesnt already exist');
+      });
+    };
+
+    const mockArtifactFileAlreadyExists = () => {
+      fs.access.mockResolvedValue(undefined);
+    };
+
+    it('should create artifact starting with [', async () => {
+      mockArtifactFileNotExists();
+
+      const config = configMock({pid});
+      const expectedArtifactPath = `detox_pid_${pid}.trace.json`;
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBeforeCleanup();
+
+      expect(traceMock.traces).toHaveBeenCalledWith({prefix: '['});
+      expect(fileArtifactMock.save).toHaveBeenCalledWith(expectedArtifactPath, {append: true});
+    });
+
+    it('should append to an existing artifact file starting with ,', async () => {
+      mockArtifactFileAlreadyExists();
+
+      const config = configMock({pid});
+      const expectedArtifactPath = `detox_pid_${pid}.trace.json`;
+      const mockTracesResult = 'mockTracesResult';
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      traceMock.traces.mockReturnValue(mockTracesResult);
+
+      await timelineArtifactPlugin.onBeforeCleanup();
+
+      expect(traceMock.traces).toHaveBeenCalledWith({prefix: ','});
+      expect(fileArtifactMock.ctor).toHaveBeenCalledWith({temporaryData: mockTracesResult});
+      expect(fileArtifactMock.save).toHaveBeenCalledWith(expectedArtifactPath, {append: true});
+    });
+
+    it('should not save artifact if disabled', async () => {
+      mockArtifactFileNotExists();
+
+      const config = configMock({enabled: false, pid});
+
+      const timelineArtifactPlugin = new TimelineArtifactPlugin(config);
+      await timelineArtifactPlugin.onBeforeCleanup();
+
+      expect(traceMock.traces).not.toHaveBeenCalled();
+      expect(fileArtifactMock.save).not.toHaveBeenCalled();
+      expect(fs.access).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('parseConfig', () => {
+    let parseConfig;
+    beforeEach(() => {
+      parseConfig = TimelineArtifactPlugin.parseConfig;
+    });
+
+    it('should enable plugin if config = all', () =>
+      expect(parseConfig('all').enabled).toBe(true));
+
+    it('should disable plugin if config = none', () =>
+      expect(parseConfig('none').enabled).toBe(false));
+  });
+});
diff --git a/detox/src/artifacts/timeline/Trace.test.js b/detox/src/artifacts/timeline/Trace.test.js
new file mode 100644
index 000000000..afa2a533b
--- /dev/null
+++ b/detox/src/artifacts/timeline/Trace.test.js
@@ -0,0 +1,144 @@
+const Trace = require('./Trace');
+
+describe('Trace', () => {
+  const tsMock = 1487076708000;
+  class TestTrace extends Trace {
+    constructor() {
+      super({
+        getTimeStampFn: () => tsMock,
+        stringifyFn: (obj) => obj
+      });
+    }
+  }
+
+  const event = overrides => ({
+    name: '',
+    pid: undefined,
+    tid: undefined,
+    ts: tsMock,
+    ph: '',
+    args: {},
+    ...overrides,
+  });
+
+  describe('constructor', () => {
+    it('should not fail, if dependencies were not provided', () => {
+      const constructor = () => new Trace();
+      expect(constructor).not.toThrow();
+    });
+  });
+
+  describe('startProcess and startThread', () => {
+    it('should add process and thread metadata events', () => {
+      const processId = 'testPid';
+      const processName = 'detox';
+      const threadId = 'testTid';
+      const threadName = 'deviceType';
+
+      const trace = new TestTrace()
+        .startProcess({id: processId, name: processName})
+        .startThread({id: threadId, name: threadName});
+      const [processMetaEvent, threadMetaEvent] = trace.json();
+
+      expect(processMetaEvent).toEqual(event({
+        name: 'process_name',
+        pid: processId,
+        tid: threadId,
+        ph: 'M',
+        args: {name: processName},
+      }));
+
+      expect(threadMetaEvent).toEqual(event({
+        name: 'thread_name',
+        pid: processId,
+        tid: threadId,
+        ph: 'M',
+        args: {name: threadName},
+      }));
+    });
+  });
+
+  describe('beginEvent', () => {
+    it('should add begin event', () => {
+      const name = 'eventName';
+
+      const trace = new TestTrace().beginEvent(name);
+      const [beginEvent] = trace.json();
+
+      expect(beginEvent).toEqual(event({name, ph: 'B'}));
+    });
+
+    it('should add begin event with args', () => {
+      const name = 'eventName';
+      const arg = 'eventArg';
+
+      const trace = new TestTrace().beginEvent(name, {arg});
+      const [beginEvent] = trace.json();
+
+      expect(beginEvent).toEqual(event({name, ph: 'B', args: {arg}}));
+    });
+  });
+
+  describe('finishEvent', () => {
+    it('should add end event', () => {
+      const name = 'eventName';
+
+      const trace = new TestTrace().finishEvent(name);
+      const [endEvent] = trace.json();
+
+      expect(endEvent).toEqual(event({name, ph: 'E'}));
+    });
+
+    it('should add end event with args', () => {
+      const name = 'eventName';
+      const arg = 'eventArg';
+
+      const trace = new TestTrace().finishEvent(name, {arg});
+      const [endEvent] = trace.json();
+
+      expect(endEvent).toEqual(event({name, ph: 'E', args: {arg}}));
+    });
+  });
+
+  describe('json', () => {
+    it('should create json representation of all events', () => {
+      const name = 'eventName';
+
+      const trace = new Trace({getTimeStampFn: () => tsMock}).beginEvent(name).finishEvent(name);
+      const json = trace.json();
+
+      expect(json).toBe(JSON.stringify([event({name, ph: 'B'}), event({name, ph: 'E'})]));
+    });
+  });
+
+  describe('traces', () => {
+    const dataString = 'dataString';
+    const jsonString = `[${dataString}]`;
+
+    class TestTraceGeneration extends Trace {
+      constructor() {
+        super({stringifyFn: () => jsonString});
+      }
+    }
+
+    it('should trim first and last character of the json representation', () => {
+      const trace = new TestTraceGeneration();
+
+      expect(trace.traces()).toEqual(dataString);
+    });
+
+    it('should add prefix', () => {
+      const prefix = 'prefix';
+      const trace = new TestTraceGeneration();
+
+      expect(trace.traces({prefix})).toEqual(`${prefix}${dataString}`);
+    });
+
+    it('should add suffix', () => {
+      const suffix = 'suffix';
+      const trace = new TestTraceGeneration();
+
+      expect(trace.traces({suffix})).toEqual(`${dataString}${suffix}`);
+    });
+  });
+});
diff --git a/detox/src/configuration.test.js b/detox/src/configuration.test.js
index d483179ba..e0c865a8a 100644
--- a/detox/src/configuration.test.js
+++ b/detox/src/configuration.test.js
@@ -1,10 +1,6 @@
 const _ = require('lodash');
 const path = require('path');
 const schemes = require('./configurations.mock');
-const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
-const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
-const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
-const ArtifactPathBuilder = require('./artifacts/utils/ArtifactPathBuilder');
 
 describe('configuration', () => {
   let configuration;
@@ -103,6 +99,7 @@ describe('configuration', () => {
           takeScreenshots: 'all',
           recordVideos: 'all',
           recordPerformance: 'all',
+          recordTimeline: 'all',
         }
       })).toMatchObject({
         pathBuilder: expect.objectContaining({
@@ -145,6 +142,7 @@ describe('configuration', () => {
           screenshot: schemes.pluginsAllResolved.screenshot,
           video: schemes.pluginsDefaultsResolved.video,
           instruments: schemes.pluginsDefaultsResolved.instruments,
+          timeline: schemes.pluginsDefaultsResolved.timeline,
         },
       });
     });
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index a6dc18f9d..fe6fb130f 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -137,7 +137,7 @@ describe('index', () => {
     const expectedConfig = {
       ...schemes.validOneDeviceNoSession.configurations['ios.sim.release'],
       device: 'iPhone X'
-    }
+    };
 
     expect(Detox).toHaveBeenCalledWith({
       artifactsConfig: expect.objectContaining({
@@ -234,7 +234,7 @@ describe('index', () => {
     expect(detox.init(schemes.validOneDeviceNoSession)).rejects.toThrow();
     expect(mockDetox.cleanup).toHaveBeenCalled();
   });
-  
+
   it(`beforeEach() should be covered - with detox initialized`, async() => {
     await detox.init(schemes.validOneDeviceNoSession);
     await detox.beforeEach();