Repository: nightwatchjs/nightwatch (js)

Issue:
`end_session_on_fail` stopped working in version 2.0.4
### Description of the bug/issue

Once a test fails, I'd like to keep the browser open so I can see what went wrong.

I can do this by setting `end_session_on_fail` to `false`, which works perfectly on v1.7.13.

Once I upgraded to v2.6.8 (latest) or v2.0.4 (first 2.x version) it stopped working :thinking: 

`end_session_on_fail` still exists in the [documentation](https://nightwatchjs.org/guide/reference/settings.html#setting-the-baseurl-property), so I guess it was just overlooked in the 2.x version?

Thanks.

### Steps to reproduce

1. Run: `npm init nightwatch` (from https://nightwatchjs.org/guide/quickstarts/create-and-run-a-nightwatch-test.html)
2. Add `end_session_on_fail: false,` to: `nightwatch.conf.js`
3. Put an `x` in the `.titleContains` at `nightwatch/examples/basic/ecosia.js:7` (so the test fails).
4. Run: `npx nightwatch ./nightwatch/examples/basic/ecosia.js` (from the same guide)

### Sample test

```node
// Copied from the example `ecosia.js` test.

describe('Ecosia.org Demo', function() {
  before(browser => browser.navigateTo('https://www.ecosia.org/'));

  it('Demo test ecosia.org', function(browser) {
    browser
      .waitForElementVisible('body')
      .assert.titleContains('Wrong title')
  });

  after(browser => browser.end());
});
```


### Command to run

```bash
npx nightwatch ./nightwatch/examples/basic/ecosia.js
```


### Verbose Output

```fundamental
[Ecosia.org Demo] Test Suite
────────────────────────────────────────────────────────
⠋ Starting ChromeDriver on port 9515...
 Starting ChromeDriver with server_path=/c/laptop/stuff/mike/junk/node_modules/chromedriver/lib/chromedriver/chromedriver...
   Request POST /session  
   {
     capabilities: {
       firstMatch: [ {} ],
       alwaysMatch: { browserName: 'chrome', 'goog:chromeOptions': {} }
     }
⠸ Starting ChromeDriver on port 9515...
   Response 200 POST /session (253ms)
   {
     value: {
       capabilities: {
         acceptInsecureCerts: false,
         browserName: 'chrome',
         browserVersion: '109.0.5414.74',
         chrome: {
           chromedriverVersion: '109.0.5414.74 (e7c5703604daa9cc128ccf5a5d3e993513758913-refs/branch-heads/5414@{#1172})',
           userDataDir: '/tmp/.com.google.Chrome.a5XRg4'
         },
         'goog:chromeOptions': { debuggerAddress: 'localhost:44841' },
         networkConnectionEnabled: false,
         pageLoadStrategy: 'normal',
         platformName: 'linux',
         proxy: {},
         setWindowRect: true,
         strictFileInteractability: false,
         timeouts: { implicit: 0, pageLoad: 300000, script: 30000 },
         unhandledPromptBehavior: 'dismiss and notify',
         'webauthn:extension:credBlob': true,
         'webauthn:extension:largeBlob': true,
         'webauthn:virtualAuthenticators': true
       },
       sessionId: '448ef5c5e3e8ce7338d108f6803d06c6'
     }
ℹ Connected to ChromeDriver on port 9515 (281ms).
  Using: chrome (109.0.5414.74) on LINUX.

 Received session with ID: 448ef5c5e3e8ce7338d108f6803d06c6

 → Running [before]:
 
 → Running command: navigateTo ('https://www.ecosia.org/')
   Request POST /session/448ef5c5e3e8ce7338d108f6803d06c6/url  
   { url: 'https://www.ecosia.org/' }
   Response 200 POST /session/448ef5c5e3e8ce7338d108f6803d06c6/url (2032ms)
   { value: null }
  → Completed command: navigateTo ('https://www.ecosia.org/') (2034ms)
 → Completed [before].



  Running Demo test ecosia.org:
──────────────────────────────────────────────────────────────────────────────────────────────────────
 → Running [beforeEach]:
 → Completed [beforeEach].
 
 → Running command: waitForElementVisible ('body')
   Request POST /session/448ef5c5e3e8ce7338d108f6803d06c6/elements  
   { using: 'css selector', value: 'body' }
   Response 200 POST /session/448ef5c5e3e8ce7338d108f6803d06c6/elements (9ms)
   {
     value: [
       {
         'element-6066-11e4-a52e-4f735466cecf': 'e778edd8-78e0-49c6-9c08-77854bae6fb8'
       }
     ]
  }
   Request POST /session/448ef5c5e3e8ce7338d108f6803d06c6/execute/sync  
   {
     script: 'return (function(){return (function(){var k=this||self;function aa(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=k;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a... (44027 characters)',
     args: [
       {
         'element-6066-11e4-a52e-4f735466cecf': 'e778edd8-78e0-49c6-9c08-77854bae6fb8',
         ELEMENT: 'e778edd8-78e0-49c6-9c08-77854bae6fb8'
       }
     ]
  }
   Response 200 POST /session/448ef5c5e3e8ce7338d108f6803d06c6/execute/sync (12ms)
   { value: true }
  ✔ Element <body> was visible after 23 milliseconds.
  → Completed command: waitForElementVisible ('body') (25ms)
 
 → Running command: assert.titleContains ('Wrong title')
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (4ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (6ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (5ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (6ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (7ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (8ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (11ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (12ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (17ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (21ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (4ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (5ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (13ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (15ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (12ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (14ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (13ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (15ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (13ms)
   { value: 'Ecosia - the search engine that plants trees' }
  → Completed command: title ([Function]) (15ms)
 
 → Running command: title ([Function])
   Request GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title  

   Response 200 GET /session/448ef5c5e3e8ce7338d108f6803d06c6/title (6ms)
   { value: 'Ecosia - the search engine that plants trees' }
  ✖ NightwatchAssertError
   Testing if the page title contains 'Wrong title' in 5000ms - expected "contains 'Wrong title'" but got: "Ecosia - the search engine that plants trees" (5149ms)

    Error location:
    /c/laptop/stuff/mike/junk/nightwatch/examples/basic/ecosia.js:
    –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
     5 |     browser
     6 |       .waitForElementVisible('body')
     7 |       .assert.titleContains('Wrong title') 
     8 |   });
     9 | 
    –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––

  → Completed command: title ([Function]) (11ms)
  → Completed command: assert.titleContains ('Wrong title') (5155ms)
 → Running [afterEach]:
 → Completed [afterEach].

  FAILED: 1 assertions failed and  1 passed (5.234s)
 → Running [after]:
 
 → Running command: end ()
 
 → Running command: session ('delete', [Function])
   Request DELETE /session/448ef5c5e3e8ce7338d108f6803d06c6  

   Response 200 DELETE /session/448ef5c5e3e8ce7338d108f6803d06c6 (52ms)
   { value: null }
  → Completed command: session ('delete', [Function]) (54ms)
 Wrote log file to: /c/laptop/stuff/mike/junk/logs/basic/ecosia_chromedriver.log
  → Completed command: end () (59ms)
 → Completed [after].

──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

  ️TEST FAILURE (7.942s):  
   - 1 assertions failed; 1 passed

   ✖ 1) basic/ecosia

   – Demo test ecosia.org (5.234s)

   → ✖ NightwatchAssertError
   Testing if the page title contains 'Wrong title' in 5000ms - expected "contains 'Wrong title'" but got: "Ecosia - the search engine that plants trees" (5149ms)

    Error location:
    /c/laptop/stuff/mike/junk/nightwatch/examples/basic/ecosia.js:
    –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
     5 |     browser
     6 |       .waitForElementVisible('body')
     7 |       .assert.titleContains('Wrong title') 
     8 |   });
     9 | 
    –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––


 ChromeDriver process closed.
```


### Nightwatch Configuration

```node
// This whole thing was generated by: `npm init nightwatch`
// The only thing I changed was adding: `end_session_on_fail: false`
module.exports = {
  end_session_on_fail: false,
  // An array of folders (excluding subfolders) where your tests are located;
  // if this is not specified, the test source must be passed as the second argument to the test runner.
  src_folders: ['test','nightwatch/examples'],

  // See https://nightwatchjs.org/guide/concepts/page-object-model.html
  page_objects_path: ['nightwatch/page-objects'],

  // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-commands.html
  custom_commands_path: ['nightwatch/custom-commands'],

  // See https://nightwatchjs.org/guide/extending-nightwatch/adding-custom-assertions.html
  custom_assertions_path: ['nightwatch/custom-assertions'],

  // See https://nightwatchjs.org/guide/extending-nightwatch/adding-plugins.html
  plugins: [],
  
  // See https://nightwatchjs.org/guide/concepts/test-globals.html
  globals_path: '',
  
  webdriver: {},

  test_workers: {
    enabled: true
  },

  test_settings: {
    default: {
      disable_error_log: false,
      launch_url: 'http://localhost',

      screenshots: {
        enabled: false,
        path: 'screens',
        on_failure: true
      },

      desiredCapabilities: {
        browserName: 'chrome'
      },
      
      webdriver: {
        start_process: true,
        server_path: ''
      },
      
    },
    
    chrome: {
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          // More info on Chromedriver: https://sites.google.com/a/chromium.org/chromedriver/
          //
          // w3c:false tells Chromedriver to run using the legacy JSONWire protocol (not required in Chrome 78)
          w3c: true,
          args: [
            //'--no-sandbox',
            //'--ignore-certificate-errors',
            //'--allow-insecure-localhost',
            //'--headless'
          ]
        }
      },

      webdriver: {
        start_process: true,
        server_path: '',
        cli_args: [
          // --verbose
        ]
      }
    },
    
  },
  
};
```


### Nightwatch.js Version

2.6.10

### Node Version

16.19.0

### Browser

Chrome 109.0.5414.74 

### Operating System

Ubuntu 20.04.5 LTS

### Additional Information

Poking around the Nightwatch code a little, I found this:
`node_modules/nightwatch/lib/core/client.js:460`
```
  /**
   * @deprecated
   */
  endSessionOnFail(val) {
```
It has `@deprecated`... so I guess this option is deprecated? The docs don't say it's deprecated though.

Pull request:
avoid closing of session according to end_session_on_fail
## Changes
- avoid closing the session when `end_on_session_fail` is set to false
- added logic to avoid reusing sessions in case of failure when `end_on_session_fail` is enabled
    - It would spawn a new session and reuse that if a failure is detected in the preceding test and will keep that session open.
## Impact
- fixes #3574 

Patch:
diff --git a/.eslintrc b/.eslintrc
index 95be1607..1a45d44c 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -114,6 +114,7 @@
     "by": "readonly",
     "expect": "readonly",
     "browser": "readonly",
+    "app": "readonly",
     "Key": "readonly"
   }
 }
