16

I have a situation where a button, on a form, that is animated into view, if the element.click() happens while the animation is in progress, it doesn't work.

element.click() doesn't throw an error, doesn't return a failed status (it returns undefined) it just silently doesn't work.

I have tried ensuring the element being clicked is not disabled, and is displayed (visible) but even though both those tests succeed, the click fails.

If I wait 0.4s before clicking, it works because the animation has finished.

I don't want to have to add delays (which are unreliable, and a bodge to be frank), if I can detect when a click worked, and if not automatically retry.

Is there a generic way to detect if a click() has actually been actioned so I can use a retry loop until it does?

Austin France
  • 2,381
  • 4
  • 25
  • 37
  • 1
    not 100% the same, but related: https://github.com/GoogleChrome/puppeteer/issues/2107 (related because the solution might solve your situation) – Yaniv Efraim Apr 23 '18 at 13:43
  • Have you tried `await page.waitForSelector('button');` before `page.click('button');` Maybe it helps. but not very sure. – Bernhard Apr 23 '18 at 22:29
  • 1
    Another way is to inject a animation css buster into the page e.g. http://marcgg.com/blog/2015/01/05/css-animations-failing-capybara-specs/ – Rippo Apr 24 '18 at 07:04
  • @Bernhard yes, the code is doing just that. page.waitForSelector(selector); element = page.$(selector); element.click(); – Austin France Apr 24 '18 at 14:25

6 Answers6

33

I have determined what is happening, and why I don't get an error, and how to work around the issue.

The main issue is with the way element.click() works. Using DEBUG="puppeteer:*" I was able to see what is going on internally. What element.click() actually does is:-

const box = element.boundingBox();
const x = box.x + (box.width/2);
const y = box.y + (box.height/2);
page.mouse.move(x,y);
page.mouse.down();
sleep(delay);
page.mouse.up();

The problem is that because the view (div) is animating the element's boundingBox() is changing, and between the time of asking for the box position, and completing the click() the element has moved or is not clickable.

An error isn't thrown (promise rejected) because its just a mouse click on a point in the viewport, and not linked to any element. The mouse event is sent, just that nothing responds to it.

One workaround is to add a sufficient delay to allow the animation to finish. Another is to disable animations during tests.

The solution for me was to wait for the position of the element to settle at its destination position, that is I spin on querying the boundingBox() and wait for the x,y to report the elements previously determined position.

In my case, this is as simple as adding at 10,10 to my test script just before the click, or specifically

test-id "form1.button3" at 10,10 click

And in action it works as follows, in this case, the view is being animated back in from the left.

00.571 [selector.test,61] at 8,410
test-id "main.add" info tag button displayed at -84,410 size 116,33 enabled not selected check "Add"
test-id "main.add" info tag button displayed at -11,410 size 116,33 enabled not selected check "Add"
test-id "main.add" info tag button displayed at 8,410 size 116,33 enabled not selected check "Add"
00.947 [selector.test,61] click

It wouldn't work for an element that was continually moving or for an element that is covered by something else. For those cases, try page.evaluate(el => el.click(), element).

Austin France
  • 2,381
  • 4
  • 25
  • 37
  • 1
    From this answer, 2 take aways for me were: `page.evaluate(el => el.click(), element)` and disabling the transitions/animations. Thank you. – Srikanth Sharma Jan 20 '22 at 02:59
4

Generic click with timeout function inspired by Andrea's answer. This one returns as soon as the element is clickable, so won't slow down tests.

click: async function (page, selector, timeout = 30000) {

    await page.waitForSelector(selector, { visible: true, timeout })

    let error;
    while (timeout > 0) {
        try {
            await page.click(selector);
            return;
        } catch (e) {
            await page.waitFor(100);
            timeout -= 100;
            error = e;
        }
    }
    throw error;
}
Austin France
  • 2,381
  • 4
  • 25
  • 37
2

The page.click() returns a promise, make sure to handle it as such, but also note that you may have issues if you are not referencing it as an xpath. That's what I had to do in order to get it working. I've tried using querySelectors and interacting with the objects that way, but I ran into issues.

page.evaluate(()=>{
await Promise.all([
    page.click("a[id=tab-default-2__item]"),
    //The page.waitFor is set to 15000 for my personal use. 
    //Feel free to play around with this.
    page.waitFor(15000)
]);
});    

I hope this helps.

ElementCR
  • 178
  • 12
  • I am using await, so a reject is thrown as an error and the return value is the value passed to resolve. It's turns out it's a limitation of the way element.click() works. (see my answer below) – Austin France Apr 25 '18 at 22:25
2

i use a helper function to handle click


    click: async function (page, selector) {

        //selector must to exists
        await page.waitForSelector(selector, {visible: true, timeout: 30000})
        //give time to extra rendering time
        await page.waitFor(500)

        try {
            await page.click(selector)
        } catch (error) {
          console.log("error clicking " + selector + " : " + error ;
        }
    }

using page.waitFor(500) is a VERY BAD PRACTICE in a THEORICAL WORLD, but it remove a lot of false positive in the practical with complex interfaces.

i prefer to wait 500ms more than obtain a false positive.

Andrea Bisello
  • 1,077
  • 10
  • 23
  • 1
    and unfortunately dramatically slows down your tests because of all the unnecessary 1/2 second waits. In my case I used targeted waits as I knew there was a 400ms animation at certain points I needed to wait for, other clicks don't wait and it was easy to add to my scripts. Waiting for final position or some known state is always going to be the best solution, as it doesn't rely on delays, which are generally bad and to be avoided. – Austin France Dec 01 '19 at 11:40
  • To clarify, if this was for example an `anim_click` method that would be a perfectly good solution, doing this every click though in my opinion would not be a good idea. – Austin France Dec 01 '19 at 11:49
  • I agree with Austin; [Puppeteer's own readme](https://github.com/puppeteer/puppeteer/tree/7748730163bc1a14cbb30881809ea529844f887e#q-is-puppeteer-replacing-seleniumwebdriver) says "Puppeteer has event-driven architecture, which removes a lot of potential flakiness. There's no need for evil `sleep(1000)` calls in puppeteer scripts." – ggorlen Mar 20 '22 at 05:06
  • @AustinFrance i totally agree with you, but sometime in very heavy web application bad developed with huge html pages this cannot be done and a forced sleep are needed. – Andrea Bisello Mar 31 '22 at 09:52
2

I've just had a very similar problem: I had Puppeteer script that used to work and now suddenly click stopped working. The culprit turned out to be zoom level. It started working again once I switched zoom to 100%. Apparently, Puppeteer does not adjust click coordinates to zoom level.

Ivan Koshelev
  • 3,830
  • 2
  • 30
  • 50
1

For me below code did the trick

const element = await page.$('[name="submit"]')
await this.page.evaluate(ele => ele.click(), element);
anjaneyulubatta505
  • 10,713
  • 1
  • 52
  • 62
  • This is essentially the same as [this answer](https://stackoverflow.com/a/50032302/6243352) except with no explanation. What new insight does this offer? As an aside, you can use `element.evaluate(ele => ele.click())`. – ggorlen Mar 20 '22 at 04:48