Repository: wix/Detox
Pull request #2522 (2020-12-09 14:06:02)

Timeline self-integration testing
## Description

A follow-up on #2505, which introduces regression testing over the _timeline-artifact_ feature through the introduction of the very concept of (JS) integration testing to Detox. The general approach is to enable the generation of the artifact in a deterministic way (i.e. same result as long as nothing changes), and then snapshot-test it. This is especially challenging because the artifact's output contain timestamps...

The concept includes the following:
1. Introduction of a new kind of a driver+expectation set, which implement the various methods using stub sleeps (driver) or no-ops (expectations); Allowing its instantiation through a new type of a Detox configuration, named `stub`. _Effectively, this enables running all of the JS code, throughout Detox' lifecycle, with no devices involved (!)_
2. Generation of the time-line artifact using the associated artifact plugin in a special mode which injects fake timestamps, so that as long as nothing in the code changes - the end-result would be the same (the opposite is also true, of course).
3. Introduce integration-testing infra, based on Jest.
4. Introduce a single integration suite, which runs Detox over 3 stub e2e suites using the stub driver and snapshot tests the resulted artifact. That's right, a test suite that runs Detox (in stub mode) 😄 .


Patch:
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index c5bc54de1..3f0de57a3 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -4,7 +4,7 @@ const DetoxConstants = require('./DetoxConstants');
 const configuration = require('./configuration');
 const logger = require('./utils/logger');
 const log = logger.child({ __filename });
-const { traceCall } = require('./utils/trace');
+const { trace, traceCall } = require('./utils/trace');
 
 const _detox = Symbol('detox');
 const _shouldLogInitError = Symbol('shouldLogInitError');
