* fix(android): report the clip an Android recording really captured
Android `screenrecord` encodes a frame only when the screen changes, so a window that ends on an
unchanged screen returns a video far shorter than the requested duration, and `record stop` had
nothing to say about it: the reported `durationMs` is host wall clock from `record start` until the
export finished, which is not the length of the file that was just pulled.
`record stop` now measures the pulled MP4 timelines and reports them as `capturedDurationMs`, and
warns with the clip length against the window when the video is two or more seconds short. The
window is measured on the device's own elapsed clock, read before the stop signal and at launch,
because host wall clock drifts against the clock the encoder timestamps frames with; an unreadable
clock or a chunk that answers no duration costs the caller the claim, never the recording.
Measuring the timeline needed an ISO-BMFF box walk, which now lives in
`@agent-device/capture-kit/recording-mp4-duration` and replaces the private top-level atom scan
that MP4 container detection was doing. Stop replay through daemon recovery carries the field too,
so a completion read back from the session resource reports the same numbers it did live.
* chore(gates): enumerate the capture-kit MP4 subpaths the layering scan holds
`@agent-device/capture-kit/recording-mp4-duration` and its fixture sibling are new declared package
subpaths, so the boundary enumeration that holds every exported workspace subpath has to name them
for the layering scan to accept the Android recorder's read of a pulled clip's timeline.
* fix(capture-kit): evaluate the MP4 box scan only when a file is validated
The Coverage job's ADR-0019 eager-closure probe failed: `recording/video.ts` evaluated 25 modules on
import where the merge-base evaluated 24, because the MP4 container gate statically imported the box
walk it now shares with the clip-duration read, and `recording/overlay.ts` grew by the same module.
An entry the merge-base already carries gets no growth budget, so the edge moves behind a
function-scoped `await import`: the scan is something recording completion asks for, and importing
this module for `waitForStableFile` or WebM detection should not evaluate a box walker.
The alternative the probe offered -- hosting the walker in a module both growing entries already
evaluate -- would have put an ISO-BMFF walk in `swift-cache.ts` or `video-webm.ts`, or made the
duration read import the Swift validator machinery that sits behind `video.ts`.
* refactor(android): bracket the recording window with the host clock
Human review of #2566: the device-clock read defended against host-vs-encoder drift that does not
matter at this threshold. Quartz drifts by tens of ppm, so a 30-minute chunked recording moves the
window under 100 ms against a 2s warning threshold, while the read cost a transport operation, its
own probe budget, and two adb round trips per recording. The window is now the host elapsed time
between `Date.now()` immediately before the recorder launches and `Date.now()` immediately before the
stop signal, so the contract change and the extra device I/O are gone, and the one case where the
clocks genuinely diverge -- a host that sleeps mid-recording -- reports a shorter window and misses
the warning rather than inventing one.
The surviving clock arithmetic is one subtraction, so it lives in the window module that already owns
that concern instead of a module of its own. A stop recovered through daemon recovery now passes the
manifest's own start instant, which is the first host timestamp the recording ever had, so a recovered
stop gets the same comparison a live stop gets.
* fix(daemon): take a foreign device claim the device's own reboot invalidated
An open that found a claim belonging to another session gave up even when
the device had rebooted since that claim was taken, leaving the surface
unreachable for every session. A reboot already took the app and the runner
away, so the claim guarded nothing.
Ask the device when its current boot began and release a foreign claim whose
stamp predates it. The stamp is the last instant the owner vouched for the
device, renewed by every open that reaches it, including the one that boots
the device on the way in, so an owner that boots the device for its own work
keeps it and only an owner that never came back loses it.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(gates): classify the daemon edge that asks a device when it booted
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(android): keep a scroll's swipe out of the IME window
Android is the case the clip exists for beyond iOS: an `adjustPan` or `adjustNothing` activity keeps a
window whose recorded bounds already run under the IME, so a plan built from them aims at the keys.
The helper now reports the largest input method window beside the application window, in absolute
screen pixels like the window next to it, and `scroll` clips its band with the shared rule or refuses
when the keyboard owns the surface.
An older helper reports no keyboard keys, and a provider-supplied viewport has no IME channel at all.
Both read as "nothing to avoid", which is what the shared rule already does with a missing frame;
neither turns into a refusal.
`UiAutomation.getWindows()` answers with an empty list until the service asks for interactive windows,
so the read applies the seam the tree capture already uses rather than depending on a snapshot capture
having run first in the same instrumentation; the one-shot fallback below it has no such neighbour.
Measured on a Pixel 7 emulator with an `adjust=pan` contact editor, the application window keeps its
full 2400px height while the IME window reports `[0,1517][1080,2400]`, and `scroll down` answers with
`referenceHeight: 1505`, `keyboardMinY: 1517`, `keyboardAvoided: true` and a swipe ending at 301
instead of starting at 1920 under the keys.
* fix(android): clear the composer, not just the key plane, before a scroll swipes
The helper kept the largest `TYPE_INPUT_METHOD` rectangle as the keyboard. A composer bar and its key
plane can arrive as separate windows and the key plane is the larger one, so the earlier top edge was
discarded and the clipped band still ended inside the composer: the swipe landed on keys the rule
exists to keep it off.
The read now copies every input method window and unions the ones the swipe's centre line crosses,
which is the same line the shared clip rule tests. A candidate strip at the edge of the screen that the
swipe can never reach no longer shortens the band either. The selection runs on plain window edges,
because `Rect` is a device type whose constructors throw off-device, so the two-window case is a unit
test rather than a simulator-only path.
* refactor(android): pass the measured occlusion explicitly and drop the duplicate rect check
The clip rule already fails open on a keyboard frame it cannot measure,
so the helper reader no longer needs its own copy of the check, and the
gesture-viewport validator goes back to its original body. The refusal
names its three numbers instead of spreading the clip variant, so the
discriminator never leaks into error details.
* fix(contracts): state the scroll keyboard clip once for every platform
* fix(apple): surface the scroll keyboard clip as evidence and a typed reason
* refactor(contracts): state the scroll keyboard refusal details once and keep the runner's message
The Apple scroll owner rebuilt the refusal per command, discarding the
runner's measured message and carrying an unmeasured variant of the
error builder for it. The shared reason and hint are now one frozen
object in scroll-gesture; the Apple owner adds it to the runner's own
error (matched on the typed runner code, transport details kept), and
the error builder takes a plain measured occlusion, which only Android
produces in-process. The help text names the behaviour in one clause;
the hint carries the recovery at the moment it matters.
* fix(record): replay the finished export from a retried record stop
A remote record stop can outlive its client window while the daemon is still exporting. The finished manifest was then read as no active recording, and its metadata carried no client output path, so the caller had no way to collect the file. A repeated record stop now serves the completed export and says so in the timeout hint.
* refactor(record): declare each completion codec once
A mapped codec per completion property drives encoding and decoding from one declaration, and the declaration fails to typecheck if a property has no codec.
* fix(record): keep manifest encoding inside the session resource module
Session teardown reaches the recording resource definition while it loads, and that eager closure takes no new module. Writing a completion is property reads only, so the field map and writers now live with the resource definition; reading one back needs the recording vocabulary and stays behind the stop path.
* test(client): give the request timeout hint its own mirror file
The hint assertions had outgrown the aggregate client test past its size ratchet; they mirror src/daemon-client/daemon-client-timeout.ts, so they move rather than shrink.
* refactor(record): store the finished stop response under one manifest key
The manifest now holds the completion as the one object record stop returned, so a replay cannot lose a field between an encoder and a decoder, and the reader lives with the stop path that needs it. Recovery still refuses a response whose served path or caller-side paths are not whole.
* refactor(record): reuse the scope guard and record path their owners declare
A stored scope is checked by isRecordingScope next to the vocabulary it validates, and a session's durable record path comes from the factory that names it instead of being re-derived at each read.
* refactor(client): hand the timed-out request to its timeout handler
Command, session, and action all come from the same request, so they are passed as one request instead of three more positional arguments.
* refactor(client): name the timed-out request fields the handler reads
The client timeout handler stays off the daemon request shape: R10 daemon-modularity holds external importers of that module at the merge-base count, so the fields arrive as named properties instead of the request object.
* refactor(client): read a timed-out request's fields once for both transports
A socket timeout and an HTTP timeout described the same request with two copies of the same mapping.
AGENTS.md routed request cancellation/progress and diagnostics to
`@agent-device/capture-kit` subpaths that no package exports; both live in
`@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also
named `@agent-device/contracts` as an importable seam although that package
publishes no root export, and claimed `src/daemon/handlers/session.ts` was over
budget after that extraction already landed at 242 lines.
Two ADRs carried number 0019. The hop trace has its own claims to make, so it now
numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022.
The Node floor split was undocumented: `engines.node` stays at 22.12 because CI
installs the published tarball on that floor, while contributors need 22.13 for the
pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and
the web backend's Node 24 requirement.
Extend the agent-guidance contract to resolve every `@agent-device/*` specifier
AGENTS.md names against the owning package's `exports`, root included, so neither a
phantom subpath nor a phantom package root can route an agent to a module that does
not exist.
* fix(cloud): cancel a screen read that its caller gave up on (#2509)
A hosted page-source read that outran its request was dropped by the client while
the driver kept walking the UI tree. Nothing at the wire said the answer was no
longer wanted, so the read stayed in flight and held the session it was blocking.
On a screen that never goes still -- a looping video, a live ticker, continuous
animation -- every later command then queued behind a capture nobody was waiting
for, which is what makes one stuck `snapshot -i` look like a frozen session.
Bind the read to the request that asked for it, on both Android and iOS, and say
what a source read that runs out of budget was waiting for. The timeout keeps its
`webdriver_request_timeout` reason and gains a hint naming a screen that never goes
still, so the rented minutes end with a cause rather than a silent hang.
Closes#2509
* fix(cloud): stop advising a timeout the source read never sees
The hint and the AWS docs both told the caller to retry with a larger `--timeout`.
That flag widens the command envelope around the read; the read's own budget is the
transport's, so the advice could not work and cost rented minutes to discover. The
report on #2509 shows exactly that experiment failing at 65 seconds.
Say whose budget it is, and offer the two things that do work: a `screenshot`, which
never reads the tree, and the `@refs` an earlier snapshot captured.
* test(cloud): claim only what the cancellation layer proves
The scenario comment credited this layer with the lease-gone symptom, which a
ten-minute cloud WebDriver lease rules out for the reported run. And one assertion
message said the driver's own tree walk had been cancelled, when what the test
observes is our request being hung up at the wire.
Behaviour and coverage are unchanged; the test now says what it measures.
* docs(snapshot): put the never-idle screen rule where snapshot advice lives
The read that fails is shared hosted WebDriver behaviour, so it belongs on the
Snapshots page rather than only under one provider. The provider page keeps the part
that is about being metered.
Both pages name the two dead ends, since both were tried on the reported run: a
larger `--timeout`, which widens the command around the read, and `settings
animations`, which hosted WebDriver sessions do not implement.
* docs(snapshot): bound the cancellation to the waiting this side controls
Hanging up the client read proves agent-device stops waiting and stops holding the
session. It says nothing about the provider, whose tree walk can keep running and can
still occupy that session's queue server-side. The paragraph claimed the recovery the
fixture does not prove.
Every backend sets truncated: true when it cuts a capture at one of its
limits, but only JSON carried it. One shared warning now renders from that
flag in the cross-platform warnings assembly and tells the agent what fell
off (what comes last in document order) and what to do.
The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android
helper's bound. Measured on a synthetic 600-row screen, acquisition time did
not change with the cap while the 1500 cut dropped the on-screen footer.
`screenshot --crop-on` paid for a full PNG decode and an RGBA re-encode of the
capture before keeping a frame. One worker job now turns the captured bytes into
the cropped bytes: a region reader that reconstructs pixels only down to the
box's last row and allocates only the box's pixels, and a truecolor writer that
drops the alpha channel when the cropped pixels carry none.
The reader claims the 8-bit non-interlaced truecolor layout that iOS simulator
and Android emulator captures arrive in, and only for a file it can vouch for:
the IHDR and every chunk checksum are verified, an unrecognised critical chunk
name is a decline, and every row's filter byte is read whether or not the box
reaches that row. Everything else — palette, grayscale, interlaced, 16-bit, a
checksum that does not match — falls through to the general PNG reader, which
keeps owning the canonical decode error and the previous RGBA output. A box
covering the whole image reads through that general reader too, so an unchanged
answer is only reported for a file that reader accepts.
Cropped bytes verify pixel-for-pixel against ImageMagick's own crop across RGB,
RGBA, grayscale, palette, 16-bit, interlaced, and translucent sources, on both
iOS simulator and Android emulator captures.
The snapshot helper never serialized `selected`, and the host reads only the
helper's XML, so no later layer could recover it: `get attrs` had no `selected`
field, no snapshot node was marked selected, `is selected` could not match, and
a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible
condition did not match" for a visible element while `selected: false` matched
every Android node (#2462).
The helper now emits both answers, like `enabled` and `password`, so an
unselected control answers `false` and a helper older than the attribute answers
nothing. The parser, the Android hierarchy node, and the published snapshot node
carry it to `get attrs` and the `[selected]` marker.
Snapshot lines render that marker on the default formatter path too: `--settle`
and `diff` already compared selection, and a line that weighs a fact it cannot
print turns a tab tap into a changed pair whose two lines look identical.
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget
The iOS Smoke lane started failing on main at the merge of #2486: the new
smoke:webview-remote-content scenario ended with `wait text "Jump to form" 20000`
failing after 288 ms with RUNNER_BUSY. Three defects stacked up.
The scenario reused acceptDeepLinkConfirmationIfPresent, whose readiness landmark
was hard-coded to the Automation lab's text. Off that route it can never match, so
the helper always fell through to its `alert get` probe — and an XCTest alert query
against a live WKWebView screen exceeds the runner's 30 s main-thread execution
watchdog (measured 10.1 s to fail locally, 10.6 s in CI), abandoning main-thread
work and leaving the runner refusing every following command as RUNNER_BUSY. The
landmark is now a parameter and each caller passes its own route's, so the probe
runs only when the destination genuinely did not arrive. The depth-frontier
scenario carried the same mismatch and is fixed with it.
A `wait` is a budgeted retry loop, but it abandoned its whole budget on the first
retriable refusal. A poll whose failure the producer itself marked retriable is now
ridden out like an unreadable capture: the wait keeps polling to its deadline,
records the poll as `retriable` in its timeout evidence, and surfaces the refusal
only when no readable capture ever completed. RUNNER_WEDGED is not retriable and
still ends the wait at once.
That classification was also missing on the path the failure actually took. A
runner error recovered from the lifecycle journal after a lost transport response
was built with a bare toAppErrorCode, so RUNNER_BUSY reached callers as a
RUNNER_BUSY wire code with no `retriable` flag, while the live-response path
published it as COMMAND_FAILED plus details.runnerErrorCode and retriable: true.
Both paths now read the runner's code through one classifier in runner-contract.
Live-validated on a booted iPhone 17 Pro simulator against the fixture app: the
destination landmark resolves in 389 ms with no alert probe, the page wait
succeeds in 81 ms, and the snapshot still carries Link "Jump to form", the
"Email address" field label, and the remote-content-boundary XCTest fallback
warning. Driving the old sequence first reproduces the wedge, after which the
fixed wait polls its full 20 s in `retriable` polls instead of failing instantly.
* test(apple-runner): move journaled runner-code classification to the recovery test
The two new cases landed in runner-command-retry.test.ts, which was already over
the 1,000-line test-file tripwire, so the size ratchet refused its growth. They
assert runnerStatusFailureError's reading of the lifecycle journal, so their home
is runner-command-recovery.test.ts, which mirrors that module and drives recovery
through the real stack against a scripted fake runner.
* test(e2e): let a deep-link route mount before probing for its confirmation alert
The 2500 ms destination budget was tuned to the Automation lab on a warm
simulator. On CI the WebView lab rendered correctly but was not in the bridge
tree that fast, so the helper fell through to its `alert get` probe — and that
XCTest query against a live WKWebView exceeds the runner's execution watchdog,
leaving every later command refused as RUNNER_BUSY.
Measured on a freshly created simulator: with the confirmation alert up the probe
is correct and costs 1.6 s, because the alert blocks the route and there is no web
view to query; with no alert the landmark resolves in 0.1-1.7 s. The budget only
has to outlast an honest mount, and overshooting it costs nothing when a
confirmation really is up, since that route never renders until it is accepted.
Cold-simulator run of the whole scenario: landmark 400 ms, page wait 613 ms,
snapshot keeps the page link, the field label, and the XCTest fallback warning.
* fix(wait): keep the poll timeline on a wait exhausted by retriable refusals
Review finding on #2493: a wait that spent its whole budget being refused threw
the last refusal raw, so the common all-RUNNER_BUSY case carried no captures,
waitedMs or polls and could not show where its budget went — contradicting the
evidence this PR documents. The mirror gap existed on the other exhaustion shape:
when the deadline cancelled the final poll, the wait reported a generic stall and
dropped the runner code and retry details instead.
Both shapes now raise one error that keeps the producer's code, message, hint and
retry details and carries the wait's own evidence, with reason wait_capture_stalled
and the original as its cause. A content verdict is still preserved untouched,
since it already describes the capture it came from, and whether it outranks the
stall verdict stays the caller's policy (wait absent).
Live-verified against a genuinely wedged simulator runner: COMMAND_FAILED,
retriable true, runnerErrorCode RUNNER_BUSY, reason wait_capture_stalled,
captures 6, readableCaptures 0, waitedMs 8041, polls
retriable,retriable,retriable,retriable,retriable,deadline.
* 0.21.1
* chore(react-devtools): pin agent-react-devtools 0.5.0 and document the React Native 0.87 setup step
agent-react-devtools 0.5.0 restores attachment on React Native 0.87+,
where the built-in DevTools websocket was removed (facebook/react-native#56897).
The app now needs a one-time `agent-react-devtools init` plus a rebundle;
the help topic says so, and warns that an empty observation is not a pass.
Verified live: a bare react-native@0.87.1 app attaches through
`agent-device react-devtools` with 149 components.
Refs #2430
* docs(react-devtools): tell agents to run uninit when the task is done
* docs(react-devtools): trim the help topic to the current setup facts
* docs(react-devtools): state the app dependency and the attachment check
* fix(network): report iOS requests that reused a keep-alive connection
CFNetwork logs a request URL only on the `com.apple.network:connection`
line that opens a connection. A request that reuses a keep-alive
connection emits a task summary carrying status, timing, and byte counts
but no URL anywhere in the log, so a URL-keyed reader dropped it and the
dump silently omitted a request that did happen. An "assert this endpoint
was called on startup" check therefore read as a definite fail.
Correlate a reused task summary with the connection it names and report
it against that connection's origin, with `pathUnavailable` set, its
status, and its timing. The request path is not in the log at all, so the
dump also notes how many requests it could not name — a gap in
observation now reads as a gap rather than as a negative observation.
Also stop a URL parsed out of a log line from carrying the punctuation
that follows it, so an entry's `url` compares equal to the endpoint under
test instead of failing on a trailing comma.
The correlation lives in the reader rather than a sibling module because
`packages/capture-kit/src/index.ts` may not grow its eager import closure.
Refs callstack/agent-device#2430
* fix(network): count keep-alive requests the reader cannot name at all
Review of the parent commit found the same definite-negative it fixes,
one level down: a reused task summary whose connection was opened before
the scanned window resolves to no origin, so it produced no entry and no
signal — an empty dump reporting "No HTTP(s) entries were found" for a
window that demonstrably carried traffic. Count those in the dump's
`unnamedRequests` and say so in the notes, so an unnameable request is
still a reported observation.
Also order the Apple note builders so the keep-alive note no longer trips
the `notes.length === 0` guard that suppresses lifecycle guidance, and
give the android-backend test a fixture an Apple dump would actually
resolve, so the backend gate it names is the thing it proves.
* fix(network): scope connection correlation to the process that opened it
Review findings on the parent commits: three ways the reader still answers
with something other than what it observed.
A connection number is only meaningful within one process, but the index
keyed on the number alone, so an app that relaunched and reopened the same
number inherited the origin its predecessor had contacted — a request
attributed to a host it never reached, which is worse than dropping it.
Key the index by the compact log's `name[pid]` and the connection number
together; a line whose process cannot be read correlates to nothing and its
traffic stays unnamed.
The simulator recovery pass merged its dump only when it carried entries,
so a recovery window holding nothing but unnameable reused-task summaries
discarded that count and the response still reported an empty window. Merge
whenever the pass observed traffic in either form, and reserve the "none
looked like HTTP traffic" note for a pass that found neither.
The trailing-separator strip was global, so a valid URL ending in
punctuation became a different endpoint. Take the URL from the delimited
`url:` field where the format establishes the separator, and leave a bare
URL exactly as matched.
Regressions cover each: the same connection number under a different pid,
an unreadable process identity, recovery-only unnamed traffic, and a path
that legitimately ends in a period.
* fix(network): reconcile unnamed keep-alive requests across scan windows
The app log and the simulator recovery pass cover different, sometimes
overlapping windows, so taking the larger of their two unnamed counts was
wrong in both directions: two unnameable requests in one window and three
in the other reported three rather than five, and a request the recovery
pass resolved stayed counted as unnamed from the app log.
Carry the identities instead of a count. Every CFNetwork line names its
request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the
same request seen in two windows is recognisable as one. A merge unions the
unnamed identities and subtracts anything either window managed to name, and
a resolved reused request carries its identity as `packetId` so that
subtraction has something to key on.
`NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of
identities is what makes the reconciliation exact rather than a lower bound.
Regressions cover disjoint windows, overlapping windows, and a request one
window named while the other could not.
* fix(network): keep unnamed-request identities out of the response
`unnamedRequestIds` collected every unresolved task in the scan window and
was spread straight into the response, so `network dump 1` could answer
with thousands of task ids: an output whose size tracked the log rather
than the requested entry limit.
The identities exist to reconcile two scan windows, which is a step that
finishes before a dump is returned. Keep them there. `NetworkDump` carries
`unnamedRequests` as a count again, bounded by construction; the identities
ride `ScannedNetworkDump`, the internal widening that the reader and the
merge speak, and the Apple runtime projects them away with
`withoutScanIdentities` on the way out.
Reconciliation is unchanged: overlapping windows still collapse to one
request and disjoint windows still sum, because the merge still sees the
identities and recomputes the count from them.
Regression: five unnameable tasks against `maxEntries: 1` reports all five
and exposes no identity list.
* fix(network): return scan identities beside the dump, not on it
The Apple route stopped leaking task identities into its response, but
Limrun and WebDriver return the scanner result directly and both serve
Apple sessions, so an iOS `network dump 1` through either still answered
with every unresolved task id in the scan window. Projecting at one
producer was never going to hold: `ScannedNetworkDump` was assignable to
`NetworkDump`, so returning the scanner result compiled everywhere and
each producer had to remember not to.
Take the shape away instead. `readRecentNetworkTrafficFromText` returns a
`NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside
the public dump rather than on it, and `mergeNetworkScans` reconciles the
pair. A route returning `scan.dump` cannot carry them out, and a route that
forgets does not compile. All four producers are updated; the response
shape is unchanged.
Regressions cover the Apple, Limrun and WebDriver routes: five unnameable
tasks against `maxEntries: 1` report the count and expose no identity list.
All three fail if the identities are put back on the dump.
A command that hand-wrote its synopsis had to restate every option it
accepts inside that string, which is the last restatement left on the help
surface after #2421 made the flag declaration own the option itself.
A synopsis is now grammar plus a generated `[label]` tail, and the two
rendering rules live on the declaration rather than per command:
- the tail names an option with its declared `usageLabel`, alias included,
the token the `Command flags:` section already shows;
- `usageHidden: true` keeps a cross-cutting opt-in out of every synopsis;
`--record` is the one today, and it stays under `Command flags:`.
`usageFlags` is where a command states that its synopsis names fewer options
than it accepts: `[]` for a synopsis that is pure grammar or writes its own
mutually-exclusive brackets, otherwise the subset it names. `Command flags:`
still documents everything in `allowedFlags`. Adding an option to a command
therefore updates `--help` on its own, except where the command said its
synopsis stays short.
`snapshot` and `proxy` drop their override; `daemon`, `device`, `doctor`,
`prepare`, `tv-remote`, `scroll` and `artifacts` drop the flag brackets from
theirs. Guards fail a tail that names an option the command does not accept,
or one the hand-written grammar already wrote.
Every synopsis except `snapshot` and `is` is byte-identical; those two move
exactly per the rules above, and the canonical `snapshot` docs line follows
the generator.
Closes#2444
* fix(apple): bind perf process selection to the resolved executable
* docs(perf): clarify that executable scoping includes sampling
* fix(apple): load perf process identity only when sampling
* fix(android): honor scroll releaseBehavior to stop the fling overshoot
scrollAndroid ignored ScrollReleaseBehavior and always released the pan
like a fling, so a controlled `scroll` overshot its requested distance
by ~60% on both a plain RecyclerView and an RN ScrollView. Android's
VelocityTracker treats a truly stationary release as a resampled
duplicate and ignores it, so a hold alone can't stop the fling.
For the default 'controlled' release, append a short tail after the
pan's endpoint that holds the scroll axis exactly fixed (zero velocity
there) while nudging the orthogonal axis every frame so no two
consecutive samples repeat. 'inertial' (the scroll top/bottom edge
passes) is unchanged. Live-measured on a Pixel 9 Pro XL emulator:
displacement lands within touch-slop of the requested drag instead of
overshooting it.
Fixes#2371
* fix(android): cap the controlled-release tail at the gesture duration ceiling
The release tail was appended after buildGesturePlan's own duration
validation, so a maximum-duration (10000ms) scroll silently produced a
10160ms plan beyond GESTURE_DURATION_MAX_MS. Cap the tail so the
dispatched plan never exceeds that shared ceiling, shrinking (and, at
the exact maximum, dropping) the tail near the boundary instead of
truncating the requested move. Add regression coverage for the exact
maximum and near-maximum cases.
Also fix the stale android-lifecycle provider-integration expectation:
a plain scroll resolves to 'controlled' release by default, so its
dispatched plan is now 510ms (350ms move + 160ms tail), not 350ms.
* fix(android): reject a controlled scroll that leaves the release tail no room, instead of shrinking it
The previous fix capped the tail near GESTURE_DURATION_MAX_MS, which
silently dropped the fling-suppressing braking step for scrolls close
to the ceiling -- defeating the point of the fix exactly where a long
scroll needs it. A controlled scroll's own accepted duration range now
stops CONTROLLED_RELEASE_TAIL_MS short of that shared ceiling
(9840ms), rejecting anything past it with a clear INVALID_ARGS error
instead of truncating the move or the tail. Every accepted controlled
scroll now runs the full, unshortened tail; 'inertial' releases are
unaffected and keep the full 10000ms range.
Also drop the multi-line explanatory comment on the corrected
android-lifecycle.test.ts expectation (a same-line comment instead)
so the fix doesn't grow that file past its line-count tripwire.
* docs(scroll): describe reduced momentum and the Android controlled-scroll limit
The scroll docs only carried the generic app-physics caveat, not the
release-behavior contract itself. State plainly that scroll releases
with reduced momentum rather than an exact stop, and document
Android's controlled-release durationMs ceiling (9840ms, reserved
headroom for the braking tail added in #2371) and that a longer
request is rejected rather than silently truncated.
* feat: support standalone Maestro clearState command
Accept '- clearState' / '- clearState: <appId>' in Maestro YAML flows.
Unlike launchApp.clearState (clear-then-open), the standalone form clears
app state without relaunching, projecting to 'settings clear-app-state'
on the daemon. Covers the Rocket.Chat login-with-deeplink helper, which
previously failed with 'Maestro command "clearState" is not supported'.
* test(maestro): cover standalone clearState with authored corpus flow
Replace the UNVERIFIED_COMMANDS exemption with an authored
clear-state flow exercising default and explicit appIds, plus the
regenerated upstream parser fixture proving Maestro compatibility.
Live iOS Simulator evidence (iPhone 16, com.apple.mobilesafari):
- marker files in the data container, then replay '- clearState'
(default) and '- clearState: <appId>' (explicit) via
'replay --maestro'; both replay 1/1, wipe the container, and leave
MobileSafari not running (no reopen).
* fix(ios): honor the startup budget through a cold Simulator boot
A never-booted Simulator runs Apple's first-boot migration, which can take
minutes, but the boot wait was capped at a fixed 120 seconds that neither
`prepare --timeout` nor `open` could reach (#2324).
- The boot wait takes an absolute deadline. `prepare --timeout` now covers the
boot and the runner preparation as one budget; `open --timeout` is new and
bounds the boot. Expiry fails with `boot_timeout` and leaves the Simulator
booting, so a retry finds it further along.
- The client envelope for open/prepare keeps the 30s margin over the budget so
the daemon's structured timeout wins the race against the client's reset.
- `close --shutdown` no longer trusts the session device's selection-time
`booted: false`; it always asks simctl. A session opened on a cold Simulator
otherwise reported a shutdown that never happened.
Supersedes the original implementation of #2325 by @PrinceD96 (head 8bdb85b7a3),
which found the bug, the shutdown shortcut, and the validation recipe.
Closes#2324
Co-authored-by: PrinceD96 <53633741+PrinceD96@users.noreply.github.com>
* fix(ios): keep the confirming boot listing inside the startup budget
After bootstatus, the listing that confirms the Booted state ran on its own
15-second timeout and the success path never re-checked the deadline, so a
bootstatus that used nearly the whole budget could still return success past
it. The listing now gets the remaining budget (capped at its own 15s), and a
confirmation that lands after the deadline is reported as boot_timeout.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* feat(ios): support explicit iOS simulator keychain reset
`settings clear-app-state` never touched keychain-backed credentials
(e.g. Firebase auth), so a customer's fresh-install reset via the CLI
left an app signed in when their in-app reset button did not (#2282).
simctl exposes no per-app keychain reset, only a whole-simulator one
(`simctl keychain <device> reset`), so this ships as a separate,
explicit `settings reset-keychain clear` command rather than folding
it into `clear-app-state` — callers opt in knowing the scope is the
whole simulator, not just the app under test.
Split the pre-existing `apps.test.ts` and `snapshot-handler.test.ts`
suites along the `app-settings.ts`/`snapshot-settings.ts` modules they
actually mirror, since both were already over the test-file-size
tripwire and could not grow further.
* fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture
settings reset-keychain clear <extra-arg> silently dropped the extra
argument in both the CLI reader and the direct-daemon parser, so a
caller expecting per-app scoping could get a whole-simulator wipe
without any signal something was off. Reject it instead in both
places, with tests proving no settings mutation happens.
Also add a small keychain-backed "auth" fixture to the test-app's
automation lab (expo-secure-store) so the settings reset-keychain
guarantee has a real regression surface: authenticate, verify the
credential survives clear-app-state and a plain relaunch, then verify
reset-keychain actually clears it. Validated live against a disposable
iOS simulator.
* fix(ci): stop a bare gradle.properties append from corrupting the last line
expo prebuild's generated android/gradle.properties has no trailing
newline, so `echo "org.gradle.jvmargs=-Xmx4g" >> gradle.properties`
appended directly onto its last line instead of a new one, producing
expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=-Xmx4g.
Gradle's JSON.parse of that property then fails at configure time,
before any real compilation runs -- the exact "Process 'command
'node'' finished with non-zero exit value 1" failure this branch hit
on Android Release and the Smoke Tests fixture-app fallback build.
This was a dormant bug: the Android build-cache job only runs on a
fingerprint miss, and no PR had changed the test-app's native
dependencies in a while. Adding expo-secure-store (#2282's keychain
fixture) was enough to trigger it. Reproduced locally against a clean
install with the exact CI script, confirmed the corrupted property,
and confirmed the printf-based fix builds cleanly (870/870 tasks).
* feat(wait): carry a per-poll timeline in timeout failures
A wait timeout said `reason`, `readableCaptures`, and `waitedMs`, so a
failure could not say where its budget went: the runs behind #2343 spent
a 10s budget on one poll (5.8s runner findText on a fresh app, 3.4s of
target discovery, a fallback cancelled at the deadline) and reported the
same `wait_capture_stalled` as a dead runner. The failure details now
carry `captures` and `polls[]`, one entry per poll with `startedMs` on
the wait's own clock, `durationMs`, and a typed outcome (readable,
unreadable, deadline, runner-restart), next to the unchanged reason and
the request-log link. Long waits keep the first five and last twenty-five
polls so the response stays compact.
* docs(wait): name the polling timeout paths that carry the poll timeline
Review follow-up: wait --stable uses its own error builder and a
never-readable strict absence preserves its predicate failure, so the
timeline is documented for the polling timeout paths that emit it.
* feat(wait): carry the poll evidence on the replay landmark-mismatch refusal
Review follow-up. A replayed selector wait refused for a recorded
landmark mismatch threw without the captures/polls evidence, and when
its final poll ended in a runner restart the refusal hid that outcome.
The refusal now carries the same failure evidence a timeout does, next
to its mismatch details; two regressions cover a mismatch followed by a
deadline-cancelled capture and by a runner restart. Docs and changelog
name the refusal alongside the polling timeout paths.
* fix(ios): avoid replaying alert mutations
* docs(alert): state the single-send rule once, without a backend qualifier
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* fix(android): preserve editable-field observation metadata
* fix(android): carry field facts through the attrs digest and selection offsets past editability
- `get attrs --level digest` kept only the pre-#2288 semantic fields, so
`editable`/`password`/`hintShowing`/`selectionStart`/`selectionEnd`
vanished on the token-cheap route. The digest now keeps them, with a
regression covering explicit false/zero/empty and omission when unavailable.
- The helper emitted selection offsets only inside `isEditable()`, but
read-only selectable text exposes a selection too. Each nonnegative offset
is now emitted independently; -1 stays absent. Parser-to-snapshot regression
for a non-editable selectable node.
- Docs: the field-metadata notes get their own section instead of leading the
efficiency tips.
- Dropped the test-isolation commit: main already mocks
notifyIosRunnerAppRelaunched, and the lifecycle test passes without it.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
* feat(screenshot): add crop-on geometry core and cropTarget selector rows
* feat(screenshot): declare crop-on flag, script round-trip, and snapshot runtime plan
* feat(screenshot): run the crop leaf after the platform write and before scale
* feat(screenshot): expose --crop-on in the CLI and surface crop warnings
* chore(gates): declare crop-on capture-kit subpaths and scope the crop scenario exemption
* refactor(screenshot): split crop target/policy module and trim redundant coverage
Address review comments at 570da2c417:
- Split the 328-line screenshot-crop.ts leaf: the target acceptance matrix,
classifier, and pre-device argument policy move to screenshot-crop-target.ts,
so both implementation modules meet the 300-line target.
- Reuse kernel isPositiveFiniteRect/rectArea in the rect-projection module
instead of redefining them locally.
- Drop the crop-on CLI forwarding case (redundant with screenshot-options
flag-mapping coverage + the generic dispatcher) and the transport-based
warnings case, replacing the latter with a focused screenshot-result unit
test. This also returns the two legacy aggregate test files to their
merge-base length for the test-file size ratchet.
* refactor(screenshot): extract macOS crop-target decision to keep classifier under the complexity budget
classifyAppleCropTarget inlined the macOS surface decision, pushing its
cyclomatic complexity to the fallow threshold. Move it back out to a
small helper so the target classifier stays within budget.
* refactor(screenshot): dedupe the meaningful-signal predicate and polish png-crop
- Hoist isMeaningfulSignal into @agent-device/contracts/snapshot (next to
normalizeType/isMeaningfulLabel) so the ref overlay and the crop
rect-projection share one copy instead of each carrying an identical
private predicate. Behavior is unchanged.
- png-crop: isCropBox was a no-op 'box is Rect' predicate (input already
Rect) — make it a plain boolean, and tighten the doc to the contract.
* refactor(screenshot): drop the dead crop outcome flag and cover the projection seams
- ScreenshotCropOutcome.cropped was a constant true that no caller read;
the crop either returns (success) or throws, so the outcome reduces to
the partialIntersection observation.
- resolveScreenshotRectSpace and resolveSnapshotBounds were the only
projection exports without coverage: pin the accepted-backend map, the
unaccepted-backend typed refusal, and the viewport-root / union / empty
bounds branches.
* fix(android): apply settings airplane through the connectivity service
settings airplane wrote airplane_mode_on and then broadcast
android.intent.action.AIRPLANE_MODE, which Android refuses for non-system
callers. The write landed, the broadcast failed, and the device reported
airplane mode with the radios still up.
The connectivity service now owns the change: it is read to prove the build
supports airplane mode before anything is written, driven with
cmd connectivity airplane-mode enable|disable, and read again so the response
reports the mode connectivity holds rather than the one requested. Builds
without that command are refused unmutated with UNSUPPORTED_OPERATION.
Closes#2223
* test(android): pin the mechanics eager closure at 178 modules
Splitting the airplane owner out of settings.ts adds one module to the
mechanics facet, which is implementation-eager by design. The row moves to the
measured number in the PR that grows it.
* fix(android): report only capability absence as unsupported airplane mode
An unrecognized nonzero probe — a permission denial, a connectivity-service
error — was answered with "requires Android 11; use a newer device". Only the
prose adb prints when a build ships no shell implementation for the command
now selects UNSUPPORTED_OPERATION; every other failed read stays
COMMAND_FAILED with its classified hint, and the write is unreachable from
both.
The predicate that reads that prose already existed for the clipboard service
and is now named for the question it answers, so airplane mode reuses it
instead of adding a second message sniff.
* feat: add stale device claim release and dead-end recovery guidance
Close the #1320 recovery loop for claims no daemon can settle on its own:
- agent-device device release --stale settles a provably dead owner's durable
resources through the same exact-owner reconciliation open and daemon
startup use, then clears the claim last — daemonlessly, composing a
local-only platform gateway in the CLI process. Live, uncertain, PID-reused,
and corrupt claims always fail closed and are reported with the reason.
- DEVICE_IN_USE conflicts whose recorded owner provably cannot release
(dead or superseded) now carry the exact release command as their recovery
instead of a status inspection that dead-ended.
- device status --stale now offers the matching release command when provably
dead owners are listed.
- daemon stop now warns in text output when a claim was orphaned (previously
visible only via --json) and names the status/release commands.
Part of #1320.
* test: cover release refusal branches, text rendering, and orphan warnings
Changed-line coverage on the stale-release slice was 69.33% against the 70%
gate: the refusal-reason branches, the text-mode outcome rendering with the
live-owner hint, and the daemon stop orphaned-claim warning had no tests.
Cover them directly; the misnamed-claim-file refusal is also pinned.
* test: prove resources gate stale release and the scan-to-lock race stays closed
Review follow-ups on #2162:
- Two end-to-end CLI regressions run device release --stale through the real
local gateway against a dead owner whose state dir holds an attributable
durable screen-recording/app-log record: an owner-mismatched record and
unreadable resource evidence both retain the claim (app-log-owner-mismatch,
app-log-descriptor-invalid), proving cleanup must reach a terminal state
before the claim can be deleted.
- A deterministic race regression holds the per-device claim lock while a
release is in flight, replaces the claim with a successor before releasing
the lock, and proves the transaction reports changed without reconciling or
touching the successor's claim.
* fix: bind stale-claim recovery to the dead owner's state dir
Review P1 on #2162: the CLI composed one gateway from the caller's state
dir, so recovery for a foreign stale claim could clear the caller's live
owned-process record when both used the same session name — Apple recording
cleanup clears by session id through the gateway-composed store.
Recovery is now composed per claim, with the owned-process record store and
session artifact paths bound to the stale claim's recorded state dir, and
disposed after each transaction. The regression writes two dead claims with
one shared session name in different state dirs and proves each recovery is
composed from that claim's own state dir, never the caller's.
An auth hook that ran but returned no tenantId opted the deployment into
tenant attestation; falling back to the client's own claim (RPC body
meta.tenantId, aux-route x-agent-device-tenant header) let a holder of one
valid shared token impersonate any tenant on /rpc and on the diagnostics/
upload/download routes. resolveTrustedTenant() in the new
src/daemon/server/tenant-trust.ts is now the single seam both surfaces go
through and the only place that computes the resulting identity: hook
attests -> use it; no hook configured -> keep today's client-declared
behavior (loopback/dev unchanged); hook configured but silent with a
client-declared tenant -> refuse (401) instead of trusting the claim, and
no raw client-declared metadata survives into the dispatched request in
that case either.
Fixes#2095
* feat: add deterministic device selection resolver
* test: adapt open selection harnesses
* chore: keep context glossary within budget
* fix: separate device identity from selection filters
* refactor: make the selection resolver the sole owner of selection provenance
Simplifies the deterministic device-selection resolver (net -114 lines vs the
previous head) while fixing the outstanding app-aware provenance finding:
- Move the booted-simulator app-affinity narrowing into the resolver behind an
appleSimulatorAppTarget param, with its own typed reason
'single-app-installed-local' (candidateCount 1). This removes the
selectedDevice escape hatch that reported 'preferred-local' with
candidateCount 2 for the app-narrowed pick, and gives the app-match errors
the same platform-aware retry selectors as every other selection failure.
- Delete dead code: the allowBootableLocal param (no caller ever passed it, so
the eligibleDevices filter was unreachable), the hasExplicitProviderIdentity
alias, the duplicated deviceCandidateDetails in dispatch-resolve, and the
double candidate computation.
- Shrink the public selection contract to what #1777 specifies: drop `booted`
(it contradicted its own doc comment once markSelectionBootedAfterPreparation
flipped it; bootOccurred plus the reason codes carry the same information)
and drop `retrySelectors` from success metadata (the daemon only ever emits
retry selectors inside error details). DeviceSelectionRetrySelector leaves
the contracts facade.
- Replace the typeof-import lazy seam and resolver threading through four
context objects with one lazy forwarding wrapper; the dispatch eager closure
stays at 83.
- Collapse the Apple path to resolve -> optional simulator fallback; the
provider branch goes through the generic resolver call directly.
- Consolidate the five copy-pasted resolveTargetDeviceSelection test mocks
into one shared stub (selectionFromResolveTargetDevice).
- Add the requested regression: two booted simulators with the app on one now
assert typed selection metadata through resolveTargetDeviceSelection, plus a
resolver-level app-affinity provenance test.
Validation: typecheck, oxlint, oxfmt, layering (184-check guard OK), DI seams,
fallow changed-files, eager-closure 235/235, daemon suite 323 files / 2287
tests, core/commands/mcp/client suites 255 files / 2133 tests.
* feat(maestro): support assertTrue phase 1 - literal/${VAR} truthiness (#1295)
Adds the assertTrue command, scoped to literal values and bare ${VAR}
lookups per the #1292 lookup-only decision; JS expressions keep
failing loud at parse time with a runScript hint. Truthiness on a
looked-up value is evaluated against a pinned falsy-string table
("", "false", "0", "null", "undefined") since flow config/env/
runScript-output values are always stored as strings, rather than
native JS truthiness (which would treat "false" as truthy).
Wires assertTrue through the parser, interpreter, optional/warning
composition, and the layer-1 conformance oracle, narrowing the
067_assertTrue_pass divergence to the JS-expression case and removing
the now-satisfied 076_optional_assertion entry. Also materializes
scrollUntilVisible's default direction in the conformance canonical
projection, a latent gap only exposed once 076 could fully compare.
* fix(maestro): correct assertTrue truthiness claim in CLI help/docs
The support-matrix text said assertTrue is "evaluated with JS
truthiness", but the engine actually uses a pinned falsy-string table
("", "false", "0", "null", "undefined") since looked-up values always
arrive as strings — native JS truthiness would treat "false" as
truthy. Spell out the actual rule instead of the misleading claim.
* fix(maestro): fix oxfmt quote-style violation in expected-divergence.ts
CI's format gate failed on a single-quoted string containing an
apostrophe; oxfmt prefers double quotes there.
* fix(maestro): bump eager-closure-budget pin for the new truthiness module
engine-truthiness.ts is a genuinely new module on the core interpreter
path (assertTrue is dispatched unconditionally by
replay-plan-step-execution.ts), so packages/maestro/src/index.ts now
eagerly evaluates 105 modules instead of 104 — a deliberate growth,
not a laziness regression.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(ios): verify fill's synthesized-replacement route before reporting success
The channel-penalized fill route (runSynthesizedReplacementRoute, taken when
the XCTest accessibility channel is already penalized under load) posted the
synthesized keystrokes and returned ok:true without ever reading the field
back, so a dropped or still-in-flight character was indistinguishable from
success. This is the same class of bug already fixed for bare `type`
(#1676/#1924), but that fix never covered this fill-only route.
Reusing type's append-mode commit-wait verbatim would have been wrong: its
"observed value isn't a prefix of expected -> trust the app" rule exists to
tolerate legitimate transforms (autocomplete, formatters) during append, but
it also waves through a dropped-middle-character corruption, since a value
with a hole in it is neither a matching prefix nor an exact match. Verified
against the real corruption strings ("Ada Lovelace" -> "Avelace", "ada@example"
-> "aexample") that the old rule would classify both as "trust it" and never
fail. Added a separate replacement-mode outcome function with no such
escape hatch, consistent with how isRepairableTextEntryMismatch already
treats every .replacement-mode mismatch as failing/repairable unconditionally.
Verified on a real iOS Simulator via xcodebuild test-without-building, not
just a build: the new regression test proves the old model accepts both
corruption strings while the new one correctly reports commit-not-observed,
and the full pre-existing append/type test suite passes unchanged.
* fix(ios): restore labeled observe-closure call sites for the redaction guard
The previous shared-plumbing refactor passed each route's outcome function
as a stored closure parameter, which erases Swift argument labels at the
call site. That broke the CI "Coverage" check's static content-redaction
test (apple-runner-log-redaction.test.ts), which locates the commit wait's
observe closure by its literal `observe: {` label to verify it only logs
polled field content through the value-free logCommitCadence boundary,
never a raw NSLog.
Restructured so the shared placeholder/deadline/observe/pacing ingredients
are still factored into one place, but each of the two public entry points
(append/type, replacement/fill) now calls its own named outcome function
directly with real argument labels, restoring the labeled closure shape the
guard depends on. Verified locally: the TS redaction test passes, and the
full on-device unit test set (19 tests, iOS Simulator) still passes with
zero regressions.
* fix(ios): report an unobserved text commit instead of a partial success
awaitSynthesizedFirstResponderCommit returned Void, so its three exits were
indistinguishable to the caller: the expected text committed, the app
transformed the input, or the 3s deadline expired with a strict prefix still
outstanding. The caller returned dispatched-with-no-failure in all three, and
`type` answered ok with textEntryRoute synthesized-first-responder over a field
holding part of the requested text.
The wait now returns a SynthesizedTextCommitOutcome and an expired deadline
becomes TEXT_INPUT_COMMIT_NOT_OBSERVED, whose hint points at fill rather than a
type retry — type appends, so retrying it concatenates onto whatever committed.
The tail is still not re-synthesized: #1676 rejected that because a stalled
prefix cannot be told apart from a suffix still queued, so repair double-posts.
Reporting is what the runner does instead.
typeIntoCurrentTarget loses its `dispatched` flag, which was exactly
`failure == nil` and could not express the new state — characters posted, commit
unconfirmed, command must refuse. Failure is now the single discriminator.
The decision moves behind an injected clock/observer so the deadline branch runs
in the macOS host lane on every PR instead of needing a simulator.
Refs #1874, #1844
* fix(ios): close false-failure windows in the commit wait
Adversarial review found two deterministic false failures in the wait added by
the previous commit, plus a message that asserted a field state never read.
The deadline was checked before observing, so a commit landing during the final
poll sleep was condemned as never observed — under exactly the loaded-host
timing the wait exists for. The check now runs after an observation, so the last
thing before condemning is a read.
`treatingPlaceholderAsEmpty` maps a value equal to the field's placeholder to
"", a prefix of every expected value. `type "0.00"` into a field placeheld
"0.00" committed instantly, read as pending for the full 3s, and failed. The
observation now settles on an exact raw match; the normalized read still drives
the prefix walk.
The outcome-to-failure mapping moves to textEntryFailure(forCommitOutcome:) so
the branch the command refuses on is pinned by a test rather than living only in
a ternary. `.unobservable` staying a success is what keeps `type "...\n"`
working, and it now has an assertion.
Message and hint no longer claim the field holds a partial value: under both
fixed windows it may hold all of it. The docs sentence no longer implies every
text-entry route verifies its result — the replacement and keyboard-visible
routes have no resolvable element to observe and are unchanged.
Refs #1874, #1844
* test(ios): pin the placeholder fix at the boundary it actually lives on
Review [P1]: testValueEqualToThePlaceholder… injected an observe closure that
already returned "0.00", so it never supplied the normalized "" that causes the
failure. The raw-value short-circuit lived in the production observe closure,
which that test bypassed entirely — reverting the fix left it green.
The raw-exact/normalized-prefix choice moves into commitObservation, and the
test drives it with (raw: "0.00", normalized: "", expected: "0.00"). Reverting
commitObservation to always return the normalized reading now fails the
exact-match assertion.
normalizedValue is a closure rather than a value so an exact match still costs
one accessibility read instead of two, on a path that polls every 20ms for up to
three seconds; a second test pins that laziness.
The old test is deleted rather than kept: its remaining assertion (an exact
match settles without polling) is already covered by
testSynthesizedCommitStopsAtTheFirstSettledObservation.
Refs #1874
* fix(ios): never treat placeholder equality as commit evidence
Review [P1]: an empty text field renders its placeholder AS its accessibility
value, which is why editableTextValue(treatingPlaceholderAsEmpty:) classifies
that value as empty. The previous revision's raw-exact short-circuit therefore
matched BEFORE anything committed whenever the requested text was the
placeholder: `type "0.00"` into a field placeheld "0.00" settled on the first
read and returned ok with zero characters delivered — reintroducing the
success-misdescribes-the-device failure this PR exists to remove.
The state is structurally indeterminate. element.value is identical whether the
placeholder is rendering or the committed text happens to equal it, and
placeholderValue does not disambiguate, so no read resolves it and waiting the
deadline out discovers nothing. placeholderMakesCommitUnobservable detects it up
front and reports the commit unobserved, which the caller refuses on.
commitObservation is deleted rather than narrowed: the raw match was only ever
consulted in this exact case, and in this exact case it is not evidence.
The failure message drops its deadline reference — this refusal never waits.
Refs #1874
* fix(ios): scope the placeholder refusal to an empty baseline
Review [P1]: the guard took only the placeholder and the expected text, so it
refused any append whose result happened to equal the placeholder. Value "0" +
`type ".00"` against placeholder "0.00" was refused before a single read, even
though the non-empty pre-dispatch value proves the placeholder is not what is
rendering and a later "0.00" is genuine commit evidence.
The baseline is what decides it, so it is now an input. placeholderCommitEvidence
returns three states rather than a boolean:
normalRead expected differs from the placeholder; the placeholder never
enters into the observation
indistinguishable expected IS the placeholder and the field was empty, so the
placeholder was what rendered and no read can resolve it
rawValueIsEvidence expected IS the placeholder but the field held content, so a
raw match is real
Only .indistinguishable refuses, and it still refuses before the wait, since no
read resolves it. .rawValueIsEvidence reaches the observation and settles on the
raw match, which the normalized read would otherwise hide.
commitObservation returns for that third state, now scoped by evidence rather
than applied unconditionally as in the revision that made raw equality a
false success. Both readings stay closures, so normalRead — the ordinary case —
never pays for the raw read.
Refs #1874
* fix(ios): keep placeholder-equal commits conservative