Repository: wix/Detox
Pull request #3694 (2022-11-11 14:50:36)

fix: logger errors on Windows
## Description

- This pull request addresses the issue described here: #3692

### DetoxLogger#close()

In this pull request, I have added an internal method, `close()`, to the `DetoxLogger` class.

It is called by DetoxPrimaryContext inside the `_finalize()` method.
Closing flushes the write streams. Ignoring this step provokes issues on Windows when Detox tries to move logs – they are often empty or incomplete. Also, omitting closing makes our integration tests flaky.

### Implications of closed logger

I had to tweak the final (try-catch) stage in `local-cli/cli.js`, because the logic there tries to use an already closed logger (i.e., stdout-only state). This is not fatal, but the logged messages won't get into the log files, which is sad, especially when we can easily overcome this in the most common case.

So, now, instead of plain logging, `Command exited with code = ...`, I log duration events (`log.info.begin() → log.error.end() OR log.trace.end()`) at every launch of a test runner. This can be considered an improvement to the rich timelines we create.

### Glob package and Windows OS

Another fixed issue was caused by the `glob` package. Turns out, it accepts forward slashes (POSIX) only. I had to correct my functions using it.

### Duplexify package and Windows OS

I didn't succeed in digging to the bottom of it, but I found out that the `duplexify` package doesn't play well with Windows. Cryptic errors like "being corked" and "this._writableState is undefined" appear, while the behavior in POSIX environments seems to be fine.

As a result, I use a clumsier approach – instead of `duplexify(writable, readable)` I return an object with both the streams, and their consumers configure all the further piping.

### Unit and integration tests

While I tried to figure out what was wrong, I had to refactor that big `streamUtils` file into several smaller classes and modules and added more tests.





