Skip to content

Commit

Permalink
feat: upgraded click mechanism on element if offset options isn't set…
Browse files Browse the repository at this point in the history
… and center isn't available (#7330)

<!--
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 #7309]

## Purpose
Click on the available point of the element.

## Approach
1. Research ways to find uncovered and visible points of the element
2. Add tests
3. Add a searching available point of the element if the element is
partly covered or hidden
4. Replace the default click offset with available

## References
#7309

## Pre-Merge TODO
- [X] Write tests for your proposed changes
- [x] Make sure that existing tests do not fail
  • Loading branch information
Aleksey28 committed Nov 2, 2022
1 parent 38e2150 commit f79980f
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 15 deletions.
82 changes: 67 additions & 15 deletions src/client/automation/visible-element-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import ensureMouseEventAfterScroll from './utils/ensure-mouse-event-after-scroll
import WARNING_TYPES from '../../shared/warnings/types';


const AVAILABLE_OFFSET_DEEP = 2;

interface ElementStateArgsBase {
element: HTMLElement | null;
clientPoint: AxisValues<number> | null;
Expand Down Expand Up @@ -153,28 +155,83 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
});
}

private _getElementOffset (): { offsetX: number; offsetY: number } {
private _getElementOffset (): AxisValues<number> {
const defaultOffsets = getOffsetOptions(this.element);

let { offsetX, offsetY } = this.options;
const { offsetX, offsetY } = this.options;

const y = offsetY || offsetY === 0 ? offsetY : defaultOffsets.offsetY;
const x = offsetX || offsetX === 0 ? offsetX : defaultOffsets.offsetX;

return AxisValues.create({ x, y });
}

private async _isTargetElement ( element: HTMLElement, expectedElement: HTMLElement | null): Promise<boolean> {
let isTarget = !expectedElement || element === expectedElement || element === this.element;

if (!isTarget && element) {
// NOTE: perform an operation with searching in dom only if necessary
isTarget = await this._contains(this.element, element);
}

return isTarget;
}

private _getCheckedPoints (centerPoint: AxisValues<number>): AxisValues<number>[] {
const points = [centerPoint];
const stepX = centerPoint.x / AVAILABLE_OFFSET_DEEP;
const stepY = centerPoint.y / AVAILABLE_OFFSET_DEEP;
const maxX = centerPoint.x * 2;
const maxY = centerPoint.y * 2;

for (let y = stepY; y < maxY; y += stepY) {
for (let x = stepX; x < maxX; x += stepX)
points.push(AxisValues.create({ x, y }));
}

return points;
}

private async _getAvailableOffset (expectedElement: HTMLElement | null, centerPoint: AxisValues<number>): Promise<AxisValues<number> | null> {
const checkedPoints = this._getCheckedPoints(centerPoint);

let screenPoint = null;
let clientPoint = null;
let element = null;

offsetX = offsetX || offsetX === 0 ? offsetX : defaultOffsets.offsetX;
offsetY = offsetY || offsetY === 0 ? offsetY : defaultOffsets.offsetY;
for (let i = 0; i < checkedPoints.length; i++) {
screenPoint = await getAutomationPoint(this.element, checkedPoints[i]);
clientPoint = await screenPointToClient(this.element, screenPoint);
element = await getElementFromPoint(clientPoint, this.window, expectedElement as HTMLElement);

return { offsetX, offsetY };
if (await this._isTargetElement(element, expectedElement))
return checkedPoints[i];
}

return null;
}

private async _wrapAction (action: () => Promise<unknown>): Promise<ElementState> {
const { offsetX: x, offsetY: y } = this._getElementOffset();
const screenPointBeforeAction = await getAutomationPoint(this.element, { x, y });
const elementOffset = this._getElementOffset();
const expectedElement = await positionUtils.containsOffset(this.element, elementOffset.x, elementOffset.y) ? this.element : null;
const screenPointBeforeAction = await getAutomationPoint(this.element, elementOffset);
const clientPositionBeforeAction = await positionUtils.getClientPosition(this.element);

await action();

const screenPointAfterAction = await getAutomationPoint(this.element, { x, y });
if (this.options.isDefaultOffset) {
const availableOffset = await this._getAvailableOffset(expectedElement, elementOffset);

elementOffset.x = availableOffset?.x || elementOffset.x;
elementOffset.y = availableOffset?.y || elementOffset.y;

this.options.offsetX = elementOffset.x;
this.options.offsetY = elementOffset.y;
}

const screenPointAfterAction = await getAutomationPoint(this.element, elementOffset);
const clientPositionAfterAction = await positionUtils.getClientPosition(this.element);
const clientPoint = await screenPointToClient(this.element, screenPointAfterAction);
const expectedElement = await positionUtils.containsOffset(this.element, x, y) ? this.element : null;

const element = await getElementFromPoint(clientPoint, this.window, expectedElement as HTMLElement);

Expand All @@ -188,12 +245,7 @@ export default class VisibleElementAutomation extends SharedEventEmitter {
});
}

