feat(mobile): complete hybrid parity hardening

This commit is contained in:
Jinwoo-H
2026-08-23 20:08:01 -04:00
parent b8d04428c0
commit 9e2da765fa
194 changed files with 6937 additions and 974 deletions
-9
View File
@@ -23,15 +23,6 @@
"packages/*/src/**/*.{js,jsx,ts,tsx}",
"*.{js,ts}"
],
"ignoreFiles": [
"src/accounts/use-open-mobile-accounts.ts",
"src/components/mobile-pr-url.ts",
"src/notifications/use-open-notification-route.ts",
"src/session/mobile-native-chat-message-occurrences.ts",
"src/session/use-mobile-native-chat-cancel-ask.ts",
"src/session/use-open-mobile-session.ts",
"src/tasks/use-open-mobile-tasks.ts"
],
"ignoreExportsUsedInFile": true,
"includeEntryExports": false
}
@@ -21,11 +21,11 @@ performance, accessibility, or production-promotion gates.
| Candidate checkpoint | `e931b2db07` |
| `origin/main` and merge base at checkpoint | `4c984d4c1b` |
| Checkpoint comparison | 81 commits; 1,396 files; 140,153 additions; 8,772 deletions |
| Post-audit integration checkpoint | `b14fe74214` |
| Current `origin/main` checkpoint | `b2902cb61e` |
| Post-audit comparison | 89 commits; 1,401 files; 134,626 additions; 8,771 deletions |
| Latest verified package | `121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5` |
| Latest package size | 51 assets; 9,151,993 raw bytes; 2,649,166 gzip bytes |
| Post-audit integration checkpoint | `b8d04428c0` |
| Current `origin/main` checkpoint | `e50cc309c3` |
| Post-audit comparison | 92 commits; 1,401 files; 134,633 additions; 8,771 deletions |
| Latest verified package | `015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25` |
| Latest package size | 52 assets; 9,310,227 raw bytes; 2,691,744 gzip bytes |
Commit and branch counts are historical evidence for that checkpoint; rerun
`git merge-base origin/main HEAD`, `git rev-list --count origin/main..HEAD`, and
@@ -45,7 +45,7 @@ Commit and branch counts are historical evidence for that checkpoint; rerun
| Bridge | Exact v2 contract, named operations, generated grants/schemas/bounds, opaque authority, mutation reauthorization, response correlation, lifecycle cleanup | `548d8d64aa`, `e526780848`, `b2068661f7`, `ee15298704`, `2094dde1f9` |
| Routes | Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, Source Control, and Review use explicit hosted adapters | [Parity inventory](2026-07-22-mobile-hybrid-webview-parity-inventory.md) |
| Terminal | Real xterm/PTY byte path, bounded ACK/backpressure, links, selection/paste, reconnect/resnapshot, SSH recovery | Direct, SSH, and packaged SSH E2E commands |
| Native capabilities | Gesture/permission/foreground-mediated clipboard, documents/photos, haptics, external URLs, speech/audio, settings, and diagnostics | iOS/Android capability journeys and focused tests |
| Native capabilities | Gesture/permission/foreground-mediated clipboard, documents/photos, haptics, external URLs, speech/audio, alerts, Android Back, settings, and diagnostics | iOS/Android capability journeys and focused tests |
| Privacy | Credentials, endpoints, paths, durable host identity, page payloads, and full build IDs excluded from page authority, storage, logs, cache identity, and diagnostics | `8ee4fcdac1`, `d6b8b14c82`, exact-app privacy audits |
| Security review | 2026-08-21 independent OpenCode review covered bridge, shells, package store, runtime RPC, Relay, and SSH; high-severity findings fixed | Review record summarized below; not physical/store certification |
| Rollback | Automatic active/previous recovery, process-loss rollback, host-scoped manual recovery, and corrected Desktop/native incident procedure | [Rollback runbook](../mobile-hybrid-webview-rollback.md) |
@@ -68,6 +68,7 @@ Run from the repository root unless noted.
| iOS hosted journey | `pnpm --dir mobile test:e2e:hosted-webview` | Route/capability/reconnect simulator journey |
| iOS hostile-content journey | `pnpm --dir mobile test:e2e:hosted-webview:security` | Network/navigation/executable/storage isolation |
| Android route journey | `pnpm --dir mobile test:e2e:hosted-webview:android-routes` | Hosted Source Control/Review and route interactions |
| Android real hardware Back | `pnpm --dir mobile test:e2e:hosted-webview:android-back` | Real `KEYCODE_BACK`: nested route, Session, workspace, native shell |
| Android hostile-content journey | `pnpm --dir mobile test:e2e:hosted-webview:android-security` | Executable/network/navigation/privacy isolation |
| Android locally signed Release inspection | `pnpm --dir mobile test:e2e:hosted-webview:android-release` | Local Release WebView behavior only; not Play signing |
| iOS process-loss rollback | `pnpm --dir mobile test:e2e:hosted-webview:ios-crash-loop` | Repeated WebView loss and generation recovery on Simulator |
@@ -83,8 +84,8 @@ and raw result location.
## Package and Topology Evidence
- Latest independent verification:
`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`,
51 assets, 9,151,993 raw bytes, 2,649,166 gzip bytes.
`015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25`,
52 assets, 9,310,227 raw bytes, 2,691,744 gzip bytes.
- Packaged macOS arm64 Desktop through an isolated Docker SSH provider to an
actual iOS WKWebView used
`7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d`.
@@ -115,6 +116,15 @@ native-versus-hosted results must replace the invalidated figures before parity
certification. Simulator evidence will still not close physical-device,
accessibility, input, or performance gates.
The 2026-08-23 strict iPhone Simulator Source Control/Review run used package
`015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25` and an
actual WKWebView. It covered host-list long press into Source Control, changed
file Review, return to Source Control and workspace root, physical workspace
selection into Session, and a second session-origin Source Control mount. The
corrected native-versus-hosted captures passed at 1.172% and 1.189% changed
pixels against a 3% budget, with 1.470 and 1.321 mean channel differences
against a 4.0 budget. The second mount retained branch and pull-request state.
## Security and Lifecycle Evidence
- TypeScript, Swift, and Kotlin share exact path, hash, MIME/role, manifest,
@@ -124,13 +134,17 @@ accessibility, input, or performance gates.
traversal, symlinks, linked parents, unexpected file types, and out-of-root
reads before activation. Mirrored concurrency tests cover same-host stage,
activation, cleanup, commit/abort, and removal races.
- All 224 recorded production grants rejected eight malformed request shapes
plus an oversized request before native/host access: 2,016 cases. Exported
- All 225 recorded production grants rejected eight malformed request shapes
plus an oversized request before native/host access: 2,025 cases. Exported
results and subscription events have invalid-payload admission coverage;
invalid events retire their subscriptions.
- Cross-build/session tests cover a 15-pair stale authority grid. Cancellation,
client replacement, disposal, replay, late subscription registration, and
delayed mutation results fail closed.
- Branch comparisons retain one bounded shell-owned host snapshot across
single-use page continuations. Only the exact next page bypasses the initial
rate charge; forged and replayed continuations remain throttled, and large
comparisons no longer rerun Git for every 128-entry page.
- Exact iOS and Android emulator apps rendered hostile filenames, diffs,
terminal links, provider/task strings, errors, Markdown, HTML, SVG, Mermaid,
and image metadata inertly, with no execution marker or sentinel traffic.
@@ -141,6 +155,10 @@ accessibility, input, or performance gates.
- Automatic recovery covered corruption and repeated process loss. The iOS
Simulator candidate-to-final three-crash drill restored the verified previous
generation. Final physical/store-signed rollback drills remain open.
- Android API 36 exercised three real hardware Back events from Agent History
through Session and workspace root to the reset app's native shell, followed
by a clean bridge-log audit. Dirty-state, mixed-version, and physical-device
Back coverage remain open.
- The 2026-08-21 independent OpenCode review found issues in live bridge/native/
package/runtime RPC/Relay/SSH boundaries. High-severity findings were repaired,
including bounded SSH/Relay methods, connection-owned file-watch teardown,
@@ -149,11 +167,19 @@ accessibility, input, or performance gates.
## Evidence Interpretation
The latest recorded full mobile validation passed 599 mobile files and 3,568
tests with two expected skips, plus mobile typecheck/lint/format and diff
hygiene. Root typecheck/lint/code-quality, 56 reliability gates, localization,
and max-lines also passed at that checkpoint. Rerun current CI before release;
historical counts are not evergreen.
The latest recorded full mobile validation passed 722 files and 4,708 tests,
with three skips. The latest concurrent root run passed 6,193 files and 58,084
tests, with 39 skipped files and 245 skipped tests; two deadline-sensitive tests
failed under load and then passed 25/25 together with one worker. Root, mobile,
and hosted-web typechecks and lints, changed-code quality, 90 reliability gates,
localization, max-lines, and Electron ratchets also passed. Relay Markdown
discovery passed its shared cross-platform process-launcher boundary tests.
Rerun current CI before release; historical counts are not evergreen.
The 2026-08-23 branch-comparison correction additionally passed 33 focused
broker/history/grant tests, mobile and hosted-web typechecks, focused lint and
formatting, diff hygiene, and the strict actual-WKWebView Source Control/Review
journey above.
No entry in this document claims the following passed: physical phones or
tablets, production-store-signed apps, Windows/Linux/headless packaged Desktop,
@@ -559,8 +559,8 @@ renderer-independent, while the authoritative RNW package still verifies as
build `f293890e…`.
Raw bridge envelopes, including initial shell setup, now pass an exact
unique-key, paired-surrogate, bounded JSON grammar before schema parsing. The
generated broker corpus rejects 2,016 malformed or oversized requests across
all 224 production grants before host or native access. Valid response
generated broker corpus rejects 2,025 malformed or oversized requests across
all 225 production grants before host or native access. Valid response
envelopes also cross 157 exported result schemas and all eight subscription
event schemas with invalid payloads; requests fail closed and invalid events
cancel their subscriptions. Pending terminal/native-chat setup cannot register
@@ -158,8 +158,8 @@ pnpm test:e2e:hosted-mobile-webview:ssh:packaged
```
The final recorded independently verified package was
`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`:
51 assets, 9,151,993 raw bytes, and 2,649,166 gzip bytes. A packaged macOS arm64
`5eda0f5c7f5265e9be2420d9178c03605dbe4c8e5ea9e669327d680f1e9c0eb3`:
52 assets, 9,308,959 raw bytes, and 2,691,373 gzip bytes. A packaged macOS arm64
Desktop to Docker SSH to actual iOS WKWebView journey used package
`7c7c673deb74e158cdfb99b1ca536fd88cd3ab5dac4eb8db78c43ca12f6ce31d`
without checkout fallback.
@@ -179,6 +179,8 @@ performance gates.
- Shared workspace, Accounts, Tasks, Session, Files/Preview, Agent History,
Source Control, Review, terminal, browser, attachments, and native-chat
presentation through explicit native/web adapters.
- Negotiated Android hardware Back and native Alert presentation so hosted
routes retain current shell behavior and mixed versions degrade safely.
- Bound files, source control, provider review, tasks, agent mutations,
terminal streams, browser controls, native chat, and continuations to opaque
current authority.
@@ -23,8 +23,8 @@ composition have recorded evidence. The 2026-08-21 independent OpenCode review
was completed and its high-severity findings were fixed.
Latest recorded package:
`121fe8682fc221fd7e6f2955fe1f246017d164db3122d71526bc3f66b19578c5`,
51 assets, 9,151,993 raw bytes, 2,649,166 gzip bytes. This summary is not a
`015f15ca1bbbc28f25318283a2fff0ea616db868540a2e86bd3d5ce61c4a3f25`,
52 assets, 9,310,227 raw bytes, 2,691,744 gzip bytes. This summary is not a
production-readiness claim.
## Packaged Desktop and Signed App Matrix
@@ -43,9 +43,12 @@ production-readiness claim.
## Physical Devices and Layouts
- [x] Re-run the corrected iPhone Simulator native-versus-hosted fixture for
Source Control/Review, including host-origin navigation and a second
session-origin Source Control mount.
- [ ] Re-run the corrected iPhone Simulator native-versus-hosted fixture for
Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, Source
Control/Review, host editing, Floating Workspace, and recovery. Require
Workspace, Accounts, Tasks, Session, Agent History, Files/Preview, host
editing, Floating Workspace, and recovery. Require
CDP proof that no hosted private-origin target exists around each native
capture and replace the invalidated historical screenshot metrics.
- [ ] Test the oldest supported low-memory and current iPhone.
@@ -55,6 +58,13 @@ production-readiness claim.
memory pressure, attachment permission/denial/revocation/interruption,
notification/deep-link routing, rotation, and host/session replacement on
those devices.
- [x] Validate Android hardware Back from nested Agent History through Session
and workspace root to the native shell on the current API 36 emulator,
using real `KEYCODE_BACK` input and a clean bridge-log audit.
- [ ] Validate Android hardware Back at dirty drafts, timeout, reconnect, mixed
Desktop/mobile versions, and on a physical device.
- [ ] Validate native Alert button ordering, dismissal, queueing, destructive
actions, and old-shell fallback on iOS and Android release candidates.
## Topology and Compatibility
@@ -4,8 +4,8 @@
- **Status:** Simplifications integrated; release validation remains
- **Candidate checkpoint:** `e931b2db07`
- **Base and `origin/main` checkpoint:** `4c984d4c1b`
- **Post-audit integration checkpoint:** `b14fe74214`
- **Current `origin/main` checkpoint:** `b2902cb61e`
- **Post-audit integration checkpoint:** `b8d04428c0`
- **Current `origin/main` checkpoint:** `e50cc309c3`
- **Decision record:** [migration record](2026-07-22-mobile-hybrid-webview-single-pr-migration.md)
- **Release gates:** [remaining-work tracker](2026-07-27-mobile-hybrid-webview-remaining-work.md)
@@ -30,9 +30,9 @@ files, 140,153 additions, and 8,772 deletions. Those counts describe
`4c984d4c1b...e931b2db07`; they are not current-main release metrics.
After the approved reductions, fixes, and current-main merge, the comparison is
89 commits, 1,401 files, 134,626 additions, and 8,771 deletions relative to
`b2902cb61e`. Documentation condensation accounts for most of the reduction;
the explicit 224-operation bridge and shared RNW presentation remain the main
92 commits, 1,401 files, 134,633 additions, and 8,771 deletions relative to
`e50cc309c3`. Documentation condensation accounts for most of the reduction;
the explicit 225-operation bridge and shared RNW presentation remain the main
implementation span.
The non-negotiable constraints were:
@@ -157,10 +157,11 @@ Tasks, Files, and Session before any broad conversion.
| Parity and cutover seams | `7815f3afb3` | Preserved native host editing, made workspace routes hybrid-only, removed the hosted picker duplicate, added test-only native baselines, and negotiated host gates |
| Subscription regression | `ee15298704` | Restored the shared bridge envelope used by subscriptions after the parity refactor exposed a removed-method call |
| Parity bounds | `2094dde1f9` | Made native CDP exclusion fail closed above its inspection limit and expanded the bounded session-capability response allowance |
| Relay process boundary | `c3725a60fc` | Moved Relay Markdown discovery to the shared cross-platform process launcher and preserved the child-process import ratchet |
The declarative registry prototype covered Tasks, Files, and Session and proved
that metadata, grants, and typed calls can be generated without replacing the
handwritten authorization executors. Converting all 224 operations in this PR
handwritten authorization executors. Converting all 225 operations in this PR
would increase review and compatibility risk, so the registry remains a
non-blocking follow-up.
@@ -180,9 +181,13 @@ non-blocking follow-up.
presentation.
- [x] Prototype the declarative registry on Tasks, Files, and Session and defer
broad conversion to a follow-up.
- [ ] Rerun package verification, typecheck/lint, bridge/cache/security suites,
Direct/SSH hosted E2E, emulator parity, and diff hygiene after integrated
simplifications.
- [x] Rerun deterministic package verification, full mobile/root suites,
typecheck/lint, bridge/cache/security suites, and diff hygiene after
integrated simplifications.
- [x] Rerun corrected Source Control/Review native-versus-hosted emulator parity,
including host-origin navigation and the second session-origin mount.
- [ ] Complete corrected native-versus-hosted emulator parity for the remaining
route matrix after integrated simplifications.
- [ ] Keep the [release-gate tracker](2026-07-27-mobile-hybrid-webview-remaining-work.md)
current; do not promote simulator/local evidence into physical, store,
production cloud Relay, mixed-version, performance, or App Review proof.
+1
View File
@@ -249,6 +249,7 @@ export default function RootLayout() {
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</View>
</RpcClientProvider>
@@ -1,9 +1,9 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
export default function MobileAgentSessionHistoryScreen() {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
+2 -2
View File
@@ -1,5 +1,5 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFileExplorerPanel } from '../../../../src/files/MobileFileExplorerPanel'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import type { HostFileExplorerOperations } from '../../../../src/files/host-file-explorer-operations'
import type { ConnectionState } from '../../../../src/transport/types'
@@ -12,7 +12,7 @@ export function MobileFileExplorerScreen({
connectionState?: ConnectionState
nativeHostBinding?: boolean
} = {}) {
const { hostId, worktreeId, name } = useLocalSearchParams<{
const { hostId, worktreeId, name } = useMobileWebRouteParams<{
hostId: string
worktreeId: string
name?: string
@@ -1,5 +1,5 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen'
import { useMobileWebRouteParams } from '../../../../../src/mobile-web/use-mobile-web-route-params'
import type { HostFilePreviewOperations } from '../../../../../src/files/host-file-preview-operations'
import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route'
import type { ConnectionState } from '../../../../../src/transport/types'
@@ -13,7 +13,7 @@ export function MobileFilePreviewRoute({
connectionState?: ConnectionState
nativeHostBinding?: boolean
} = {}) {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
relativePath?: string | string[]
@@ -1,11 +1,12 @@
import { Redirect, useLocalSearchParams } from 'expo-router'
import { Redirect } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
// History is now a segment of the Source Control hub. This route stays as a thin
// redirect so existing deep links (and any cached navigation) land on the hub with
// the History segment selected.
export default function HistoryRedirect() {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
+3 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams } from 'expo-router'
import { useFocusEffect } from 'expo-router'
import {
Search,
X,
@@ -21,6 +21,7 @@ import {
SquareTerminal
} from 'lucide-react-native'
import { useHostClient } from '../../../src/transport/client-context'
import { useMobileWebRouteParams } from '../../../src/mobile-web/use-mobile-web-route-params'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
@@ -140,7 +141,7 @@ export function HostScreen({
hostState: hostStateProp,
shellOperations: shellOperationsProp
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const params = useMobileWebRouteParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
+3 -2
View File
@@ -1,12 +1,13 @@
import { Redirect, useLocalSearchParams } from 'expo-router'
import { Redirect } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
// The Pull Request view is now a segment of the Source Control hub. This route
// stays as a thin redirect so existing deep links land on the hub with the Pull
// Request segment selected. The wide-layout dock opens the same hub with
// initialTab="pr" (SessionDockColumn), not this route.
export default function PrRedirect() {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import { MobileDiffReviewScreenView } from '../../../../src/components/MobileDiffReviewScreenView'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import {
firstReviewParam,
normalizeReviewFilterParam
@@ -11,11 +12,13 @@ import type { HostDiffReviewBinding } from '../../../../src/session/host-diff-re
import { useHostDiffReviewBinding } from '../../../../src/session/use-host-diff-review-binding'
export function MobileDiffReviewRoute({
binding
binding,
routeName
}: {
binding?: HostDiffReviewBinding
routeName?: string
} = {}) {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
@@ -25,7 +28,7 @@ export function MobileDiffReviewRoute({
}>()
const hostId = firstReviewParam(params.hostId)
const worktreeId = firstReviewParam(params.worktreeId)
const name = firstReviewParam(params.name)
const name = routeName ?? firstReviewParam(params.name)
const initialFilter = normalizeReviewFilterParam(firstReviewParam(params.scope))
const initialFile = firstReviewParam(params.file)
const initialArea = normalizeReviewAreaParam(firstReviewParam(params.area))
+7 -21
View File
@@ -14,12 +14,11 @@ import {
Keyboard,
Platform,
ActivityIndicator,
type KeyboardEvent,
type LayoutChangeEvent,
type ListRenderItem
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import {
AlertTriangle,
ArrowUp,
@@ -54,6 +53,7 @@ import {
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import {
useLastConnectedAt,
useRelayRecoveryStatus,
@@ -92,6 +92,7 @@ import type {
} from '../../../../src/terminal/terminal-webview-contract'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import { useMobileKeyboardInset } from '../../../../src/hooks/use-mobile-keyboard-inset'
import {
getDefaultTerminalAccessoryBuiltInIds,
getVisibleTerminalAccessoryKeys
@@ -751,7 +752,7 @@ export function SessionScreen({
name: routeWorktreeName,
created,
warning: createdWarning
} = useLocalSearchParams<{
} = useMobileWebRouteParams<{
hostId: string
worktreeId: string
name?: string
@@ -1013,7 +1014,7 @@ export function SessionScreen({
[visibleBuiltInIds]
)
// Why: Expo SDK 55 edge-to-edge doesn't resize the window on IME open, so track keyboard height ourselves and lift the input without resizing the desktop PTY.
const [keyboardHeight, setKeyboardHeight] = useState(0)
const keyboardHeight = useMobileKeyboardInset().height
// Why: server-authoritative display mode per terminal, populated from subscribe responses.
const [terminalModes, setTerminalModes] = useState<Map<string, MobileDisplayMode>>(new Map())
const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState<
@@ -2519,23 +2520,8 @@ export function SessionScreen({
})
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
notifyKeyboardVisibility(true)
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
notifyKeyboardVisibility(false)
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const showSub = Keyboard.addListener(showEvent, onShow)
const hideSub = Keyboard.addListener(hideEvent, onHide)
return () => {
showSub.remove()
hideSub.remove()
}
}, [notifyKeyboardVisibility])
notifyKeyboardVisibility(keyboardHeight > 0)
}, [keyboardHeight, notifyKeyboardVisibility])
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
@@ -1,15 +1,19 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
import type { HostSourceControlBinding } from '../../../../src/source-control/host-source-control-binding'
export function MobileSourceControlRoute({
binding
binding,
routeName,
routeOrigin
}: {
binding?: HostSourceControlBinding
routeName?: string
routeOrigin?: string
} = {}) {
const params = useLocalSearchParams<{
const params = useMobileWebRouteParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
@@ -20,8 +24,8 @@ export function MobileSourceControlRoute({
<MobileSourceControlPanel
hostId={firstParam(params.hostId)}
worktreeId={firstParam(params.worktreeId)}
name={firstParam(params.name)}
origin={firstParam(params.origin)}
name={routeName ?? firstParam(params.name)}
origin={routeOrigin ?? firstParam(params.origin)}
initialTab={parseSourceControlHubTab(params.tab)}
embedded={false}
binding={binding}
+3 -2
View File
@@ -12,7 +12,7 @@ import {
View
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import {
AlertTriangle,
Check,
@@ -31,6 +31,7 @@ import {
X
} from 'lucide-react-native'
import { useHostClient } from '../../../src/transport/client-context'
import { useMobileWebRouteParams } from '../../../src/mobile-web/use-mobile-web-route-params'
import {
useLastConnectedAt,
useRelayRecoveryStatus,
@@ -2144,7 +2145,7 @@ export default function MobileTasksScreen({
connectionMetrics,
nativeHostBinding = true
}: MobileTasksScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; taskSource?: string }>()
const params = useMobileWebRouteParams<{ hostId: string; taskSource?: string }>()
const hostId = hostIdProp ?? params.hostId
const taskSource = params.taskSource
const router = useRouter()
+47 -49
View File
@@ -25,10 +25,9 @@ import { useMobileWebUserGestureAuthority as useGestureAuthority } from '../src/
import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog'
import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store'
import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref'
import {
completeMobileWebNativeRouteHandoffAfterResponse,
MobileWebNativeRouteHandoff
} from '../src/mobile-web/mobile-web-native-route-handoff'
import { useMobileWebHardwareBackHandoff } from '../src/mobile-web/use-mobile-web-hardware-back-handoff'
import { MobileWebNativeRouteHandoff } from '../src/mobile-web/mobile-web-native-route-handoff'
import { handleMobileWebBrokerMessage } from '../src/mobile-web/mobile-web-broker-message-handoff'
import {
useForceReconnect,
useForgetHostClient,
@@ -67,19 +66,20 @@ export default function HybridScreen() {
const { client, state } = useHostClient(selectedHostId)
const closeHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const reconnectAttempts = useReconnectAttempt(selectedHostId)
const lastConnectedAt = useLastConnectedAt(selectedHostId)
const reconnects = useReconnectAttempt(selectedHostId)
const lastConnected = useLastConnectedAt(selectedHostId)
const selectedHost = useMemo(
() => hosts.find((host) => host.id === selectedHostId),
[hosts, selectedHostId]
)
const hostName = selectedHost?.name
const {
session,
viewEpoch,
packageLoading,
packageWarning,
markHealthy,
handleHealthTimeout,
handleHealthTimeout: onHealthTimeout,
handleProcessTerminated,
retryPackage,
recoverPrevious,
@@ -96,6 +96,10 @@ export default function HybridScreen() {
shellSessionId: session?.sessionId,
selectHost
})
const handleBack = useCallback(() => {
coldResumeRoute.clearRoute()
leaveHostRoute(router)
}, [coldResumeRoute.clearRoute, router])
useEffect(() => {
if (hostsLoading || selectedHost || e2eHostId) {
@@ -151,6 +155,13 @@ export default function HybridScreen() {
}
await view.postMessage(JSON.stringify(message))
}, [])
const hardwareBackHandoff = useMobileWebHardwareBackHandoff({
shellSessionId: session?.sessionId,
buildId: session?.buildId,
forwardingEnabled: hostedViewActive,
postMessage: postToWeb,
onUnhandled: handleBack
})
useEffect(() => {
brokerRef.current?.dispose()
@@ -251,7 +262,7 @@ export default function HybridScreen() {
initializedSessionRef.current = current.sessionId
healthDeadlineRef.current.arm(current.sessionId, (sessionId) => {
if (activeSessionIdRef.current === sessionId) {
void handleHealthTimeout(sessionId)
void onHealthTimeout(sessionId)
}
})
await postToWeb({
@@ -260,12 +271,13 @@ export default function HybridScreen() {
shellSessionId: current.sessionId,
buildId: current.buildId,
connection: mobileWebBridgeConnectionState(state),
reconnectAttempts,
lastConnectedAt,
hostDisplayName: hostName,
reconnectAttempts: reconnects,
lastConnectedAt: lastConnected,
resumeRoute: resumeRouteRef.current,
grants: [...MOBILE_WEB_PRODUCTION_GRANTS]
})
}, [handleHealthTimeout, lastConnectedAt, postToWeb, reconnectAttempts, session, state])
}, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, session, state])
useEffect(() => {
postInitRef.current = postInit
}, [postInit])
@@ -282,10 +294,10 @@ export default function HybridScreen() {
shellSessionId: current.sessionId,
buildId: current.buildId,
state: mobileWebBridgeConnectionState(state),
reconnectAttempts,
lastConnectedAt
reconnectAttempts: reconnects,
lastConnectedAt: lastConnected
})
}, [lastConnectedAt, postToWeb, reconnectAttempts, session, state])
}, [lastConnected, postToWeb, reconnects, session, state])
const handleBridgeMessage = useCallback(
async (raw: string) => {
@@ -301,53 +313,40 @@ export default function HybridScreen() {
return
}
responseDropRef.current.recordRequest(parsed.value)
const backMessageHandled = hardwareBackHandoff.handlePageMessage(parsed.value)
if (parsed.value.type === 'ready') {
// `ready` acknowledges init; echoing init here starves the health frame.
if (activeSessionIdRef.current === current.sessionId) {
setPageReadySessionId(current.sessionId)
}
} else if (backMessageHandled) {
return
} else if (parsed.value.type === 'health') {
healthDeadlineRef.current.acknowledge(current.sessionId)
await markHealthy(current.sessionId)
} else if (parsed.value.type === 'routeState') {
brokerRef.current?.rememberRoute(parsed.value.route)
} else {
const broker = brokerRef.current
await broker?.handle(parsed.value)
if (
parsed.value.type === 'request' &&
broker &&
brokerRef.current === broker &&
activeSessionIdRef.current === current.sessionId
) {
completeMobileWebNativeRouteHandoffAfterResponse({
handoff: nativeRouteHandoffRef.current,
requestId: parsed.value.requestId,
shouldNavigate: () =>
brokerRef.current === broker && activeSessionIdRef.current === current.sessionId,
deactivateSessionView: async () => {
const view = viewRef.current
if (!view) {
throw new Error('mobile_web_session_view_unavailable')
}
await view.deactivateSessionView()
},
setHostedViewActive,
navigate: () => {
router.push('/terminal-settings')
},
onFailure: () => showWarning('Terminal settings could not be opened.')
})
}
await handleMobileWebBrokerMessage({
message: parsed.value,
brokerRef,
activeSessionIdRef,
sessionId: current.sessionId,
viewRef,
routeHandoff: nativeRouteHandoffRef.current,
setHostedViewActive,
navigateToTerminalSettings: () => router.push('/terminal-settings'),
onNavigationFailure: () => showWarning('Terminal settings could not be opened.')
})
}
},
[markHealthy, postInit, router, session, showWarning]
[hardwareBackHandoff, markHealthy, postInit, router, session, showWarning]
)
const shellContext = useMemo(
() => (session ? { sessionId: session.sessionId, buildId: session.buildId } : null),
[session?.buildId, session?.sessionId]
)
const getBroker = useCallback(() => brokerRef.current, [])
const rememberRoute = useCallback((route: MobileWebResumeRoute) => {
resumeRouteRef.current = route
@@ -369,11 +368,6 @@ export default function HybridScreen() {
showWarning
})
const handleBack = useCallback(() => {
coldResumeRoute.clearRoute()
leaveHostRoute(router)
}, [coldResumeRoute.clearRoute, router])
return (
<MobileWebHybridShellPresentation
viewRef={viewRef}
@@ -403,9 +397,13 @@ export default function HybridScreen() {
recentWebGestureAtRef.current = Date.now()
}}
onBridgeMessage={(message) => void handleBridgeMessage(message)}
onPageLoaded={() => void postInit()}
onPageLoaded={() => {
hardwareBackHandoff.resetPage()
void postInit()
}}
onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')}
onProcessTerminated={(sessionId) => {
hardwareBackHandoff.resetPage()
healthDeadlineRef.current.clear()
void handleProcessTerminated(sessionId)
}}
+30 -7
View File
@@ -1,16 +1,26 @@
import { Stack } from 'expo-router'
import { Stack, usePathname } from 'expo-router'
import { StyleSheet, View } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { MobileWebNativeShellProvider } from '../../src/mobile-web/src/native-shell-channel'
import {
MobileWebNativeShellProvider,
useMobileWebNativeShell
} from '../../src/mobile-web/src/native-shell-channel'
import { installMobileWebHistorySessionFragment } from '../src/mobile-web/mobile-web-history-session-fragment'
import {
MobileWebNativeBehaviorAdapter,
installMobileWebNativeBehaviorAdapters
} from '../src/mobile-web/mobile-web-native-behavior-adapter'
import { installMobileWebQuerylessHistory } from '../src/mobile-web/mobile-web-queryless-history'
import { colors } from '../src/theme/mobile-theme'
import { RpcClientProvider } from '../src/transport/client-context'
import { MobileWebRouteErrorBoundary } from './mobile-web-route-error-boundary'
import { MobileWebRouteRestorer } from './mobile-web-route-restorer'
installMobileWebHistorySessionFragment()
installMobileWebQuerylessHistory()
installMobileWebNativeBehaviorAdapters()
export default function HostMobileWebLayout() {
return (
@@ -19,11 +29,8 @@ export default function HostMobileWebLayout() {
<RpcClientProvider>
<MobileWebNativeShellProvider>
<MobileWebRouteRestorer />
<View style={styles.root}>
<MobileWebRouteErrorBoundary>
<Stack screenOptions={{ headerShown: false }} />
</MobileWebRouteErrorBoundary>
</View>
<MobileWebRouteStack />
<MobileWebNativeBehaviorAdapter />
</MobileWebNativeShellProvider>
</RpcClientProvider>
</SafeAreaProvider>
@@ -31,6 +38,22 @@ export default function HostMobileWebLayout() {
)
}
function MobileWebRouteStack() {
const pathname = usePathname()
const shell = useMobileWebNativeShell()
const contextKey = shell.context
? `${shell.context.shellSessionId}:${shell.context.buildId}`
: 'pending'
return (
<View style={styles.root}>
<MobileWebRouteErrorBoundary resetKey={`${contextKey}:${pathname}`}>
<Stack screenOptions={{ headerShown: false }} />
</MobileWebRouteErrorBoundary>
</View>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
+5 -2
View File
@@ -8,8 +8,11 @@ const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop'
export default function HostMobileWebAccountsRoute() {
const shell = useMobileWebNativeShell()
const operations = useMemo(
() => (shell.client ? webHostAccountsOperations(shell.client, 'Orca Desktop') : undefined),
[shell.client]
() =>
shell.client
? webHostAccountsOperations(shell.client, shell.hostDisplayName ?? 'Orca Desktop')
: undefined,
[shell.client, shell.hostDisplayName]
)
const connectionState =
shell.connection === 'offline'
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import type { AiVaultScope } from '../../../../../src/shared/ai-vault-types'
import type {
MobileWebAgentHistorySession,
@@ -7,6 +7,7 @@ import type {
} from '../../../../../src/shared/mobile-web/agent-history-operation-contract'
import type { MobileWebBridgeClient } from '../../../../../src/mobile-web/src/mobile-web-bridge-client'
import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
import {
MobileAgentSessionHistoryPresentation,
type MobileAgentHistoryPresentationState
@@ -18,7 +19,7 @@ import { getWorktreeLabel } from '../../../../src/session/worktree-label'
export default function HostMobileWebAgentHistoryRoute() {
const shell = useMobileWebNativeShell()
const router = useRouter()
const { hostId, worktreeId, name } = useLocalSearchParams<{
const { hostId, worktreeId, name } = useMobileWebRouteParams<{
hostId: string
worktreeId: string
name?: string
@@ -31,6 +32,11 @@ export default function HostMobileWebAgentHistoryRoute() {
const [resumingSessionId, setResumingSessionId] = useState<string | null>(null)
const sessionsRef = useRef<MobileWebAgentHistorySession[]>([])
const loadGenerationRef = useRef(0)
const workspaceName =
name ??
(shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === worktreeId
? shell.resumeRoute.workspaceName
: undefined)
const load = useCallback(
async (force: boolean) => {
@@ -145,7 +151,7 @@ export default function HostMobileWebAgentHistoryRoute() {
return (
<MobileAgentSessionHistoryPresentation
worktreeLabel={getWorktreeLabel(name, worktreeId)}
worktreeLabel={getWorktreeLabel(workspaceName, worktreeId)}
scope={scope}
state={state}
refreshing={refreshing}
@@ -8,6 +8,10 @@ export default function HostMobileWebDiffReviewRoute() {
const params = useLocalSearchParams<{ worktreeId?: string | string[] }>()
const workspaceId = firstParam(params.worktreeId)
const shell = useMobileWebNativeShell()
const workspaceName =
shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === workspaceId
? shell.resumeRoute.workspaceName
: undefined
const client = useMemo(
() => (shell.client && workspaceId ? webHostDiffReviewClient(shell.client, workspaceId) : null),
[shell.client, workspaceId]
@@ -21,6 +25,7 @@ export default function HostMobileWebDiffReviewRoute() {
return (
<MobileDiffReviewRoute
routeName={workspaceName}
binding={{
client,
connectionState,
@@ -1,5 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useLocalSearchParams } from 'expo-router'
import { SessionScreen } from '../../../../app/h/[hostId]/session/[worktreeId]'
import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel'
import { webHostSessionTabOperations } from '../../../../src/session/web-host-session-tab-operations'
@@ -14,10 +13,11 @@ import { webHostSessionDictationOperations } from '../../../../src/session/web-h
import { webHostSessionNativeChatOperations } from '../../../../src/session/web-host-session-native-chat-operations'
import { webHostSessionChatDraftOperations } from '../../../../src/session/web-host-session-chat-draft-operations'
import { webHostSessionChatPendingDeliveryOperations } from '../../../../src/session/web-host-session-chat-pending-delivery-operations'
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
export default function HostMobileWebSessionRoute() {
const shell = useMobileWebNativeShell()
const { worktreeId, name } = useLocalSearchParams<{
const { worktreeId, name } = useMobileWebRouteParams<{
worktreeId: string
name?: string
}>()
@@ -8,6 +8,10 @@ export default function HostMobileWebSourceControlRoute() {
const params = useLocalSearchParams<{ worktreeId?: string | string[] }>()
const workspaceId = firstParam(params.worktreeId)
const shell = useMobileWebNativeShell()
const workspaceName =
shell.resumeRoute.kind === 'session' && shell.resumeRoute.workspaceId === workspaceId
? shell.resumeRoute.workspaceName
: undefined
const client = useMemo(
() =>
shell.client && workspaceId ? webHostSourceControlClient(shell.client, workspaceId) : null,
@@ -22,6 +26,7 @@ export default function HostMobileWebSourceControlRoute() {
return (
<MobileSourceControlRoute
routeName={workspaceName}
binding={{
client,
connectionState,
+6 -2
View File
@@ -30,8 +30,12 @@ export default function HostMobileWebRoute() {
[shell.client]
)
const hostState = useMemo(
() => webHostScreenHostState({ name: 'Orca Desktop', publicKeyB64: '' }),
[]
() =>
webHostScreenHostState({
name: shell.hostDisplayName ?? 'Orca Desktop',
publicKeyB64: ''
}),
[shell.hostDisplayName]
)
const shellOperations = useMemo(
() =>
@@ -6,19 +6,35 @@ import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
type MobileWebRouteErrorBoundaryProps = {
children: ReactNode
resetKey: string
}
type MobileWebRouteErrorBoundaryState = {
failureCode: string | null
resetKey: string
}
export class MobileWebRouteErrorBoundary extends Component<
MobileWebRouteErrorBoundaryProps,
MobileWebRouteErrorBoundaryState
> {
state: MobileWebRouteErrorBoundaryState = { failureCode: null }
state: MobileWebRouteErrorBoundaryState = {
failureCode: null,
resetKey: this.props.resetKey
}
static getDerivedStateFromError(error: unknown): MobileWebRouteErrorBoundaryState {
static getDerivedStateFromProps(
props: MobileWebRouteErrorBoundaryProps,
state: MobileWebRouteErrorBoundaryState
): MobileWebRouteErrorBoundaryState | null {
return props.resetKey === state.resetKey
? null
: { failureCode: null, resetKey: props.resetKey }
}
static getDerivedStateFromError(
error: unknown
): Pick<MobileWebRouteErrorBoundaryState, 'failureCode'> {
return {
failureCode: mobileWebRouteFailureCode(error)
}
@@ -37,11 +53,23 @@ export class MobileWebRouteErrorBoundary extends Component<
}
return (
<View style={styles.container}>
<Text style={styles.title}>Workspace view stopped</Text>
<Text style={styles.message}>
<Text accessibilityRole="header" style={styles.title}>
Workspace view stopped
</Text>
<Text
accessibilityLabel={`Workspace view stopped. Reload the desktop-served interface to reconnect. Diagnostic: ${this.state.failureCode}`}
accessibilityLiveRegion="assertive"
accessibilityRole="alert"
style={styles.message}
>
Reload the desktop-served interface to reconnect. Diagnostic: {this.state.failureCode}
</Text>
<Pressable style={styles.button} onPress={() => window.location.reload()}>
<Pressable
accessibilityLabel="Reload interface"
accessibilityRole="button"
style={styles.button}
onPress={reloadMobileWebRoot}
>
<Text style={styles.buttonText}>Reload interface</Text>
</Pressable>
</View>
@@ -49,6 +77,11 @@ export class MobileWebRouteErrorBoundary extends Component<
}
}
function reloadMobileWebRoot(): void {
window.history.replaceState(window.history.state, '', '/')
window.location.reload()
}
const styles = StyleSheet.create({
container: {
flex: 1,
@@ -3,6 +3,7 @@ import { useRouter } from 'expo-router'
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration'
import { rememberMobileWebRouteQuery } from '../src/mobile-web/mobile-web-route-query-cache'
export function MobileWebRouteRestorer() {
const router = useRouter()
@@ -18,8 +19,47 @@ export function MobileWebRouteRestorer() {
return
}
restoredContextRef.current = restorationKey
router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute))
const target = mobileWebNavigationRouteTarget(shell.navigationRoute)
const query = navigationRouteQuery(shell.navigationRoute)
rememberNavigationRouteQuery(target, query)
router.replace(mobileWebRouterReplacementHref(target, query))
}, [router, shell.context, shell.navigationRoute, shell.routeRevision])
return null
}
function rememberNavigationRouteQuery(
target: string,
query: Readonly<Record<string, string | undefined>>
): void {
const pathname = new URL(target, 'https://orca-mobile-web.invalid').pathname
rememberMobileWebRouteQuery(pathname, query)
}
function navigationRouteQuery(
route: ReturnType<typeof useMobileWebNativeShell>['navigationRoute']
): Readonly<Record<string, string | undefined>> {
if (route.kind === 'session') {
return { name: route.workspaceName }
}
if (route.kind === 'tasks') {
return { taskSource: route.taskSource }
}
if (route.kind === 'newWorkspace') {
return { action: 'newWorktree' }
}
return {}
}
function mobileWebRouterReplacementHref(
target: string,
query: Readonly<Record<string, string | undefined>>
): string {
const url = new URL(target, 'https://orca-mobile-web.invalid')
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(key, value)
}
}
return `${url.pathname}${url.search}`
}
+2 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"export:host-web": "node scripts/export-host-mobile-web.mjs",
"export:host-web": "tsx scripts/export-host-mobile-web.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
@@ -20,6 +20,7 @@
"test:e2e:hosted-webview": "node scripts/run-hosted-webview-simulator-e2e.mjs",
"test:e2e:hosted-webview:security": "node scripts/run-hosted-webview-simulator-e2e.mjs --security-only",
"test:e2e:hosted-webview:android-routes": "node scripts/run-hosted-android-source-control-review-e2e.mjs",
"test:e2e:hosted-webview:android-back": "node scripts/run-hosted-android-hardware-back-e2e.mjs",
"test:e2e:hosted-webview:android-security": "node scripts/run-hosted-android-webview-security-e2e.mjs",
"test:e2e:hosted-webview:android-release": "node scripts/verify-hosted-android-release-webview.mjs",
"test:e2e:hosted-webview:android-crash-loop": "node scripts/run-hosted-android-webview-crash-loop.mjs",
@@ -0,0 +1,30 @@
import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
export function linkEmulatorMacKeychain(
homeDir,
{ platform = process.platform, realHome = os.homedir() } = {}
) {
if (platform !== 'darwin') {
return null
}
const source = path.join(realHome, 'Library', 'Keychains')
if (!existsSync(source)) {
return null
}
const target = path.join(homeDir, 'Library', 'Keychains')
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
if (existsSync(target)) {
if (lstatSync(target).isSymbolicLink()) {
const linkedSource = path.resolve(path.dirname(target), readlinkSync(target))
if (linkedSource === path.resolve(source)) {
return target
}
}
throw new Error(`Refusing to replace emulator Keychain path: ${target}`)
}
symlinkSync(source, target, 'dir')
return target
}
@@ -0,0 +1,20 @@
export function createEmulatorPairingChildEnvironment({
inheritedEnvironment,
environment,
userData,
homeDir
}) {
const childEnvironment = {
...inheritedEnvironment,
...environment,
ORCA_DEV_USER_DATA_PATH: userData,
ORCA_E2E_USER_DATA_DIR: userData,
ORCA_E2E_HOME_DIR: homeDir,
HOME: homeDir,
USERPROFILE: homeDir
}
// Keep the disposable home from importing the developer's real agent histories.
delete childEnvironment.CODEX_HOME
delete childEnvironment.ORCA_CODEX_HOME
return childEnvironment
}
@@ -36,6 +36,17 @@ export function pairingDaemonPidsFromUserData(
}
}
export function currentPairingDaemonPids(
userData,
knownPids,
readDirectory = (directory) => readdirSync(directory),
readText = (filePath) => readFileSync(filePath, 'utf8')
) {
return [
...new Set([...knownPids, ...pairingDaemonPidsFromUserData(userData, readDirectory, readText)])
]
}
export function signalPairingRuntime(runtimePid, sendSignal = process.kill) {
if (runtimePid) {
sendSignal(runtimePid, 'SIGTERM')
+59 -24
View File
@@ -1,37 +1,72 @@
import { spawn } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { spawnProcess } from '../../src/shared/child-process/run-process.ts'
const mobileRoot = path.resolve(import.meta.dirname, '..')
const outputArgument = process.argv.slice(2).find((value) => value !== '--')
const outputDirectory = path.resolve(mobileRoot, outputArgument ?? '../out/mobile-web-rnw-proof')
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const child = spawn(
packageManager,
['exec', 'expo', 'export', '--platform', 'web', '--output-dir', outputDirectory],
{
cwd: mobileRoot,
export function createHostMobileWebExportProcessSpec({
environment = process.env,
mobileDirectory = mobileRoot,
outputDirectory,
platform = process.platform
}) {
return {
program: platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
args: ['exec', 'expo', 'export', '--platform', 'web', '--output-dir', outputDirectory],
cwd: mobileDirectory,
env: {
...process.env,
...environment,
NODE_ENV: 'production',
ORCA_EXPO_ROUTER_ROOT: 'host-web-app'
},
shell: process.platform === 'win32',
stdio: 'inherit'
}
}
)
}
child.on('error', (error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
export async function runHostMobileWebExport({
environment = process.env,
mobileDirectory = mobileRoot,
outputDirectory,
platform = process.platform,
spawn = spawnProcess,
stderr = process.stderr,
stdout = process.stdout
}) {
const child = spawn(
createHostMobileWebExportProcessSpec({
environment,
mobileDirectory,
outputDirectory,
platform
})
)
child.stdout?.pipe(stdout)
child.stderr?.pipe(stderr)
for (const stream of [child.stdin, child.stdout, child.stderr]) {
stream?.on('error', () => {})
}
child.stdin?.end()
child.on('exit', (code, signal) => {
const { code, signal } = await new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (exitCode, exitSignal) => {
resolve({ code: exitCode, signal: exitSignal })
})
})
if (signal) {
console.error(`Expo web export terminated by ${signal}`)
process.exitCode = 1
return
stderr.write(`Expo web export terminated by ${signal}\n`)
return 1
}
process.exitCode = code ?? 1
})
return code ?? 1
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
const outputArgument = process.argv.slice(2).find((value) => value !== '--')
const outputDirectory = path.resolve(mobileRoot, outputArgument ?? '../out/mobile-web-rnw-proof')
try {
process.exitCode = await runHostMobileWebExport({ outputDirectory })
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
@@ -97,7 +97,7 @@ export async function verifyHostedAdversarialTasks({
try {
workspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: 'Host 1',
timeoutMs: Math.min(timeoutMs, 15_000)
})
} catch (error) {
@@ -21,35 +21,13 @@ export async function verifyHostedAndroidAgentHistoryJourney({
sessionDocument,
timeoutMs
}) {
if (!sessionDocument.href.includes('/session/')) {
throw new Error('Agent History journey requires an active Session route.')
}
const actionsDocument = await evidenceStep('open Session actions', () =>
retryAndroidRouteActivation({
activate: () => tapAndroidLabel(emulator, sessionDocument, 'More session actions'),
wait: () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Agent History',
expectedHrefIncludes: '/session/',
timeoutMs: Math.min(timeoutMs, 3_000)
})
})
)
await evidenceStep('open Agent History', () =>
retryAndroidRouteActivation({
activate: () =>
tapAndroidText(emulator, actionsDocument, 'Agent History', Math.min(timeoutMs, 5_000)),
wait: () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Agent Session History',
expectedHrefIncludes: '/agent-history/',
timeoutMs: Math.min(timeoutMs, 3_000)
})
})
)
let historyDocument = await waitForVisibleHostedWebView({
let historyDocument = await openHostedAndroidAgentHistory({
discoveryUrl,
emulator,
sessionDocument,
timeoutMs
})
historyDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: EMULATOR_AGENT_HISTORY_TITLE,
expectedHrefIncludes: '/agent-history/',
@@ -142,6 +120,42 @@ export async function verifyHostedAndroidAgentHistoryJourney({
}
}
export async function openHostedAndroidAgentHistory({
discoveryUrl,
emulator,
sessionDocument,
timeoutMs
}) {
if (!sessionDocument.href.includes('/session/')) {
throw new Error('Agent History journey requires an active Session route.')
}
const actionsDocument = await evidenceStep('open Session actions', () =>
retryAndroidRouteActivation({
activate: () => tapAndroidLabel(emulator, sessionDocument, 'More session actions'),
wait: () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Agent History',
expectedHrefIncludes: '/session/',
timeoutMs: Math.min(timeoutMs, 3_000)
})
})
)
return evidenceStep('open Agent History', () =>
retryAndroidRouteActivation({
activate: () =>
tapAndroidText(emulator, actionsDocument, 'Agent History', Math.min(timeoutMs, 5_000)),
wait: () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Agent Session History',
expectedHrefIncludes: '/agent-history/',
timeoutMs: Math.min(timeoutMs, 3_000)
})
})
)
}
async function tapAndroidLabel(emulator, document, label) {
const point = await readStableHostedAndroidWebViewPoint(() =>
readHostedWebViewControlPoint(document, label)
@@ -81,10 +81,10 @@ export async function startHostedAndroidMetro({ mobileDir, pairingUrl, port }) {
}
}
export async function installAndResetHostedAndroidApp(adb, apk) {
await runAndroidAdb(adb, ['install', '-r', '-t', apk], 120_000)
await runAndroidAdb(adb, ['shell', 'pm', 'clear', packageName])
await runAndroidAdb(adb, [
export async function installAndResetHostedAndroidApp(adb, apk, runAdb = runAndroidAdb) {
await runAdb(adb, ['install', '-r', '-d', '-t', apk], 120_000)
await runAdb(adb, ['shell', 'pm', 'clear', packageName])
await runAdb(adb, [
'shell',
'pm',
'grant',
@@ -0,0 +1,119 @@
import { waitForHostedAndroidAccessibilityControlMatch } from './hosted-android-emulator-accessibility.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import {
activateHostedWebViewControl,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
export async function sendHostedAndroidHardwareBack(adb, runAdb = runAndroidAdb) {
await runAdb(adb, ['shell', 'input', 'keyevent', 'KEYCODE_BACK'])
}
export async function verifyHostedAndroidHardwareBackJourney(
{ adb, discoveryUrl, emulator, sessionDocument, timeoutMs, workspaceMarker },
dependencies = {}
) {
const openAgentHistory = dependencies.openAgentHistory ?? openHostedAndroidHardwareBackNestedRoute
const pressBack = dependencies.pressBack ?? sendHostedAndroidHardwareBack
const waitForDocument = dependencies.waitForDocument ?? waitForVisibleHostedWebView
const waitForWorkspaceRoot =
dependencies.waitForWorkspaceRoot ?? waitForHostedAndroidWorkspaceRoot
const waitForNativeControl =
dependencies.waitForNativeControl ?? waitForHostedAndroidAccessibilityControlMatch
const historyDocument = await openAgentHistory({
discoveryUrl,
emulator,
sessionDocument,
timeoutMs
})
await pressBack(adb)
const returnedSession = await waitForDocument({
discoveryUrl,
expectedText: '1 tab',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs
})
await pressBack(adb)
const workspaceRoot = await waitForWorkspaceRoot({
discoveryUrl,
timeoutMs,
waitForDocument,
workspaceMarker
})
if (workspaceRoot.href.includes('/session/') || workspaceRoot.href.includes('/agent-history/')) {
throw new Error(`Android hardware Back did not reach the workspace root: ${workspaceRoot.href}`)
}
await pressBack(adb)
const nativeShell = await waitForNativeControl(
emulator,
[
'Open settings',
'Open sessions in Chat UI',
'Open sessions in the terminal',
'Show paired hosts'
],
timeoutMs
)
return {
nestedRoute: historyDocument.href,
returnedSessionRoute: returnedSession.href,
workspaceRootRoute: workspaceRoot.href,
nativeShellControl: nativeShell.label,
hardwareBackPresses: 3
}
}
export async function waitForHostedAndroidWorkspaceRoot({
discoveryUrl,
settle = delay,
timeoutMs,
waitForDocument = waitForVisibleHostedWebView,
workspaceMarker
}) {
const deadline = Date.now() + timeoutMs
let lastHref = ''
while (Date.now() < deadline) {
const document = await waitForDocument({
discoveryUrl,
expectedText: workspaceMarker,
timeoutMs: Math.min(2_000, Math.max(1, deadline - Date.now()))
})
lastHref = document.href
if (!lastHref.includes('/session/') && !lastHref.includes('/agent-history/')) {
return document
}
await settle(100)
}
throw new Error(`Android hardware Back did not reach the workspace root: ${lastHref}`)
}
export async function openHostedAndroidHardwareBackNestedRoute(
{ discoveryUrl, sessionDocument, timeoutMs },
dependencies = {}
) {
const activate = dependencies.activate ?? activateHostedWebViewControl
const waitForDocument = dependencies.waitForDocument ?? waitForVisibleHostedWebView
const settle = dependencies.settle ?? delay
await activate(sessionDocument, { kind: 'label', value: 'More session actions' })
const actionsDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Agent History',
expectedHrefIncludes: '/session/',
timeoutMs
})
await settle(500)
await activate(actionsDocument, { kind: 'text', value: 'Agent History' })
return waitForDocument({
discoveryUrl,
expectedText: 'Agent Session History',
expectedHrefIncludes: '/agent-history/',
timeoutMs
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+42
View File
@@ -0,0 +1,42 @@
import {
tapHostedAndroidAccessibilityControl,
waitForHostedAndroidAccessibilityControlMatch
} from './hosted-android-emulator-accessibility.mjs'
import { openHostedAndroidUrl } from './hosted-android-emulator-session.mjs'
export async function pairHostedAndroidApp({ adb, pairingUrl, timeoutMs }) {
const emulator = { adb }
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await openHostedAndroidUrl(adb, pairingUrl)
try {
const control = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
['Close', 'Continue', 'Pair'],
Math.min(5_000, deadline - Date.now())
)
if (control.label === 'Pair') {
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
break
}
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
} catch {
// The development bundle can mount after the first deep link.
}
}
const destination = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
[
'Open sessions in Chat UI',
'Open sessions in the terminal',
'Enable agent notifications',
'Skip notifications for now',
'Show paired hosts',
'Back to home'
],
Math.max(1_000, deadline - Date.now())
)
if (destination.label === 'Back to home') {
throw new Error('Android pairing failed before reaching the onboarding flow')
}
}
@@ -45,6 +45,7 @@ export async function captureHostedAccountsParity({
deviceUdid,
discoveryUrl,
emulator,
expectedWorkspace,
nativeBaseline,
runtimeDirectory,
timeoutMs,
@@ -67,7 +68,7 @@ export async function captureHostedAccountsParity({
await tapHostedIosPoint(emulator, accountsBackPoint(accounts.screenTitlePoint))
const returnedWorkspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
return {
@@ -37,7 +37,7 @@ export async function verifyHostedAgentHistoryJourney({
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
@@ -94,6 +94,12 @@ export async function verifyHostedAgentHistoryJourney({
if (!state.placeholders.includes('Search sessions, repo:, path:')) {
throw new Error('Agent History is missing its existing search input.')
}
await evidenceStep('filter Agent History to parity fixture', () =>
setHostedWebViewInput(activeHistoryDocument, {
placeholder: 'Search sessions, repo:, path:',
value: EMULATOR_AGENT_HISTORY_TITLE
})
)
activeHistoryDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: EMULATOR_AGENT_HISTORY_TITLE,
@@ -151,7 +157,17 @@ export async function verifyHostedAgentHistoryJourney({
const connectionEntries = await waitForHostedWebViewConnectionSequence(
activeHistoryDocument,
['recovering', 'connected'],
timeoutMs
timeoutMs,
{
reacquireDocument: (remainingMs) =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: EMULATOR_AGENT_HISTORY_TITLE,
expectedHrefIncludes: '/agent-history/',
requireInteractiveControls: false,
timeoutMs: remainingMs
})
}
)
const recoveringEntry = connectionEntries.find((entry) => entry.state === 'recovering')
if (!recoveringEntry?.retainedExpectedText || !recoveringEntry.retainedExpectedRoute) {
@@ -7,6 +7,7 @@ import {
rotateHostedIosEmulator,
tapHostedIosAccessibilityControl,
tapHostedIosAccessibilityControlByLabelPrefix,
typeHostedIosText,
waitForHostedIosAccessibilityControl,
waitForHostedIosAccessibilityControlByLabelPrefix
} from './hosted-ios-emulator-accessibility.mjs'
@@ -14,6 +15,8 @@ import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.
import { readHostedWebViewTextPoint } from './hosted-webview-cdp-session.mjs'
const execFileAsync = promisify(execFile)
const AGENT_HISTORY_SEARCH_PLACEHOLDER = 'Search sessions, repo:, path:'
const AGENT_HISTORY_SEARCH_QUERY = 'hybrid agent history fixture'
export async function captureNativeAgentHistoryBaseline({
deviceUdid,
@@ -47,6 +50,15 @@ export async function openNativeAgentHistoryBaseline({ emulator, expectedWorkspa
await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
await tapHostedIosAccessibilityControl(emulator, 'More session actions', timeoutMs)
await tapHostedIosAccessibilityControlByLabelPrefix(emulator, 'Agent History', timeoutMs)
await tapHostedIosAccessibilityControl(emulator, AGENT_HISTORY_SEARCH_PLACEHOLDER, timeoutMs)
await delay(500)
await typeHostedIosText(emulator, AGENT_HISTORY_SEARCH_QUERY)
await waitForHostedIosAccessibilityControlByLabelPrefix(
emulator,
EMULATOR_AGENT_HISTORY_TITLE,
timeoutMs
)
await tapHostedIosAccessibilityControl(emulator, 'Agent Session History', timeoutMs)
}
export async function captureHostedAgentHistoryParity({
+41 -10
View File
@@ -8,7 +8,8 @@ import {
tapHostedIosPoint,
waitForHostedIosAccessibilityControl,
waitForHostedIosAccessibilityControlByLabelPrefix,
waitForHostedIosAccessibilityControlMatch
waitForHostedIosAccessibilityControlMatch,
waitForHostedIosAccessibilityControlMatching
} from './hosted-ios-emulator-accessibility.mjs'
import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs'
import {
@@ -42,11 +43,7 @@ export async function captureNativeCoreRouteBaselines({
const filterPoint = await waitForHostedIosAccessibilityControl(emulator, 'Filter', timeoutMs)
// The existing non-embedded Tasks icon has no native accessibility label.
await tapHostedIosPoint(emulator, { x: TASKS_TOOLBAR_X, y: filterPoint.y })
const taskStableState = await waitForHostedIosAccessibilityControlMatch(
emulator,
TASK_STABLE_TEXTS,
timeoutMs
)
const taskStableText = await waitForNativeTasksStableText(emulator, timeoutMs)
const tasks = await captureNativeRoute({
deviceUdid,
emulator,
@@ -55,7 +52,7 @@ export async function captureNativeCoreRouteBaselines({
title: 'Tasks',
timeoutMs
})
tasks.stableText = taskStableState.label
tasks.stableText = taskStableText
await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint))
await waitForHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
await tapHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
@@ -73,6 +70,40 @@ export async function captureNativeCoreRouteBaselines({
return { session, tasks }
}
async function waitForNativeTasksStableText(emulator, timeoutMs) {
const emptyStateTimeoutMs = Math.min(timeoutMs, 5_000)
try {
const state = await waitForHostedIosAccessibilityControlMatch(
emulator,
TASK_STABLE_TEXTS,
emptyStateTimeoutMs
)
return state.label
} catch {
const task = await waitForHostedIosAccessibilityControlMatching(
emulator,
(node) => nativeTaskTitleFromAccessibilityLabel(node.label) !== null,
Math.max(1, timeoutMs - emptyStateTimeoutMs)
)
const title = nativeTaskTitleFromAccessibilityLabel(task.label)
if (!title) {
throw new Error('Populated native Tasks row did not expose a stable title')
}
return title
}
}
function nativeTaskTitleFromAccessibilityLabel(label) {
if (typeof label !== 'string') {
return null
}
return (
label.match(
/^(.*), \d+[mhd], (?:Pull request|Issue|Merge request|Linear ticket|[^,]+ todo) · /
)?.[1] ?? null
)
}
export async function captureHostedCoreRouteParity({
deviceUdid,
discoveryUrl,
@@ -101,7 +132,7 @@ export async function captureHostedCoreRouteParity({
await tapHostedIosPoint(emulator, tasksBackPoint(tasks.screenTitlePoint))
let activeWorkspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
await activateHostedWorkspaceRow(
@@ -112,7 +143,7 @@ export async function captureHostedCoreRouteParity({
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
@@ -133,7 +164,7 @@ export async function captureHostedCoreRouteParity({
await tapHostedSessionBack(emulator, sessionDocument, timeoutMs)
activeWorkspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
return {
@@ -36,6 +36,21 @@ export async function tapHostedIosPoint(args, point, runCommand = runHostedIosEm
return point
}
export async function typeHostedIosText(args, value, runCommand = runHostedIosEmulatorCommand) {
for (const character of value) {
const command = ['type', character]
try {
await runCommand(args, command)
} catch (error) {
if (!isMissingActiveEmulatorError(error)) {
throw error
}
await restartHostedIosEmulatorController(args, runCommand)
await runCommand(args, command)
}
}
}
export async function tapHostedIosAccessibilityControlAtOccurrence(
args,
label,
@@ -219,6 +234,50 @@ export async function waitForHostedIosAccessibilityControlMatch(
)
}
export async function waitForHostedIosAccessibilityControlMatching(
args,
matches,
timeoutMs,
runCommand = runHostedIosEmulatorCommand
) {
return waitForHostedIosAccessibilityControlOccurrence(
args,
'matching accessibility control',
0,
timeoutMs,
runCommand,
matches,
(frame) => ({
x: frame.x + frame.width / 2,
y: frame.y + frame.height / 2
}),
(node, point) => ({ ...point, label: node.label ?? '', value: node.value ?? '' })
)
}
export async function waitForHostedIosAccessibilityLabel(
args,
label,
timeoutMs,
runCommand = runHostedIosEmulatorCommand
) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const nodes = await readAccessibilityNodesUntilDeadline(args, runCommand, deadline)
const node = nodes.find(
(candidate) =>
(candidate.label === label || candidate.value === label) &&
candidate.enabled !== false &&
isVisibleFrame(candidate.frame)
)
if (node) {
return { frame: node.frame, label: node.label ?? '', value: node.value ?? '' }
}
await delay(250)
}
throw new Error(`${label} was not accessible in the visible viewport`)
}
export async function rotateHostedIosEmulator(
args,
orientation,
@@ -237,7 +296,8 @@ async function waitForHostedIosAccessibilityControlOccurrence(
pointForFrame = (frame) => ({
x: frame.x + frame.width / 2,
y: frame.y + frame.height / 2
})
}),
resultForControl = (_node, point) => point
) {
const deadline = Date.now() + timeoutMs
let lastLabels = []
@@ -251,7 +311,7 @@ async function waitForHostedIosAccessibilityControlOccurrence(
if (control) {
const point = pointForFrame(control.frame)
if (isNormalizedPoint(point)) {
return point
return resultForControl(control, point)
}
}
await delay(250)
@@ -440,6 +500,16 @@ function isFiniteFrame(frame) {
)
}
function isVisibleFrame(frame) {
return (
isFiniteFrame(frame) &&
frame.x + frame.width > 0 &&
frame.y + frame.height > 0 &&
frame.x < 1 &&
frame.y < 1
)
}
function isNormalizedPoint(point) {
return point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1
}
@@ -7,7 +7,8 @@ import {
tapHostedIosAccessibilityControlByLabelPrefix,
tapHostedIosPoint,
waitForHostedIosAccessibilityControl,
waitForHostedIosAccessibilityControlByLabelPrefix
waitForHostedIosAccessibilityControlByLabelPrefix,
waitForHostedIosAccessibilityLabel
} from './hosted-ios-emulator-accessibility.mjs'
import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs'
import {
@@ -47,7 +48,7 @@ export async function captureNativeFilesPreviewBaselines({
})
await tapHostedIosAccessibilityControl(emulator, FILES_STABLE_LABEL, timeoutMs)
await tapHostedIosAccessibilityControl(emulator, PREVIEW_FILE_LABEL, timeoutMs)
await waitForHostedIosAccessibilityControl(emulator, PREVIEW_STABLE_LABEL, timeoutMs)
await waitForHostedIosAccessibilityLabel(emulator, PREVIEW_STABLE_LABEL, timeoutMs)
const preview = await captureNativeRoute({
deviceUdid,
emulator,
@@ -78,7 +79,7 @@ export async function captureHostedFilesPreviewParity({
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
@@ -253,7 +254,7 @@ async function returnHostedToWorkspaces(options) {
resolve: (transitionTimeoutMs) =>
waitForVisibleHostedWebView({
discoveryUrl: options.discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: options.expectedWorkspace,
timeoutMs: transitionTimeoutMs
})
})
@@ -0,0 +1,130 @@
import {
activateHostedWebViewControl,
readHostedWebViewState,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { tapHostedIosAccessibilityControl } from './hosted-ios-emulator-accessibility.mjs'
import { longPressHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-long-press.mjs'
const CHANGED_FILE_PREFIX = 'Open changed file '
const REVIEW_CONTROLS = ['Back', 'Open review actions']
export async function verifyHostedHostOriginSourceControlJourney({
discoveryUrl,
emulator,
nativeBaseline,
timeoutMs,
workspaceName,
operations = {}
}) {
const activate = operations.activate ?? activateHostedWebViewControl
const longPress = operations.longPress ?? longPressHostedIosAccessibilityControlByLabelPrefix
const tapNative = operations.tapNative ?? tapHostedIosAccessibilityControl
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
const readState = operations.readState ?? readHostedWebViewState
await longPress(emulator, workspaceName, timeoutMs)
await tapNative(emulator, 'Source Control', timeoutMs)
const sourceControl = await waitForDocument({
discoveryUrl,
expectedHrefIncludes: '/source-control/',
expectedText: 'Source Control',
timeoutMs
})
const sourceState = await waitForChangedFileState(sourceControl, timeoutMs, readState)
const changedFileLabel = nativeBaseline.changedFileLabel
if (!sourceState.labels.includes(changedFileLabel)) {
throw new Error(`Host-origin Source Control is missing ${changedFileLabel}`)
}
await activate(sourceControl, { kind: 'label', value: changedFileLabel })
const review = await waitForDocument({
discoveryUrl,
expectedHrefIncludes: '/review/',
expectedText: 'reviewed',
timeoutMs
})
const reviewState = await waitForReviewControls(review, timeoutMs, readState)
await activate(review, { kind: 'label', value: 'Back' })
const returnedSourceControl = await waitForDocument({
discoveryUrl,
expectedHrefIncludes: '/source-control/',
expectedText: 'Source Control',
timeoutMs
})
await activate(returnedSourceControl, { kind: 'label', value: 'Back to session' })
const returnedWorkspace = await waitForWorkspaceRoot({
discoveryUrl,
expectedText: workspaceName,
timeoutMs,
waitForDocument
})
return {
changedFileLabel,
reviewRoute: reviewState.href,
sourceControlRoute: sourceState.href,
workspaceDocument: returnedWorkspace
}
}
async function waitForWorkspaceRoot({ discoveryUrl, expectedText, timeoutMs, waitForDocument }) {
const deadline = Date.now() + timeoutMs
let document
while (Date.now() < deadline) {
document = await waitForDocument({
discoveryUrl,
expectedText,
timeoutMs: Math.min(1_000, Math.max(1, deadline - Date.now()))
})
if (isHostedRoot(document.href)) {
return document
}
await delay(250)
}
throw new Error(
`Host-origin Source Control did not return to the workspace list: ${document?.href}`
)
}
function isHostedRoot(href) {
try {
return new URL(href).pathname === '/'
} catch {
return false
}
}
async function waitForReviewControls(document, timeoutMs, readState) {
const deadline = Date.now() + Math.min(timeoutMs, 10_000)
let state
while (Date.now() < deadline) {
state = await readState(document)
if (REVIEW_CONTROLS.every((label) => state.labels.includes(label))) {
return state
}
await delay(250)
}
const missing = REVIEW_CONTROLS.filter((label) => !state?.labels.includes(label))
throw new Error(
`Host-origin Review is missing ${missing.join(', ')}. Labels: ${state?.labels.join(', ') ?? ''}`
)
}
async function waitForChangedFileState(document, timeoutMs, readState) {
const deadline = Date.now() + timeoutMs
let state
while (Date.now() < deadline) {
state = await readState(document)
if (state.labels.some((label) => label.startsWith(CHANGED_FILE_PREFIX))) {
return state
}
await delay(250)
}
throw new Error('Host-origin Source Control has no changed file')
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -0,0 +1,157 @@
import { randomBytes } from 'node:crypto'
import {
tapHostedIosAccessibilityControl,
waitForHostedIosAccessibilityLabel,
waitForHostedIosAccessibilityLabelToDisappear
} from './hosted-ios-emulator-accessibility.mjs'
import {
activateHostedWebViewControl,
evaluateHostedDocumentWithRetry,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
const ALERT_PROBE_PROPERTY = '__orcaE2eNativeAlertProbe'
const ALERT_TITLE = 'Hosted native Alert probe'
export async function verifyHostedIosNativeAlertJourney(
{ discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument },
operations = {}
) {
const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
const activateWorkspace = operations.activateWorkspace ?? activateHostedWorkspaceRow
const activateControl = operations.activateControl ?? activateHostedWebViewControl
const waitForLabel = operations.waitForLabel ?? waitForHostedIosAccessibilityLabel
const tapControl = operations.tapControl ?? tapHostedIosAccessibilityControl
const waitForLabelToDisappear =
operations.waitForLabelToDisappear ?? waitForHostedIosAccessibilityLabelToDisappear
await installNativeAlertProbe(workspaceDocument, evaluate)
await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () =>
waitForWorkspaceDocument(discoveryUrl, expectedWorkspace, timeoutMs, waitForDocument)
)
const sessionDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs
})
const requestId = randomBytes(16).toString('base64url')
await postNativeAlertProbe(sessionDocument, requestId, evaluate)
const title = await waitForLabel(emulator, ALERT_TITLE, timeoutMs)
const button = await tapControl(emulator, 'Keep editing', timeoutMs)
await waitForLabelToDisappear(emulator, ALERT_TITLE, timeoutMs)
const response = await waitForNativeAlertResponse(sessionDocument, requestId, timeoutMs, evaluate)
if (response?.status !== 'success' || response.payload?.buttonIndex !== 0) {
throw new Error(`Native Alert returned an invalid response: ${JSON.stringify(response)}`)
}
await activateControl(sessionDocument, { kind: 'label', value: 'Back to worktrees' })
const returnedWorkspaceDocument = await waitForWorkspaceDocument(
discoveryUrl,
expectedWorkspace,
timeoutMs,
waitForDocument
)
return {
evidence: {
button,
buttonIndex: response.payload.buttonIndex,
presentation: 'native',
title: title.frame
},
workspaceDocument: returnedWorkspaceDocument
}
}
async function installNativeAlertProbe(document, evaluate) {
const expression = `(() => {
const key = ${JSON.stringify(ALERT_PROBE_PROPERTY)};
if (globalThis[key]) return JSON.stringify({ started: true });
const native = globalThis.OrcaNative;
if (!native || typeof native.postMessage !== 'function') {
return JSON.stringify({ started: false });
}
const state = globalThis[key] = { context: null, responses: Object.create(null) };
addEventListener('message', (event) => {
try {
const message = typeof event.data === 'string' ? JSON.parse(event.data) : null;
if (message?.type === 'response' && typeof message.requestId === 'string') {
state.responses[message.requestId] = message;
}
} catch {}
});
globalThis.OrcaNative = Object.freeze({
postMessage(value) {
try {
const message = JSON.parse(value);
if (message?.shellSessionId && message?.buildId && Number.isInteger(message.version)) {
state.context = {
version: message.version,
shellSessionId: message.shellSessionId,
buildId: message.buildId
};
}
} catch {}
native.postMessage(value);
}
});
return JSON.stringify({ started: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.started !== true) {
throw new Error('Native Alert probe could not observe the hosted bridge')
}
}
async function postNativeAlertProbe(document, requestId, evaluate) {
const expression = `(() => {
const state = globalThis[${JSON.stringify(ALERT_PROBE_PROPERTY)}];
if (!state?.context) return JSON.stringify({ posted: false });
globalThis.OrcaNative.postMessage(JSON.stringify({
...state.context,
type: 'request',
mode: 'once',
requestId: ${JSON.stringify(requestId)},
capability: 'native',
operation: 'alert',
payload: {
title: ${JSON.stringify(ALERT_TITLE)},
message: 'The hosted page requested this operating-system dialog.',
buttons: [
{ text: 'Keep editing', style: 'cancel' },
{ text: 'Discard', style: 'destructive' }
],
options: { cancelable: false }
}
}));
return JSON.stringify({ posted: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.posted !== true) {
throw new Error('Native Alert probe did not capture an active bridge context')
}
}
async function waitForNativeAlertResponse(document, requestId, timeoutMs, evaluate) {
const deadline = Date.now() + timeoutMs
const expression = `JSON.stringify(globalThis[${JSON.stringify(
ALERT_PROBE_PROPERTY
)}]?.responses?.[${JSON.stringify(requestId)}] ?? null)`
while (Date.now() < deadline) {
const result = JSON.parse(await evaluate(document, expression))
if (result) {
return result
}
await delay(100)
}
throw new Error('Native Alert response did not return to the hosted page')
}
function waitForWorkspaceDocument(discoveryUrl, expectedWorkspace, timeoutMs, waitForDocument) {
return waitForDocument({ discoveryUrl, expectedText: expectedWorkspace, timeoutMs })
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -20,7 +20,7 @@ export async function verifyHostedIosNativeSettingsJourney({
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
@@ -98,7 +98,7 @@ export async function verifyHostedIosPhotoPermissionRevocation(
waitForDocument
})
let activeSessionDocument = grantedSession.document
assertSameSessionRoute(sessionDocument, activeSessionDocument)
assertSessionRoute(activeSessionDocument)
const grantPrivateOriginRotated =
hostedPrivateOrigin(sessionDocument.href) !== hostedPrivateOrigin(activeSessionDocument.href)
const grantWorkspaceAuthorityRotated =
@@ -144,7 +144,7 @@ export async function verifyHostedIosPhotoPermissionRevocation(
waitForDocument
})
activeSessionDocument = interruptedSession.document
assertSameSessionRoute(sessionDocument, activeSessionDocument)
assertSessionRoute(activeSessionDocument)
const interruptionPrivateOriginRetained =
hostedPrivateOrigin(grantedSession.document.href) ===
hostedPrivateOrigin(activeSessionDocument.href)
@@ -171,7 +171,7 @@ export async function verifyHostedIosPhotoPermissionRevocation(
waitForDocument
})
activeSessionDocument = revokedSession.document
assertSameSessionRoute(sessionDocument, activeSessionDocument)
assertSessionRoute(activeSessionDocument)
const revocationPrivateOriginRotated =
hostedPrivateOrigin(grantedSession.document.href) !==
hostedPrivateOrigin(activeSessionDocument.href)
@@ -241,10 +241,10 @@ async function changeHostedIosPhotosPermission(action, deviceUdid, runCommand) {
])
}
function waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument) {
function waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument, expectedWorkspace) {
return waitForDocument({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedText: expectedWorkspace,
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs
@@ -267,7 +267,8 @@ async function restoreHostedIosSessionAfterLaunch({
document: await waitForSessionDocument(
discoveryUrl,
Math.min(timeoutMs, 5_000),
waitForDocument
waitForDocument,
expectedWorkspace
),
recovery: 'session-retained'
}
@@ -277,7 +278,7 @@ async function restoreHostedIosSessionAfterLaunch({
await openHybridRoute(emulator, timeoutMs)
const workspaceDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
await activateWorkspace(
@@ -288,12 +289,17 @@ async function restoreHostedIosSessionAfterLaunch({
() =>
waitForDocument({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
return {
document: await waitForSessionDocument(discoveryUrl, timeoutMs, waitForDocument),
document: await waitForSessionDocument(
discoveryUrl,
timeoutMs,
waitForDocument,
expectedWorkspace
),
recovery: 'hybrid-route-handoff'
}
}
@@ -320,24 +326,9 @@ function assertNoPrivilegedPageMarkers(state) {
}
}
function assertSameSessionRoute(expectedDocument, actualDocument) {
const expectedRoute = hostedSessionRouteIdentity(expectedDocument.href)
const actualRoute = hostedSessionRouteIdentity(actualDocument.href)
if (
actualRoute.routeKind !== expectedRoute.routeKind ||
actualRoute.name !== expectedRoute.name
) {
throw new Error(
`Photos permission change did not restore the exact session: ${actualDocument.href}`
)
}
}
function hostedSessionRouteIdentity(href) {
const url = new URL(href)
return {
name: url.searchParams.get('name'),
routeKind: url.pathname.split('/').at(-2)
function assertSessionRoute(document) {
if (new URL(document.href).pathname.split('/').at(-2) !== 'session') {
throw new Error(`Photos permission change did not restore the session route: ${document.href}`)
}
}
@@ -12,6 +12,12 @@ import {
sourceControlReviewParityEvidence
} from './hosted-ios-source-control-review-parity.mjs'
import { navigateHostedWebViewRoute } from './hosted-webview-route-navigation.mjs'
import {
readHostedWebViewBridgeErrors,
startHostedWebViewBridgeErrorObservation
} from './hosted-webview-bridge-error-observation.mjs'
const SOURCE_CONTROL_SETTLE_TIMEOUT_MS = 15_000
export async function verifyHostedSourceControlReviewJourney({
deviceUdid,
@@ -27,6 +33,7 @@ export async function verifyHostedSourceControlReviewJourney({
tapPoint = tapHostedJourneyPoint,
transformPoint = retainHostedJourneyPoint
}) {
await startHostedWebViewBridgeErrorObservation(sessionDocument)
const sourceControl = await journeyStep('wait for Source Control route', () =>
openSourceControlRoute({
discoveryUrl,
@@ -72,12 +79,6 @@ export async function verifyHostedSourceControlReviewJourney({
)
}
const changedFileLabel = sourceState.labels.find((label) =>
label.startsWith('Open changed file ')
)
if (!changedFileLabel) {
throw new Error('Source Control has no changed file available for Review.')
}
if (inspectChangedContent) {
await journeyStep('inspect Source Control content', () =>
inspectChangedContent({ phase: 'sourceControl', document: sourceControl })
@@ -85,9 +86,15 @@ export async function verifyHostedSourceControlReviewJourney({
}
if (nativeBaselines) {
sourceState = await journeyStep('wait for stable Source Control parity state', () =>
waitForSourceControlParityState(sourceControl, timeoutMs, sourceState)
waitForSourceControlParityState(
sourceControl,
timeoutMs,
sourceState,
nativeBaselines.sourceControl.pullRequestState
)
)
}
const changedFileLabel = selectChangedFileLabel(sourceState, nativeBaselines)
const hostedSourceControl = nativeBaselines
? await journeyStep('capture Source Control parity', () =>
captureHostedSourceControlReviewScreen({
@@ -278,20 +285,56 @@ async function waitForChangedFileState(document, timeoutMs) {
return state ?? readHostedWebViewState(document)
}
async function waitForSourceControlParityState(document, timeoutMs, initialState) {
const deadline = Date.now() + timeoutMs
async function waitForSourceControlParityState(
document,
timeoutMs,
initialState,
pullRequestState
) {
const deadline = Date.now() + Math.min(timeoutMs, SOURCE_CONTROL_SETTLE_TIMEOUT_MS)
let state = initialState
while (Date.now() < deadline) {
if (
state.bodyText.includes('Create pull request') &&
/\b\d+ on branch\b/.test(state.bodyText)
) {
if (sourceControlMatchesPullRequestState(state, pullRequestState)) {
return state
}
await delay(250)
state = await readHostedWebViewState(document)
}
throw new Error('Source Control did not settle its branch and pull-request state.')
const bridgeErrors = await readHostedWebViewBridgeErrors(document)
throw new Error(
`Source Control did not settle its branch and pull-request state. Bridge errors: ${JSON.stringify(bridgeErrors)}`
)
}
function sourceControlMatchesPullRequestState(state, pullRequestState) {
if (!/\b\d+ on branch\b/.test(state.bodyText) || !pullRequestState) {
return false
}
if (state.labels.includes(pullRequestState.label)) {
return true
}
if (pullRequestState.kind === 'create') {
return state.bodyText.includes('Create pull request')
}
if (pullRequestState.kind === 'ready') {
return state.bodyText.includes(`#${pullRequestState.number}`)
}
return state.bodyText.includes(pullRequestState.label.replace('Pull request unavailable: ', ''))
}
function selectChangedFileLabel(sourceState, nativeBaselines) {
const nativeLabel = nativeBaselines?.sourceControl.changedFileLabel
if (nativeLabel && sourceState.labels.includes(nativeLabel)) {
return nativeLabel
}
if (nativeLabel) {
throw new Error(`Source Control is missing the native baseline file: ${nativeLabel}`)
}
const label = sourceState.labels.find((candidate) => candidate.startsWith('Open changed file '))
if (!label) {
throw new Error('Source Control has no changed file available for Review.')
}
return label
}
function standaloneReviewRoute(sourceControlHref) {
@@ -4,11 +4,10 @@ import { promisify } from 'node:util'
import { dismissEmulatorDeveloperMenuIfPresent } from './emulator-developer-menu-dismissal.mjs'
import {
tapHostedIosAccessibilityControl,
tapHostedIosAccessibilityControlStartingWith,
waitForHostedIosAccessibilityControl,
waitForHostedIosAccessibilityControlByLabelPrefix,
waitForHostedIosAccessibilityControlEndingWith,
waitForHostedIosAccessibilityControlStartingWith
waitForHostedIosAccessibilityControlMatching
} from './hosted-ios-emulator-accessibility.mjs'
import { longPressHostedIosAccessibilityControlByLabelPrefix } from './hosted-ios-emulator-long-press.mjs'
import { assertHostedIosScreenshotParity } from './hosted-ios-screenshot-parity.mjs'
@@ -27,12 +26,19 @@ export async function captureNativeSourceControlReviewBaselines({
await dismissEmulatorDeveloperMenuIfPresent(emulator)
await longPressHostedIosAccessibilityControlByLabelPrefix(emulator, expectedWorkspace, timeoutMs)
await tapHostedIosAccessibilityControl(emulator, 'Source Control', timeoutMs)
await waitForHostedIosAccessibilityControlStartingWith(
const changedFileControl = await waitForHostedIosAccessibilityControlMatching(
emulator,
CHANGED_FILE_LABEL_PREFIX,
(node) =>
node.label?.startsWith(CHANGED_FILE_LABEL_PREFIX) ||
node.value?.startsWith(CHANGED_FILE_LABEL_PREFIX),
timeoutMs
)
const reviewControl = await waitForHostedIosAccessibilityControlMatching(
emulator,
(node) =>
nativePullRequestState(node.label) !== null || nativePullRequestState(node.value) !== null,
timeoutMs
)
await waitForHostedIosAccessibilityControl(emulator, 'Create pull request', timeoutMs)
await waitForHostedIosAccessibilityControlEndingWith(emulator, ' on branch', timeoutMs)
const sourceControl = await captureNativeRoute({
deviceUdid,
@@ -42,7 +48,12 @@ export async function captureNativeSourceControlReviewBaselines({
title: 'Source Control',
timeoutMs
})
await tapHostedIosAccessibilityControlStartingWith(emulator, CHANGED_FILE_LABEL_PREFIX, timeoutMs)
sourceControl.changedFileLabel = accessibilityControlText(changedFileControl, (value) =>
value.startsWith(CHANGED_FILE_LABEL_PREFIX)
)
sourceControl.pullRequestState =
nativePullRequestState(reviewControl.label) ?? nativePullRequestState(reviewControl.value)
await tapHostedIosAccessibilityControl(emulator, sourceControl.changedFileLabel, timeoutMs)
await waitForHostedIosAccessibilityControl(emulator, 'Open review actions', timeoutMs)
await waitForHostedIosAccessibilityControlEndingWith(emulator, ' reviewed', timeoutMs)
const review = await captureNativeRoute({
@@ -60,6 +71,29 @@ export async function captureNativeSourceControlReviewBaselines({
return { review, sourceControl }
}
function nativePullRequestState(label) {
if (label === 'Create pull request') {
return { kind: 'create', label }
}
const number = label?.match(/^Pull request #(\d+),/)?.[1]
if (number) {
return { kind: 'ready', label, number }
}
if (label?.startsWith('Pull request unavailable:')) {
return { kind: 'unavailable', label }
}
return null
}
function accessibilityControlText(control, matches) {
for (const value of [control.label, control.value]) {
if (typeof value === 'string' && matches(value)) {
return value
}
}
throw new Error('Accessibility control lost its matched text')
}
export async function captureHostedSourceControlReviewScreen({
deviceUdid,
document,
@@ -39,7 +39,7 @@ export async function verifyHostedIosTerminalClipboardPaste(
() =>
waitForDocument({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs
})
)
@@ -0,0 +1,64 @@
import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs'
const OBSERVATION_PROPERTY = '__orcaE2eBridgeErrorObservation'
export async function startHostedWebViewBridgeErrorObservation(document) {
const expression = `(() => {
const key = ${JSON.stringify(OBSERVATION_PROPERTY)};
const previous = globalThis[key];
if (previous) {
previous.errors.length = 0;
previous.requests = Object.create(null);
return JSON.stringify({ started: true });
}
const native = globalThis.OrcaNative;
if (!native || typeof native.postMessage !== 'function') {
return JSON.stringify({ started: false });
}
const state = globalThis[key] = {
errors: [],
requests: Object.create(null)
};
addEventListener('message', (event) => {
try {
const message = typeof event.data === 'string' ? JSON.parse(event.data) : null;
if (message?.type !== 'response' || message?.status !== 'error') return;
const request = state.requests[message.requestId];
state.errors.push({
capability: request?.capability ?? 'unknown',
operation: request?.operation ?? 'unknown',
code: message.error?.code ?? 'unknown',
retryable: message.error?.retryable === true
});
if (state.errors.length > 64) state.errors.shift();
} catch {}
});
globalThis.OrcaNative = Object.freeze({
postMessage(value) {
try {
const message = JSON.parse(value);
if (message?.type === 'request' && typeof message.requestId === 'string') {
state.requests[message.requestId] = {
capability: String(message.capability ?? '').slice(0, 64),
operation: String(message.operation ?? '').slice(0, 64)
};
}
} catch {}
native.postMessage(value);
}
});
return JSON.stringify({ started: true });
})()`
const result = JSON.parse(await evaluateHostedDocumentWithRetry(document, expression))
if (result?.started !== true) {
throw new Error('Hosted WebView bridge error observation could not start')
}
}
export async function readHostedWebViewBridgeErrors(document) {
const expression = `JSON.stringify(globalThis[${JSON.stringify(
OBSERVATION_PROPERTY
)}]?.errors ?? [])`
const errors = JSON.parse(await evaluateHostedDocumentWithRetry(document, expression))
return Array.isArray(errors) ? errors : []
}
+40 -5
View File
@@ -183,7 +183,18 @@ export async function readHostedWebViewState(document, WebSocketCtor = WebSocket
const expression = `JSON.stringify({
href: String(location.href).slice(0, 2048),
bodyText: String(document.body?.innerText ?? '').slice(0, ${HOSTED_DOCUMENT_TEXT_LIMIT}),
labels: Array.from(document.querySelectorAll('[aria-label]')).slice(0, 128)
labels: Array.from(document.querySelectorAll('[aria-label]')).filter((element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < innerHeight &&
rect.left < innerWidth;
}).slice(0, 128)
.map((element) => String(element.getAttribute('aria-label') ?? '').slice(0, 240)),
placeholders: Array.from(document.querySelectorAll('input[placeholder],textarea[placeholder]'))
.slice(0, 32)
@@ -381,16 +392,36 @@ export async function waitForHostedWebViewConnectionSequence(
document,
expectedStates,
timeoutMs,
WebSocketCtor = WebSocket
transport = WebSocket
) {
const WebSocketCtor =
typeof transport === 'function' ? transport : (transport.WebSocketCtor ?? WebSocket)
const reacquireDocument =
typeof transport === 'function' ? undefined : transport.reacquireDocument
const deadline = Date.now() + timeoutMs
let entries = []
let activeDocument = document
const observedEntryCounts = new Map()
const expression = `JSON.stringify(globalThis[${JSON.stringify(
HOSTED_CONNECTION_OBSERVATION_PROPERTY
)}]?.entries ?? [])`
while (Date.now() < deadline) {
const value = await evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor)
entries = JSON.parse(value)
let currentEntries
try {
const value = await evaluateHostedDocumentWithRetry(activeDocument, expression, WebSocketCtor)
currentEntries = JSON.parse(value)
} catch (error) {
if (!reacquireDocument) {
throw error
}
activeDocument = await reacquireDocument(Math.max(1, deadline - Date.now()))
continue
}
const documentKey = activeDocument.webSocketDebuggerUrl
const previousCount = observedEntryCounts.get(documentKey) ?? 0
const nextOffset = currentEntries.length < previousCount ? 0 : previousCount
entries.push(...currentEntries.slice(nextOffset))
observedEntryCounts.set(documentKey, currentEntries.length)
if (hasOrderedConnectionStates(entries, expectedStates)) {
return entries
}
@@ -413,7 +444,11 @@ function hasOrderedConnectionStates(entries, expectedStates) {
return expectedIndex === expectedStates.length
}
export async function evaluateHostedDocumentWithRetry(document, expression, WebSocketCtor) {
export async function evaluateHostedDocumentWithRetry(
document,
expression,
WebSocketCtor = WebSocket
) {
let lastError = null
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
@@ -0,0 +1,51 @@
export async function evidenceStep(label, run) {
try {
return await run()
} catch (error) {
throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, {
cause: error
})
}
}
export function printHostedWebViewE2eReport(evidence) {
console.log(
JSON.stringify(
{
ok: true,
device: evidence.deviceUdid,
targetId: evidence.workspaceDocument.targetId,
route: evidence.workspaceDocument.href,
nativeAppPath: evidence.nativeAppPath,
visibleText: evidence.expectedWorkspace,
interactiveControls: evidence.workspaceDocument.buttonCount,
networkIsolation: evidence.networkIsolation,
navigationIsolation: evidence.navigationIsolation,
executableIsolation: evidence.executableIsolation,
privacyIsolation: evidence.privacyIsolation,
nativeOnboarding: evidence.nativeOnboarding,
nativeAlert: evidence.nativeAlert.evidence,
documentUpload: evidence.terminalDeviceInput?.documentUpload?.evidence ?? null,
photoPermissionDenial:
evidence.terminalDeviceInput?.photoPermissionDenial?.evidence ?? null,
photoPermissionRevocation:
evidence.terminalDeviceInput?.photoPermissionRevocation?.evidence ?? null,
terminalClipboardImagePaste:
evidence.terminalDeviceInput?.terminalClipboardImagePaste?.evidence ?? null,
terminalClipboardPaste:
evidence.terminalDeviceInput?.terminalClipboardPaste.evidence ?? null,
workspaceParity: evidence.hostedWorkspace,
accountsParity: evidence.hostedAccounts?.evidence ?? null,
agentHistory: evidence.historyEvidence,
coreRouteParity: evidence.hostedCoreRoutes?.evidence ?? null,
filesPreviewParity: evidence.hostedFilesPreview?.evidence ?? null,
sourceControlReview: evidence.sourceControlReview,
adversarialContent: evidence.adversarialContent,
adversarialProviderContent: evidence.adversarialProviderContent,
adversarialTerminalLinks: evidence.adversarialTerminalLinks
},
null,
2
)
)
}
@@ -0,0 +1,62 @@
import { evaluateHostedDocumentWithRetry } from './hosted-webview-cdp-session.mjs'
import { WebSocket } from 'ws'
const CAPTURE_KEY = '__orcaE2eHostedRouteExceptions'
const MAX_ENTRIES = 24
const MAX_TEXT_LENGTH = 4_096
export async function installHostedWebViewRouteExceptionCapture(
document,
WebSocketCtor = WebSocket
) {
await evaluateHostedDocumentWithRetry(
document,
`(() => {
const key = ${JSON.stringify(CAPTURE_KEY)};
if (globalThis[key]?.installed === true) return 'installed';
const evidence = { entries: [], installed: true };
const append = (kind, values) => {
const text = values.map((value) => {
if (value instanceof Error) return value.stack || value.message || value.name;
if (typeof value === 'string') return value;
try { return JSON.stringify(value); } catch { return String(value); }
}).join(' ').slice(0, ${MAX_TEXT_LENGTH});
evidence.entries.push({ kind, text });
if (evidence.entries.length > ${MAX_ENTRIES}) evidence.entries.shift();
};
const originalConsoleError = console.error.bind(console);
console.error = (...values) => {
append('console-error', values);
originalConsoleError(...values);
};
addEventListener('error', (event) => append('window-error', [event.error ?? event.message]));
addEventListener('unhandledrejection', (event) => append('unhandled-rejection', [event.reason]));
globalThis[key] = evidence;
return 'installed';
})()`,
WebSocketCtor
)
}
export async function readHostedWebViewRouteExceptionEvidence(document, WebSocketCtor = WebSocket) {
const value = await evaluateHostedDocumentWithRetry(
document,
`JSON.stringify((globalThis[${JSON.stringify(CAPTURE_KEY)}]?.entries ?? []).slice(-${MAX_ENTRIES}))`,
WebSocketCtor
)
const entries = JSON.parse(value)
if (!Array.isArray(entries)) {
return []
}
return entries.filter(isExceptionEvidenceEntry)
}
function isExceptionEvidenceEntry(value) {
return (
value !== null &&
typeof value === 'object' &&
['console-error', 'window-error', 'unhandled-rejection'].includes(value.kind) &&
typeof value.text === 'string' &&
value.text.length <= MAX_TEXT_LENGTH
)
}
@@ -15,7 +15,8 @@ export async function navigateHostedWebViewRoute(document, route, WebSocketCtor
})()`,
WebSocketCtor
)
if (value !== route) {
const expectedPath = new URL(route, 'https://orca-mobile-web.invalid').pathname
if (value !== expectedPath) {
throw new Error('Hosted WebView route navigation was not retained')
}
}
@@ -0,0 +1,29 @@
import {
verifyHostedWebViewNavigationIsolation,
verifyHostedWebViewNetworkIsolation
} from './hosted-webview-cdp-session.mjs'
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
export async function captureHostedWebViewSecurityEvidence({
document,
evidenceStep,
probeId,
workspacePrivacyIsolation
}) {
const networkIsolation = await evidenceStep('network isolation probe', () =>
verifyHostedWebViewNetworkIsolation({ document, probeId })
)
const navigationIsolation = await evidenceStep('navigation isolation probe', () =>
verifyHostedWebViewNavigationIsolation({ document, probeId })
)
const executableIsolation = await evidenceStep('executable isolation probe', () =>
verifyHostedWebViewExecutableIsolation({ document, probeId })
)
const privacyIsolation =
workspacePrivacyIsolation ??
(await evidenceStep('privacy isolation probe', () =>
verifyHostedWebViewPrivacyIsolation({ document })
))
return { executableIsolation, navigationIsolation, networkIsolation, privacyIsolation }
}
@@ -229,7 +229,7 @@ function parseOptions(args) {
adb: 'adb',
desktopPid: 0,
devClientUrl: 'exp+orca-mobile://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A8081',
expectedText: 'Orca Desktop',
expectedText: 'Host 1',
timeoutMs: 30_000
}
for (let index = 0; index < args.length; index += 1) {
@@ -0,0 +1,238 @@
#!/usr/bin/env node
import { execFile } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
import {
assertHostedAndroidBridgeLogClean,
buildHostedAndroidDebugApp,
forwardHostedAndroidInspector,
installAndResetHostedAndroidApp,
launchHostedAndroidDevClient,
openHostedAndroidUrl,
resolveHostedAndroidAdb,
startHostedAndroidMetro,
stopHostedAndroidApp,
waitForHostedAndroidReactReady
} from './hosted-android-emulator-session.mjs'
import { verifyHostedAndroidHardwareBackJourney } from './hosted-android-hardware-back-journey.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { pairHostedAndroidApp } from './hosted-android-pairing.mjs'
import {
activateHostedAndroidWorkspaceControl,
prepareHostedAndroidWorkspaceInput
} from './hosted-android-workspace-activation.mjs'
import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs'
import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
import { startHostedWebViewSecurityProbe } from './hosted-ios-webview-security-probe.mjs'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
const execFileAsync = promisify(execFile)
const worktree = path.resolve(import.meta.dirname, '../..')
const mobileDir = path.join(worktree, 'mobile')
const androidDir = path.join(mobileDir, 'android')
const defaultApk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk')
const options = parseOptions(process.argv.slice(2))
const adb = resolveHostedAndroidAdb(options.adb)
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
worktree,
override: process.env.ORCA_E2E_MOBILE_WEBVIEW_RUN_DIRECTORY
})
const orcaCli = resolveEmulatorOrcaCli({
explicitCommand: process.env.ORCA_CLI,
managedCommand: process.env.ORCA_CLI_COMMAND,
devRepoRoot: process.env.ORCA_DEV_REPO_ROOT,
worktree,
cwd: worktree
}).command
async function main() {
let runtime
let metro
let probe
let inspector
const reversePorts = new Set()
try {
await stage('Android emulator', () => runAndroidAdb(adb, ['get-state']))
await stage('Android log reset', () => runAndroidAdb(adb, ['logcat', '-c']))
if (!options.skipNativeBuild) {
await stage('Android debug app build', () => buildHostedAndroidDebugApp({ adb, androidDir }))
}
probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe)
runtime = await stage('temporary paired desktop runtime', () =>
startHeadlessPairingRuntime({
enabled: true,
orcaCli,
cwd: worktree,
runDirectory: path.join(runtimeDirectory, 'paired-host'),
lanIpCandidates: () => ['127.0.0.1'],
logStep: () => {},
logSuccess: () => {}
})
)
await stage('test workspace registration', () =>
registerWorktreeForPairingRuntime(runtime, worktree, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
})
)
metro = await stage('Metro', () =>
startHostedAndroidMetro({ mobileDir, pairingUrl: runtime.pairingUrl })
)
for (const port of [runtime.port, metro.port, probe.port]) {
await runAndroidAdb(adb, ['reverse', `tcp:${port}`, `tcp:${port}`])
reversePorts.add(port)
}
await stage('exact Android app install', () =>
installAndResetHostedAndroidApp(adb, options.apk)
)
await stage('development client launch', () =>
launchHostedAndroidDevClient(adb, metro.port, probe)
)
await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs))
await stage('native pairing', () =>
pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: options.timeoutMs })
)
await stage('native hybrid route handoff', () =>
openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL)
)
inspector = await stage('Android WebView inspector', () =>
forwardHostedAndroidInspector(adb, options.timeoutMs)
)
const discoveryUrl = `http://127.0.0.1:${inspector.port}`
const workspaceName = path.basename(worktree)
let workspaceDocument = await stage('hosted workspace data', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceName.toLocaleUpperCase(),
timeoutMs: options.timeoutMs
})
)
const sessionDocument = await stage('hosted Session route', async () => {
await prepareHostedAndroidWorkspaceInput({ adb })
await activateHostedWorkspaceRow(
workspaceDocument,
workspaceName,
(document, target) => activateHostedAndroidWorkspaceControl({ adb }, document, target),
options.timeoutMs,
async () => {
workspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceName.toLocaleUpperCase(),
timeoutMs: options.timeoutMs
})
return workspaceDocument
}
)
return waitForVisibleHostedWebView({
discoveryUrl,
expectedText: '1 tab',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs: options.timeoutMs
})
})
const evidence = await stage('real Android hardware Back journey', () =>
verifyHostedAndroidHardwareBackJourney({
adb,
discoveryUrl,
emulator: { adb },
sessionDocument,
timeoutMs: options.timeoutMs,
workspaceMarker: workspaceName.toLocaleUpperCase()
})
)
await stage('Android bridge log audit', () => assertHostedAndroidBridgeLogClean(adb))
console.log(
JSON.stringify(
{
ok: true,
device: await runAndroidAdb(adb, ['shell', 'getprop', 'ro.product.model']),
workspace: workspaceName,
...evidence
},
null,
2
)
)
} finally {
await stopHostedAndroidApp(adb)
if (inspector) {
await runAndroidAdb(adb, ['forward', '--remove', `tcp:${inspector.port}`]).catch(() => {})
}
for (const port of reversePorts) {
await runAndroidAdb(adb, ['reverse', '--remove', `tcp:${port}`]).catch(() => {})
}
await metro?.stop()
await runtime?.stop({ shutdownDaemon: true })
await probe?.stop()
}
}
async function runOrca(args, runOptions) {
const result = await execFileAsync(orcaCli, args, {
cwd: runOptions.cwd,
env: runOptions.env,
encoding: 'utf8',
timeout: runOptions.timeout
})
return { stdout: result.stdout.trim(), stderr: result.stderr.trim() }
}
async function stage(label, run) {
process.stderr.write(`[android-back-e2e] ${label}...\n`)
try {
const result = await run()
process.stderr.write(`[android-back-e2e] ${label}: ok\n`)
return result
} catch (error) {
throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, {
cause: error
})
}
}
function parseOptions(args) {
const result = { adb: null, apk: defaultApk, skipNativeBuild: false, timeoutMs: 90_000 }
for (let index = 0; index < args.length; index += 1) {
const option = args[index]
if (option === '--') {
continue
}
if (option === '--adb') {
result.adb = requireValue(args, ++index, option)
} else if (option === '--apk') {
result.apk = path.resolve(requireValue(args, ++index, option))
} else if (option === '--skip-native-build') {
result.skipNativeBuild = true
} else if (option === '--timeout-ms') {
result.timeoutMs = Number.parseInt(requireValue(args, ++index, option), 10)
} else {
throw new Error(`Unknown option: ${option}`)
}
}
if (!Number.isInteger(result.timeoutMs) || result.timeoutMs < 1_000) {
throw new Error('--timeout-ms must be an integer of at least 1000')
}
return result
}
function requireValue(args, index, option) {
if (!args[index]) {
throw new Error(`${option} requires a value`)
}
return args[index]
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 1
})
@@ -23,8 +23,7 @@ import {
} from './hosted-android-privacy-audit.mjs'
import {
tapHostedAndroidAccessibilityControl,
tapHostedAndroidPoint,
waitForHostedAndroidAccessibilityControlMatch
tapHostedAndroidPoint
} from './hosted-android-emulator-accessibility.mjs'
import {
activateHostedAndroidWorkspaceControl,
@@ -43,6 +42,7 @@ import {
waitForHostedAndroidReactReady
} from './hosted-android-emulator-session.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { pairHostedAndroidApp } from './hosted-android-pairing.mjs'
import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs'
import {
activateHostedWebViewControl,
@@ -172,7 +172,7 @@ async function main() {
await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs))
const emulator = { adb }
await stage('native pairing', () =>
pairAndroidApp(emulator, runtime.pairingUrl, options.timeoutMs)
pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: options.timeoutMs })
)
await stage('native hybrid route handoff', async () => {
await openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL)
@@ -184,7 +184,7 @@ async function main() {
await stage('hosted workspace route', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: 'Host 1',
timeoutMs: options.timeoutMs
})
)
@@ -247,7 +247,7 @@ async function main() {
lastError = error
activeDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: 'Host 1',
timeoutMs: options.timeoutMs
})
}
@@ -456,42 +456,6 @@ async function inspectAndroidAdversarialContent({ document, fixture, observation
}
}
async function pairAndroidApp(emulator, pairingUrl, timeoutMs) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
await openHostedAndroidUrl(adb, pairingUrl)
try {
const control = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
['Close', 'Continue', 'Pair'],
Math.min(5_000, deadline - Date.now())
)
if (control.label === 'Pair') {
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
break
}
await tapHostedAndroidAccessibilityControl(emulator, control.label, 2_000)
} catch {
// The first deep link can arrive before the development bundle mounts.
}
}
const destination = await waitForHostedAndroidAccessibilityControlMatch(
emulator,
[
'Open sessions in Chat UI',
'Open sessions in the terminal',
'Enable agent notifications',
'Skip notifications for now',
'Show paired hosts',
'Back to home'
],
Math.max(1_000, deadline - Date.now())
)
if (destination.label === 'Back to home') {
throw new Error('Android pairing failed before reaching the onboarding flow')
}
}
async function tapHostedAndroidJourneyControl(emulator, point, label, attempt = 0, document) {
if (label && attempt === 0) {
try {
@@ -23,13 +23,9 @@ import {
import { startHostedIosEmulatorController } from './hosted-ios-emulator-controller.mjs'
import { createHostedIosNativeBaselineStep } from './hosted-ios-native-baseline-step.mjs'
import {
activateHostedWebViewControl,
assertNoHostedMobileWebCdpTarget,
verifyHostedWebViewNavigationIsolation,
verifyHostedWebViewNetworkIsolation,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { verifyHostedWebViewExecutableIsolation } from './hosted-webview-executable-isolation.mjs'
import { verifyHostedWebViewPrivacyIsolation } from './hosted-webview-privacy-isolation.mjs'
import { captureNativeAgentHistoryBaseline } from './hosted-ios-agent-history-parity.mjs'
import {
@@ -54,24 +50,28 @@ import {
alignHostedIosSessionPoint,
verifyHostedIosAdversarialTerminalLinks
} from './hosted-ios-adversarial-terminal-links.mjs'
import { tapHostedIosPoint } from './hosted-ios-emulator-accessibility.mjs'
import {
tapHostedIosAccessibilityControlByLabelPrefix,
tapHostedIosPoint
} from './hosted-ios-emulator-accessibility.mjs'
import { openHostedIosHybridRoute } from './hosted-ios-hybrid-route-handoff.mjs'
import {
startHostedIosMobileLauncher,
waitForHostedIosMobileLauncher
} from './hosted-ios-mobile-launcher.mjs'
import { verifyHostedIosNativeSettingsJourney } from './hosted-ios-native-settings-journey.mjs'
import { verifyHostedIosNativeAlertJourney } from './hosted-ios-native-alert-journey.mjs'
import {
bootHostedIosSimulator,
resolveHostedIosSimulatorUdid
} from './hosted-ios-simulator-device.mjs'
import { verifyHostedSourceControlReviewJourney } from './hosted-ios-source-control-review-journey.mjs'
import { verifyHostedHostOriginSourceControlJourney } from './hosted-ios-host-origin-source-control-journey.mjs'
import { captureNativeSourceControlReviewBaselines } from './hosted-ios-source-control-review-parity.mjs'
import { resetHostedIosPhotosPermission } from './hosted-ios-photo-permission-denial.mjs'
import { verifyHostedIosTerminalInputJourney } from './hosted-ios-terminal-device-input-journey.mjs'
import { waitForHostedIosBuildActivation } from './hosted-ios-build-activation.mjs'
import { completeHostedIosNativeOnboarding } from './hosted-ios-native-onboarding.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
import {
clearHostedIosWebViewSecurityProbe,
@@ -79,6 +79,12 @@ import {
startHostedIosWebViewSecurityProbe
} from './hosted-ios-webview-security-probe.mjs'
import { hostedIosSimulatorAppPreparation } from './hosted-ios-simulator-app-preparation.mjs'
import {
installHostedWebViewRouteExceptionCapture,
readHostedWebViewRouteExceptionEvidence
} from './hosted-webview-route-exception-evidence.mjs'
import { captureHostedWebViewSecurityEvidence } from './hosted-webview-security-evidence.mjs'
import { evidenceStep, printHostedWebViewE2eReport } from './hosted-webview-e2e-report.mjs'
const worktree = path.resolve(import.meta.dirname, '../..')
const options = parseHostedWebViewSimulatorE2eOptions(process.argv.slice(2))
@@ -111,6 +117,7 @@ async function main() {
let adversarialSessionYOffset = 0
let adversarialTerminalLinks = null
let adversarialTerminalHandle = null
let hostedExceptionDocument = null
try {
networkProbe = await startHostedIosWebViewSecurityProbe()
if (options.adversarialContent) {
@@ -273,9 +280,21 @@ async function main() {
)
let workspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Orca Desktop',
expectedText: expectedWorkspace,
timeoutMs: options.timeoutMs
})
hostedExceptionDocument = workspaceDocument
await installHostedWebViewRouteExceptionCapture(workspaceDocument)
const nativeAlert = await evidenceStep('native Alert bridge journey', () =>
verifyHostedIosNativeAlertJourney({
discoveryUrl,
emulator,
expectedWorkspace,
timeoutMs: options.timeoutMs,
workspaceDocument
})
)
workspaceDocument = nativeAlert.workspaceDocument
if (adversarialFixture) {
await enableHostedTerminalDiagnostics(workspaceDocument)
}
@@ -343,6 +362,7 @@ async function main() {
nativeBaseline: nativeAccounts,
runtimeDirectory,
timeoutMs: options.timeoutMs,
expectedWorkspace,
workspaceDocument
})
)
@@ -421,18 +441,22 @@ async function main() {
options.nativeSettingsOnly
? null
: await evidenceStep('Source Control and Review journey', async () => {
let hostOrigin
if (options.sourceControlOnly) {
await activateHostedWorkspaceRow(
workspaceDocument,
hostOrigin = await evidenceStep('host-origin Source Control journey', () =>
verifyHostedHostOriginSourceControlJourney({
discoveryUrl,
emulator,
nativeBaseline: nativeSourceControlReview.sourceControl,
timeoutMs: options.timeoutMs,
workspaceName: expectedWorkspace
})
)
workspaceDocument = hostOrigin.workspaceDocument
await tapHostedIosAccessibilityControlByLabelPrefix(
emulator,
adversarialFixture?.workspaceRowName ?? expectedWorkspace,
activateHostedWebViewControl,
options.timeoutMs,
() =>
waitForVisibleHostedWebView({
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
expectedText: adversarialFixture?.workspaceRowName ?? 'Orca Desktop',
timeoutMs: options.timeoutMs
})
options.timeoutMs
)
}
let sessionDocument = await waitForVisibleHostedWebView({
@@ -459,7 +483,7 @@ async function main() {
adversarialSessionYOffset = result.yOffset
sessionDocument = result.sessionDocument
}
return verifyHostedSourceControlReviewJourney({
const sessionOrigin = await verifyHostedSourceControlReviewJourney({
deviceUdid,
discoveryUrl: `http://127.0.0.1:${inspectorPort}`,
emulator,
@@ -488,6 +512,7 @@ async function main() {
: undefined,
timeoutMs: options.timeoutMs
})
return { ...sessionOrigin, ...(hostOrigin ? { hostOrigin } : {}) }
})
const adversarialContent = adversarialInspector
? await evidenceStep('adversarial filename and diff presentation', () =>
@@ -507,72 +532,53 @@ async function main() {
expectedHrefIncludes: options.nativeSettingsOnly ? '/session/' : '/review/',
timeoutMs: options.timeoutMs
})
const networkIsolation = await evidenceStep('network isolation probe', () =>
verifyHostedWebViewNetworkIsolation({
const { executableIsolation, navigationIsolation, networkIsolation, privacyIsolation } =
await captureHostedWebViewSecurityEvidence({
document: securityDocument,
probeId: networkProbe.token
evidenceStep,
probeId: networkProbe.token,
workspacePrivacyIsolation
})
)
const navigationIsolation = await evidenceStep('navigation isolation probe', () =>
verifyHostedWebViewNavigationIsolation({
document: securityDocument,
probeId: networkProbe.token
})
)
const executableIsolation = await evidenceStep('executable isolation probe', () =>
verifyHostedWebViewExecutableIsolation({
document: securityDocument,
probeId: networkProbe.token
})
)
const privacyIsolation =
workspacePrivacyIsolation ??
(await evidenceStep('privacy isolation probe', () =>
verifyHostedWebViewPrivacyIsolation({ document: securityDocument })
))
await delay(500)
await new Promise((resolve) => setTimeout(resolve, 500))
if (networkProbe.observations.length > 0) {
throw new Error(
`Hosted WebView reached the network probe: ${networkProbe.observations.join(', ')}`
)
}
await waitForHostedIosBuildActivation(deviceUdid, options, runtimeDirectory)
console.log(
JSON.stringify(
{
ok: true,
device: deviceUdid,
targetId: workspaceDocument.targetId,
route: workspaceDocument.href,
nativeAppPath,
visibleText: expectedWorkspace,
interactiveControls: workspaceDocument.buttonCount,
networkIsolation,
navigationIsolation,
executableIsolation,
privacyIsolation,
nativeOnboarding,
documentUpload: terminalDeviceInput?.documentUpload?.evidence ?? null,
photoPermissionDenial: terminalDeviceInput?.photoPermissionDenial?.evidence ?? null,
photoPermissionRevocation:
terminalDeviceInput?.photoPermissionRevocation?.evidence ?? null,
terminalClipboardImagePaste:
terminalDeviceInput?.terminalClipboardImagePaste?.evidence ?? null,
terminalClipboardPaste: terminalDeviceInput?.terminalClipboardPaste.evidence ?? null,
workspaceParity: hostedWorkspace,
accountsParity: hostedAccounts?.evidence ?? null,
agentHistory: historyEvidence,
coreRouteParity: hostedCoreRoutes?.evidence ?? null,
filesPreviewParity: hostedFilesPreview?.evidence ?? null,
sourceControlReview,
adversarialContent,
adversarialProviderContent,
adversarialTerminalLinks
},
null,
2
printHostedWebViewE2eReport({
adversarialContent,
adversarialProviderContent,
adversarialTerminalLinks,
deviceUdid,
executableIsolation,
expectedWorkspace,
historyEvidence,
hostedAccounts,
hostedCoreRoutes,
hostedFilesPreview,
hostedWorkspace,
nativeAlert,
nativeAppPath,
nativeOnboarding,
navigationIsolation,
networkIsolation,
privacyIsolation,
sourceControlReview,
terminalDeviceInput,
workspaceDocument
})
} catch (error) {
const evidence = hostedExceptionDocument
? await readHostedWebViewRouteExceptionEvidence(hostedExceptionDocument).catch(() => [])
: []
if (evidence.length > 0) {
throw new Error(
`${error instanceof Error ? error.message : String(error)} Hosted exception evidence: ${JSON.stringify(evidence)}`,
{ cause: error }
)
)
}
throw error
} finally {
inspector?.stop()
await stopHostedChildProcess(launcher)
@@ -585,20 +591,6 @@ async function main() {
}
}
async function evidenceStep(label, run) {
try {
return await run()
} catch (error) {
throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, {
cause: error
})
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 1
@@ -7,9 +7,12 @@ import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
import { createEmulatorPairingChildEnvironment } from './emulator-pairing-child-environment.mjs'
import { linkEmulatorMacKeychain } from './emulator-macos-keychain-home.mjs'
import { seedEmulatorAgentHistoryFixture } from './emulator-agent-history-fixture.mjs'
import { pairingEndpointPortFromUrl } from './emulator-pairing-public-key.mjs'
import {
currentPairingDaemonPids,
pairingDaemonPidsFromUserData,
pairingRuntimePidFromUserData,
signalPairingDaemons,
@@ -45,6 +48,8 @@ export async function startHeadlessPairingRuntime({
// home, so the pairing runtime must hand it a matching disposable HOME.
const homeDir = path.join(runDir, 'home')
mkdirSync(homeDir, { recursive: true, mode: 0o700 })
// Keep the disposable home boundary while letting macOS reuse Orca Dev's approved safeStorage item.
linkEmulatorMacKeychain(homeDir)
const pairingAddress = primaryLanIp(lanIpCandidates)
const child = spawn(
orcaCli,
@@ -58,15 +63,12 @@ export async function startHeadlessPairingRuntime({
],
{
cwd,
env: {
...process.env,
...environment,
ORCA_DEV_USER_DATA_PATH: userData,
ORCA_E2E_USER_DATA_DIR: userData,
ORCA_E2E_HOME_DIR: homeDir,
HOME: homeDir,
USERPROFILE: homeDir
},
env: createEmulatorPairingChildEnvironment({
inheritedEnvironment: process.env,
environment,
userData,
homeDir
}),
stdio: ['ignore', 'pipe', 'pipe']
}
)
@@ -151,6 +153,7 @@ async function waitForPairingRuntime({
child.stdout?.destroy()
child.stderr?.destroy()
if (shutdownDaemon) {
daemonPids = currentPairingDaemonPids(userData, daemonPids)
signalPairingDaemons(daemonPids)
const gracefulTimeouts = await waitForProcessExit(daemonPids, 2_500)
signalPairingDaemons(gracefulTimeouts, process.kill, 'SIGKILL')
@@ -19,7 +19,9 @@ describe('mobile web accounts screen binding', () => {
expect(hostedRoute).toContain(
"import { AccountsScreen } from '../../../app/h/[hostId]/accounts'"
)
expect(hostedRoute).toContain("webHostAccountsOperations(shell.client, 'Orca Desktop')")
expect(hostedRoute).toContain(
"webHostAccountsOperations(shell.client, shell.hostDisplayName ?? 'Orca Desktop')"
)
expect(hostedRoute).toContain('operations={operations}')
expect(hostedRoute).toContain('nativeHostBinding={false}')
expect(hostedRoute).not.toMatch(/StyleSheet|className|<View|<Text|<Pressable|<div/)
@@ -1,14 +0,0 @@
import { useCallback } from 'react'
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
import { mobileAccountsRouteTarget } from './mobile-accounts-route'
export function useOpenMobileAccounts(): (hostId: string) => void {
const openHostStackRoute = useOpenHostStackRoute()
return useCallback(
(hostId) => {
openHostStackRoute(hostId, mobileAccountsRouteTarget(hostId))
},
[openHostStackRoute]
)
}
+10 -1
View File
@@ -22,6 +22,7 @@ type Props = {
title?: string
message?: string
actions: ActionSheetAction[]
dismissible?: boolean
onClose: () => void
}
@@ -107,7 +108,14 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
)
}
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
export function ActionSheetModal({
visible,
title,
message,
actions,
dismissible = true,
onClose
}: Props) {
const pendingActionRef = useRef<(() => void) | null>(null)
const sequencedActions = actions.map((action) =>
action.closeBeforePress
@@ -123,6 +131,7 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }:
return (
<BottomDrawer
visible={visible}
dismissible={dismissible}
onClose={onClose}
onAfterClose={() => {
// Why: iOS cannot present a second native modal until the action
+3
View File
@@ -7,6 +7,7 @@ type Props = {
onClose: () => void
onAfterClose?: () => void
children: ReactNode
dismissible?: boolean
dragContentToDismiss?: boolean
contentScrollable?: boolean
// Why: smart-source (and similar) need a stable outer frame so a docked
@@ -24,6 +25,7 @@ export function BottomDrawer({
onClose,
onAfterClose,
children,
dismissible = true,
dragContentToDismiss = true,
contentScrollable = true,
fillAvailable = false,
@@ -81,6 +83,7 @@ export function BottomDrawer({
visible={visible}
onClose={onClose}
onHidden={handleHidden}
dismissible={dismissible}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
+15 -9
View File
@@ -7,10 +7,12 @@ type Props = {
title: string
message?: string
confirmLabel?: string
cancelLabel?: string
cancelLabel?: string | null
destructive?: boolean
dismissible?: boolean
onConfirm: () => void
onCancel: () => void
onDismiss?: () => void
}
export function ConfirmModal({
@@ -20,22 +22,26 @@ export function ConfirmModal({
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = false,
dismissible = true,
onConfirm,
onCancel
onCancel,
onDismiss = onCancel
}: Props) {
return (
<BottomDrawer visible={visible} onClose={onCancel}>
<BottomDrawer visible={visible} dismissible={dismissible} onClose={onDismiss}>
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<View style={styles.buttons}>
<Pressable
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
onPress={onCancel}
>
<Text style={styles.cancelText}>{cancelLabel}</Text>
</Pressable>
{cancelLabel ? (
<Pressable
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
onPress={onCancel}
>
<Text style={styles.cancelText}>{cancelLabel}</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.button,
-6
View File
@@ -1,6 +0,0 @@
import { Linking } from 'react-native'
export function openMobilePrUrl(url: string): void {
// Prevent unhandled rejections when no app accepts the URL.
void Linking.openURL(url).catch(() => {})
}
+31 -30
View File
@@ -28,6 +28,7 @@ import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useMobileKeyboardInset } from '../hooks/use-mobile-keyboard-inset'
const DISMISS_THRESHOLD = 80
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
@@ -43,6 +44,7 @@ export type MountedBottomDrawerProps = {
onClose: () => void
onHidden: () => void
children: ReactNode
dismissible?: boolean
dragContentToDismiss?: boolean
contentScrollable?: boolean
fillAvailable?: boolean
@@ -57,6 +59,7 @@ export function MountedBottomDrawer({
onClose,
onHidden,
children,
dismissible = true,
dragContentToDismiss = true,
contentScrollable = true,
fillAvailable = false,
@@ -80,6 +83,7 @@ export function MountedBottomDrawer({
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
const mobileKeyboardInset = useMobileKeyboardInset()
const fillHeight = fillAvailable
? resolveBottomDrawerFillHeight({
screenHeight,
@@ -133,34 +137,19 @@ export function MountedBottomDrawer({
}
}
// Why: fill sheets dock to the true keyboard top; autoFocus can raise the
// keyboard before listeners attach. Seed only in fill mode so content-sized
// outer sheets do not inherit a stale metrics height after an inner dismiss.
if (fillAvailable) {
const existing = Keyboard.metrics()
if (existing != null && existing.height > 0) {
applyKeyboardHeight(existing.height)
}
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (e) => {
applyKeyboardHeight(e.endCoordinates.height, e.duration || 250)
})
const onHide = Keyboard.addListener(hideEvent, (e) => {
setKeyboardInset(0)
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
})
applyKeyboardHeight(mobileKeyboardInset.height, mobileKeyboardInset.duration || 250)
return () => {
onShow.remove()
onHide.remove()
keyboardOffset.value = 0
setKeyboardInset(0)
}
}, [visible, interactive, insets.bottom, fillAvailable])
}, [
visible,
interactive,
insets.bottom,
fillAvailable,
mobileKeyboardInset.duration,
mobileKeyboardInset.height
])
const dismiss = useCallback(() => {
Keyboard.dismiss()
@@ -177,11 +166,13 @@ export function MountedBottomDrawer({
}
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
dismiss()
if (dismissible) {
dismiss()
}
return true
})
return () => sub.remove()
}, [visible, interactive, dismiss])
}, [visible, interactive, dismissible, dismiss])
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
@@ -189,6 +180,7 @@ export function MountedBottomDrawer({
const scrollGesture = Gesture.Native()
const handlePanGesture = Gesture.Pan()
.enabled(dismissible)
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onUpdate((e) => {
@@ -212,6 +204,7 @@ export function MountedBottomDrawer({
}
})
const contentPanGesture = Gesture.Pan()
.enabled(dismissible)
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onBegin(() => {
@@ -293,8 +286,8 @@ export function MountedBottomDrawer({
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
accessibilityRole={dismissible ? 'button' : undefined}
accessibilityLabel={dismissible ? 'Dismiss drawer' : undefined}
>
<View style={styles.handle} />
</Animated.View>
@@ -353,7 +346,9 @@ export function MountedBottomDrawer({
// their backdrop so only the top interactive drawer dims the canvas.
style={[styles.backdrop, interactive ? backdropStyle : { opacity: 0 }]}
>
{interactive ? <Pressable style={styles.backdropPressable} onPress={dismiss} /> : null}
{interactive && dismissible ? (
<Pressable style={styles.backdropPressable} onPress={dismiss} />
) : null}
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
@@ -393,7 +388,13 @@ export function MountedBottomDrawer({
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={dismissible ? dismiss : () => {}}
>
{overlay}
</Modal>
)
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { Keyboard, Platform, type KeyboardEvent } from 'react-native'
export type MobileKeyboardInset = {
height: number
duration: number
}
export function useMobileKeyboardInset(): MobileKeyboardInset {
const [inset, setInset] = useState<MobileKeyboardInset>(() => ({
height: Math.max(0, Keyboard.metrics?.()?.height ?? 0),
duration: 0
}))
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const show = Keyboard.addListener(showEvent, (event: KeyboardEvent) => {
setInset({ height: Math.max(0, event.endCoordinates?.height ?? 0), duration: event.duration })
})
const hide = Keyboard.addListener(hideEvent, (event: KeyboardEvent) => {
setInset({ height: 0, duration: event.duration })
})
return () => {
show.remove()
hide.remove()
}
}, [])
return inset
}
@@ -0,0 +1,16 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { measureMobileWebKeyboardInset } from './use-mobile-keyboard-inset.web'
describe('mobile web keyboard inset', () => {
it('measures the viewport region obscured by the software keyboard', () => {
expect(measureMobileWebKeyboardInset(844, { height: 510, offsetTop: 47 })).toBe(287)
expect(measureMobileWebKeyboardInset(844, { height: 844, offsetTop: 0 })).toBe(0)
})
it('clamps oversize and unavailable viewports', () => {
expect(measureMobileWebKeyboardInset(600, { height: 650, offsetTop: 0 })).toBe(0)
expect(measureMobileWebKeyboardInset(600, null)).toBe(0)
})
})
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react'
import type { MobileKeyboardInset } from './use-mobile-keyboard-inset'
export function measureMobileWebKeyboardInset(
innerHeight: number,
viewport: Pick<VisualViewport, 'height' | 'offsetTop'> | null
): number {
return viewport ? Math.max(0, Math.round(innerHeight - viewport.height - viewport.offsetTop)) : 0
}
export function useMobileKeyboardInset(): MobileKeyboardInset {
const [inset, setInset] = useState<MobileKeyboardInset>({ height: 0, duration: 0 })
useEffect(() => {
const viewport = window.visualViewport
const update = () => {
const height = measureMobileWebKeyboardInset(window.innerHeight, viewport)
setInset((current) =>
current.height === height ? current : { height, duration: current.duration === 0 ? 0 : 250 }
)
}
update()
if (!viewport) {
return
}
const animatedUpdate = () => {
const height = measureMobileWebKeyboardInset(window.innerHeight, viewport)
setInset((current) => (current.height === height ? current : { height, duration: 250 }))
}
viewport.addEventListener('resize', animatedUpdate)
viewport.addEventListener('scroll', animatedUpdate)
return () => {
viewport.removeEventListener('resize', animatedUpdate)
viewport.removeEventListener('scroll', animatedUpdate)
}
}, [])
return inset
}
@@ -1,33 +1,28 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
primeHosts: (hosts: HostProfile[]) => void
}
export type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
const noop = () => {}
const value: RpcClientContextValue = {
acquire: () => null,
release: noop,
releaseAndCloseIfUnused: noop,
closeIfUnused: noop,
async forceReconnect() {},
closeHost: noop,
refreshHostClient: noop,
forgetHostClient: noop,
disconnectHostClient: noop,
getState: () => 'disconnected',
getKnownState: () => null,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getActivePath: () => 'lan',
getPendingPath: () => null,
isPairingRejected: () => false,
subscribeHostState: () => noop,
getAllClients: () => [],
subscribeAllHosts: () => noop,
@@ -67,7 +62,7 @@ export function useAllHostClients(_hostIds: string[]): {
}
export function useCloseHost(): (hostId: string) => void {
return useRpcClientContext().closeHost
return useRpcClientContext().disconnectHostClient
}
export function useForceReconnect(): (hostId: string) => Promise<void> {
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
assertHostedAndroidBridgeLogClean,
findHostedAndroidBridgeLogFailures,
installAndResetHostedAndroidApp,
waitForHostedAndroidReactReady
} from '../../scripts/hosted-android-emulator-session.mjs'
@@ -50,6 +51,22 @@ describe('hosted Android emulator session', () => {
await expect(waitForHostedAndroidReactReady('adb', 2_000, runAdb)).resolves.toBe('7301')
})
it('allows an exact debug APK to replace a higher-version beta install', async () => {
const calls: Array<{ args: string[]; timeout?: number }> = []
const runAdb = async (_adb: string, args: string[], timeout?: number) => {
calls.push({ args, timeout })
return ''
}
await installAndResetHostedAndroidApp('adb', '/tmp/app-debug.apk', runAdb)
expect(calls[0]).toEqual({
args: ['install', '-r', '-d', '-t', '/tmp/app-debug.apk'],
timeout: 120_000
})
expect(calls[1]?.args).toEqual(['shell', 'pm', 'clear', 'com.stably.orca.mobile'])
})
it('scopes the bridge audit to the current Android app process', async () => {
const calls: string[][] = []
const runAdb = async (_adb: string, args: string[]) => {
@@ -0,0 +1,157 @@
import { describe, expect, it, vi } from 'vitest'
import {
openHostedAndroidHardwareBackNestedRoute,
sendHostedAndroidHardwareBack,
waitForHostedAndroidWorkspaceRoot,
verifyHostedAndroidHardwareBackJourney
} from '../../scripts/hosted-android-hardware-back-journey.mjs'
describe('hosted Android hardware Back journey', () => {
it('uses the real Android Back key event', async () => {
const runAdb = vi.fn(async () => '')
await sendHostedAndroidHardwareBack('/sdk/adb', runAdb)
expect(runAdb).toHaveBeenCalledWith('/sdk/adb', ['shell', 'input', 'keyevent', 'KEYCODE_BACK'])
})
it('opens the nested route through the hosted UI navigation stack', async () => {
const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' }
const actionsDocument = { href: sessionDocument.href }
const historyDocument = {
href: 'orca-mobile-web://build/h/host/agent-history/workspace'
}
const activate = vi.fn().mockResolvedValue(undefined)
const settle = vi.fn().mockResolvedValue(undefined)
const waitForDocument = vi
.fn()
.mockResolvedValueOnce(actionsDocument)
.mockResolvedValueOnce(historyDocument)
await expect(
openHostedAndroidHardwareBackNestedRoute(
{
discoveryUrl: 'http://127.0.0.1:9222',
sessionDocument,
timeoutMs: 30_000
},
{ activate, settle, waitForDocument }
)
).resolves.toBe(historyDocument)
expect(activate.mock.calls.map((call) => call[1])).toEqual([
{ kind: 'label', value: 'More session actions' },
{ kind: 'text', value: 'Agent History' }
])
expect(waitForDocument).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedHrefIncludes: '/agent-history/' })
)
expect(settle).toHaveBeenCalledWith(500)
})
it('waits for the Session route to leave before accepting the workspace root', async () => {
const waitForDocument = vi
.fn()
.mockResolvedValueOnce({ href: '/session/workspace' })
.mockResolvedValueOnce({ href: '/session/workspace' })
.mockResolvedValueOnce({ href: '/' })
const settle = vi.fn().mockResolvedValue(undefined)
await expect(
waitForHostedAndroidWorkspaceRoot({
discoveryUrl: 'http://127.0.0.1:9222',
settle,
timeoutMs: 30_000,
waitForDocument,
workspaceMarker: 'MOBILE-REARCH'
})
).resolves.toEqual({ href: '/' })
expect(settle).toHaveBeenCalledTimes(2)
})
it('returns through Session and workspace root before dismissing to native home', async () => {
const events: string[] = []
const pressBack = vi.fn(async () => events.push('back'))
const openAgentHistory = vi.fn(async () => {
events.push('agent-history')
return { href: 'orca-mobile-web://build/h/host/agent-history/workspace' }
})
const waitForDocument = vi.fn(async ({ expectedHrefIncludes }) => {
const route = expectedHrefIncludes ? 'session' : 'workspace'
events.push(route)
return { href: `orca-mobile-web://build/h/host/${route}` }
})
const waitForNativeControl = vi.fn(async () => {
events.push('native-shell')
return { label: 'Open settings' }
})
const evidence = await verifyHostedAndroidHardwareBackJourney(
{
adb: '/sdk/adb',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { adb: '/sdk/adb' },
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
timeoutMs: 30_000,
workspaceMarker: 'MOBILE-REARCH'
},
{ openAgentHistory, pressBack, waitForDocument, waitForNativeControl }
)
expect(events).toEqual([
'agent-history',
'back',
'session',
'back',
'workspace',
'back',
'native-shell'
])
expect(evidence).toEqual({
nestedRoute: 'orca-mobile-web://build/h/host/agent-history/workspace',
returnedSessionRoute: 'orca-mobile-web://build/h/host/session',
workspaceRootRoute: 'orca-mobile-web://build/h/host/workspace',
nativeShellControl: 'Open settings',
hardwareBackPresses: 3
})
expect(waitForDocument).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ expectedHrefIncludes: '/session/', expectedText: '1 tab' })
)
expect(waitForNativeControl).toHaveBeenCalledWith(
{ adb: '/sdk/adb' },
[
'Open settings',
'Open sessions in Chat UI',
'Open sessions in the terminal',
'Show paired hosts'
],
30_000
)
})
it('rejects a nested route that remains visible at the workspace step', async () => {
await expect(
verifyHostedAndroidHardwareBackJourney(
{
adb: '/sdk/adb',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { adb: '/sdk/adb' },
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
timeoutMs: 30_000,
workspaceMarker: 'MOBILE-REARCH'
},
{
openAgentHistory: async () => ({ href: '/agent-history/' }),
pressBack: async () => {},
waitForWorkspaceRoot: async () => ({ href: '/agent-history/' }),
waitForDocument: async ({ expectedHrefIncludes }) => ({
href: expectedHrefIncludes ? '/session/' : '/agent-history/'
}),
waitForNativeControl: vi.fn()
}
)
).rejects.toThrow('did not reach the workspace root')
})
})
@@ -82,6 +82,7 @@ describe('hosted iOS Accounts parity', () => {
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
nativeBaseline: {
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-accounts-portrait.png'
@@ -1,10 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { dismissDeveloperMenu, rotateEmulator, tapControl, tapControlByPrefix } = vi.hoisted(() => ({
const {
dismissDeveloperMenu,
rotateEmulator,
tapControl,
tapControlByPrefix,
typeText,
waitByPrefix
} = vi.hoisted(() => ({
dismissDeveloperMenu: vi.fn(),
rotateEmulator: vi.fn(),
tapControl: vi.fn(),
tapControlByPrefix: vi.fn()
tapControlByPrefix: vi.fn(),
typeText: vi.fn(),
waitByPrefix: vi.fn()
}))
vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
@@ -14,8 +23,9 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
rotateHostedIosEmulator: rotateEmulator,
tapHostedIosAccessibilityControl: tapControl,
tapHostedIosAccessibilityControlByLabelPrefix: tapControlByPrefix,
typeHostedIosText: typeText,
waitForHostedIosAccessibilityControl: vi.fn(),
waitForHostedIosAccessibilityControlByLabelPrefix: vi.fn()
waitForHostedIosAccessibilityControlByLabelPrefix: waitByPrefix
}))
import { openNativeAgentHistoryBaseline } from '../../scripts/hosted-ios-agent-history-parity.mjs'
@@ -34,7 +44,7 @@ describe('native Agent History baseline', () => {
timeoutMs: 30_000
})
await vi.advanceTimersByTimeAsync(500)
await vi.runAllTimersAsync()
await opened
expect(dismissDeveloperMenu).toHaveBeenCalledWith(emulator)
@@ -42,6 +52,10 @@ describe('native Agent History baseline', () => {
expect(tapControlByPrefix).toHaveBeenNthCalledWith(1, emulator, 'mobile-rearch', 30_000)
expect(tapControl).toHaveBeenCalledWith(emulator, 'More session actions', 30_000)
expect(tapControlByPrefix).toHaveBeenNthCalledWith(2, emulator, 'Agent History', 30_000)
expect(tapControl).toHaveBeenCalledWith(emulator, 'Search sessions, repo:, path:', 30_000)
expect(typeText).toHaveBeenCalledWith(emulator, 'hybrid agent history fixture')
expect(waitByPrefix).toHaveBeenCalledWith(emulator, 'Hybrid Agent History Fixture', 30_000)
expect(tapControl).toHaveBeenCalledWith(emulator, 'Agent Session History', 30_000)
expect(rotateEmulator.mock.invocationCallOrder[0]).toBeLessThan(
dismissDeveloperMenu.mock.invocationCallOrder[0]
)
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
waitForControl: vi.fn(),
waitForControlByPrefix: vi.fn(),
waitForControlMatch: vi.fn(),
waitForControlMatching: vi.fn(),
waitForDocument: vi.fn()
}))
@@ -29,7 +30,8 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
tapHostedIosPoint: mocks.tapPoint,
waitForHostedIosAccessibilityControl: mocks.waitForControl,
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch
waitForHostedIosAccessibilityControlMatch: mocks.waitForControlMatch,
waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching
}))
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
assertHostedIosScreenshotParity: mocks.compareScreenshots
@@ -70,6 +72,12 @@ describe('hosted iOS core-route parity', () => {
x: 0.5,
y: 0.5
})
mocks.waitForControlMatching.mockResolvedValue({
label: 'Stable task title, 3m, Issue · orca #1, Open',
value: '',
x: 0.5,
y: 0.5
})
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`
}))
@@ -104,6 +112,30 @@ describe('hosted iOS core-route parity', () => {
expect(baselines.session.screenshot).toBe('/tmp/parity/native-session-portrait.png')
})
it('captures a populated GitHub Tasks list after pagination appears', async () => {
mocks.waitForControlMatch.mockRejectedValueOnce(new Error('no empty state'))
const baselines = await captureNativeCoreRouteBaselines({
deviceUdid: 'simulator',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000
})
expect(mocks.waitForControlMatch).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
expect.any(Array),
5_000
)
expect(mocks.waitForControlMatching).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
expect.any(Function),
25_000
)
expect(baselines.tasks.stableText).toBe('Stable task title')
})
it('compares hosted Tasks and Session and returns to the workspace route', async () => {
const nativeBaselines = {
tasks: {
@@ -8,9 +8,12 @@ import {
tapHostedIosAccessibilityControlByLabelPrefixAtPosition,
tapHostedIosAccessibilityControlStartingWith,
tapHostedIosPoint,
typeHostedIosText,
waitForHostedIosAccessibilityControl,
waitForHostedIosAccessibilityControlByLabelPrefix,
waitForHostedIosAccessibilityControlEndingWith,
waitForHostedIosAccessibilityControlMatching,
waitForHostedIosAccessibilityLabel,
waitForHostedIosAccessibilityLabelToDisappear
} from '../../scripts/hosted-ios-emulator-accessibility.mjs'
@@ -246,6 +249,62 @@ describe('hosted iOS emulator accessibility controls', () => {
).resolves.toEqual({ x: 0.75, y: 0.35 })
})
it('returns metadata for a visible control matched by shape', async () => {
const runCommand = vi.fn().mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify({
ok: true,
result: [
{
label: 'Stable task title, 3m, Issue · orca #1, Open',
value: '',
enabled: true,
frame: { x: 0.1, y: 0.3, width: 0.8, height: 0.1 }
}
]
})
})
await expect(
waitForHostedIosAccessibilityControlMatching(
emulator,
(node) => node.label?.endsWith(', Open'),
1_000,
runCommand
)
).resolves.toEqual({
label: 'Stable task title, 3m, Issue · orca #1, Open',
value: '',
x: 0.5,
y: 0.35
})
})
it('accepts a tall visible label whose center is below the viewport', async () => {
const runCommand = vi.fn().mockResolvedValueOnce({
stderr: '',
stdout: JSON.stringify({
ok: true,
result: [
{
label: 'File preview',
value: '',
enabled: true,
frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 }
}
]
})
})
await expect(
waitForHostedIosAccessibilityLabel(emulator, 'File preview', 1_000, runCommand)
).resolves.toEqual({
frame: { x: 0.03, y: 0.28, width: 0.94, height: 1.52 },
label: 'File preview',
value: ''
})
})
it('rejects an invalid accessibility response', async () => {
await expect(
waitForHostedIosAccessibilityControl(emulator, 'Resume agent session', 1_000, async () => ({
@@ -307,6 +366,19 @@ describe('hosted iOS emulator accessibility controls', () => {
expect(runCommand).toHaveBeenNthCalledWith(3, emulator, ['attach', emulator.deviceUdid])
})
it('types through the isolated emulator controller', async () => {
const runCommand = vi.fn().mockResolvedValue({
stderr: '',
stdout: JSON.stringify({ ok: true })
})
await typeHostedIosText(emulator, 'Hybrid Agent History Fixture', runCommand)
expect(runCommand.mock.calls.map(([, command]) => command)).toEqual(
Array.from('Hybrid Agent History Fixture', (character) => ['type', character])
)
})
it('retries transient helper failures while reattaching a point tap', async () => {
let attachAttempts = 0
const runCommand = vi.fn(async (_args, command) => {
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
tapPoint: vi.fn(),
waitForControl: vi.fn(),
waitForControlByPrefix: vi.fn(),
waitForLabel: vi.fn(),
waitForDocument: vi.fn()
}))
@@ -28,7 +29,8 @@ vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
tapHostedIosAccessibilityControlByLabelPrefix: mocks.tapControlByPrefix,
tapHostedIosPoint: mocks.tapPoint,
waitForHostedIosAccessibilityControl: mocks.waitForControl,
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
waitForHostedIosAccessibilityLabel: mocks.waitForLabel
}))
vi.mock('../../scripts/hosted-ios-screenshot-parity.mjs', () => ({
assertHostedIosScreenshotParity: mocks.compareScreenshots
@@ -71,6 +73,11 @@ describe('hosted iOS Files and Preview parity', () => {
mocks.tapPoint.mockResolvedValue(undefined)
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.2, y: 0.1 })
mocks.waitForLabel.mockResolvedValue({
frame: { x: 0, y: 0.3, width: 1, height: 1.5 },
label: 'File preview',
value: ''
})
mocks.waitForDocument.mockImplementation(({ expectedHrefIncludes }) => ({
href: `orca-mobile-web://build${expectedHrefIncludes ?? '/h/host'}`,
targetId: expectedHrefIncludes ?? 'workspace'
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { verifyHostedHostOriginSourceControlJourney } from '../../scripts/hosted-ios-host-origin-source-control-journey.mjs'
describe('hosted iOS host-origin Source Control journey', () => {
it('opens mobile Review without mutating the Session route', async () => {
const sourceControl = {
href: 'orca-mobile-web://build/h/host/source-control/workspace'
}
const review = { href: 'orca-mobile-web://build/h/host/review/workspace' }
const returnedWorkspace = { href: 'orca-mobile-web://build/' }
const activate = vi.fn().mockResolvedValue(undefined)
const longPress = vi.fn().mockResolvedValue(undefined)
const tapNative = vi.fn().mockResolvedValue(undefined)
const waitForDocument = vi
.fn()
.mockResolvedValueOnce(sourceControl)
.mockResolvedValueOnce(review)
.mockResolvedValueOnce(sourceControl)
.mockResolvedValueOnce(sourceControl)
.mockResolvedValueOnce(returnedWorkspace)
const readState = vi
.fn()
.mockResolvedValueOnce({
href: sourceControl.href,
labels: ['Refresh source control', 'Open changed file mobile/src/mobile-web/bridge.ts']
})
.mockResolvedValueOnce({
href: review.href,
labels: ['Show all review files']
})
.mockResolvedValueOnce({
href: review.href,
labels: ['Back', 'Open review actions']
})
const result = await verifyHostedHostOriginSourceControlJourney({
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { udid: 'SIMULATOR-1' },
nativeBaseline: {
changedFileLabel: 'Open changed file mobile/src/mobile-web/bridge.ts'
},
timeoutMs: 30_000,
workspaceName: 'mobile-rearch',
operations: { activate, longPress, readState, tapNative, waitForDocument }
})
expect(longPress).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'mobile-rearch', 30_000)
expect(tapNative).toHaveBeenCalledWith({ udid: 'SIMULATOR-1' }, 'Source Control', 30_000)
expect(activate.mock.calls.map((call) => call[1])).toEqual([
{ kind: 'label', value: 'Open changed file mobile/src/mobile-web/bridge.ts' },
{ kind: 'label', value: 'Back' },
{ kind: 'label', value: 'Back to session' }
])
expect(waitForDocument).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ expectedHrefIncludes: '/review/' })
)
expect(waitForDocument).toHaveBeenCalledTimes(5)
expect(result).toMatchObject({
reviewRoute: review.href,
sourceControlRoute: sourceControl.href,
workspaceDocument: returnedWorkspace
})
})
})
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { verifyHostedIosNativeAlertJourney } from '../../scripts/hosted-ios-native-alert-journey.mjs'
describe('hosted iOS native Alert journey', () => {
it('presents an OS alert, correlates its action, and returns to workspaces', async () => {
const workspaceDocument = { href: 'orca-mobile-web://build/h/host' }
const sessionDocument = { href: 'orca-mobile-web://build/h/host/session/workspace' }
const returnedDocument = { href: 'orca-mobile-web://build/h/host' }
const evaluate = vi
.fn()
.mockResolvedValueOnce(JSON.stringify({ started: true }))
.mockResolvedValueOnce(JSON.stringify({ posted: true }))
.mockResolvedValueOnce(
JSON.stringify({ status: 'success', payload: { kind: 'button', buttonIndex: 0 } })
)
const waitForDocument = vi
.fn()
.mockResolvedValueOnce(sessionDocument)
.mockResolvedValueOnce(returnedDocument)
const activateWorkspace = vi.fn().mockResolvedValue(undefined)
const activateControl = vi.fn().mockResolvedValue(undefined)
const waitForLabel = vi.fn().mockResolvedValue({ frame: { x: 0, y: 0, width: 1, height: 1 } })
const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 })
const waitForLabelToDisappear = vi.fn().mockResolvedValue(undefined)
const result = await verifyHostedIosNativeAlertJourney(
{
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
timeoutMs: 30_000,
workspaceDocument
},
{
activateControl,
activateWorkspace,
evaluate,
tapControl,
waitForDocument,
waitForLabel,
waitForLabelToDisappear
}
)
expect(tapControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Keep editing', 30_000)
expect(activateControl).toHaveBeenCalledWith(sessionDocument, {
kind: 'label',
value: 'Back to worktrees'
})
expect(result).toEqual({
evidence: {
button: { x: 0.5, y: 0.5 },
buttonIndex: 0,
presentation: 'native',
title: { x: 0, y: 0, width: 1, height: 1 }
},
workspaceDocument: returnedDocument
})
})
})
@@ -15,7 +15,7 @@ const args = {
orcaCli: './config/scripts/orca-dev.mjs',
pairingRuntimeUserDataPath: '/tmp/pairing/userData',
sessionDocument: {
href: 'orca-mobile-web://build/h/host/session/workspace?name=mobile-rearch'
href: 'orca-mobile-web://build/h/host/session/workspace'
},
terminalHandle: 'terminal-handle',
timeoutMs: 30_000,
@@ -24,12 +24,12 @@ const args = {
const grantedDocument = {
href: args.sessionDocument.href
.replace('://build/', '://grant-restart/')
.replace('/workspace?', '/grant-workspace?')
.replace('/workspace', '/grant-workspace')
}
const revokedDocument = {
href: args.sessionDocument.href
.replace('://build/', '://revocation-restart/')
.replace('/workspace?', '/revoked-workspace?')
.replace('/workspace', '/revoked-workspace')
}
function createOperations() {
@@ -231,7 +231,7 @@ describe('hosted iOS Photos permission revocation', () => {
it('rejects opaque authority replacement across picker interruption', async () => {
const operations = createOperations()
const replacedAuthority = {
href: grantedDocument.href.replace('/grant-workspace?', '/interrupted-workspace?')
href: grantedDocument.href.replace('/grant-workspace', '/interrupted-workspace')
}
operations.waitForDocument
.mockReset()
@@ -249,7 +249,7 @@ describe('hosted iOS Photos permission revocation', () => {
const interruptedDocument = {
href: grantedDocument.href
.replace('://grant-restart/', '://interruption-restart/')
.replace('/grant-workspace?', '/interrupted-workspace?')
.replace('/grant-workspace', '/interrupted-workspace')
}
operations.waitForDocument
.mockReset()
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
captureParityScreen: vi.fn(),
readBridgeErrors: vi.fn(),
navigateRoute: vi.fn(),
parityEvidence: vi.fn(),
readControlPoint: vi.fn(),
readState: vi.fn(),
tapAccessibilityControl: vi.fn(),
tapPoint: vi.fn(),
startBridgeErrorObservation: vi.fn(),
waitForDocument: vi.fn()
}))
@@ -29,6 +31,11 @@ vi.mock('../../scripts/hosted-webview-route-navigation.mjs', () => ({
navigateHostedWebViewRoute: mocks.navigateRoute
}))
vi.mock('../../scripts/hosted-webview-bridge-error-observation.mjs', () => ({
readHostedWebViewBridgeErrors: mocks.readBridgeErrors,
startHostedWebViewBridgeErrorObservation: mocks.startBridgeErrorObservation
}))
vi.mock('../../scripts/hosted-ios-source-control-review-parity.mjs', () => ({
captureHostedSourceControlReviewScreen: mocks.captureParityScreen,
sourceControlReviewParityEvidence: mocks.parityEvidence
@@ -51,19 +58,20 @@ describe('hosted iOS Source Control and Review journey', () => {
.mockReturnValueOnce({ screen: 'review' })
mocks.tapAccessibilityControl.mockResolvedValue(undefined)
mocks.tapPoint.mockResolvedValue(undefined)
mocks.startBridgeErrorObservation.mockResolvedValue(undefined)
mocks.waitForDocument
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session'
href: 'orca-mobile-web://build/h/host/source-control/workspace'
})
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/session/workspace?name=repo'
href: 'orca-mobile-web://build/h/host/session/workspace'
})
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/review/workspace'
})
mocks.readState
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session',
href: 'orca-mobile-web://build/h/host/source-control/workspace',
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx']
})
@@ -88,10 +96,9 @@ describe('hosted iOS Source Control and Review journey', () => {
timeoutMs: 30_000
})
).resolves.toEqual({
sourceControlRoute:
'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session',
sourceControlRoute: 'orca-mobile-web://build/h/host/source-control/workspace',
sourceControlSegments: ['Changes', 'Pull Request', 'Commits'],
sessionDiffRoute: 'orca-mobile-web://build/h/host/session/workspace?name=repo',
sessionDiffRoute: 'orca-mobile-web://build/h/host/session/workspace',
reviewRoute: 'orca-mobile-web://build/h/host/review/workspace',
reviewControls: ['Back', 'Open review actions']
})
@@ -115,8 +122,8 @@ describe('hosted iOS Source Control and Review journey', () => {
)
expect(mocks.tapPoint).not.toHaveBeenCalled()
expect(mocks.navigateRoute).toHaveBeenCalledWith(
{ href: 'orca-mobile-web://build/h/host/session/workspace?name=repo' },
'/h/host/review/workspace?scope=all&name=repo'
{ href: 'orca-mobile-web://build/h/host/session/workspace' },
'/h/host/review/workspace?scope=all'
)
})
@@ -158,7 +165,14 @@ describe('hosted iOS Source Control and Review journey', () => {
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
nativeBaselines: {
sourceControl: { screenshot: '/tmp/native-source-control.png' },
sourceControl: {
changedFileLabel: 'Open changed file mobile/app/index.tsx',
pullRequestState: {
kind: 'create',
label: 'Create pull request'
},
screenshot: '/tmp/native-source-control.png'
},
review: { screenshot: '/tmp/native-review.png' }
},
runtimeDirectory: '/tmp/parity',
@@ -190,6 +204,91 @@ describe('hosted iOS Source Control and Review journey', () => {
})
})
it('settles parity against the existing pull request captured by native', async () => {
const pullRequestLabel = 'Pull request #13386, Draft, No checks. Open pull request.'
mocks.readState
.mockReset()
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace',
bodyText: 'Source Control Changes Pull Request Commits #13386 Draft 128 on branch',
labels: [
'Refresh source control',
pullRequestLabel,
'Open changed file mobile/app/index.tsx'
]
})
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/review/workspace',
bodyText: 'reviewed',
labels: ['Back', 'Open review actions']
})
await expect(
verifyHostedSourceControlReviewJourney({
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
nativeBaselines: {
sourceControl: {
changedFileLabel: 'Open changed file mobile/app/index.tsx',
pullRequestState: { kind: 'ready', label: pullRequestLabel, number: '13386' },
screenshot: '/tmp/native-source-control.png'
},
review: { screenshot: '/tmp/native-review.png' }
},
runtimeDirectory: '/tmp/parity',
sessionDocument: {
href: 'orca-mobile-web://build/h/host/session/workspace'
},
timeoutMs: 30_000
})
).resolves.toMatchObject({ sourceControlSegments: ['Changes', 'Pull Request', 'Commits'] })
})
it('opens the exact changed file captured by the native baseline', async () => {
const nativeChangedFileLabel = 'Open changed file mobile/src/mobile-web/bridge.ts'
mocks.readState
.mockReset()
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace',
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
labels: [
'Refresh source control',
'Open changed file mobile/app/index.tsx',
nativeChangedFileLabel
]
})
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/review/workspace',
bodyText: 'reviewed',
labels: ['Back', 'Open review actions']
})
await verifyHostedSourceControlReviewJourney({
deviceUdid: 'simulator',
discoveryUrl: 'http://127.0.0.1:9222',
emulator: { deviceUdid: 'simulator' },
nativeBaselines: {
sourceControl: {
changedFileLabel: nativeChangedFileLabel,
pullRequestState: { kind: 'create', label: 'Create pull request' },
screenshot: '/tmp/native-source-control.png'
},
review: { screenshot: '/tmp/native-review.png' }
},
runtimeDirectory: '/tmp/parity',
sessionDocument: { href: 'orca-mobile-web://build/h/host/session/workspace' },
timeoutMs: 30_000
})
expect(mocks.tapAccessibilityControl).toHaveBeenNthCalledWith(
2,
{ deviceUdid: 'simulator' },
nativeChangedFileLabel,
5_000
)
})
it('falls back to measured points when WebKit omits accessibility descendants', async () => {
mocks.tapAccessibilityControl.mockRejectedValue(new Error('missing descendant'))
@@ -214,7 +313,7 @@ describe('hosted iOS Source Control and Review journey', () => {
it('uses measured native taps for duplicated Source Control segment labels', async () => {
const sourceState = {
href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session',
href: 'orca-mobile-web://build/h/host/source-control/workspace',
bodyText: 'Source Control Changes Pull Request Commits Create pull request 128 on branch',
labels: ['Refresh source control', 'Open changed file mobile/app/index.tsx']
}
@@ -283,12 +382,12 @@ describe('hosted iOS Source Control and Review journey', () => {
mocks.waitForDocument
.mockReset()
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session'
href: 'orca-mobile-web://build/h/host/source-control/workspace'
})
.mockRejectedValueOnce(new Error('route unchanged'))
.mockRejectedValueOnce(new Error('route unchanged'))
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/session/workspace?name=repo'
href: 'orca-mobile-web://build/h/host/session/workspace'
})
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/review/workspace'
@@ -312,12 +411,12 @@ describe('hosted iOS Source Control and Review journey', () => {
it('waits for tab state after the route changes', async () => {
const sessionDocument = {
href: 'orca-mobile-web://build/h/host/session/workspace?name=repo'
href: 'orca-mobile-web://build/h/host/session/workspace'
}
mocks.waitForDocument
.mockReset()
.mockResolvedValueOnce({
href: 'orca-mobile-web://build/h/host/source-control/workspace?name=repo&origin=session'
href: 'orca-mobile-web://build/h/host/source-control/workspace'
})
.mockRejectedValueOnce(new Error('tab count pending'))
.mockResolvedValueOnce(sessionDocument)
@@ -7,11 +7,10 @@ const mocks = vi.hoisted(() => ({
longPressControlByPrefix: vi.fn(),
readTextPoint: vi.fn(),
tapControl: vi.fn(),
tapControlStartingWith: vi.fn(),
waitForControl: vi.fn(),
waitForControlByPrefix: vi.fn(),
waitForControlEndingWith: vi.fn(),
waitForControlStartingWith: vi.fn()
waitForControlMatching: vi.fn()
}))
vi.mock('node:child_process', () => ({
@@ -22,11 +21,10 @@ vi.mock('../../scripts/emulator-developer-menu-dismissal.mjs', () => ({
}))
vi.mock('../../scripts/hosted-ios-emulator-accessibility.mjs', () => ({
tapHostedIosAccessibilityControl: mocks.tapControl,
tapHostedIosAccessibilityControlStartingWith: mocks.tapControlStartingWith,
waitForHostedIosAccessibilityControl: mocks.waitForControl,
waitForHostedIosAccessibilityControlByLabelPrefix: mocks.waitForControlByPrefix,
waitForHostedIosAccessibilityControlEndingWith: mocks.waitForControlEndingWith,
waitForHostedIosAccessibilityControlStartingWith: mocks.waitForControlStartingWith
waitForHostedIosAccessibilityControlMatching: mocks.waitForControlMatching
}))
vi.mock('../../scripts/hosted-ios-emulator-long-press.mjs', () => ({
longPressHostedIosAccessibilityControlByLabelPrefix: mocks.longPressControlByPrefix
@@ -53,11 +51,22 @@ describe('hosted iOS Source Control and Review parity', () => {
mocks.longPressControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 })
mocks.readTextPoint.mockResolvedValue({ x: 0.2, y: 0.1 })
mocks.tapControl.mockResolvedValue({ x: 0.2, y: 0.1 })
mocks.tapControlStartingWith.mockResolvedValue({ x: 0.5, y: 0.5 })
mocks.waitForControl.mockResolvedValue({ x: 0.2, y: 0.1 })
mocks.waitForControlByPrefix.mockResolvedValue({ x: 0.5, y: 0.4 })
mocks.waitForControlEndingWith.mockResolvedValue({ x: 0.5, y: 0.4 })
mocks.waitForControlStartingWith.mockResolvedValue({ x: 0.5, y: 0.5 })
mocks.waitForControlMatching
.mockResolvedValueOnce({
label: 'Open changed file mobile/app/index.tsx',
value: '',
x: 0.5,
y: 0.5
})
.mockResolvedValueOnce({
label: 'Pull request #13386, Draft, No checks. Open pull request.',
value: '',
x: 0.5,
y: 0.3
})
})
it('captures the real host-origin Source Control and standalone Review path', async () => {
@@ -79,9 +88,9 @@ describe('hosted iOS Source Control and Review parity', () => {
'Source Control',
30_000
)
expect(mocks.tapControlStartingWith).toHaveBeenCalledWith(
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Open changed file ',
'Open changed file mobile/app/index.tsx',
30_000
)
expect(baselines).toEqual({
@@ -90,12 +99,53 @@ describe('hosted iOS Source Control and Review parity', () => {
screenshot: '/tmp/parity/native-review-portrait.png'
},
sourceControl: {
changedFileLabel: 'Open changed file mobile/app/index.tsx',
pullRequestState: {
kind: 'ready',
label: 'Pull request #13386, Draft, No checks. Open pull request.',
number: '13386'
},
screenTitlePoint: { x: 0.2, y: 0.1 },
screenshot: '/tmp/parity/native-source-control-portrait.png'
}
})
})
it('uses value-backed accessibility text for the changed file and pull request', async () => {
mocks.waitForControlMatching
.mockReset()
.mockResolvedValueOnce({
label: 'Changed file',
value: 'Open changed file mobile/src/mobile-web/bridge.ts',
x: 0.5,
y: 0.5
})
.mockResolvedValueOnce({
label: '',
value: 'Pull request #13386, Draft, No checks. Open pull request.',
x: 0.5,
y: 0.3
})
const baselines = await captureNativeSourceControlReviewBaselines({
deviceUdid: 'simulator',
emulator: { deviceUdid: 'simulator' },
expectedWorkspace: 'mobile-rearch',
runtimeDirectory: '/tmp/parity',
timeoutMs: 30_000
})
expect(baselines.sourceControl).toMatchObject({
changedFileLabel: 'Open changed file mobile/src/mobile-web/bridge.ts',
pullRequestState: { kind: 'ready', number: '13386' }
})
expect(mocks.tapControl).toHaveBeenCalledWith(
{ deviceUdid: 'simulator' },
'Open changed file mobile/src/mobile-web/bridge.ts',
30_000
)
})
it('compares a hosted route against its native screenshot and title landmark', async () => {
const capture = await captureHostedSourceControlReviewScreen({
deviceUdid: 'simulator',
@@ -51,6 +51,14 @@ const simulatorHarnessSource = readFileSync(
new URL('../../scripts/run-hosted-webview-simulator-e2e.mjs', import.meta.url),
'utf8'
)
const simulatorSecurityEvidenceSource = readFileSync(
new URL('../../scripts/hosted-webview-security-evidence.mjs', import.meta.url),
'utf8'
)
const simulatorReportSource = readFileSync(
new URL('../../scripts/hosted-webview-e2e-report.mjs', import.meta.url),
'utf8'
)
const simulatorAppBuildSource = readFileSync(
new URL('../../scripts/hosted-ios-simulator-app-build.mjs', import.meta.url),
'utf8'
@@ -518,6 +526,8 @@ describe('hosted WebView CDP target selection', () => {
.map((evaluation) => evaluation.params.expression)
.join('\n')
expect(expressions).toContain('input[placeholder],textarea[placeholder]')
expect(expressions).toContain('element.getBoundingClientRect()')
expect(expressions).toContain('rect.top < innerHeight')
expect(expressions).toContain('More session actions')
expect(expressions).toContain('closest(\'button,[role="button"],a,[tabindex]\')')
expect(expressions).toContain('getBoundingClientRect()')
@@ -704,11 +714,12 @@ describe('hosted WebView CDP target selection', () => {
expect(simulatorHarnessSource).toContain(
'terminalDeviceInput.photoPermissionRevocation?.sessionDocument'
)
expect(simulatorHarnessSource).toContain(
expect(simulatorReportSource).toContain(
'terminalDeviceInput?.terminalClipboardImagePaste?.evidence'
)
expect(simulatorHarnessSource).toContain('verifyHostedWebViewExecutableIsolation')
expect(simulatorHarnessSource).toContain('verifyHostedWebViewPrivacyIsolation')
expect(simulatorHarnessSource).toContain('captureHostedWebViewSecurityEvidence')
expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewExecutableIsolation')
expect(simulatorSecurityEvidenceSource).toContain('verifyHostedWebViewPrivacyIsolation')
expect(simulatorHarnessSource).toContain("await evidenceStep('Photos permission reset'")
expect(simulatorHarnessSource).toContain('await clearHostedIosWebViewSecurityProbe(deviceUdid)')
expect(simulatorAppBuildSource).toContain("'xcodebuild'")
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import { waitForHostedWebViewConnectionSequence } from '../../scripts/hosted-webview-cdp-session.mjs'
describe('hosted WebView CDP target replacement', () => {
it('reacquires the document while observing reconnect', async () => {
const recovering = connectionEntry('recovering')
const connected = connectionEntry('connected')
const stale = new TargetSocket([
JSON.stringify([recovering]),
new Error('target replaced'),
new Error('target replaced'),
new Error('target replaced')
])
const replacement = new TargetSocket([JSON.stringify([connected])])
const WebSocketCtor = class {
constructor(endpoint: string) {
return endpoint.endsWith('/stale') ? stale : replacement
}
}
const reacquireDocument = vi.fn().mockResolvedValue({
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/replacement'
})
await expect(
waitForHostedWebViewConnectionSequence(
{ webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/page/stale' },
['recovering', 'connected'],
5_000,
{ WebSocketCtor, reacquireDocument }
)
).resolves.toEqual([recovering, connected])
expect(reacquireDocument).toHaveBeenCalledOnce()
})
})
function connectionEntry(state: string) {
return {
state,
href: 'orca-mobile-web://session-a/h/host/agent-history/worktree',
retainedExpectedText: true,
retainedExpectedRoute: true
}
}
class TargetSocket {
private readonly values: (string | Error)[]
private errorListener: ((error: Error) => void) | undefined
private messageListener: ((data: Buffer) => void) | undefined
constructor(values: (string | Error)[]) {
this.values = [...values]
}
once(event: string, listener: (value?: unknown) => void): void {
if (event === 'open') {
queueMicrotask(listener)
} else if (event === 'error') {
this.errorListener = listener as (error: Error) => void
}
}
on(event: string, listener: (data: Buffer) => void): void {
if (event === 'message') {
this.messageListener = listener
}
}
send(): void {
const value = this.values.shift() ?? ''
queueMicrotask(() => {
if (value instanceof Error) {
this.errorListener?.(value)
return
}
this.messageListener?.(Buffer.from(JSON.stringify({ id: 1, result: { result: { value } } })))
})
}
close(): void {}
}
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { evaluate } = vi.hoisted(() => ({ evaluate: vi.fn() }))
vi.mock('../../scripts/hosted-webview-cdp-session.mjs', () => ({
evaluateHostedDocumentWithRetry: evaluate
}))
import {
installHostedWebViewRouteExceptionCapture,
readHostedWebViewRouteExceptionEvidence
} from '../../scripts/hosted-webview-route-exception-evidence.mjs'
describe('hosted WebView route exception evidence', () => {
beforeEach(() => {
evaluate.mockReset()
})
it('installs a bounded error-only capture in the hosted document', async () => {
evaluate.mockResolvedValue('installed')
const WebSocketCtor = class {}
await installHostedWebViewRouteExceptionCapture({ targetId: 'hosted' }, WebSocketCtor)
expect(evaluate).toHaveBeenCalledWith(
{ targetId: 'hosted' },
expect.stringContaining("append('unhandled-rejection'"),
WebSocketCtor
)
expect(evaluate.mock.calls[0][1]).toContain('entries.length > 24')
expect(evaluate.mock.calls[0][1]).toContain('.slice(0, 4096)')
})
it('returns only bounded exception evidence entries', async () => {
evaluate.mockResolvedValue(
JSON.stringify([
{ kind: 'console-error', text: 'TypeError: failed' },
{ kind: 'other', text: 'ignored' },
{ kind: 'window-error', text: 'x'.repeat(4_097) }
])
)
const WebSocketCtor = class {}
await expect(
readHostedWebViewRouteExceptionEvidence({ targetId: 'hosted' }, WebSocketCtor)
).resolves.toEqual([{ kind: 'console-error', text: 'TypeError: failed' }])
})
})
@@ -3,7 +3,7 @@ import { navigateHostedWebViewRoute } from '../../scripts/hosted-webview-route-n
describe('hosted WebView route navigation', () => {
it('moves within the authenticated hosted SPA without a document navigation', async () => {
const socket = cdpSocket('/h/host/review/workspace?scope=all')
const socket = cdpSocket('/h/host/review/workspace')
await expect(
navigateHostedWebViewRoute(
@@ -11,6 +11,14 @@ const hybridPresentation = readFileSync(
new URL('./MobileWebHybridShellPresentation.tsx', import.meta.url),
'utf8'
)
const brokerMessageHandoff = readFileSync(
new URL('./mobile-web-broker-message-handoff.ts', import.meta.url),
'utf8'
)
const hardwareBackHook = readFileSync(
new URL('./use-mobile-web-hardware-back-handoff.ts', import.meta.url),
'utf8'
)
const hostedRouteRoot = new URL('../../host-web-app', import.meta.url)
const NATIVE_ROUTE_NAMES = [
@@ -40,7 +48,10 @@ describe('mobile native shell route ownership', () => {
expect(nativeSettings).not.toContain('Open hybrid workspace UI')
expect(nativeSettings).not.toContain('Hybrid workspace UI')
expect(nativeSettings).not.toContain('Experimental')
expect(nativeLayout).not.toContain('<Stack.Screen name="h"')
expect(nativeLayout).toContain(
'isRetiredNativeWorkspaceRoute(pathname, MOBILE_NATIVE_BASELINE_MODE)'
)
expect(nativeLayout).toContain('<Stack.Screen name="h" options={{ headerShown: false }} />')
})
it('keeps host selection on the existing native Home presentation', () => {
@@ -61,9 +72,10 @@ describe('mobile native shell route ownership', () => {
)?.[1]
expect(settingsBranch).toContain('nativeRouteHandoffRef.current.record(requestId, destination)')
expect(settingsBranch).not.toContain('setHostedViewActive(false)')
expect(hybridShell).toContain('completeMobileWebNativeRouteHandoffAfterResponse')
expect(hybridShell).toContain('await view.deactivateSessionView()')
expect(hybridShell).toContain('setHostedViewActive,')
expect(hybridShell).toContain('handleMobileWebBrokerMessage')
expect(brokerMessageHandoff).toContain('completeMobileWebNativeRouteHandoffAfterResponse')
expect(brokerMessageHandoff).toContain('await view.deactivateSessionView()')
expect(brokerMessageHandoff).toContain('setHostedViewActive: args.setHostedViewActive')
expect(hybridShell).toContain("router.push('/terminal-settings')")
expect(hybridShell).toContain('void view.activateSessionView(sessionId)')
expect(hybridShell).toContain('return () => setHostedViewActive(false)')
@@ -85,6 +97,17 @@ describe('mobile native shell route ownership', () => {
expect(readyBranch).toContain('setPageReadySessionId(current.sessionId)')
expect(readyBranch).not.toContain('postInit')
})
it('returns Android hardware Back to the native host route when the page does not handle it', () => {
const handleBack = hybridShell.match(
/const handleBack = useCallback\(\(\) => \{([\s\S]*?)\n\s*\},/
)?.[1]
expect(hybridShell).toContain('useMobileWebHardwareBackHandoff')
expect(hybridShell).toContain('onUnhandled: handleBack')
expect(handleBack).toContain('leaveHostRoute(router)')
expect(hardwareBackHook).toContain("Platform.OS !== 'android'")
expect(hardwareBackHook).toContain("BackHandler.addEventListener('hardwareBackPress'")
})
})
function listRouteFiles(root: URL): string[] {
@@ -0,0 +1,232 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MobileWebAlertAdapter,
createMobileWebAlertController,
installMobileWebAlertAdapter
} from './mobile-web-alert-adapter'
vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } }))
vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: 'ConfirmModal' }))
describe('mobile web Alert adapter', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('renders the native confirmation copy and invokes only the chosen action', () => {
const stay = vi.fn()
const discard = vi.fn()
const target = { alert: vi.fn() }
expect(installMobileWebAlertAdapter(target)).toBe(true)
act(() => {
renderer = create(createElement(MobileWebAlertAdapter))
target.alert('Discard changes?', 'Unsaved edits will be lost.', [
{ text: 'Stay', style: 'cancel', onPress: stay },
{ text: 'Discard', style: 'destructive', onPress: discard }
])
})
const modal = renderer.root.findByType('ConfirmModal')
expect(modal.props).toMatchObject({
title: 'Discard changes?',
message: 'Unsaved edits will be lost.',
cancelLabel: 'Stay',
confirmLabel: 'Discard',
destructive: true
})
act(() => {
modal.props.onConfirm()
modal.props.onCancel()
})
expect(discard).toHaveBeenCalledOnce()
expect(stay).not.toHaveBeenCalled()
})
it('queues alerts and supplies the native implicit OK action', () => {
const controller = createMobileWebAlertController()
controller.alert('First')
controller.alert('Second')
const first = controller.getSnapshot()
expect(first).toMatchObject({ title: 'First', buttons: [{ text: 'OK' }] })
controller.choose(first!.id, first!.buttons[0])
expect(controller.getSnapshot()).toMatchObject({ title: 'Second' })
})
it('bounds queued alerts', () => {
const controller = createMobileWebAlertController()
for (let index = 0; index < 20; index += 1) {
controller.alert(`Alert ${index}`)
}
const titles: string[] = []
while (controller.getSnapshot()) {
const prompt = controller.getSnapshot()!
titles.push(prompt.title)
controller.choose(prompt.id, prompt.buttons[0])
}
expect(titles).toEqual(Array.from({ length: 16 }, (_, index) => `Alert ${index}`))
})
it('uses the negotiated native alert and returns only the selected button callback', async () => {
const cancel = vi.fn()
const confirm = vi.fn()
const presentNative = vi.fn().mockResolvedValue({ kind: 'button', buttonIndex: 1 })
const target = { alert: vi.fn() }
installMobileWebAlertAdapter(target)
await act(async () => {
renderer = create(createElement(MobileWebAlertAdapter, { presentNative }))
target.alert(
'Use reset?',
'This spends one reset.',
[
{ text: 'Cancel', style: 'cancel', onPress: cancel },
{ text: 'Use reset', isPreferred: true, onPress: confirm }
],
{ cancelable: false, userInterfaceStyle: 'dark' }
)
await Promise.resolve()
})
expect(presentNative).toHaveBeenCalledWith({
title: 'Use reset?',
message: 'This spends one reset.',
buttons: [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Use reset', isPreferred: true }
],
options: { cancelable: false, userInterfaceStyle: 'dark' }
})
expect(confirm).toHaveBeenCalledOnce()
expect(cancel).not.toHaveBeenCalled()
expect(renderer.toJSON()).toBeNull()
})
it('returns native dismissals and falls back only when the capability is unavailable', async () => {
const onDismiss = vi.fn()
const target = { alert: vi.fn() }
installMobileWebAlertAdapter(target)
const presentNative = vi
.fn()
.mockResolvedValueOnce({ kind: 'dismissed' })
.mockRejectedValueOnce(new Error('unsupported_capability'))
await act(async () => {
renderer = create(createElement(MobileWebAlertAdapter, { presentNative }))
target.alert('First', undefined, undefined, { onDismiss })
await Promise.resolve()
})
expect(onDismiss).toHaveBeenCalledOnce()
await act(async () => {
target.alert('Second')
await Promise.resolve()
})
expect(renderer.root.findByType('ConfirmModal').props).toMatchObject({
title: 'Second',
confirmLabel: 'OK'
})
act(() => renderer!.root.findByType('ConfirmModal').props.onConfirm())
})
it('keeps native presentation ownership across adapter remounts', async () => {
let resolveResult!: (result: { kind: 'button'; buttonIndex: number }) => void
const presentNative = vi.fn(
() =>
new Promise<{ kind: 'button'; buttonIndex: number }>((resolve) => {
resolveResult = resolve
})
)
const pressed = vi.fn()
const target = { alert: vi.fn() }
installMobileWebAlertAdapter(target)
act(() => {
renderer = create(createElement(MobileWebAlertAdapter, { presentNative }))
target.alert('Deferred', undefined, [{ text: 'Done', onPress: pressed }])
})
act(() => renderer!.unmount())
act(() => {
renderer = create(createElement(MobileWebAlertAdapter, { presentNative }))
})
expect(presentNative).toHaveBeenCalledOnce()
await act(async () => {
resolveResult({ kind: 'button', buttonIndex: 0 })
await Promise.resolve()
})
expect(pressed).toHaveBeenCalledOnce()
expect(renderer.toJSON()).toBeNull()
})
it('dismisses an Android-cancelable fallback without pressing its first button', () => {
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Android' })
const first = vi.fn()
const second = vi.fn()
const onDismiss = vi.fn()
const target = { alert: vi.fn() }
installMobileWebAlertAdapter(target)
act(() => {
renderer = create(createElement(MobileWebAlertAdapter))
target.alert(
'Choose',
undefined,
[
{ text: 'First', onPress: first },
{ text: 'Second', onPress: second }
],
{ cancelable: true, onDismiss }
)
})
const modal = renderer.root.findByType('ConfirmModal')
expect(modal.props.dismissible).toBe(true)
act(() => modal.props.onDismiss())
expect(onDismiss).toHaveBeenCalledOnce()
expect(first).not.toHaveBeenCalled()
expect(second).not.toHaveBeenCalled()
})
it('keeps non-cancelable fallbacks modal while explicit secondary actions still work', () => {
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 Android' })
const first = vi.fn()
const second = vi.fn()
const onDismiss = vi.fn()
const target = { alert: vi.fn() }
installMobileWebAlertAdapter(target)
act(() => {
renderer = create(createElement(MobileWebAlertAdapter))
target.alert(
'Choose',
undefined,
[
{ text: 'First', onPress: first },
{ text: 'Second', onPress: second }
],
{ cancelable: false, onDismiss }
)
})
const modal = renderer.root.findByType('ConfirmModal')
expect(modal.props.dismissible).toBe(false)
act(() => modal.props.onCancel())
expect(first).toHaveBeenCalledOnce()
expect(second).not.toHaveBeenCalled()
expect(onDismiss).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,240 @@
import { useEffect, useRef, useSyncExternalStore, type ReactElement } from 'react'
import { Alert, type AlertButton, type AlertOptions } from 'react-native'
import type {
MobileWebNativeAlertPayload,
MobileWebNativeAlertResult
} from '../../../src/shared/mobile-web/native-operation-contract'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
type MobileWebAlertTarget = {
alert(title: string, message?: string, buttons?: AlertButton[], options?: AlertOptions): void
}
export type MobileWebAlertPrompt = {
id: number
title: string
message?: string
buttons: AlertButton[]
options?: AlertOptions
presentation: 'unattempted' | 'native' | 'fallback'
}
export type MobileWebAlertController = {
alert: MobileWebAlertTarget['alert']
beginNativePresentation: (id: number) => boolean
choose: (id: number, button?: AlertButton, dismissed?: boolean) => void
getSnapshot: () => MobileWebAlertPrompt | null
subscribe: (listener: () => void) => () => void
useFallbackPresentation: (id: number) => void
}
export type MobileWebNativeAlertPresenter = (
payload: MobileWebNativeAlertPayload
) => Promise<MobileWebNativeAlertResult>
const installedTargets = new WeakSet<object>()
const MOBILE_WEB_ALERT_QUEUE_LIMIT = 16
export function createMobileWebAlertController(): MobileWebAlertController {
const listeners = new Set<() => void>()
const pending: MobileWebAlertPrompt[] = []
let active: MobileWebAlertPrompt | null = null
let nextId = 1
const publish = (): void => {
for (const listener of listeners) {
listener()
}
}
const alert: MobileWebAlertTarget['alert'] = (title, message, buttons, options) => {
const prompt = {
id: nextId++,
title,
message,
buttons: buttons?.length ? buttons : [{ text: 'OK' }],
options,
presentation: 'unattempted' as const
}
if (active) {
if (pending.length >= MOBILE_WEB_ALERT_QUEUE_LIMIT - 1) {
return
}
pending.push(prompt)
return
}
active = prompt
publish()
}
const choose = (id: number, button?: AlertButton, dismissed = button === undefined): void => {
if (active?.id !== id) {
return
}
const completed = active
try {
if (button) {
button.onPress?.()
} else if (dismissed) {
completed.options?.onDismiss?.()
}
} finally {
active = pending.shift() ?? null
publish()
}
}
return {
alert,
beginNativePresentation(id) {
if (active?.id !== id || active.presentation !== 'unattempted') {
return false
}
active = { ...active, presentation: 'native' }
publish()
return true
},
choose,
getSnapshot: () => active,
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
useFallbackPresentation(id) {
if (active?.id === id && active.presentation !== 'fallback') {
active = { ...active, presentation: 'fallback' }
publish()
}
}
}
}
const hostedAlertController = createMobileWebAlertController()
export function installMobileWebAlertAdapter(target: MobileWebAlertTarget = Alert): boolean {
if (installedTargets.has(target)) {
return false
}
target.alert = hostedAlertController.alert
installedTargets.add(target)
return true
}
export function MobileWebAlertAdapter({
presentNative
}: {
presentNative?: MobileWebNativeAlertPresenter
}): ReactElement | null {
const prompt = useSyncExternalStore(
hostedAlertController.subscribe,
hostedAlertController.getSnapshot,
hostedAlertController.getSnapshot
)
const chosenPromptRef = useRef<number | null>(null)
const presenterRef = useRef(presentNative)
presenterRef.current = presentNative
useEffect(() => {
const current = hostedAlertController.getSnapshot()
if (!current || !hostedAlertController.beginNativePresentation(current.id)) {
return
}
const presenter = presenterRef.current
if (!presenter) {
hostedAlertController.useFallbackPresentation(current.id)
return
}
void presenter(nativeAlertPayload(current))
.then((result) => {
if (result.kind === 'button') {
hostedAlertController.choose(current.id, current.buttons[result.buttonIndex], false)
} else {
hostedAlertController.choose(current.id)
}
})
.catch(() => {
hostedAlertController.useFallbackPresentation(current.id)
})
}, [prompt?.id])
if (!prompt) {
return null
}
if (prompt.presentation !== 'fallback') {
return null
}
const choose = (button?: AlertButton): void => {
if (chosenPromptRef.current === prompt.id) {
return
}
chosenPromptRef.current = prompt.id
hostedAlertController.choose(prompt.id, button)
queueMicrotask(() => {
if (chosenPromptRef.current === prompt.id) {
chosenPromptRef.current = null
}
})
}
const dismissible =
prompt.options?.cancelable === true && globalThis.navigator?.userAgent.includes('Android')
if (prompt.buttons.length > 2) {
return (
<ActionSheetModal
visible
title={prompt.title}
message={prompt.message}
dismissible={dismissible}
actions={prompt.buttons.map((button) => ({
label: button.text ?? 'OK',
destructive: button.style === 'destructive',
onPress: () => choose(button)
}))}
onClose={() => choose()}
/>
)
}
const cancelButton =
prompt.buttons.length === 2
? (prompt.buttons.find((button) => button.style === 'cancel') ?? prompt.buttons[0])
: undefined
const confirmButton = prompt.buttons.find((button) => button !== cancelButton)
return (
<ConfirmModal
visible
title={prompt.title}
message={prompt.message}
cancelLabel={cancelButton?.text ?? null}
confirmLabel={confirmButton?.text ?? 'OK'}
destructive={confirmButton?.style === 'destructive'}
dismissible={dismissible}
onConfirm={() => choose(confirmButton)}
onCancel={() => choose(cancelButton)}
onDismiss={() => choose()}
/>
)
}
function nativeAlertPayload(prompt: MobileWebAlertPrompt): MobileWebNativeAlertPayload {
return {
title: prompt.title,
...(prompt.message === undefined ? {} : { message: prompt.message }),
buttons: prompt.buttons.map(({ text, style, isPreferred }) => ({
...(text === undefined ? {} : { text }),
...(style === undefined ? {} : { style }),
...(isPreferred === undefined ? {} : { isPreferred })
})),
...(prompt.options
? {
options: {
...(prompt.options.cancelable === undefined
? {}
: { cancelable: prompt.options.cancelable }),
...(prompt.options.userInterfaceStyle === undefined
? {}
: { userInterfaceStyle: prompt.options.userInterfaceStyle })
}
}
: {})
}
}
@@ -0,0 +1,181 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
dispatchMobileWebBackNavigation,
installMobileWebBackNavigationAdapter
} from './mobile-web-back-navigation-adapter'
vi.mock('react-native', () => ({ BackHandler: {} }))
afterEach(() => vi.useRealTimers())
describe('mobile web BackHandler adapter', () => {
it('restores the current entry before dispatching a handled browser back', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
const observed: string[] = []
harness.target.addEventListener('popstate', () => observed.push(harness.target.location.href))
backHandler.addEventListener('hardwareBackPress', () => true)
harness.browserBack()
expect(harness.target.location.href).toBe('https://orca.test/preview')
expect(observed).toEqual([])
})
it('lets the native callback perform one programmatic back after restoration', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
const observed: string[] = []
harness.target.addEventListener('popstate', () => observed.push(harness.target.location.href))
backHandler.addEventListener('hardwareBackPress', () => {
harness.target.history.back()
return true
})
harness.browserBack()
expect(harness.target.location.href).toBe('https://orca.test/files')
expect(observed).toEqual(['https://orca.test/files'])
})
it('dispatches the newest active handler first', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
const older = vi.fn(() => true)
const newer = vi.fn(() => true)
backHandler.addEventListener('hardwareBackPress', older)
const subscription = backHandler.addEventListener('hardwareBackPress', newer)
harness.browserBack()
expect(newer).toHaveBeenCalledOnce()
expect(older).not.toHaveBeenCalled()
subscription.remove()
harness.browserBack()
expect(older).toHaveBeenCalledOnce()
})
it('lets a dirty-route handler consume an explicit hardware back request', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
const dirtyDraftHandler = vi.fn(() => true)
backHandler.addEventListener('hardwareBackPress', dirtyDraftHandler)
expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true)
expect(dirtyDraftHandler).toHaveBeenCalledOnce()
expect(harness.target.location.href).toBe('https://orca.test/preview')
})
it('returns an unconsumed request through page history', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
backHandler.addEventListener('hardwareBackPress', () => false)
expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true)
expect(harness.target.location.href).toBe('https://orca.test/files')
})
it('returns false at the page root so the native shell can leave the route', () => {
const harness = navigationHarness()
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
expect(dispatchMobileWebBackNavigation(harness.target)).toBe(false)
expect(harness.target.location.href).toBe('https://orca.test/')
})
it('does not redispatch a delayed programmatic traversal as physical Back', () => {
vi.useFakeTimers()
const harness = navigationHarness(500)
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
const handler = vi.fn(() => false)
backHandler.addEventListener('hardwareBackPress', handler)
harness.target.history.back()
vi.advanceTimersByTime(500)
expect(harness.target.location.href).toBe('https://orca.test/files')
expect(handler).not.toHaveBeenCalled()
})
})
function backHandlerTarget() {
return {
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
removeEventListener: vi.fn()
}
}
function navigationHarness(eventDelayMs = 0) {
const events = new EventTarget()
const location = { href: 'https://orca.test/' }
let entries = [{ state: null as unknown, href: location.href }]
let index = 0
const traverse = (delta: number): void => {
const nextIndex = Math.max(0, Math.min(entries.length - 1, index + delta))
if (nextIndex === index) {
return
}
index = nextIndex
location.href = entries[index]!.href
const state = entries[index]!.state
const dispatch = () => events.dispatchEvent(new PopStateEvent('popstate', { state }))
if (eventDelayMs > 0) {
setTimeout(dispatch, eventDelayMs)
} else {
dispatch()
}
}
const history = {
get state() {
return entries[index]!.state
},
pushState(data: unknown, _unused: string, url?: string | URL | null) {
const href = url == null ? location.href : new URL(String(url), location.href).href
entries = entries.slice(0, index + 1)
entries.push({ state: data, href })
index += 1
location.href = href
},
replaceState(data: unknown, _unused: string, url?: string | URL | null) {
const href = url == null ? location.href : new URL(String(url), location.href).href
entries[index] = { state: data, href }
location.href = href
},
go(delta = 0) {
traverse(delta)
},
back() {
traverse(-1)
},
forward() {
traverse(1)
}
} as History
const target = {
history,
location,
addEventListener: events.addEventListener.bind(events)
}
return { target, browserBack: () => traverse(-1) }
}
@@ -0,0 +1,192 @@
import { BackHandler } from 'react-native'
type MobileWebBackHandlerCallback = () => boolean | null | undefined
type MobileWebBackHandlerTarget = {
addEventListener(
eventName: 'hardwareBackPress',
handler: MobileWebBackHandlerCallback
): { remove(): void }
removeEventListener?: (
eventName: 'hardwareBackPress',
handler: MobileWebBackHandlerCallback
) => void
}
type MobileWebNavigationTarget = {
history: History
location: Pick<Location, 'href'>
addEventListener(type: 'popstate', listener: (event: PopStateEvent) => void): void
}
const HISTORY_INDEX_KEY = '__orcaMobileWebBackIndex'
const PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS = 5_000
const installedHistories = new WeakMap<object, () => boolean>()
export function installMobileWebBackNavigationAdapter(
backHandler: MobileWebBackHandlerTarget = BackHandler,
target: MobileWebNavigationTarget = window
): boolean {
const { history } = target
if (installedHistories.has(history)) {
return false
}
const handlers: MobileWebBackHandlerCallback[] = []
const originalPushState = history.pushState.bind(history)
const originalReplaceState = history.replaceState.bind(history)
const originalGo = history.go.bind(history)
const originalBack = history.back.bind(history)
const originalForward = history.forward.bind(history)
let currentIndex = historyIndex(history.state) ?? 0
let maximumIndex = currentIndex
let expectedProgrammaticIndex: number | null = null
let programmaticReset: ReturnType<typeof setTimeout> | undefined
let restoringFromIndex: number | null = null
originalReplaceState(indexedHistoryState(history.state, currentIndex), '', undefined)
const markProgrammatic = (targetIndex: number): void => {
expectedProgrammaticIndex = targetIndex
clearTimeout(programmaticReset)
programmaticReset = setTimeout(() => {
expectedProgrammaticIndex = null
}, PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS)
}
history.pushState = (data, unused, url) => {
currentIndex += 1
maximumIndex = currentIndex
originalPushState(indexedHistoryState(data, currentIndex), unused, url)
}
history.replaceState = (data, unused, url) => {
originalReplaceState(indexedHistoryState(data, currentIndex), unused, url)
}
history.go = (delta = 0) => {
const targetIndex = Math.max(0, Math.min(maximumIndex, currentIndex + delta))
if (targetIndex !== currentIndex) {
markProgrammatic(targetIndex)
}
originalGo(delta)
}
history.back = () => {
if (currentIndex > 0) {
markProgrammatic(currentIndex - 1)
}
originalBack()
}
history.forward = () => {
if (currentIndex < maximumIndex) {
markProgrammatic(currentIndex + 1)
}
originalForward()
}
backHandler.addEventListener = (_eventName, handler) => {
handlers.push(handler)
let active = true
return {
remove() {
if (!active) {
return
}
active = false
removeBackHandler(handlers, handler)
}
}
}
backHandler.removeEventListener = (_eventName, handler) => {
removeBackHandler(handlers, handler)
}
target.addEventListener('popstate', (event) => {
const nextIndex = historyIndex(event.state)
if (nextIndex === null) {
return
}
if (restoringFromIndex !== null) {
event.stopImmediatePropagation()
restoringFromIndex = null
currentIndex = nextIndex
if (!dispatchBackHandlers(handlers)) {
history.back()
}
return
}
const direction = Math.sign(nextIndex - currentIndex)
if (nextIndex === expectedProgrammaticIndex) {
expectedProgrammaticIndex = null
clearTimeout(programmaticReset)
currentIndex = nextIndex
return
}
if (direction >= 0 || handlers.length === 0) {
currentIndex = nextIndex
return
}
event.stopImmediatePropagation()
restoringFromIndex = nextIndex
originalGo(currentIndex - nextIndex)
})
installedHistories.set(history, () => {
if (dispatchBackHandlers(handlers)) {
return true
}
if (currentIndex === 0) {
return false
}
history.back()
return true
})
return true
}
export function dispatchMobileWebBackNavigation(
target: MobileWebNavigationTarget = window
): boolean {
return installedHistories.get(target.history)?.() === true
}
function dispatchBackHandlers(handlers: MobileWebBackHandlerCallback[]): boolean {
for (let index = handlers.length - 1; index >= 0; index -= 1) {
const handler = handlers[index]
if (!handler) {
continue
}
try {
if (handler() === true) {
return true
}
} catch {
continue
}
}
return false
}
function removeBackHandler(
handlers: MobileWebBackHandlerCallback[],
target: MobileWebBackHandlerCallback
): void {
const index = handlers.lastIndexOf(target)
if (index !== -1) {
handlers.splice(index, 1)
}
}
function indexedHistoryState(state: unknown, index: number): Record<string, unknown> {
return isRecord(state) ? { ...state, [HISTORY_INDEX_KEY]: index } : { [HISTORY_INDEX_KEY]: index }
}
function historyIndex(state: unknown): number | null {
if (!isRecord(state)) {
return null
}
const value = state[HISTORY_INDEX_KEY]
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -77,6 +77,7 @@ export function createMobileWebBridgeRoundtripFixture(options: {
function defaultNativeAuthority(): MobileWebNativeCapabilityAuthority {
return {
alert: async () => ({ kind: 'dismissed' }),
hapticFeedback: () => {},
clipboardAvailability: async () => ({ hasText: false, hasImage: false }),
clipboardWrite: async () => ({ confirmation: 'in-app' }),
@@ -0,0 +1,47 @@
import type { RefObject } from 'react'
import type { MobileWebShellViewRef } from '@orca/expo-mobile-web-shell'
import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract'
import type { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import {
completeMobileWebNativeRouteHandoffAfterResponse,
type MobileWebNativeRouteHandoff
} from './mobile-web-native-route-handoff'
export async function handleMobileWebBrokerMessage(args: {
message: MobileWebBridgePageMessage
brokerRef: RefObject<MobileWebCapabilityBroker | null>
activeSessionIdRef: RefObject<string | undefined>
sessionId: string
viewRef: RefObject<MobileWebShellViewRef | null>
routeHandoff: MobileWebNativeRouteHandoff
setHostedViewActive: (active: boolean) => void
navigateToTerminalSettings: () => void
onNavigationFailure: () => void
}): Promise<void> {
const broker = args.brokerRef.current
await broker?.handle(args.message)
if (
args.message.type !== 'request' ||
!broker ||
args.brokerRef.current !== broker ||
args.activeSessionIdRef.current !== args.sessionId
) {
return
}
completeMobileWebNativeRouteHandoffAfterResponse({
handoff: args.routeHandoff,
requestId: args.message.requestId,
shouldNavigate: () =>
args.brokerRef.current === broker && args.activeSessionIdRef.current === args.sessionId,
deactivateSessionView: async () => {
const view = args.viewRef.current
if (!view) {
throw new Error('mobile_web_session_view_unavailable')
}
await view.deactivateSessionView()
},
setHostedViewActive: args.setHostedViewActive,
navigate: args.navigateToTerminalSettings,
onFailure: args.onNavigationFailure
})
}
@@ -0,0 +1,13 @@
export type MobileWebCachedBuildProbe = {
hostEpoch: number
promise: Promise<string | null>
resolve: (buildId: string | null) => void
}
export function createMobileWebCachedBuildProbe(hostEpoch: number): MobileWebCachedBuildProbe {
let resolvePromise: (buildId: string | null) => void = () => {}
const promise = new Promise<string | null>((resolve) => {
resolvePromise = resolve
})
return { hostEpoch, promise, resolve: resolvePromise }
}
@@ -3,6 +3,7 @@ import { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-autho
import { MobileWebAgentHistoryPager } from './mobile-web-agent-history-pager'
import { MobileWebAgentHistoryResume } from './mobile-web-agent-history-resume'
import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority'
import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager'
import { MobileWebTerminalArtifactAuthority } from './mobile-web-terminal-artifact-authority'
import { MobileWebTaskTargetAuthority } from './mobile-web-task-target-authority'
import { MobileWebTaskProjectTablePager } from './mobile-web-task-project-table-pager'
@@ -15,6 +16,7 @@ export class MobileWebCapabilityAuthorities {
readonly agentHistoryResume: MobileWebAgentHistoryResume
readonly browser: MobileWebBrowserAuthority
readonly nativeChat: MobileWebNativeChatAuthority
readonly sourceControlBranchCompare: MobileWebSourceControlBranchComparePager
readonly terminalArtifact: MobileWebTerminalArtifactAuthority
readonly taskTarget: MobileWebTaskTargetAuthority
readonly taskProjectTable: MobileWebTaskProjectTablePager
@@ -27,6 +29,7 @@ export class MobileWebCapabilityAuthorities {
this.agentHistoryResume = new MobileWebAgentHistoryResume(options.randomBytes)
this.browser = new MobileWebBrowserAuthority(options.randomBytes)
this.nativeChat = new MobileWebNativeChatAuthority(options.randomBytes)
this.sourceControlBranchCompare = new MobileWebSourceControlBranchComparePager()
this.terminalArtifact = new MobileWebTerminalArtifactAuthority(options)
this.taskTarget = new MobileWebTaskTargetAuthority(options.randomBytes)
this.taskProjectTable = new MobileWebTaskProjectTablePager(options.randomBytes)
@@ -40,6 +43,7 @@ export class MobileWebCapabilityAuthorities {
this.agentHistoryResume.clear()
this.browser.clear()
this.nativeChat.clear()
this.sourceControlBranchCompare.clear()
this.terminalArtifact.clear()
this.taskTarget.clear()
this.taskProjectTable.clear()
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest'
import {
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
type MobileWebBridgePageMessage,
type MobileWebBridgeShellMessage
} from '../../../src/shared/mobile-web/bridge-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
const CONTEXT = { shellSessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) }
const WORKSPACE_ID = `workspace_0_${'01'.repeat(16)}`
describe('mobile web branch-compare rate accounting', () => {
it('exempts only the exact next page after a charged snapshot request', async () => {
const harness = createHarness()
await harness.broker.handle(workspaceSnapshotRequest())
for (let index = 0; index < 8; index += 1) {
await harness.broker.handle(branchCompareRequest(String(index).repeat(22), firstPayload()))
}
const firstPage = successPayload(harness.messages.at(-1))
const continuation = continuationPayload(firstPage)
await harness.broker.handle(branchCompareRequest('C'.repeat(22), continuation))
expect(harness.messages.at(-1)).toMatchObject({
status: 'success',
payload: { offset: 128, nextOffset: null }
})
expect(gitCompareCalls(harness.sendRequest)).toHaveLength(8)
await harness.broker.handle(branchCompareRequest('R'.repeat(22), continuation))
expect(harness.messages.at(-1)).toMatchObject({
status: 'error',
error: { code: 'rate_limited', retryable: true }
})
expect(gitCompareCalls(harness.sendRequest)).toHaveLength(8)
})
})
function createHarness() {
const messages: MobileWebBridgeShellMessage[] = []
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (method) => {
if (method === 'worktree.ps') {
return {
ok: true,
result: { worktrees: [{ worktreeId: 'workspace-1', repoId: 'repo-1' }] }
}
}
if (method === 'git.branchCompare') {
return { ok: true, result: compareResult() }
}
return { ok: false, error: { code: 'method_not_found', message: 'unsupported' } }
})
const client = { sendRequest, subscribe: vi.fn() } as unknown as RpcClient
const broker = new MobileWebCapabilityBroker({
context: CONTEXT,
getClient: () => client,
isConnected: () => true,
isActive: () => true,
postMessage: (message) => messages.push(message),
nativeAuthority: {
hapticFeedback: vi.fn(),
clipboardWrite: vi.fn(),
openExternal: vi.fn(),
terminalPreferences: vi.fn(),
terminalTextScaleUpdate: vi.fn()
},
terminalClientId: 'device-token',
randomBytes: (length) => new Uint8Array(length).fill(1),
now: () => 1_000
})
return { broker, messages, sendRequest }
}
function workspaceSnapshotRequest(): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
return request('P'.repeat(22), 'workspace', 'snapshot', { limit: 1 })
}
function branchCompareRequest(
requestId: string,
payload: Record<string, unknown>
): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
return request(requestId, 'sourceControl', 'branchCompare', payload)
}
function request(
requestId: string,
capability: string,
operation: string,
payload: unknown
): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
return {
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
type: 'request',
mode: 'once',
...CONTEXT,
requestId,
capability,
operation,
payload
}
}
function firstPayload() {
return { workspaceId: WORKSPACE_ID, baseRef: 'main', offset: 0, limit: 128 }
}
function continuationPayload(value: unknown): Record<string, unknown> {
if (!isRecord(value) || typeof value.revision !== 'string' || value.nextOffset !== 128) {
throw new Error('Expected a branch-compare continuation')
}
return { ...firstPayload(), offset: value.nextOffset, expectedRevision: value.revision }
}
function compareResult() {
return {
summary: {
baseOid: 'a'.repeat(40),
compareRef: 'feature/mobile',
headOid: 'b'.repeat(40),
mergeBase: 'a'.repeat(40),
changedFiles: 129,
status: 'ready'
},
entries: Array.from({ length: 129 }, (_, index) => ({
path: `src/file-${index}.ts`,
status: 'modified'
}))
}
}
function successPayload(message: MobileWebBridgeShellMessage | undefined): unknown {
if (message?.type !== 'response' || message.status !== 'success') {
throw new Error(`Expected a successful response: ${JSON.stringify(message)}`)
}
return message.payload
}
function gitCompareCalls(sendRequest: ReturnType<typeof vi.fn<RpcClient['sendRequest']>>) {
return sendRequest.mock.calls.filter(([method]) => method === 'git.branchCompare')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -6,6 +6,7 @@ import {
} from '../../../src/shared/mobile-web/bridge-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
const CONTEXT = {
shellSessionId: 'S'.repeat(43),
@@ -76,9 +77,55 @@ describe('mobile web capability broker races', () => {
error: { code: 'invalid_request', retryable: false }
})
})
it('keeps a shell-owned native alert alive across Desktop client replacement', async () => {
const alertResult = deferredAlertResult()
const alert = vi.fn<NonNullable<MobileWebNativeCapabilityAuthority['alert']>>()
alert.mockReturnValue(alertResult.promise)
const harness = await createPrimedHarness(alert)
const pending = harness.broker.handle(nativeAlertRequest())
await vi.waitFor(() => expect(alert).toHaveBeenCalledOnce())
harness.broker.replaceClient(null)
alertResult.resolve({ kind: 'button', buttonIndex: 1 })
await pending
expect(responseFor(harness.messages, 'A')).toEqual([
expect.objectContaining({
status: 'success',
payload: { kind: 'button', buttonIndex: 1 }
})
])
})
it('keeps an OS alert authoritative after page cancellation', async () => {
const alertResult = deferredAlertResult()
const alert = vi.fn<NonNullable<MobileWebNativeCapabilityAuthority['alert']>>()
alert.mockReturnValue(alertResult.promise)
const harness = await createPrimedHarness(alert)
const pending = harness.broker.handle(nativeAlertRequest())
await vi.waitFor(() => expect(alert).toHaveBeenCalledOnce())
await harness.broker.handle(requestCancel('A'))
await harness.broker.handle(nativeAlertRequest('B'))
expect(alert).toHaveBeenCalledOnce()
expect(responseFor(harness.messages, 'B')).toEqual([
expect.objectContaining({
status: 'error',
error: { code: 'rate_limited', retryable: true }
})
])
alertResult.resolve({ kind: 'button', buttonIndex: 0 })
await pending
expect(responseFor(harness.messages, 'A')).toEqual([
expect.objectContaining({ status: 'success' })
])
})
})
async function createPrimedHarness() {
async function createPrimedHarness(alert?: MobileWebNativeCapabilityAuthority['alert']) {
const messages: MobileWebBridgeShellMessage[] = []
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const subscribe = vi.fn<RpcClient['subscribe']>()
@@ -94,6 +141,7 @@ async function createPrimedHarness() {
isActive: () => true,
postMessage: (message) => messages.push(message),
nativeAuthority: {
alert,
hapticFeedback: vi.fn(),
clipboardWrite: vi.fn(),
openExternal: vi.fn(),
@@ -111,6 +159,34 @@ async function createPrimedHarness() {
return { broker, messages, sendRequest, subscribe }
}
function nativeAlertRequest(id = 'A'): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
return {
...envelope(),
type: 'request',
mode: 'once',
requestId: id.repeat(22),
capability: 'native',
operation: 'alert',
payload: {
title: 'Discard changes?',
message: 'Unsaved edits will be lost.',
buttons: [
{ text: 'Stay', style: 'cancel' },
{ text: 'Discard', style: 'destructive' }
]
}
}
}
function requestCancel(id: string): Extract<MobileWebBridgePageMessage, { type: 'cancel' }> {
return {
...envelope(),
type: 'cancel',
target: 'request',
id: id.repeat(22)
}
}
function workspaceSnapshotRequest(): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
return {
...envelope(),
@@ -167,6 +243,14 @@ function deferredHostResult() {
return { promise, resolve }
}
function deferredAlertResult() {
let resolve = (_value: { kind: 'button'; buttonIndex: number }): void => {}
const promise = new Promise<{ kind: 'button'; buttonIndex: number }>((done) => {
resolve = done
})
return { promise, resolve }
}
function terminalTabsResult() {
return {
ok: true as const,
@@ -35,7 +35,6 @@ import {
type PageRequest = Extract<MobileWebBridgePageMessage, { type: 'request' }>
type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled: boolean }
export { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
export class MobileWebCapabilityBroker {
private readonly pending = new Map<string, PendingRequest>()
private readonly replay = new MobileWebBrokerReplayGuard()
@@ -108,6 +107,9 @@ export class MobileWebCapabilityBroker {
this.terminalStreams.dispose(null)
this.speechAuthority.replaceClient()
for (const [requestId, pending] of this.pending) {
if (pending.operationKey === 'native.alert') {
continue
}
pending.cancelled = true
this.pending.delete(requestId)
void this.messages.error(requestId, 'cancelled', false)
@@ -179,9 +181,12 @@ export class MobileWebCapabilityBroker {
await this.messages.error(request.requestId, 'rate_limited', true)
return
}
const branchCompareContinuation =
this.authorities.sourceControlBranchCompare.claimRequestContinuation(request)
if (
!mobileWebWorkspaceSnapshotContinuation(request) &&
!mobileWebAgentHistoryContinuation(request) &&
!branchCompareContinuation &&
!this.rateLimiter.take(mobileWebOperationKey(request), grant)
) {
await this.messages.error(request.requestId, 'rate_limited', true)
@@ -234,6 +239,7 @@ export class MobileWebCapabilityBroker {
nativeChatSubscriptions: this.subscriptions.nativeChat,
sessionSubscriptions: this.subscriptions.session,
sourceControlSubscriptions: this.subscriptions.sourceControl,
sourceControlBranchCompare: this.authorities.sourceControlBranchCompare,
speechAuthority: this.speechAuthority,
postSpeechEvent: (subscriptionId, sequence, event) =>
this.messages.event(subscriptionId, sequence, event),
@@ -282,6 +288,9 @@ export class MobileWebCapabilityBroker {
if (!pending) {
return
}
if (pending.operationKey === 'native.alert') {
return
}
pending.cancelled = true
this.pending.delete(id)
await this.commitMessageGeneration.cancelByRequest(id)

Some files were not shown because too many files have changed in this diff Show More