Repository: codeceptjs/CodeceptJS (js)

Issue:
[Appium] Can't interactive with the page element of webview in Android application
I am running `Codecept` version `3.0.7`, and running the hybrid Android application, facing that Codecept can switch into Webview, the problems are:
1. Some page elements of the webview, codecept can recognize, but some can't recognize.
2. The page element of the webview can be recognized by codecept, but can't interactive, for example, I would like to click the element, the action is performed but nothing happens in the application (should something happen, for example, navigate to a new page, trigger the input field, etc.)
3. perform the click action by `I.click()` is failed, it is passes if `I.tap()`, why?

![2021-05-19 20 45 50](https://user-images.githubusercontent.com/6060224/118815973-5307b380-b8e4-11eb-8b6e-4f13a6f84a54.jpg)

When perform `click` action by `I.click()` in the webview, will be error, and the error message: 
> ERROR webdriver: Request failed with status 404 due to unknown command: unknown command: Cannot call non W3C standard command while in W3C mode
> unknown command: unknown command: Cannot call non W3C standard command while in W3C mode

![2021-05-19 20 45 44](https://user-images.githubusercontent.com/6060224/118816009-5b5fee80-b8e4-11eb-9011-c69ae9b38682.jpg)


And have a strange problem, as the above scanrio, `I.click()` action is failed in the webview, expect the test case will be failed, but finally codecept says the test is passed! Why? I don't understand.

![2021-05-19 20 45 54](https://user-images.githubusercontent.com/6060224/118815926-484d1e80-b8e4-11eb-9a4b-202c8d42f523.jpg)




Could you help to provide some solutions to my problems?

Thanks.




Pull request description:
use W3C standards when interfacing with appium.
It may still be broken when interfacing Ios, because I have no idea what
IOS platform name is

## Motivation/Description of the PR
- Description of this PR, which problem it solves
- Resolves #2904 (if applicable).

Applicable helpers:

- [x] WebDriver
- [ ] Puppeteer
- [ ] Nightmare
- [ ] REST
- [ ] FileHelper
- [ ] Appium
- [ ] Protractor
- [ ] TestCafe
- [ ] Playwright

Applicable plugins:

- [ ] allure
- [ ] autoDelay
- [ ] autoLogin
- [ ] customLocator
- [ ] pauseOnFail
- [ ] coverage
- [ ] retryFailedStep
- [ ] screenshotOnFail
- [ ] selenoid
- [ ] stepByStepReport
- [ ] stepTimeout
- [ ] wdio
- [ ] subtitles

## Type of change

- [ ] :fire: Breaking changes
- [ ] :rocket: New functionality
- [x] :bug: Bug fix
- [ ] :clipboard: Documentation changes/updates
- [ ] :hotsprings: Hot fix
- [ ] :hammer: Markdown files fix - not related to source code
- [ ] :nail_care: Polish code

## Checklist:

- [ ] Tests have been added
- [ ] Documentation has been added (Run `npm run docs`)
- [ ] Lint checking (Run `npm run lint`)
- [ ] Local tests are passed (Run `npm test`)


Patch:
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 8c31960b..27533860 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -16,7 +16,7 @@ env:
 jobs:
   build:
 
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-20.04
 
     strategy:
       matrix:
@@ -28,7 +28,6 @@ jobs:
       uses: actions/setup-node@v1
       with:
         node-version: ${{ matrix.node-version }}
-    - uses: microsoft/playwright-github-action@v1
     - uses: shivammathur/setup-php@v2
       with:
         php-version: 7.4
@@ -37,6 +36,8 @@ jobs:
         npm install
       env:
         PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
+    - name: Install deps
+      run: npx playwright install-deps        
     - name: start a server
       run: "php -S 127.0.0.1:8000 -t test/data/app &"
     - name: run chromium tests
diff --git a/docs/basics.md b/docs/basics.md
index 9fca12d4..3d8757f8 100644
--- a/docs/basics.md
+++ b/docs/basics.md
@@ -212,6 +212,8 @@ To fill in sensitive data use the `secret` function, it won't expose actual valu
 I.fillField('password', secret('123456'));
 ```
 
+> ℹ️ Learn more about [masking secret](/secrets/) output
+
 ### Assertions
 
 In order to verify the expected behavior of a web application, its content should be checked.
diff --git a/docs/changelog.md b/docs/changelog.md
index 7fd1ade0..82203574 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,6 +7,12 @@ layout: Section
 
 # Releases
 
+## 3.3.3
+
+* Fixed `DataCloneError: () => could not be cloned` when running data tests in run-workers
+* 🇺🇦 Added #StandWithUkraine notice to CLI
+
+
 ## 3.3.2
 
 * **[REST]** Fixed override of headers/token in `haveRequestHeaders()` and `amBearerAuthenticated()`. See [#3304](https://github.com/codeceptjs/CodeceptJS/issues/3304) by **[mirao](https://github.com/mirao)**
@@ -50,8 +56,6 @@ npx codeceptjs run test-dir/*"
 * Improving the typings in many places
 * Improving the return type of helpers for TS users ([#3245](https://github.com/codeceptjs/CodeceptJS/issues/3245)) - **[nlespiaucq](https://github.com/nlespiaucq)**
 
-🇺🇦 Added #StandWithUkraine hashtag notice to CLI
-
 ## 3.3.0
 
 🛩️ Features:
diff --git a/docs/helpers/Playwright.md b/docs/helpers/Playwright.md
index e7e58aba..0260eebe 100644
--- a/docs/helpers/Playwright.md
+++ b/docs/helpers/Playwright.md
@@ -131,7 +131,8 @@ Traces will be saved to `output/trace`
      Playwright: {
        url: "http://localhost",
        chromium: {
-         browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a'
+         browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a',
+         cdpConnection: false // default is false
        }
      }
    }
@@ -811,10 +812,13 @@ Returns **[Promise][18]&lt;[Array][19]&lt;[string][12]>>** attribute value
 Get JS log from browser.
 
 ```js
-let logs = await I.grabBrowserLogs();
-console.log(JSON.stringify(logs))
+const logs = await I.grabBrowserLogs();
+const errors = logs.map(l => ({ type: l.type(), text: l.text() })).filter(l => l.type === 'error');
+console.log(JSON.stringify(errors));
 ```
 
+[Learn more about console messages][20]
+
 Returns **[Promise][18]&lt;[Array][19]&lt;any>>** 
 
 ### grabCookie
@@ -1102,7 +1106,7 @@ Returns **[Promise][18]&lt;[Array][19]&lt;[string][12]>>** attribute value
 Handles a file download.Aa file name is required to save the file on disk.
 Files are saved to "output" directory.
 
-Should be used with [FileSystem helper][20] to check that file were downloaded correctly.
+Should be used with [FileSystem helper][21] to check that file were downloaded correctly.
 
 ```js
 I.handleDownloads('downloads/avatar.jpg');
@@ -1133,7 +1137,7 @@ I.haveRequestHeaders({
 
 ### makeApiRequest
 
-Performs [api request][21] using
+Performs [api request][22] using
 the cookies from the current browser session.
 
 ```js
@@ -1154,17 +1158,17 @@ Returns **[Promise][18]&lt;[object][10]>** response
 
 ### mockRoute
 
-Mocks network request using [`browserContext.route`][22] of Playwright
+Mocks network request using [`browserContext.route`][23] of Playwright
 
 ```js
 I.mockRoute(/(.png$)|(.jpg$)/, route => route.abort());
 ```
 
-This method allows intercepting and mocking requests & responses. [Learn more about it][23]
+This method allows intercepting and mocking requests & responses. [Learn more about it][24]
 
 #### Parameters
 
--   `url` **([string][12] | [RegExp][24])?** URL, regex or pattern for to match URL
+-   `url` **([string][12] | [RegExp][25])?** URL, regex or pattern for to match URL
 -   `handler` **[function][17]?** a function to process reques
 
 ### moveCursorTo
@@ -1192,7 +1196,7 @@ Open new tab and automatically switched to new tab
 I.openNewTab();
 ```
 
-You can pass in [page options][25] to emulate device on this page
+You can pass in [page options][26] to emulate device on this page
 
 ```js
 // enable mobile
@@ -1207,7 +1211,7 @@ I.openNewTab({ isMobile: true });
 
 Presses a key in the browser (on a focused element).
 
-_Hint:_ For populating text field or textarea, it is recommended to use [`fillField`][26].
+_Hint:_ For populating text field or textarea, it is recommended to use [`fillField`][27].
 
 ```js
 I.pressKey('Backspace');
@@ -1267,13 +1271,13 @@ Some of the supported key names are:
 #### Parameters
 
 -   `key` **([string][12] | [Array][19]&lt;[string][12]>)** key or array of keys to press.
-    [!] returns a _promise_ which is synchronized internally by recorder_Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/Puppeteer#1313][27]).
+    [!] returns a _promise_ which is synchronized internally by recorder_Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/Puppeteer#1313][28]).
 
 ### pressKeyDown
 
 Presses a key in the browser and leaves it in a down state.
 
-To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`][28]).
+To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`][29]).
 
 ```js
 I.pressKeyDown('Control');
@@ -1290,7 +1294,7 @@ I.pressKeyUp('Control');
 
 Releases a key in the browser which was previously set to a down state.
 
-To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`][28]).
+To make combinations with modifier key and user operation (e.g. `'Control'` + [`click`][29]).
 
 ```js
 I.pressKeyDown('Control');
@@ -1378,7 +1382,7 @@ I.saveScreenshot('debug.png', true) //resizes to available scrollHeight and scro
 #### Parameters
 
 -   `fileName` **[string][12]** file name to save.
--   `fullPage` **[boolean][29]** (optional, `false` by default) flag to enable fullscreen screenshot mode.
+-   `fullPage` **[boolean][30]** (optional, `false` by default) flag to enable fullscreen screenshot mode.
     [!] returns a _promise_ which is synchronized internally by recorder 
 
 ### scrollPageToBottom
@@ -1724,7 +1728,7 @@ If no handler is passed, all mock requests for the rote are disabled.
 
 #### Parameters
 
--   `url` **([string][12] | [RegExp][24])?** URL, regex or pattern for to match URL
+-   `url` **([string][12] | [RegExp][25])?** URL, regex or pattern for to match URL
 -   `handler` **[function][17]?** a function to process reques
 
 ### switchTo
@@ -1771,7 +1775,7 @@ I.switchToPreviousTab(2);
 
 Types out the given text into an active field.
 To slow down typing use a second parameter, to set interval between key presses.
-_Note:_ Should be used when [`fillField`][26] is not an option.
+_Note:_ Should be used when [`fillField`][27] is not an option.
 
 ```js
 // passing in a string
@@ -1808,7 +1812,7 @@ I.uncheckOption('agree', '//form');
 
 -   `field` **([string][12] | [object][10])** checkbox located by label | name | CSS | XPath | strict locator.
 -   `context` **([string][12]? | [object][10])** (optional, `null` by default) element located by CSS | XPath | strict locator.
-    [!] returns a _promise_ which is synchronized internally by recorder[Additional options][30] for uncheck available as 3rd argument.Examples:```js
+    [!] returns a _promise_ which is synchronized internally by recorder[Additional options][31] for uncheck available as 3rd argument.Examples:```js
     // click on element at position
     I.uncheckOption('Agree', '.signup', { position: { x: 5, y: 5 } })
     ```> ⚠️ To avoid flakiness, option `force: true` is set by default 
@@ -1821,7 +1825,7 @@ Use Playwright API inside a test.
 First argument is a description of an action.
 Second argument is async function that gets this helper as parameter.
 
-{ [`page`][31], [`browserContext`][32] [`browser`][33] } objects from Playwright API are available.
+{ [`page`][32], [`browserContext`][33] [`browser`][34] } objects from Playwright API are available.
 
 ```js
 I.usePlaywrightTo('emulate offline mode', async ({ browserContext }) => {
@@ -1947,7 +1951,7 @@ I.waitForInvisible('#popup');
 
 Waits for navigation to finish. By default takes configured `waitForNavigation` option.
 
-See [Playwright's reference][34]
+See [Playwright's reference][35]
 
 #### Parameters
 
@@ -2027,7 +2031,7 @@ I.waitForVisible('#popup');
 
 -   `locator` **([string][12] | [object][10])** element located by CSS|XPath|strict locator.
 -   `sec` **[number][16]** (optional, `1` by default) time in seconds to wait
-    [!] returns a _promise_ which is synchronized internally by recorderThis method accepts [React selectors][35]. 
+    [!] returns a _promise_ which is synchronized internally by recorderThis method accepts [React selectors][36]. 
 
 ### waitInUrl
 
@@ -2126,34 +2130,36 @@ I.waitUrlEquals('http://127.0.0.1:8000/info');
 
 [19]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
 
-[20]: https://codecept.io/helpers/FileSystem
+[20]: https://playwright.dev/docs/api/class-consolemessage
+
+[21]: https://codecept.io/helpers/FileSystem
 
-[21]: https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get
+[22]: https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-get
 
-[22]: https://playwright.dev/docs/api/class-browsercontext#browser-context-route
+[23]: https://playwright.dev/docs/api/class-browsercontext#browser-context-route
 
-[23]: https://playwright.dev/docs/network#handle-requests
+[24]: https://playwright.dev/docs/network#handle-requests
 
-[24]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp
+[25]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/RegExp
 
-[25]: https://github.com/microsoft/playwright/blob/main/docs/api.md#browsernewpageoptions
+[26]: https://github.com/microsoft/playwright/blob/main/docs/api.md#browsernewpageoptions
 
-[26]: #fillfield
+[27]: #fillfield
 
-[27]: https://github.com/GoogleChrome/puppeteer/issues/1313
+[28]: https://github.com/GoogleChrome/puppeteer/issues/1313
 
-[28]: #click
+[29]: #click
 
-[29]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
+[30]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
 
-[30]: https://playwright.dev/docs/api/class-elementhandle#element-handle-uncheck
+[31]: https://playwright.dev/docs/api/class-elementhandle#element-handle-uncheck
 
-[31]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-page.md
+[32]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-page.md
 
-[32]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browsercontext.md
+[33]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browsercontext.md
 
-[33]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browser.md
+[34]: https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browser.md
 
-[34]: https://playwright.dev/docs/api/class-page?_highlight=waitfornavi#pagewaitfornavigationoptions
+[35]: https://playwright.dev/docs/api/class-page?_highlight=waitfornavi#pagewaitfornavigationoptions
 
-[35]: https://codecept.io/react
+[36]: https://codecept.io/react
diff --git a/docs/plugins.md b/docs/plugins.md
index 8848b6e5..285436d2 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -1,10 +1,3 @@
----
-permalink: plugins
-sidebarDepth: 
-sidebar: auto
-title: Plugins
----
-
 <!-- Generated by documentation.js. Update this documentation by updating the source code. -->
 
 ## allure
@@ -512,6 +505,46 @@ I.seeElement('=user'); // matches => [data-qa=user]
 I.click('=sign-up'); // matches => [data-qa=sign-up]
 ```
 
+Using `data-qa` OR `data-test` attribute with `=` prefix:
+
+```js
+// in codecept.conf.js
+plugins: {
+ customLocator: {
+   enabled: true,
+   prefix: '=',
+   attribute: ['data-qa', 'data-test'],
+   strategy: 'xpath'
+ }
+}
+```
+
+In a test:
+
+```js
+I.seeElement('=user'); // matches => //*[@data-qa=user or @data-test=user]
+I.click('=sign-up'); // matches => //*[data-qa=sign-up or @data-test=sign-up]
+```
+
+```js
+// in codecept.conf.js
+plugins: {
+ customLocator: {
+   enabled: true,
+   prefix: '=',
+   attribute: ['data-qa', 'data-test'],
+   strategy: 'css'
+ }
+}
+```
+
+In a test:
+
+```js
+I.seeElement('=user'); // matches => [data-qa=user],[data-test=user]
+I.click('=sign-up'); // matches => [data-qa=sign-up],[data-test=sign-up]
+```
+
 ### Parameters
 
 -   `config`  
diff --git a/docs/secrets.md b/docs/secrets.md
new file mode 100644
index 00000000..3efaecec
--- /dev/null
+++ b/docs/secrets.md
@@ -0,0 +1,30 @@
+# Secrets
+
+It is possible to mask out sensitive data when passing it to steps. This is important when filling password fields, or sending secure keys to API endpoint. 
+
+Wrap data in `secret` function to mask sensitive values in output and logs.
+
+For basic string `secret` just wrap a value into a string:
+
+```js
+I.fillField('password', secret('123456'));
+```
+
+When executed it will be printed like this:
+
+```
+I fill field "password" "*****"
+```
+
+For an object, which can be a payload to POST request, specify which fields should be masked:
+
+```js
+I.sendPostRequest('/login', secret({
+  name: 'davert',
+  password: '123456'
+}, 'password'))
+```
+
+The object created from `secret` is as Proxy to the object passed in. When printed password will be replaced with ****. 
+
+> ⚠️ Currently, properties of nested objects can't be masked via `secret`
\ No newline at end of file
diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js
index 5ec97818..9b0d1158 100644
--- a/lib/helper/Playwright.js
+++ b/lib/helper/Playwright.js
@@ -167,7 +167,8 @@ const { createValueEngine, createDisabledEngine } = require('./extras/Playwright
  *      Playwright: {
  *        url: "http://localhost",
  *        chromium: {
- *          browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a'
+ *          browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a',
+ *          cdpConnection: false // default is false
  *        }
  *      }
  *    }
@@ -272,6 +273,7 @@ class Playwright extends Helper {
     this.sessionPages = {};
     this.activeSessionName = '';
     this.isElectron = false;
+    this.isCDPConnection = false;
     this.electronSessions = [];
     this.storageState = null;
 
@@ -347,6 +349,7 @@ class Playwright extends Helper {
     this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint;
     this.isElectron = this.options.browser === 'electron';
     this.userDataDir = this.playwrightOptions.userDataDir;
+    this.isCDPConnection = this.playwrightOptions.cdpConnection;
     popupStore.defaultAction = this.options.defaultPopupAction;
   }
 
@@ -692,6 +695,15 @@ class Playwright extends Helper {
   async _startBrowser() {
     if (this.isElectron) {
       this.browser = await playwright._electron.launch(this.playwrightOptions);
+    } else if (this.isRemoteBrowser && this.isCDPConnection) {
+      try {
+        this.browser = await playwright[this.options.browser].connectOverCDP(this.playwrightOptions);
+      } catch (err) {
+        if (err.toString().indexOf('ECONNREFUSED')) {
+          throw new RemoteBrowserConnectionRefused(err);
+        }
+        throw err;
+      }
     } else if (this.isRemoteBrowser) {
       try {
         this.browser = await playwright[this.options.browser].connect(this.playwrightOptions);
@@ -1054,6 +1066,7 @@ class Playwright extends Helper {
     if (!page) {
       throw new Error(`There is no ability to switch to next tab with offset ${num}`);
     }
+    targetCreatedHandler.call(this, page);
     await this._setPage(page);
     return this._waitForAction();
   }
@@ -1136,7 +1149,9 @@ class Playwright extends Helper {
     if (this.isElectron) {
       throw new Error('Cannot open new tabs inside an Electron container');
     }
-    await this._setPage(await this.browserContext.newPage(options));
+    const page = await this.browserContext.newPage(options);
+    targetCreatedHandler.call(this, page);
+    await this._setPage(page);
     return this._waitForAction();
   }
 
@@ -1558,9 +1573,11 @@ class Playwright extends Helper {
    * Get JS log from browser.
    *
    * ```js
-   * let logs = await I.grabBrowserLogs();
-   * console.log(JSON.stringify(logs))
+   * const logs = await I.grabBrowserLogs();
+   * const errors = logs.map(l => ({ type: l.type(), text: l.text() })).filter(l => l.type === 'error');
+   * console.log(JSON.stringify(errors));
    * ```
+   * [Learn more about console messages](https://playwright.dev/docs/api/class-consolemessage)
    * @return {Promise<any[]>}
    */
   async grabBrowserLogs() {
diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js
index 54cad726..f7d91946 100644
--- a/lib/helper/WebDriver.js
+++ b/lib/helper/WebDriver.js
@@ -907,7 +907,7 @@ class WebDriver extends Helper {
    * {{ react }}
    */
   async click(locator, context = null) {
-    const clickMethod = this.browser.isMobile ? 'touchClick' : 'elementClick';
+    const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick';
     const locateFn = prepareLocateFn.call(this, context);
 
     const res = await findClickable.call(this, locator, locateFn);
@@ -1117,7 +1117,7 @@ class WebDriver extends Helper {
    * Appium: not tested
    */
   async checkOption(field, context = null) {
-    const clickMethod = this.browser.isMobile ? 'touchClick' : 'elementClick';
+    const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick';
     const locateFn = prepareLocateFn.call(this, context);
 
     const res = await findCheckable.call(this, field, locateFn);
@@ -1136,7 +1136,7 @@ class WebDriver extends Helper {
    * Appium: not tested
    */
   async uncheckOption(field, context = null) {
-    const clickMethod = this.browser.isMobile ? 'touchClick' : 'elementClick';
+    const clickMethod = this.browser.isMobile && this.browser.capabilities.platformName !== 'android' ? 'touchClick' : 'elementClick';
     const locateFn = prepareLocateFn.call(this, context);
 
     const res = await findCheckable.call(this, field, locateFn);
@@ -1623,7 +1623,7 @@ class WebDriver extends Helper {
       assertElementExists(res);
       const elem = usingFirstElement(res);
       const elementId = getElementId(elem);
-      if (this.browser.isMobile) return this.browser.touchScroll(offsetX, offsetY, elementId);
+      if (this.browser.isMobile && this.browser.capabilities.platformName !== 'android') return this.browser.touchScroll(offsetX, offsetY, elementId);
       const location = await elem.getLocation();
       assertElementExists(location, 'Failed to receive', 'location');
       /* eslint-disable prefer-arrow-callback */
@@ -1631,7 +1631,7 @@ class WebDriver extends Helper {
       /* eslint-enable */
     }
 
-    if (this.browser.isMobile) return this.browser.touchScroll(locator, offsetX, offsetY);
+    if (this.browser.isMobile && this.browser.capabilities.platformName !== 'android') return this.browser.touchScroll(locator, offsetX, offsetY);
 
     /* eslint-disable prefer-arrow-callback, comma-dangle */
     return this.browser.execute(function (x, y) { return window.scrollTo(x, y); }, offsetX, offsetY);
diff --git a/lib/plugin/customLocator.js b/lib/plugin/customLocator.js
index 00f00341..acc81239 100644
--- a/lib/plugin/customLocator.js
+++ b/lib/plugin/customLocator.js
@@ -70,23 +70,70 @@ const defaultConfig = {
  * I.seeElement('=user'); // matches => [data-qa=user]
  * I.click('=sign-up'); // matches => [data-qa=sign-up]
  * ```
+ *
+ * Using `data-qa` OR `data-test` attribute with `=` prefix:
+ *
+ * ```js
+ * // in codecept.conf.js
+ * plugins: {
+ *  customLocator: {
+ *    enabled: true,
+ *    prefix: '=',
+ *    attribute: ['data-qa', 'data-test'],
+ *    strategy: 'xpath'
+ *  }
+ * }
+ * ```
+ *
+ * In a test:
+ *
+ * ```js
+ * I.seeElement('=user'); // matches => //*[@data-qa=user or @data-test=user]
+ * I.click('=sign-up'); // matches => //*[data-qa=sign-up or @data-test=sign-up]
+ * ```
+ *
+ * ```js
+ * // in codecept.conf.js
+ * plugins: {
+ *  customLocator: {
+ *    enabled: true,
+ *    prefix: '=',
+ *    attribute: ['data-qa', 'data-test'],
+ *    strategy: 'css'
+ *  }
+ * }
+ * ```
+ *
+ * In a test:
+ *
+ * ```js
+ * I.seeElement('=user'); // matches => [data-qa=user],[data-test=user]
+ * I.click('=sign-up'); // matches => [data-qa=sign-up],[data-test=sign-up]
+ * ```
  */
 module.exports = (config) => {
-  config = Object.assign(defaultConfig, config);
+  config = { ...defaultConfig, ...config };
 
   Locator.addFilter((value, locatorObj) => {
     if (typeof value !== 'string') return;
     if (!value.startsWith(config.prefix)) return;
 
+    if (!['String', 'Array'].includes(config.attribute.constructor.name)) return;
+
     const val = value.substr(config.prefix.length);
 
     if (config.strategy.toLowerCase() === 'xpath') {
-      locatorObj.value = `.//*[@${config.attribute}=${xpathLocator.literal(val)}]`;
+      locatorObj.value = `.//*[${
+        [].concat(config.attribute)
+          .map((attr) => `@${attr}=${xpathLocator.literal(val)}`)
+          .join(' or ')}]`;
       locatorObj.type = 'xpath';
     }
 
     if (config.strategy.toLowerCase() === 'css') {
-      locatorObj.value = `[${config.attribute}=${val}]`;
+      locatorObj.value = [].concat(config.attribute)
+        .map((attr) => `[${attr}=${val}]`)
+        .join(',');
       locatorObj.type = 'css';
     }
 
diff --git a/lib/plugin/retryTo.js b/lib/plugin/retryTo.js
index 86b9b9dc..f35237fc 100644
--- a/lib/plugin/retryTo.js
+++ b/lib/plugin/retryTo.js
@@ -101,27 +101,20 @@ module.exports = function (config) {
           err = e;
           recorder.session.restore(`retryTo ${tries}`);
           tries++;
-          // recorder.session.restore(`retryTo`);
           if (tries <= maxTries) {
             debug(`Error ${err}... Retrying`);
             err = null;
 
-            recorder.add(`retryTo ${tries}`, () => {
-              tryBlock();
-              // recorder.add(() => new Promise(done => setTimeout(done, pollInterval)));
-            });
+            recorder.add(`retryTo ${tries}`, () => setTimeout(tryBlock, pollInterval));
           } else {
-            // recorder.throw(err);
             done(null);
           }
         });
-        // return recorder.promise();
       };
 
       recorder.add('retryTo', async () => {
         store.debugMode = true;
         tryBlock();
-        // recorder.add(() => recorder.session.restore(`retryTo ${tries-1}`));
       });
     }).then(() => {
       if (err) recorder.throw(err);
diff --git a/lib/secret.js b/lib/secret.js
index 4567df3e..9613816a 100644
--- a/lib/secret.js
+++ b/lib/secret.js
@@ -1,3 +1,6 @@
+/* eslint-disable max-classes-per-file */
+const { deepClone } = require('./utils');
+
 /** @param {string} secret */
 class Secret {
   constructor(secret) {
@@ -9,13 +12,40 @@ class Secret {
     return this._secret;
   }
 
+  getMasked() {
+    return '*****';
+  }
+
   /**
    * @param {*} secret
    * @returns {Secret}
    */
   static secret(secret) {
+    if (typeof secret === 'object') {
+      const fields = Array.from(arguments);
+      fields.shift();
+      return secretObject(secret, fields);
+    }
     return new Secret(secret);
   }
 }
 
+function secretObject(obj, fieldsToHide = []) {
+  const handler = {
+    get(obj, prop) {
+      if (prop === 'toString') {
+        return function () {
+          const maskedObject = deepClone(obj);
+          fieldsToHide.forEach(f => maskedObject[f] = '****');
+          return JSON.stringify(maskedObject);
+        };
+      }
+
+      return obj[prop];
+    },
+  };
+
+  return new Proxy(obj, handler);
+}
+
 module.exports = Secret;
diff --git a/lib/step.js b/lib/step.js
index 490a00e3..bec8cabb 100644
--- a/lib/step.js
+++ b/lib/step.js
@@ -168,7 +168,7 @@ class Step {
       } else if (typeof arg === 'undefined') {
         return `${arg}`;
       } else if (arg instanceof Secret) {
-        return '*****';
+        return arg.getMasked();
       } else if (arg.toString && arg.toString() !== '[object Object]') {
         return arg.toString();
       } else if (typeof arg === 'object') {
diff --git a/package.json b/package.json
index b5f69e53..0a2a9c32 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
     "uuid": "^8.3.2"
   },
   "devDependencies": {
-    "@codeceptjs/detox-helper": "^1.0.2",
+    "@codeceptjs/detox-helper": "^1.0.2",    
     "@codeceptjs/mock-request": "^0.3.1",
     "@faker-js/faker": "^5.5.3",
     "@pollyjs/adapter-puppeteer": "^5.1.0",
@@ -101,7 +101,7 @@
     "@wdio/selenium-standalone-service": "^5.16.10",
     "@wdio/utils": "^5.23.0",
     "apollo-server-express": "^2.25.3",
-    "chai-as-promised": "^5.2.0",
+    "chai-as-promised": "^7.1.1",
     "chai-subset": "^1.6.0",
     "contributor-faces": "^1.0.3",
     "documentation": "^12.3.0",
@@ -122,7 +122,7 @@
     "mocha-parallel-tests": "^2.3.0",
     "nightmare": "^3.0.2",
     "nodemon": "^1.19.4",
-    "playwright": "^1.18.1",
+    "playwright": "^1.23.2",
     "puppeteer": "^10.4.0",
     "qrcode-terminal": "^0.12.0",
     "rosie": "^1.6.0",


Test patch:
diff --git a/.github/workflows/testcafe.yml b/.github/workflows/testcafe.yml
index 167e4aef..4c31a992 100644
--- a/.github/workflows/testcafe.yml
+++ b/.github/workflows/testcafe.yml
@@ -21,7 +21,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v1
diff --git a/test/acceptance/codecept.Playwright.js b/test/acceptance/codecept.Playwright.js
index 1ca7f1b2..ce874776 100644
--- a/test/acceptance/codecept.Playwright.js
+++ b/test/acceptance/codecept.Playwright.js
@@ -11,6 +11,10 @@ module.exports.config = {
       show: false,
       restart: process.env.BROWSER_RESTART || false,
       browser: process.env.BROWSER || 'chromium',
+      ignoreHTTPSErrors: true,
+      webkit: {
+        ignoreHTTPSErrors: true,
+      },
     },
     JSONResponse: {
       requestHelper: 'Playwright',
diff --git a/test/data/rest/db.json b/test/data/rest/db.json
index 4930c5ac..ad6f29c4 100644
--- a/test/data/rest/db.json
+++ b/test/data/rest/db.json
@@ -1,1 +1,13 @@
-{"posts":[{"id":1,"title":"json-server","author":"davert"}],"user":{"name":"davert"}}
\ No newline at end of file
+{
+  "posts": [
+    {
+      "id": 1,
+      "title": "json-server",
+      "author": "davert"
+    }
+  ],
+  "user": {
+    "name": "john",
+    "password": "123456"
+  }
+}
\ No newline at end of file
diff --git a/test/data/sandbox/codecept.workers-custom-output-folder-name.conf.js b/test/data/sandbox/codecept.workers-custom-output-folder-name.conf.js
index a89ec673..4371177f 100644
--- a/test/data/sandbox/codecept.workers-custom-output-folder-name.conf.js
+++ b/test/data/sandbox/codecept.workers-custom-output-folder-name.conf.js
@@ -9,7 +9,8 @@ exports.config = {
     },
   },
   include: {},
-  bootstrap: {},
+  // eslint-disable-next-line no-empty-function
+  async bootstrap() {},
   mocha: {},
   name: 'sandbox',
 };
diff --git a/test/helper/Playwright_test.js b/test/helper/Playwright_test.js
index c9879790..d2192f18 100644
--- a/test/helper/Playwright_test.js
+++ b/test/helper/Playwright_test.js
@@ -578,6 +578,43 @@ describe('Playwright', function () {
         const matchingLogs = logs.filter(log => log.text().indexOf('Test log entry') > -1);
         assert.equal(matchingLogs.length, 1);
       }));
+
+    it('should grab browser logs in new tab', () => I.amOnPage('/')
+      .then(() => I.openNewTab())
+      .then(() => I.executeScript(() => {
+        console.log('Test log entry');
+      }))
+      .then(() => I.grabBrowserLogs())
+      .then((logs) => {
+        const matchingLogs = logs.filter(log => log.text().indexOf('Test log entry') > -1);
+        assert.equal(matchingLogs.length, 1);
+      }));
+
+    it('should grab browser logs in two tabs', () => I.amOnPage('/')
+      .then(() => I.executeScript(() => {
+        console.log('Test log entry 1');
+      }))
+      .then(() => I.openNewTab())
+      .then(() => I.executeScript(() => {
+        console.log('Test log entry 2');
+      }))
+      .then(() => I.grabBrowserLogs())
+      .then((logs) => {
+        const matchingLogs = logs.filter(log => log.text().includes('Test log entry'));
+        assert.equal(matchingLogs.length, 2);
+      }));
+
+    it('should grab browser logs in next tab', () => I.amOnPage('/info')
+      .then(() => I.click('New tab'))
+      .then(() => I.switchToNextTab())
+      .then(() => I.executeScript(() => {
+        console.log('Test log entry');
+      }))
+      .then(() => I.grabBrowserLogs())
+      .then((logs) => {
+        const matchingLogs = logs.filter(log => log.text().indexOf('Test log entry') > -1);
+        assert.equal(matchingLogs.length, 1);
+      }));
   });
 
   describe('#dragAndDrop', () => {
diff --git a/test/rest/REST_test.js b/test/rest/REST_test.js
index 0ceca637..c3724a38 100644
--- a/test/rest/REST_test.js
+++ b/test/rest/REST_test.js
@@ -1,6 +1,7 @@
 const path = require('path');
 const fs = require('fs');
 const FormData = require('form-data');
+const secret = require('../../lib/secret').secret;
 
 const TestHelper = require('../support/TestHelper');
 const REST = require('../../lib/helper/REST');
@@ -68,6 +69,14 @@ describe('REST', () => {
       response.data.name.should.eql('john');
     });
 
+    it('should send POST requests with secret', async () => {
+      const secretData = secret({ name: 'john', password: '123456' }, 'password');
+      const response = await I.sendPostRequest('/user', secretData);
+      response.data.name.should.eql('john');
+      response.data.password.should.eql('123456');
+      secretData.toString().should.include('"password":"****"');
+    });
+
     it('should send PUT requests: payload format = json', async () => {
       const putResponse = await I.sendPutRequest('/posts/1', { author: 'john' });
       putResponse.data.author.should.eql('john');
diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js
index 0d3e52c2..80a7d45a 100644
--- a/test/unit/locator_test.js
+++ b/test/unit/locator_test.js
@@ -202,13 +202,13 @@ describe('Locator', () => {
   it('should throw an error when xpath with round brackets is nested', () => {
     expect(() => {
       Locator.build('tr').find('(./td)[@id="id"]');
-    }, /round brackets/).to.be.thrown;
+    }).to.throw('round brackets');
   });
 
   it('should throw an error when locator with specific position is nested', () => {
     expect(() => {
       Locator.build('tr').withChild(Locator.build('td').first());
-    }, /round brackets/).to.be.thrown;
+    }).to.throw('round brackets');
   });
 
   it('should not select element by deep nested siblings', () => {
diff --git a/test/unit/plugin/customLocator_test.js b/test/unit/plugin/customLocator_test.js
index 0234cdbc..98856c3e 100644
--- a/test/unit/plugin/customLocator_test.js
+++ b/test/unit/plugin/customLocator_test.js
@@ -53,4 +53,36 @@ describe('customLocator', () => {
     expect(l.isCSS()).to.be.true;
     expect(l.simplify()).to.eql('[data-test=user]');
   });
+
+  it('add a custom locator with array $ -> data-qa, data-qa-id', () => {
+    customLocatorPlugin({
+      prefix: '$',
+      attribute: ['data-qa', 'data-qa-id'],
+      showActual: true,
+    });
+    const l = new Locator('$user-id');
+    expect(l.isXPath()).to.be.true;
+    expect(l.toXPath()).to.eql('.//*[@data-qa=\'user-id\' or @data-qa-id=\'user-id\']');
+    expect(l.toString()).to.eql('.//*[@data-qa=\'user-id\' or @data-qa-id=\'user-id\']');
+  });
+
+  it('add a custom locator array with CSS', () => {
+    customLocatorPlugin({
+      prefix: '$',
+      attribute: ['data-test', 'data-test-id'],
+      strategy: 'css',
+    });
+    const l = new Locator('$user');
+    expect(l.isCSS()).to.be.true;
+    expect(l.simplify()).to.eql('[data-test=user],[data-test-id=user]');
+  });
+
+  it('should return initial locator value when it does not start with specified prefix', () => {
+    customLocatorPlugin({
+      prefix: '$',
+      attribute: 'data-test',
+    });
+    const l = new Locator('=user');
+    expect(l.simplify()).to.eql('=user');
+  });
 });