D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

`browser_click` Timing Out on `waiting for stable`? Reveal Animations Mean the Element Never Stops Moving

· · 7 min read
`browser_click` Timing Out on `waiting for stable`? Reveal Animations Mean the Element Never Stops Moving

I was running a production QA pass on a client's holiday rental booking platform. Not through unit tests, but through a real browser driven from an automation session: open a page, fill a form, click a button, see what changes. As long as the work stayed inside the WordPress admin, everything went smoothly. The moment I moved to the front end, the automation stopped working entirely.

The click command did not error out and did not miss its target. It simply hung, then ran out of time, always for the same reason: waiting for stable. The login page did it, the listing detail page did it, the add-listing form did it, the homepage did it. Screenshot commands timed out on those same pages too. So not only could I not click anything, I could not photograph the evidence either.

The split is what caught my attention. Admin pages stable, front-end pages not. One site, one browser, one automation session, two completely different behaviours. When a failure sticks to only some pages and those pages share a single trait, that trait is your suspect.

The shared trait was reveal animations

The front end of this site is full of entrance animations. Sections animate in as they enter the viewport, elements slide and fade toward their final position, and that motion keeps getting retriggered as the page is scrolled. To a visitor it feels smooth, and it is meant to. To Playwright it is a problem.

Before it actually clicks, Playwright runs a series of actionability checks against the target element. One of them is called stable: the element's bounding box has to be unchanged across two consecutive animation frames. The idea is sound, and it is genuinely there to protect me. If a button is still sliding into place, a click sent now can land on coordinates the button has already left a fraction of a second later.

The problem is that the check was waiting for a state that never arrives on these pages. Something is always moving, so the bounding box is never identical two frames running. Playwright waits patiently until its budget is gone, then gives up. This is not a bug in the site and not a bug in Playwright. It is two assumptions colliding: one designs the page to always be alive, the other refuses to act until the page holds still.

The fix: stop waiting, dispatch the events yourself

I did briefly consider killing the animations with some injected CSS for the duration of the audit. I decided against it, because what I was auditing was production exactly as it ships, and changing the page before testing it means testing a page that is not the one visitors see.

I went the other way instead: skip the stability queue. I already knew the element existed and was interactive. What I needed was proof that its handler ran, not a guarantee that a physical pointer had landed on a motionless surface. So every interaction went through evaluate, straight to the DOM, bypassing the actionability checks.

It took three shapes, depending on the element.

Forms: set the values, then submit without touching the button

For login and for longer forms like add-listing, I filled the fields through evaluate and then submitted the form directly, either by pressing Enter or by calling requestSubmit().

const form = document.querySelector('form[data-login]');
form.querySelector('input[name="email"]').value = 'account@example.test';
form.querySelector('input[name="password"]').value = 'secret';
form.requestSubmit();

Those selectors are only illustrative, match them to whatever markup you are dealing with. What matters is requestSubmit() rather than submit(). submit() skips built-in validation and does not fire the submit event, so any handler the site attached to that event never runs and you walk away thinking the form is broken. requestSubmit() behaves like a genuine submit button.

Buttons and calendars: dispatch the whole MouseEvent sequence

For buttons and date pickers, el.click() on its own is frequently not enough. That call only fires a click event, while plenty of widgets begin their work much earlier, at pointerdown or mousedown. Date calendars are one of them. Send nothing but click and nothing happens, which looks exactly like a dead widget.

So I sent the full sequence, the same one a real pointer produces:

['pointerdown','mousedown','pointerup','mouseup','click'].forEach(t => el.dispatchEvent(new MouseEvent(t,{bubbles:true})));

The bubbles: true is not decoration. Handlers are often attached by delegation on a container rather than on the element you are clicking, and an event that does not bubble will never reach them.

This also retired a note I had written myself. In earlier sessions I recorded that the calendar on this site "needs a real click" and could not be driven synthetically. It turns out that was wrong. It accepts bubbled synthetic clicks quite happily. What failed back then was probably not the synthetic part but the incomplete sequence.

Actions that end in a REST call: go straight to the endpoint

Some actions, publishing a listing for example, are really just a wrapper around a single REST call. For those I did not bother with the button at all. The nonce is already sitting on the data object the theme localizes into the front end, so the endpoint can be called directly from evaluate.

await fetch('/wp-json/app/v1/properties/123', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'X-WP-Nonce': window.appData.nonce,
  },
  body: JSON.stringify({ status: 'publish' }),
});

One note that saves time: a data object like that is not guaranteed to exist on every page. On this site it was available on the front end but not reliably in the admin. Check before you assume it is there.

Change the evidence: DOM and network, not screenshots

Since screenshots were timing out on the same pages, I stopped using them as evidence and switched to inspecting DOM state and network responses.

That started as a forced move, but I later realised the evidence is actually stronger. A screenshot only proves pixels at one moment. A successful response from the endpoint plus a new row that genuinely appears in the list proves the whole flow ran all the way to the database and back.

It is worth remembering that not every page needs this treatment either. The admin pages on the same site were mostly stable, and ordinary clicks worked fine there. The only occasional timeouts came from admin notices appearing and shifting the layout, and even those were handled by the same form.requestSubmit() trick.

What this approach still cannot prove

There is one place where this hits a wall, and I think it matters more to write that down than to pretend everything passed.

I drove the mobile hamburger menu with synthetic events. Its aria attribute flipped exactly as expected, so the handler clearly ran. But the panel rendered at zero width, and I could not tell whether that was a real bug or a consequence of operating the widget in a way no human ever would. A real automated click was blocked by the homepage animation instability described above, so that route was closed as well.

I did not mark it as passing. I logged it as unverified, with a request that someone tap it manually on an actual phone. Automation that skips the actionability checks buys speed by trading away some certainty, and that trade belongs in the report honestly.

What I took away