Repository: nightwatchjs/nightwatch
Pull request #3573 (2023-01-16 18:40:38)

Add support to upload app to app-automate.
To test an app on BrowserStack app-automate, we first need to upload that application to BrowserStack using BrowserStack's REST API: https://www.browserstack.com/docs/app-automate/api-reference/appium/apps#upload-an-app

The upload can be done either from a public URL or using a file from the local filesystem. This PR allows users to set `appUploadPath` or `appUploadUrl` as desired capability if they wish to upload the app they wish to test to BrowserStack. 

Additionally, if they have already set `appium:app` desired capability to some BrowserStack URL (starts with `bs://`), it will be overwritten with the new URL. And if they have set `appium:app` to something else other than BrowserStack URL, that will be considered as the custom id while uploading the application, which then can be used as it is to run tests against the uploaded app.

A few test runs:

Without custom id:
![image](https://user-images.githubusercontent.com/39924567/212746702-5c3f4856-2e7e-4ca8-8f0f-a0e3c8de2cb9.png)

With custom id:
![image](https://user-images.githubusercontent.com/39924567/212746743-ac7ff8ac-355c-4ce7-ab5d-b0ef279b49de.png)


beatfactor: can you fix the "Using: undefined()" bit?
garg3133: This PR is ready to be merged now.

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/lib/api/element-commands/getNextSibling.js b/lib/api/element-commands/getNextSibling.js
index b21931f7..cf54871c 100644
--- a/lib/api/element-commands/getNextSibling.js
+++ b/lib/api/element-commands/getNextSibling.js
@@ -20,6 +20,7 @@ const BaseElementCommand = require('./_baseElementCommand.js');
  * @moreinfo developer.mozilla.org/en-US/docs/Web/API/Element/nextElementSibling
  * @since 2.0.0
  * @api protocol.elements
+ * @exampleLink /api/getNextSibling.js
  */
 class GetNextSibling extends BaseElementCommand {
     
diff --git a/lib/element/locate/recursive-lookup.js b/lib/element/locate/recursive-lookup.js
index 928cca55..65710e9f 100644
--- a/lib/element/locate/recursive-lookup.js
+++ b/lib/element/locate/recursive-lookup.js
@@ -50,7 +50,8 @@ class RecursiveLookupBase extends EventEmitter {
         element,
         transportAction: this.transportAction,
         commandName: this.commandName,
-        returnSingleElement: this.shouldReturnSingleElement(returnSingleElement)
+        returnSingleElement: this.shouldReturnSingleElement(returnSingleElement),
+        cacheElementId: false
       })
         .then(result => {
           this.recursiveElementLookup({
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 bac5ccfc..7b47a18e 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -26,7 +26,7 @@ const Nightwatch = module.exports = {};
  *
  * @returns {{browser}}
  */
-Nightwatch.createClient = function({
+module.exports.createClient = function({
   headless = false,
   silent = true,
   output = true,
@@ -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/reporter/summary.js b/lib/reporter/summary.js
index cf048acb..bb24e57b 100644
--- a/lib/reporter/summary.js
+++ b/lib/reporter/summary.js
@@ -120,6 +120,7 @@ module.exports = class Summary {
     const testSuites = Object.keys(globalResults.modules);
     const testSuitesCount = testSuites.length;
 
+    let failedIndex = 0;
     testSuites.forEach((testSuiteName, index) => {
       const testSuite = globalResults.modules[testSuiteName];
 
@@ -128,7 +129,7 @@ module.exports = class Summary {
 
         Summary.printSuite(Summary.getFailedSuiteContent({
           testSuite,
-          index,
+          index: failedIndex++,
           testSuiteName,
           startSessionEnabled: this.settings.start_session
         }));
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 a039c2e3..4a8824c1 100644
--- a/lib/transport/selenium-webdriver/index.js
+++ b/lib/transport/selenium-webdriver/index.js
@@ -7,6 +7,7 @@ const {Logger, isObject, IosSessionNotCreatedError, AndroidConnectionError} = re
 const httpClient = require('./httpclient.js');
 const Session = require('./session.js');
 const BaseTransport = require('../');
+const CDP = require('./cdp.js');
 const {colors} = Logger;
 const {isErrorResponse, checkLegacyResponse, throwDecodedError, WebDriverError} = error;
 
@@ -187,7 +188,7 @@ class Transport extends BaseTransport {
 
   async sessionFinished(reason) {
     this.emit('session:finished', reason);
-
+    CDP.resetConnection();
     await this.closeDriver();
   }
 
@@ -269,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/options.js b/lib/transport/selenium-webdriver/options.js
index aebb7c41..c7c853c5 100644
--- a/lib/transport/selenium-webdriver/options.js
+++ b/lib/transport/selenium-webdriver/options.js
@@ -328,7 +328,7 @@ module.exports = class SeleniumCapabilities {
 
   addHeadlessOption({options}) {
     if (this.argv.headless && (options instanceof Capabilities) && (this.isChrome || options.headless)) {
-      this.isChrome ? options.addArguments('headless=chrome') : options.headless();
+      this.isChrome ? options.addArguments('headless=new') : options.headless();
     }
 
     return this;
diff --git a/lib/utils/requireModule.js b/lib/utils/requireModule.js
index aa7c1e15..f65315e8 100644
--- a/lib/utils/requireModule.js
+++ b/lib/utils/requireModule.js
@@ -1,3 +1,9 @@
+const getNodeVersion = () => {
+  const version = process.version.replace('v', '');
+  
+  return parseInt(version, 10);
+}
+
 module.exports = function (fullpath) {
   let exported;
   try {
@@ -15,6 +21,12 @@ module.exports = function (fullpath) {
       throw err;
     }
 
+    if (getNodeVersion() >= 14) {
+      const {pathToFileURL} = require('node:url');
+      
+      return import(pathToFileURL(fullpath).href).then(result => (result.default || {}));
+    }
+
     return import(fullpath).then(result => (result.default || {}));
   }
 
diff --git a/package-lock.json b/package-lock.json
index 330c74ee..f561cddc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "nightwatch",
-  "version": "2.6.10",
+  "version": "2.6.13",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "nightwatch",
-      "version": "2.6.10",
+      "version": "2.6.13",
       "license": "MIT",
       "dependencies": {
         "@nightwatch/chai": "5.0.2",
@@ -3326,9 +3326,9 @@
       "dev": true
     },
     "node_modules/http-cache-semantics": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
-      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+      "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
       "optional": true,
       "peer": true
     },
@@ -3964,9 +3964,9 @@
       "dev": true
     },
     "node_modules/json5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
-      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "dev": true,
       "bin": {
         "json5": "lib/cli.js"
@@ -9458,9 +9458,9 @@
       "dev": true
     },
     "http-cache-semantics": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
-      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+      "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
       "optional": true,
       "peer": true
     },
@@ -9935,9 +9935,9 @@
       "dev": true
     },
     "json5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
-      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "dev": true
     },
     "jsonfile": {
diff --git a/package.json b/package.json
index bc31c5d0..ae46a221 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.10",
+  "version": "2.6.13",
   "author": "Andrei Rusu",
   "homepage": "https://nightwatchjs.org",
   "main": "./lib/index.js",


Test patch:
diff --git a/lib/testsuite/context.js b/lib/testsuite/context.js
index 7db19a82..3187860c 100644
--- a/lib/testsuite/context.js
+++ b/lib/testsuite/context.js
@@ -420,9 +420,11 @@ class Context extends EventEmitter {
    */
   invokeMethod(fnName, client, expectedArgs, done) {
     const isES6Async = this.isES6Async(fnName);
+    const isTestHook = this.isTestHook;
+
     if (client) {
       client.isES6AsyncTestcase = isES6Async;
-      client.isES6AsyncTestHook = this.isTestHook ? isES6Async : undefined;
+      client.isES6AsyncTestHook = isTestHook ? isES6Async : undefined;
     }
 
     const api = client && client.api || null;
@@ -438,7 +440,7 @@ class Context extends EventEmitter {
     switch (expectedArgs) {
       case 2:
       case 1:
-        return this.callAsync({fnName, api, expectedArgs, done, context, isES6Async});
+        return this.callAsync({fnName, api, expectedArgs, done, context, isES6Async, isTestHook});
 
       case 0: {
         try {
diff --git a/lib/testsuite/globals.js b/lib/testsuite/globals.js
index e22c1eb3..fb60ecc4 100644
--- a/lib/testsuite/globals.js
+++ b/lib/testsuite/globals.js
@@ -3,6 +3,7 @@ const lodashClone = require('lodash.clone');
 const TestHooks = require('./hooks.js');
 const Context = require('./context.js');
 const Utils = require('../utils');
+const {Logger} = Utils;
 const PluginLoader = require('../api/_loaders/plugin.js');
 
 class GlobalsContext extends Context {
@@ -144,7 +145,21 @@ class GlobalsContext extends Context {
     });
   }
 
-  callAsync({fnName, api, expectedArgs = 2, done = function() {}}) {
+  callAsync({fnName, api, expectedArgs = 2, isTestHook = false, isES6Async, done = function() {}}) {
+
+    if (isES6Async && isTestHook && expectedArgs === 1) {
+      return this.module[fnName].call(this.module, api)
+        .then(function(result) {
+          return done(result);
+        })
+        .catch(function(err) {
+          Logger.error(err);
+
+          return done(err);
+        });
+    }
+
+
     let fnAsync = Utils.makeFnAsync(expectedArgs, this.module[fnName], this.module);
     let args = this.getHookFnArgs(done, api, expectedArgs);
 
diff --git a/lib/testsuite/index.js b/lib/testsuite/index.js
index 4c319701..1ac1ef6b 100644
--- a/lib/testsuite/index.js
+++ b/lib/testsuite/index.js
@@ -260,6 +260,13 @@ class TestSuite {
       return null;
     }
 
+    Object.defineProperty(global, 'app', {
+      configurable: true,
+      get: function() {
+        return global.browser;
+      }
+    });
+
     Object.defineProperty(global, 'by', {
       configurable: true,
       get: function() {
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/apidemos/cdp/registerAuth.js b/test/apidemos/cdp/registerAuth.js
new file mode 100644
index 00000000..b2d096ac
--- /dev/null
+++ b/test/apidemos/cdp/registerAuth.js
@@ -0,0 +1,6 @@
+describe('cdp tests', function() {
+  it('register basic auth', function() {
+    browser.registerBasicAuth('admin', 'admin')
+      .navigateTo('http://localhost');
+  });
+});
\ No newline at end of file
diff --git a/test/apidemos/cdp/registerAuth2.js b/test/apidemos/cdp/registerAuth2.js
new file mode 100644
index 00000000..b2d096ac
--- /dev/null
+++ b/test/apidemos/cdp/registerAuth2.js
@@ -0,0 +1,6 @@
+describe('cdp tests', function() {
+  it('register basic auth', function() {
+    browser.registerBasicAuth('admin', 'admin')
+      .navigateTo('http://localhost');
+  });
+});
\ No newline at end of file
diff --git a/test/extra/external-globals-async.js b/test/extra/external-globals-async.js
new file mode 100644
index 00000000..c2462613
--- /dev/null
+++ b/test/extra/external-globals-async.js
@@ -0,0 +1,34 @@
+const assert = require('assert');
+
+module.exports = {
+  'default': {},
+
+  async before() {
+    this.isFungus = true;
+  },
+
+  async beforeEach(client) {
+    expect(this.isFungus).to.be.true;
+
+    return new Promise(resolve => {
+      setTimeout(resolve, 500);
+    });
+  },
+
+  async afterEach(client) {
+    assert.strictEqual(client.globals.isFungus, true);
+  },
+
+  async reporter(results) {
+    assert.ok('modules' in results);
+
+    if (results.lastError instanceof Error) {
+      throw results.lastError;
+    }
+
+    assert.strictEqual(this.reporterCount, 0);
+    assert.strictEqual(this.isFungus, true);
+
+    this.reporterCount++;
+  }
+};
diff --git a/test/extra/pageobjects/pages/waitForElementNotPresentPageObj.js b/test/extra/pageobjects/pages/waitForElementNotPresentPageObj.js
new file mode 100644
index 00000000..70fae6fc
--- /dev/null
+++ b/test/extra/pageobjects/pages/waitForElementNotPresentPageObj.js
@@ -0,0 +1,25 @@
+module.exports = {
+  commands: [{
+    waitForElementNotPresentDemo(cb) {
+      this.click('@button')
+      const dialog = this.section.dialog;
+      dialog.selectValueDemo(cb)
+    }
+  }],
+  elements: {
+    button: '#weblogin'
+  },
+  sections: {
+    dialog: {
+      selector: '#weblogin',
+      elements: {
+        select: '#badElement'
+      },
+      commands: [{
+        selectValueDemo(cb) {
+          this.waitForElementNotPresent('@select', cb);
+        }
+      }]
+    }
+  }
+};
diff --git a/test/lib/command-mocks.js b/test/lib/command-mocks.js
index 2bb21d9b..57ba2708 100644
--- a/test/lib/command-mocks.js
+++ b/test/lib/command-mocks.js
@@ -385,7 +385,7 @@ module.exports = {
     url = '/wd/hub/session'
   }) {
     const browserName = 'chrome';
-    const headlessOpt = headless ? 'headless=chrome' : '';
+    const headlessOpt = headless ? 'headless=new' : '';
     const options = {
       ['goog:chromeOptions']: {}
     };
@@ -448,7 +448,7 @@ module.exports = {
           sessionId,
           capabilities: {
             acceptInsecureCerts: false,
-            browserName: 'firefox',
+            browserName,
             browserVersion: '65.0.1'
           }
         }
diff --git a/test/lib/nocks.js b/test/lib/nocks.js
index 434aa9ac..f841d488 100644
--- a/test/lib/nocks.js
+++ b/test/lib/nocks.js
@@ -179,6 +179,30 @@ module.exports = {
     return this;
   },
 
+  childElementsNotFound(selector='#badElement') {
+    nock('http://localhost:10195')
+      .post('/wd/hub/session/1352110219202/element/0/elements', {'using': 'css selector', 'value': selector})
+      .reply(200, {
+        status: 0,
+        state: 'success',
+        value: []
+      });
+
+    return this;
+  },
+
+  childElementsFound(selector='#weblogin') {
+    nock('http://localhost:10195')
+      .post('/wd/hub/session/1352110219202/element/0/elements', {'using': 'css selector', 'value': selector})
+      .reply(200, {
+        status: 0,
+        state: 'success',
+        value: [{'element-6066-11e4-a52e-4f735466cecf': '0'}]
+      })
+
+    return this;
+  },
+
   elementFoundXpath() {
     nock('http://localhost:10195')
       .post('/wd/hub/session/1352110219202/elements', {'using': 'xpath', 'value': '//weblogin'})
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/apidemos/cdp/testCdpCommands.js b/test/src/apidemos/cdp/testCdpCommands.js
new file mode 100644
index 00000000..90e45ca6
--- /dev/null
+++ b/test/src/apidemos/cdp/testCdpCommands.js
@@ -0,0 +1,81 @@
+const path = require('path');
+const assert = require('assert');
+const MockServer = require('../../../lib/mockserver.js');
+const Mocks = require('../../../lib/command-mocks');
+
+const mockery = require('mockery');
+const common = require('../../../common.js');
+const {settings} = common;
+const NightwatchClient = common.require('index.js');
+
+describe('cdp commands test', function() {
+  beforeEach(function(done) {
+    mockery.enable({useCleanCache: true, warnOnReplace: false, warnOnUnregistered: false});
+    this.server = MockServer.init();
+    this.server.on('listening', () => {
+      done();
+    });
+  });
+
+  afterEach(function(done) {
+    mockery.deregisterAll();
+    mockery.resetCache();
+    mockery.disable();
+    this.server.close(function() {
+      done();
+    });
+  });
+
+  it('reset cdp connection after each session', function() {
+    const testsPath = [path.join(__dirname, '../../../apidemos/cdp')];
+    let resetConnectionCalled = false;
+
+    mockery.registerMock('./cdp.js', {
+      getConnection: function(...args) {
+        return Promise.resolve();
+      },
+      resetConnection: function() {
+        resetConnectionCalled = true;
+      }
+    });
+
+    Mocks
+      .createNewW3CSession({
+        persist: true,
+        browserName: 'chrome',
+        postdata: {
+          capabilities: {firstMatch: [{}], alwaysMatch: {browserName: 'chrome', 'goog:chromeOptions': {}}}
+        }
+      })
+      .navigateTo({url: 'http://localhost', persist: true});
+
+   
+
+    const globals = {
+      calls: 0,
+      waitForConditionPollInterval: 50,
+      waitForConditionTimeout: 120,
+      retryAssertionTimeout: 1000,
+
+
+      reporter(results) {
+        assert.strictEqual(resetConnectionCalled, true);
+        if (results.lastError) {
+          throw results.lastError;
+        }
+      }
+    };
+
+    return NightwatchClient.runTests(testsPath, settings({
+      desiredCapabilities: {
+        browserName: 'chrome'
+      },
+      selenium_host: null,
+      output: false,
+      skip_testcases_on_fail: false,
+      globals
+    }));
+  });
+
+  
+});
diff --git a/test/src/cli/testServiceCreationFromCli.js b/test/src/cli/testServiceCreationFromCli.js
index 5e939f20..b23c9ab0 100644
--- a/test/src/cli/testServiceCreationFromCli.js
+++ b/test/src/cli/testServiceCreationFromCli.js
@@ -54,7 +54,7 @@ describe('Service creation from cli.js', function() {
     mockery.registerMock('./options.js', MockOptions);
   }
 
-  it('test appium server startup', function() {
+  xit('test appium server startup', function() {
     let appiumPathQueried = false;
     mockOptions({
       onAppiumPathQuery() {
diff --git a/test/src/core/testCreateSession.js b/test/src/core/testCreateSession.js
index 815471e4..6c4737c4 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');
 
@@ -198,7 +199,7 @@ describe('test Request With Credentials', function () {
       sessionId: '1352110219202',
       capabilities: {browserName: 'chrome', version: 'TEST', platform: 'TEST'}
     });
-    assert.deepStrictEqual(sessionOptions.get('goog:chromeOptions'), {args: ['headless=chrome']});
+    assert.deepStrictEqual(sessionOptions.get('goog:chromeOptions'), {args: ['headless=new']});
   });
 
   it('Test create session with headless mode in Edge', async function () {
@@ -287,7 +288,7 @@ describe('test Request With Credentials', function () {
     assert.deepStrictEqual(sessionOptions.get('goog:chromeOptions'), {
       args: [
         '--no-sandbox',
-        'headless=chrome'
+        'headless=new'
       ]
     });
   });
@@ -583,7 +584,7 @@ describe('test Request With Credentials', function () {
     });
   });
 
-  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 +593,7 @@ describe('test Request With Credentials', function () {
           capabilities: {
             firstMatch: [{}],
             alwaysMatch: {
+              'appium:app': 'bs://878bdf21505f0004ce',
               'bstack:options': {
                 local: 'false',
                 userName: 'test_user',
@@ -608,11 +610,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 +669,8 @@ describe('test Request With Credentials', function () {
         browserName: null,
         chromeOptions: {
           w3c: false
-        }
+        },
+        appUploadUrl: 'https://some_host.com/app.apk'
       },
 
       parallel: false
@@ -671,20 +685,139 @@ 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');
+  });
+
+  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);
   });
 
   it('Test create session with browserstack and when buildName is not set', async function () {
diff --git a/test/src/core/testPageObjectWaitForElementNotPresent.js b/test/src/core/testPageObjectWaitForElementNotPresent.js
new file mode 100644
index 00000000..40351e1c
--- /dev/null
+++ b/test/src/core/testPageObjectWaitForElementNotPresent.js
@@ -0,0 +1,33 @@
+const assert = require('assert');
+const path = require('path');
+const Nocks = require('../../lib/nocks.js');
+const Nightwatch = require('../../lib/nightwatch.js');
+
+describe('test PageObject WaitForElementNotPresent', function () {
+  beforeEach(function (done) {
+    Nocks.enable().cleanAll().createSession();
+    Nightwatch.init({
+      page_objects_path: path.join(__dirname, '../../extra/pageobjects/pages')
+    }, function () {
+      done();
+    });
+    this.client = Nightwatch.client();
+  });
+
+  afterEach(function () {
+    Nocks.deleteSession().disable();
+  });
+
+  it('WaitForElementNotPresent with section', function(done) {
+    Nocks.elementFound().click().elementFound().childElementsFound('#badElement').elementFound().childElementsNotFound()
+
+    const page = this.client.api.page.waitForElementNotPresentPageObj();
+
+    page.waitForElementNotPresentDemo(function(result) {
+      assert.equal(result.status, 0);
+      done();
+    })
+
+    this.client.start()
+  });
+});
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/testChromeOptions.js b/test/src/index/transport/testChromeOptions.js
index b1a82695..d61856c8 100644
--- a/test/src/index/transport/testChromeOptions.js
+++ b/test/src/index/transport/testChromeOptions.js
@@ -108,7 +108,7 @@ describe('Test chrome options', function () {
     const options = client.transport.createSessionOptions({headless: true});
 
     assert.strictEqual(options instanceof ChromeOptions, true);
-    assert.deepStrictEqual(options.options_.args, ['headless=chrome']);
+    assert.deepStrictEqual(options.options_.args, ['headless=new']);
   });
 
   it('devtools cli arg', function(){
diff --git a/test/src/runner/testRunWithExternalGlobals.js b/test/src/runner/testRunWithExternalGlobals.js
index e4398a92..07352724 100644
--- a/test/src/runner/testRunWithExternalGlobals.js
+++ b/test/src/runner/testRunWithExternalGlobals.js
@@ -39,5 +39,16 @@ describe('testRunWithExternalGlobals', function() {
     }));
   });
 
+  it('testRun with async external globals', function() {
+    let testsPath = path.join(__dirname, '../../sampletests/before-after/sampleSingleTest.js');
+    const globals = {
+      reporterCount: 0
+    };
 
+    return runTests(testsPath, settings({
+      globals,
+      globals_path: path.join(__dirname, '../../extra/external-globals-async.js'),
+      output_folder: false
+    }));
+  });
 });