Patch:
diff --git a/detox/local-cli/cli.js b/detox/local-cli/cli.js
index 1ebe8ce00..52d648be2 100755
--- a/detox/local-cli/cli.js
+++ b/detox/local-cli/cli.js
@@ -28,9 +28,12 @@ yargs
   .wrap(yargs.terminalWidth() * 0.9)
   .fail(function(msg, err, program) {
     if (err) {
-      logger.error(DetoxError.format(err));
-      // eslint-disable-next-line no-console
-      process.stderr.write('\n');
+      const message = DetoxError.format(err);
+      if (message) {
+        logger.error(message);
+        process.stderr.write('\n');
+      }
+
       // @ts-ignore
       _.attempt(() => fs.unlinkSync(logger.file));
       // eslint-disable-next-line no-process-exit
diff --git a/detox/package.json b/detox/package.json
index 36e14e1f9..133028beb 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -61,7 +61,6 @@
     "caf": "^15.0.1",
     "chalk": "^2.4.2",
     "child-process-promise": "^2.2.0",
-    "duplexify": "^4.1.2",
     "find-up": "^4.1.0",
     "fs-extra": "^4.0.2",
     "funpermaproxy": "^1.1.0",
diff --git a/detox/src/artifacts/utils/temporaryPath.js b/detox/src/artifacts/utils/temporaryPath.js
index 82086aba2..7225f925b 100644
--- a/detox/src/artifacts/utils/temporaryPath.js
+++ b/detox/src/artifacts/utils/temporaryPath.js
@@ -1,14 +1,42 @@
 const path = require('path');
+const { promisify } = require('util');
 
+const glob = require('glob');
 const tempfile = require('tempfile');
 
+const { useForwardSlashes } = require('../../utils/shellUtils');
+
+const globSync = glob.sync;
+const globAsync = promisify(glob);
+
+function getRoot() {
+  return path.dirname(tempfile());
+}
+
+function createGlobber(ext) {
+  const fullExt = `.detox.${ext}`;
+
+  return {
+    sync: (pattern) => {
+      const cwd = getRoot();
+      const files = globSync(useForwardSlashes(pattern + fullExt), { cwd });
+      return files.map(f => path.join(cwd, f));
+    },
+    async: async (pattern) => {
+      const cwd = getRoot();
+      const files = await globAsync(useForwardSlashes(pattern + fullExt), { cwd });
+      return files.map(f => path.join(cwd, f));
+    },
+  };
+}
+
 function createTempFileBuilderFn(fileExtension) {
   /**
    * @param {string} [basename]
    */
   return (basename) => {
     return basename
-      ? path.join(path.dirname(tempfile()), `${basename}.detox.${fileExtension}`)
+      ? path.join(getRoot(), `${basename}.detox.${fileExtension}`)
       : tempfile(`.detox.${fileExtension}`);
   };
 }
@@ -23,5 +51,7 @@ module.exports = {
     dtxrec: createTempFileBuilderFn('dtxrec'),
     viewhierarchy: createTempFileBuilderFn('viewhierarchy'),
   },
-  mask: () => path.join(tempfile(), '..') + path.sep + '*.detox.*',
+  find: {
+    jsonl: createGlobber('jsonl'),
+  },
 };
diff --git a/detox/src/errors/DetoxError.js b/detox/src/errors/DetoxError.js
index 8f666fa02..cdc9b2538 100644
--- a/detox/src/errors/DetoxError.js
+++ b/detox/src/errors/DetoxError.js
@@ -8,6 +8,10 @@ class DetoxError extends Error {
     this.name = 'DetoxError';
   }
 
+  format() {
+    return this.message;
+  }
+
   static get reportIssue() {
     return 'Please report this issue on our GitHub tracker:\nhttps://github.com/wix/Detox/issues';
   }
@@ -32,7 +36,7 @@ class DetoxError extends Error {
    */
   static format(err, inspectOptions = { depth: 1 }) {
     if (err instanceof DetoxError) {
-      return err.message;
+      return err.format();
     }
 
     if (_.isError(err) && /^Command failed:/.test(err.message)) {
diff --git a/detox/src/logger/DetoxLogger.js b/detox/src/logger/DetoxLogger.js
index 9d67b7303..70e6e385d 100644
--- a/detox/src/logger/DetoxLogger.js
+++ b/detox/src/logger/DetoxLogger.js
@@ -19,11 +19,12 @@ const sanitizeBunyanContext = require('./utils/sanitizeBunyanContext');
  * @property {CategoryThreadDispatcher} [dispatcher]
  * @property {BunyanLogger} [bunyan]
  * @property {MessageStack} [messageStack]
+ * @property {boolean} [unsafeMode] Disables sanitization of user input, used for integration tests.
  */
 
 class DetoxLogger {
   /**
-   * @param {Pick<SharedLoggerConfig, 'file' | 'userConfig'>} sharedConfig
+   * @param {SharedLoggerConfig} sharedConfig
    * @param {object} [context]
    */
   constructor(sharedConfig, context) {
@@ -91,7 +92,7 @@ class DetoxLogger {
    * @returns {DetoxLogger}
    */
   child(overrides) {
-    const merged = this._mergeContexts(this._context, sanitizeBunyanContext(overrides));
+    const merged = this._mergeContexts(this._context, this._sanitizeContext(overrides));
     return new DetoxLogger(this._sharedConfig, merged);
   }
 
@@ -119,6 +120,25 @@ class DetoxLogger {
     this.overrideConsole();
   }
 
+  /**
+   * Closes the file descriptors to make sure that the temporary
+   * JSONL files are flushed and contain the last error messages.
+   * This safety measure is especially important for Windows OS.
+   *
+   * @async
+   * @internal
+   */
+  async close() {
+    if (this._context) {
+      throw new DetoxInternalError(
+        'Trying to close file streams from a non-root logger.\n' +
+        'If you are not fiddling with Detox internals on purpose, yet you see this error, then...'
+      );
+    }
+
+    await this._sharedConfig.bunyan.closeFileStreams();
+  }
+
   /**
    * @internal
    */
@@ -192,7 +212,7 @@ class DetoxLogger {
 
   /** @private */
   _beginInternal(level, context, msg) {
-    this._sharedConfig.messageStack.push(context.tid, msg);
+    this._sharedConfig.messageStack.push(context, msg);
     this._sharedConfig.bunyan.logger[level](context, ...msg);
   }
 
@@ -204,7 +224,7 @@ class DetoxLogger {
 
   /** @private */
   _endInternal(level, context, msg) {
-    const beginMsg = this._sharedConfig.messageStack.pop(context.tid);
+    const beginMsg = this._sharedConfig.messageStack.pop(context);
     if (msg.length === 0) {
       msg = beginMsg;
     }
@@ -272,12 +292,21 @@ class DetoxLogger {
     const context = this._mergeContexts(
       this._context,
       boundContext,
-      sanitizeBunyanContext(userContext),
+      this._sanitizeContext(userContext),
     );
 
     return { context, msg };
   }
 
+  /** @private */
+  _sanitizeContext(context) {
+    if (this._sharedConfig.unsafeMode) {
+      return context;
+    }
+
+    return sanitizeBunyanContext(context);
+  }
+
   /** @internal */
   static defaultOptions({ level }) {
     const ph = level === 'trace' || level === 'debug'
diff --git a/detox/src/logger/__mocks__/DetoxLogger.js b/detox/src/logger/__mocks__/DetoxLogger.js
index 9314a9022..e2abf8061 100644
--- a/detox/src/logger/__mocks__/DetoxLogger.js
+++ b/detox/src/logger/__mocks__/DetoxLogger.js
@@ -54,6 +54,10 @@ class FakeLogger {
 
   setConfig() {}
 
+  async close() {
+
+  }
+
   child(opts) {
     this.opts = Object.assign(this.opts, opts);
     return this;
diff --git a/detox/src/logger/utils/BunyanLogger.js b/detox/src/logger/utils/BunyanLogger.js
index d5ac8802c..f5fb1face 100644
--- a/detox/src/logger/utils/BunyanLogger.js
+++ b/detox/src/logger/utils/BunyanLogger.js
@@ -71,6 +71,45 @@ class BunyanLogger {
 
     return this;
   }
+
+  async closeFileStreams() {
+    const { _closeStream } = BunyanLogger;
+    const internalBunyanStreams = this._bunyan['streams'];
+    const openFileStreams = _.filter(internalBunyanStreams, this._isOpenFileStream);
+    _.remove(internalBunyanStreams, openFileStreams);
+
+    await Promise.all(openFileStreams.map(_closeStream));
+  }
+
+  /** @private */
+  _isOpenFileStream = (bunyanStream) => {
+    switch (bunyanStream.type) {
+      case 'file':
+        return bunyanStream.path && !bunyanStream.stream.destroyed;
+      case 'raw':
+        /* istanbul ignore next */
+        const stream = bunyanStream.stream === this._debugStream.stream
+          ? bunyanStream.stream._out
+          : bunyanStream.stream;
+
+        /* istanbul ignore next */
+        return stream.fd !== undefined && !stream.closed;
+    }
+  };
+
+  /** @private */
+  static _closeStream(bunyanStream) {
+    return new Promise((resolve, reject) => {
+      bunyanStream.stream.end((err) => {
+        /* istanbul ignore next */
+        if (err) {
+          reject(err);
+        } else {
+          resolve();
+        }
+      });
+    });
+  }
 }
 
 module.exports = BunyanLogger;
diff --git a/detox/src/logger/utils/CategoryThreadDispatcher.js b/detox/src/logger/utils/CategoryThreadDispatcher.js
index 480eebe4b..bf1f744f8 100644
--- a/detox/src/logger/utils/CategoryThreadDispatcher.js
+++ b/detox/src/logger/utils/CategoryThreadDispatcher.js
@@ -1,4 +1,5 @@
 const ThreadDispatcher = require('./ThreadDispatcher');
+const getMainCategory = require('./getMainCategory');
 
 class CategoryThreadDispatcher {
   constructor() {
@@ -24,7 +25,7 @@ class CategoryThreadDispatcher {
 
   /** @returns {ThreadDispatcher} */
   _resolveDispatcher(cat) {
-    const mainCategory = cat ? cat.split(',', 1)[0] : 'undefined';
+    const mainCategory = getMainCategory(cat);
     if (!this._dispatchers[mainCategory]) {
       this._dispatchers[mainCategory] = new ThreadDispatcher(mainCategory);
     }
diff --git a/detox/src/logger/utils/DetoxLogFinalizer.js b/detox/src/logger/utils/DetoxLogFinalizer.js
index ee103b050..5d56bbd77 100644
--- a/detox/src/logger/utils/DetoxLogFinalizer.js
+++ b/detox/src/logger/utils/DetoxLogFinalizer.js
@@ -1,39 +1,38 @@
 const path = require('path');
-const { promisify } = require('util');
 
 const fs = require('fs-extra');
-const glob = require('glob');
-const pipe = require('multipipe');
 
 const temporary = require('../../artifacts/utils/temporaryPath');
 
-const globAsync = promisify(glob);
-const globSync = glob.sync;
-const streamUtils = () => require('../../logger/utils/streamUtils');
+const { BunyanTransformer, ChromeTraceTransformer } = require('./streams');
 
 /**
  * @typedef DetoxLogFinalizerConfig
  * @property {import('../../ipc/SessionState')} session
+ * @property {import('../DetoxLogger')} logger
  */
 
 class DetoxLogFinalizer {
   /** @param {DetoxLogFinalizerConfig} config */
   constructor(config) {
     this._session = config.session;
+    this._bunyanTransformer = new BunyanTransformer(
+      config.logger.child({ cat: 'logger' }),
+    );
+    this._chromeTransformer = new ChromeTraceTransformer();
   }
 
   createEventStream() {
     const sessionId = this._session.id;
-    const logs = globSync(temporary.for.jsonl(`${sessionId}.*`));
+    const logs = temporary.find.jsonl.sync(`${sessionId}.*`);
 
-    return streamUtils()
-      .uniteSessionLogs(logs)
-      .pipe(streamUtils().chromeTraceStream());
+    const toChromeTrace = this._chromeTransformer.createStream();
+    return this._bunyanTransformer.uniteSessionLogs(logs).pipe(toChromeTrace);
   }
 
   async finalize() {
     const sessionId = this._session.id;
-    const logs = await globAsync(temporary.for.jsonl(`${sessionId}.*`));
+    const logs = await temporary.find.jsonl.async(`${sessionId}.*`);
     if (logs.length === 0) {
       return;
     }
@@ -42,16 +41,22 @@ class DetoxLogFinalizer {
       const rootDir = this._config.artifacts.rootDir;
 
       await fs.mkdirp(rootDir);
-      const [out1Stream, out2Stream] = ['detox.log', 'detox.trace.json']
-        .map((filename) => fs.createWriteStream(path.join(rootDir, filename)));
 
-      const tidHashMap = await this._scanForThreadIds(logs);
-      const mergedStream = streamUtils().uniteSessionLogs(logs);
+      const firstPass = this._bunyanTransformer.uniteSessionLogs(logs);
+      await this._chromeTransformer.scanThreadIDs(firstPass);
 
-      await Promise.all([
-        pipe(mergedStream, streamUtils().debugStream(this._config.logger.options), out1Stream),
-        pipe(mergedStream, streamUtils().chromeTraceStream(tidHashMap), streamUtils().writeJSON(), out2Stream),
-      ]);
+      const secondPass = this._bunyanTransformer.uniteSessionLogs(logs);
+      const outStreams = [this._createPlainFileStream(), this._createChromeTraceStream()];
+
+      await Promise.all(outStreams.map(stream => {
+        return new Promise((resolve, reject) => {
+          stream.target
+            .on('finish', resolve)
+            .on('error', reject);
+
+          secondPass.pipe(stream.writable);
+        });
+      }));
     }
 
     await Promise.all(logs.map(filepath => fs.remove(filepath)));
@@ -59,7 +64,7 @@ class DetoxLogFinalizer {
 
   finalizeSync() {
     const sessionId = this._session.id;
-    const logs = globSync(temporary.for.jsonl(`${sessionId}.*`));
+    const logs = temporary.find.jsonl.sync(`${sessionId}.*`);
     if (logs.length === 0) {
       return;
     }
@@ -72,13 +77,59 @@ class DetoxLogFinalizer {
 
     for (const log of logs) {
       if (logsEnabled) {
-        fs.moveSync(log, path.join(rootDir, path.basename(log)));
+        const dest = path.join(rootDir, path.basename(log));
+        this._safeMoveSync(log, dest);
       } else {
-        fs.removeSync(log);
+        this._safeRemoveSync(log);
+      }
+    }
+  }
+
+  _createPlainFileStream() {
+    const rootDir = this._config.artifacts.rootDir;
+    const bunyanOptions = this._config.logger.options;
+    const transformer = this._bunyanTransformer.createPlainTransformer(bunyanOptions);
+    const fileStream = fs.createWriteStream(path.join(rootDir, 'detox.log'));
+    transformer.readable.pipe(fileStream);
+
+    return { writable: transformer.writable, target: fileStream };
+  }
+
+  _createChromeTraceStream() {
+    const rootDir = this._config.artifacts.rootDir;
+    const transformer = this._chromeTransformer.createSerializedStream();
+    const fileStream = fs.createWriteStream(path.join(rootDir, 'detox.trace.json'));
+    transformer.readable.pipe(fileStream);
+
+    return { writable: transformer.writable, target: fileStream };
+  }
+
+  /* istanbul ignore next */
+  _safeMoveSync(src, dest) {
+    // Using console.* instead of logger.* because this is the end of the execution,
+    // and the logger is already closed.
+
+    try {
+      fs.moveSync(src, dest);
+    } catch (moveError) {
+      console.warn(`Failed to move a log file from: ${src}.\nReason: ${moveError.message}`);
+      try {
+        fs.copySync(src, dest);
+      } catch (copyError) {
+        console.warn(`Attempt to copy the file also failed.\nReason: ${copyError.message}`);
       }
     }
   }
 
+  /* istanbul ignore next */
+  _safeRemoveSync(filepath) {
+    try {
+      fs.removeSync(filepath);
+    } catch (removeError) {
+      console.warn(`Failed to remove a log file at: ${filepath}.\nReason: ${removeError.message}`);
+    }
+  }
+
   /** @private */
   get _config() {
     // The config appears later in the lifecycle, so we need to access it lazily
@@ -106,35 +157,6 @@ class DetoxLogFinalizer {
 
     return this._session.testResults.some(r => !r.success);
   }
-
-  /** @private */
-  async _scanForThreadIds(logs) {
-    const processes = await new Promise((resolve, reject) => {
-      const result = {};
-      streamUtils().uniteSessionLogs(logs)
-        .on('end', () => resolve(result))
-        .on('error', /* istanbul ignore next */ (err) => reject(err))
-        .on('data', (event) => {
-          const { ph, pid, tid, cat } = event;
-          if (ph === 'B' || ph === 'i') {
-            const categories = (result[pid] = result[pid] || {});
-            const mainCategory = String(cat).split(',')[0];
-            const tids = (categories[mainCategory] = categories[mainCategory] || []);
-            if (!tids.includes(tid)) {
-              tids.push(tid);
-            }
-          }
-        });
-    });
-
-    const tidArray = Object.entries(processes).flatMap(([pid, categories]) => {
-      return Object.entries(categories).flatMap(([category, tids]) => {
-        return tids.map(tid => `${pid}:${category}:${tid}`);
-      });
-    });
-
-    return new Map(tidArray.map((hash, index) => [hash, index]));
-  }
 }
 
 module.exports = DetoxLogFinalizer;
diff --git a/detox/src/logger/utils/MessageStack.js b/detox/src/logger/utils/MessageStack.js
index 275349958..eaee87437 100644
--- a/detox/src/logger/utils/MessageStack.js
+++ b/detox/src/logger/utils/MessageStack.js
@@ -1,24 +1,35 @@
+const getMainCategory = require('./getMainCategory');
+
 class MessageStack {
   constructor() {
     this._map = {};
   }
 
-  push(tid, msg) {
-    if (this._map[tid] == null) {
-      this._map[tid] = [];
+  push(context, msg) {
+    const hash = this._hash(context);
+
+    if (this._map[hash] == null) {
+      this._map[hash] = [];
     }
 
-    return this._map[tid].push(msg);
+    return this._map[hash].push(msg);
   }
 
-  pop(tid) {
-    const stack = this._map[tid];
+  pop(context) {
+    const hash = this._hash(context);
+    const stack = this._map[hash];
     if (stack == null || stack.length === 0) {
       return ['<no begin message>'];
     }
 
     return stack.pop();
   }
+
+  _hash(context) {
+    const cat = getMainCategory(context.cat);
+    const tid = context.tid;
+    return `${cat}:${tid}`;
+  }
 }
 
 module.exports = MessageStack;
diff --git a/detox/src/logger/utils/customConsoleLogger.js b/detox/src/logger/utils/customConsoleLogger.js
index 05f7f6607..2194fe050 100644
--- a/detox/src/logger/utils/customConsoleLogger.js
+++ b/detox/src/logger/utils/customConsoleLogger.js
@@ -47,6 +47,16 @@ function overrideConsoleMethods(console, bunyanLogger) {
   if (!console.__detox_log__) {
     const log = bunyanLogger;
 
+    console.__detox_log__ = {
+      log: console.log,
+      info: console.info,
+      warn: console.warn,
+      trace: console.trace,
+      error: console.error,
+      debug: console.debug,
+      assert: console.assert,
+    };
+
     override(console, 'log', log.info.bind(log));
     override(console, 'info', log.info.bind(log));
     override(console, 'warn', log.warn.bind(log));
@@ -54,13 +64,19 @@ function overrideConsoleMethods(console, bunyanLogger) {
     override(console, 'error', log.error.bind(log));
     override(console, 'debug', log.debug.bind(log));
     override(console, 'assert', log.error.bind(log));
-
-    console.__detox_log__ = log;
   }
 
   return console;
 }
 
+function restoreConsoleMethods(console) {
+  if (console.__detox_log__) {
+    Object.assign(console, console.__detox_log__);
+    delete console.__detox_log__;
+  }
+}
+
 module.exports = {
   overrideConsoleMethods,
+  restoreConsoleMethods,
 };
diff --git a/detox/src/logger/utils/getMainCategory.js b/detox/src/logger/utils/getMainCategory.js
new file mode 100644
index 000000000..84b5557ee
--- /dev/null
+++ b/detox/src/logger/utils/getMainCategory.js
@@ -0,0 +1,5 @@
+function getMainCategory(category) {
+  return category ? String(category).split(',', 1)[0] : 'undefined';
+}
+
+module.exports = getMainCategory;
diff --git a/detox/src/logger/utils/streamUtils.js b/detox/src/logger/utils/streamUtils.js
deleted file mode 100644
index 94cb54c25..000000000
--- a/detox/src/logger/utils/streamUtils.js
+++ /dev/null
@@ -1,248 +0,0 @@
-const { PassThrough, Transform } = require('stream');
-
-const bunyanDebugStream = require('bunyan-debug-stream');
-const duplexify = require('duplexify');
-const fs = require('fs-extra');
-const multiSort = require('multi-sort-stream');
-const pipe = require('multipipe');
-const JsonlParser = require('stream-json/jsonl/Parser');
-const stripAnsi = require('strip-ansi');
-const { AbstractEventBuilder } = require('trace-event-lib');
-
-const log = require('../../utils/logger').child({ cat: 'logger' });
-
-function compareTimestamps(a, b) {
-  return +(a.value.time > b.value.time) - +(a.value.time < b.value.time);
-}
-
-function mapTransform(fn) {
-  return new Transform({
-    objectMode: true,
-    transform(chunk, encoding, callback){
-      this.push(fn(chunk));
-      callback();
-    },
-  });
-}
-
-function flatMapTransform(fn) {
-  let index = 0;
-
-  return new Transform({
-    objectMode: true,
-    transform(chunk, encoding, callback){
-      const result = fn(chunk, index++);
-      if (Array.isArray(result)) {
-        // eslint-disable-next-line unicorn/no-array-method-this-argument
-        result.forEach(pushThis, this);
-      } else if (result) {
-        this.push(result);
-      }
-
-      callback();
-    },
-  });
-}
-
-function pushThis(x) {
-  return this.push(x);
-}
-
-function extractValue({ value }) {
-  value.msg = stripAnsi(value.msg);
-
-  if (typeof value.time === 'string') {
-    value.time = new Date(value.time);
-  }
-
-  return value;
-}
-
-function through() {
-  return new PassThrough({ objectMode: true });
-}
-
-function mergeSortedJSONL(jsonlStreams, comparator = compareTimestamps) {
-  const intermediate = mapTransform(extractValue);
-  const reemitError = (err) => intermediate.emit('error', err);
-  const addReemit = (stream) => stream.on('error', reemitError);
-  return pipe(multiSort(jsonlStreams.map(addReemit), comparator), intermediate, through());
-}
-
-function writeJSONL() {
-  return new JsonlStringer({
-    header: '',
-    delimiter: '\n',
-    footer: '',
-  });
-}
-
-function writeJSON() {
-  return new JsonlStringer({
-    header: '[\n\t',
-    delimiter: ',\n\t',
-    footer: '\n]\n',
-  });
-}
-
-function debugStream(options) {
-  const out = new PassThrough({ encoding: 'utf8' });
-  const writable = bunyanDebugStream.default({
-    ...options,
-    colors: false,
-    out,
-  });
-
-  return duplexify(writable, out, {
-    readableObjectMode: false,
-    writableObjectMode: true
-  });
-}
-
-class JsonlStringer extends Transform {
-  constructor({ replacer = undefined, header = '', delimiter = '\n', footer = '' } = {}) {
-    super({ writableObjectMode: true, readableObjectMode: false });
-
-    this._replacer = replacer;
-    this._header = header;
-    this._delimiter = delimiter;
-    this._footer = footer;
-  }
-
-  _transform(chunk, _, callback) {
-    if (this._header) {
-      this.push(this._header);
-    }
-
-    this.push(JSON.stringify(chunk, this._replacer));
-    this._transform = this._nextTransform;
-    callback(null);
-  }
-
-  _nextTransform(chunk, _, callback) {
-    this.push(this._delimiter + JSON.stringify(chunk, this._replacer));
-    callback(null);
-  }
-
-  _flush(callback) {
-    if (this._footer) {
-      this.push(this._footer);
-    }
-
-    callback();
-  }
-}
-
-class SimpleEventBuilder extends AbstractEventBuilder {
-  events = [];
-
-  send(event) {
-    this.events.push(event);
-  }
-}
-
-const ERROR_TID = 37707;
-
-function getTidHash({ pid, tid, cat }) {
-  const mainCategory = String(cat).split(',')[0];
-  return `${pid}:${mainCategory}:${tid}`;
-}
-
-function getFixedTID(tidHashMap, event) {
-  let tid = tidHashMap.get(getTidHash(event));
-  if (tid === undefined) {
-    tid = ERROR_TID;
-  }
-
-  return tid;
-}
-
-/**
- * @param {Map<string, number>} [tidHashMap]
- */
-function chromeTraceStream(tidHashMap) {
-  const knownPids = new Set();
-  const knownTids = new Set();
-  const fixTID = tidHashMap ? getFixedTID.bind(null, tidHashMap) : (x) => x.tid;
-
-  return flatMapTransform((data) => {
-    // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
-    const { cat: rawCat, msg: name, ph = 'i', pid, tid: _tid, time, name: _name, hostname: _hostname, ...args } = data;
-    const ts = new Date(time).getTime() * 1E3;
-    const cat = rawCat || 'undefined';
-    const tid = fixTID(data);
-
-    const builder = new SimpleEventBuilder();
-    if (!knownPids.has(pid)) {
-      builder.metadata({ pid, ts, name: 'process_name', args: { name: pid === process.pid ? 'primary' : 'secondary' } });
-      builder.metadata({ pid, ts, name: 'process_sort_index', args: { sort_index: knownPids.size } });
-      knownPids.add(pid);
-    }
-
-    if (!knownTids.has(tid)) {
-      const primaryCategory = cat.split(',', 1)[0];
-      builder.metadata({ pid, tid, ts, name: 'thread_name', args: { name: primaryCategory } });
-      builder.metadata({ pid, tid, ts, name: 'thread_sort_index', args: { sort_index: tid } });
-      knownTids.add(tid);
-    }
-
-    const event = { ph, name, pid, tid, cat, ts, args };
-    if (ph === 'E') {
-      delete event.name;
-    }
-
-    builder.events.push(event);
-    return builder.events;
-  });
-}
-
-// TODO: create PR to https://github.com/mafintosh/duplexify
-// should be able to pass { error: false } to end-of-stream
-function preventErrorSubscriptions(emitter) {
-  const originalOn = emitter.on.bind(emitter);
-  emitter.on = (event, ...args) => {
-    if (event === 'error') {
-      return emitter;
-    }
-
-    return originalOn(event, ...args);
-  };
-
-  return emitter;
-}
-
-function readJSONL() {
-  const readable = through();
-  const writable = JsonlParser.make({ checkErrors: true })
-    .on('error', (err) => {
-      if (err instanceof SyntaxError) {
-        log.debug({ err });
-        readable.end();
-      } else {
-        readable.emit('error', err);
-      }
-    });
-
-  return duplexify.obj(preventErrorSubscriptions(writable), writable.pipe(readable));
-}
-
-/**
- * @param {string[]} logs
- * @returns {NodeJS.ReadableStream}
- */
-function uniteSessionLogs(logs) {
-  const readable = through();
-  const jsonlStreams = logs.map(filePath => fs.createReadStream(filePath).pipe(readJSONL()));
-  mergeSortedJSONL(jsonlStreams).pipe(readable);
-  return readable;
-}
-
-module.exports = {
-  readJSONL,
-  writeJSON,
-  writeJSONL,
-  mergeSortedJSONL,
-  debugStream,
-  chromeTraceStream,
-  uniteSessionLogs,
-};
diff --git a/detox/src/logger/utils/streams/BunyanTransformer.js b/detox/src/logger/utils/streams/BunyanTransformer.js
new file mode 100644
index 000000000..982b1aee2
--- /dev/null
+++ b/detox/src/logger/utils/streams/BunyanTransformer.js
@@ -0,0 +1,72 @@
+const fs = require('fs');
+const { PassThrough } = require('stream');
+
+const bunyanDebugStream = require('bunyan-debug-stream');
+const multiSort = require('multi-sort-stream');
+const pipe = require('multipipe');
+const stripAnsi = require('strip-ansi');
+
+const DetoxJSONLParser = require('./DetoxJSONLParser');
+const { mapTransform, through } = require('./transformers');
+
+class BunyanTransformer {
+  /**
+   * @param {Detox.Logger} log
+   */
+  constructor(log) {
+    this._jsonlParser = new DetoxJSONLParser(log);
+  }
+
+  /**
+   * @param {string[]} logFilePaths
+   */
+  uniteSessionLogs(logFilePaths) {
+    const intermediate = mapTransform(BunyanTransformer.normalizeBunyanRecord);
+    const reemitError = (err) => intermediate.emit('error', err);
+
+    const jsonlStreams = logFilePaths.map(filePath => {
+      const { readable, writable } = this._jsonlParser.createTransformer();
+      fs.createReadStream(filePath)
+        .on('error', reemitError)
+        .pipe(writable);
+
+      return readable;
+    });
+
+    const multisorted = multiSort(jsonlStreams, BunyanTransformer.compareTimestamps);
+    return pipe(multisorted, intermediate, through());
+  }
+
+  createPlainTransformer(bunyanOptions) {
+    /** @type {*} */
+    const readable = new PassThrough({ encoding: 'utf8', objectMode: false });
+    const writable = bunyanDebugStream.default({
+      ...bunyanOptions,
+
+      colors: false,
+      out: readable,
+    });
+
+    writable.on('error', /* istanbul ignore next */ (err) => readable.emit('error', err));
+    writable.on('finish', () => readable.end());
+
+    return {
+      writable,
+      readable,
+    };
+  }
+
+  static normalizeBunyanRecord(record) {
+    const value = record.value;
+    value.msg = stripAnsi(value.msg);
+    value.time = new Date(value.time);
+
+    return value;
+  }
+
+  static compareTimestamps(a, b) {
+    return +(a.value.time > b.value.time) - +(a.value.time < b.value.time);
+  }
+}
+
+module.exports = BunyanTransformer;
diff --git a/detox/src/logger/utils/streams/ChromeTraceTransformer.js b/detox/src/logger/utils/streams/ChromeTraceTransformer.js
new file mode 100644
index 000000000..3f34f3e4a
--- /dev/null
+++ b/detox/src/logger/utils/streams/ChromeTraceTransformer.js
@@ -0,0 +1,127 @@
+const { AbstractEventBuilder } = require('trace-event-lib');
+
+const getMainCategory = require('../getMainCategory');
+
+const JSONLStringer = require('./JSONLStringer');
+const { flatMapTransform } = require('./transformers');
+
+class ChromeTraceTransformer {
+  constructor() {
+    /** @type {Map<string, number>} */
+    this._globalThreadMap = null;
+  }
+
+  /**
+   * @param {NodeJS.ReadableStream} eventStream
+   */
+  async scanThreadIDs(eventStream) {
+    const processes = await new Promise((resolve, reject) => {
+      const result = {};
+      eventStream
+        .on('end', () => resolve(result))
+        .on('error', /* istanbul ignore next */ (err) => reject(err))
+        .on('data', (event) => {
+          const { ph, pid, tid, cat } = event;
+          if (ph === 'B' || ph === 'i') {
+            const categories = (result[pid] = result[pid] || {});
+            const mainCategory = String(cat).split(',')[0];
+            const tids = (categories[mainCategory] = categories[mainCategory] || []);
+            if (!tids.includes(tid)) {
+              tids.push(tid);
+            }
+          }
+        });
+    });
+
+    const tidArray = Object.entries(processes).flatMap(([pid, categories]) => {
+      return Object.entries(categories).flatMap(([category, tids]) => {
+        return tids.map(tid => `${pid}:${category}:${tid}`);
+      });
+    });
+
+    this._globalThreadMap = new Map(tidArray.map((hash, index) => [hash, index]));
+  }
+
+  /**
+   * @returns {module:stream.internal.Transform}
+   */
+  createStream() {
+    const transformFn = this._transformBunyanRecord.bind(this, {
+      knownPids: new Set(),
+      knownTids: new Set(),
+    });
+
+    return flatMapTransform(transformFn);
+  }
+
+  createSerializedStream() {
+    const writable = this.createStream();
+    const readable = JSONLStringer.serializeJSON();
+    writable.pipe(readable);
+    return { writable, readable };
+  }
+
+  /**
+   * @param {{ knownPids: Set<number>, knownTids: Set<number> }} state
+   * @param {*} bunyanLogRecord
+   * @returns {import('trace-event-lib').Event[]}
+   * @private
+   */
+  _transformBunyanRecord(state, bunyanLogRecord) {
+    // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+    const { cat: rawCat, msg: name, ph, pid, tid: _tid, time, name: _name, hostname: _hostname, ...args } = bunyanLogRecord;
+    const ts = new Date(time).getTime() * 1E3;
+    const cat = rawCat || 'undefined';
+    const tid = this._resolveGlobalTID(bunyanLogRecord);
+
+    const builder = new SimpleEventBuilder();
+    if (!state.knownPids.has(pid)) {
+      builder.metadata({ pid, ts, name: 'process_name', args: { name: pid === process.pid ? 'primary' : 'secondary' } });
+      builder.metadata({ pid, ts, name: 'process_sort_index', args: { sort_index: state.knownPids.size } });
+      state.knownPids.add(pid);
+    }
+
+    if (!state.knownTids.has(tid)) {
+      const mainCategory = getMainCategory(cat);
+      builder.metadata({ pid, tid, ts, name: 'thread_name', args: { name: mainCategory } });
+      builder.metadata({ pid, tid, ts, name: 'thread_sort_index', args: { sort_index: tid } });
+      state.knownTids.add(tid);
+    }
+
+    const event = { ph, name, pid, tid, cat, ts, args };
+    if (ph === 'E') {
+      delete event.name;
+    }
+
+    builder.events.push(event);
+    return builder.events;
+  }
+
+  _resolveGlobalTID(event) {
+    const hash = this._computeThreadHash(event);
+    const tid = this._globalThreadMap ? this._globalThreadMap.get(hash) : event.tid;
+
+    // Impossible condition, but anyway it is better to be safe than sorry.
+    /* istanbul ignore next */
+    return tid === undefined ? ChromeTraceTransformer.ERROR_TID : tid;
+  }
+
+  _computeThreadHash({ pid, tid, cat }) {
+    return `${pid}:${getMainCategory(cat)}:${tid}`;
+  }
+
+  /* istanbul ignore next */
+  static get ERROR_TID() {
+    return 37707;
+  }
+}
+
+class SimpleEventBuilder extends AbstractEventBuilder {
+  events = [];
+
+  send(event) {
+    this.events.push(event);
+  }
+}
+
+module.exports = ChromeTraceTransformer;
diff --git a/detox/src/logger/utils/streams/DetoxJSONLParser.js b/detox/src/logger/utils/streams/DetoxJSONLParser.js
new file mode 100644
index 000000000..a6e82346a
--- /dev/null
+++ b/detox/src/logger/utils/streams/DetoxJSONLParser.js
@@ -0,0 +1,31 @@
+const JsonlParser = require('stream-json/jsonl/Parser');
+
+const { through } = require('./transformers');
+
+class DetoxJSONLParser {
+  constructor(log) {
+    this._log = log;
+  }
+
+  createTransformer() {
+    const log = this._log;
+    const readable = through();
+    const writable = JsonlParser.make({ checkErrors: true })
+      .on('error', (err) => {
+        /* istanbul ignore else */
+        if (err instanceof SyntaxError) {
+          log.debug({ err });
+          readable.end();
+        } else {
+          readable.emit('error', err);
+        }
+      });
+
+    return {
+      writable,
+      readable: writable.pipe(readable),
+    };
+  }
+}
+
+module.exports = DetoxJSONLParser;
diff --git a/detox/src/logger/utils/streams/JSONLStringer.js b/detox/src/logger/utils/streams/JSONLStringer.js
new file mode 100644
index 000000000..45a2d1c40
--- /dev/null
+++ b/detox/src/logger/utils/streams/JSONLStringer.js
@@ -0,0 +1,55 @@
+const { Transform } = require('stream');
+
+class JSONLStringer extends Transform {
+  constructor({ replacer, header, delimiter, footer }) {
+    super({ writableObjectMode: true, readableObjectMode: false });
+
+    this._replacer = replacer;
+    this._header = header;
+    this._delimiter = delimiter;
+    this._footer = footer;
+  }
+
+  _transform(chunk, _, callback) {
+    if (this._header) {
+      this.push(this._header);
+    }
+
+    this.push(JSON.stringify(chunk, this._replacer));
+    this._transform = this._nextTransform;
+    callback(null);
+  }
+
+  _nextTransform(chunk, _, callback) {
+    this.push(this._delimiter + JSON.stringify(chunk, this._replacer));
+    callback(null);
+  }
+
+  _flush(callback) {
+    if (this._footer) {
+      this.push(this._footer);
+    }
+
+    callback();
+  }
+
+  static serializeJSONL() {
+    return new JSONLStringer({
+      replacer: undefined,
+      header: '',
+      delimiter: '\n',
+      footer: '',
+    });
+  }
+
+  static serializeJSON() {
+    return new JSONLStringer({
+      replacer: undefined,
+      header: '[\n\t',
+      delimiter: ',\n\t',
+      footer: '\n]\n',
+    });
+  }
+}
+
+module.exports = JSONLStringer;
diff --git a/detox/src/logger/utils/streams/index.js b/detox/src/logger/utils/streams/index.js
new file mode 100644
index 000000000..70ce9bc83
--- /dev/null
+++ b/detox/src/logger/utils/streams/index.js
@@ -0,0 +1,7 @@
+const BunyanTransformer = require('./BunyanTransformer');
+const ChromeTraceTransformer = require('./ChromeTraceTransformer');
+
+module.exports = {
+  BunyanTransformer,
+  ChromeTraceTransformer,
+};
diff --git a/detox/src/logger/utils/streams/transformers.js b/detox/src/logger/utils/streams/transformers.js
new file mode 100644
index 000000000..855dc2fb9
--- /dev/null
+++ b/detox/src/logger/utils/streams/transformers.js
@@ -0,0 +1,39 @@
+const { PassThrough, Transform } = require('stream');
+
+function through() {
+  return new PassThrough({ objectMode: true });
+}
+
+function mapTransform(fn) {
+  return new Transform({
+    objectMode: true,
+    transform(chunk, encoding, callback){
+      this.push(fn(chunk));
+      callback();
+    },
+  });
+}
+
+function flatMapTransform(fn) {
+  let index = 0;
+
+  return new Transform({
+    objectMode: true,
+    transform(chunk, encoding, callback){
+      const results = fn(chunk, index++);
+      // eslint-disable-next-line unicorn/no-array-method-this-argument
+      results.forEach(pushThis, this);
+      callback();
+    },
+  });
+}
+
+function pushThis(x) {
+  return this.push(x);
+}
+
+module.exports = {
+  through,
+  mapTransform,
+  flatMapTransform,
+};
diff --git a/detox/src/realms/DetoxContext.js b/detox/src/realms/DetoxContext.js
index d788e914d..c0ec43a76 100644
--- a/detox/src/realms/DetoxContext.js
+++ b/detox/src/realms/DetoxContext.js
@@ -60,7 +60,8 @@ class DetoxContext {
     installLegacyTracerInterface(this.log, this);
 
     this[$logFinalizer] = new DetoxLogFinalizer({
-      session: this[$sessionState]
+      session: this[$sessionState],
+      logger: this[symbols.logger],
     });
 
     /** @type {import('../DetoxWorker') | null} */
diff --git a/detox/src/realms/DetoxPrimaryContext.js b/detox/src/realms/DetoxPrimaryContext.js
index b015f1d9c..391bf177f 100644
--- a/detox/src/realms/DetoxPrimaryContext.js
+++ b/detox/src/realms/DetoxPrimaryContext.js
@@ -24,6 +24,7 @@ const _dirty = Symbol('dirty');
 const _emergencyTeardown = Symbol('emergencyTeardown');
 const _lifecycleLogger = Symbol('lifecycleLogger');
 const _sessionFile = Symbol('sessionFile');
+const _logFinalError = Symbol('logFinalError');
 //#endregion
 
 class DetoxPrimaryContext extends DetoxContext {
@@ -178,9 +179,10 @@ class DetoxPrimaryContext extends DetoxContext {
       if (this[_dirty]) {
         try {
           this[_lifecycleLogger].trace.end();
+          await this[symbols.logger].close();
           await this[$logFinalizer].finalize();
         } catch (err) {
-          this[_lifecycleLogger].error({ err }, 'Encountered an error while merging the process logs:');
+          this[_logFinalError](err);
         }
       }
     }
@@ -210,12 +212,17 @@ class DetoxPrimaryContext extends DetoxContext {
 
     try {
       this[_lifecycleLogger].trace.end({ abortSignal: signal });
+      this[symbols.logger].close().catch(this[_logFinalError]);
       this[$logFinalizer].finalizeSync();
     } catch (err) {
-      this[symbols.logger].error({ err }, 'Encountered an error while merging the process logs:');
+      this[_logFinalError](err);
     }
   };
 
+  [_logFinalError] = (err) => {
+    this[_lifecycleLogger].error(err, 'Encountered an error while merging the process logs:');
+  };
+
   //#endregion
 
   //#region Protected members


Test patch:
diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js
index 04dba0e32..0c5b14905 100644
--- a/detox/local-cli/testCommand/TestRunnerCommand.js
+++ b/detox/local-cli/testCommand/TestRunnerCommand.js
@@ -6,11 +6,12 @@ const unparse = require('yargs-unparser');
 
 const detox = require('../../internals');
 const log = detox.log.child({ cat: ['lifecycle', 'cli'] });
-const { DetoxRuntimeError } = require('../../src/errors');
 const { printEnvironmentVariables, prependNodeModulesBinToPATH } = require('../../src/utils/envUtils');
 const { toSimplePath } = require('../../src/utils/pathUtils');
 const { escapeSpaces, useForwardSlashes } = require('../../src/utils/shellUtils');
 
+const TestRunnerError = require('./TestRunnerError');
+
 class TestRunnerCommand {
   /*
     @param {object} opts
@@ -116,7 +117,7 @@ class TestRunnerCommand {
     const fullCommand = this._buildSpawnArguments().map(escapeSpaces);
     const fullCommandWithHint = printEnvironmentVariables(this._envHint) + fullCommand.join(' ');
 
-    log.info({ env: this._envHint }, fullCommandWithHint);
+    log.info.begin({ env: this._envHint }, fullCommandWithHint);
 
     return new Promise((resolve, reject) => {
       cp.spawn(fullCommand[0], fullCommand.slice(1), {
@@ -130,10 +131,20 @@ class TestRunnerCommand {
           .value()
       })
         .on('error', /* istanbul ignore next */ (err) => reject(err))
-        .on('exit', (code) => code === 0
-          ? resolve()
-          : reject(new DetoxRuntimeError(`Command failed with exit code = ${code}:\n${fullCommandWithHint}`)
-        ));
+        .on('exit', (code, signal) => {
+          if (code === 0) {
+            log.trace.end({ success: true });
+            resolve();
+          } else {
+            const error = new TestRunnerError({
+              command: fullCommandWithHint,
+              code,
+              signal,
+            });
+            log.error.end({ success: false, code, signal }, error.message);
+            reject(error);
+          }
+        });
     });
   }
 
diff --git a/detox/local-cli/testCommand/TestRunnerError.js b/detox/local-cli/testCommand/TestRunnerError.js
new file mode 100644
index 000000000..cc8b1b41c
--- /dev/null
+++ b/detox/local-cli/testCommand/TestRunnerError.js
@@ -0,0 +1,17 @@
+const { DetoxError } = require('../../src/errors');
+
+class TestRunnerError extends DetoxError {
+  constructor({ command, code, signal }) {
+    super(`Command failed with exit code = ${code}:\n${command}`);
+
+    this.code = code;
+    this.signal = signal;
+    this.name = 'TestRunnerError';
+  }
+
+  format() {
+    return '';
+  }
+}
+
+module.exports = TestRunnerError;
diff --git a/detox/local-cli/testCommand/TestRunnerError.test.js b/detox/local-cli/testCommand/TestRunnerError.test.js
new file mode 100644
index 000000000..880588af2
--- /dev/null
+++ b/detox/local-cli/testCommand/TestRunnerError.test.js
@@ -0,0 +1,25 @@
+const { DetoxError } = require('../../src/errors');
+
+const TestRunnerError = require('./TestRunnerError');
+
+describe('TestRunnerError', () => {
+  let error;
+
+  beforeAll(() => {
+    error = new TestRunnerError({ command: 'foo', code: 1, signal: null });
+  });
+
+  it('should format an error message', () => {
+    expect(`${error}`).toMatch(/Command failed with exit code = 1:\nfoo/);
+  });
+
+  it('should assign properties (code, signal)', () => {
+    expect(error.code).toBe(1);
+    expect(error.signal).toBe(null);
+  });
+
+  it('should be formatted to an empty string', () => {
+    // So that the try-catch block in cli.js will not print it for the second time
+    expect(DetoxError.format(error)).toBe('');
+  });
+});
diff --git a/detox/src/artifacts/utils/temporaryPath.test.js b/detox/src/artifacts/utils/temporaryPath.test.js
index 69db36100..719b9a47d 100644
--- a/detox/src/artifacts/utils/temporaryPath.test.js
+++ b/detox/src/artifacts/utils/temporaryPath.test.js
@@ -1,24 +1,64 @@
 const path = require('path');
 
+const fs = require('fs-extra');
 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.viewhierarchy for .dtxrec', expectTemporaryFile('dtxrec'));
-  it('should create a temporary file like *.detox.viewhierarchy for .viewhierarchy', expectTemporaryFile('viewhierarchy'));
-
-  it('should generate a glob mask for those temporary files', () => {
-    expect(temporaryPath.mask()).toMatch(/\*\.detox\.\*$/);
+  describe.each([
+    ['json'],
+    ['jsonl'],
+    ['png'],
+    ['log'],
+    ['mp4'],
+    ['dtxrec'],
+    ['viewhierarchy'],
+  ])('for.%s', (ext) => {
+    it(`should generate a temporary path with the correct extension`, () => {
+      expect(path.dirname(temporaryPath.for[ext]())).toBe(path.dirname(tempfile()));
+      expect(temporaryPath.for[ext]()).toMatch(new RegExp(`.+\\.detox\\.${ext}$`));
+    });
+
+    it(`should generate a temporary path with specified name and correct extension`, () => {
+      const basename = Math.random().toString(36).slice(2);
+      const tempPath = temporaryPath.for[ext](basename);
+      expect(path.dirname(temporaryPath.for[ext]())).toBe(path.dirname(tempfile()));
+      expect(path.basename(tempPath, `.detox.${ext}`)).toBe(basename);
+    });
   });
 
-  function expectTemporaryFile(extension) {
-    return function () {
-      expect(path.dirname(temporaryPath.for[extension]())).toBe(path.dirname(tempfile()));
-      expect(temporaryPath.for[extension]()).toMatch(new RegExp(`.+\\.detox\\.${extension}$`));
-    };
-  }
+  describe.each([
+    ['jsonl'],
+  ])('find.%s', (ext) => {
+    let file1, file2;
+
+    beforeEach(async () => {
+      file1 = temporaryPath.for[ext]('ABC.DEF');
+      file2 = temporaryPath.for[ext]('XYZ.DEF');
+      await fs.ensureFile(file1);
+      await fs.ensureFile(file2);
+    });
+
+    afterEach(async () => {
+      await fs.remove(file1);
+      await fs.remove(file2);
+    });
+
+    describe('.sync', () => {
+      it('should find temporary files by mask synchronously', async () => {
+        expect(temporaryPath.find[ext].sync('ABC.DEF')).toEqual([file1]);
+        expect(temporaryPath.find[ext].sync('ABC.*')).toEqual([file1]);
+        expect(temporaryPath.find[ext].sync('*.DEF')).toEqual([file1, file2]);
+      });
+    });
+
+    describe('.async', () => {
+      it('should find temporary files by mask asynchronously', async () => {
+        await expect(temporaryPath.find[ext].async('ABC.DEF')).resolves.toEqual([file1]);
+        await expect(temporaryPath.find[ext].async('ABC.*')).resolves.toEqual([file1]);
+        await expect(temporaryPath.find[ext].async('*.DEF')).resolves.toEqual([file1, file2]);
+      });
+    });
+  });
 });
diff --git a/detox/src/logger/DetoxLogger.test.js b/detox/src/logger/DetoxLogger.test.js
index 04cb667e5..539d288a1 100644
--- a/detox/src/logger/DetoxLogger.test.js
+++ b/detox/src/logger/DetoxLogger.test.js
@@ -12,12 +12,6 @@ const tempfile = require('tempfile');
 
 const sleep = require('../utils/sleep');
 
-// TODO: investigate why the test fails
-if (os.platform() === 'win32') {
-  // eslint-disable-next-line no-global-assign
-  describe = describe.skip;
-}
-
 jest.retryTimes(2);
 
 describe('DetoxLogger', () => {
@@ -25,6 +19,8 @@ describe('DetoxLogger', () => {
 
   /** @type {typeof import('./DetoxLogger')} */
   let DetoxLogger;
+  /** @type {import('./DetoxLogger')[]} */
+  let _loggerInstances = [];
   /** @type {string} */
   let jsonlLogPath;
   /** @type {string} */
@@ -44,7 +40,7 @@ describe('DetoxLogger', () => {
     const { level = 'trace', overrideConsole = false, options } = opts;
     DetoxLogger = require('./DetoxLogger');
 
-    return new DetoxLogger({
+    const instance = new DetoxLogger({
       file: jsonlLogPath,
       userConfig: {
         level,
@@ -58,6 +54,10 @@ describe('DetoxLogger', () => {
         },
       },
     });
+
+    _loggerInstances = _loggerInstances || [];
+    _loggerInstances.push(instance);
+    return instance;
   }
 
   let _env;
@@ -70,8 +70,12 @@ describe('DetoxLogger', () => {
     safetyTimeout = null;
   }
 
-  function teardownSuite() {
+  async function teardownSuite() {
     process.env = _env;
+
+    await Promise.all(_loggerInstances.map((logger) => logger.close()));
+    _loggerInstances = [];
+
     fs.removeSync(jsonlLogPath);
     fs.removeSync(plainLogPath);
   }
@@ -120,6 +124,19 @@ describe('DetoxLogger', () => {
       expect(logger().level).toBe('trace'); // set in tests
     });
 
+    it('should cast non-standard log levels to the closest standard level', async () => {
+      const LoggerClass = logger().constructor;
+      expect(LoggerClass).toBe(DetoxLogger);
+      expect(DetoxLogger.castLevel('trace')).toBe('trace');
+      expect(DetoxLogger.castLevel('debug')).toBe('debug');
+      expect(DetoxLogger.castLevel('verbose')).toBe('debug');
+      expect(DetoxLogger.castLevel('info')).toBe('info');
+      expect(DetoxLogger.castLevel('warn')).toBe('warn');
+      expect(DetoxLogger.castLevel('error')).toBe('error');
+      expect(DetoxLogger.castLevel('fatal')).toBe('fatal');
+      expect(DetoxLogger.castLevel('unknown')).toBe('info');
+    });
+
     it('should be able to create a child logger', async () => {
       const parent = logger();
       const child1 = parent.child({ cat: 'child1' });
@@ -233,6 +250,19 @@ describe('DetoxLogger', () => {
       await expect(txt()).resolves.toMatchSnapshot();
     });
 
+    it('should log end duration events correctly for different categories', async () => {
+      const parent = logger();
+      const cat1 = parent.child({ cat: 'cat1' });
+      const cat2 = parent.child({ cat: 'cat2' });
+
+      cat1.info.begin('Activity 1 start');
+      cat2.info.begin('Activity 2 start');
+      cat1.info.end();
+      cat2.info.end();
+
+      await expect(txt()).resolves.toMatchSnapshot();
+    });
+
     it.each([
       ['trace'],
       ['debug'],
@@ -367,6 +397,10 @@ describe('DetoxLogger', () => {
       await expect(logger().child().setConfig({ level: 'info' })).rejects.toThrowError(/Trying to set a config in a non-root logger/);
     });
 
+    it('should not allow closing the entire logging capability from a child logger', async () => {
+      await expect(logger().child().close()).rejects.toThrowError(/Trying to close file streams from a non-root logger/);
+    });
+
     it('should escape properties conflicting with Bunyan internal ones', async () => {
       logger().info({
         hostname: 'hostname',
@@ -401,19 +435,26 @@ describe('DetoxLogger', () => {
         v: 0,
       }]);
     });
-  });
 
-  it('should cast non-standard log levels to the closest standard level', async () => {
-    const LoggerClass = logger().constructor;
-    expect(LoggerClass).toBe(DetoxLogger);
-    expect(DetoxLogger.castLevel('trace')).toBe('trace');
-    expect(DetoxLogger.castLevel('debug')).toBe('debug');
-    expect(DetoxLogger.castLevel('verbose')).toBe('debug');
-    expect(DetoxLogger.castLevel('info')).toBe('info');
-    expect(DetoxLogger.castLevel('warn')).toBe('warn');
-    expect(DetoxLogger.castLevel('error')).toBe('error');
-    expect(DetoxLogger.castLevel('fatal')).toBe('fatal');
-    expect(DetoxLogger.castLevel('unknown')).toBe('info');
+    it('should allow creating a logger without a config', async () => {
+      const DetoxLogger = require('./DetoxLogger');
+      const noopFilePath = os.platform() === 'win32' ? 'nul' : '/dev/null';
+      const instance = new DetoxLogger({ file: noopFilePath });
+
+      expect(instance.config).toEqual({
+        level: 'info',
+        overrideConsole: false,
+        options: expect.objectContaining({
+          showDate: false,
+          showLevel: false,
+          showLoggerName: false,
+          showMetadata: false,
+          showPid: false,
+          showPrefixes: false,
+          showProcess: false,
+        }),
+      });
+    });
   });
 
   function anError(msg) {
diff --git a/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap
index c9de9880f..3b261c639 100644
--- a/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap
+++ b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap
@@ -103,3 +103,10 @@ exports[`DetoxLogger - main functionality - should log complete duration events
 00:00:00.000 detox[PID] E  Error duration (async)
   error: Error: Oops (async)!"
 `;
+
+exports[`DetoxLogger - main functionality - should log end duration events correctly for different categories 1`] = `
+"00:00:00.000 detox[PID] B cat1 Activity 1 start
+00:00:00.000 detox[PID] B cat2 Activity 2 start
+00:00:00.000 detox[PID] E cat1 Activity 1 start
+00:00:00.000 detox[PID] E cat2 Activity 2 start"
+`;
diff --git a/detox/src/logger/utils/DetoxLogFinalizer.test.js b/detox/src/logger/utils/DetoxLogFinalizer.test.js
index 9b2d439ab..53012497b 100644
--- a/detox/src/logger/utils/DetoxLogFinalizer.test.js
+++ b/detox/src/logger/utils/DetoxLogFinalizer.test.js
@@ -1,22 +1,4 @@
 jest.unmock('../DetoxLogger');
-jest.useFakeTimers({
-  doNotFake: [
-    'nextTick',
-    'performance',
-    'queueMicrotask',
-    'requestAnimationFrame',
-    'cancelAnimationFrame',
-    'requestIdleCallback',
-    'cancelIdleCallback',
-    'setImmediate',
-    'clearImmediate',
-    'setInterval',
-    'clearInterval',
-    'setTimeout',
-    'clearTimeout',
-  ],
-  now: new Date(2023, 0, 1),
-});
 
 const path = require('path');
 const { PassThrough } = require('stream');
@@ -26,17 +8,10 @@ const glob = require('glob');
 const tempfile = require('tempfile');
 
 const temporary = require('../../artifacts/utils/temporaryPath');
-const sleep = require('../../utils/sleep');
 const DetoxLogger = require('../DetoxLogger');
 
 const DetoxLogFinalizer = require('./DetoxLogFinalizer');
 
-// TODO: investigate why the test fails
-if (require('os').platform() === 'win32') {
-  // eslint-disable-next-line no-global-assign
-  describe = describe.skip;
-}
-
 describe('DetoxLogFinalizer', () => {
   /** @type {string[]} */
   let temporaryFiles = [];
@@ -44,6 +19,8 @@ describe('DetoxLogFinalizer', () => {
   let session;
   /** @type {import('./DetoxLogFinalizer')} */
   let finalizer;
+  /** @type {import('../DetoxLogger')} */
+  let fakeLogger;
 
   let _env;
 
@@ -70,7 +47,7 @@ describe('DetoxLogFinalizer', () => {
           overrideConsole: false,
           options: {
             ...DetoxLogger.defaultOptions({
-              level: 'info',
+              level: 'trace',
             }),
             showDate: (date) => date.toISOString(),
             colors: false,
@@ -80,7 +57,11 @@ describe('DetoxLogFinalizer', () => {
     };
 
     temporaryFiles = [session.detoxConfig.artifacts.rootDir];
-    finalizer = new DetoxLogFinalizer({ session });
+
+    const FakeLogger = jest.requireMock('../DetoxLogger');
+    fakeLogger = new FakeLogger();
+
+    finalizer = new DetoxLogFinalizer({ session, logger: fakeLogger });
   });
 
   afterEach(async () => {
@@ -104,11 +85,16 @@ describe('DetoxLogFinalizer', () => {
       let plainLog = await fs.readFile(path.join(artifactsDir(), 'detox.log'), 'utf8');
       let traceLog = JSON.parse(await fs.readFile(path.join(artifactsDir(), 'detox.trace.json'), 'utf8'));
 
-      plainLog = plainLog.replace(/detox\[\d+\]/g, 'detox[1234]');
-      traceLog = traceLog.map(t => (t.pid = 1234, t));
+      plainLog = plainLog
+        .replace(new RegExp(`detox\\[${process.pid}]`, 'g'), 'detox[1234]')
+        .replace(new RegExp(`detox\\[${process.pid + 1}]`, 'g'), 'detox[1235]');
+
+      for (const t of traceLog) {
+        t.pid = 1234 + t.pid - process.pid;
+      }
 
-      expect(plainLog).toMatchSnapshot();
-      expect(traceLog).toMatchSnapshot();
+      expect(plainLog).toMatchSnapshot('plain');
+      expect(traceLog).toMatchSnapshot('chrome-trace');
     });
 
     it('should not create logs when they are disabled', async () => {
@@ -138,8 +124,8 @@ describe('DetoxLogFinalizer', () => {
       await createLogFiles();
       await finalizer.finalize();
 
-      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
-      expect(outFiles.map(f => path.basename(f))).toEqual([
+      const outFiles = glob.sync('*', { cwd: artifactsDir() });
+      expect(outFiles).toEqual([
         'detox.log',
         'detox.trace.json',
       ]);
@@ -170,7 +156,7 @@ describe('DetoxLogFinalizer', () => {
       await createLogFiles();
       finalizer.finalizeSync();
 
-      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
+      const outFiles = glob.sync('*', { cwd: artifactsDir() });
       expect(outFiles.map(f => path.extname(f))).toEqual([
         '.jsonl',
         '.jsonl',
@@ -204,7 +190,7 @@ describe('DetoxLogFinalizer', () => {
       await createLogFiles();
       finalizer.finalizeSync();
 
-      const outFiles = glob.sync(path.join(artifactsDir(), '*'));
+      const outFiles = glob.sync('*', { cwd: artifactsDir() });
       expect(outFiles.map(f => path.extname(f))).toEqual([
         '.jsonl',
         '.jsonl',
@@ -249,22 +235,35 @@ describe('DetoxLogFinalizer', () => {
       },
     };
 
-    const log1 = new DetoxLogger({
+    const root1 = new DetoxLogger({
+      unsafeMode: true,
       file: logPath1,
       userConfig,
     });
 
-    const log2 = new DetoxLogger({
+    const root2 = new DetoxLogger({
+      unsafeMode: true,
       file: logPath2,
       userConfig,
     });
 
+    const log1 = root1.child({ pid: process.pid });
+    const log2 = root2.child({ pid: process.pid + 1 });
+
+    jest.useFakeTimers();
+
     jest.setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
     log1.info({ cat: 'main' }, 'msg1');
 
     jest.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
     log1.trace.begin({ id: 1, cat: 'parallel' }, 'duration1');
 
+    jest.setSystemTime(new Date('2023-01-01T00:00:01.100Z'));
+    log1.trace.begin({ id: 1, cat: 'parallel' }, 'duration1:stacked');
+
+    jest.setSystemTime(new Date('2023-01-01T00:00:01.400Z'));
+    log1.trace.end({ id: 1, cat: 'parallel' }, 'duration1:stacked');
+
     jest.setSystemTime(new Date('2023-01-01T00:00:01.500Z'));
     log1.trace.begin({ id: 2, cat: 'parallel' }, 'duration2');
 
@@ -275,14 +274,15 @@ describe('DetoxLogFinalizer', () => {
     log1.trace.end({ id: 2, cat: 'parallel' });
 
     jest.setSystemTime(new Date('2023-01-01T00:00:00.500Z'));
-    log2.info.begin('long-duration');
+    log2.info.begin({ cat: 'parallel' }, 'long-duration');
 
     jest.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
     log2.debug('instant event');
 
     jest.setSystemTime(new Date('2023-01-01T00:00:02.250Z'));
-    log2.trace.end();
+    log2.trace.end({ cat: 'parallel' });
 
-    await sleep(10);
+    jest.useRealTimers();
+    await Promise.all([root1.close(), root2.close()]);
   }
 });
diff --git a/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap
index 762523d04..872ba9cc2 100644
--- a/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap
+++ b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap
@@ -54,12 +54,32 @@ Array [
     "tid": 0,
     "ts": 1672531200000000,
   },
+  Object {
+    "args": Object {
+      "name": "secondary",
+    },
+    "name": "process_name",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 1,
+    },
+    "name": "process_sort_index",
+    "ph": "M",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531200500000,
+  },
   Object {
     "args": Object {
       "level": 30,
       "v": 0,
     },
-    "cat": "undefined",
+    "cat": "parallel",
     "name": "long-duration",
     "ph": "B",
     "pid": 1234,
@@ -91,6 +111,31 @@ Array [
     "tid": 0,
     "ts": 1672531201000000,
   },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration1:stacked",
+    "ph": "B",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531201100000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 0,
+    "ts": 1672531201400000,
+  },
   Object {
     "args": Object {
       "name": "parallel",
@@ -141,7 +186,7 @@ Array [
       "level": 10,
       "v": 0,
     },
-    "cat": "undefined",
+    "cat": "parallel",
     "ph": "E",
     "pid": 1234,
     "tid": 0,
@@ -162,19 +207,7 @@ Array [
 ]
 `;
 
-exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format 1`] = `
-"2023-01-01T00:00:00.000Z detox[1234] i msg1
-2023-01-01T00:00:00.500Z detox[1234] B long-duration
-2023-01-01T00:00:01.000Z detox[1234] B duration1
-2023-01-01T00:00:01.000Z detox[1234] i instant event
-2023-01-01T00:00:01.500Z detox[1234] B duration2
-2023-01-01T00:00:02.000Z detox[1234] E duration1
-2023-01-01T00:00:02.250Z detox[1234] E long-duration
-2023-01-01T00:00:02.500Z detox[1234] E duration2
-"
-`;
-
-exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format 2`] = `
+exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format: chrome-trace 1`] = `
 Array [
   Object {
     "args": Object {
@@ -230,22 +263,42 @@ Array [
   },
   Object {
     "args": Object {
-      "name": "undefined",
+      "name": "secondary",
     },
-    "name": "thread_name",
+    "name": "process_name",
     "ph": "M",
-    "pid": 1234,
-    "tid": 1,
+    "pid": 1235,
+    "tid": 0,
     "ts": 1672531200500000,
   },
   Object {
     "args": Object {
       "sort_index": 1,
     },
+    "name": "process_sort_index",
+    "ph": "M",
+    "pid": 1235,
+    "tid": 0,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "name": "parallel",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1235,
+    "tid": 3,
+    "ts": 1672531200500000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 3,
+    },
     "name": "thread_sort_index",
     "ph": "M",
-    "pid": 1234,
-    "tid": 1,
+    "pid": 1235,
+    "tid": 3,
     "ts": 1672531200500000,
   },
   Object {
@@ -253,11 +306,11 @@ Array [
       "level": 30,
       "v": 0,
     },
-    "cat": "undefined",
+    "cat": "parallel",
     "name": "long-duration",
     "ph": "B",
-    "pid": 1234,
-    "tid": 1,
+    "pid": 1235,
+    "tid": 3,
     "ts": 1672531200500000,
   },
   Object {
@@ -267,17 +320,17 @@ Array [
     "name": "thread_name",
     "ph": "M",
     "pid": 1234,
-    "tid": 2,
+    "tid": 1,
     "ts": 1672531201000000,
   },
   Object {
     "args": Object {
-      "sort_index": 2,
+      "sort_index": 1,
     },
     "name": "thread_sort_index",
     "ph": "M",
     "pid": 1234,
-    "tid": 2,
+    "tid": 1,
     "ts": 1672531201000000,
   },
   Object {
@@ -290,7 +343,27 @@ Array [
     "name": "duration1",
     "ph": "B",
     "pid": 1234,
-    "tid": 2,
+    "tid": 1,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "name": "undefined",
+    },
+    "name": "thread_name",
+    "ph": "M",
+    "pid": 1235,
+    "tid": 4,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "sort_index": 4,
+    },
+    "name": "thread_sort_index",
+    "ph": "M",
+    "pid": 1235,
+    "tid": 4,
     "ts": 1672531201000000,
   },
   Object {
@@ -301,9 +374,34 @@ Array [
     "cat": "undefined",
     "name": "instant event",
     "ph": "i",
+    "pid": 1235,
+    "tid": 4,
+    "ts": 1672531201000000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "name": "duration1:stacked",
+    "ph": "B",
     "pid": 1234,
     "tid": 1,
-    "ts": 1672531201000000,
+    "ts": 1672531201100000,
+  },
+  Object {
+    "args": Object {
+      "id": 1,
+      "level": 10,
+      "v": 0,
+    },
+    "cat": "parallel",
+    "ph": "E",
+    "pid": 1234,
+    "tid": 1,
+    "ts": 1672531201400000,
   },
   Object {
     "args": Object {
@@ -312,17 +410,17 @@ Array [
     "name": "thread_name",
     "ph": "M",
     "pid": 1234,
-    "tid": 3,
+    "tid": 2,
     "ts": 1672531201500000,
   },
   Object {
     "args": Object {
-      "sort_index": 3,
+      "sort_index": 2,
     },
     "name": "thread_sort_index",
     "ph": "M",
     "pid": 1234,
-    "tid": 3,
+    "tid": 2,
     "ts": 1672531201500000,
   },
   Object {
@@ -335,7 +433,7 @@ Array [
     "name": "duration2",
     "ph": "B",
     "pid": 1234,
-    "tid": 3,
+    "tid": 2,
     "ts": 1672531201500000,
   },
   Object {
@@ -347,7 +445,7 @@ Array [
     "cat": "parallel",
     "ph": "E",
     "pid": 1234,
-    "tid": 2,
+    "tid": 1,
     "ts": 1672531202000000,
   },
   Object {
@@ -355,10 +453,10 @@ Array [
       "level": 10,
       "v": 0,
     },
-    "cat": "undefined",
+    "cat": "parallel",
     "ph": "E",
-    "pid": 1234,
-    "tid": 1,
+    "pid": 1235,
+    "tid": 3,
     "ts": 1672531202250000,
   },
   Object {
@@ -370,8 +468,22 @@ Array [
     "cat": "parallel",
     "ph": "E",
     "pid": 1234,
-    "tid": 3,
+    "tid": 2,
     "ts": 1672531202500000,
   },
 ]
 `;
+
+exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format: plain 1`] = `
+"2023-01-01T00:00:00.000Z detox[1234] i main msg1
+2023-01-01T00:00:00.500Z detox[1235] B parallel long-duration
+2023-01-01T00:00:01.000Z detox[1234] B parallel@1 duration1
+2023-01-01T00:00:01.000Z detox[1235] i  instant event
+2023-01-01T00:00:01.100Z detox[1234] B parallel@1 duration1:stacked
+2023-01-01T00:00:01.400Z detox[1234] E parallel@1 duration1:stacked
+2023-01-01T00:00:01.500Z detox[1234] B parallel@2 duration2
+2023-01-01T00:00:02.000Z detox[1234] E parallel@1 duration1
+2023-01-01T00:00:02.250Z detox[1235] E parallel long-duration
+2023-01-01T00:00:02.500Z detox[1234] E parallel@2 duration2
+"
+`;
diff --git a/detox/src/logger/utils/customConsoleLogger.test.js b/detox/src/logger/utils/customConsoleLogger.test.js
index 47a136c91..74a4ac914 100644
--- a/detox/src/logger/utils/customConsoleLogger.test.js
+++ b/detox/src/logger/utils/customConsoleLogger.test.js
@@ -1,9 +1,11 @@
-describe('customConsoleLogger.overrideConsoleMethods(console, bunyanLogger)', () => {
+describe('customConsoleLogger', () => {
   let overrideConsoleMethods;
+  let restoreConsoleMethods;
   let fakeConsole, bunyanLogger;
 
   beforeEach(() => {
     overrideConsoleMethods = require('./customConsoleLogger').overrideConsoleMethods;
+    restoreConsoleMethods = require('./customConsoleLogger').restoreConsoleMethods;
 
     fakeConsole = {
       log: jest.fn(),
@@ -92,4 +94,21 @@ describe('customConsoleLogger.overrideConsoleMethods(console, bunyanLogger)', ()
       expect(bunyanLogger.error).not.toHaveBeenCalled();
     });
   });
+
+  describe('- restoring the override -', () => {
+    it('should not throw if the console is not overridden', () => {
+      expect(() => restoreConsoleMethods(fakeConsole)).not.toThrow();
+    });
+
+    it('should work if the console has been overridden', () => {
+      const originalMethods = Object.values(fakeConsole);
+      overrideConsoleMethods(fakeConsole, bunyanLogger);
+      const overridenMethods = Object.values(fakeConsole);
+      restoreConsoleMethods(fakeConsole);
+      const restoredMethods = Object.values(fakeConsole);
+
+      expect(restoredMethods).toEqual(originalMethods);
+      expect(originalMethods).not.toEqual(overridenMethods);
+    });
+  });
 });
diff --git a/detox/src/logger/utils/streamUtils.test.js b/detox/src/logger/utils/streamUtils.test.js
deleted file mode 100644
index d6c0c59a0..000000000
--- a/detox/src/logger/utils/streamUtils.test.js
+++ /dev/null
@@ -1,127 +0,0 @@
-const fs = require('fs');
-const { Readable } = require('stream');
-
-const pipe = require('multipipe');
-const tempfile = require('tempfile');
-
-jest.mock('../../utils/logger');
-
-describe('JSONL', () => {
-  let utils;
-
-  beforeEach(() => {
-    utils = require('./streamUtils');
-  });
-
-  it('should sort JSON streams', async () => {
-    const streamA = stringToStream(toJSONLString([
-      { time: '2000-01-01T00:00:00.001Z', value: 'a1' },
-      { time: '2000-01-01T00:00:00.002Z', value: 'a2' },
-      { time: '2000-01-01T00:00:00.004Z', value: 'a3' },
-    ])).pipe(utils.readJSONL());
-
-    const streamB = stringToStream(toJSONLString([
-      { time: '2000-01-01T00:00:00.000Z', value: 'b1' },
-      { time: '2000-01-01T00:00:00.003Z', value: 'b2' },
-      { time: '2000-01-01T00:00:00.005Z', value: 'b3' },
-    ])).pipe(utils.readJSONL());
-
-    const result = utils.mergeSortedJSONL([streamA, streamB]);
-    await expect(toObjects(result)).resolves.toEqual([
-      { time: new Date('2000-01-01T00:00:00.000Z'), value: 'b1' },
-      { time: new Date('2000-01-01T00:00:00.001Z'), value: 'a1' },
-      { time: new Date('2000-01-01T00:00:00.002Z'), value: 'a2' },
-      { time: new Date('2000-01-01T00:00:00.003Z'), value: 'b2' },
-      { time: new Date('2000-01-01T00:00:00.004Z'), value: 'a3' },
-      { time: new Date('2000-01-01T00:00:00.005Z'), value: 'b3' },
-    ]);
-  });
-
-  it('should handle unfinished JSON streams', async () => {
-    const jsonlStream = stringToStream(toJSONLString([
-      { time: '2000-01-01T00:00:00.001Z', value: 'a1' },
-      { time: '2000-01-01T00:00:00.002Z', value: 'a2' },
-    ]).slice(0, -2)).pipe(utils.readJSONL());
-
-    await expect(toObjects(jsonlStream)).resolves.toEqual([
-      { key: 0, value: { time: '2000-01-01T00:00:00.001Z', value: 'a1' } },
-    ]);
-
-    const logger = require('../../utils/logger');
-    expect(logger.debug).toHaveBeenCalledWith({ err: expect.any(Error) });
-  });
-
-  it('should convert bunyan JSON to bunyan debug streams', async () => {
-    const events = objectsToStream([
-      { time: '2000-01-01T00:00:00.001Z', pid: 1, msg: 'Event 1', name: 'app', level: 30 },
-      { time: '2000-01-01T00:00:00.002Z', pid: 1, msg: 'Event 2', name: 'app', level: 20 },
-      { time: '2000-01-01T00:00:00.004Z', pid: 1, msg: 'Event 3', name: 'app', level: 10 },
-    ]);
-
-    const bsd = events.pipe(utils.debugStream());
-    await expect(toString(bsd)).resolves.toEqual([
-      '2000-01-01T00:00:00.001Z app[1] INFO:  Event 1',
-      '2000-01-01T00:00:00.002Z app[1] DEBUG: Event 2',
-      '2000-01-01T00:00:00.004Z app[1] TRACE: Event 3',
-      ''
-    ].join('\n'));
-  });
-
-  it('should propagate errors from the input streams too', async () => {
-    const nonexistentFileStream1 = pipe(fs.createReadStream(tempfile('.log')), utils.readJSONL());
-    const nonexistentFileStream2 = pipe(fs.createReadStream(tempfile('.log')), utils.readJSONL());
-    const mergedStream = utils.mergeSortedJSONL([nonexistentFileStream1, nonexistentFileStream2]);
-    const stringifiedStream = pipe(mergedStream, utils.writeJSONL());
-    await expect(toString(stringifiedStream)).rejects.toThrowError(/ENOENT/);
-  });
-});
-
-//#region Helper functions
-
-/***
- * @param {unknown[]} data
- */
-function toJSONLString(data) {
-  return data.map(j => JSON.stringify(j)).join('\n');
-}
-
-function stringToStream(str) {
-  const s = new Readable();
-  s._read = () => {};
-  s.push(str);
-  s.push(null); // eslint-disable-line unicorn/no-array-push-push
-  return s;
-}
-
-function objectsToStream(objs) {
-  const s = new Readable({ objectMode: true });
-  s._read = () => {};
-  for (const o of objs) {
-    s.push(o);
-  }
-  s.push(null); // eslint-disable-line unicorn/no-array-push-push
-  return s;
-}
-
-/** @async */
-function toString(stream) {
-  const chunks = [];
-
-  return new Promise((resolve, reject) => {
-    stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
-    stream.on('error', (err) => reject(err));
-    stream.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')));
-  });
-}
-
-/** @async */
-function toObjects(stream) {
-  const chunks = [];
-
-  return new Promise((resolve, reject) => {
-    stream.on('data', (obj) => chunks.push(obj));
-    stream.on('error', (err) => reject(err));
-    stream.on('end', () => resolve(chunks));
-  });
-}
-//#endregion
diff --git a/detox/src/logger/utils/streams/BunyanTransformer.test.js b/detox/src/logger/utils/streams/BunyanTransformer.test.js
new file mode 100644
index 000000000..b71dee7e5
--- /dev/null
+++ b/detox/src/logger/utils/streams/BunyanTransformer.test.js
@@ -0,0 +1,140 @@
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+
+describe('BunyanTransformer', () => {
+  /** @type {import('./BunyanTransformer')} */
+  let transformer;
+  let DetoxLogger;
+  let logger;
+  let temporaryFiles;
+
+  beforeEach(() => {
+    temporaryFiles = [];
+
+    const BunyanTransformer = require('./BunyanTransformer');
+
+    DetoxLogger = jest.requireMock('../../DetoxLogger');
+    logger = new DetoxLogger();
+    transformer = new BunyanTransformer(logger);
+  });
+
+  afterEach(async () => {
+    await Promise.all(temporaryFiles.map(filepath => fs.remove(filepath)));
+    temporaryFiles = [];
+  });
+
+  it('should merge log files according to the timestamp', async () => {
+    const fileA = saveAsFile(toJSONLString([
+      { time: '2000-01-01T00:00:00.001Z', msg: 'a1' },
+      { time: '2000-01-01T00:00:00.002Z', msg: 'a2' },
+      { time: '2000-01-01T00:00:00.004Z', msg: 'a3' },
+    ]));
+
+    const fileB = saveAsFile(toJSONLString([
+      { time: '2000-01-01T00:00:00.000Z', msg: 'b1' },
+      { time: '2000-01-01T00:00:00.003Z', msg: 'b2' },
+      { time: '2000-01-01T00:00:00.005Z', msg: 'b3' },
+    ]));
+
+    const result = transformer.uniteSessionLogs([fileA, fileB]);
+    await expect(toObjects(result)).resolves.toEqual([
+      { time: new Date('2000-01-01T00:00:00.000Z'), msg: 'b1' },
+      { time: new Date('2000-01-01T00:00:00.001Z'), msg: 'a1' },
+      { time: new Date('2000-01-01T00:00:00.002Z'), msg: 'a2' },
+      { time: new Date('2000-01-01T00:00:00.003Z'), msg: 'b2' },
+      { time: new Date('2000-01-01T00:00:00.004Z'), msg: 'a3' },
+      { time: new Date('2000-01-01T00:00:00.005Z'), msg: 'b3' },
+    ]);
+  });
+
+  it('should handle unfinished JSON streams', async () => {
+    const fileA = saveAsFile(toJSONLString([
+      { time: '2000-01-01T00:00:00.001Z', msg: 'a1' },
+      { time: '2000-01-01T00:00:00.002Z', msg: 'a2' },
+    ]).slice(0, -2));
+
+    const fileB = saveAsFile(toJSONLString([
+      { time: '2000-01-01T00:00:01.001Z', msg: 'b1' },
+      { time: '2000-01-01T00:00:01.002Z', msg: 'b2' },
+    ]));
+
+    const result = transformer.uniteSessionLogs([fileA, fileB]);
+    await expect(toObjects(result)).resolves.toEqual([
+      { time: new Date('2000-01-01T00:00:00.001Z'), msg: 'a1' },
+      { time: new Date('2000-01-01T00:00:01.001Z'), msg: 'b1' },
+      { time: new Date('2000-01-01T00:00:01.002Z'), msg: 'b2' },
+    ]);
+
+    expect(logger.debug).toHaveBeenCalledWith({ err: expect.any(Error) });
+    expect(logger.debug).toHaveBeenCalledTimes(1);
+  });
+
+  it('should convert bunyan JSON to bunyan debug streams', async () => {
+    const logFile = saveAsFile(toJSONLString([
+      { time: '2000-01-01T00:00:00.001Z', pid: 1, msg: 'Event 1', name: 'app', level: 30 },
+      { time: '2000-01-01T00:00:00.002Z', pid: 1, msg: 'Event 2', name: 'app', level: 20 },
+      { time: '2000-01-01T00:00:00.004Z', pid: 1, msg: 'Event 3', name: 'app', level: 10 },
+    ]));
+    const logStream = transformer.uniteSessionLogs([logFile]);
+    const plainTransformer = transformer.createPlainTransformer({
+      ...DetoxLogger.defaultOptions({ level: 'trace' }),
+      showDate: time => time.toISOString(),
+      showLevel: true,
+    });
+
+    logStream.pipe(plainTransformer.writable);
+    await expect(toString(plainTransformer.readable)).resolves.toEqual([
+      '2000-01-01T00:00:00.001Z app[1] INFO:  Event 1',
+      '2000-01-01T00:00:00.002Z app[1] DEBUG: Event 2',
+      '2000-01-01T00:00:00.004Z app[1] TRACE: Event 3',
+      ''
+    ].join('\n'));
+  });
+
+  it('should propagate errors from the input streams too', async () => {
+    const logStream = transformer.uniteSessionLogs([tempfile()]);
+    await expect(toString(logStream)).rejects.toThrowError(/ENOENT/);
+  });
+
+  //#region Helper functions
+
+  /***
+   * @param {unknown[]} data
+   */
+  function toJSONLString(data) {
+    return data.map(j => JSON.stringify(j)).join('\n');
+  }
+
+  function saveAsFile(str) {
+    const filename = tempfile();
+    temporaryFiles.push(filename);
+    fs.writeFileSync(filename, str);
+    return filename;
+  }
+
+  /** @async */
+  function toString(stream) {
+    const chunks = [];
+
+    return new Promise((resolve, reject) => {
+      stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
+      stream.on('error', (err) => reject(err));
+      stream.on('finish', () => resolve(Buffer.concat(chunks).toString('utf8')));
+    });
+  }
+
+  /** @async */
+  function toObjects(stream) {
+    const chunks = [];
+
+    return new Promise((resolve, reject) => {
+      stream.on('data', (obj) => chunks.push(obj));
+      stream.on('error', (err) => reject(err));
+      stream.on('end', () => resolve(chunks));
+    });
+  }
+
+  //#endregion
+});
+
+
diff --git a/detox/src/logger/utils/streams/JSONLStringer.test.js b/detox/src/logger/utils/streams/JSONLStringer.test.js
new file mode 100644
index 000000000..fdfeb0124
--- /dev/null
+++ b/detox/src/logger/utils/streams/JSONLStringer.test.js
@@ -0,0 +1,35 @@
+const JSONLStringer = require('./JSONLStringer');
+
+describe('JSONLStringer', () => {
+  it('should serialize to JSONL', (resolve) => {
+    expect.hasAssertions();
+
+    let output = '';
+    const stringer = JSONLStringer.serializeJSONL()
+      .on('data', (chunk) => { output += chunk; })
+      .on('end', () => {
+        expect(output).toEqual('{"a":1}\n{"b":2}');
+        resolve();
+      });
+
+    stringer.write({ a: 1 });
+    stringer.write({ b: 2 });
+    stringer.end();
+  });
+
+  it('should serialize to JSON', (resolve) => {
+    expect.hasAssertions();
+
+    let output = '';
+    const stringer = JSONLStringer.serializeJSON()
+      .on('data', (chunk) => { output += chunk; })
+      .on('end', () => {
+        expect(output).toEqual('[\n\t{"a":1},\n\t{"b":2}\n]\n');
+        resolve();
+      });
+
+    stringer.write({ a: 1 });
+    stringer.write({ b: 2 });
+    stringer.end();
+  });
+});
diff --git a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
index 2bcde5250..5a1fb014e 100644
--- a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
+++ b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap
@@ -36,7 +36,7 @@ exports[`Timeline integration test should deterministically produce a timeline a
     },
     "cat": "lifecycle,cli",
     "name": "nyc jest --config integration/e2e/config.js stub1",
-    "ph": "i",
+    "ph": "B",
     "pid": 0,
     "tid": 0,
     "ts": 2,
@@ -400,6 +400,20 @@ exports[`Timeline integration test should deterministically produce a timeline a
     "tid": 1,
     "ts": 28,
   },
+  {
+    "args": {
+      "code": 1,
+      "level": 50,
+      "signal": null,
+      "success": false,
+      "v": 0,
+    },
+    "cat": "lifecycle,cli",
+    "ph": "E",
+    "pid": 0,
+    "tid": 0,
+    "ts": 29,
+  },
   {
     "args": {
       "level": 50,
@@ -414,7 +428,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 0,
     "tid": 0,
-    "ts": 29,
+    "ts": 30,
   },
   {
     "args": {
@@ -424,10 +438,10 @@ Detox CLI is going to restart the test runner with those files...
     },
     "cat": "lifecycle,cli",
     "name": "nyc jest --config integration/e2e/config.js $CWD/integration/e2e/stub1.test.js",
-    "ph": "i",
+    "ph": "B",
     "pid": 0,
     "tid": 0,
-    "ts": 30,
+    "ts": 31,
   },
   {
     "args": {
@@ -439,7 +453,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 31,
+    "ts": 32,
   },
   {
     "args": {
@@ -451,7 +465,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 32,
+    "ts": 33,
   },
   {
     "args": {
@@ -463,7 +477,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 33,
+    "ts": 34,
   },
   {
     "args": {
@@ -475,7 +489,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 34,
+    "ts": 35,
   },
   {
     "args": {
@@ -487,7 +501,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 35,
+    "ts": 36,
   },
   {
     "args": {
@@ -499,7 +513,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 36,
+    "ts": 37,
   },
   {
     "args": {
@@ -515,7 +529,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 37,
+    "ts": 38,
   },
   {
     "args": {
@@ -527,7 +541,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 38,
+    "ts": 39,
   },
   {
     "args": {
@@ -560,7 +574,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 39,
+    "ts": 40,
   },
   {
     "args": {
@@ -572,7 +586,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 40,
+    "ts": 41,
   },
   {
     "args": {
@@ -588,7 +602,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 41,
+    "ts": 42,
   },
   {
     "args": {
@@ -600,7 +614,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 42,
+    "ts": 43,
   },
   {
     "args": {
@@ -623,7 +637,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 43,
+    "ts": 44,
   },
   {
     "args": {
@@ -636,7 +650,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 44,
+    "ts": 45,
   },
   {
     "args": {
@@ -649,7 +663,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 45,
+    "ts": 46,
   },
   {
     "args": {
@@ -661,7 +675,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 46,
+    "ts": 47,
   },
   {
     "args": {
@@ -677,7 +691,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 47,
+    "ts": 48,
   },
   {
     "args": {
@@ -689,7 +703,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 48,
+    "ts": 49,
   },
   {
     "args": {
@@ -701,7 +715,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 49,
+    "ts": 50,
   },
   {
     "args": {
@@ -717,7 +731,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 50,
+    "ts": 51,
   },
   {
     "args": {
@@ -729,7 +743,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "i",
     "pid": 2,
     "tid": 2,
-    "ts": 51,
+    "ts": 52,
   },
   {
     "args": {
@@ -740,7 +754,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 52,
+    "ts": 53,
   },
   {
     "args": {
@@ -751,7 +765,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 53,
+    "ts": 54,
   },
   {
     "args": {
@@ -763,7 +777,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "B",
     "pid": 2,
     "tid": 2,
-    "ts": 54,
+    "ts": 55,
   },
   {
     "args": {
@@ -774,7 +788,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 55,
+    "ts": 56,
   },
   {
     "args": {
@@ -786,7 +800,21 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 2,
     "tid": 2,
-    "ts": 56,
+    "ts": 57,
+  },
+  {
+    "args": {
+      "code": 1,
+      "level": 50,
+      "signal": null,
+      "success": false,
+      "v": 0,
+    },
+    "cat": "lifecycle,cli",
+    "ph": "E",
+    "pid": 0,
+    "tid": 0,
+    "ts": 58,
   },
   {
     "args": {
@@ -797,7 +825,7 @@ Detox CLI is going to restart the test runner with those files...
     "ph": "E",
     "pid": 0,
     "tid": 0,
-    "ts": 57,
+    "ts": 59,
   },
 ]
 `;