let isTarget = !expectedElement || element === expectedElement || element === this.element;

if (!isTarget) {
// NOTE: perform an operation with searching in dom only if necessary
isTarget = await this._contains(this.element, element);
}
const isTarget = await this._isTargetElement(element, expectedElement);

const offsetPositionChanged = screenPointBeforeAction.x !== screenPointAfterAction.x ||
screenPointBeforeAction.y !== screenPointAfterAction.y;
Expand Down
2 changes: 2 additions & 0 deletions src/client/driver/command-executors/action-executor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export default class ActionExecutor extends EventEmitter {
if (this._elements.length && opts && 'offsetX' in opts && 'offsetY' in opts) { // @ts-ignore
const { offsetX, offsetY } = getOffsetOptions(this._elements[0], opts.offsetX, opts.offsetY);

// @ts-ignore TODO
opts.isDefaultOffset = !opts.offsetX && !opts.offsetY;
// @ts-ignore TODO
opts.offsetX = offsetX;
// @ts-ignore TODO
Expand Down
1 change: 1 addition & 0 deletions src/test-run/commands/options.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class ActionOptions {
export class OffsetOptions extends ActionOptions {
public offsetX: number;
public offsetY: number;
public isDefaultOffset?: boolean;
}

export class MouseOptions extends OffsetOptions {
Expand Down
1 change: 1 addition & 0 deletions src/test-run/commands/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class OffsetOptions extends ActionOptions {
return [
{ name: 'offsetX', type: integerOption },
{ name: 'offsetY', type: integerOption },
{ name: 'isDefaultOffset', type: booleanOption },
];
}
}
Expand Down
64 changes: 64 additions & 0 deletions test/client/fixtures/automation/click-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,70 @@ $(document).ready(function () {
});
});

// TODO: stabilize test on iOS
(isIOS ? QUnit.skip : asyncTest)('click on covered element', function () {
$el.css({ display: 'none' });

const clickOffsets = [];
const $target = addDiv(150, 150);
const target = $target[0];
const elOffset = $target.offset();

addDiv(elOffset.left + 50, elOffset.top + 50)
.css({ backgroundColor: 'red' })
.width(50)
.height(50);

$target.click(function (e) {
clickOffsets.push({ x: Math.floor(e.pageX - elOffset.left), y: Math.floor(e.pageY - elOffset.top) });
});

Promise.resolve()
.then(function () {
const click = new ClickAutomation(target, new ClickOptions({ offsetX: 75, offsetY: 75, isDefaultOffset: true }), window, cursor);

return click.run();
})
.then(function () {
const click = new ClickAutomation(target, new ClickOptions({ offsetX: 75, offsetY: 75, isDefaultOffset: true }), window, cursor);

addDiv(elOffset.left, elOffset.top)
.css({ backgroundColor: 'red' })
.width(80)
.height(80);

return click.run();
})
.then(function () {
const click = new ClickAutomation(target, new ClickOptions({ offsetX: 75, offsetY: 75, isDefaultOffset: true }), window, cursor);

addDiv(elOffset.left + 70, elOffset.top)
.css({ backgroundColor: 'red' })
.width(80)
.height(80);

return click.run();
})
.then(function () {
const click = new ClickAutomation(target, new ClickOptions({ offsetX: 75, offsetY: 75, isDefaultOffset: true }), window, cursor);

addDiv(elOffset.left, elOffset.top + 70)
.css({ backgroundColor: 'red' })
.width(80)
.height(80);

return click.run();
})
.then(function () {
deepEqual(clickOffsets[0], { x: 37, y: 37 }, 'click in the upper left corner');
deepEqual(clickOffsets[1], { x: 112, y: 37 }, 'click in the upper right corner');
deepEqual(clickOffsets[2], { x: 37, y: 112 }, 'click in the lower left corner');
deepEqual(clickOffsets[3], { x: 112, y: 112 }, 'click in the lower right corner');

startNext();
});
});

asyncTest('cancel bubble', function () {
let divClicked = false;
let btnClicked = false;
Expand Down
21 changes: 21 additions & 0 deletions test/functional/fixtures/api/es-next/click/pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,26 @@
status.textContent = 'Clicked!';
});
</script>
<!--Click shifted element-->
<div id="shifted-element" style="background: green; width: 100px; height: 100px; transform: translateX(-60px);"></div>
<script>
document.querySelector('#shifted-element').addEventListener('click', function (e) {
const rect = document.querySelector('#shifted-element').getBoundingClientRect();

window.clickOffset = { x: e.pageX - Math.round(rect.left), y: e.pageY - Math.round(rect.top) };
});
</script>
<!--Click overlapped element-->
<div style="position: relative;">
<div id="overlapped-center" style="background: blue; width: 100px; height: 100px; position: absolute;"></div>
<div style="background: red; width: 60px; height: 60px; position: absolute;"></div>
</div>
<script>
document.querySelector('#overlapped-center').addEventListener('click', function (e) {
const rect = document.querySelector('#overlapped-center').getBoundingClientRect();

window.clickOffset = { x: e.pageX - Math.round(rect.left), y: e.pageY - Math.round(rect.top) };
});
</script>
</body>
</html>
8 changes: 8 additions & 0 deletions test/functional/fixtures/api/es-next/click/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ describe('[API] t.click()', function () {
);
});

