feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) - #2448
feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438)#2448thymikee wants to merge 8 commits into
Conversation
…2438) iOS apps that sign in via ASWebAuthenticationSession present the identity provider in com.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AX primaryApp. Serve and drive the sheet in place. A closed registry names the host (shared by the TypeScript and Swift sides under a parity test); the runner reads and drives it without activation and never adopts it as the session target; and the Simulator route detects a running host with a cheap device-scoped ps probe and takes the runner path, since the bridge would serve the occluded app tree as if healthy. open refuses to launch a registered host, and captures carry a system-surface disclosure. Presence is foreground state, not tree content: a torn-down host serves a richer tree than a live one, so content heuristics cannot tell them apart. The never-activate guard is what keeps the foreground predicate sound, which also makes the stale-tree failure mode unrepresentable for this flow. Closes #2438
Size Report
Startup median (7 runs, lower is better):
|
|
|
There are still correctness gaps at 0cde7a4. The presence probe caches absence for one second and treats ps failures as absence. A sheet opened just after an app capture, or while ps fails, therefore falls through to the bridge and returns the occluded app tree. Cache only positive presence, route unknown results to the runner, and test absent-to-present within the TTL plus probe failure. The explicit-URL branches in openIosApp return before the new host guard. open com.apple.SafariViewService https://example.com can still launch the host and cancel authentication. Apply the refusal before every resolved-host launch or termination, and test the URL variants. The provenance is reduced to a warning instead of reaching comparison identity. App and sheet captures can therefore enter legacy same-presentation matching, including recorded-tap failure corroboration. #2450 needs to land with this change, or comparison across this boundary must be explicitly refused. Selector responses also drop the disclosure because they only read Android systemSurfaceOnly; include #2451 or narrow the advertised contract explicitly. Coverage fails on a related import-closure regression: app-lifecycle-facade grows from 120 to 121 modules through the new static import. Fix the import boundary without raising the budget. The iOS failure is an initial fixture-app capture stall, which looks unrelated to the auth route. Please itemize the +8.1 kB unpacked growth and briefly record the smaller design considered. The reported live sign-in flow is useful evidence, but does not cover the failure paths above. |
Presence probe: absence and probe failure are no longer reported as "no surface". The probe returns present/absent/unknown and the route takes the runner for anything but a proven absent, so a sheet opened between two captures, or a probe that cannot answer, can no longer fall through to a bridge capture that would answer confidently from the occluded app tree. Only a positive observation is memoized. The probe now matches with pgrep and reads only a matched pid's environment, which is ~3x cheaper than the previous full process-environment dump and stops copying every process's environment. Open guard: the refusal moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check can no longer launch the host. Terminating a host is refused too, since that cancels the presented session just as launching it does. Comparison: the surface identity now reaches SnapshotState, and tap-failure corroboration refuses outright when a baseline and a post-action capture disagree about it, instead of letting app and sheet captures meet in legacy same-presentation matching. Selector routes disclose an iOS system surface through the shared disclosure seam rather than reading only the Android field. The contracts import in the launch path is deferred so the app-lifecycle facade's eager closure stays flat, and the runner's comment prose is trimmed because apple/runner ships to npm as uncompiled source.
The probe shelled out with runCmd, so every eligible capture spawned a real process even in provider-backed tests that stub the Apple tool seam — 17 real spawns in one scenario file, which is both wasted work and added latency on timing-sensitive settle paths. It now goes through runAppleToolCommand like the sibling ps probe, so a stubbed provider answers instead of spawning.
Routing an unprovable probe to the runner is right, but the early return also skipped runFallback, so the response lost its warning and kept an identity that could still be compared against a bridge publication. An unknown probe now falls back through the same disclosed path as a bridge failure, with its own reason.
|
Addressed at Presence probe. It now returns Open guard. Moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check are covered — parameterized tests assert all four plus that no simctl command ever names the host bundle. Terminate is refused too, since that cancels the session just as launching does. Provenance. #2450 and #2451 are folded in rather than deferred. The surface identity reaches Import closure. The contracts import in the launch path is deferred, so Two more found while verifying. The probe used iOS smoke. Pre-existing, not this PR. The identical signature ( Size, itemized (measured at the reviewed commit). +4,273 B is Swift: Smaller design considered. Collapsing the contracts module into an existing one saves only ~200 B, because most of that chunk is the irreducible refusal/disclosure strings — not worth conflating the registry with an unrelated module. The real lever was Swift comment prose, which ships uncompiled: I trimmed the new blocks to pointers at the ADR/TS twin. That also turned up a repo-wide finding — comments are 16.6% of the 446 kB of shipped runner Swift (~74 kB) — filed as #2461 with a PR in flight. Gates: |
|
Still needs changes at 00e4185:
The current size report is +10.6 kB unpacked, while the explanation accounts for +8.1 kB. Please reconcile the remaining growth against this head. CI is green, but these code gaps remain. |
…cope A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an unreadable or truncated environment could route a live sheet to the occluded app tree. Only a scope naming a different device is a real negative now; a missing one stays unknown. The shared post-gesture comparison token used comparisonKey or the backend alone, so an app capture and a sheet capture — both XCTest — compared equal and a sheet appearing or dismissing read as a stable surface. The token now carries the surface, which covers stabilization, verify and settle through the one path they share. Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the shared disclosure helper could not report the sheet on either outcome. It is preserved now. Each fix has a regression that fails without it.
|
Addressed at Probe device scope. A successful Generic post-gesture comparison. You were right that this was still open — Mutating find. Size, reconciled against this head. Reproducing CI's own definition locally ( Of that, +8,908 B is this branch's own code measured against the merge-base — Swift +3,122 ( The Correction to my earlier comment: the "~1.9 kB of diffuse minifier churn" I claimed does not exist. That figure was a base mismatch — I itemized against the merge-base and compared it to a CI number measured against the PR base sha. Rebuilding the same sha in two independent directories yields byte-identical Gates: |
|
The probe and mutating-find fixes are addressed at 2200150, and the size accounting now reconciles. The verify/settle gap remains: the new key only protects deferred post-gesture stabilization. |
An eligible iOS simulator snapshot is served by the host AX bridge (packages/platform-apple/src/snapshot-route.ts), which never reaches the runner's prepareActiveCommandContext. The rescue's direct querySelector always does, so the two requests share that surface policy only when the capture is runner-routed too - which is the case #2448 forces for the system surface. Keep the unchanged statement that no surface identity crosses the two requests.
`--verify` compared node digests and `--settle` diffed node-only baselines, so an app baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one presentation: a meaningless changed verdict, and a whole-surface replacement presented as an in-surface diff with refs. The pre-action baseline now travels with the surface its capture described, from the resolution and the session frame through to the settled capture, and one module owns the comparison for both routes. Across a surface change no same-surface claim is made: evidence reports the transition instead of a digest comparison, the settled diff and its refs are withheld, and both payloads disclose the transition.
|
Verify/settle gap addressed at 36fdde3. Identity now travels with the baseline, not beside it. One module, What a cross-surface verify/settle now does
Same-surface behavior is byte-identical: all 55 pre-existing verify/settle tests pass unchanged (two accessor renames in New tests (6, beside the existing verify/settle tests) cover app→sheet and sheet→app through each route: Revert-sensitivity — three separate reverts, each confirmed:
Gates on the pushed commit: |
|
The verify/settle comparison fix looks correct at 36fdde3, including both transition directions and the combined press path. Two validation gaps remain: add an app-to-sheet and sheet-to-app regression through generic scroll/back --settle, where the baseline is passed separately, and exercise the new verify/settle transitions on the live sign-in flow. The earlier live sign-in evidence does not cover these new outputs. The generic-route test should fail if its surface identity is dropped and assert that no cross-surface diff or refs are returned. Please also update the size breakdown for the current +14.8 kB report; the previous accounting covers +10.8 kB. Coverage has now failed on a related test-size guard: settle.test.ts grew from 2,359 to 2,528 lines. Split the new surface tests along their owning module, preserving command-path coverage, rather than raising the limit. Native smoke checks are still running. The readiness hold is for the missing evidence, not CI alone. |
Summary
iOS apps that sign in via
ASWebAuthenticationSessionpresent the identity provider incom.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AXprimaryApp.This serves and drives the sheet in place:
contracts/fixtures/ios-system-surface-hosts.json, mirrored by the TypeScript and Swift registries under a parity test).psprobe and takes the runner path; the bridge would serve the occluded app tree as if healthy.open com.apple.SafariViewServiceis refused (UNSUPPORTED_OPERATION); captures carry a system-surface disclosure.Presence is foreground state, not tree content — a torn-down host serves a richer tree than a live one — and the never-activate guard is what keeps that predicate sound, which also makes issue #2438's stale-tree bug unrepresentable for this flow. Design rationale in the ADR 0004 amendment.
17 files, Apple platform + contracts + docs only. Closes #2438.
Validation
Tested at
0cde7a4d2e.pnpm check:affected --runpasses (40/40; lint, typecheck, layering, fallow, unit). Swift: runner builds + 2 registry parity tests pass.Live on iPhone 17 / iOS 26.2 (real daemon + runner, an
ASWebAuthenticationSessionprobe app + local IdP):open svsrefused with the sheet intact;wait label="LOGIN"→press role=textfield label="Email"+type→fill role=securetextfield(values confirmed) →press role=button label="LOGIN"fired the auth callback and returned to the app; post-dismiss captures revert to the bridge with no disclosure.Follow-ups: #2449 (test-app fixture + CI lane), #2450 (verify/settle lineage guard), #2451 (selector-route disclosure parity), #2452 (ADR 0011 offscreen comment).
Caveat: the first snapshot after a cold runner can return 0 nodes (pre-existing mid-bundle flake); use
wait stable/wait <element>.