Repository: wix/Detox (js)

Issue:
[Website] Create a visual regression test suite
## Description

- [x] Set up a test suite in the `website/` project using [Percy.io](https://percy.io).
- [ ] Create a corresponding step in [.github/workflows/website.html](https://github.com/wix/Detox/blob/master/.github/workflows/website.yml)

![Promo-image](https://percy.io/cdn-cgi/image/quality=85,width=1536,fit=scale-down/static/images/marketing/homepage_2x.png)

We could start from a simple set of test pages:

- home page
- documentation (demo page)
- blog page
- showcase page

Reportedly, the easiest way to start is **Percy Snapshots**:

https://docs.percy.io/docs/percy-snapshot

You can see a similar use case here:

* https://github.com/rohit-gohri/redocusaurus/tree/main/website
* https://github.com/rohit-gohri/redocusaurus/blob/main/website/package.json#L22
* https://github.com/rohit-gohri/redocusaurus/blob/main/website/snapshots.js

Although, I'd advise sticking with `snapshots.yml` (YAML format) just like the docs say.


Pull request:
[Website]: Add GitHub Actions Step for Visual Regression Tests
In this pull request, I have:

- Created a GitHub actions step in the .github/workflows/website.yml file using an 'if' conditional for running Percy  regression test step and take snapshots if there are any changes in the 'website/src/css' files.
- This If condition uses the statndard way to check for changes in a specific file or folder in a GitHub Actions workflow as an array of file paths that were modified in the latest commit.

Resolves #3838 

cc: @asafkorem 

Patch:
diff --git a/.editorconfig b/.editorconfig
index 1f68adb6a..e1e863d41 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -18,7 +18,7 @@ tab_width = 2
 indent_size = 4
 tab_width = 4
 
-[{*.cjs,*.js}]
+[{*.cjs,*.js,*.mjs}]
 indent_size = 2
 tab_width = 2
 
diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index c1d194779..9f44d879e 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -3,8 +3,16 @@ name: Website
 on:
   pull_request:
     branches: [master]
+    paths:
+      - '.github/workflows/website.yml'
+      - 'docs/**'
+      - 'website/**'
   push:
     branches: [master]
+    paths:
+      - '.github/workflows/website.yml'
+      - 'docs/**'
+      - 'website/**'
 
 jobs:
   checks:
@@ -15,11 +23,23 @@ jobs:
       - uses: actions/setup-node@v1
         with:
           node-version: '16.x'
+      - uses: dorny/paths-filter@v2
+        id: changes
+        with:
+          filters: |
+            website:
+              - 'website/**'
       - name: Test Build
+        working-directory: ./website
         run: |
-          cd website/
           npm i
           npm run build
+      - name: Run Percy Snapshot
+        working-directory: ./website
+        if: steps.changes.outputs.website == 'true'
+        run: npm run percy
+        env:
+          PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
   gh-release:
     if: github.event_name != 'pull_request'
     permissions:
diff --git a/detox/local-cli/cli.js b/detox/local-cli/cli.js
index 52d648be2..7c6f3d56b 100755
--- a/detox/local-cli/cli.js
+++ b/detox/local-cli/cli.js
@@ -7,6 +7,8 @@ const yargs = require('yargs');
 const logger = require('../internals').log.child({ cat: 'cli' });
 const DetoxError = require('../src/errors/DetoxError');
 
+const { isErrorAlreadyLogged } = require('./utils/cliErrorHandling');
+
 yargs
   .scriptName('detox')
   .parserConfiguration({
@@ -28,9 +30,8 @@ yargs
   .wrap(yargs.terminalWidth() * 0.9)
   .fail(function(msg, err, program) {
     if (err) {
-      const message = DetoxError.format(err);
-      if (message) {
-        logger.error(message);
+      if (!isErrorAlreadyLogged(err)) {
+        logger.error(DetoxError.format(err));
         process.stderr.write('\n');
       }
 
diff --git a/detox/local-cli/utils/cliErrorHandling.js b/detox/local-cli/utils/cliErrorHandling.js
new file mode 100644
index 000000000..99d32804b
--- /dev/null
+++ b/detox/local-cli/utils/cliErrorHandling.js
@@ -0,0 +1,15 @@
+const loggedErrors = new WeakSet();
+
+function markErrorAsLogged(error) {
+  loggedErrors.add(error);
+  return error;
+}
+
+function isErrorAlreadyLogged(error) {
+  return loggedErrors.has(error);
+}
+
+module.exports = {
+  markErrorAsLogged,
+  isErrorAlreadyLogged,
+};
diff --git a/detox/src/artifacts/utils/temporaryPath.js b/detox/src/artifacts/utils/temporaryPath.js
index 7225f925b..c55f0a435 100644
--- a/detox/src/artifacts/utils/temporaryPath.js
+++ b/detox/src/artifacts/utils/temporaryPath.js
@@ -2,16 +2,14 @@ const path = require('path');
 const { promisify } = require('util');
 
 const glob = require('glob');
+const _ = require('lodash');
 const tempfile = require('tempfile');
 
 const { useForwardSlashes } = require('../../utils/shellUtils');
 
 const globSync = glob.sync;
 const globAsync = promisify(glob);
-
-function getRoot() {
-  return path.dirname(tempfile());
-}
+const getRoot = _.once(() => path.dirname(tempfile()));
 
 function createGlobber(ext) {
   const fullExt = `.detox.${ext}`;
diff --git a/detox/src/logger/utils/BunyanLogger.js b/detox/src/logger/utils/BunyanLogger.js
index e02770a51..162e0c612 100644
--- a/detox/src/logger/utils/BunyanLogger.js
+++ b/detox/src/logger/utils/BunyanLogger.js
@@ -1,4 +1,5 @@
 const { PassThrough } = require('stream');
+const { promisify } = require('util');
 
 const bunyan = require('bunyan');
 const bds = require('bunyan-debug-stream');
@@ -74,12 +75,14 @@ class BunyanLogger {
   }
 
   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));
+    await Promise.all(openFileStreams.map(bunyanStream => {
+      const stream = bunyanStream.stream;
+      return promisify(stream.end.bind(stream))();
+    }));
   }
 
   /** @private */
@@ -97,20 +100,6 @@ class BunyanLogger {
         return stream.fd > 2 && !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/streams/DetoxJSONLParser.js b/detox/src/logger/utils/streams/DetoxJSONLParser.js
index a6e82346a..d94743237 100644
--- a/detox/src/logger/utils/streams/DetoxJSONLParser.js
+++ b/detox/src/logger/utils/streams/DetoxJSONLParser.js
@@ -14,7 +14,7 @@ class DetoxJSONLParser {
       .on('error', (err) => {
         /* istanbul ignore else */
         if (err instanceof SyntaxError) {
-          log.debug({ err });
+          log.debug({ err }, 'Failed to parse log line:');
           readable.end();
         } else {
           readable.emit('error', err);
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 000000000..fe33cb3be
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,13 @@
+# Detox Website
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
+[![This project is using Percy.io for visual regression testing.](https://percy.io/static/images/percy-badge.svg)](https://percy.io/66fcbcad/detox-website)
+
+Explore further about using Detox from our new **[website](https://wix.github.io/Detox/)**.
+This project is tested with [Browserstack](https://browserstack.com).
+
+[![Website Preview](https://user-images.githubusercontent.com/1962469/214340717-7ac3114b-8f22-4e94-960a-47dc21b6a88b.png)](https://wix.github.io/Detox)
+
+## Contributing
+
+See our [Contributing Guide](https://wix.github.io/Detox/docs/contributing#detox-documentation-website).
diff --git a/website/package.json b/website/package.json
index 04032b47e..9f8cc8c1e 100644
--- a/website/package.json
+++ b/website/package.json
@@ -3,6 +3,7 @@
   "version": "0.0.0",
   "private": true,
   "scripts": {
+    "percy": "zx percy.mjs",
     "docusaurus": "docusaurus",
     "start": "docusaurus start",
     "build": "docusaurus build",
@@ -19,6 +20,7 @@
     "@docusaurus/remark-plugin-npm2yarn": "^2.2.0",
     "@docusaurus/theme-search-algolia": "^2.2.0",
     "@mdx-js/react": "^1.6.21",
+    "@percy/cli": "^1.17.0",
     "@svgr/webpack": "^5.5.0",
     "clsx": "^1.1.1",
     "file-loader": "^6.2.0",
@@ -26,7 +28,8 @@
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-loader-spinner": "^5.3.4",
-    "url-loader": "^4.1.1"
+    "url-loader": "^4.1.1",
+    "zx": "^7.1.1"
   },
   "browserslist": {
     "production": [
@@ -39,8 +42,5 @@
       "last 1 firefox version",
       "last 1 safari version"
     ]
-  },
-  "devDependencies": {
-    "@percy/cli": "^1.17.0"
   }
 }
diff --git a/website/percy.mjs b/website/percy.mjs
new file mode 100644
index 000000000..b610d1564
--- /dev/null
+++ b/website/percy.mjs
@@ -0,0 +1,78 @@
+#!/usr/bin/env zx
+
+import 'zx/globals';
+
+class Server {
+  constructor() {
+    this.childPromise = null;
+  }
+
+  async isDown() {
+    try {
+      await fetch('http://localhost:3000', { method: 'HEAD' });
+      return false;
+    } catch (e) {
+      if (e.code === 'ECONNREFUSED') {
+        return true;
+      }
+
+      throw e;
+    }
+  }
+
+  async isUp() {
+    return !(await this.isDown());
+  }
+
+  async start() {
+    if (await this.isUp()) {
+      return;
+    }
+
+    this.childPromise = $`docusaurus serve --no-open`;
+
+    await new Promise((resolve, reject) => {
+      const handle = setTimeout(() => {
+        reject(new Error('Server timed out'));
+      }, 20000).unref();
+
+      this.childPromise.stdout.on('data', (data) => {
+        if (`${data}`.startsWith('[SUCCESS] Serving')) {
+          clearTimeout(handle);
+          resolve();
+        }
+      });
+    });
+
+    if (await this.isDown()) {
+      throw new Error('Server is not available after start')
+    }
+  }
+
+  async stop() {
+    if (!this.childPromise) {
+      return;
+    }
+
+    if (typeof this.childPromise.exitCode === 'number') {
+      return;
+    }
+
+    this.childPromise.catch((e) => {
+      if (e.signal !== 'SIGTERM') {
+        throw e;
+      }
+    });
+
+    await this.childPromise.kill('SIGTERM');
+  }
+}
+
+const server = new Server();
+try {
+  await server.start();
+  await $`percy snapshot snapshots.yml`
+} finally {
+  await server.stop();
+}
+


Test patch:
diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js
index 0c5b14905..8a79dfa5a 100644
--- a/detox/local-cli/testCommand/TestRunnerCommand.js
+++ b/detox/local-cli/testCommand/TestRunnerCommand.js
@@ -9,6 +9,7 @@ const log = detox.log.child({ cat: ['lifecycle', 'cli'] });
 const { printEnvironmentVariables, prependNodeModulesBinToPATH } = require('../../src/utils/envUtils');
 const { toSimplePath } = require('../../src/utils/pathUtils');
 const { escapeSpaces, useForwardSlashes } = require('../../src/utils/shellUtils');
+const { markErrorAsLogged } = require('../utils/cliErrorHandling');
 
 const TestRunnerError = require('./TestRunnerError');
 
@@ -142,7 +143,7 @@ class TestRunnerCommand {
               signal,
             });
             log.error.end({ success: false, code, signal }, error.message);
-            reject(error);
+            reject(markErrorAsLogged(error));
           }
         });
     });
diff --git a/detox/local-cli/testCommand/TestRunnerError.js b/detox/local-cli/testCommand/TestRunnerError.js
index cc8b1b41c..96b165bdb 100644
--- a/detox/local-cli/testCommand/TestRunnerError.js
+++ b/detox/local-cli/testCommand/TestRunnerError.js
@@ -8,10 +8,6 @@ class TestRunnerError extends DetoxError {
     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
index 880588af2..f8b6bfd80 100644
--- a/detox/local-cli/testCommand/TestRunnerError.test.js
+++ b/detox/local-cli/testCommand/TestRunnerError.test.js
@@ -1,25 +1,19 @@
-const { DetoxError } = require('../../src/errors');
-
 const TestRunnerError = require('./TestRunnerError');
 
 describe('TestRunnerError', () => {
   let error;
 
   beforeAll(() => {
-    error = new TestRunnerError({ command: 'foo', code: 1, signal: null });
+    error = new TestRunnerError({ command: 'foo', code: 1, signal: 'SIGINT' });
   });
 
   it('should format an error message', () => {
     expect(`${error}`).toMatch(/Command failed with exit code = 1:\nfoo/);
+    expect(`${error.format()}`).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('');
+    expect(error.signal).toBe('SIGINT');
   });
 });
diff --git a/detox/src/logger/utils/streams/BunyanTransformer.test.js b/detox/src/logger/utils/streams/BunyanTransformer.test.js
index b71dee7e5..bfd749897 100644
--- a/detox/src/logger/utils/streams/BunyanTransformer.test.js
+++ b/detox/src/logger/utils/streams/BunyanTransformer.test.js
@@ -65,7 +65,7 @@ describe('BunyanTransformer', () => {
       { time: new Date('2000-01-01T00:00:01.002Z'), msg: 'b2' },
     ]);
 
-    expect(logger.debug).toHaveBeenCalledWith({ err: expect.any(Error) });
+    expect(logger.debug).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Failed to parse log line:');
     expect(logger.debug).toHaveBeenCalledTimes(1);
   });