Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: upgraded click mechanism on element if offset options isn't set and center isn't available #7330

Merged
merged 16 commits into from
Nov 2, 2022
Merged
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 @@ -112,6 +112,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