Skip to content

fix: reject selector-shaped wait arguments instead of reading them as text - #1813

Merged
thymikee merged 1 commit into
mainfrom
fix/1800-wait-selector-shaped-text
Aug 18, 2026
Merged

fix: reject selector-shaped wait arguments instead of reading them as text#1813
thymikee merged 1 commit into
mainfrom
fix/1800-wait-selector-shaped-text

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

wait accepted an argument shape its grammar does not hold, joined the leftover positionals into one literal-text string, and then waited out the full timeout for text that could never appear on screen — reading as a false "element absent" instead of the caller's own mistake.

Before (0.20.10):

$ agent-device wait open 'label="Open"' 25000
Error (COMMAND_FAILED): wait timed out for text: open label="Open"

(after the full 25s timeout, exit code non-zero but read as "element not found")

After:

$ agent-device wait open 'label="Open"' 25000
Error (INVALID_ARGS): "open label="Open"" mixes plain word "open" with selector-shaped
"label="Open"", so it is neither a valid selector nor safe as literal text. "open" is not
a recognized selector key (id, role, text, label, value, appname, windowtitle, visible,
hidden, editable, selected, focused, enabled, hittable). Use wait '<selector>' [timeoutMs]
for a selector, or wait text 'open label="Open"' [timeoutMs] to wait for the literal text
"open label="Open"".

(immediate, before any device work)

