From d8092e41cce2163801e77656b10074589d8b50a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 24 Jul 2026 20:07:43 +0200 Subject: [PATCH 1/3] feat: add WebView accessibility lab --- CONTEXT.md | 1 + .../RunnerTests+Snapshot.swift | 6 +- .../RunnerTests.swift | 3 +- examples/test-app/README.md | 8 + examples/test-app/app/(tabs)/settings.tsx | 1 + examples/test-app/app/_layout.tsx | 1 + examples/test-app/app/webview.tsx | 9 + examples/test-app/package.json | 3 +- examples/test-app/pnpm-lock.yaml | 79 +++--- .../test-app/src/screens/SettingsScreen.tsx | 12 + .../test-app/src/screens/WebViewScreen.tsx | 234 ++++++++++++++++++ .../src/webview-accessibility-fixture.ts | 73 ++++++ .../__tests__/screenshot-overlay.test.ts | 2 +- src/daemon/snapshot-presentation/ios/index.ts | 2 + src/daemon/snapshot-presentation/ios/noise.ts | 52 +++- .../ios/presentation.test.ts | 5 +- .../snapshot-presentation/ios/web.test.ts | 117 +++++++++ src/daemon/snapshot-presentation/ios/web.ts | 85 +++++++ src/snapshot/snapshot-lines.ts | 3 + 19 files changed, 647 insertions(+), 49 deletions(-) create mode 100644 examples/test-app/app/webview.tsx create mode 100644 examples/test-app/src/screens/WebViewScreen.tsx create mode 100644 examples/test-app/src/webview-accessibility-fixture.ts create mode 100644 src/daemon/snapshot-presentation/ios/web.test.ts create mode 100644 src/daemon/snapshot-presentation/ios/web.ts diff --git a/CONTEXT.md b/CONTEXT.md index 6956b48169..fd586484ad 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,6 +76,7 @@ - Settled observation: opt-in (`--settle`) post-action payload on press/click/fill/longpress — the quiet-window stable loop re-captures until the UI settles, and the response carries the diff vs the pre-action tree (changed lines only, added lines with fresh refs, `refsGeneration` when the settled tree was stored). Best-effort: never fails the action; `settled: false` plus a hint on never-quiet content. - Snapshot capture plan: per-strategy ordered chain of iOS snapshot capture backends (recursive tree, query sweep, private AX) run by one plan runner under a shared wall-clock budget; recovery ordering is declared data, never a per-call-site branch. - Snapshot quality verdict: structured outcome (state, backend, reason code, effective depth, collapsed leaves) computed once by the plan runner and shipped with every planned snapshot payload; the daemon and CLI render it instead of re-deriving degradation from node shapes. +- iOS WebView semantic presentation: the interactive snapshot projection that recognizes XCTest's typed `WebView` root and WebKit's `Other -> StaticText` wrapper pairs. It keeps raw diagnostics unchanged, presents ordinary wrapper text as `StaticText`, and presents wrappers carrying WebKit's numeric HTML heading level as `Heading`. - AX-unavailable target invalidation: iOS/macOS runner behavior where a root accessibility snapshot failure such as `kAXErrorIllegalArgument` marks the cached `XCUIApplication` target handle suspect. The runner fails closed for degraded interactive snapshots, clears the cached target, and lets the next command reacquire the app through normal activation. - Resolution disclosure (ADR 0012 decision 2): additive `resolution` field on press/click/fill/longpress responses discloses how the acting path resolved its target — `runtime`/`unique` or `runtime`/`disambiguated` (with `matchCount`/`winnerDiagnostic`/`tiebreak`/up-to-5 `alternatives`) on the daemon tree, `ref`/`exact` for a resolved `@ref` (runtime-ref and native-ref), `ref`/`label-fallback` when runtime-ref recovered a stale `@ref` via its recorded trailing label, or `direct-ios`/`not-observed` on the XCTest fast path; absent entirely on the coordinate path and on dispatches whose runner actually executed the maestro non-hittable coordinate fallback (permission alone keeps the direct path's `not-observed`). Pre-action diagnostics only: `winnerDiagnostic`/`alternatives` entries carry an opaque, non-`@` `diagnosticRef` that is never ref-issued, never MCP-pinned, and cannot be reused as an `@ref` target — a fresh snapshot/find is required before acting on an alternative. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 25c70805cc..efac1b7a19 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -57,12 +57,16 @@ extension RunnerTests { case .collectionView: return "CollectionView" case .table: return "Table" case .scrollView: return "ScrollView" + case .toolbar: return "Toolbar" case .searchField: return "SearchField" case .segmentedControl: return "SegmentedControl" case .stepper: return "Stepper" case .picker: return "Picker" + case .activityIndicator: return "ActivityIndicator" + case .progressIndicator: return "ProgressIndicator" case .checkBox: return "CheckBox" case .menuItem: return "MenuItem" + case .webView: return "WebView" case .other: return "Other" default: switch type.rawValue { @@ -70,8 +74,6 @@ extension RunnerTests { return "Keyboard" case 20: return "Key" - case 24: - return "SearchField" default: return "Element(\(type.rawValue))" } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 0fbd6e63d1..5a60f0efb3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -131,7 +131,8 @@ final class RunnerTests: XCTestCase { .tabBar, .textField, .secureTextField, - .textView + .textView, + .webView ] // Keep blocker actions narrow to avoid false positives from generic hittable containers. let actionableTypes: Set = [ diff --git a/examples/test-app/README.md b/examples/test-app/README.md index bea995d094..b1d7623432 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -17,9 +17,17 @@ It is intentionally small, but each surface is dense with durable accessibility - `Product detail`: back navigation, quantity stepper, multiline notes, save action - `Checkout form`: required-field validation, fill vs type, checkbox state, choice groups, keyboard dismiss, success summary - `Settings`: switch rows, accordion content, loading and error states, retry flow, destructive-confirm modal +- `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface. +The deterministic WebView fixture is the stable accessibility oracle. On iOS, an interactive +snapshot should expose its root as `webview`, both titles as `heading`, paragraph and label content +as `text`, and the form controls as `text-field`, `switch`, and `button`. The live-site buttons are +exploratory smoke coverage for real-world WebKit trees, not stable assertion targets. +Use an unscoped snapshot for this oracle: XCTest can detach a scoped WebKit document subtree from +its `WebView` ancestor, leaving insufficient evidence for safe semantic projection. + ## Coverage map These are the main case families this app can support without adding more screens: diff --git a/examples/test-app/app/(tabs)/settings.tsx b/examples/test-app/app/(tabs)/settings.tsx index 5d2112ac08..6047017e7b 100644 --- a/examples/test-app/app/(tabs)/settings.tsx +++ b/examples/test-app/app/(tabs)/settings.tsx @@ -17,6 +17,7 @@ export default function SettingsRoute() { notificationsEnabled={state.notificationsEnabled} onOpenAccessorySetup={() => router.push('/accessory-setup')} onOpenInertSurface={() => router.push('/inert')} + onOpenWebViewLab={() => router.push('/webview')} onConfirmReset={state.resetLabState} onLoadDiagnostics={state.loadDiagnostics} onRetryDiagnostics={state.retryDiagnostics} diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index 7f6b88f7cf..cfd43e091d 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -26,6 +26,7 @@ function RootLayoutContent() { + {toastMessage ? : null} diff --git a/examples/test-app/app/webview.tsx b/examples/test-app/app/webview.tsx new file mode 100644 index 0000000000..8ca901a9a3 --- /dev/null +++ b/examples/test-app/app/webview.tsx @@ -0,0 +1,9 @@ +import { useRouter } from 'expo-router'; + +import { WebViewScreen } from '../src/screens/WebViewScreen'; + +export default function WebViewRoute() { + const router = useRouter(); + + return router.back()} />; +} diff --git a/examples/test-app/package.json b/examples/test-app/package.json index aa88c59002..3fa736d950 100644 --- a/examples/test-app/package.json +++ b/examples/test-app/package.json @@ -26,7 +26,8 @@ "react-native-gesture-handler": "^2.31.2", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.25.2", - "react-native-web": "^0.21.2" + "react-native-web": "^0.21.2", + "react-native-webview": "13.16.1" }, "devDependencies": { "@expo/fingerprint": "^0.19.4", diff --git a/examples/test-app/pnpm-lock.yaml b/examples/test-app/pnpm-lock.yaml index aa8751ca3b..4bb1170b2e 100644 --- a/examples/test-app/pnpm-lock.yaml +++ b/examples/test-app/pnpm-lock.yaml @@ -27,7 +27,7 @@ importers: version: 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) expo: specifier: ~56.0.12 - version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-audio: specifier: ~56.0.12 version: 56.0.12(expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) @@ -70,6 +70,9 @@ importers: react-native-web: specifier: ^0.21.2 version: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-native-webview: + specifier: 13.16.1 + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) devDependencies: '@expo/fingerprint': specifier: ^0.19.4 @@ -2460,6 +2463,12 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + react-native-webview@13.16.1: + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets@0.10.0: resolution: {integrity: sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw==} peerDependencies: @@ -3565,7 +3574,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-server: 56.0.5 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -3663,7 +3672,7 @@ snapshots: '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)': dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -3730,7 +3739,7 @@ snapshots: dependencies: '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) stacktrace-parser: 0.1.11 @@ -3761,7 +3770,7 @@ snapshots: postcss: 8.5.12 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) transitivePeerDependencies: - bufferutil - supports-color @@ -3783,7 +3792,7 @@ snapshots: dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -3866,7 +3875,7 @@ snapshots: '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) expo-server: 56.0.5 @@ -3890,7 +3899,7 @@ snapshots: '@expo/ui@56.0.18(@babel/core@7.29.7)(@types/react@19.2.14)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.5.0(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)': dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) sf-symbols-typescript: 2.2.0 @@ -4155,7 +4164,7 @@ snapshots: '@react-native/babel-plugin-codegen@0.86.0(@babel/core@7.29.7)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 '@react-native/codegen': 0.86.0(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' @@ -4212,7 +4221,7 @@ snapshots: '@react-native/codegen@0.86.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 hermes-parser: 0.36.0 invariant: 2.2.4 nullthrows: 1.1.1 @@ -4287,7 +4296,9 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {} @@ -4306,7 +4317,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -4509,7 +4520,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.2 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -4775,7 +4786,7 @@ snapshots: expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -4785,7 +4796,7 @@ snapshots: expo-audio@56.0.12(expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -4803,14 +4814,14 @@ snapshots: expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) transitivePeerDependencies: - supports-color expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-dev-launcher: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) expo-dev-menu-interface: 56.0.1(expo@56.0.12) @@ -4822,36 +4833,36 @@ snapshots: expo-dev-launcher@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) expo-manifests: 56.0.4(expo@56.0.12) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) expo-dev-menu-interface@56.0.1(expo@56.0.12): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-dev-menu@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-dev-menu-interface: 56.0.1(expo@56.0.12) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) fontfaceobserver: 2.3.0 react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -4859,7 +4870,7 @@ snapshots: expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): @@ -4874,7 +4885,7 @@ snapshots: expo-manifests@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.16(typescript@6.0.3): @@ -4916,7 +4927,7 @@ snapshots: color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)) expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) @@ -4956,14 +4967,14 @@ snapshots: expo-status-bar@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.29 - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) @@ -4971,9 +4982,9 @@ snapshots: expo-updates-interface@56.0.2(expo@56.0.12): dependencies: - expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo@56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + expo@56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-router@56.2.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.2 '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -5004,6 +5015,7 @@ snapshots: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -5773,6 +5785,13 @@ snapshots: transitivePeerDependencies: - encoding + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) + react-native-worklets@0.10.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3): dependencies: '@babel/core': 7.29.7 @@ -5785,7 +5804,7 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@react-native/metro-config': 0.86.0(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 diff --git a/examples/test-app/src/screens/SettingsScreen.tsx b/examples/test-app/src/screens/SettingsScreen.tsx index f9d4ba10ec..87055c81c6 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -19,6 +19,7 @@ export interface SettingsScreenProps { reducedMotionEnabled: boolean; onOpenAccessorySetup: () => void; onOpenInertSurface: () => void; + onOpenWebViewLab: () => void; onLoadDiagnostics: () => void; onRetryDiagnostics: () => void; onSetNotificationsEnabled: (value: boolean) => void; @@ -83,6 +84,17 @@ export function SettingsScreen(props: SettingsScreenProps) { /> + + + + void }) { + const colors = useAppColors(); + const insets = useSafeAreaInsets(); + const styles = createStyles(colors); + const [site, setSite] = useState(SITES[0]); + const [loadState, setLoadState] = useState({ kind: 'loading' }); + + function selectSite(nextSite: Site) { + if (nextSite.key === site.key) return; + setSite(nextSite); + setLoadState({ kind: 'loading' }); + } + + return ( + + + [styles.closeButton, pressed ? styles.pressed : null]} + testID="close-webview-lab" + > + Close + + + WebView accessibility + + Compare semantic roles exposed by real websites through the native accessibility tree. + + + + + + {SITES.map((candidate) => { + const selected = candidate.key === site.key; + return ( + selectSite(candidate)} + style={({ pressed }) => [ + styles.siteButton, + selected ? styles.siteButtonSelected : null, + pressed ? styles.pressed : null, + ]} + testID={`webview-site-${candidate.key}`} + > + + {candidate.label} + + + ); + })} + + + + + {formatLoadStatus(site.label, loadState)} + + {loadState.kind === 'loading' ? ( + + ) : null} + + + + { + setLoadState({ kind: 'error', message: event.nativeEvent.description }); + }} + onHttpError={(event) => { + const { description, statusCode } = event.nativeEvent; + setLoadState({ + kind: 'error', + message: description || `HTTP ${statusCode}`, + }); + }} + onLoad={() => + setLoadState((current) => (current.kind === 'error' ? current : { kind: 'loaded' })) + } + onLoadStart={() => setLoadState({ kind: 'loading' })} + source={site.source} + startInLoadingState + style={styles.webView} + testID="webview-content" + /> + + + ); +} + +function formatLoadStatus(siteLabel: string, loadState: LoadState): string { + switch (loadState.kind) { + case 'loading': + return `Loading ${siteLabel}…`; + case 'loaded': + return `Loaded ${siteLabel}`; + case 'error': + return `Failed to load ${siteLabel}: ${loadState.message}`; + } +} + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + screen: { + backgroundColor: colors.surface, + flex: 1, + }, + header: { + alignItems: 'flex-start', + borderBottomColor: colors.line, + borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: 14, + paddingBottom: 14, + paddingHorizontal: 16, + }, + closeButton: { + borderColor: colors.lineStrong, + borderRadius: 4, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 12, + paddingVertical: 10, + }, + closeLabel: { + color: colors.text, + fontSize: 14, + fontWeight: '600', + }, + heading: { + flex: 1, + gap: 4, + }, + title: { + color: colors.text, + fontSize: 24, + fontWeight: '600', + lineHeight: 28, + }, + subtitle: { + color: colors.textSoft, + fontSize: 13, + lineHeight: 18, + }, + sitePicker: { + gap: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + siteButton: { + alignSelf: 'flex-start', + borderColor: colors.lineStrong, + borderRadius: 4, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 12, + paddingVertical: 9, + }, + siteButtonSelected: { + backgroundColor: colors.text, + borderColor: colors.text, + }, + siteLabel: { + color: colors.text, + fontSize: 13, + fontWeight: '600', + }, + siteLabelSelected: { + color: colors.surface, + }, + status: { + alignItems: 'center', + borderBottomColor: colors.line, + borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: 8, + justifyContent: 'space-between', + minHeight: 38, + paddingHorizontal: 16, + paddingVertical: 8, + }, + statusText: { + color: colors.textSoft, + flex: 1, + fontSize: 12, + }, + webViewFrame: { + flex: 1, + }, + webView: { + backgroundColor: colors.surface, + flex: 1, + }, + pressed: { + opacity: 0.75, + }, + }); +} diff --git a/examples/test-app/src/webview-accessibility-fixture.ts b/examples/test-app/src/webview-accessibility-fixture.ts new file mode 100644 index 0000000000..84f3820756 --- /dev/null +++ b/examples/test-app/src/webview-accessibility-fixture.ts @@ -0,0 +1,73 @@ +export const WEBVIEW_ACCESSIBILITY_FIXTURE = ` + + + + + WebView semantic fixture + + + +
+ +
+
+

WebView semantic fixture

+

Ordinary paragraph text should not be categorized as other.

+

Account form

+
+ + + + +
+

Media

+ Blue accessibility test square +
+
Fixture footer text
+ + +`; diff --git a/src/daemon/__tests__/screenshot-overlay.test.ts b/src/daemon/__tests__/screenshot-overlay.test.ts index 5a9fd26d1b..9280c95164 100644 --- a/src/daemon/__tests__/screenshot-overlay.test.ts +++ b/src/daemon/__tests__/screenshot-overlay.test.ts @@ -193,7 +193,7 @@ test('buildScreenshotOverlayRefs skips generic actionable container labels when { index: 1, parentIndex: 0, - type: 'XCUIElementTypeSearchField', + type: 'XCUIElementTypeToolbar', label: 'Toolbar', rect: { x: 0, y: 150, width: 100, height: 30 }, }, diff --git a/src/daemon/snapshot-presentation/ios/index.ts b/src/daemon/snapshot-presentation/ios/index.ts index 5580997d39..51f0b91e40 100644 --- a/src/daemon/snapshot-presentation/ios/index.ts +++ b/src/daemon/snapshot-presentation/ios/index.ts @@ -2,6 +2,7 @@ import type { RawSnapshotNode } from '../../../kernel/snapshot.ts'; import { collectIosImplicitScrollableActions } from './actions.ts'; import { collectIosPresentationNoiseSuppression } from './noise.ts'; import { collectIosRowPresentation } from './rows.ts'; +import { collectIosWebSemanticPresentation } from './web.ts'; import { reindexSnapshotNodesWithSuppressedParents, type SnapshotTreeRuleContext, @@ -10,6 +11,7 @@ import { const IOS_PRESENTATION_RULES: Array< (nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext) => void > = [ + collectIosWebSemanticPresentation, collectIosPresentationNoiseSuppression, collectIosImplicitScrollableActions, collectIosRowPresentation, diff --git a/src/daemon/snapshot-presentation/ios/noise.ts b/src/daemon/snapshot-presentation/ios/noise.ts index 509d8c4253..810bfc6225 100644 --- a/src/daemon/snapshot-presentation/ios/noise.ts +++ b/src/daemon/snapshot-presentation/ios/noise.ts @@ -32,7 +32,7 @@ export function collectIosPresentationNoiseSuppression( collectIosActionWrapperSuppression(nodes, suppressedIndexes); collectIosReactNativeOverlayActionPresentation(nodes, context.replacements); collectIosReactNativeOverlayWrapperSuppression(nodes, suppressedIndexes); - collectIosRepeatedStaticSuppression(nodes, suppressedIndexes); + collectIosRepeatedStaticSuppression(nodes, suppressedIndexes, context.replacements); } function collectIosReactNativeOverlayActionPresentation( @@ -133,6 +133,7 @@ function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): Raw function collectIosRepeatedStaticSuppression( nodes: RawSnapshotNode[], suppressedIndexes: Set, + replacements: Map, ): void { for (let position = 0; position < nodes.length; position += 1) { const node = nodes[position]; @@ -141,7 +142,14 @@ function collectIosRepeatedStaticSuppression( continue; } - collectRepeatedStaticSuppressionForNode(nodes, position, node, nodeLabel, suppressedIndexes); + collectRepeatedStaticSuppressionForNode( + nodes, + position, + node, + nodeLabel, + suppressedIndexes, + replacements, + ); } } @@ -151,10 +159,11 @@ function collectRepeatedStaticSuppressionForNode( node: RawSnapshotNode, nodeLabel: string, suppressedIndexes: Set, + replacements: Map, ): void { const type = normalizeType(node.type ?? ''); if (type === 'statictext' || type === 'link') { - suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes); + suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes, replacements); return; } if (type !== 'other') { @@ -164,7 +173,7 @@ function collectRepeatedStaticSuppressionForNode( suppressedIndexes.add(node.index); return; } - suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes); + suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes, replacements); } function hasEquivalentSemanticDescendant( @@ -188,14 +197,27 @@ function suppressRepeatedStaticDescendants( position: number, label: string, suppressedIndexes: Set, + replacements: Map, ): void { forEachDescendant(nodes, position, (descendant) => { - if (isRepeatedStaticNode(descendant, label)) { + if ( + !hasWebSemanticReplacement(descendant, replacements.get(descendant.index)) && + isRepeatedStaticNode(descendant, label) + ) { suppressedIndexes.add(descendant.index); } }); } +function hasWebSemanticReplacement( + source: RawSnapshotNode, + replacement: RawSnapshotNode | undefined, +): boolean { + if (normalizeType(source.type ?? '') !== 'other') return false; + const replacementType = normalizeType(replacement?.type ?? ''); + return replacementType === 'heading' || replacementType === 'statictext'; +} + function collectIosActionWrapperSuppression( nodes: RawSnapshotNode[], suppressedIndexes: Set, @@ -320,16 +342,13 @@ function collectIosSearchToolbarSuppression( ): void { for (let position = 0; position < nodes.length; position += 1) { const node = nodes[position]; - if (!node || normalizeType(node.type ?? '') !== 'searchfield') { - continue; - } - if (node.label === 'Search') { + if (!node) continue; + if (isExposedSearchField(node)) { suppressSearchToolbarDescendants(nodes, position, null, suppressedIndexes); continue; } - if (node.label !== 'Toolbar') { - continue; - } + if (!isSearchToolbar(node)) continue; + const innerSearch = findDescendant( nodes, position, @@ -346,6 +365,15 @@ function collectIosSearchToolbarSuppression( } } +function isExposedSearchField(node: RawSnapshotNode): boolean { + return normalizeType(node.type ?? '') === 'searchfield' && node.label === 'Search'; +} + +function isSearchToolbar(node: RawSnapshotNode): boolean { + const type = normalizeType(node.type ?? ''); + return node.label === 'Toolbar' && (type === 'toolbar' || type === 'searchfield'); +} + function suppressSearchToolbarDescendants( nodes: RawSnapshotNode[], position: number, diff --git a/src/daemon/snapshot-presentation/ios/presentation.test.ts b/src/daemon/snapshot-presentation/ios/presentation.test.ts index 8703f42d61..f1174d4d7d 100644 --- a/src/daemon/snapshot-presentation/ios/presentation.test.ts +++ b/src/daemon/snapshot-presentation/ios/presentation.test.ts @@ -397,7 +397,7 @@ test('buildSnapshotState uses the innermost iOS RedBox dismiss action geometry', index: 4, depth: 4, parentIndex: 3, - type: 'Other', + type: 'StaticText', label: 'Dismiss', rect: { x: 0, y: 770, width: 196.6666717529297, height: 48 }, }, @@ -419,6 +419,7 @@ test('buildSnapshotState uses the innermost iOS RedBox dismiss action geometry', width: 196.6666717529297, height: 48, }); + expect(state.nodes.filter((node) => node.label === 'Dismiss')).toHaveLength(1); }); test('buildSnapshotState promotes iOS scroll-contained other rows to cells', () => { @@ -757,7 +758,7 @@ test('buildSnapshotState collapses duplicated iOS search toolbar wrappers', () = index: 2, depth: 2, parentIndex: 1, - type: 'SearchField', + type: 'Toolbar', label: 'Toolbar', identifier: 'Toolbar', rect: { x: 0, y: 788, width: 402, height: 86 }, diff --git a/src/daemon/snapshot-presentation/ios/web.test.ts b/src/daemon/snapshot-presentation/ios/web.test.ts new file mode 100644 index 0000000000..5f00eabff3 --- /dev/null +++ b/src/daemon/snapshot-presentation/ios/web.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'vitest'; +import type { RawSnapshotNode } from '../../../kernel/snapshot.ts'; +import { presentIosInteractiveSnapshot } from './index.ts'; + +test('projects iOS WebKit heading and text wrappers to semantic roles', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'Application', label: 'Example' }, + { index: 1, depth: 1, parentIndex: 0, type: 'WebView', label: 'Example page' }, + { index: 2, depth: 2, parentIndex: 1, type: 'Other', label: 'Example page' }, + { index: 3, depth: 3, parentIndex: 2, type: 'Other', label: 'Welcome', value: '1' }, + { index: 4, depth: 4, parentIndex: 3, type: 'StaticText', label: 'Welcome' }, + { index: 5, depth: 3, parentIndex: 2, type: 'Other', label: 'Introduction' }, + { index: 6, depth: 4, parentIndex: 5, type: 'StaticText', label: 'Introduction' }, + { index: 7, depth: 3, parentIndex: 2, type: 'Link', label: 'Read more' }, + ]; + + expect( + presentIosInteractiveSnapshot(nodes).map((node) => [ + node.type, + node.label, + node.value, + node.parentIndex, + ]), + ).toEqual([ + ['Application', 'Example', undefined, undefined], + ['WebView', 'Example page', undefined, 0], + ['Heading', 'Welcome', '1', 1], + ['StaticText', 'Introduction', undefined, 1], + ['Link', 'Read more', undefined, 1], + ]); +}); + +test('recognizes numeric WebView types from retained older runners', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'Element(58)', label: 'Example page' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Body copy' }, + { index: 2, depth: 2, parentIndex: 1, type: 'StaticText', label: 'Body copy' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Example page'], + ['StaticText', 'Body copy'], + ]); +}); + +test('keeps a nested heading whose label matches the WebView title', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'WebView', label: 'Same title' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Same title' }, + { index: 2, depth: 2, parentIndex: 1, type: 'Other', label: 'Same title', value: '1' }, + { index: 3, depth: 3, parentIndex: 2, type: 'StaticText', label: 'Same title' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Same title'], + ['Heading', 'Same title'], + ]); +}); + +test('keeps a heading through repeated WebKit document-title wrappers', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'WebView', label: 'Same title' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Same title' }, + { index: 2, depth: 2, parentIndex: 1, type: 'Other', label: 'Same title' }, + { index: 3, depth: 3, parentIndex: 2, type: 'Other', label: 'main' }, + { index: 4, depth: 4, parentIndex: 3, type: 'Other', label: 'Same title', value: '1' }, + { index: 5, depth: 5, parentIndex: 4, type: 'StaticText', label: 'Same title' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Same title'], + ['Other', 'Same title'], + ['Other', 'main'], + ['Heading', 'Same title'], + ]); +}); + +test('keeps a direct heading whose label matches the WebView title', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'WebView', label: 'Same title' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Same title', value: '1' }, + { index: 2, depth: 2, parentIndex: 1, type: 'StaticText', label: 'Same title' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Same title'], + ['Heading', 'Same title'], + ]); +}); + +test('does not infer text from a nested same-label descendant', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'WebView', label: 'Example' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Container' }, + { index: 2, depth: 2, parentIndex: 1, type: 'Button', label: 'Action' }, + { index: 3, depth: 3, parentIndex: 2, type: 'StaticText', label: 'Container' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Example'], + ['Other', 'Container'], + ['Button', 'Action'], + ]); +}); + +test('does not infer semantic roles for matching wrappers outside a WebView', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'Application', label: 'Example' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'Native copy', value: '1' }, + { index: 2, depth: 2, parentIndex: 1, type: 'StaticText', label: 'Native copy' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['Application', 'Example'], + ['Other', 'Native copy'], + ]); +}); diff --git a/src/daemon/snapshot-presentation/ios/web.ts b/src/daemon/snapshot-presentation/ios/web.ts new file mode 100644 index 0000000000..08025fefa3 --- /dev/null +++ b/src/daemon/snapshot-presentation/ios/web.ts @@ -0,0 +1,85 @@ +import type { RawSnapshotNode } from '../../../kernel/snapshot.ts'; +import { normalizeType } from '../../../snapshot/snapshot-processing.ts'; +import { findNearestAncestor, mergeReplacement, type SnapshotTreeRuleContext } from '../tree.ts'; + +/** + * WebKit exposes HTML text through an `Other -> StaticText` wrapper pair on iOS. + * Keep the wrapper's stable tree position but project the semantic leaf role so + * selectors and rendered snapshots do not describe ordinary text as "other". + * + * HTML headings use the same pair and expose their level as the wrapper value. + * XCTest does not publish the level as a dedicated field, so the public role is + * `Heading` while the existing value remains available in the raw node. + */ +export function collectIosWebSemanticPresentation( + nodes: RawSnapshotNode[], + context: SnapshotTreeRuleContext, +): void { + const byIndex = new Map(nodes.map((node) => [node.index, node])); + const repeatedStaticTextByParent = collectRepeatedStaticTextByParent(nodes); + + for (const node of nodes) { + if (normalizeType(node.type ?? '') === 'element(58)') { + mergeReplacement(context.replacements, node, { type: 'WebView' }); + continue; + } + const presentation = classifyIosWebTextWrapper(node, byIndex, repeatedStaticTextByParent); + if (presentation === 'suppress') { + context.suppressedIndexes.add(node.index); + continue; + } + if (presentation) { + mergeReplacement(context.replacements, node, { type: presentation }); + } + } +} + +function classifyIosWebTextWrapper( + node: RawSnapshotNode, + byIndex: Map, + repeatedStaticTextByParent: Map>, +): 'Heading' | 'StaticText' | 'suppress' | null { + if (normalizeType(node.type ?? '') !== 'other') return null; + const webView = findNearestAncestor(node, byIndex, isWebView); + const label = node.label?.trim(); + if (!webView || !label) return null; + if (isDocumentTitleWrapper(node, webView, label)) return 'suppress'; + if (!repeatedStaticTextByParent.get(node.index)?.has(label)) return null; + return isHtmlHeadingLevel(node.value) ? 'Heading' : 'StaticText'; +} + +function isDocumentTitleWrapper( + node: RawSnapshotNode, + webView: RawSnapshotNode, + label: string, +): boolean { + return ( + node.parentIndex === webView.index && + webView.label?.trim() === label && + !isHtmlHeadingLevel(node.value) + ); +} + +function collectRepeatedStaticTextByParent(nodes: RawSnapshotNode[]): Map> { + const labelsByParent = new Map>(); + for (const node of nodes) { + if (typeof node.parentIndex !== 'number' || normalizeType(node.type ?? '') !== 'statictext') { + continue; + } + const label = node.label?.trim(); + if (!label) continue; + const labels = labelsByParent.get(node.parentIndex) ?? new Set(); + labels.add(label); + labelsByParent.set(node.parentIndex, labels); + } + return labelsByParent; +} + +function isWebView(node: RawSnapshotNode): boolean { + const type = normalizeType(node.type ?? ''); + return type === 'webview' || type === 'element(58)'; +} + +function isHtmlHeadingLevel(value: string | undefined): boolean { + return /^[1-6]$/.test(value?.trim() ?? ''); +} diff --git a/src/snapshot/snapshot-lines.ts b/src/snapshot/snapshot-lines.ts index 1c3a9f0c87..30a0a8a881 100644 --- a/src/snapshot/snapshot-lines.ts +++ b/src/snapshot/snapshot-lines.ts @@ -42,6 +42,9 @@ const ROLE_LABELS: Record = { recyclerview: 'list', collectionview: 'collection', searchfield: 'search', + heading: 'heading', + activityindicator: 'activity-indicator', + progressindicator: 'progress-indicator', segmentedcontrol: 'segmented-control', group: 'group', window: 'window', From f3ac9ef6cc18c19fe8b968c4535e293942229784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 24 Jul 2026 20:41:10 +0200 Subject: [PATCH 2/3] refactor: tighten iOS snapshot presentation rules --- .../snapshot-presentation/ios/actions.ts | 7 +- src/daemon/snapshot-presentation/ios/index.ts | 13 +++- src/daemon/snapshot-presentation/ios/noise.ts | 66 +++++++++++-------- .../snapshot-presentation/ios/scroll.ts | 5 +- .../snapshot-presentation/ios/web.test.ts | 3 +- src/daemon/snapshot-presentation/ios/web.ts | 33 +++++++--- src/daemon/snapshot-presentation/tree.ts | 12 +++- 7 files changed, 89 insertions(+), 50 deletions(-) diff --git a/src/daemon/snapshot-presentation/ios/actions.ts b/src/daemon/snapshot-presentation/ios/actions.ts index 08afa463b7..7e0154f1ee 100644 --- a/src/daemon/snapshot-presentation/ios/actions.ts +++ b/src/daemon/snapshot-presentation/ios/actions.ts @@ -12,10 +12,9 @@ export function collectIosImplicitScrollableActions( nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext, ): void { - const byIndex = new Map(nodes.map((node) => [node.index, node])); - const viewport = findLargestViewportRect(byIndex.values()); + const viewport = findLargestViewportRect(context.sourceNodesByIndex.values()); for (const node of nodes) { - if (!isImplicitScrollableAction(node, byIndex, viewport)) { + if (!isImplicitScrollableAction(node, context.sourceNodesByIndex, viewport)) { continue; } mergeReplacement(context.replacements, node, { type: 'Cell' }); @@ -24,7 +23,7 @@ export function collectIosImplicitScrollableActions( function isImplicitScrollableAction( node: RawSnapshotNode, - byIndex: Map, + byIndex: ReadonlyMap, viewport: RawSnapshotNode['rect'], ): boolean { if (normalizeType(node.type ?? '') !== 'other') { diff --git a/src/daemon/snapshot-presentation/ios/index.ts b/src/daemon/snapshot-presentation/ios/index.ts index 51f0b91e40..b654eb3fae 100644 --- a/src/daemon/snapshot-presentation/ios/index.ts +++ b/src/daemon/snapshot-presentation/ios/index.ts @@ -11,6 +11,7 @@ import { const IOS_PRESENTATION_RULES: Array< (nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext) => void > = [ + // Semantic representatives must be collected before duplicate-label suppression. collectIosWebSemanticPresentation, collectIosPresentationNoiseSuppression, collectIosImplicitScrollableActions, @@ -34,14 +35,22 @@ export function buildIosInteractiveSnapshotPresentation( } const replacements = new Map(); + const semanticRepresentativeIndexes = new Set(); + const sourceNodesByIndex = new Map(nodes.map((node) => [node.index, node])); const suppressedIndexes = new Set(); + const ruleContext: SnapshotTreeRuleContext = { + replacements, + semanticRepresentativeIndexes, + sourceNodesByIndex, + suppressedIndexes, + }; for (const rule of IOS_PRESENTATION_RULES) { - rule(nodes, { replacements, suppressedIndexes }); + rule(nodes, ruleContext); } if (suppressedIndexes.size === 0 && replacements.size === 0) { - return { nodes, sourceNodes: new Map(nodes.map((node) => [node.index, node])) }; + return { nodes, sourceNodes: sourceNodesByIndex }; } const presentedSourceNodes = nodes diff --git a/src/daemon/snapshot-presentation/ios/noise.ts b/src/daemon/snapshot-presentation/ios/noise.ts index 810bfc6225..3bfa25f2ad 100644 --- a/src/daemon/snapshot-presentation/ios/noise.ts +++ b/src/daemon/snapshot-presentation/ios/noise.ts @@ -25,14 +25,18 @@ export function collectIosPresentationNoiseSuppression( context: SnapshotTreeRuleContext, ): void { const { suppressedIndexes } = context; - collectIosOffscreenKeyboardSuppression(nodes, suppressedIndexes); + collectIosOffscreenKeyboardSuppression(nodes, context.sourceNodesByIndex, suppressedIndexes); collectIosStructuralIdentifierSuppression(nodes, suppressedIndexes); collectIosScrollIndicatorPresentation(nodes, context); - collectIosSearchToolbarSuppression(nodes, suppressedIndexes); + collectIosSearchToolbarSuppression(nodes, context.sourceNodesByIndex, suppressedIndexes); collectIosActionWrapperSuppression(nodes, suppressedIndexes); collectIosReactNativeOverlayActionPresentation(nodes, context.replacements); collectIosReactNativeOverlayWrapperSuppression(nodes, suppressedIndexes); - collectIosRepeatedStaticSuppression(nodes, suppressedIndexes, context.replacements); + collectIosRepeatedStaticSuppression( + nodes, + suppressedIndexes, + context.semanticRepresentativeIndexes, + ); } function collectIosReactNativeOverlayActionPresentation( @@ -133,7 +137,7 @@ function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): Raw function collectIosRepeatedStaticSuppression( nodes: RawSnapshotNode[], suppressedIndexes: Set, - replacements: Map, + semanticRepresentativeIndexes: ReadonlySet, ): void { for (let position = 0; position < nodes.length; position += 1) { const node = nodes[position]; @@ -148,7 +152,7 @@ function collectIosRepeatedStaticSuppression( node, nodeLabel, suppressedIndexes, - replacements, + semanticRepresentativeIndexes, ); } } @@ -159,11 +163,17 @@ function collectRepeatedStaticSuppressionForNode( node: RawSnapshotNode, nodeLabel: string, suppressedIndexes: Set, - replacements: Map, + semanticRepresentativeIndexes: ReadonlySet, ): void { const type = normalizeType(node.type ?? ''); if (type === 'statictext' || type === 'link') { - suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes, replacements); + suppressRepeatedStaticDescendants( + nodes, + position, + nodeLabel, + suppressedIndexes, + semanticRepresentativeIndexes, + ); return; } if (type !== 'other') { @@ -173,7 +183,13 @@ function collectRepeatedStaticSuppressionForNode( suppressedIndexes.add(node.index); return; } - suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes, replacements); + suppressRepeatedStaticDescendants( + nodes, + position, + nodeLabel, + suppressedIndexes, + semanticRepresentativeIndexes, + ); } function hasEquivalentSemanticDescendant( @@ -197,11 +213,11 @@ function suppressRepeatedStaticDescendants( position: number, label: string, suppressedIndexes: Set, - replacements: Map, + semanticRepresentativeIndexes: ReadonlySet, ): void { forEachDescendant(nodes, position, (descendant) => { if ( - !hasWebSemanticReplacement(descendant, replacements.get(descendant.index)) && + !semanticRepresentativeIndexes.has(descendant.index) && isRepeatedStaticNode(descendant, label) ) { suppressedIndexes.add(descendant.index); @@ -209,15 +225,6 @@ function suppressRepeatedStaticDescendants( }); } -function hasWebSemanticReplacement( - source: RawSnapshotNode, - replacement: RawSnapshotNode | undefined, -): boolean { - if (normalizeType(source.type ?? '') !== 'other') return false; - const replacementType = normalizeType(replacement?.type ?? ''); - return replacementType === 'heading' || replacementType === 'statictext'; -} - function collectIosActionWrapperSuppression( nodes: RawSnapshotNode[], suppressedIndexes: Set, @@ -276,6 +283,7 @@ function isFullscreenActionLabelWrapper( function collectIosOffscreenKeyboardSuppression( nodes: RawSnapshotNode[], + sourceNodesByIndex: ReadonlyMap, suppressedIndexes: Set, ): void { const viewport = findLargestViewportRect(nodes); @@ -289,7 +297,7 @@ function collectIosOffscreenKeyboardSuppression( continue; } suppressedIndexes.add(node.index); - suppressOffscreenKeyboardAncestors(node, nodes, suppressedIndexes, screenBottom); + suppressOffscreenKeyboardAncestors(node, sourceNodesByIndex, suppressedIndexes, screenBottom); forEachDescendant(nodes, position, (descendant) => { suppressedIndexes.add(descendant.index); }); @@ -305,16 +313,18 @@ function isOffscreenKeyboardNode(node: RawSnapshotNode, screenBottom: number): b function suppressOffscreenKeyboardAncestors( node: RawSnapshotNode, - nodes: RawSnapshotNode[], + sourceNodesByIndex: ReadonlyMap, suppressedIndexes: Set, screenBottom: number, ): void { - const byIndex = new Map(nodes.map((candidate) => [candidate.index, candidate])); - let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined; + let current = + typeof node.parentIndex === 'number' ? sourceNodesByIndex.get(node.parentIndex) : undefined; while (current?.rect && current.rect.y >= screenBottom) { suppressedIndexes.add(current.index); current = - typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; + typeof current.parentIndex === 'number' + ? sourceNodesByIndex.get(current.parentIndex) + : undefined; } } @@ -338,6 +348,7 @@ function collectIosStructuralIdentifierSuppression( function collectIosSearchToolbarSuppression( nodes: RawSnapshotNode[], + sourceNodesByIndex: ReadonlyMap, suppressedIndexes: Set, ): void { for (let position = 0; position < nodes.length; position += 1) { @@ -360,7 +371,7 @@ function collectIosSearchToolbarSuppression( } suppressedIndexes.add(node.index); - suppressToolbarAncestors(node, nodes, suppressedIndexes); + suppressToolbarAncestors(node, sourceNodesByIndex, suppressedIndexes); suppressSearchToolbarDescendants(nodes, position, innerSearch.index, suppressedIndexes); } } @@ -392,13 +403,12 @@ function suppressSearchToolbarDescendants( function suppressToolbarAncestors( node: RawSnapshotNode, - nodes: RawSnapshotNode[], + sourceNodesByIndex: ReadonlyMap, suppressedIndexes: Set, ): void { - const byIndex = new Map(nodes.map((candidate) => [candidate.index, candidate])); let current = node; while (typeof current.parentIndex === 'number') { - const parent = byIndex.get(current.parentIndex); + const parent = sourceNodesByIndex.get(current.parentIndex); if (!parent || parent.label !== 'Toolbar') { return; } diff --git a/src/daemon/snapshot-presentation/ios/scroll.ts b/src/daemon/snapshot-presentation/ios/scroll.ts index bfc87e795e..71feb88ea3 100644 --- a/src/daemon/snapshot-presentation/ios/scroll.ts +++ b/src/daemon/snapshot-presentation/ios/scroll.ts @@ -14,12 +14,11 @@ export function collectIosScrollIndicatorPresentation( nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext, ): void { - const byIndex = new Map(nodes.map((node) => [node.index, node])); for (const node of nodes) { if (!isIosScrollIndicatorNode(node)) { continue; } - collectIosScrollIndicatorNodePresentation(node, byIndex, context); + collectIosScrollIndicatorNodePresentation(node, context.sourceNodesByIndex, context); } } @@ -30,7 +29,7 @@ function isIosScrollIndicatorNode(node: RawSnapshotNode): boolean { function collectIosScrollIndicatorNodePresentation( node: RawSnapshotNode, - byIndex: Map, + byIndex: ReadonlyMap, context: SnapshotTreeRuleContext, ): void { if (!isScrollableSnapshotType(node.type)) { diff --git a/src/daemon/snapshot-presentation/ios/web.test.ts b/src/daemon/snapshot-presentation/ios/web.test.ts index 5f00eabff3..ab524218e7 100644 --- a/src/daemon/snapshot-presentation/ios/web.test.ts +++ b/src/daemon/snapshot-presentation/ios/web.test.ts @@ -24,10 +24,11 @@ test('projects iOS WebKit heading and text wrappers to semantic roles', () => { ).toEqual([ ['Application', 'Example', undefined, undefined], ['WebView', 'Example page', undefined, 0], - ['Heading', 'Welcome', '1', 1], + ['Heading', 'Welcome', undefined, 1], ['StaticText', 'Introduction', undefined, 1], ['Link', 'Read more', undefined, 1], ]); + expect(nodes[3]?.value).toBe('1'); }); test('recognizes numeric WebView types from retained older runners', () => { diff --git a/src/daemon/snapshot-presentation/ios/web.ts b/src/daemon/snapshot-presentation/ios/web.ts index 08025fefa3..4c8b1f6a62 100644 --- a/src/daemon/snapshot-presentation/ios/web.ts +++ b/src/daemon/snapshot-presentation/ios/web.ts @@ -9,13 +9,13 @@ import { findNearestAncestor, mergeReplacement, type SnapshotTreeRuleContext } f * * HTML headings use the same pair and expose their level as the wrapper value. * XCTest does not publish the level as a dedicated field, so the public role is - * `Heading` while the existing value remains available in the raw node. + * `Heading` while the backend-specific level remains available only in the raw + * diagnostic node. */ export function collectIosWebSemanticPresentation( nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext, ): void { - const byIndex = new Map(nodes.map((node) => [node.index, node])); const repeatedStaticTextByParent = collectRepeatedStaticTextByParent(nodes); for (const node of nodes) { @@ -23,29 +23,44 @@ export function collectIosWebSemanticPresentation( mergeReplacement(context.replacements, node, { type: 'WebView' }); continue; } - const presentation = classifyIosWebTextWrapper(node, byIndex, repeatedStaticTextByParent); - if (presentation === 'suppress') { + const presentation = classifyIosWebTextWrapper( + node, + context.sourceNodesByIndex, + repeatedStaticTextByParent, + ); + if (presentation?.kind === 'suppress') { context.suppressedIndexes.add(node.index); continue; } if (presentation) { - mergeReplacement(context.replacements, node, { type: presentation }); + mergeReplacement(context.replacements, node, { + type: presentation.type, + ...(presentation.type === 'Heading' ? { value: undefined } : {}), + }); + context.semanticRepresentativeIndexes.add(node.index); } } } +type IosWebTextWrapperPresentation = + | { kind: 'semantic'; type: 'Heading' | 'StaticText' } + | { kind: 'suppress' }; + function classifyIosWebTextWrapper( node: RawSnapshotNode, - byIndex: Map, + byIndex: ReadonlyMap, repeatedStaticTextByParent: Map>, -): 'Heading' | 'StaticText' | 'suppress' | null { +): IosWebTextWrapperPresentation | null { if (normalizeType(node.type ?? '') !== 'other') return null; const webView = findNearestAncestor(node, byIndex, isWebView); const label = node.label?.trim(); if (!webView || !label) return null; - if (isDocumentTitleWrapper(node, webView, label)) return 'suppress'; + if (isDocumentTitleWrapper(node, webView, label)) return { kind: 'suppress' }; if (!repeatedStaticTextByParent.get(node.index)?.has(label)) return null; - return isHtmlHeadingLevel(node.value) ? 'Heading' : 'StaticText'; + return { + kind: 'semantic', + type: isHtmlHeadingLevel(node.value) ? 'Heading' : 'StaticText', + }; } function isDocumentTitleWrapper( diff --git a/src/daemon/snapshot-presentation/tree.ts b/src/daemon/snapshot-presentation/tree.ts index 8f4a87b69a..6b0435a498 100644 --- a/src/daemon/snapshot-presentation/tree.ts +++ b/src/daemon/snapshot-presentation/tree.ts @@ -4,6 +4,9 @@ export { areRectsApproximatelyEqual } from '../../utils/rect-center.ts'; export type SnapshotTreeRuleContext = { replacements: Map; + /** Projected label owners that repeated-text suppression must retain. */ + semanticRepresentativeIndexes: Set; + sourceNodesByIndex: ReadonlyMap; suppressedIndexes: Set; }; @@ -71,7 +74,10 @@ function getDescendantEndPositions(nodes: RawSnapshotNode[]): number[] { return endPositions; } -function collectAncestors(node: T, byIndex: Map): T[] { +function collectAncestors( + node: T, + byIndex: ReadonlyMap, +): T[] { const ancestors: T[] = []; let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined; const visited = new Set(); @@ -86,7 +92,7 @@ function collectAncestors(node: T, byIndex: Map( node: T, - byIndex: Map, + byIndex: ReadonlyMap, predicate: (ancestor: T) => boolean, ): T | null { for (const ancestor of collectAncestors(node, byIndex)) { @@ -99,7 +105,7 @@ export function findNearestAncestor( export function findNearestScrollableContainer( node: T, - byIndex: Map, + byIndex: ReadonlyMap, options: { includeSelf?: boolean } = {}, ): T | null { const self = options.includeSelf === true && isScrollableSnapshotType(node.type) ? node : null; From 4c65a10081a91a6a39d61ebb9559a2a511eb5ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 25 Jul 2026 07:47:02 +0200 Subject: [PATCH 3/3] fix: preserve semantic WebView containers --- .../snapshot-presentation/ios/web.test.ts | 15 +++++++ src/daemon/snapshot-presentation/ios/web.ts | 41 ++++++++++++------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/daemon/snapshot-presentation/ios/web.test.ts b/src/daemon/snapshot-presentation/ios/web.test.ts index ab524218e7..469f5c88f7 100644 --- a/src/daemon/snapshot-presentation/ios/web.test.ts +++ b/src/daemon/snapshot-presentation/ios/web.test.ts @@ -104,6 +104,21 @@ test('does not infer text from a nested same-label descendant', () => { ]); }); +test('keeps a labelled WebKit container with additional semantic content', () => { + const nodes: RawSnapshotNode[] = [ + { index: 0, depth: 0, type: 'WebView', label: 'Example' }, + { index: 1, depth: 1, parentIndex: 0, type: 'Other', label: 'News' }, + { index: 2, depth: 2, parentIndex: 1, type: 'StaticText', label: 'News' }, + { index: 3, depth: 2, parentIndex: 1, type: 'Link', label: 'Read more' }, + ]; + + expect(presentIosInteractiveSnapshot(nodes).map((node) => [node.type, node.label])).toEqual([ + ['WebView', 'Example'], + ['Other', 'News'], + ['Link', 'Read more'], + ]); +}); + test('does not infer semantic roles for matching wrappers outside a WebView', () => { const nodes: RawSnapshotNode[] = [ { index: 0, depth: 0, type: 'Application', label: 'Example' }, diff --git a/src/daemon/snapshot-presentation/ios/web.ts b/src/daemon/snapshot-presentation/ios/web.ts index 4c8b1f6a62..4a9683f187 100644 --- a/src/daemon/snapshot-presentation/ios/web.ts +++ b/src/daemon/snapshot-presentation/ios/web.ts @@ -16,7 +16,7 @@ export function collectIosWebSemanticPresentation( nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext, ): void { - const repeatedStaticTextByParent = collectRepeatedStaticTextByParent(nodes); + const childrenByParent = collectChildrenByParent(nodes); for (const node of nodes) { if (normalizeType(node.type ?? '') === 'element(58)') { @@ -26,7 +26,7 @@ export function collectIosWebSemanticPresentation( const presentation = classifyIosWebTextWrapper( node, context.sourceNodesByIndex, - repeatedStaticTextByParent, + childrenByParent, ); if (presentation?.kind === 'suppress') { context.suppressedIndexes.add(node.index); @@ -49,20 +49,35 @@ type IosWebTextWrapperPresentation = function classifyIosWebTextWrapper( node: RawSnapshotNode, byIndex: ReadonlyMap, - repeatedStaticTextByParent: Map>, + childrenByParent: ReadonlyMap, ): IosWebTextWrapperPresentation | null { if (normalizeType(node.type ?? '') !== 'other') return null; const webView = findNearestAncestor(node, byIndex, isWebView); const label = node.label?.trim(); if (!webView || !label) return null; if (isDocumentTitleWrapper(node, webView, label)) return { kind: 'suppress' }; - if (!repeatedStaticTextByParent.get(node.index)?.has(label)) return null; + if (!isWebTextLeafWrapper(node, label, childrenByParent)) return null; return { kind: 'semantic', type: isHtmlHeadingLevel(node.value) ? 'Heading' : 'StaticText', }; } +function isWebTextLeafWrapper( + node: RawSnapshotNode, + label: string, + childrenByParent: ReadonlyMap, +): boolean { + const children = childrenByParent.get(node.index); + if (children?.length !== 1) return false; + const text = children[0]!; + return ( + normalizeType(text.type ?? '') === 'statictext' && + text.label?.trim() === label && + !childrenByParent.has(text.index) + ); +} + function isDocumentTitleWrapper( node: RawSnapshotNode, webView: RawSnapshotNode, @@ -75,19 +90,15 @@ function isDocumentTitleWrapper( ); } -function collectRepeatedStaticTextByParent(nodes: RawSnapshotNode[]): Map> { - const labelsByParent = new Map>(); +function collectChildrenByParent(nodes: RawSnapshotNode[]): Map { + const childrenByParent = new Map(); for (const node of nodes) { - if (typeof node.parentIndex !== 'number' || normalizeType(node.type ?? '') !== 'statictext') { - continue; - } - const label = node.label?.trim(); - if (!label) continue; - const labels = labelsByParent.get(node.parentIndex) ?? new Set(); - labels.add(label); - labelsByParent.set(node.parentIndex, labels); + if (typeof node.parentIndex !== 'number') continue; + const children = childrenByParent.get(node.parentIndex) ?? []; + children.push(node); + childrenByParent.set(node.parentIndex, children); } - return labelsByParent; + return childrenByParent; } function isWebView(node: RawSnapshotNode): boolean {