Skip to content

Commit e2972ad

Browse files
authored
feat(click): retry when the element it outside of the viewport (#2330)
The element might get animated into the viewport.
1 parent 55d47fd commit e2972ad

File tree

7 files changed

+113
-43
lines changed

7 files changed

+113
-43
lines changed

src/chromium/crPage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export class CRPage implements PageDelegate {
255255
return this._sessionForHandle(handle)._getBoundingBox(handle);
256256
}
257257

258-
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void> {
258+
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
259259
return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
260260
}
261261

@@ -803,15 +803,15 @@ class FrameSession {
803803
return {x, y, width, height};
804804
}
805805

806-
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void> {
807-
await this._client.send('DOM.scrollIntoViewIfNeeded', {
806+
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
807+
return await this._client.send('DOM.scrollIntoViewIfNeeded', {
808808
objectId: handle._remoteObject.objectId,
809809
rect,
810-
}).catch(e => {
810+
}).then(() => 'success' as const).catch(e => {
811+
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
812+
return 'invisible';
811813
if (e instanceof Error && e.message.includes('Node is detached from document'))
812814
throw new NotConnectedError();
813-
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
814-
e.message = 'Node is either not visible or not an HTMLElement';
815815
throw e;
816816
});
817817
}

src/dom.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
171171
injected.dispatchEvent(node, type, eventInit), { type, eventInit });
172172
}
173173

174-
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<void> {
175-
await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
174+
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'success' | 'invisible'> {
175+
return await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
176176
}
177177

178178
async scrollIntoViewIfNeeded() {
179179
await this._scrollRectIntoViewIfNeeded();
180180
}
181181

182-
private async _clickablePoint(): Promise<types.Point> {
182+
private async _clickablePoint(): Promise<types.Point | 'invisible' | 'outsideviewport'> {
183183
const intersectQuadWithViewport = (quad: types.Quad): types.Quad => {
184184
return quad.map(point => ({
185185
x: Math.min(Math.max(point.x, 0), metrics.width),
@@ -204,11 +204,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
204204
this._page._delegate.layoutViewport(),
205205
] as const);
206206
if (!quads || !quads.length)
207-
throw new Error('Node is either not visible or not an HTMLElement');
207+
return 'invisible';
208208

209209
const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 1);
210210
if (!filtered.length)
211-
throw new Error('Node is either not visible or not an HTMLElement');
211+
return 'outsideviewport';
212212
// Return the middle point of the first quad.
213213
const result = { x: 0, y: 0 };
214214
for (const point of filtered[0]) {
@@ -218,22 +218,18 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
218218
return result;
219219
}
220220

221-
private async _offsetPoint(offset: types.Point): Promise<types.Point> {
221+
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'invisible'> {
222222
const [box, border] = await Promise.all([
223223
this.boundingBox(),
224224
this._evaluateInUtility(({ injected, node }) => injected.getElementBorderWidth(node), {}).catch(logError(this._context._logger)),
225225
]);
226-
const point = { x: offset.x, y: offset.y };
227-
if (box) {
228-
point.x += box.x;
229-
point.y += box.y;
230-
}
231-
if (border) {
232-
// Make point relative to the padding box to align with offsetX/offsetY.
233-
point.x += border.left;
234-
point.y += border.top;
235-
}
236-
return point;
226+
if (!box || !border)
227+
return 'invisible';
228+
// Make point relative to the padding box to align with offsetX/offsetY.
229+
return {
230+
x: box.x + border.left + offset.x,
231+
y: box.y + border.top + offset.y,
232+
};
237233
}
238234

239235
async _retryPointerAction(actionName: string, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
@@ -257,11 +253,30 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
257253
await this._page._delegate.setActivityPaused(true);
258254
paused = true;
259255

260-
// Scroll into view and calculate the point again while paused just in case something has moved.
261256
this._page._log(inputLog, 'scrolling into view if needed...');
262-
await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
257+
const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
258+
if (scrolled === 'invisible') {
259+
if (force)
260+
throw new Error('Element is not visible');
261+
this._page._log(inputLog, '...element is not visible, retrying input action');
262+
return 'retry';
263+
}
263264
this._page._log(inputLog, '...done scrolling');
264-
const point = roundPoint(position ? await this._offsetPoint(position) : await this._clickablePoint());
265+
266+
const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint();
267+
if (maybePoint === 'invisible') {
268+
if (force)
269+
throw new Error('Element is not visible');
270+
this._page._log(inputLog, 'element is not visibile, retrying input action');
271+
return 'retry';
272+
}
273+
if (maybePoint === 'outsideviewport') {
274+
if (force)
275+
throw new Error('Element is outside of the viewport');
276+
this._page._log(inputLog, 'element is outside of the viewport, retrying input action');
277+
return 'retry';
278+
}
279+
const point = roundPoint(maybePoint);
265280

266281
if (!force) {
267282
if ((options as any).__testHookBeforeHitTarget)

src/firefox/ffPage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,12 +411,12 @@ export class FFPage implements PageDelegate {
411411
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
412412
}
413413

414-
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void> {
415-
await this._session.send('Page.scrollIntoViewIfNeeded', {
414+
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
415+
return await this._session.send('Page.scrollIntoViewIfNeeded', {
416416
frameId: handle._context.frame._id,
417417
objectId: handle._remoteObject.objectId!,
418418
rect,
419-
}).catch(e => {
419+
}).then(() => 'success' as const).catch(e => {
420420
if (e instanceof Error && e.message.includes('Node is detached from document'))
421421
throw new NotConnectedError();
422422
throw e;

src/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface PageDelegate {
6868
setInputFiles(handle: dom.ElementHandle<HTMLInputElement>, files: types.FilePayload[]): Promise<void>;
6969
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
7070
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
71-
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void>;
71+
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'>;
7272
setActivityPaused(paused: boolean): Promise<void>;
7373
rafCountForStablePosition(): number;
7474

src/webkit/wkPage.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -735,15 +735,15 @@ export class WKPage implements PageDelegate {
735735
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
736736
}
737737

738-
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void> {
739-
await this._session.send('DOM.scrollIntoViewIfNeeded', {
738+
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
739+
return await this._session.send('DOM.scrollIntoViewIfNeeded', {
740740
objectId: toRemoteObject(handle).objectId!,
741741
rect,
742-
}).catch(e => {
742+
}).then(() => 'success' as const).catch(e => {
743+
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
744+
return 'invisible';
743745
if (e instanceof Error && e.message.includes('Node is detached from document'))
744746
throw new NotConnectedError();
745-
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
746-
e.message = 'Node is either not visible or not an HTMLElement';
747747
throw e;
748748
});
749749
}

test/click.spec.js

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ describe('Page.click', function() {
138138
await page.click('button');
139139
expect(await page.evaluate(() => result)).toBe('Clicked');
140140
});
141-
it('should not wait with false waitFor', async({page, server}) => {
141+
it('should not wait with force', async({page, server}) => {
142142
let error = null;
143143
await page.goto(server.PREFIX + '/input/button.html');
144144
await page.$eval('button', b => b.style.display = 'none');
145145
await page.click('button', { force: true }).catch(e => error = e);
146-
expect(error.message).toBe('Node is either not visible or not an HTMLElement');
146+
expect(error.message).toBe('Element is not visible');
147147
expect(await page.evaluate(() => result)).toBe('Was not clicked');
148148
});
149149
it('should waitFor display:none to be gone', async({page, server}) => {
@@ -591,6 +591,61 @@ describe('Page.click', function() {
591591
expect(clicked).toBe(true);
592592
expect(await page.evaluate(() => window.clicked)).toBe(true);
593593
});
594+
it('should retry when element is animating from outside the viewport', async({page, server}) => {
595+
await page.setContent(`<style>
596+
@keyframes move {
597+
from { left: -300px; }
598+
to { left: 0; }
599+
}
600+
button {
601+
position: absolute;
602+
left: -300px;
603+
top: 0;
604+
bottom: 0;
605+
width: 200px;
606+
}
607+
button.animated {
608+
animation: 1s linear 1s move forwards;
609+
}
610+
</style>
611+
<div style="position: relative; width: 300px; height: 300px;">
612+
<button onclick="window.clicked=true"></button>
613+
</div>
614+
`);
615+
const handle = await page.$('button');
616+
const promise = handle.click();
617+
await handle.evaluate(button => button.className = 'animated');
618+
await promise;
619+
expect(await page.evaluate(() => window.clicked)).toBe(true);
620+
});
621+
it('should fail when element is animating from outside the viewport with force', async({page, server}) => {
622+
await page.setContent(`<style>
623+
@keyframes move {
624+
from { left: -300px; }
625+
to { left: 0; }
626+
}
627+
button {
628+
position: absolute;
629+
left: -300px;
630+
top: 0;
631+
bottom: 0;
632+
width: 200px;
633+
}
634+
button.animated {
635+
animation: 1s linear 1s move forwards;
636+
}
637+
</style>
638+
<div style="position: relative; width: 300px; height: 300px;">
639+
<button onclick="window.clicked=true"></button>
640+
</div>
641+
`);
642+
const handle = await page.$('button');
643+
const promise = handle.click({ force: true }).catch(e => e);
644+
await handle.evaluate(button => button.className = 'animated');
645+
const error = await promise;
646+
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
647+
expect(error.message).toBe('Element is outside of the viewport');
648+
});
594649
it('should fail when element jumps during hit testing', async({page, server}) => {
595650
await page.setContent('<button>Click me</button>');
596651
let clicked = false;

test/elementhandle.spec.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -254,25 +254,25 @@ describe('ElementHandle.click', function() {
254254
await button.click().catch(err => error = err);
255255
expect(error.message).toContain('Element is not attached to the DOM');
256256
});
257-
it('should throw for hidden nodes', async({page, server}) => {
257+
it('should throw for hidden nodes with force', async({page, server}) => {
258258
await page.goto(server.PREFIX + '/input/button.html');
259259
const button = await page.$('button');
260260
await page.evaluate(button => button.style.display = 'none', button);
261261
const error = await button.click({ force: true }).catch(err => err);
262-
expect(error.message).toBe('Node is either not visible or not an HTMLElement');
262+
expect(error.message).toBe('Element is not visible');
263263
});
264-
it('should throw for recursively hidden nodes', async({page, server}) => {
264+
it('should throw for recursively hidden nodes with force', async({page, server}) => {
265265
await page.goto(server.PREFIX + '/input/button.html');
266266
const button = await page.$('button');
267267
await page.evaluate(button => button.parentElement.style.display = 'none', button);
268268
const error = await button.click({ force: true }).catch(err => err);
269-
expect(error.message).toBe('Node is either not visible or not an HTMLElement');
269+
expect(error.message).toBe('Element is not visible');
270270
});
271-
it('should throw for <br> elements', async({page, server}) => {
271+
it('should throw for <br> elements with force', async({page, server}) => {
272272
await page.setContent('hello<br>goodbye');
273273
const br = await page.$('br');
274274
const error = await br.click({ force: true }).catch(err => err);
275-
expect(error.message).toBe('Node is either not visible or not an HTMLElement');
275+
expect(error.message).toBe('Element is outside of the viewport');
276276
});
277277
});
278278

0 commit comments

Comments
 (0)