DEV Community

BotFumble
BotFumble

Posted on

Playwright docs say dispatch_event('click') equals element.click(). On a disabled button it didn't.

We gave a script one simple task: press three buttons on a tiny signup form. A disabled "Continue", a "Create account" pushed 5000px off-screen, and a "Next" with visibility:hidden. A mouse user can press none of them.

Playwright's Locator docs say dispatch_event("click") "is equivalent to calling element.click()". On the disabled button they behaved differently. dispatch_event ran the button's onclick. element.click() did not.

In our run (playwright-python 1.64.0, Chromium 156), the four calls gave three different outcomes on the same disabled button, and two of them returned normally while its onclick never ran. If a test or an agent tool counts "no exception" as "the click worked", it can pass when nothing happened, and pass when a handler ran that no user could trigger.

Why this matters for agent tools

An agent tool that clicks through dispatch_event or a JavaScript element.click() hands the model whatever that call does. We have not checked which tools use which. We did see the effect in one of them earlier the same day: agent-browser (its package.json calls it a "Browser automation CLI for AI agents"), driven by fixed commands, which makes that run a script. Version 0.39.1 printed ✓ Done with exit code 0 on all three buttons, and document.title stayed empty, so no onclick ran. Version 0.39.2 refused all three with a reason, for example Element '#cont' is disabled, so the click would not reach it. That change came in PR #2173.

An agent that reads "Done" plans its next step on a page that never changed. So we checked what plain Playwright does with the same buttons.

The setup

The test page. Save it as exp3-page.html, since the script loads it by that name:

<!doctype html><html><body><h1>Sign up</h1>
<form onsubmit="return false"><input aria-label="Email" value="a@b.co">
<button id="cont" disabled onclick="document.title='clicked-disabled'">Continue</button>
<button id="off" style="position:absolute;left:-5000px" onclick="document.title='clicked-off'">Create account</button>
<button id="hid" style="visibility:hidden" onclick="document.title='clicked-hidden'">Next</button></form></body></html>
Enter fullscreen mode Exit fullscreen mode

Every onclick writes to document.title, so an empty title after the call means the handler did not run. The script opens a fresh page for each button and method, tries one click with a 2 second timeout, and records whether the call raised and what the title became. There is no LLM in the loop. This is the exact file we ran:

# exp6: same 3 unclickable buttons (exp3-page.html), 4 ways to "click" in Playwright (Python).
# For each: did the call report success (no exception) and did the onclick actually run (document.title)?
import json, pathlib, datetime, playwright
from playwright.sync_api import sync_playwright
page_url = pathlib.Path("exp3-page.html").resolve().as_uri()
methods = {
 "click()": lambda l: l.click(timeout=2000),
 "click(force=True)": lambda l: l.click(force=True, timeout=2000),
 "dispatch_event('click')": lambda l: l.dispatch_event("click", timeout=2000),
 "evaluate(el.click())": lambda l: l.evaluate("e => e.click()", timeout=2000),
}
res=[]
with sync_playwright() as p:
  b=p.chromium.launch()
  for bid in ["cont","off","hid"]:
    for name,fn in methods.items():
      pg=b.new_page(); pg.goto(page_url)
      err=None
      try: fn(pg.locator("#"+bid))
      except Exception as e: err=str(e).splitlines()[0][:160]
      title=pg.title()
      res.append({"button":bid,"method":name,"reported_success":err is None,"onclick_ran":title.startswith("clicked"),"title":title,"error":err})
      pg.close()
  ver=b.version; b.close()
out={"run_at":datetime.datetime.now().astimezone().isoformat(),"tool":"playwright-python "+__import__("importlib.metadata").metadata.version("playwright"),"chromium":ver,"page":"exp3-page.html","kind":"SCRIPT, no LLM","results":res}
json.dump(out,open("exp6-results.json","w"),indent=1)
for r in res: print(r["button"],"|",r["method"],"| success:",r["reported_success"],"| onclick ran:",r["onclick_ran"],"|",r["error"] or "")
Enter fullscreen mode Exit fullscreen mode

We ran it twice and got identical results both times.

Results

Button click() click(force=True) dispatch_event("click") evaluate(e => e.click())
disabled timeout, no run returned, no run returned, ran returned, no run
off-screen (left:-5000px) timeout, no run error: outside of the viewport returned, ran returned, ran
visibility:hidden timeout, no run returned, no run returned, ran returned, ran

"Returned" means the call finished without an exception. "Ran" means the onclick changed the title. We measured only the onclick, not which input events reached the page.

What each column shows

click() waits for Playwright's actionability checks (visible, stable, receives events, enabled). It raised Timeout 2000ms exceeded on all three buttons.

click(force=True) disables what the docs call the non-essential actionability checks. On the disabled and hidden buttons it returned normally and nothing ran, which is what a real user would also get from those buttons. A test that only checks for "no exception" passes here. On the off-screen button force still raised Element is outside of the viewport.

dispatch_event("click") fires a DOM event at the element. The docs say it is dispatched regardless of visibility, which matches the hidden and off-screen rows. The disabled row is the one that contradicts "equivalent to element.click()": the onclick ran.

evaluate(e => e.click()) is plain JavaScript run through Playwright's evaluate. It ran the handler on the hidden and off-screen buttons and did nothing on the disabled one.

Limitations

  • The off-screen button is still focusable, so a keyboard user could probably Tab to it. We did not test that. The disabled and hidden buttons are the ones no user can press.
  • We only detected the onclick through document.title. Form submission and other side effects were not checked.
  • Playwright's dispatch_event creates the event and dispatches it on the element. Whether a disabled button's listeners then fire is up to the browser, so Firefox and WebKit may give a different disabled row. We only ran Chromium.
  • One synthetic page, one Chromium build, one Playwright version.
  • There is no test runner and no assertions in this run, only calls that did or did not raise.

FAQ

Does Playwright force=True click a disabled button?
The call returned without an error in our run, and the button's onclick did not run.

Is dispatch_event("click") the same as a user click?
Not on a disabled button in Chromium: it ran an onclick that no user could trigger, and element.click() on the same button ran nothing.

What we changed in our own checks

  1. Assert on what the click should cause: a URL, a title, a network request, a new element. With Playwright's expect, for example expect(page).to_have_title("Welcome"), or page.wait_for_url(...).
  2. Treat force=True, dispatch_event and evaluate(... .click()) in a user-flow test as a review question. Each one removes part of what makes the test mean "a person could do this".
  3. If you build tools for agents, return what changed on the page after the action. agent-browser 0.39.2 refuses the click and gives the reason, and that reason is something the model can act on.

The page and script above are the full repro. If your browser or Playwright version gives a different table, we'd like to see it in the comments.


I write these while building BotFumble, a tool for seeing where AI agents get stuck on real signup and checkout flows. It is not live yet.

Top comments (1)

Collapse
 
mercerk17 profile image
Kai Mercer •

This matches what the DOM does without Playwright in the way. I tried it on a plain disabled button in Chrome 155: button.click() did not run the onclick, while dispatchEvent(new MouseEvent("click", {bubbles: true})) did, and so did dispatchEvent(new Event("click")). So the gap is in how the browser treats a synthetic event versus the click method, not only in the Playwright docs wording. That is a plain-DOM test, so I can't say what Playwright's own dispatch_event adds. For tools that report success, a post-click assertion on a visible effect (title, URL or a changed element) catches both the silent no-op and the handler that ran when no user could trigger it.