@@ -35,6 +35,7 @@ class DetoxExportWrapper {
   async init(configOverride, userParams) {
     let configError, exposeGlobals, resolvedConfig;
 
+    trace.init();
     logger.reinitialize(Detox.global);
 
     try {
diff --git a/detox/src/artifacts/timeline/TimelineArtifactPlugin.js b/detox/src/artifacts/timeline/TimelineArtifactPlugin.js
index ccee3d0cb..3e57bcefe 100644
--- a/detox/src/artifacts/timeline/TimelineArtifactPlugin.js
+++ b/detox/src/artifacts/timeline/TimelineArtifactPlugin.js
@@ -4,8 +4,9 @@ const ArtifactPlugin = require('../templates/plugin/ArtifactPlugin');
 const FileArtifact = require('../templates/artifact/FileArtifact');
 const { trace } = require('../../utils/trace');
 const ChromeTracingExporter = require('../../utils/ChromeTracingExporter');
+const fakeTimestampsProvider = require('../../utils/fakeTimestampsProvider');
 
-const traceStub = {
+const traceNoop = {
   startSection: _noop,
   endSection: _noop,
 };
@@ -13,11 +14,13 @@ const traceStub = {
 class TimelineArtifactPlugin extends ArtifactPlugin {
   constructor(config) {
     super(config);
+    this._useFakeTimestamps = config.useFakeTimestamps;
 
-    this._trace = this.enabled ? trace : traceStub;
+    const threadId = process.env.JEST_WORKER_ID || process.pid;
+    this._trace = this.enabled ? trace : traceNoop;
     this._traceExporter = new ChromeTracingExporter({
       process: { id: 0, name: 'detox' },
-      thread: { id: process.pid, name: `Worker #${process.pid}` },
+      thread: { id: threadId, name: `Worker #${threadId}` },
     });
   }
 
@@ -57,7 +60,8 @@ class TimelineArtifactPlugin extends ArtifactPlugin {
 
     const traceLogPath = await this.api.preparePathForArtifact(`detox.trace.json`);
     const append = await this._logFileExists(traceLogPath);
-    const data = this._traceExporter.export(trace.events, append);
+    const events = this._useFakeTimestamps ? this._transformEventTimestamps(trace.events) : trace.events;
+    const data = this._traceExporter.export(events, append);
 
     const fileArtifact = new FileArtifact({ temporaryData: data });
     await fileArtifact.save(traceLogPath, { append });
@@ -67,6 +71,13 @@ class TimelineArtifactPlugin extends ArtifactPlugin {
     return fs.access(traceLogPath).then(() => true).catch(() => false);
   }
 
+  _transformEventTimestamps(events) {
+    return events.map((event) => ({
+      ...event,
+      ts: fakeTimestampsProvider(),
+    }));
+  }
+
   /** @param {string} config */
   static parseConfig(config) {
     return {
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index e58b6454d..76275d78f 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -51,7 +51,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}),
+      timeline: (api) => new TimelineArtifactPlugin({ api }),
     };
   }
 
diff --git a/detox/src/utils/__mocks__/trace.js b/detox/src/utils/__mocks__/trace.js
index 837109240..014d5fd39 100644
--- a/detox/src/utils/__mocks__/trace.js
+++ b/detox/src/utils/__mocks__/trace.js
@@ -1,5 +1,6 @@
 class FakeTrace {
   constructor() {
+    this.init = jest.fn();
     this.startSection = jest.fn();
     this.endSection = jest.fn();
     this.events = [];
diff --git a/detox/src/utils/fakeTimestampsProvider.js b/detox/src/utils/fakeTimestampsProvider.js
new file mode 100644
index 000000000..b36f26d24
--- /dev/null
+++ b/detox/src/utils/fakeTimestampsProvider.js
@@ -0,0 +1,9 @@
+let _timestamp = 1000;
+
+function nextTimestamp() {
+  const timestamp = _timestamp;
+  _timestamp += 100;
+  return timestamp;
+}
+
+module.exports = nextTimestamp;
diff --git a/detox/src/utils/trace.js b/detox/src/utils/trace.js
index 4b0425fe6..4eae09843 100644
--- a/detox/src/utils/trace.js
+++ b/detox/src/utils/trace.js
@@ -1,5 +1,9 @@
 class Trace {
-  constructor(timestampProviderFn = Date.now) {
+  constructor() {
+    this.events = [];
+  }
+
+  init(timestampProviderFn = Date.now) {
     this._timestampProviderFn = timestampProviderFn;
     this.events = [
       this._event('init'),
@@ -30,7 +34,7 @@ class Trace {
   }
 }
 
-const trace = new Trace();
+let trace = new Trace();
 async function traceCall(sectionName, func) {
   trace.startSection(sectionName);
   try {
diff --git a/scripts/ci.android.sh b/scripts/ci.android.sh
index af688d46a..a0f8000ac 100755
--- a/scripts/ci.android.sh
+++ b/scripts/ci.android.sh
@@ -5,6 +5,10 @@ yes | $ANDROID_HOME/tools/bin/sdkmanager --licenses
 
 source $(dirname "$0")/ci.sh
 
+pushd detox/test
+run_f "npm run integration"
+popd
+
 pushd detox/android
 run_f "./gradlew test"
 popd


Test patch:
diff --git a/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js b/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js
index 6a6164879..d75e1cb49 100644
--- a/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js
+++ b/detox/src/artifacts/timeline/TimelineArtifactPlugin.test.js
@@ -1,5 +1,4 @@
 const _ = require('lodash');
-
 const latestInstanceOf = (clazz) => _.last(clazz.mock.instances);
 
 describe('TimelineArtifactPlugin', () => {
@@ -17,6 +16,7 @@ describe('TimelineArtifactPlugin', () => {
   let chromeTracingExporterObj;
   let FileArtifact;
   let fileArtifactObj;
+  let Trace;
   let trace;
   let TimelineArtifactPlugin;
   let uutEnabled;
@@ -35,11 +35,12 @@ describe('TimelineArtifactPlugin', () => {
     FileArtifact = require('../templates/artifact/FileArtifact');
     fileArtifactObj = () => latestInstanceOf(FileArtifact);
 
-    const { Trace } = jest.genMockFromModule('../../utils/trace');
-    const mockTrace = new Trace();
+    const { Trace: MockTrace } = jest.genMockFromModule('../../utils/trace');
+    const mockTrace = new MockTrace();
     jest.mock('../../utils/trace', () => ({
       trace: mockTrace,
     }));
+    Trace = MockTrace;
     trace = mockTrace;
 
     TimelineArtifactPlugin = require('./TimelineArtifactPlugin');
@@ -205,9 +206,10 @@ describe('TimelineArtifactPlugin', () => {
       expect(fs.access).not.toHaveBeenCalled();
     });
 
-    it('should properly init the trace-events exporter', async () => {
-      const expectedThreadId = process.pid;
-      const expectedThreadName = `Worker #${process.pid}`;
+    it('should properly init the trace-events exporter, and use the jest worker ID for thread-ID', async () => {
+      process.env.JEST_WORKER_ID = '102030';
+      const expectedThreadId = '102030';
+      const expectedThreadName = `Worker #102030`;
       const uut = uutEnabled();
 
       const exportedData = JSON.stringify({ mocked: 'mocked data' });
@@ -219,7 +221,82 @@ describe('TimelineArtifactPlugin', () => {
       expect(ChromeTracingExporter).toHaveBeenCalledWith({
         process: { id: 0, name: 'detox' },
         thread: { id: expectedThreadId, name: expectedThreadName },
-      })
+      });
+    });
+
+    it('should resort to our pid as the exporter\'s thread-ID if not running using Jest', async () => {
+      process.env.JEST_WORKER_ID = '';
+      const expectedThreadId = process.pid;
+      const expectedThreadName = `Worker #${process.pid}`;
+      const uut = uutEnabled();
+
+      const exportedData = JSON.stringify({ mocked: 'mocked data' });
+      givenArtifactFileNotExists();
+      givenExportedTraceDataResult(exportedData);
+
+      await uut.onBeforeCleanup();
+
+      expect(ChromeTracingExporter).toHaveBeenCalledWith(expect.objectContaining({
+        thread: { id: expectedThreadId, name: expectedThreadName },
+      }));
+    });
+
+    it('should use trace events as inpute to data exporter', async () => {
+      trace.events = [
+        { name: 'mock-event', type: 'start', ts: 1234},
+        { name: 'mock-event', type: 'end', ts: 1235},
+      ];
+      const uut = uutEnabled();
+
+      givenArtifactFileNotExists();
+      givenExportedTraceDataResult('mock');
+
+      await uut.onBeforeCleanup();
+
+      expect(chromeTracingExporterObj().export).toHaveBeenCalledWith(trace.events, false);
+    });
+
+    it('should pass in append=true to exporter if artifact file already exists', async () => {
+      trace.events = [];
+      const uut = uutEnabled();
+
+      givenArtifactFileAlreadyExists();
+      givenExportedTraceDataResult('mock');
+
+      await uut.onBeforeCleanup();
+
+      expect(chromeTracingExporterObj().export).toHaveBeenCalledWith([], true);
+    });
+
+    describe('In a stub-like operational mode', () => {
+      let uut;
+      beforeEach(() => {
+        uut = new TimelineArtifactPlugin({
+          ...configMock({ enabled: true }),
+          useFakeTimestamps: true,
+        });
+      });
+
+      it('should rewrite the timeline events with fake (deterministic) timestamps', async () => {
+        const events = [
+          { type: 'init', ts: 1233},
+          { name: 'mock-event', type: 'start', ts: 1234},
+          { name: 'mock-event', type: 'end', ts: 1235},
+        ];
+        const expectedEvents = [
+          { type: 'init', ts: 1000},
+          { name: 'mock-event', type: 'start', ts: 1100},
+          { name: 'mock-event', type: 'end', ts: 1200},
+        ];
+        trace.events = events;
+
+        givenArtifactFileNotExists();
+        givenExportedTraceDataResult('mock');
+
+        await uut.onBeforeCleanup();
+
+        expect(chromeTracingExporterObj().export).toHaveBeenCalledWith(expectedEvents, false);
+      });
     });
   });
 
diff --git a/detox/src/matchersRegistry.test.js b/detox/src/matchersRegistry.test.js
index f13d341bb..10484e784 100644
--- a/detox/src/matchersRegistry.test.js
+++ b/detox/src/matchersRegistry.test.js
@@ -46,7 +46,6 @@ describe('Detox matchers registry', () => {
 
   it('should init the Android-matchers with opts', () => {
     withAndroidDevice();
-
     uut.resolve(device, opts);
     expect(AndroidExpect).toHaveBeenCalledWith(opts);
   });
diff --git a/detox/src/utils/fakeTimestampsProvider.test.js b/detox/src/utils/fakeTimestampsProvider.test.js
new file mode 100644
index 000000000..47ebe42ed
--- /dev/null
+++ b/detox/src/utils/fakeTimestampsProvider.test.js
@@ -0,0 +1,8 @@
+describe('Fake-timestamps provider', () => {
+  it('should provide timestamps in fixed intervals', () => {
+    const now = require('./fakeTimestampsProvider');
+    expect(now()).toEqual(1000);
+    expect(now()).toEqual(1100);
+    expect(now()).toEqual(1200);
+  });
+});
diff --git a/detox/src/utils/trace.test.js b/detox/src/utils/trace.test.js
index a7c5f86cd..5913ed569 100644
--- a/detox/src/utils/trace.test.js
+++ b/detox/src/utils/trace.test.js
@@ -1,13 +1,19 @@
 describe('Trace util', () => {
   const timestampProviderFn = () => 1234;
 
+  let Trace;
   let uut;
   beforeEach(() => {
-    const { Trace } = require('./trace');
-    uut = new Trace(timestampProviderFn);
+    Trace = require('./trace').Trace;
+    uut = new Trace();
+    uut.init(timestampProviderFn);
   });
 
-  it('should produce an init event at creation time', () => {
+  it('should produce an empty events list upon creation', () => {
+    expect(new Trace().events).toEqual([]);
+  });
+
+  it('should produce an init event at init time', () => {
     expect(uut.events).toEqual([{
       type: 'init',
       ts: 1234,
@@ -60,6 +66,7 @@ describe('Trace util', () => {
 
   it('should export an instance', () => {
     const { trace } = require('./trace');
+    trace.init();
     trace.startSection('section1', {});
     trace.endSection('section1', {});
     trace.reset();
@@ -95,6 +102,7 @@ describe('Trace util', () => {
     it('should trace a successful function', async () => {
       const functionCall = () => Promise.resolve(42);
 
+      trace.init();
       const result = await traceCall(sectionName, functionCall);
       expect(trace.events).toEqual([
         expect.any(Object),
@@ -109,6 +117,7 @@ describe('Trace util', () => {
       const functionCall = () => Promise.reject(error);
 
       try {
+        trace.init();
         await traceCall(sectionName, functionCall);
         fail('Expected an error');
       } catch (e) {
diff --git a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
new file mode 100644
index 000000000..dc6d28e65
--- /dev/null
+++ b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Timeline integration test should deterministically produce a timeline artifact 1`] = `"[{\\"name\\":\\"process_name\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1000,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"detox\\"}},{\\"name\\":\\"thread_name\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1000,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"Worker #2\\"}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1200,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1300,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1500,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1600,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1700,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1800,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":1900,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2000,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"StubDevice#2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"Stub2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2200,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2300,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2500,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2600,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"passed\\"}},{\\"name\\":\\"should show hello screen after tap\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2700,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2800,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":2900,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should show hello screen after tap\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3000,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"passed\\"}},{\\"name\\":\\"Stub2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3100,\\"ph\\":\\"E\\",\\"args\\":{}},{\\"name\\":\\"StubDevice#2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3200,\\"ph\\":\\"E\\",\\"args\\":{}},{\\"name\\":\\"process_name\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1000,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"detox\\"}},{\\"name\\":\\"thread_name\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1000,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"Worker #1\\"}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1200,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1300,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1500,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1600,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1700,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1800,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":1900,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2000,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"StubDevice#1\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"Stub3\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2200,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2300,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2500,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2600,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"passed\\"}},{\\"name\\":\\"should show hello screen after tap\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2700,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2800,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":2900,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should show hello screen after tap\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3000,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"passed\\"}},{\\"name\\":\\"should show world screen after tap\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3200,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3300,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should show world screen after tap\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3400,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"passed\\"}},{\\"name\\":\\"Stub3\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3500,\\"ph\\":\\"E\\",\\"args\\":{}},{\\"name\\":\\"StubDevice#1\\",\\"pid\\":0,\\"tid\\":\\"1\\",\\"ts\\":3600,\\"ph\\":\\"E\\",\\"args\\":{}},{\\"name\\":\\"process_name\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3300,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"detox\\"}},{\\"name\\":\\"thread_name\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3300,\\"ph\\":\\"M\\",\\"args\\":{\\"name\\":\\"Worker #2\\"}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3500,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"acquireDevice\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3600,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3700,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appUninstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3800,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":3900,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"appInstall\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4000,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4100,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"launchApp\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4200,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"detoxInit\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4300,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"StubDevice#2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4400,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"Stub1\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4500,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4600,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4700,\\"ph\\":\\"B\\",\\"args\\":{}},{\\"name\\":\\"reloadRN\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4800,\\"ph\\":\\"E\\",\\"args\\":{\\"success\\":true}},{\\"name\\":\\"should have welcome screen\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":4900,\\"ph\\":\\"E\\",\\"args\\":{\\"status\\":\\"failed\\"}},{\\"name\\":\\"Stub1\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":5000,\\"ph\\":\\"E\\",\\"args\\":{}},{\\"name\\":\\"StubDevice#2\\",\\"pid\\":0,\\"tid\\":\\"2\\",\\"ts\\":5100,\\"ph\\":\\"E\\",\\"args\\":{}}"`;
diff --git a/detox/test/integration/config.js b/detox/test/integration/config.js
new file mode 100644
index 000000000..359f3a505
--- /dev/null
+++ b/detox/test/integration/config.js
@@ -0,0 +1,6 @@
+module.exports = {
+  "testMatch": ["<rootDir>/*.test.js"],
+  "testTimeout": 120000,
+  "verbose": true,
+  "bail": false,
+};
diff --git a/detox/test/integration/e2e/config.js b/detox/test/integration/e2e/config.js
new file mode 100644
index 000000000..80ac479ff
--- /dev/null
+++ b/detox/test/integration/e2e/config.js
@@ -0,0 +1,10 @@
+module.exports = {
+  "rootDir": "../..",
+  "testEnvironment": "../test/e2e/environment.js",
+  "testRunner": "../test/node_modules/jest-circus/runner",
+  "testMatch": ["<rootDir>/integration/e2e/*.test.js"],
+  "setupFilesAfterEnv": [],
+  "testTimeout": 120000,
+  "verbose": false,
+  "bail": false,
+};
diff --git a/detox/test/integration/e2e/stub1.test.js b/detox/test/integration/e2e/stub1.test.js
new file mode 100644
index 000000000..d3f0d6539
--- /dev/null
+++ b/detox/test/integration/e2e/stub1.test.js
@@ -0,0 +1,13 @@
+describe('Stub1', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('Sanity')).tap();
+  });
+
+  it('should have welcome screen', async () => {
+    await expect(element(by.text('Welcome'))).toBeVisible();
+    await expect(element(by.text('Say Hello'))).toBeVisible();
+    await expect(element(by.text('Say World'))).toBeVisible();
+    throw new Error(`I'm only here to make things interesting!`);
+  });
+});
diff --git a/detox/test/integration/e2e/stub2.test.js b/detox/test/integration/e2e/stub2.test.js
new file mode 100644
index 000000000..b53aade76
--- /dev/null
+++ b/detox/test/integration/e2e/stub2.test.js
@@ -0,0 +1,17 @@
+describe('Stub2', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('Sanity')).tap();
+  });
+
+  it('should have welcome screen', async () => {
+    await expect(element(by.text('Welcome'))).toBeVisible();
+    await expect(element(by.text('Say Hello'))).toBeVisible();
+    await expect(element(by.text('Say World'))).toBeVisible();
+  });
+
+  it('should show hello screen after tap', async () => {
+    await element(by.text('Say Hello')).tap();
+    await expect(element(by.text('Hello!!!'))).toBeVisible();
+  });
+});
diff --git a/detox/test/integration/e2e/stub3.test.js b/detox/test/integration/e2e/stub3.test.js
new file mode 100644
index 000000000..4dc2f7ef6
--- /dev/null
+++ b/detox/test/integration/e2e/stub3.test.js
@@ -0,0 +1,22 @@
+describe('Stub3', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('Sanity')).tap();
+  });
+
+  it('should have welcome screen', async () => {
+    await expect(element(by.text('Welcome'))).toBeVisible();
+    await expect(element(by.text('Say Hello'))).toBeVisible();
+    await expect(element(by.text('Say World'))).toBeVisible();
+  });
+
+  it('should show hello screen after tap', async () => {
+    await element(by.text('Say Hello')).tap();
+    await expect(element(by.text('Hello!!!'))).toBeVisible();
+  });
+
+  it('should show world screen after tap', async () => {
+    await element(by.text('Say World')).tap();
+    await expect(element(by.text('World!!!'))).toBeVisible();
+  });
+});
diff --git a/detox/test/integration/stub/StubDriver.js b/detox/test/integration/stub/StubDriver.js
new file mode 100644
index 000000000..4af9fb1c5
--- /dev/null
+++ b/detox/test/integration/stub/StubDriver.js
@@ -0,0 +1,106 @@
+const DeviceDriverBase = require('detox/src/devices/drivers/DeviceDriverBase');
+const TimelineArtifactPlugin = require('detox/src/artifacts/timeline/TimelineArtifactPlugin');
+const tempfile = require('tempfile');
+const temporaryPath = require('detox/src/artifacts/utils/temporaryPath');
+const sleep = require('detox/src/utils/sleep');
+
+const sleepVeryLittle = () => sleep(10);
+const sleepALittle = () => sleep(100);
+const sleepSomeTime = () => sleep(1000);
+const sleepALot = () => sleep(2000);
+
+class StubDriver extends DeviceDriverBase {
+  constructor(config) {
+    super(config);
+
+    this._deviceId = `StubDevice#${process.env.JEST_WORKER_ID}`;
+    this._name = `Stub #${this._deviceId}`;
+  }
+
+  get name() {
+    return this._name;
+  }
+
+  getPlatform() {
+    return 'stub';
+  }
+
+  declareArtifactPlugins() {
+    return {
+      timeline: (api) => new TimelineArtifactPlugin({ api, useFakeTimestamps: true, }),
+    };
+  }
+
+  async acquireFreeDevice(deviceQuery) {
+    await sleepSomeTime();
+    await this.emitter.emit('bootDevice', { coldBoot: false, deviceId: this._deviceId, type: 'stub' });
+    return this._deviceId;
+  }
+
+  async installApp(deviceId, binaryPath, testBinaryPath) {
+    await sleepALot();
+  }
+
+  async uninstallApp() {
+    await sleepALot();
+  }
+
+  async installUtilBinaries() {
+    await sleepALot();
+  }
+
+  async launchApp() {
+    return process.pid;
+  }
+
+  async deliverPayload(params) {
+    await sleepVeryLittle();
+  }
+
+  async waitUntilReady() {
+    await sleepSomeTime();
+  }
+
+  async reloadReactNative() {
+    await sleepALittle();
+  }
+
+  createPayloadFile(notification) {
+    return tempfile('fake_payload');
+  }
+
+  async waitForActive() {
+    await sleepALittle();
+  }
+
+  async waitForBackground() {
+    await sleepALittle();
+  }
+
+  async takeScreenshot(deviceId, screenshotName) {
+    await sleepALittle();
+    return temporaryPath.for.png();
+  }
+
+  async sendToHome() {
+    await sleepVeryLittle();
+  }
+
+  async pressBack() {
+    await sleepSomeTime();
+  }
+
+  async terminate(deviceId, bundleId) {
+    await sleepSomeTime();
+  }
+
+  async shutdown() {
+    await sleepSomeTime();
+  }
+
+  async resetContentAndSettings() {
+    await sleepALittle();
+  }
+}
+
+module.exports = StubDriver;
diff --git a/detox/test/integration/stub/StubExpect.js b/detox/test/integration/stub/StubExpect.js
new file mode 100644
index 000000000..2e7f78da7
--- /dev/null
+++ b/detox/test/integration/stub/StubExpect.js
@@ -0,0 +1,143 @@
+const temporaryPath = require('detox/src/artifacts/utils/temporaryPath');
+const sleep = require('detox/src/utils/sleep');
+
+class StubMatcher {
+  constructor() {
+    [
+      'withAncestor',
+      'withDescendant',
+      'and',
+      'or',
+    ].forEach((method) => {
+      this[method] = () => this;
+    });
+    this.not = this;
+  }
+}
+
+class StubInteraction {
+  constructor({ delay = 10 } = {}) {
+    this._delay = delay;
+  }
+
+  async execute() {
+    await sleep(this._delay);
+  }
+}
+
+class StubActionInteraction extends StubInteraction {
+  constructor() {
+    super({ delay: 100 });
+  }
+}
+
+class StubMatcherAssertionInteraction extends StubInteraction {
+}
+
+class StubWaitForInteraction extends StubInteraction {
+  constructor() {
+    super({ delay: 100 });
+    this.withTimeout = () => this.execute();
+    this.whileElement = () => ({
+      scroll: () => this.execute(),
+    });
+  }
+}
+
+class StubElement {
+  constructor() {
+    const stubInteraction = new StubActionInteraction();
+    const interactionExecFn = () => stubInteraction.execute();
+
+    [
+      'tap',
+      'tapAtPoint',
+      'longPress',
+      'multiTap',
+      'tapBackspaceKey',
+      'tapReturnKey',
+      'typeText',
+      'replaceText',
+      'clearText',
+      'scroll',
+      'scrollTo',
+      'swipe',
+    ].forEach((method) => {
+        this[method] = interactionExecFn;
+    });
+    this.atIndex = () => this;
+    this.takeScreenshot = () => Promise.resolve(temporaryPath.for.png());
+  }
+}
+
+class StubElementExpect {
+  constructor() {
+    const stubInteraction = new StubMatcherAssertionInteraction();
+    const interactionExecFn = () => stubInteraction.execute();
+
+    [
+      'toBeNotVisible',
+      'toBeVisible',
+      'toExist',
+      'toHaveId',
+      'toHaveLabel',
+      'toHaveText',
+      'toHaveToggleValue',
+      'toHaveValue',
+      'toNotExist',
+      'toNotHaveId',
+      'toNotHaveLabel',
+      'toNotHaveText',
+      'toNotHaveValue',
+    ].forEach((method) => {
+      this[method] = interactionExecFn;
+    });
+    this.not = this;
+  }
+}
+
+class StubWaitForElement {
+  constructor() {
+    const stubInteraction = new StubWaitForInteraction();
+    const interactionExecFn = () => stubInteraction.execute();
+
+    [
+      'toBeNotVisible',
+      'toBeVisible',
+      'toExist',
+      'toHaveId',
+      'toHaveLabel',
+      'toHaveText',
+      'toHaveValue',
+      'toNotExist',
+      'toNotHaveId',
+      'toNotHaveLabel',
+      'toNotHaveText',
+      'toNotHaveValue',
+    ].forEach((method) => {
+      this[method] = interactionExecFn;
+    });
+    this.not = this;
+  }
+}
+
+class StubExpect {
+  constructor() {
+    const stubMatcher = new StubMatcher();
+    this.by = {
+      accessibilityLabel: () => stubMatcher,
+      label: () => stubMatcher,
+      id: () => stubMatcher,
+      type: () => stubMatcher,
+      traits: () => stubMatcher,
+      value: () => stubMatcher,
+      text: () => stubMatcher,
+    };
+
+    this.element = () => new StubElement();
+    this.expect = () => new StubElementExpect();
+    this.waitFor = () => new StubWaitForElement();
+  }
+}
+
+module.exports = StubExpect;
diff --git a/detox/test/integration/stub/index.js b/detox/test/integration/stub/index.js
new file mode 100644
index 000000000..8a5e76fe0
--- /dev/null
+++ b/detox/test/integration/stub/index.js
@@ -0,0 +1,7 @@
+const StubDriver = require('./StubDriver');
+const StubExpect = require('./StubExpect');
+
+module.exports = {
+  DriverClass: StubDriver,
+  ExpectClass: StubExpect,
+};
diff --git a/detox/test/integration/timeline-artifact.test.js b/detox/test/integration/timeline-artifact.test.js
new file mode 100644
index 000000000..1feda4301
--- /dev/null
+++ b/detox/test/integration/timeline-artifact.test.js
@@ -0,0 +1,22 @@
+const path = require('path');
+const fs = require('fs-extra');
+const { promisify } = require('util');
+const { execCommand } = require('./utils/exec');
+
+const readFile = promisify(fs.readFile);
+const remove = promisify(fs.remove);
+
+describe('Timeline integration test', () => {
+  const artifactsDirectory = 'integration_artifacts/'
+  const timelineArtifactFilename = 'detox.trace.json';
+  const timelineArtifactPath = path.join(artifactsDirectory, timelineArtifactFilename);
+  const clearAllArtifacts = () => remove(artifactsDirectory);
+
+  beforeEach(clearAllArtifacts);
+
+  it('should deterministically produce a timeline artifact', async () => {
+    await execCommand(`detox test -c stub -o integration/e2e/config.js -a ${artifactsDirectory} -w 2 --record-timeline all .`);
+    const timelineArtifactContents = await readFile(timelineArtifactPath, 'utf8');
+    expect(timelineArtifactContents).toMatchSnapshot();
+  });
+});
diff --git a/detox/test/integration/utils/exec.js b/detox/test/integration/utils/exec.js
new file mode 100644
index 000000000..e55b73f58
--- /dev/null
+++ b/detox/test/integration/utils/exec.js
@@ -0,0 +1,14 @@
+const { exec } = require('child_process');
+
+const execCommand = async (cmd) => {
+  const cp = exec(cmd);
+  cp.stdout.pipe(process.stdout);
+  cp.stderr.pipe(process.stderr);
+  return new Promise((resolve) => {
+    cp.on('close', resolve);
+  });
+}
+
+module.exports = {
+  execCommand,
+};
diff --git a/detox/test/integration_artifacts/detox.trace.json b/detox/test/integration_artifacts/detox.trace.json
new file mode 100644
index 000000000..271e971c7
--- /dev/null
+++ b/detox/test/integration_artifacts/detox.trace.json
@@ -0,0 +1,1 @@
+[{"name":"process_name","pid":0,"tid":"2","ts":1000,"ph":"M","args":{"name":"detox"}},{"name":"thread_name","pid":0,"tid":"2","ts":1000,"ph":"M","args":{"name":"Worker #2"}},{"name":"detoxInit","pid":0,"tid":"2","ts":1100,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"2","ts":1200,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"2","ts":1300,"ph":"E","args":{"success":true}},{"name":"appUninstall","pid":0,"tid":"2","ts":1400,"ph":"B","args":{}},{"name":"appUninstall","pid":0,"tid":"2","ts":1500,"ph":"E","args":{"success":true}},{"name":"appInstall","pid":0,"tid":"2","ts":1600,"ph":"B","args":{}},{"name":"appInstall","pid":0,"tid":"2","ts":1700,"ph":"E","args":{"success":true}},{"name":"launchApp","pid":0,"tid":"2","ts":1800,"ph":"B","args":{}},{"name":"launchApp","pid":0,"tid":"2","ts":1900,"ph":"E","args":{"success":true}},{"name":"detoxInit","pid":0,"tid":"2","ts":2000,"ph":"E","args":{"success":true}},{"name":"StubDevice#2","pid":0,"tid":"2","ts":2100,"ph":"B","args":{}},{"name":"Stub2","pid":0,"tid":"2","ts":2200,"ph":"B","args":{}},{"name":"should have welcome screen","pid":0,"tid":"2","ts":2300,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":2400,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":2500,"ph":"E","args":{"success":true}},{"name":"should have welcome screen","pid":0,"tid":"2","ts":2600,"ph":"E","args":{"status":"passed"}},{"name":"should show hello screen after tap","pid":0,"tid":"2","ts":2700,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":2800,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":2900,"ph":"E","args":{"success":true}},{"name":"should show hello screen after tap","pid":0,"tid":"2","ts":3000,"ph":"E","args":{"status":"passed"}},{"name":"Stub2","pid":0,"tid":"2","ts":3100,"ph":"E","args":{}},{"name":"StubDevice#2","pid":0,"tid":"2","ts":3200,"ph":"E","args":{}},{"name":"process_name","pid":0,"tid":"1","ts":1000,"ph":"M","args":{"name":"detox"}},{"name":"thread_name","pid":0,"tid":"1","ts":1000,"ph":"M","args":{"name":"Worker #1"}},{"name":"detoxInit","pid":0,"tid":"1","ts":1100,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"1","ts":1200,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"1","ts":1300,"ph":"E","args":{"success":true}},{"name":"appUninstall","pid":0,"tid":"1","ts":1400,"ph":"B","args":{}},{"name":"appUninstall","pid":0,"tid":"1","ts":1500,"ph":"E","args":{"success":true}},{"name":"appInstall","pid":0,"tid":"1","ts":1600,"ph":"B","args":{}},{"name":"appInstall","pid":0,"tid":"1","ts":1700,"ph":"E","args":{"success":true}},{"name":"launchApp","pid":0,"tid":"1","ts":1800,"ph":"B","args":{}},{"name":"launchApp","pid":0,"tid":"1","ts":1900,"ph":"E","args":{"success":true}},{"name":"detoxInit","pid":0,"tid":"1","ts":2000,"ph":"E","args":{"success":true}},{"name":"StubDevice#1","pid":0,"tid":"1","ts":2100,"ph":"B","args":{}},{"name":"Stub3","pid":0,"tid":"1","ts":2200,"ph":"B","args":{}},{"name":"should have welcome screen","pid":0,"tid":"1","ts":2300,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":2400,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":2500,"ph":"E","args":{"success":true}},{"name":"should have welcome screen","pid":0,"tid":"1","ts":2600,"ph":"E","args":{"status":"passed"}},{"name":"should show hello screen after tap","pid":0,"tid":"1","ts":2700,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":2800,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":2900,"ph":"E","args":{"success":true}},{"name":"should show hello screen after tap","pid":0,"tid":"1","ts":3000,"ph":"E","args":{"status":"passed"}},{"name":"should show world screen after tap","pid":0,"tid":"1","ts":3100,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":3200,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"1","ts":3300,"ph":"E","args":{"success":true}},{"name":"should show world screen after tap","pid":0,"tid":"1","ts":3400,"ph":"E","args":{"status":"passed"}},{"name":"Stub3","pid":0,"tid":"1","ts":3500,"ph":"E","args":{}},{"name":"StubDevice#1","pid":0,"tid":"1","ts":3600,"ph":"E","args":{}},{"name":"process_name","pid":0,"tid":"2","ts":3300,"ph":"M","args":{"name":"detox"}},{"name":"thread_name","pid":0,"tid":"2","ts":3300,"ph":"M","args":{"name":"Worker #2"}},{"name":"detoxInit","pid":0,"tid":"2","ts":3400,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"2","ts":3500,"ph":"B","args":{}},{"name":"acquireDevice","pid":0,"tid":"2","ts":3600,"ph":"E","args":{"success":true}},{"name":"appUninstall","pid":0,"tid":"2","ts":3700,"ph":"B","args":{}},{"name":"appUninstall","pid":0,"tid":"2","ts":3800,"ph":"E","args":{"success":true}},{"name":"appInstall","pid":0,"tid":"2","ts":3900,"ph":"B","args":{}},{"name":"appInstall","pid":0,"tid":"2","ts":4000,"ph":"E","args":{"success":true}},{"name":"launchApp","pid":0,"tid":"2","ts":4100,"ph":"B","args":{}},{"name":"launchApp","pid":0,"tid":"2","ts":4200,"ph":"E","args":{"success":true}},{"name":"detoxInit","pid":0,"tid":"2","ts":4300,"ph":"E","args":{"success":true}},{"name":"StubDevice#2","pid":0,"tid":"2","ts":4400,"ph":"B","args":{}},{"name":"Stub1","pid":0,"tid":"2","ts":4500,"ph":"B","args":{}},{"name":"should have welcome screen","pid":0,"tid":"2","ts":4600,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":4700,"ph":"B","args":{}},{"name":"reloadRN","pid":0,"tid":"2","ts":4800,"ph":"E","args":{"success":true}},{"name":"should have welcome screen","pid":0,"tid":"2","ts":4900,"ph":"E","args":{"status":"failed"}},{"name":"Stub1","pid":0,"tid":"2","ts":5000,"ph":"E","args":{}},{"name":"StubDevice#2","pid":0,"tid":"2","ts":5100,"ph":"E","args":{}}
\ No newline at end of file
diff --git a/detox/test/package.json b/detox/test/package.json
index 27ae38e28..aed50f701 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -16,6 +16,8 @@
     "e2e:android-debug": "detox test -c android.emu.debug",
     "e2e:ios-ci": "npm run e2e:ios -- --workers 3 --retries 1",
     "e2e:android-ci": "npm run e2e:android -- --workers 3 --retries 1 --headless --jest-report-specs --loglevel verbose",
+    "integration": "jest --clearCache && jest unit --config integration/config.js -w 1 .",
+    "integration-update-snapshots": "npm run integration -- -u",
     "build:ios": "detox build -c ios.sim.release",
     "build:android": "detox build -c android.emu.release",
     "build:android-debug": "detox build -c android.emu.debug",
@@ -157,6 +159,13 @@
         "device": {
           "avdName": "Pixel_API_28"
         }
+      },
+      "stub": {
+        "type": "./integration/stub",
+        "name": "integration-stub",
+        "device": {
+          "integ": "stub"
+        }
       }
     }
   }