Repository: DevExpress/testcafe (js)

Issue:
Inform a user that a click was made on a wrong element
When testcafe clicks an element which is overlapped by the second element, it waits for the first element to appear. If the first element do not appear during selector timeout period, then it clicks the second element.
It would be nice to inform users that a click is made on a wrong element.

```HTML
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title></title>
    <style>
        #parent {
            position: relative;
        }

        #child1 {
            width: 100px;
            height: 100px;
            background-color: blue;
            position: absolute;
            top: 0;
            left: 0;
        }

        #child2 {
            width: 400px;
            height: 400px;
            background-color: red;
            position: absolute;
            top: 0;
            left: 0;
        }
    </style>
</head>
<body>
    <div id="parent"></div>
    <div id="child1"></div>
    <div id="child2"></div>
</body>
</html>
```
```JS
fixture `fixture`
    .page `../pages/index.html`;

test(`test`, async t => {
    await t
        .click('#child1');
});
```

Pull request:
feat: added sending warnings from client
<!--
Thank you for your contribution.

Before making a PR, please read our contributing guidelines at
https://github.com/DevExpress/testcafe/blob/master/CONTRIBUTING.md#code-contribution

We recommend creating a *draft* PR, so that you can mark it as 'ready for review' when you are done.
-->

[closes #2930]

## Purpose
Inform a user that a click was made on a wrong element.

## Approach
1. Analyze the ways to send warnings from the client to the server
2. Add tests
3. Implement events and handlers that send information from client to server
4. Add warnings from the client to WarningLog


## References
https://github.com/DevExpress/testcafe/issues/2930

## Pre-Merge TODO
- [X] Write tests for your proposed changes
- [x] Make sure that existing tests do not fail


Patch:
diff --git a/src/client/automation/types.d.ts b/src/client/automation/types.d.ts
index 2434e0338..dbc69fb51 100644
--- a/src/client/automation/types.d.ts
+++ b/src/client/automation/types.d.ts
@@ -12,6 +12,7 @@ export interface AutomationHandler {
 export interface Automation extends EventEmitter {
     run(strictElementCheck: boolean): Promise<any>;
     TARGET_ELEMENT_FOUND_EVENT?: string;
+    WARNING_EVENT?: string;
 }
 
 export interface CursorUI {
diff --git a/src/client/automation/visible-element-automation.ts b/src/client/automation/visible-element-automation.ts
index bd9649962..daebf3e73 100644
--- a/src/client/automation/visible-element-automation.ts
+++ b/src/client/automation/visible-element-automation.ts
@@ -11,6 +11,7 @@ import Cursor from './cursor/cursor';
 import cursorInstance from './cursor';
 import delay from '../core/utils/delay';
 import SharedEventEmitter from '../core/utils/event-emitter';
+import stringifyElement from '../core/utils/stringify-element';
 
 import {
     MoveOptions,
@@ -25,6 +26,7 @@ import * as positionUtils from '../core/utils/position';
 import ScrollAutomation from '../core/scroll/index';
 import { Dictionary } from '../../configuration/interfaces';
 import ensureMouseEventAfterScroll from './utils/ensure-mouse-event-after-scroll';
+import WARNING_TYPES from '../../shared/warnings/types';
 
 
 interface ElementStateArgsBase {
@@ -90,6 +92,7 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
     public window: Window;
     public cursor: Cursor;
     private readonly TARGET_ELEMENT_FOUND_EVENT: string;
+    private readonly WARNING_EVENT: string;
     protected automationSettings: AutomationSettings;
     private readonly options: OffsetOptions;
 
@@ -97,6 +100,7 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
         super();
 
         this.TARGET_ELEMENT_FOUND_EVENT = 'automation|target-element-found-event';
+        this.WARNING_EVENT              = 'automation|warning-event';
 
         this.element            = element;
         this.options            = offsetOptions;
@@ -235,7 +239,19 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
                 return state;
             })
             .then(state => {
-                this.emit(this.TARGET_ELEMENT_FOUND_EVENT, { element: state?.element || null });
+                const element = state?.element;
+
+                this.emit(this.TARGET_ELEMENT_FOUND_EVENT, { element: element || null });
+
+                if ( !useStrictElementCheck && element && !state.isTarget) {
+                    const expectedElementStr = stringifyElement(this.element);
+                    const actualElementStr   = stringifyElement(element);
+
+                    this.emit(this.WARNING_EVENT, {
+                        type: WARNING_TYPES.elementOverlapped,
+                        args: [expectedElementStr, actualElementStr],
+                    });
+                }
 
                 return {
                     element:     state?.element || null,
diff --git a/src/client/core/index.js b/src/client/core/index.js
index 3c199e104..1393374e2 100644
--- a/src/client/core/index.js
+++ b/src/client/core/index.js
@@ -28,6 +28,7 @@ import getKeyArray from './utils/get-key-array';
 import getSanitizedKey from './utils/get-sanitized-key';
 import parseKeySequence from './utils/parse-key-sequence';
 import sendRequestToFrame from './utils/send-request-to-frame';
+import stringifyElement from './utils/stringify-element';
 
 import * as browser from '../browser';
 
@@ -66,6 +67,7 @@ exports.parseKeySequence       = parseKeySequence;
 exports.sendRequestToFrame     = sendRequestToFrame;
 exports.KEY_MAPS               = KEY_MAPS;
 exports.browser                = browser;
+exports.stringifyElement       = stringifyElement;
 
 exports.selectorTextFilter      = selectorTextFilter;
 exports.selectorAttributeFilter = selectorAttributeFilter;
diff --git a/src/client/core/utils/stringify-element.ts b/src/client/core/utils/stringify-element.ts
new file mode 100644
index 000000000..c6ac83b82
--- /dev/null
+++ b/src/client/core/utils/stringify-element.ts
@@ -0,0 +1,31 @@
+import hammerhead from '../deps/hammerhead';
+
+const htmlUtils        = hammerhead.utils.html;
+const nativeMethods    = hammerhead.nativeMethods;
+
+const MAX_TEXT_CONTENT_LENGTH = 10;
+
+function truncateString (str: string, length: number, omission = '...'): string {
+    if (str.length < length)
+        return str;
+
+    return str.substring(0, length - omission.length) + omission;
+}
+
+export default function stringifyElement (element: HTMLElement | null): string {
+    if (!element)
+        return '';
+
+    const emptyElement = nativeMethods.cloneNode.call(element);
+    const outerHtml    = htmlUtils.cleanUpHtml(nativeMethods.elementOuterHTMLGetter.call(emptyElement));
+    const text         = truncateString(nativeMethods.nodeTextContentGetter.call(element), MAX_TEXT_CONTENT_LENGTH);
+    const children     = nativeMethods.elementChildrenGetter.call(element);
+
+    if (nativeMethods.htmlCollectionLengthGetter.call(children) > 0)
+        return outerHtml.replace('></', '>...</');
+
+    if (text)
+        return outerHtml.replace('></', `>${text}</`);
+
+    return outerHtml;
+}
diff --git a/src/client/driver/command-executors/action-executor/index.ts b/src/client/driver/command-executors/action-executor/index.ts
index 26085119d..c3a5dad2c 100644
--- a/src/client/driver/command-executors/action-executor/index.ts
+++ b/src/client/driver/command-executors/action-executor/index.ts
@@ -19,6 +19,7 @@ const CHECK_ELEMENT_IN_AUTOMATIONS_INTERVAL = 250;
 export default class ActionExecutor extends EventEmitter {
     public static readonly EXECUTION_STARTED_EVENT = 'execution-started';
     public static readonly WAITING_FOR_ELEMENT_EVENT = 'waiting-for-elements';
+    public static readonly WARNING_EVENT = 'warning';
     public static readonly ACTIONS_HANDLERS: Dictionary<AutomationHandler> = {};
 
     private readonly _command: ActionCommandBase;
@@ -134,6 +135,12 @@ export default class ActionExecutor extends EventEmitter {
             .then(() => {
                 const automation = this._createAutomation();
 
+                if (automation.WARNING_EVENT) {
+                    automation.on(automation.WARNING_EVENT, warning => {
+                        this.emit(ActionExecutor.WARNING_EVENT, warning);
+                    });
+                }
+
                 if (automation.TARGET_ELEMENT_FOUND_EVENT) {
                     automation.on(automation.TARGET_ELEMENT_FOUND_EVENT, e => {
                         this._targetElement = e.element;
diff --git a/src/client/driver/driver.js b/src/client/driver/driver.js
index 078f46d7f..992a40f4e 100644
--- a/src/client/driver/driver.js
+++ b/src/client/driver/driver.js
@@ -1165,6 +1165,7 @@ export default class Driver extends serviceUtils.EventEmitter {
         };
 
         const executor = new ActionExecutor(command, this.options.selectorTimeout, this.speed, executeSelectorCb);
+        const warnings = [];
 
         executor.on(ActionExecutor.EXECUTION_STARTED_EVENT, () => {
             this.statusBar.hideWaitingElementStatus(true);
@@ -1175,6 +1176,10 @@ export default class Driver extends serviceUtils.EventEmitter {
             this.statusBar.showWaitingElementStatus(timeout);
         });
 
+        executor.on(ActionExecutor.WARNING_EVENT, warning => {
+            warnings.push(warning);
+        });
+
         const clientRequestEmitter   = new ClientRequestEmitter();
         const scriptExecutionEmitter = new ScriptExecutionEmitter();
         const barriers               = new BarriersComplex(clientRequestEmitter, scriptExecutionEmitter, pageUnloadBarrier);
@@ -1183,6 +1188,7 @@ export default class Driver extends serviceUtils.EventEmitter {
             .then(elements => new DriverStatus({
                 isCommandResult: true,
                 result:          createReplicator(new SelectorElementActionTransform()).encode(elements),
+                warnings,
             }))
             .catch(err => this.statusBar.hideWaitingElementStatus(false)
                 .then(() => new DriverStatus({ isCommandResult: true, executionError: err })),
diff --git a/src/client/driver/status.js b/src/client/driver/status.js
index f3099690d..4f7cf0914 100644
--- a/src/client/driver/status.js
+++ b/src/client/driver/status.js
@@ -17,6 +17,7 @@ export default class DriverStatus extends Assignable {
         this.isObservingFileDownloadingInNewWindow = false;
         this.isFirstRequestAfterWindowSwitching    = false;
         this.debug                                 = '';
+        this.warnings                              = null;
 
         this._assignFrom(obj, true);
     }
@@ -31,6 +32,7 @@ export default class DriverStatus extends Assignable {
             { name: 'isPendingWindowSwitching' },
             { name: 'isObservingFileDownloadingInNewWindow' },
             { name: 'isFirstRequestAfterWindowSwitching' },
+            { name: 'warnings' },
         ];
     }
 }
diff --git a/src/configuration/configuration-base.ts b/src/configuration/configuration-base.ts
index b24d96349..deb568bbc 100644
--- a/src/configuration/configuration-base.ts
+++ b/src/configuration/configuration-base.ts
@@ -177,11 +177,8 @@ export default class Configuration {
 
         this._filePath = existedConfigs[0].filePath;
 
-        if (existedConfigs.length > 1) {
-            const configPriorityListStr = this._getConfigPriorityListString();
-
-            Configuration._showConsoleWarning(renderTemplate(WARNING_MESSAGES.multipleConfigurationFilesFound, this._filePath, configPriorityListStr));
-        }
+        if (existedConfigs.length > 1)
+            Configuration._showConsoleWarning(renderTemplate(WARNING_MESSAGES.multipleConfigurationFilesFound, this._filePath));
 
         return existedConfigs[0].options;
     }
@@ -304,8 +301,4 @@ export default class Configuration {
 
         option.source = OptionSource.Input;
     }
-
-    protected _getConfigPriorityListString (filesPaths = this.defaultPaths): string {
-        return filesPaths?.map((path, index) => `${index + 1}. ${path}`).join('\n') || '';
-    }
 }
diff --git a/src/notifications/warning-message.ts b/src/notifications/warning-message.ts
index c07cdf7e7..eb6d3c09e 100644
--- a/src/notifications/warning-message.ts
+++ b/src/notifications/warning-message.ts
@@ -48,6 +48,7 @@ export default {
     testsCompilationTakesTooLong:       'Tests took too long to compile ({compileTime}). Ensure the test code has no excessive imports.',
     deprecatedAPI:                      '{API} is deprecated and will be removed in the next major release. Use {replacement} instead.',
     unawaitedMethodWithAssertion:       "An asynchronous method that you do not await includes an assertion. Inspect that method's execution chain and add the 'await' keyword where necessary.",
-    multipleConfigurationFilesFound:    'There are multiple configuration files found, TestCafe will only use one. The file "{path}" will be used.\nThe priority order is as follows:\n{priorityList}',
+    multipleConfigurationFilesFound:    'TestCafe detected more than one configuration file.\nTo prevent configuration conflicts, TestCafe will use the configuration file with the highest priority: {path}.\nRefer to the following article for more information: https://testcafe.io/documentation/402638/reference/configuration-file?search#configuration-file-priority',
+    elementOverlapped:                  'TestCafe cannot interact with the {expected} element because another element obstructs it.\nWhen something overlaps the action target, TestCafe performs the action with the topmost element at the original target\'s location.\nThe following element with a greater z-order replaced the original action target: {actual}.\nReview your code to prevent this behavior.',
 };
 
diff --git a/src/shared/warnings/types.ts b/src/shared/warnings/types.ts
new file mode 100644
index 000000000..120c0f748
--- /dev/null
+++ b/src/shared/warnings/types.ts
@@ -0,0 +1,3 @@
+export default {
+    elementOverlapped: 'elementOverlapped',
+};


Test patch:
diff --git a/src/errors/test-run/templates.js b/src/errors/test-run/templates.js
index c8929abd4..090eb03ae 100644
--- a/src/errors/test-run/templates.js
+++ b/src/errors/test-run/templates.js
@@ -10,6 +10,7 @@ import {
 } from './utils';
 import { getConcatenatedValuesString } from '../../utils/string';
 
+
 const EXTERNAL_LINKS = {
     createNewIssue:      'https://github.com/DevExpress/testcafe/issues/new?template=bug-report.md',
     troubleshootNetwork: 'https://go.devexpress.com/TestCafe_FAQ_ARequestHasFailed.aspx',
diff --git a/src/test-run/index.ts b/src/test-run/index.ts
index ad1c6615c..a6c42ec2e 100644
--- a/src/test-run/index.ts
+++ b/src/test-run/index.ts
@@ -177,6 +177,11 @@ interface DriverMessage {
     status: DriverStatus;
 }
 
+interface DriverWarning {
+    type: keyof typeof WARNING_MESSAGE;
+    args: string[];
+}
+
 interface RequestTimeout {
     page?: number;
     ajax?: number;
@@ -894,6 +899,12 @@ export default class TestRun extends AsyncEventEmitter {
         if (!this.currentDriverTask)
             return;
 
+        if (driverStatus.warnings?.length) {
+            driverStatus.warnings.forEach((warning: DriverWarning) => {
+                this.warningLog.addWarning(WARNING_MESSAGE[warning.type], ...warning.args);
+            });
+        }
+
         if (driverStatus.executionError)
             this._rejectCurrentDriverTask(driverStatus.executionError);
         else if (this._shouldResolveCurrentDriverTask(driverStatus))
diff --git a/test/client/fixtures/automation/utils-test.js b/test/client/fixtures/automation/utils-test.js
index 4670cc390..9e703ffb8 100644
--- a/test/client/fixtures/automation/utils-test.js
+++ b/test/client/fixtures/automation/utils-test.js
@@ -1,5 +1,9 @@
+const hammerhead         = window.getTestCafeModule('hammerhead');
+const browserUtils       = hammerhead.utils.browser;
 const testCafeAutomation = window.getTestCafeModule('testCafeAutomation');
 const MoveAutomation     = testCafeAutomation.MoveAutomation;
+const testCafeCore       = window.getTestCafeModule('testCafeCore');
+const stringifyElement   = testCafeCore.stringifyElement;
 
 function isInteger (num) {
     return (num ^ 0) === num;
@@ -23,3 +27,27 @@ asyncTest('MoveAutomation._getTargetClientPoint should return integer numbers (G
             start();
         });
 });
+
+test('Stringify element', function () {
+    const emptyElement                   = $('<div />')[0];
+    const elementWithChild               = $('<div><div></div></div>')[0];
+    const elementWithShortText           = $('<div>Short</div>')[0];
+    const elementWithLongText            = $('<div>Long text long</div>')[0];
+    const elementWithChildAndText        = $('<div>With child<div></div></div>')[0];
+    const elementWithNestedElementsAndBr = $('<div id="root"><div id="node1"><br></div><div id="node2"><div id="node3"><br></div><div id="node4"><br></div></div></div>')[0];
+
+    equal(stringifyElement(null), '');
+    equal(stringifyElement(emptyElement), '<div></div>');
+    equal(stringifyElement(elementWithChild), '<div>...</div>');
+    equal(stringifyElement(elementWithShortText), '<div>Short</div>');
+    equal(stringifyElement(elementWithLongText), '<div>Long te...</div>');
+    equal(stringifyElement(elementWithChildAndText), '<div>...</div>');
+    equal(stringifyElement(elementWithNestedElementsAndBr), '<div id="root">...</div>');
+
+    //NOTE: IE change an order of the id and class
+    if (!browserUtils.isIE) {
+        const elementWithAttributes = $('<div id="element-id" class="element-class" data-property="element-data"/>')[0];
+
+        equal(stringifyElement(elementWithAttributes), '<div id="element-id" class="element-class" data-property="element-data"></div>');
+    }
+});
diff --git a/test/functional/fixtures/api/es-next/click/pages/overlapped.html b/test/functional/fixtures/api/es-next/click/pages/overlapped.html
new file mode 100644
index 000000000..f753303b2
--- /dev/null
+++ b/test/functional/fixtures/api/es-next/click/pages/overlapped.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="UTF-8">
+    <title></title>
+    <style>
+        .child1 {
+            width: 100px;
+            height: 100px;
+            background-color: blue;
+            position: absolute;
+            top: 0;
+            left: 0;
+        }
+
+        .child2 {
+            width: 400px;
+            height: 400px;
+            background-color: red;
+            position: absolute;
+            top: 0;
+            left: 0;
+        }
+    </style>
+</head>
+
+<body>
+    <div class="child1">Text</div>
+    <div class="child2">
+        <div></div>
+        <div></div>
+    </div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/test/functional/fixtures/api/es-next/click/test.js b/test/functional/fixtures/api/es-next/click/test.js
index bdfa4ec65..9c5598bd7 100644
--- a/test/functional/fixtures/api/es-next/click/test.js
+++ b/test/functional/fixtures/api/es-next/click/test.js
@@ -110,6 +110,17 @@ describe('[API] t.click()', function () {
             });
     });
 
+    it('Should show warning that the element was overlapped', async function () {
+        await runTests('./testcafe-fixtures/click-test.js', 'Click overlapped element', { only: 'chrome' });
+
+        expect(testReport.warnings[0]).eql(
+            'TestCafe cannot interact with the <div class="child1">Text</div> element because another element obstructs it.\n' +
+            'When something overlaps the action target, TestCafe performs the action with the topmost element at the original target\'s location.\n' +
+            'The following element with a greater z-order replaced the original action target: <div class="child2">...</div>.\n' +
+            'Review your code to prevent this behavior.'
+        );
+    });
+
     describe('[Regression](GH-628)', function () {
         it('Should click on an "option" element', function () {
             return runTests('./testcafe-fixtures/click-on-select-child-test.js', 'Click on an "option" element');
diff --git a/test/functional/fixtures/api/es-next/click/testcafe-fixtures/click-test.js b/test/functional/fixtures/api/es-next/click/testcafe-fixtures/click-test.js
index ab9ea4946..8fd23ed9d 100644
--- a/test/functional/fixtures/api/es-next/click/testcafe-fixtures/click-test.js
+++ b/test/functional/fixtures/api/es-next/click/testcafe-fixtures/click-test.js
@@ -82,3 +82,7 @@ test('Selector returns text node', async t => {
 
     await t.click(getNode);
 });
+
+test('Click overlapped element', async t => {
+    await t.click('.child1');
+}).page('http://localhost:3000/fixtures/api/es-next/click/pages/overlapped.html');
diff --git a/test/server/configuration-test.js b/test/server/configuration-test.js
index 57f30ca28..cf52958e0 100644
--- a/test/server/configuration-test.js
+++ b/test/server/configuration-test.js
@@ -325,10 +325,9 @@ describe('TestCafeConfiguration', function () {
                     consoleWrapper.unwrap();
 
                     const expectedMessage =
-                        `There are multiple configuration files found, TestCafe will only use one. The file "${pathUtil.resolve('.testcaferc.js')}" will be used.\n` +
-                        'The priority order is as follows:\n' +
-                        `1. ${pathUtil.resolve('.testcaferc.js')}\n` +
-                        `2. ${pathUtil.resolve('.testcaferc.json')}`;
+                        `TestCafe detected more than one configuration file.\n` +
+                        `To prevent configuration conflicts, TestCafe will use the configuration file with the highest priority: ${pathUtil.resolve('.testcaferc.js')}.\n` +
+                        `Refer to the following article for more information: https://testcafe.io/documentation/402638/reference/configuration-file?search#configuration-file-priority`;
 
                     expect(consoleWrapper.messages.log).eql(expectedMessage);
                 });