diff --git a/README.md b/README.md
index 60877ee0..d406f308 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
 [![npm](https://img.shields.io/npm/v/nightwatch.svg)](https://www.npmjs.com/package/nightwatch)
 [![Node.js CI](https://github.com/nightwatchjs/nightwatch/actions/workflows/build-node.yaml/badge.svg?branch=main)](https://github.com/nightwatchjs/nightwatch/actions/workflows/build-node.yaml)
 [![codecov](https://codecov.io/gh/nightwatchjs/nightwatch/branch/main/graph/badge.svg?token=MSObyfECEh)](https://codecov.io/gh/nightwatchjs/nightwatch)
-[![npm package](https://img.shields.io/npm/dm/nightwatch.svg)](https://www.npmjs.com/package/nightwatch)
 [![Discord][discord-badge]][discord]
 [![Node Support](https://img.shields.io/badge/node-%3E12.x-brightgreen.svg)](https://github.com/nightwatchjs/nightwatch/blob/27a855a2ec0c2008073708d5a2286c2819584fdc/.github/workflows/build-node.yaml#L19)
 
@@ -96,5 +95,5 @@ Nightwatch was initially built by [@pineviewlabs](https://github.com/pineviewlab
 ## Licence
 [MIT](https://github.com/nightwatchjs/nightwatch/blob/main/LICENSE.md)
 
-[discord-badge]: https://img.shields.io/discord/618399631038218240.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square
+[discord-badge]: https://img.shields.io/discord/618399631038218240.svg?color=7389D8&labelColor=6A7EC2&logo=discord&logoColor=ffffff&style=flat-square&label=discord
 [discord]: https://discord.gg/SN8Da2X
diff --git a/lib/api/_loaders/element-global.js b/lib/api/_loaders/element-global.js
index b599d6e4..419be0ac 100644
--- a/lib/api/_loaders/element-global.js
+++ b/lib/api/_loaders/element-global.js
@@ -208,7 +208,12 @@ class ElementGlobal {
         throw new Error('Component was not rendered.');
       }
       // eslint-disable-next-line
-      return window['@@component_element'].componentVM[property];
+      if (window['@@component_element'].componentVM) {
+        // eslint-disable-next-line
+        return window['@@component_element'].componentVM[property];
+      }
+      // eslint-disable-next-line
+      return window['@@component_element'][property];
     }, [propName]).then(result => {
       if (result.error instanceof Error) {
         throw result.error;
diff --git a/lib/core/client.js b/lib/core/client.js
index 86efaa2c..4e59c3e2 100644
--- a/lib/core/client.js
+++ b/lib/core/client.js
@@ -10,6 +10,7 @@ const Transport = require('../transport');
 const Element = require('../element');
 const ApiLoader = require('../api');
 const ElementGlobal = require('../api/_loaders/element-global.js');
+const Factory = require('../transport/factory.js');
 const {isAndroid, isIos} = require('../utils/mobile');
 
 const {LocateStrategy, Locator} = Element;
@@ -76,7 +77,7 @@ class NightwatchAPI {
       return false;
     }
 
-    return  this.platformName.toLowerCase() === platform.toLowerCase(); 
+    return this.platformName.toLowerCase() === platform.toLowerCase(); 
   }
 
   isIOS() {
@@ -114,6 +115,21 @@ class NightwatchAPI {
   isOpera() {
     return this.capabilities.browserName === Browser.OPERA;
   }
+
+  isAppiumClient() {
+    if (this.options.selenium && this.options.selenium.use_appium) {
+      return true;
+    }
+
+    // Handle BrowserStack case
+    // (BrowserStack always returns platformName in capabilities)
+    const isMobile = this.__isPlatformName('android') || this.__isPlatformName('ios');
+    if (Factory.usingBrowserstack(this.options) && isMobile) {
+      return true;
+    }
+
+    return false;
+  }
 }
 
 class NightwatchClient extends EventEmitter {
diff --git a/lib/element/appium-locator.js b/lib/element/appium-locator.js
new file mode 100644
index 00000000..6caf9e06
--- /dev/null
+++ b/lib/element/appium-locator.js
@@ -0,0 +1,31 @@
+const {By, RelativeBy} = require('selenium-webdriver');
+
+const LocateElement = require('./locator.js');
+const {AVAILABLE_LOCATORS} = LocateElement;
+
+class AppiumLocator extends LocateElement {
+  /**
+   * @param {object|string} element
+   * @return {By|RelativeBy}
+   */
+  static create(element) {
+    if (!element) {
+      throw new Error(`Error while trying to locate element: missing element definition; got: "${element}".`);
+    }
+
+    const byInstance = LocateElement.locateInstanceOfBy(element);
+    if (byInstance !== null) {
+      return byInstance;
+    }
+
+    const elementInstance = LocateElement.createElementInstance(element);
+
+    if (elementInstance.locateStrategy === 'id') {
+      return new By(elementInstance.locateStrategy, elementInstance.selector);
+    }
+
+    return By[AVAILABLE_LOCATORS[elementInstance.locateStrategy]](elementInstance.selector);
+  }
+}
+
+module.exports = AppiumLocator;
diff --git a/lib/element/locator.js b/lib/element/locator.js
index 600105ea..bd5dd56a 100644
--- a/lib/element/locator.js
+++ b/lib/element/locator.js
@@ -3,7 +3,7 @@ const Element = require('./index.js');
 const ElementsByRecursion = require('./locate/elements-by-recursion.js');
 const SingleElementByRecursion = require('./locate/single-element-by-recursion.js');
 
-const availableLocators = {
+const AVAILABLE_LOCATORS = {
   'css selector': 'css',
   'id': 'id',
   'link text': 'linkText',
@@ -17,13 +17,28 @@ const availableLocators = {
 class LocateElement {
   /**
    * @param {object|string} element
-   * @return {*}
+   * @return {By|RelativeBy}
    */
   static create(element) {
     if (!element) {
       throw new Error(`Error while trying to locate element: missing element definition; got: "${element}".`);
     }
 
+    const byInstance = LocateElement.locateInstanceOfBy(element);
+    if (byInstance !== null) {
+      return byInstance;
+    }
+
+    const elementInstance = LocateElement.createElementInstance(element);
+
+    return By[AVAILABLE_LOCATORS[elementInstance.locateStrategy]](elementInstance.selector);
+  }
+
+  /**
+   * @param {object} element
+   * @return {By|RelativeBy|null}
+   */
+  static locateInstanceOfBy(element) {
     if (element instanceof By) {
       return element;
     }
@@ -36,6 +51,14 @@ class LocateElement {
       return element.value;
     }
 
+    return null;
+  }
+
+  /**
+   * @param {object|string} element
+   * @return {Element}
+   */
+  static createElementInstance(element) {
     if (typeof element != 'object' && typeof element != 'string') {
       throw new Error(`Invalid element definition type; expected string or object, but got: ${typeof element}.`);
     }
@@ -50,9 +73,7 @@ class LocateElement {
       selector = element;
     }
 
-    const elementInstance = Element.createFromSelector(selector, strategy);
-
-    return By[availableLocators[elementInstance.locateStrategy]](elementInstance.selector);
+    return Element.createFromSelector(selector, strategy);
   }
 
   get api() {
@@ -316,3 +337,4 @@ class NoSuchElementError extends Error {
 
 module.exports = LocateElement;
 module.exports.NoSuchElementError = NoSuchElementError;
+module.exports.AVAILABLE_LOCATORS = AVAILABLE_LOCATORS;
diff --git a/lib/http/http.js b/lib/http/http.js
index 799b461c..2c5f5caf 100644
--- a/lib/http/http.js
+++ b/lib/http/http.js
@@ -1,6 +1,7 @@
 const ContentTypes = {
   JSON: 'application/json',
-  JSON_WITH_CHARSET: 'application/json; charset=utf-8'
+  JSON_WITH_CHARSET: 'application/json; charset=utf-8',
+  MULTIPART_FORM_DATA: 'multipart/form-data'
 };
 
 const Headers = {
diff --git a/lib/http/request.js b/lib/http/request.js
index 496f3ffd..c0bd74a8 100644
--- a/lib/http/request.js
+++ b/lib/http/request.js
@@ -2,6 +2,8 @@ const EventEmitter = require('events');
 const http   = require('http');
 const https  = require('https');
 const dns = require('dns');
+const path = require('path');
+
 const HttpUtil = require('./http.js');
 const HttpOptions = require('./options.js');
 const Auth = require('./auth.js');
@@ -88,8 +90,15 @@ class HttpRequest extends EventEmitter {
       options.data = '';
     }
 
+    if (options.multiPartFormData) {
+      this.multiPartFormData = options.multiPartFormData;
+      this.formBoundary = `----NightwatchFormBoundary${Math.random().toString(16).slice(2)}`;
+
+      this.setFormData(this.formBoundary);
+    } else {
+      this.setData(options);
+    }
     this.params = options.data;
-    this.setData(options);
     this.contentLength = this.data.length;
     this.use_ssl = this.httpOpts.use_ssl || options.use_ssl;
     this.reqOptions = this.createHttpOptions(options);
@@ -364,7 +373,9 @@ class HttpRequest extends EventEmitter {
       this.reqOptions.headers[HttpUtil.Headers.ACCEPT] = HttpUtil.ContentTypes.JSON;
     }
 
-    if (this.contentLength > 0) {
+    if (this.multiPartFormData) {
+      this.reqOptions.headers[HttpUtil.Headers.CONTENT_TYPE] = `${HttpUtil.ContentTypes.MULTIPART_FORM_DATA}; boundary=${this.formBoundary}`;
+    } else if (this.contentLength > 0) {
       this.reqOptions.headers[HttpUtil.Headers.CONTENT_TYPE] = HttpUtil.ContentTypes.JSON_WITH_CHARSET;
     }
 
@@ -391,6 +402,34 @@ class HttpRequest extends EventEmitter {
     return this;
   }
 
+  setFormData(boundary) {
+    const crlf = '\r\n';
+    const delimiter = `${crlf}--${boundary}`;
+    const closeDelimiter = `${delimiter}--`;
+
+    const bufferArray = [];
+    for (const [fieldName, fieldValue] of Object.entries(this.multiPartFormData)) {
+      // set header
+      let header = `Content-Disposition: form-data; name="${fieldName}"`;
+      if (fieldValue.filePath) {
+        const fileName = path.basename(fieldValue.filePath);
+        header += `; filename="${fileName}"`;
+      }
+      bufferArray.push(Buffer.from(delimiter + crlf + header + crlf + crlf));
+
+      // set data
+      const {readFileSync} = require('fs');
+      if (fieldValue.filePath) {
+        bufferArray.push(readFileSync(fieldValue.filePath));
+      } else {
+        bufferArray.push(Buffer.from(fieldValue.data));
+      }
+    }
+    bufferArray.push(Buffer.from(closeDelimiter));
+
+    this.data = Buffer.concat(bufferArray);
+  }
+
   hasCredentials() {
     return Utils.isObject(this.httpOpts.credentials) && this.httpOpts.credentials.username;
   }
diff --git a/lib/index.js b/lib/index.js
index 3c181959..7b47a18e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -345,7 +345,7 @@ Object.defineProperty(Nightwatch, 'by', {
   }
 });
 
-Object.defineProperty(Nightwatch, 'browser', {
+const globalBrowserDescriptor = {
   configurable: true,
   get() {
     if (global.browser) {
@@ -357,7 +357,11 @@ Object.defineProperty(Nightwatch, 'browser', {
 
     throw err;
   }
-});
+};
+
+Object.defineProperty(Nightwatch, 'browser', globalBrowserDescriptor);
+
+Object.defineProperty(Nightwatch, 'app', globalBrowserDescriptor);
 
 Object.defineProperty(Nightwatch, 'Key', {
   configurable: true,
diff --git a/lib/page-object/base-object.js b/lib/page-object/base-object.js
index 8df0accf..c586ef3c 100644
--- a/lib/page-object/base-object.js
+++ b/lib/page-object/base-object.js
@@ -84,25 +84,25 @@ class BaseObject {
    * @param {Object} commands Array of commands that will be added to the page or section
    */
   static addCommands(parent, commands) {
-    if (Utils.isFunction(commands)) {
-      return BaseObject.addCommandsFromClass(parent, commands);
-    }
-
-    if (Utils.isObject(commands) && !Array.isArray(commands)) {
+    if (Utils.isFunction(commands) || Utils.isObject(commands) && !Array.isArray(commands)) {
       commands = [commands];
     }
 
     commands.forEach(m => {
-      Object.keys(m).forEach(k => {
-        try {
-          parent[k] = m[k];
-        } catch (err) {
-          const error = new Error(`Trying to overwrite page object/section "${parent.name}"  method/property "${k}".`);
-          error.detailedErr = `Using ${Object.keys(m).join(', ')}.`;
-
-          throw error;
-        }
-      });
+      if (Utils.isFunction(m)) {
+        BaseObject.addCommandsFromClass(parent, m);
+      } else {
+        Object.keys(m).forEach(k => {
+          try {
+            parent[k] = m[k];
+          } catch (err) {
+            const error = new Error(`Trying to overwrite page object/section "${parent.name}"  method/property "${k}".`);
+            error.detailedErr = `Using ${Object.keys(m).join(', ')}.`;
+
+            throw error;
+          }
+        });
+      }
     });
   }
 
@@ -132,6 +132,10 @@ class BaseObject {
         return page;
       }
 
+      get section() {
+        return this.page.section;
+      }
+
       get api() {
         return this.client.api;
       }
diff --git a/lib/reporter/axe-report.js b/lib/reporter/axe-report.js
index accd01b6..a3842c56 100644
--- a/lib/reporter/axe-report.js
+++ b/lib/reporter/axe-report.js
@@ -12,6 +12,7 @@ const describeViolations = (violations) => {
       const key = JSON.stringify(target) + html;
 
       if (aggregate[key]) {
+        aggregate[key].violations = aggregate[key].violations || [];
         aggregate[key].violations.push(index);
       } else {
         aggregate[key] = {
diff --git a/lib/runner/cli/cli.js b/lib/runner/cli/cli.js
index cf0df4a3..20c05226 100644
--- a/lib/runner/cli/cli.js
+++ b/lib/runner/cli/cli.js
@@ -93,6 +93,16 @@ class CliRunner {
       argv._source = [argv._source];
     }
 
+    if (argv.firefox) {
+      argv.e = argv.env = 'firefox';
+    } else if (argv.chrome) {
+      argv.e = argv.env = 'chrome';
+    } else if (argv.safari) {
+      argv.e = argv.env = 'safari';
+    } else if (argv.edge) {
+      argv.e = argv.env = 'edge';
+    }
+
     this.argv = argv;
 
     this.testRunner = null;
diff --git a/lib/transport/selenium-webdriver/browserstack/appAutomate.js b/lib/transport/selenium-webdriver/browserstack/appAutomate.js
index 79563ae3..988a57e7 100644
--- a/lib/transport/selenium-webdriver/browserstack/appAutomate.js
+++ b/lib/transport/selenium-webdriver/browserstack/appAutomate.js
@@ -1,5 +1,9 @@
+const path = require('path');
+const Utils = require('../../../utils');
 const BrowserStack = require('./browserstack.js');
 
+const {Logger} = Utils;
+
 class AppAutomate extends BrowserStack {
   get ApiUrl() {
     return `https://api.browserstack.com/${this.productNamespace}`;
@@ -9,10 +13,97 @@ class AppAutomate extends BrowserStack {
     return 'app-automate';
   }
 
-  createSessionOptions() {
+  async createSessionOptions() {
     this.extractAppiumOptions();
 
-    return this.desiredCapabilities;
+    const options = this.desiredCapabilities;
+    if (options && (options.appUploadUrl || options.appUploadPath)) {
+      await this.uploadAppToBrowserStack(options);
+    }
+
+    return options;
+  }
+
+  async uploadAppToBrowserStack(options) {
+    const {appUploadPath, appUploadUrl} = options;
+
+    const multiPartFormData = {};
+    if (appUploadPath) {
+      multiPartFormData['file'] = {
+        filePath: path.resolve(appUploadPath)
+      };
+    } else if (appUploadUrl) {
+      multiPartFormData['url'] = {
+        data: appUploadUrl
+      };
+    }
+
+    if (options['appium:app'] && !options['appium:app'].startsWith('bs://')) {
+      multiPartFormData['custom_id'] = {
+        data: options['appium:app']
+      };
+    }
+
+    // eslint-disable-next-line no-console
+    console.log(Logger.colors.stack_trace(`Uploading app to BrowserStack from '${appUploadPath || appUploadUrl}'...`));
+
+    try {
+      const response = await this.sendHttpRequest({
+        url: 'https://api-cloud.browserstack.com/app-automate/upload',
+        method: 'POST',
+        use_ssl: true,
+        port: 443,
+        auth: {
+          user: this.username,
+          pass: this.accessKey
+        },
+        multiPartFormData
+      });
+
+      if (response.error) {
+        const errMessage = 'App upload to BrowserStack failed. Original error: ' + response.error;
+
+        throw new Error(errMessage);
+      }
+
+      if (!response.app_url) {
+        const errMessage = 'App upload was unsuccessful. Got response: ' + response;
+
+        throw new Error(errMessage);
+      }
+
+      // eslint-disable-next-line no-console
+      console.log(Logger.colors.green(Utils.symbols.ok), Logger.colors.stack_trace('App upload successful!'), '\n');
+  
+      if (!response.custom_id) {
+        // custom_id not being used
+        options['appium:app'] = response.app_url;
+
+        // to display url when test suite is finished
+        this.uploadedAppUrl = response.app_url;
+      }
+    } catch (err) {
+      err.help = [];
+
+      if (appUploadPath) {
+        err.help.push('Check if you have entered correct file path in \'appUploadPath\' desired capability.');
+      } else if (appUploadUrl) {
+        err.help.push('Check if you have entered correct publicly available file URL in \'appUploadUrl\' desired capability.');
+      }
+
+      if (err.message.includes('BROWSERSTACK_INVALID_CUSTOM_ID')) {
+        err.help.push('Check if \'appium:app\' or \'appium:options\' > app desired capability is correctly set to BrowserStack app url or required custom ID.');
+      }
+
+      err.help.push(
+        'See BrowserStack app-upload docs for more details: https://www.browserstack.com/docs/app-automate/api-reference/appium/apps#upload-an-app',
+        'More details on setting custom ID for app: https://www.browserstack.com/docs/app-automate/appium/upload-app-define-custom-id'
+      );
+
+      Logger.error(err);
+
+      throw err;
+    }
   }
 
   createDriver({options}) {
diff --git a/lib/transport/selenium-webdriver/browserstack/browserstack.js b/lib/transport/selenium-webdriver/browserstack/browserstack.js
index ee7160ac..5d11e8d4 100644
--- a/lib/transport/selenium-webdriver/browserstack/browserstack.js
+++ b/lib/transport/selenium-webdriver/browserstack/browserstack.js
@@ -138,11 +138,21 @@ class Browserstack extends AppiumBaseServer {
 
   async testSuiteFinished(failures) {
     try {
-      const reason = failures instanceof Error ? `${failures.name}: ${failures.message}` : '';
-      await this.sendReasonToBrowserstack(!!failures, reason);
-      // eslint-disable-next-line no-console
-      console.log('\n  ' + 'See more info, video, & screenshots on Browserstack:\n' +
-        '  ' + Logger.colors.light_cyan(`https://${this.productNamespace}.browserstack.com/builds/${this.buildId}/sessions/${this.sessionId}`));
+      if (this.sessionId) {
+        const reason = failures instanceof Error ? `${failures.name}: ${failures.message}` : '';
+        await this.sendReasonToBrowserstack(!!failures, reason);
+        // eslint-disable-next-line no-console
+        console.log('\n  ' + 'See more info, video, & screenshots on Browserstack:\n' +
+          '  ' + Logger.colors.light_cyan(`https://${this.productNamespace}.browserstack.com/builds/${this.buildId}/sessions/${this.sessionId}`));
+      }
+
+      if (this.uploadedAppUrl) {
+        // App was uploaded to BrowserStack and custom_id not being used
+        // eslint-disable-next-line no-console
+        console.log('\n  ' + Logger.colors.light_cyan(
+          `Please set 'appium:app' capability to '${this.uploadedAppUrl}' to avoid uploading the app again in future runs.`
+        ) + '\n');
+      }
 
       this.sessionId = null;
 
diff --git a/lib/transport/selenium-webdriver/index.js b/lib/transport/selenium-webdriver/index.js
index fac23ca2..4a8824c1 100644
--- a/lib/transport/selenium-webdriver/index.js
+++ b/lib/transport/selenium-webdriver/index.js
@@ -270,7 +270,7 @@ class Transport extends BaseTransport {
     const startTime = new Date();
     const {host, port, start_process} = this.settings.webdriver;
     const portStr = port ? `port ${port}` : 'auto-generated port';
-    const options = this.createSessionOptions(argv);
+    const options = await this.createSessionOptions(argv);
 
     if (start_process) {
       if (this.usingSeleniumServer) {
diff --git a/lib/transport/selenium-webdriver/method-mappings.js b/lib/transport/selenium-webdriver/method-mappings.js
index 961ab1e1..3a01e22a 100644
--- a/lib/transport/selenium-webdriver/method-mappings.js
+++ b/lib/transport/selenium-webdriver/method-mappings.js
@@ -1,5 +1,6 @@
 const {WebElement, WebDriver, Origin, By} = require('selenium-webdriver');
 const {Locator} = require('../../element');
+const AppiumLocator = require('../../element/appium-locator.js');
 const {isString} = require('../../utils');
 const fs = require('fs');
 const cdp = require('./cdp.js');
@@ -367,7 +368,12 @@ module.exports = class MethodMappings {
         },
 
         async locateMultipleElements(element) {
-          const locator = Locator.create(element);
+          let locator;
+          if (this.transport.api.isAppiumClient()) {
+            locator = AppiumLocator.create(element);
+          } else {
+            locator = Locator.create(element);
+          }
           const resultValue = await this.driver.findElements(locator);
 
           if (Array.isArray(resultValue) && resultValue.length === 0) {
@@ -531,11 +537,14 @@ module.exports = class MethodMappings {
           return `/element/${id}/location_in_view`;
         },
 
-        async isElementDisplayed(webElementOrId) {
+        isElementDisplayed(webElementOrId) {
+          if (this.transport.api.isAppiumClient()) {
+            return `/element/${webElementOrId}/displayed`;
+          }
+
           const element = this.getWebElement(webElementOrId);
-          const result = await element.isDisplayed();
 
-          return result;
+          return element.isDisplayed();
         },
 
         async isElementEnabled(webElementOrId) {
diff --git a/lib/utils/requireModule.js b/lib/utils/requireModule.js
index f65315e8..d476fe8b 100644
--- a/lib/utils/requireModule.js
+++ b/lib/utils/requireModule.js
@@ -24,7 +24,17 @@ module.exports = function (fullpath) {
     if (getNodeVersion() >= 14) {
       const {pathToFileURL} = require('node:url');
       
-      return import(pathToFileURL(fullpath).href).then(result => (result.default || {}));
+      return import(pathToFileURL(fullpath).href).then(result => {
+        const _default = result.default || {};
+
+        return Object.keys(result).reduce((prev, val) => {
+          if (val !== 'default') {
+            prev[val] = result[val];
+          }
+
+          return prev;
+        }, _default);
+      });
     }
 
     return import(fullpath).then(result => (result.default || {}));
diff --git a/package-lock.json b/package-lock.json
index f561cddc..dce110fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "nightwatch",
-  "version": "2.6.13",
+  "version": "2.6.14",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "nightwatch",
-      "version": "2.6.13",
+      "version": "2.6.14",
       "license": "MIT",
       "dependencies": {
         "@nightwatch/chai": "5.0.2",
diff --git a/package.json b/package.json
index ae46a221..1a9158f4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "nightwatch",
   "description": "Easy to use Node.js based end-to-end testing solution for web applications using the W3C WebDriver API.",
-  "version": "2.6.13",
+  "version": "2.6.14",
   "author": "Andrei Rusu",
   "homepage": "https://nightwatchjs.org",
   "main": "./lib/index.js",


Test patch:
diff --git a/lib/runner/test-runners/default.js b/lib/runner/test-runners/default.js
index 5d9289bc..2b0f3f17 100644
--- a/lib/runner/test-runners/default.js
+++ b/lib/runner/test-runners/default.js
@@ -48,7 +48,9 @@ class DefaultRunner {
     if (this.client && this.client.sessionId && this.client.startSessionEnabled) {
       Logger.info(`Attempting to close session ${this.client.sessionId}...`);
 
-      return this.currentSuite.terminate(null, null, true);
+      const failures = !this.currentSuite.reporter.allTestsPassed;
+
+      return this.currentSuite.terminate(failures ? 'FAILED' : '', null, true);
     }
 
     return Promise.resolve();
diff --git a/lib/testsuite/index.js b/lib/testsuite/index.js
index 4c319701..d604ba9e 100644
--- a/lib/testsuite/index.js
+++ b/lib/testsuite/index.js
@@ -1,11 +1,13 @@
 const AssertionError = require('assertion-error');
 const {By, Key, locateWith, withTagName} = require('selenium-webdriver');
 
+
 const Reporter = require('../reporter');
 const Context = require('./context.js');
 const TestHooks = require('./hooks.js');
 const TestCase = require('./testcase.js');
 const Runnable = require('./runnable.js');
+const Transport = require('../transport/selenium-webdriver');
 const NightwatchAssertError = require('../assertion').AssertionError;
 const SuiteRetries = require('./retries.js');
 const NightwatchClient = require('../core/client.js');
@@ -260,6 +262,13 @@ class TestSuite {
       return null;
     }
 
+    Object.defineProperty(global, 'app', {
+      configurable: true,
+      get: function() {
+        return global.browser;
+      }
+    });
+
     Object.defineProperty(global, 'by', {
       configurable: true,
       get: function() {
@@ -341,14 +350,14 @@ class TestSuite {
 
     Object.defineProperty(globalExpect, 'cookie', {
       value: function(...args) {
-        return this.expect.cookie(...args);
+        return global.browser.expect.cookie(...args);
       },
       writable: false
     });
 
     Object.defineProperty(globalExpect, 'url', {
       value: function(...args) {
-        return this.expect.url(...args);
+        return global.browser.expect.url(...args);
       },
       writable: false
     });
@@ -662,6 +671,11 @@ class TestSuite {
     }
 
     if (!this.endSessionOnFail && reason === 'FAILED') {
+
+      // Keep the session open; avoid reusing of same session
+      Transport.driver = null;
+      Transport.driverService = null;
+
       return potentialError;
     }
 
diff --git a/test/apidemos/appium/appiumTest.js b/test/apidemos/appium/appiumTest.js
new file mode 100644
index 00000000..059fd0f8
--- /dev/null
+++ b/test/apidemos/appium/appiumTest.js
@@ -0,0 +1,37 @@
+const assert = require('assert');
+
+describe('appium api demo', function () {
+  after((app) => app.end());
+
+  const availableAppiumCommands = [
+    'startActivity',
+    'getCurrentActivity',
+    'getCurrentPackage',
+    'getOrientation',
+    'setOrientation',
+    'getGeolocation',
+    'setGeolocation',
+    'pressKeyCode',
+    'longPressKeyCode',
+    'hideKeyboard',
+    'isKeyboardShown',
+    'getContexts',
+    'getContext',
+    'setContext'
+  ];
+
+  it('test appium available API commands', async function () {
+    // app variable is available globally
+    assert.strictEqual(app !== undefined, true);
+
+    availableAppiumCommands.forEach((command) => {
+      assert.strictEqual(typeof app.appium[command], 'function');
+    });
+  });
+
+  it('Search for Nightwatch', async function() {
+    app  // available globally
+      .click('id', 'com.app:id/search')
+      .sendKeys('id', 'com.app:id/search', 'Nightwatch');
+  });
+});
diff --git a/test/extra/pageobjects/pages/simplePageObjWithCommandsClass.js b/test/extra/pageobjects/pages/simplePageObjWithCommandsClass.js
index 01e5cf56..cf69672a 100644
--- a/test/extra/pageobjects/pages/simplePageObjWithCommandsClass.js
+++ b/test/extra/pageobjects/pages/simplePageObjWithCommandsClass.js
@@ -10,6 +10,8 @@ class PageCommands extends CommonCommands {
     assert.strictEqual(this.client.api.sessionId, '1352110219202');
     assert.ok(this.transportActions);
     assert.ok(this.driver);
+    assert.ok(this.section);
+    assert.ok(this.page);
     assert.ok(typeof this.httpRequest == 'function');
     assert.ok(typeof this.toString == 'function');
   }
@@ -43,5 +45,5 @@ module.exports = {
     }
   },
 
-  commands: PageCommands
+  commands: [PageCommands]
 };
diff --git a/test/lib/nocks.js b/test/lib/nocks.js
index f841d488..e40d4f51 100644
--- a/test/lib/nocks.js
+++ b/test/lib/nocks.js
@@ -138,6 +138,18 @@ module.exports = {
     return this;
   },
 
+  appiumElementFound() {
+    nock('http://localhost:10195')
+      .post('/wd/hub/session/1352110219202/elements', {'using': 'id', 'value': 'com.app:id/web-login'})
+      .reply(200, {
+        status: 0,
+        state: 'success',
+        value: [{'element-6066-11e4-a52e-4f735466cecf': '0'}]
+      });
+
+    return this;
+  },
+
   click() {
     nock('http://localhost:10195')
       .post('/wd/hub/session/1352110219202/element/0/click')
@@ -370,6 +382,19 @@ module.exports = {
     return this;
   },
 
+  appiumElementVisible() {
+    nock('http://localhost:10195')
+      .get('/wd/hub/session/1352110219202/element/0/displayed')
+      .reply(200, {
+        status: 0,
+        sessionId: '1352110219202',
+        value: true,
+        state: 'success'
+      });
+
+    return this;
+  },
+
   notVisible(times) {
     var mock = nock('http://localhost:10195')
       .post('/wd/hub/session/1352110219202/execute/sync');
diff --git a/test/src/api/commands/element/testIsVisible.js b/test/src/api/commands/element/testIsVisible.js
index 3807dc8d..b50f665e 100644
--- a/test/src/api/commands/element/testIsVisible.js
+++ b/test/src/api/commands/element/testIsVisible.js
@@ -12,13 +12,21 @@ describe('isVisible', function () {
       url: '/wd/hub/session/1352110219202/execute/sync',
       method: 'POST'
     });
+    MockServer.removeMock({
+      url: '/wd/hub/session/1352110219202/elements',
+      method: 'POST'
+    });
+    MockServer.removeMock({
+      url: '/wd/hub/session/1352110219202/element/999/displayed',
+      method: 'GET'
+    });
   });
 
   after(function (done) {
     CommandGlobals.afterEach.call(this, done);
   });
 
-  it('client.isVisible()', function (done) {
+  it('client.isVisible() [visible]', function (done) {
     MockServer.addMock({
       url: '/wd/hub/session/1352110219202/execute/sync',
       method: 'POST',
@@ -38,7 +46,7 @@ describe('isVisible', function () {
     this.client.start(done);
   });
 
-  it('client.isVisible()', function (done) {
+  it('client.isVisible() [not visible]', function (done) {
     MockServer.addMock({
       url: '/wd/hub/session/1352110219202/execute/sync',
       method: 'POST',
@@ -57,4 +65,76 @@ describe('isVisible', function () {
 
     this.client.start(done);
   });
+
+  it('client.isVisible() [visible] -- appium', function (done) {
+    MockServer
+      .addMock({
+        url: '/wd/hub/session/1352110219202/elements',
+        postdata: {
+          using: 'id',
+          value: 'com.app:id/weblogin'
+        },
+        method: 'POST',
+        response: JSON.stringify({
+          status: 0,
+          state: 'success',
+          value: [{'element-6066-11e4-a52e-4f735466cecf': '999'}]
+        })
+      })
+      .addMock({
+        url: '/wd/hub/session/1352110219202/element/999/displayed',
+        method: 'GET',
+        response: JSON.stringify({
+          value: true
+        })
+      });
+
+    // Make appium client
+    this.client.api.options.selenium.use_appium = true;
+
+    this.client.api.isVisible('id', 'com.app:id/weblogin', function callback(result) {
+      assert.strictEqual(result.value, true);
+    }).isVisible({selector: 'com.app:id/weblogin', locateStrategy: 'id'}, function callback(result) {
+      assert.strictEqual(result.value, true);
+    });
+
+    this.client.start(done);
+  });
+
+  it('client.isVisible() [not visible] -- appium', function (done) {
+    MockServer
+      .addMock({
+        url: '/wd/hub/session/1352110219202/elements',
+        postdata: {
+          using: 'id',
+          value: 'com.app:id/weblogin'
+        },
+        method: 'POST',
+        response: JSON.stringify({
+          status: 0,
+          state: 'success',
+          value: [{'element-6066-11e4-a52e-4f735466cecf': '999'}]
+        })
+      })
+      .addMock({
+        url: '/wd/hub/session/1352110219202/element/999/displayed',
+        method: 'GET',
+        response: JSON.stringify({
+          value: false
+        })
+      });
+
+    // Make appium client
+    this.client.api.options.selenium.use_appium = true;
+
+    assert.strictEqual(this.client.api.isAppiumClient(), true);
+
+    this.client.api.isVisible('id', 'com.app:id/weblogin', function callback(result) {
+      assert.strictEqual(result.value, false);
+    }).isVisible({selector: 'com.app:id/weblogin', locateStrategy: 'id'}, function callback(result) {
+      assert.strictEqual(result.value, false);
+    });
+
+    this.client.start(done);
+  });
 });
diff --git a/test/src/api/commands/element/testWaitForElementVisible.js b/test/src/api/commands/element/testWaitForElementVisible.js
index aeb4e948..e5966c16 100644
--- a/test/src/api/commands/element/testWaitForElementVisible.js
+++ b/test/src/api/commands/element/testWaitForElementVisible.js
@@ -336,4 +336,46 @@ describe('waitForElementVisible', function () {
     });
   });
 
+  it('client.waitForElementVisible() success with appium client', function () {
+    MockServer
+      .addMock({
+        url: '/wd/hub/session/1352110219202/elements',
+        postdata: {
+          using: 'id',
+          value: 'com.app:id/web-login'
+        },
+        method: 'POST',
+        response: JSON.stringify({
+          status: 0,
+          state: 'success',
+          value: [{'element-6066-11e4-a52e-4f735466cecf': '99'}]
+        })
+      }, undefined, true)
+      .addMock({
+        url: '/wd/hub/session/1352110219202/element/99/displayed',
+        method: 'GET',
+        response: JSON.stringify({
+          value: true
+        })
+      }, undefined, true);
+
+    // Make appium client
+    this.client.api.options.selenium.use_appium = true;
+
+    assert.strictEqual(this.client.api.isAppiumClient(), true);
+
+    this.client.api.waitForElementVisible('id', 'com.app:id/web-login', function callback(result, instance) {
+      assert.strictEqual(instance.elementId, '99');
+      assert.strictEqual(result.value, true);
+    }).waitForElementVisible({selector: 'com.app:id/web-login', locateStrategy: 'id'}, function callback(result, instance) {
+      assert.strictEqual(instance.elementId, '99');
+      assert.strictEqual(result.value, true);
+    });
+
+    return this.client.start(function (err) {
+      if (err) {
+        throw err;
+      }
+    });
+  });
 });
diff --git a/test/src/api/expect/testExpectVisible.js b/test/src/api/expect/testExpectVisible.js
index ba170e29..50afb93f 100644
--- a/test/src/api/expect/testExpectVisible.js
+++ b/test/src/api/expect/testExpectVisible.js
@@ -80,6 +80,25 @@ describe('expect.visible', function () {
       });
     });
 
+    it('to be visible [PASSED] -- appium', function () {
+      Nocks.appiumElementFound().appiumElementVisible();
+
+      // Make appium client
+      this.client.api.options.selenium.use_appium = true;
+      strictEqual(this.client.api.isAppiumClient(), true);
+
+      let expect = this.client.api.expect.element({selector: 'com.app:id/web-login', locateStrategy: 'id'}).to.be.visible;
+
+      return this.client.start(function () {
+        strictEqual(expect.assertion.selector, 'com.app:id/web-login');
+        strictEqual(expect.assertion.negate, false);
+        strictEqual(expect.assertion.passed, true);
+        strictEqual(expect.assertion.resultValue, true);
+        strictEqual(expect.assertion.message, `Expected element <com.app:id/web-login> to be visible (${expect.assertion.elapsedTime}ms)`);
+        strictEqual(expect.assertion.messageParts.length, 1);
+      });
+    });
+
     it('to be visible with waitFor [PASSED]', function () {
       Nocks.elementFound().visible();
 
diff --git a/test/src/apidemos/appium/testAppiumAPI.js b/test/src/apidemos/appium/testAppiumAPI.js
new file mode 100644
index 00000000..47dfc7d2
--- /dev/null
+++ b/test/src/apidemos/appium/testAppiumAPI.js
@@ -0,0 +1,98 @@
+const {strictEqual} = require('assert');
+const path = require('path');
+const common = require('../../../common.js');
+const MockServer = require('../../../lib/mockserver.js');
+const NightwatchClient = common.require('index.js');
+
+describe('appium api demos', function () {
+  beforeEach(function (done) {
+    this.server = MockServer.init(undefined, {port: 4723});
+    this.server.on('listening', () => {
+      done();
+    });
+  });
+
+  afterEach(function (done) {
+    this.server.close(function () {
+      done();
+    });
+  });
+
+  it('run appium api demo tests basic', function () {
+    const testsPath = path.join(__dirname, '../../../apidemos/appium/appiumTest.js');
+
+    const globals = {
+      waitForConditionPollInterval: 50,
+
+      reporter(results) {
+        if (results.lastError) {
+          throw results.lastError;
+        }
+      }
+    };
+
+    MockServer
+      .addMock({
+        url: '/wd/hub/session',
+        method: 'POST',
+        response: JSON.stringify({
+          value: {
+            sessionId: '13521-10219-202',
+            version: 'TEST',
+            platform: 'TEST'
+          }
+        })
+      }, true)
+      .addMock({
+        url: '/wd/hub/session/13521-10219-202/elements',
+        postdata: {
+          using: 'id',
+          value: 'com.app:id/search'
+        },
+        method: 'POST',
+        response: JSON.stringify({
+          status: 0,
+          state: 'success',
+          value: [{'element-6066-11e4-a52e-4f735466cecf': '0'}]
+        })
+      }, true, true)
+      .addMock({
+        url: '/wd/hub/session/13521-10219-202/element/0/click',
+        method: 'POST',
+        response: JSON.stringify({
+          value: null
+        })
+      }, true)
+      .addMock({
+        url: '/wd/hub/session/13521-10219-202/element/0/value',
+        method: 'POST',
+        postdata: JSON.stringify({
+          text: 'Nightwatch',
+          value: 'Nightwatch'.split('')
+        }),
+        response: JSON.stringify({
+          value: null
+        })
+      }, true);
+
+    return NightwatchClient.runTests(testsPath, {
+      selenium: {
+        host: 'localhost',
+        port: 4723,
+        start_process: false,
+        use_appium: true
+      },
+      desiredCapabilities: {
+        browserName: ''
+      },
+      output: false,
+      skip_testcases_on_fail: false,
+      silent: true,
+      persist_globals: true,
+      globals,
+      output_folder: false
+    }).then(_ => {
+      strictEqual(NightwatchClient.app !== undefined, true);
+    });
+  });
+});
diff --git a/test/src/core/testCreateSession.js b/test/src/core/testCreateSession.js
index 251ae1de..6c6dd493 100644
--- a/test/src/core/testCreateSession.js
+++ b/test/src/core/testCreateSession.js
@@ -1,5 +1,6 @@
 const assert = require('assert');
 const nock = require('nock');
+const path = require('path');
 const Nocks = require('../../lib/nocks.js');
 const Nightwatch = require('../../lib/nightwatch.js');
 
@@ -581,9 +582,11 @@ describe('test Request With Credentials', function () {
         }
       }
     });
+
+    assert.strictEqual(client.api.isAppiumClient(), false);
   });
 
-  it('Test create session with browserstack and browserName set to null', async function () {
+  it('Test create session with browserstack and browserName set to null (App Automate)', async function () {
     nock('https://hub.browserstack.com')
       .post('/wd/hub/session')
       .reply(201, function (uri, requestBody) {
@@ -592,6 +595,7 @@ describe('test Request With Credentials', function () {
           capabilities: {
             firstMatch: [{}],
             alwaysMatch: {
+              'appium:app': 'bs://878bdf21505f0004ce',
               'bstack:options': {
                 local: 'false',
                 userName: 'test_user',
@@ -608,11 +612,22 @@ describe('test Request With Credentials', function () {
         return {
           value: {
             sessionId: '1352110219202',
-            capabilities: requestBody.capabilities
+            capabilities: {
+              platform: 'MAC',
+              platformName: 'iOS',
+              deviceName: 'iPhone 12',
+              realMobile: true
+            }
           }
         };
       });
 
+    nock('https://api-cloud.browserstack.com')
+      .post('/app-automate/upload')
+      .reply(200, {
+        app_url: 'bs://878bdf21505f0004ce'
+      });
+
     nock('https://api.browserstack.com')
       .get('/app-automate/builds.json')
       .reply(200, [
@@ -656,7 +671,8 @@ describe('test Request With Credentials', function () {
         browserName: null,
         chromeOptions: {
           w3c: false
-        }
+        },
+        appUploadUrl: 'https://some_host.com/app.apk'
       },
 
       parallel: false
@@ -671,20 +687,145 @@ describe('test Request With Credentials', function () {
     assert.deepStrictEqual(result, {
       sessionId: '1352110219202',
       capabilities: {
-        firstMatch: [{}],
-        alwaysMatch: {
-          'bstack:options': {
-            local: 'false',
-            userName: 'test_user',
-            accessKey: 'test_key',
-            osVersion: '14',
-            deviceName: 'iPhone 12',
-            realMobile: 'true',
-            buildName: 'Nightwatch Programmatic Api Demo'
+        platform: 'MAC',
+        platformName: 'iOS',
+        deviceName: 'iPhone 12',
+        realMobile: true
+      }
+    });
+  
+    assert.strictEqual(client.transport.uploadedAppUrl, 'bs://878bdf21505f0004ce');
+
+    assert.strictEqual(client.settings.selenium.use_appium, undefined);
+    assert.strictEqual(client.api.isAppiumClient(), true);
+  });
+
+  it('Test create session with Browserstack App Automate using custom id', async function () {
+    nock('https://hub.browserstack.com')
+      .post('/wd/hub/session')
+      .reply(201, function (uri, requestBody) {
+        assert.deepEqual(requestBody, {
+          capabilities: {
+            firstMatch: [{}],
+            alwaysMatch: {
+              'appium:automationName': 'UiAutomator2',
+              'appium:platformVersion': '9.0',
+              'appium:deviceName': 'Google Pixel 3',
+              'appium:app': 'sample_app',
+              'browserName': '',
+              'bstack:options': {
+                local: 'false',
+                userName: 'test_user',
+                accessKey: 'test_key',
+                realMobile: true,
+                buildName: 'Nightwatch Programmatic Api Demo'
+              }
+            }
+          }
+        });
+
+        return {
+          value: {
+            sessionId: '1352110219202',
+            capabilities: {
+              platform: 'LINUX',
+              platformName: 'Android',
+              deviceName: '8B3X12Y71',
+              automationName: 'uiautomator2',
+              platformVersion: '9',
+              realMobile: true
+            }
+          }
+        };
+      });
+
+    nock('https://api-cloud.browserstack.com')
+      .post('/app-automate/upload')
+      .reply(200, function (uri, requestBody) {
+        const body = requestBody.toString();
+        assert.strictEqual(body.includes('name="custom_id"'), true);
+        assert.strictEqual(body.includes('sample_app'), true);
+        assert.strictEqual(body.includes('name="file"; filename="nightwatch.json"'), true);
+
+        return {
+          app_url: 'bs://878bdf21505f0004ce',
+          custom_id: 'sample_app',
+          shareable_id: 'test_user/sample_app'
+        };
+      });
+
+    nock('https://api.browserstack.com')
+      .get('/app-automate/builds.json')
+      .reply(200, [
+        {
+          automation_build: {
+            name: 'WIN_CHROME_PROD_SANITY_LIVE_1831',
+            duration: 47,
+            status: 'running',
+            hashed_id: '8dd73aad3365429dec0ec12cf64c0c475a22dasds',
+            build_tag: null
+          }
+        },
+        {
+          automation_build: {
+            name: 'External monitoring - aps - 2022-08-30',
+            duration: 44,
+            status: 'done',
+            hashed_id: '8dd73aad3365429dec0ec12cf64c0c475a22dasdk',
+            build_tag: null
           }
         }
+      ]);
+
+    const client = Nightwatch.createClient({
+      webdriver: {
+        start_process: false
+      },
+      selenium: {
+        host: 'hub.browserstack.com',
+        port: 443
+      },
+      desiredCapabilities: {
+        'bstack:options': {
+          local: 'false',
+          userName: 'test_user',
+          accessKey: 'test_key',
+          realMobile: true
+        },
+        'appium:options': {
+          automationName: 'UiAutomator2',
+          app: 'sample_app',
+          deviceName: 'Google Pixel 3',
+          platformVersion: '9.0'
+        },
+        browserName: '',
+        appUploadPath: path.resolve(__dirname, '../../extra/nightwatch.json')
+      },
+      parallel: false
+    });
+
+    client.mergeCapabilities({
+      name: 'Try 1',
+      build: 'Nightwatch Programmatic Api Demo'
+    });
+
+    const result = await client.createSession();
+    assert.deepStrictEqual(result, {
+      sessionId: '1352110219202',
+      capabilities: {
+        platform: 'LINUX',
+        platformName: 'Android',
+        deviceName: '8B3X12Y71',
+        automationName: 'uiautomator2',
+        platformVersion: '9',
+        realMobile: true
       }
     });
+
+    assert.strictEqual(client.transport.uploadedAppUrl, undefined);
+
+    assert.strictEqual(client.settings.selenium.use_appium, undefined);
+    assert.strictEqual(client.api.isAppiumClient(), true);
   });
 
   it('Test create session with browserstack and when buildName is not set', async function () {
diff --git a/test/src/index/testRequest.js b/test/src/index/testRequest.js
index 366187c0..f63bcbdf 100644
--- a/test/src/index/testRequest.js
+++ b/test/src/index/testRequest.js
@@ -1,6 +1,8 @@
 const nock = require('nock');
 const assert = require('assert');
 const mockery = require('mockery');
+const fs = require('fs');
+const path = require('path');
 
 const common = require('../../common.js');
 const HttpRequest = common.require('http/request.js');
@@ -50,10 +52,19 @@ describe('test HttpRequest', function() {
         sessionId: '123456-789'
       });
 
+    nock('https://api-cloud.browserstack.com')
+      .post('/app-automate/upload')
+      .reply(200, {
+        app_url: 'bs://878bdf21505f0004ce',
+        custom_id: 'some_app'
+      });
+
     callback();
   });
 
   afterEach(function () {
+    mockery.deregisterAll();
+    mockery.resetCache();
     mockery.disable();
   });
 
@@ -405,5 +416,50 @@ describe('test HttpRequest', function() {
     
   });
 
+  it('send POST request with multi-part form data', function(done) {
+    mockery.registerMock('fs', {
+      readFileSync() {
+        return Buffer.from('app-data');
+      }
+    });
+
+    const options = {
+      method: 'POST',
+      url: 'https://api-cloud.browserstack.com/app-automate/upload',
+      use_ssl: true,
+      port: 443,
+      multiPartFormData: {
+        file: {
+          filePath: 'some/path/app.apk'
+        },
+        custom_id: {
+          data: 'some_app'
+        }
+      }
+    };
+
+    const request = new HttpRequest(options);
+    request.on('success', function () {
+      done();
+    }).send();
+
+    const boundary = request.formBoundary;
+    const data = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="app.apk"\r\n\r\napp-data` +
+      `\r\n--${boundary}\r\nContent-Disposition: form-data; name="custom_id"\r\n\r\nsome_app` +
+      `\r\n--${boundary}--`;
+    const bufferData = Buffer.from(data);
 
+    assert.deepStrictEqual(request.data, bufferData);
+    assert.strictEqual(request.contentLength, bufferData.length);
+    assert.strictEqual(request.use_ssl, true);
+
+    const opts = request.reqOptions;
+    assert.strictEqual(opts.path, '/app-automate/upload');
+    assert.strictEqual(opts.host, 'api-cloud.browserstack.com');
+    assert.strictEqual(opts.port, 443);
+    assert.strictEqual(opts.method, 'POST');
+    assert.strictEqual(opts.headers['content-type'], `multipart/form-data; boundary=${boundary}`);
+    assert.strictEqual(opts.headers['content-length'], bufferData.length);
+    assert.ok(opts.headers['User-Agent'].startsWith('nightwatch.js/'));
+  });
 });
diff --git a/test/src/index/transport/testMobileOptions.js b/test/src/index/transport/testMobileOptions.js
index 5a88af3f..f66dbabc 100644
--- a/test/src/index/transport/testMobileOptions.js
+++ b/test/src/index/transport/testMobileOptions.js
@@ -26,6 +26,7 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), true);
     assert.strictEqual(client.api.isAndroid(), false);
     assert.strictEqual(client.api.isMobile(), true);
+    assert.strictEqual(client.api.isAppiumClient(), false);
   });
 
   it('have isAndroid() for web testing on Android', function () {
@@ -48,6 +49,7 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), false);
     assert.strictEqual(client.api.isAndroid(), true);
     assert.strictEqual(client.api.isMobile(), true);
+    assert.strictEqual(client.api.isAppiumClient(), false);
   });
 
   it('doesn\'t have isIOS(), isAndroid() and isMobile() for web testing on desktop', function () {
@@ -66,9 +68,10 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), false);
     assert.strictEqual(client.api.isAndroid(), false);
     assert.strictEqual(client.api.isMobile(), false);
+    assert.strictEqual(client.api.isAppiumClient(), false);
   });
 
-  it('have isIOS() for native testing on iOS', function () {
+  it('have isIOS() and isAppiumClient() for native testing on iOS', function () {
     const client = Nightwatch.createClient({
       selenium: {
         use_appium: true
@@ -86,12 +89,13 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), true);
     assert.strictEqual(client.api.isAndroid(), false);
     assert.strictEqual(client.api.isMobile(), true);
+    assert.strictEqual(client.api.isAppiumClient(), true);
   });
 
   it('have isAndroid() for native testing on Android', function () {
     const client = Nightwatch.createClient({
       selenium: {
-        use_appium: true
+        use_appium: false
       },
       desiredCapabilities: {
         browserName: null,
@@ -105,9 +109,11 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), false);
     assert.strictEqual(client.api.isAndroid(), true);
     assert.strictEqual(client.api.isMobile(), true);
+    // backward compatibility with browserName: null
+    assert.strictEqual(client.api.isAppiumClient(), true);
   });
 
-  it('have isAndroid() for web testing on Android using Appium', function () {
+  it('have isAndroid() and isAppiumClient() for web testing on Android using Appium', function () {
     const client = Nightwatch.createClient({
       selenium: {
         use_appium: true
@@ -124,5 +130,6 @@ describe('Test mobile options in Nightwatch/Appium client', function () {
     assert.strictEqual(client.api.isIOS(), false);
     assert.strictEqual(client.api.isAndroid(), true);
     assert.strictEqual(client.api.isMobile(), true);
+    assert.strictEqual(client.api.isAppiumClient(), true);
   });
 });
diff --git a/test/src/runner/testRunnerEndSessionOnFail.js b/test/src/runner/testRunnerEndSessionOnFail.js
new file mode 100644
index 00000000..b0367063
--- /dev/null
+++ b/test/src/runner/testRunnerEndSessionOnFail.js
@@ -0,0 +1,56 @@
+const path = require('path');
+const assert = require('assert');
+const nock = require('nock');
+const nocks = require('../../lib/nocks');
+const common = require('../../common.js');
+const CommandGlobals = require('../../lib/globals/commands.js');
+const {settings} = common;
+const {runTests} = common.require('index.js');
+
+describe('test runner with end_session_on_fail flag', function() {
+
+  before(function(done) {
+    nocks.enable().cleanAll();
+    CommandGlobals.beforeEach.call(this, done);
+    
+  });
+
+  after(function(done) {
+    nocks.disable();
+    CommandGlobals.afterEach.call(this, done);
+  });
+
+
+  it('session is not ended with end_on_sessiion_fail: false', function() {
+    let endSessionCalled = false;
+    const testFile = path.join(__dirname, '../../sampletests/withfailures/sample.js');
+
+    nocks.createSession()
+      .url()
+      .elementNotFound('#weblogin');
+   
+
+    nock('http://localhost:10195')
+      .delete('/wd/hub/session/1352110219202')
+      .reply(204, ()=> {
+        endSessionCalled = true;
+      
+        return '';
+      });
+
+    return runTests(testFile, settings({
+      enable_fail_fast: true,
+      end_session_on_fail: false,
+      globals: {
+        waitForConditionPollInterval: 5,
+        waitForConditionTimeout: 5,
+        retryAssertionTimeout: 1
+      }
+    })).catch(_ => {
+      assert.strictEqual(endSessionCalled, false);
+    });
+
+
+  });
+
+});