Repository navigation
feat(cache): fallback relocation ladder and recorded replay pacing - #805
okwasniewski wants to merge 23 commits into
Conversation
🟢 e2e web: 91 passedAll 91 tests in 41 files
e2e 0.17.0 · 2m 2s · web · run artifacts |
🟢 e2e web agent: 33 passed34 agent steps · 26 replayed from cache · 20 model calls · 76.3k tokens (92% cached) · $0.0035 All 33 tests in 5 files
e2e 0.17.0 · 3m 26s · web · run artifacts |
There was a problem hiding this comment.
All reported issues were addressed across 32 files
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Re-trigger cubic
🟢 e2e android: 54 passed, 1 skippedAll 55 tests in 7 files
e2e 0.17.0 · 9m 8s · android-emulator · run artifacts |
🟢 e2e android agent: 20 passed24 agent steps · 14 replayed from cache · 45 model calls · 247.9k tokens (81% cached) · $0.0179 All 20 tests in 1 file
e2e 0.17.0 · 8m 3s · android-emulator · run artifacts |
🟢 e2e ios: 1 flaky, 51 passed, 1 skipped
|
| Test | Time | |
|---|---|---|
| tests/controls.e2e.ts · 1 flaky, 22 passed, 1 skipped | 3m 13s | |
| 🟢 | control inventory › a label query resolves a labeled text field | 9.3s |
| 🟢 | control inventory › a labeled field answers name, display value, value reads, fill, and clear | 13.7s |
| 🟢 | control inventory › a placeholder query and attribute reads find the field | 4.9s |
| 🟢 | control inventory › a secure field takes a fill and denies value reads | 9.5s |
| 🟢 | control inventory › press sends a character and Enter through the soft keyboard | 7.4s |
| 🟢 | control inventory › a field autofocuses on mount and focus() moves the focus | 11.1s |
| control inventory › toBeFocused follows the focus (2 failed attempts first) | 12.6s | |
| 🟢 | control inventory › a switch checks, unchecks, and reports its state | 11.1s |
| 🟢 | control inventory › checkbox and radio roles answer getByRole, and a checkbox toggles by tap | 9.1s |
| 🟢 | control inventory › checkbox and radio roles check, uncheck, and report their state | 14.8s |
| 🟢 | control inventory › a radio group reports the checked option | 11.9s |
| 🟢 | control inventory › buttons report disabled, enabled, and a state that arrives later | 10.7s |
| 🟢 | control inventory › a display: none element is absent rather than hidden, its visible twin attached | 8.2s |
| 🟢 | control inventory › a header answers its accessible name | 5.0s |
| 🟢 | control inventory › a tab row answers count, all, texts, filters, positions, and switching | 7.1s |
| 🟢 | control inventory › a list answers counts, list-form text, reads, and boxes in order | 7.6s |
| ⏭️ | control inventory › a row contains its texts (skipped: React Native flattens a plain View out of the accessibility tree on both platforms: XCTest and the Android helper report a View with a testID as a leaf beside its children, so filter({ has }) and a query scoped to it match nothing; only Sc…) | |
| 🟢 | control inventory › a long press lands as one | 9.1s |
| 🟢 | control inventory › pointer verbs land at the point asked for | 10.4s |
| 🟢 | control inventory › an ambiguous locator fails at once | 6.3s |
| 🟢 | control inventory › screenshot, restart, and clearState reopen the app on its home list | 11.2s |
| 🟢 | refusals › verbs a device never declares are refused before the engine | 3ms |
| 🟢 | refusals › keys the soft keyboard cannot send are refused by the engine | 815ms |
| 🟢 | refusals › a missing locator fails as not found once its timeout passes | 1.3s |
| 🟢 | tests/device-fixture.e2e.ts · 9 passed | 1m 17s |
| 🟢 | device fixture › setAppearance flips the color scheme the app reads | 10.4s |
| 🟢 | device fixture › setOrientation rotates the window the app measures | 6.4s |
| 🟢 | device fixture › the clipboard reads back what was written | 4.7s |
| 🟢 | device fixture › home leaves the app, openApp brings it back where it was | 6.3s |
| 🟢 | device fixture › closeApp then openApp relaunches on the home list | 7.8s |
| 🟢 | device fixture › foregroundApp after closeApp reports the closed session as APP_NOT_OPEN | 4.6s |
| 🟢 | device fixture › device.back pops the scenario | 5.9s |
| 🟢 | device fixture › setLocation and clearLocation change what the app reads | 14.8s |
| 🟢 | device fixture › enrollBiometrics and setBiometrics answer a biometric prompt | 16.5s |
| 🟢 | tests/device.e2e.ts · 2 passed | 53.0s |
| 🟢 | sequential onboarding echoes the exact values | 38.9s |
| 🟢 | onboarding rejects a malformed email before advancing | 14.1s |
| 🟢 | tests/flows-lists.e2e.ts · 7 passed | 3m 32s |
| 🟢 | infinite scroll reaches the target item | 29.6s |
| 🟢 | product catalog adds exactly two of the right variant | 10.7s |
| 🟢 | gestures: long-press, then swipe left | 9.8s |
| 🟢 | sticky chrome: accept below the fold, then continue | 11.0s |
| 🟢 | async states: load, pull to refresh, claim through the toast | 12.7s |
| 🟢 | huge virtualized list reaches row 512 | 2m 6s |
| 🟢 | web view coupon form applies the code shown on the page | 12.9s |
| 🟢 | tests/flows.e2e.ts · 7 passed | 1m 12s |
| 🟢 | text inputs unlock the submit button | 15.7s |
| 🟢 | filled text inputs report their values | 9.8s |
| 🟢 | modal flow confirms through the native alert | 10.1s |
| 🟢 | bottom tabs act inside the last tab | 7.8s |
| 🟢 | error recovery retries, then confirms the delete | 9.7s |
| 🟢 | choice controls place the exact order | 8.1s |
| 🟢 | debounced search selects the target once results arrive | 10.6s |
| 🟢 | tests/login-form.e2e.ts · 3 passed | 45.6s |
| 🟢 | login form › rejects a malformed email | 11.9s |
| 🟢 | login form › rejects an unknown account | 12.1s |
| 🟢 | login form › signs in and logs out | 21.6s |
| 🟢 | tests/smoke.e2e.ts · 1 passed | 9.5s |
| 🟢 | home lists the scenarios and opens one | 9.5s |
e2e 0.17.0 · 5m 59s · ios-simulator · run artifacts
🟢 e2e ios agent: 21 passed, 1 skipped25 agent steps · 13 replayed from cache · 57 model calls · 389.2k tokens (80% cached) · $0.0285 All 22 tests in 1 file
e2e 0.17.0 · 10m 36s · ios-simulator · run artifacts |
There was a problem hiding this comment.
All reported issues were addressed across 36 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
[claude-opus-5-5] responding on behalf of okwasniewski Ready for review. One thread left for you: volatile-only anchors are now compared by shape ( |
okwasniewski
left a comment
There was a problem hiding this comment.
On 841a384:
Can the label shape rung move a replay onto a different option? AGE_PATTERNS and the today|yesterday|tomorrow branch of RELATIVE_TIME fold a duration or day anywhere in a label, so Snooze 1 hour and Snooze 3 hours, Last 7 days and Last 30 days, or Deliver today and Deliver tomorrow share one shape. If the recorded option is gone at replay and one sibling with the same role is left, the label shape rung finds it as a single match, labelDriftOnly marks it transient so it is never re-recorded, and anchorShape masks the duration in an end anchor like Snoozed for 3 hours, so the step passes while doing something other than what was recorded. Folding these only at an edge or after a separator (as now already is), or only before ago, would keep a missing option handing off instead.
reviewed 841a384 by Hermes on behalf of @okwasniewski
There was a problem hiding this comment.
All reported issues were addressed across 10 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
22fe2b4 to
61d83c2
Compare
There was a problem hiding this comment.
All reported issues were addressed across 28 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
859167c to
cd9f3ba
Compare
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
okwasniewski
left a comment
There was a problem hiding this comment.
On 236ebb8:
Can the new replay retry tap a mobile button twice? The retry in packages/e2e/src/agent/replay.ts:454 fires for any failed call that is not ACTION_MAY_HAVE_COMMITTED and not a policy or argument refusal, and dispatched only records that the grammar method was called. On mobile, translateError in packages/mobile/src/errors.ts never emits ACTION_MAY_HAVE_COMMITTED and ignores agent-device's details.dispatched, so a runner failure where the input may already have landed (for example an iOS main thread timeout) arrives as APP_UNREACHABLE and is retried. On a Submit or Send button the app then gets the action twice, and the replay passes or hands off on the doubled state. Toggles are covered by the new state filter, other controls are not. Retrying only on positive proof that nothing was sent (NODE_STALE, NOT_ACTIONABLE, or dispatched: 'no'), or mapping dispatched: 'unknown' and a missing dispatched on action calls to ACTION_MAY_HAVE_COMMITTED in the mobile engine, would keep the promise that a possible commit never repeats.
The duration folding from the last review is gone with the label shape rung.
reviewed 236ebb8 by Hermes on behalf of @okwasniewski
…xies and side channels From the strict review of #805: - replay runs every grammar call through one dispatch helper; the three Proxy wrappers and the paceNext set/clear protocol are gone, the dispatcher scopes a change wait to one call (withChangeWait), and the step cache marks its entry consumed through onDispatch - record() returns a settle note the change wait carries; ArmedChange, armedChange/noteSettled pass-throughs, the cursors, and the public onChangeSettled field are gone; the keep-path pacing backfill is dropped - replayTrace's switch moves to runPlanned, the retry policy to retriesAfter, FallbackSighting to a pure sightingKey; step-cache's replay booleans become one EntryUse union - toggle state only on taps that flip; field values in anchors compare exactly; readTarget/readAnchor; relocateExact/relocateWithFallbacks - README holds the rules; dated runs and audit history live in the PR
236ebb8 to
b7243db
Compare
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
okwasniewski
left a comment
There was a problem hiding this comment.
On 27bfcc7:
The mobile double tap from the last review is closed: mobile only raises NODE_STALE and NOT_ACTIONABLE before the call reaches the device, so a failed press is no longer retried.
Can a replayed web press still go out twice? locator.press sends the key and then waits for any navigation it started, and that wait logs only elementHandle.press("Enter"), never the performing ... action or waiting for scheduled navigations lines POST_DISPATCH_PATTERN looks for. So Enter on a form whose submit is slow times out after the key landed, classifyActionError in packages/web/src/actions.ts returns NOT_ACTIONABLE, and retriesAfter presses Enter again, which can submit the form twice. fill, clear and selectOption have the same gap. Treating a timeout on those non pointer actions as ACTION_MAY_HAVE_COMMITTED, or adding elementHandle\.press\( to POST_DISPATCH_PATTERN, would keep the retry to proven misses.
The branch now has a merge conflict with main.
reviewed 27bfcc7 by Hermes on behalf of @okwasniewski
Replay matched a recorded control only when every recorded field still held, so a relabeled button with a stable test id handed off to the model on every run. A replay now falls back, most stable evidence first: test id with role, test id, role and name, name across a role family, label shape. Each rung needs exactly one control on two looks in a row; a test id and a name that disagree hand off. A replay that needed a fallback re-records the step unless only a tally or time in a label moved. step.cache.relocated, the run summary, and telemetry count those steps. Also fixes: volatile-only anchors failing their own replay, unique() keys with | or }}, record ids in device screen titles, hash-bang routes, soft assertion failures confirming entries, and a replay cut off by a step timeout keeping its entry. Design and audit in packages/e2e/src/cache/README.md.
…e folded viewport scrolls in full
The rung needed word rules (plurals, relative times, counts vs names) and review already found a false match in it. Controls whose label carries state are better served by a test id. Every rung now compares fields as they read, and any fallback heals in read-write mode.
…shots, one anchor per label Found by replaying recordings on an iOS simulator: - the navigation bar's identifier is the screen title; its label is the back button's text on every pushed native-stack screen - a capture the runner rejects mid-animation is retried after the transition lands; three presentation reasons were not recognized - a recording keeps one end-state anchor per label, since which wrappers iOS reports depends on agent-device's snapshot backend
… toggle's state; re-record iOS entries Found replaying recordings on an iOS simulator: - a replayed tap the engine reports as not dispatched (a list re-rendering, a sheet sliding in) is tried once more after the screen holds still; policy refusals and possible commits never repeat - a tap on a checkbox, switch or radio records the state it flipped, and replays only onto the control in that state, so a recorded correction never flips a control the other way - the committed iOS recordings were keyed under an older engine and never replayed; re-recorded on iPhone 17 Pro, iOS 26.5
…pp and tapped toggle state
The 0.12 release changed the engine minor in every cache key, so every committed recording missed and the strict web agent job failed on main.
…d entries On an Android list the nearest container of a row is the list, whose first text is whichever row is on top: a recording keyed row 512 by Row 0499 and missed it after any scroll. A container key that is the label of another control of the target's role names a neighbour and is dropped. The committed Android recordings were keyed under an older engine and never replayed; re-recorded on a Pixel 7 API 36 emulator.
…apper never displaces its status anchor
…ailed look A look or relocation that throws before the action runs hands off as before; agent-fallback-handoff's failed-capture case caught the retry taking another look.
…xies and side channels From the strict review of #805: - replay runs every grammar call through one dispatch helper; the three Proxy wrappers and the paceNext set/clear protocol are gone, the dispatcher scopes a change wait to one call (withChangeWait), and the step cache marks its entry consumed through onDispatch - record() returns a settle note the change wait carries; ArmedChange, armedChange/noteSettled pass-throughs, the cursors, and the public onChangeSettled field are gone; the keep-path pacing backfill is dropped - replayTrace's switch moves to runPlanned, the retry policy to retriesAfter, FallbackSighting to a pure sightingKey; step-cache's replay booleans become one EntryUse union - toggle state only on taps that flip; field values in anchors compare exactly; readTarget/readAnchor; relocateExact/relocateWithFallbacks - README holds the rules; dated runs and audit history live in the PR
…d; README names what ends a step
…nd-state anchors Anchors split into identity, found with the same ladder as targets, and content (text, value, states, a name that is all a node says), never loosened. A test id still on another node vetoes a name match for anchors too.
There was a problem hiding this comment.
All reported issues were addressed across 23 files (changes from recent commits).
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
3c2fc77 to
b8e78b9
Compare
…scopes anchor candidates
okwasniewski
left a comment
There was a problem hiding this comment.
On dc80df2:
The web press question from the last review is closed for the plain case: POST_DISPATCH_PATTERN now counts the elementHandle.press(...) log line, so a press that timed out waiting on its navigation is reported as possibly committed and not retried.
Can a press that was retried inside Playwright still go out twice? If the input re-renders before focus, Playwright logs element was detached from the DOM, retrying, then on the next pass logs elementHandle.press("Enter"), sends the key, and times out waiting on the navigation it started. classifyActionError in packages/web/src/actions.ts:289 tests DETACHED_PATTERN before the timeout branch, so that log is classified NODE_STALE (retryable), which is in NEVER_REACHED_APP, and replay sends Enter again, e.g. submitting a form twice. Checking POST_DISPATCH_PATTERN before DETACHED_PATTERN, with a test for a log that has the detach line followed by the press line, would keep it uncertain.
reviewed dc80df2 by Hermes on behalf of @okwasniewski
What and why
Like (0 likes)becomingLike (1 like)handed the step to the model on every run, and the entry was refreshed only after a relocation failed.relocateWithFallbacks(packages/e2e/src/cache/locate.ts) now walks a ladder once the exact match finds nothing. The first rung that settles on exactly one control wins.within) and the recorded place among twins. An ambiguous exact match never falls back, an anonymous control has no fallback, and a test id that points at one control while the name points at another hands off as a conflict.sightingKeyinagent/replay.ts), so a page still leaving is never clicked.read-writemode, unless only a tally or time in a label moved (that moves again next run and would rewrite the entry every time).step.cache.relocated(report schema, both fixtures),· N relocatedin the run summary, andagent_steps_relocatedin telemetry count those steps, so read-only CI shows what to re-record.REPLAY_POLICY_VERSIONstaysconservative/6: the exact match runs first and is unchanged, so every existing recording still finds the same node. Committed benchmark recordings replay as before.packages/e2e/src/cache/README.mdis the design doc: lifecycle, key, ladder, anchors, routes, templates, write side, testing map, and the audit below.AGENTS.mdpoints at it.flowchart LR E{exact match} -- one --> F[act] E -- several --> H[hand off] E -- none --> T1[test id + role] --> T2[test id] --> A[role + name] --> R[name in role family] --> N[hand off] T1 & T2 & A & R -- one, same on next look, no test id/name conflict --> Ftest-idaccessiblelink/button,checkbox/switch,textbox/comboboxrole-familyOne locator for every lookup
locateinpackages/e2e/src/cache/locate.ts): one projection per observation, the ladder, the container filter, and the test id veto.REPLAY_POLICY_VERSIONstays: target matching is unchanged and anchors never act, and the committed caches replay as before (below).flowchart LR R[recorded descriptor] --> P[candidates: in container, carrying content] P --> X{exact tiers} X -- match --> V[test id veto] X -- none --> L[fallback rungs] --> V V --> T[target: exactly one or recorded place] V --> A[anchor: any match]Replay pacing
ObservationFeedreports whether any capture of the settled look left the action's starting shape, the dispatcher tells the recorder which action armed the wait, and an action that changed nothing is storedquiet. A note is dropped when it cannot answer for one action alone (another wait still pending, another action before the look).quietaction a 300 ms change wait (QUIET_CHANGE_WAIT_MS,ActionDispatcher.withChangeWait); held-still checks, relocation polling, and the end-state wait are unchanged. The pace is cleared once the call settles, so it never reaches the executor after a hand-off.quietis a timing and never counts as a new flow. Entries recorded before learn it once, from the next recording or from a whole replay. A step whose entry did not serve it (anything but a gap) replaces the entry, so a stale mark cannot outlive it. The committed web benchmark entries gain their pacing in this PR.Found on a simulator and an emulator
other "Flow completed"around the status exists in one backend only), and an iOS screen is named by its navigation bar's identifier: its label is the back button's text on every pushed native-stack screen, so every scenario read as "Benchmark Examples".regular-node-outside-cumulative-clipand two other reasons the engine did not recognize) is retried after the transition lands instead of failing the step withAPP_UNREACHABLE.NODE_STALE,FRAME_NOT_FOUND,NOT_ACTIONABLE). An engine fault or timeout may have landed and hands off. agent-device's untouchable-target refusals (covered_by_interactive_descendants,target_covered,target_bounds_invalid) are nowNOT_ACTIONABLEon mobile (refusedOr), which is what the debounced-search result tap hits; a webcheckwhose click changed nothing, and a webpressthat timed out on the navigation its key started (log ends atelementHandle.press(...)), areACTION_MAY_HAVE_COMMITTED.awaitStartRoute), then settles on it, instead of missing aswrong-context, keeping the step's hand-off reserve. Stripe's demo presents its sheet once a request returns.Row 0499), unreachable after any scroll; a container key that is a neighbour of the target's own role is dropped.Bugs from the audit, fixed here
Saved at 10:42) recorded it verbatim and failed its own replay every run. Anchors now compare by shape (anchorShape).unique()param whose key contains|or}}wrote a placeholder that never parsed back (invalid-entryforever,REPLAY_STALEunder strict). Pointers are escaped; old placeholders still read.Order 48213) never matched across runs; hash-bang routes (#!/a,#!/b) all compared equal.expect.softfailure confirmed the entries staged before it (SoftFailures.verifiedBeforeFirstFailure).Builds on #637 (closed): keeps its twins rule and its drift probe. Its label fold was tried as a fifth rung and dropped (word rules, a false match found in review); its element id rung is left out (framework counter ids typed into the wrong field in its own probe).
Verified
Ran it locally: yes
b5dc0f87), the same entries copied into each build, then replayed against an app that drifted.e2e run --config e2e.probe.config.ts, real model (openai/gpt-6-luna-fast):Saved at <clock>statusend-mismatch, model callSavebecomesSave changes, same test idtarget-not-found, modelrelocated: 1(test-id)Like (0 likes)becomesLike (3 likes), no test idtarget-not-found, modeltarget-not-found, model (no label-shape rung)Settingsbecomes a buttontarget-not-found, modelrelocated: 1(role-family)Incrementclick does nothing (Count: 0)end-mismatch, hand offend-mismatch, hand offDelete item 3gone, onlyDelete item 4target-not-foundtarget-not-found, item 4 untouchedEmailfield id moves toPhonetestidandsettingsmatch exactly (healed entry now readsSave changes).CI=1 --strict-cache, on main and on this branch: 33/33 passed on both,26 replayed · 2 handed off · 4 missedon both, every step's cache mode identical, norelocated.CI=1 --strict-cache, iPhone 17 Pro iOS 26.5 and Pixel 7 API 36:27bfcc7a)26 replayed · 2 handed off · 4 missedaction-failed, Choice Controlstarget-not-found, Stripewrong-contexttarget-not-foundcovered_by_interactive_descendants) and surfaced asENGINE_FAILURE, so it was never retried. NowNOT_ACTIONABLE, retried, replays.pressonNew cardright after the sheet appears does nothing, the same press 1 s later opens the card form, and the node reportshittable: trueat its final rect throughout, so no snapshot signal tells a presenting sheet from a ready one. Follow-up in agent-device (wait for app idle before a press, as XCUITest'stap()does).scrollUntil28.9 s to 20.3 s (-30%). Run to run spread under 2%. ThescrollUntilstep is unchanged in an interleaved A/B (107.3 vs 107.6 s)./armfixture, real Chromium): recording 2.44 to 2.46 s, replay 0.61 to 0.62 s across three runs.end-mismatch, modelend-mismatch, model, evicted, re-recorded next runtarget-not-found, test failstarget-not-foundwhen it flipstest-id