it('Should click on a more than half-shifted element', async function () {
await runTests('./testcafe-fixtures/click-test.js', 'Click on a more than half-shifted element', { only: 'chrome' });
});

it('Should click on an element with overlapped center', async function () {
await runTests('./testcafe-fixtures/click-test.js', 'Click on an element with overlapped center', { only: 'chrome' });
});

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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ test('Selector returns text node', async t => {
await t.click(getNode);
});


test('Click on a more than half-shifted element', async t => {
await t.click('#shifted-element');

const expectedClickOffset = { x: 75, y: 25 };
const actualClickOffset = await getClickOffset();

expect(actualClickOffset.x).eql(expectedClickOffset.x);
expect(actualClickOffset.y).eql(expectedClickOffset.y);
});

test('Click on an element with overlapped center', async t => {
await t.click('#overlapped-center');

const expectedClickOffset = { x: 75, y: 25 };
const actualClickOffset = await getClickOffset();

expect(actualClickOffset.x).eql(expectedClickOffset.x);
expect(actualClickOffset.y).eql(expectedClickOffset.y);
});

test('Click overlapped element', async t => {
await t.click('.child1');
}).page('http://localhost:3000/fixtures/api/es-next/click/pages/overlapped.html');
1 change: 1 addition & 0 deletions test/server/test-run-command-options-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ describe('Test run command options', function () {
propertyName: 'invalidProp',
availableProperties: [
'caretPos',
'isDefaultOffset',
'modifiers',
'offsetX',
'offsetY',
Expand Down

0 comments on commit f79980f

Please sign in to comment.