parseWaitPositionals (src/core/wait-positionals.ts) now returns a typed invalid
variant whenever, after stripping the trailing timeout, a positional token is
selector-shaped — a recognized selector key (isSelectorToken), or an unrecognized
key=value (detectUnknownSelectorKeyToken) — but the token list does not form a
valid selector expression, or a valid selector prefix is followed by unquoted trailing
tokens. The message:

  • names the offending token and, for an unrecognized key, lists the supported keys plus
    a label=/role= "did you mean" hint (mirrors Bare ref positional produces cryptic coordinate error instead of a hint #1035's click/press/fill/get fix);
  • points a condition word (exists, present, appears, gone, disappears) at the
    selector form, e.g. wait 'visible label="Open"' [timeoutMs]visible/hidden
    are already selector keys, so they parse correctly and are unaffected;
  • always offers the explicit escape hatch: wait text '<text>' [timeoutMs].

Bare text (wait Continue 1500, wait Sign in 2000) and the explicit text keyword
form are untouched — including the documented boundary that a single word matching a
recognized selector key (e.g. wait id 3000) is now rejected rather than silently
read as literal text, since it must go through wait text 'id' 3000 to disambiguate.

resolveWaitBudgetMs (used by the command descriptor's timeout-policy budget) returns
null for a rejected list, same as an unparseable one. dispatchWaitViaRuntime
(src/daemon/selector-runtime.ts) — the one seam both a live CLI wait and a replayed
.ad wait step dispatch through — rejects before any session/device work. Excluding
invalid from the type toWaitTarget accepts makes its kind-by-kind narrowing
exhaustive at compile time, with no runtime fallback branch.

Closes #1800

Validation

  • New src/core/wait-positionals.test.ts: the issue's repro shapes (open/exists +
    selector value), an unknown key=value token, a valid selector prefix followed by an
    unquoted trailing word, visible/bare-text/text-keyword forms staying unaffected,
    the bare-recognized-key-word boundary (wait id 3000), and two fast-check properties
    — a generated valid selector expression never parses as text, and any token list
    containing a key=value-shaped token never parses as text.
  • Proved red: stashed the three production files (wait-positionals.ts, wait.ts,
    selector-runtime.ts) and reran the new/updated tests against pre-fix code. All 9
    assertions failed as expected — 7 asserted 'text'/a stale budget instead of
    'invalid', the property test found ["", 'editable="!"', 0] as a counterexample in
    one run, and the new dispatchWaitViaRuntime daemon-dispatch test hung to the
    5000ms test timeout
    (the pre-fix code actually dispatches and polls instead of
    rejecting immediately) — confirming this is exactly the "silent full-timeout" defect
    class from the issue, not a vacuous pin. Restored the fix; all 45 tests pass.
  • Updated the pre-existing snapshot.test.ts case that had codified the bug
    (parseWaitArgs falls back to text when selector-like token is invalid, using
    foo=bar — an unrecognized key) to assert the new invalid rejection.
  • Added one test at the shared daemon-dispatch seam
    (wait-landmark-recording.test.ts) proving dispatchWaitViaRuntime — the route both
    a live CLI wait and a replayed .ad wait step call — rejects before any
    session/device work, since neither the .ad script grammar
    (packages/ad-script) nor test/replay-compat interpret wait's positional shape at
    parse time (pnpm exec vitest run --project unit-core test/replay-compat — all 55
    cases pass unchanged, no corpus verdict moved).
  • pnpm check:affected --run green (format, lint, typecheck, layering, fallow, build,
    and the full vitest-related selection — 424 files / 3651 tests). wait-positionals.ts
    isn't a mutation-kernel module (scripts/mutation/modules.ts) and I didn't touch
    packages/selectors/src/**, so the mutation gate wasn't selected — confirmed by the
    selector's own output.
  • Live iOS Simulator (iPhone 17 Pro, isolated --state-dir, com.apple.Preferences):
    • wait open 'label="General"' 3000 → immediate INVALID_ARGS in 0.32s (not a 3s
      timeout), quoting the same message shape as above.
    • wait exists 'label="General"' 3000 → immediate INVALID_ARGS in 0.068s, with the
      condition-word hint (wait 'visible label="General"' [timeoutMs]).
    • wait 'label="General"' 3000 → correctly dispatched and got a legitimate
      AMBIGUOUS_MATCH (multiple "General" nodes on the live Settings screen — pre-existing,
      by-design direct-iOS-selector behavior, unrelated to this fix); wait 'role="button" label="General"' 3000 (disambiguated) → exit 0.
    • wait General 3000 (bare text) → exit 0.
    • wait text 'General' 3000 (explicit form) → exit 0.
    • Session closed, daemon stopped, simulator shut down; no stray processes remained for
      the udid afterward.

Docs: no website/docs/docs/adr change needed — the accepted wait forms are
unchanged, only a previously-silent-degrading input now gets a typed refusal instead of
a timeout (same precedent as #1035's click "Did you mean" fix, which also didn't touch
docs).

6 files changed (5 touched, 1 new test file); scope stayed within the wait command
family (src/core/wait-positionals.ts, its CLI reader, and the one daemon dispatch
seam) plus test updates — no scope expansion.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.27 MB 2.27 MB +2.1 kB
JS gzip 745.8 kB 746.4 kB +606 B
npm tarball 866.4 kB 867.0 kB +607 B
npm unpacked 3.02 MB 3.03 MB +2.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.4 ms 26.4 ms +0.0 ms
CLI --help 67.3 ms 65.0 ms -2.3 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-batch-runner.js +2.0 kB +583 B
dist/src/selector-runtime.js +57 B +15 B
dist/src/registry.js +77 B +8 B

… text

`wait <condition> '<selector>' [timeoutMs]` (e.g. `wait open 'label="Open"' 25000`,
`wait exists 'label="x"' 100`) and any unrecognized `key=value` token used to fall
through parseWaitPositionals' text fallback and wait out the full timeout for
literal text that could never appear on screen — reading as a false "element
absent" instead of the caller's own argument mistake (#1035 is the sibling fix
for click/press/fill/get).

parseWaitPositionals now returns a typed `invalid` variant whenever a positional
token is selector-shaped (a recognized key, or an unrecognized key=value) but the
list doesn't form a valid selector expression, or a valid selector prefix is
followed by unquoted trailing tokens. The message names the offending token,
points condition words (exists/present/appears/gone/disappears) at the selector
form, and always offers the explicit `wait text '<text>'` escape hatch. Bare text
(single- and multi-word) and the explicit `text` keyword form are unaffected.

Excluding `invalid` from the type consumed by selector-runtime's toWaitTarget
makes the remaining kind-by-kind narrowing exhaustive without a runtime fallback
branch.
@thymikee
thymikee force-pushed the fix/1800-wait-selector-shaped-text branch from 8b4c133 to b06bf1f Compare August 18, 2026 09:43
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 18, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Clean review at b06bf1f: selector-shaped wait inputs are now rejected at both the owning positional parser and the shared daemon dispatch seam before device work. The typed invalid variant, timeout-budget handling, production-path regression, planted-red evidence, and live iOS validation are sound. All completed gates are green; only iOS Smoke remains pending. Code review is ready-for-human.

@thymikee
thymikee merged commit 0f4f322 into main Aug 18, 2026
31 checks passed
@thymikee
thymikee deleted the fix/1800-wait-selector-shaped-text branch August 18, 2026 09:58
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-18 09:59 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wait accepts an invalid argument shape as literal text, then times out

1 participant