Add scrollToLoad and an absent variant of waitFor

scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.

The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.

waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.

The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.

Also fixes a missing comma in the reset flow that broke the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 14:31:36 -05:00
co-authored by Claude Opus 5
parent 3ac9fe060f
commit 3cc7ddcc5c
8 changed files with 190 additions and 23 deletions
+4 -2
View File
@@ -113,7 +113,8 @@ class Dashboard:
return self._request("/api/autobuyer/status")
def locate(self, selector: str, index: int, url_pattern: str, timeout: float,
open_url: str = "", navigate_url: str = "") -> dict:
open_url: str = "", navigate_url: str = "",
options: dict | None = None) -> dict:
"""Queue a lookup and block until the extension answers it.
`open_url` is the page the extension should open if no tab matches
@@ -123,7 +124,8 @@ class Dashboard:
"/api/autobuyer/locate",
"POST",
{"selector": selector, "index": index, "urlPattern": url_pattern,
"openUrl": open_url, "navigateUrl": navigate_url},
"openUrl": open_url, "navigateUrl": navigate_url,
"options": options or {}},
)
request_id = queued["id"]
+46 -9
View File
@@ -33,7 +33,7 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of.
VERSION = "0.14.0"
VERSION = "0.16.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
@@ -75,10 +75,13 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
report(f"tab is on {landed.get('url', target)}")
elif step["action"] == "waitFor":
# A gate, not an action: block until the element exists. Nothing is
# clicked or typed. Whatever has to make it appear — a person solving a
# challenge, a slow server, a background job — happens outside this run.
# A gate, not an action: block until the element exists — or, with
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
# change the page — a person solving a challenge, a modal closing itself,
# a slow server — happens outside this run.
selector = step["selector"]
absent = bool(step.get("absent"))
goal = "disappear" if absent else "appear"
timeout_s = float(step.get("timeoutSeconds", 120))
deadline = time.time() + timeout_s
announced = False
@@ -92,20 +95,54 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "")
report(f"{selector} appeared")
break
if not absent:
report(f"{selector} appeared")
break
# Still there. Keep waiting for it to go.
except NotFoundError:
if absent:
report(f"{selector} is gone")
break
# Not there yet; keep waiting for it to arrive.
except DashboardError:
pass # not there yet, or the tab is mid-render
# A dead extension or an unreachable dashboard must not be read
# as "the element is gone" — that would satisfy an absent gate
# for entirely the wrong reason.
pass
if time.time() >= deadline:
raise actions.StepError(
f"{selector} did not appear within {timeout_s:.0f}s"
f"{selector} did not {goal} within {timeout_s:.0f}s"
)
if not announced:
report(f"waiting for {selector} (up to {timeout_s:.0f}s)")
report(f"waiting for {selector} to {goal} (up to {timeout_s:.0f}s)")
announced = True
time.sleep(2.0)
elif step["action"] == "scrollToLoad":
# Lists that load progressively need walking to the bottom before the
# steps that act on their items can see everything.
result = dash.locate(
step["selector"], 0,
step.get("urlPattern", "") or opts.url,
max(opts.timeout, 120.0), # scrolling a long list outlasts a normal step
step.get("openUrl", "") or "",
options={
"op": "scrollToLoad",
"containerSelector": step.get("containerSelector", ""),
"maxScrolls": int(step.get("maxScrolls", 25)),
"settleMs": int(step.get("settleMs", 800)),
},
)
found_n = result.get("after", 0)
report(f"{found_n} match(es) after {result.get('scrolls', 0)} scroll(s) "
f"of {result.get('container', '?')} (was {result.get('before', 0)})")
if not result.get("exhausted"):
# Stopping on the scroll cap is not a failure, but it does mean the
# list may still have more below — worth saying so rather than
# letting a later step quietly work on a partial list.
report("hit the scroll limit — there may be more not loaded")
elif step["action"] not in ("click", "type"):
# Almost always a stale runner: the server defines the step vocabulary,
# so a step type this process has never heard of means automations.ts has