Skip to content

fix(wait): retire native selector bypass and recover text observations - #2440

Merged
thymikee merged 2 commits into
mainfrom
fix/2404-apple-wait-capture
Sep 10, 2026
Merged

fix(wait): retire native selector bypass and recover text observations#2440
thymikee merged 2 commits into
mainfrom
fix/2404-apple-wait-capture

Conversation

@thymikee

@thymikee thymikee commented Sep 10, 2026

Copy link
Copy Markdown
Member

Summary

Selector waits could enter XCTest querySelector before canonical capture and fail while is succeeded. Replay inherited the normal wait route.

Delete the unused findSelector runtime operation end to end: contracts, owner facts, binding plumbing, dispatch, and unreachable tests. Selector waits now use canonical capture across platforms. The runner's interaction-facing selector command remains intact.

Native text-observation failures now defer to capture at the shared wait binding; cancellation and canonical capture failures remain visible. Correct findText runner-demand classification without adding conditional observations to required execution plans.

33 files changed, primarily removal of dead plumbing. Closes #2404.

Validation

  • Head 985701a340: pnpm check:affected --run passed, including 3,753 related tests, 12 documentation tests, format, lint, typecheck, layering, DI, Fallow, and build.
  • Standalone and replay regressions proved red before their fixes for selector and native-text failures. Tests also preserve cancellation, capture errors, fresh polling, and observation-only startup.
  • Live iOS 26.2 simulator, Settings: selector and text waits passed standalone and together in a two-step replay on this head. Session closed; isolated daemon and runner cleaned.
  • Reporter’s EAS app unavailable locally. GitHub CI pending on this head.

@github-actions

github-actions Bot commented Sep 10, 2026

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

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.53 MB 4.53 MB -2.5 kB
Package (unpacked) 4.53 MB 4.53 MB -2.5 kB
Package (download) 1.34 MB 1.34 MB -736 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.8 ms 28.5 ms -0.3 ms
CLI --help 81.9 ms 77.2 ms -4.7 ms

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear code quality review

The behavioral fix is right and the live validation is convincing. But the PR retires an operation by disabling it rather than deleting it, and it leaves the identical defect live on the sibling port. Requesting changes on two structural items.


1. findSelector is now globally dead. Delete the operation, don't flip a fact to false.

After this diff there is not one producer of findSelector left in the repo. Every owner declares it unavailable:

owner declaration
apple nativeSelectorUnavailable (this PR)
android snapshotKindUnavailable
harmonyos snapshotKindUnavailable
web openTargetKindUnavailable
limrun customSnapshotUnavailable / liveSessionUnavailable

All five are unconditional constants, not device-dependent cells. Interactor.findSelector? has zero implementers. PlatformRuntimeOperations.findSelector has zero binders. So waitOperations?.findSelector is now permanently undefined, which makes dispatchConditionalWaitIfNeeded in wait-runtime.ts a permanent no-op and wait-conditional-selector.ts unreachable in its entirety.

The PR description says "Other runtime owners retain their observation contracts." They retain a declaration that the operation does not exist. That is not a contract, it is a row.

What survives, alive and unreachable:

  • src/daemon/wait-conditional-selector.ts — the whole 64-line module
  • src/daemon/wait-runtime.tsdispatchConditionalWaitIfNeeded plus its import and call site
  • src/daemon/selector-operation-binding.tsBoundNativeSelectorRead and the selectedSelector half of selectWaitObservationOperations, which then collapses to a one-key projection shaped exactly like selectElementTextOperation right above it
  • src/daemon/selector-capture-binding.ts and src/daemon/snapshot-runtime-binding.ts — three threading lines each
  • packages/contractsFindSelectorInput, FindSelectorResult, FindSelectorRuntimeOperations, the facts key, the operation name, both conditional: ['findText', 'findSelector'] plan uses, and the unavailable-table row
  • packages/contracts/src/interactor-types.ts — the optional findSelector? method
  • packages/platform-apple/src/runner-demand.ts — the demand classification
  • six owner fact declarations

That is roughly 200 lines of production plumbing kept alive to express "nobody does this," plus a 201-line test file (below) that will guard it indefinitely.

The code-judo move is to delete the operation end to end. This repo has already done exactly that once: the note in ios-world.ts records that R37 retired the same querySelector shortcut for is, and the scripted transcript is the proof the bypass is gone. Finish the same job for wait. The deletion is mechanical, the type checker walks you through it, and it is strictly smaller than the state this PR leaves behind.

To be precise about scope, this is not an argument to delete the runner's querySelector command or queryAppleRunnerSelector. Both stay: the direct-iOS tap path and the offscreen target probe still use them legitimately. Only the runtime operation is dead.


2. findText is the surviving twin of this exact bug, and the PR does not close the class.

