Files
autofirmer-expanded/clicker/actions.py
T
Brandon LiandClaude Fable 5 b1dccd7dec Revamp login guards into general per-firm page guards
Replace the single hardcoded signed-out check with a guards list on each
firm: any guard can trip mid-run via a URL substring (checked on every
step) or a selector probe (checked when a step fails). Each guard defines
recovery steps or a human-wait fallback, a per-run trigger cap, and a
resume policy — 'continue' retries the tripped step, 'restart' rewinds
the run to step 1.

Runner bumped to 0.20.0; the server still ships the legacy
signedOut/authSteps shape for stale runners, and a new runner synthesizes
a guard from a legacy payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 12:50:14 -05:00

263 lines
9.3 KiB
Python

"""Execution of a single automation step: locate the element, then click or type.
Shared by the CLI (clicker.py) and the automation runner (runner.py) so there is
one implementation of the safety checks. Callers differ only in how they report
progress and how they surface failures.
"""
import random
import time
import focus
class StepError(Exception):
"""A step refused to run, or ran and could not be verified.
`code` mirrors the CLI's exit codes: 3 = refused before acting,
4 = acted but verification failed, 5 = a page guard tripped (login
redirect, blocking modal, and the like).
"""
def __init__(self, message: str, code: int = 3):
super().__init__(message)
self.code = code
class GuardTripped(StepError):
"""A page guard's condition is on the page — the run cannot proceed past it.
Distinct from an ordinary failure because nothing is wrong with the step:
the page has been taken over by something (a login redirect, an expired-
session modal) and every selector now resolves against the wrong document.
A click or a keystroke aimed at the old page would land on whatever the
guard page happens to render in the same place. The runner decides how to
recover; the CLI just stops with exit code 5.
"""
def __init__(self, guard: dict, url: str):
name = guard.get("label") or guard.get("id") or "page"
super().__init__(
f"{name} guard tripped — the tab is on {url}. "
"Deal with the page, then run this again.",
code=5,
)
self.guard = guard
self.url = url
def check_guards(found: dict, guards: list | None) -> None:
"""Abort if the page has tripped any URL guard.
Worth doing before every action, not just at the start: a session can
expire (or a guard page appear) mid-run. Only URL guards are checked here —
the URL rides along with every locate answer, so they are free. Selector
guards cost a locate round trip each and are probed by the runner only once
a step has already failed.
"""
if not guards:
return
url = found.get("url") or ""
for guard in guards:
pattern = guard.get("urlContains") or ""
if pattern and pattern in url:
raise GuardTripped(guard, url)
# Display scaling factors that actually exist. Anything else means the two sides
# are describing different things (usually a multi-monitor desktop) rather than a
# 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
Windows display scaling breaks that.
Deliberately conservative: a raw ratio is only trusted when it lands on a real
scaling factor. On a multi-monitor desktop the browser reports the display it
is on while pyautogui reports the primary one, and the resulting ratio is
meaningless — halving every coordinate would put clicks far from the target.
Fall back to 1:1 and say so, rather than silently scaling.
"""
if override is not None:
return override
try:
import pyautogui
except ImportError:
return 1.0
css_width = (found.get("screenSize") or {}).get("width")
if not css_width:
return 1.0
os_width = pyautogui.size().width
ratio = os_width / css_width
for known in KNOWN_SCALES:
if abs(ratio - known) < 0.02:
return known
if report:
report(
f"screen size disagrees (browser {css_width}px, OS {os_width}px, "
f"ratio {ratio:.3f}) — assuming 1:1. If clicks land off, pass --scale."
)
return 1.0
def perform(
dash,
action: str,
selector: str,
*,
index: int = 0,
url: str = "",
open_url: str = "",
signed_out: str = "",
guards: list | None = None,
frame: str = "",
text: str | None = None,
clear: bool = False,
scale: float | None = None,
timeout: float = 20.0,
rng: random.Random | None = None,
activate: bool = True,
verify: bool = True,
force: bool = False,
dry_run: bool = False,
robotic: bool = False,
report=None,
on_located=None,
) -> dict:
"""Run one step. Returns the located element's data.
Raises DashboardError if the extension can't find it, StepError if the step
is refused or fails verification.
"""
import pyautogui
import humanize
rng = rng or random.Random()
say = report or (lambda _m: None)
# `guards` is the runner's list of page guards; `signed_out` is the CLI's
# single --signed-out pattern. Same check either way, so fold them together.
guards = list(guards or [])
if signed_out:
guards.append({"id": "signed-out", "label": "signed out", "urlContains": signed_out})
# `frame` names the <iframe> the element lives in; the extension resolves the
# selector inside it and offsets the coordinates back. Absent for the common
# top-document case, and then locate behaves exactly as before.
frame_opts = {"frame": frame} if frame else None
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
# Before any check that assumes we are on the real page.
check_guards(found, guards)
# 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, options=frame_opts)
check_guards(found, guards)
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 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:
kind = found.get("inputType")
raise StepError(
f"<{found['tag']}{f' type={kind}' if kind else ''}> is not an editable field "
"(disabled, read-only, or not an input)"
)
factor = scale_factor(found, scale, report)
x = found["screen"]["x"] * factor
y = found["screen"]["y"] * factor
screen_w, screen_h = pyautogui.size()
if not (0 <= x < screen_w and 0 <= y < screen_h):
raise StepError(
f"target ({x:.0f}, {y:.0f}) is off-screen ({screen_w}x{screen_h}) — "
"is the Chrome window on another display?"
)
say(f"at {x:.0f},{y:.0f}")
# A click on a background window is consumed activating it and never reaches
# the page, so raise the browser immediately before pressing.
if activate:
# Raise the browser the extension actually measured from, not whichever
# one happens to be first in the list.
focused = focus.ensure_frontmost(browser=found.get("browser"))
say(focused.detail)
if not focused.ok:
raise StepError(
"the browser is not frontmost — the click would be consumed "
"activating its window instead of pressing the element"
)
pyautogui.FAILSAFE = True
if robotic:
pyautogui.moveTo(x, y, duration=0.25)
if not dry_run:
pyautogui.click()
else:
humanize.click(x, y, rng=rng, press=not dry_run)
if action == "click" or dry_run:
return found
# ── type ────────────────────────────────────────────────────────────────
time.sleep(0.15) # let the field take focus and any JS handlers settle
if clear:
humanize.clear_field(rng)
humanize.type_text(text or "", rng)
if not verify:
return found
# Typing into a field that never took focus, or that ignores the input, fails
# silently and is otherwise indistinguishable from success.
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_guards(after, guards)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")
return found
if (text or "") in got:
say(f"verified, field contains {got!r}")
return after
raise StepError(f"verification failed — field contains {got!r}", code=4)