Hand the token to the live captcha widget, not a dead one

First live run reset one account, then failed on the second: the confirm
button never enabled and the step reported it covered by a bare `div`.

reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and
removes none, so the second modal leaves two — and the callback walk fired the
first it found, the one belonging to the modal that already closed. The token
goes to a widget nobody is listening to, the live modal never hears it was
solved, and its button stays dark. Iteration 1 worked only because there was
one client to find. Now the newest wins: hook widgets are walked in reverse,
clients sorted by descending key, and the count of candidates is reported so
`2 widgets on the page, used the newest` says plainly when a page is stacking
stale ones.

Also from that run:

- coveredBy printed `div` and nothing else, which diagnoses nothing. It only
  ever reported tag and id, and modal backdrops and captcha overlays carry
  neither. It now names classes too.
- A covered target gets 8s to clear before the step fails. Most things over a
  button are on their way out — a backdrop mid-fade, an overlay closing. This
  does not weaken the check: still covered at the deadline still fails, and
  nothing is clicked while anything is on top of it.
- The reset list's scroll container is confirmed as a class, so the
  class-or-id hedge is trimmed. The run reported it as
  `dashboard_body MuiBox-root mui-0`.

Extension to 0.11.1 so the dashboard can confirm the reload took. Runner
VERSION deliberately unchanged — the step vocabulary is the same, and a bump
would raise the mismatch banner for a change that does not need it. The
runner does still need restarting to pick up the covered-retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 00:56:47 -05:00
co-authored by Claude Opus 5
parent 5538215650
commit 2d53639c82
5 changed files with 78 additions and 16 deletions
+31 -4
View File
@@ -6,6 +6,7 @@ progress and how they surface failures.
"""
import random
import time
import focus
@@ -56,6 +57,19 @@ def check_signed_in(found: dict, signed_out: str) -> None:
# scaled single display.
KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
# How long to let something covering the target get out of the way.
#
# Most things that cover a button are on their way out: a modal backdrop
# mid-fade, a captcha overlay closing, a spinner over the button that is about
# to be enabled. One measurement cannot tell those apart from a banner that is
# there to stay, and failing on the first look turns a timing wobble into a
# stopped run halfway through a purchase.
#
# This does not weaken the check — still covered when the time is up still
# fails, and nothing is ever clicked while something is on top of it.
COVERED_SETTLE_SECONDS = 8.0
COVERED_POLL_SECONDS = 0.75
def scale_factor(found: dict, override: float | None, report=None) -> float:
"""CSS pixels and desktop pixels match on macOS and unscaled Windows, but
@@ -128,15 +142,29 @@ def perform(
say = report or (lambda _m: None)
found = dash.locate(selector, index, url, timeout, open_url)
if on_located:
on_located(found)
# Before any check that assumes we are on the real page.
check_signed_in(found, signed_out)
# Give a transient overlay a chance to finish leaving before giving up on it.
if found.get("covered") and not force:
say(f"{selector} is covered by {found.get('coveredBy')} — "
f"waiting up to {COVERED_SETTLE_SECONDS:g}s for it to clear")
deadline = time.time() + COVERED_SETTLE_SECONDS
while found.get("covered") and time.time() < deadline:
time.sleep(COVERED_POLL_SECONDS)
found = dash.locate(selector, index, url, timeout, open_url)
check_signed_in(found, signed_out)
if not found.get("covered"):
say("it cleared")
if on_located:
on_located(found)
if found.get("covered") and not force:
raise StepError(
f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead"
f"{selector} is still covered by {found.get('coveredBy')} after "
f"{COVERED_SETTLE_SECONDS:g}s — the click would hit that instead"
)
if action == "type" and not found.get("editable", True) and not force:
@@ -185,7 +213,6 @@ def perform(
return found
# ── type ────────────────────────────────────────────────────────────────
import time
time.sleep(0.15) # let the field take focus and any JS handlers settle
if clear:
+3 -1
View File
@@ -152,8 +152,10 @@ def solve(dash, step, opts, report, cancelled=None) -> None:
}, PAGE_TIMEOUT)
called = delivered.get("called")
seen = delivered.get("candidates") or 0
report(f"filled {delivered.get('filled', 0)} response field(s)"
+ (f", fired {called}" if called else ""))
+ (f", fired {called}" if called else "")
+ (f" — {seen} widgets on the page, used the newest" if seen > 1 else ""))
if not called:
# The token is in the form, so a plain submit still works — but a page
# that gates its submit button on the callback will look unsolved, and