interactor.findText calls runAppleRunnerCommand({ command: 'findText' }) — the XCTest runner, same as the retired querySelector did. Nothing between bindAppleFindTextRuntime and wait-text.ts catches a throw:

  • runtime-snapshot.ts awaits interactor.findText(...) bare
  • selector-runtime-backend.ts forwards it bare
  • wait-text.ts awaits runtime.backend.findText(...) bare, inside the poll loop

So on a live-but-poisoned runner — precisely the state in #2404, where the error was XCTest recorded a failure while executing querySelector — a wait text <value> still fails outright while a capture would have satisfied it. hasLiveRunnerSession gates startup, not health, so the admission check does not cover this.

The doc block on bindAppleFindTextRuntime states the invariant explicitly: "All report found: false — 'not proven here' — never an error, so the caller's canonical tree remains the complete path (ADR 0019 section 2)." Today that invariant holds only for the admission conditions the author remembered to enumerate. A runner-level throw walks straight past it.

Right now the invariant is enforced by taste. Make it structural: the one place that forwards a native observation should be total — any failure degrades to { found: false } and the poll consults the canonical tree. One try/catch at the single forwarding seam, and the defect becomes unrepresentable for every current and future native find port, instead of being re-fixed port by port.

Related, and probably the actual mechanism worth putting in the PR description: runner-demand.ts classifies both findText and findSelector as 'simulator', meaning "served by simctl or the host AX bridge and never waits for runner readiness." Both implementations call the runner. readTextAtPoint, which also calls the runner, is correctly 'runner'. That misclassification is a plausible root cause for the wait plan never guaranteeing a healthy runner in the first place. If it is, the same question applies to the rest of the table.


3. wait-conditional-selector.test.ts now tests a shape the product cannot produce.

The 201-line harness hand-forges a DeviceBinding with findSelector available and bound. No owner can produce that binding any more. The new case added here, "an unavailable native selector query cannot fail a capture-backed wait," asserts findSelector was not called — which is now true by construction for every input, so the test can no longer fail.

This is what keeps dead code alive: the module is unreachable, but the suite proves it works, so nothing flags it. Delete the file along with the module in item 1.


4. The docs line documents a transient state.

"Apple selector waits use snapshot capture, including steps inside replay" singles out Apple and implies other platforms differ. After this PR no platform has a native selector wait, so the Apple qualifier is wrong the moment it lands. Either state the behavior platform-neutrally, or drop the sentence — with the operation deleted, capture-backed is simply what wait <selector> means, and there is nothing left to contrast it with.


Verdict

The behavior change is correct and well validated. But per the approval bar: there is a visible path that deletes a whole category of complexity instead of rearranging it, and it is not taken; and the fix addresses one instance of a defect whose sibling is still live and reachable. Items 1 and 2 are blocking. Items 3 and 4 fall out of item 1.

Comment thread packages/platform-apple/src/runtime.ts Outdated
Comment thread src/daemon/__tests__/wait-conditional-selector.test.ts Outdated
Comment thread website/docs/docs/commands.md Outdated
@thymikee
thymikee force-pushed the fix/2404-apple-wait-capture branch from daf47bc to 985701a Compare September 10, 2026 09:38
@thymikee thymikee changed the title fix(apple): use canonical capture for standalone and replayed selector waits fix(wait): retire native selector bypass and recover text observations Sep 10, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Addressed all four review items in 985701a; the three inline threads have detailed replies.

For item 2, the sibling bug is now reproduced and fixed: standalone and replayed wait text failed when a live runner's findText threw despite a matching capture. The shared wait-observation binding now returns found: false on native failure so capture runs. Explicit tests preserve cancellation and ensure a subsequent capture failure remains visible.

Also corrected findText to runner in the demand table. This classification was misleading, but it was not the reproduced cause: resolvePlannedRuntimeOperations includes required operations only, so conditional findText never entered these open plans. The regression reproduces with a live runner and no preceding open. Planner and lifecycle tests retain observation-only startup without a runner.

Validation: pnpm check:affected --run passed on 985701a (3,753 related tests plus 12 documentation tests). Live iOS 26.2 Settings selector/text waits and two-step replay passed; session and daemon/runner cleanup completed. Updated-head GitHub CI is pending.

@thymikee

Copy link
Copy Markdown
Member Author

No remaining findings at 985701a. Selector waits and replay now use canonical capture; native text failures defer to capture without hiding cancellation or capture errors. The reported iOS verification covers the changed route, and CI is green. Ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 10, 2026
@thymikee
thymikee merged commit 41e2633 into main Sep 10, 2026
20 checks passed
@thymikee
thymikee deleted the fix/2404-apple-wait-capture branch September 10, 2026 10:04
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.

0.21.0: a wait step inside replay still fails with querySelector, while the is command answers the same selector

1 participant