mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(mobile): complete hybrid parity hardening
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user