mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(mobile): hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) (#21435)
* refactor(mobile): say whether a host status was readable, and carry its protocol window `useHostStatusGates` settled the same closed gates for a host that answered `status.get` with no capabilities and for one whose status nobody could read: both paths produced an empty capability list and an `ok` verdict. A caller that walls on a missing capability cannot tell those apart, and the mobile web bundle's wall is terminal, so it must never fire for the second. `statusReadable` distinguishes them. `hostProtocolWindow` exposes the two protocol numbers the hook already read for `evaluateCompat`, as the reply's own fields, so the bundle wall can evaluate its own window without a second `status.get`. Both are additive; no existing consumer changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): add the hybrid shell flag and the generation path both loaders demand `orca:mobileWebShellEnabled`, default off and unreadable-is-off, in the same shape as the terminal autocomplete flag. `generationDirectoryPath` converts the store's `file://` uri to the absolute path the native shell view requires: both `MobileWebShellGeneration.load` implementations refuse anything without a leading slash, and `expo-file-system` only ever hands out uris. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell session as a pure reducer Every decision the route makes, as `(session, event) -> (state, effects)`: the capability wall, the lazy sweep and cache read, the manifest check, the cached build-id hit that skips paging, the offline open with no compat check, and the three recovery rules the native shell view's contract states. Pure, so the rules are table tests rather than a simulator run. Two latches sit beside the state because both outlive it: `retriedOnce` spans the delete and refetch that returns to `checking`, and `remountedOnce` spans a `ready` replaced by a `ready` under a new session id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the hybrid shell route, dark behind a dev-only flag (OTA phase B, 4/4) Wires the four Phase B and A pieces together and adds no decision of its own. `h/[hostId]/web` sits inside the existing `HostProtocolGate` tree, so the native `desktop-too-old` wall still applies above it. With the flag off — every store build, since the only writer is a `__DEV__` Troubleshoot toggle — the route redirects to `h/[hostId]` and the screen is never constructed, so nothing is fetched, written or swept. The runner owns only the impure edges and checks an epoch before every dispatch, so an unmount, a host change or a retry abandons work in flight and aborts a download that would otherwise hold four of the host's read slots. The native view is keyed on the session id, which is what makes the reducer's remount a rebuilt WebView with every fence reinstalled. A census test pins who touches the flag: the route reads it, the developer row reads and writes it, and the key itself lives in one module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the hoisted test doubles instead of asserting them The changed-code casting gate refuses `as` in new code, and these three were only widening an empty literal. An annotated `vi.hoisted` factory does the same job under a check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a scheduled reconnect as an unreachable host, not a dial in progress Found on a simulator with the paired desktop stopped: the client never settles on `disconnected`. It dials, fails, schedules a retry, and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a minute. Mapping `reconnecting` to "still connecting" left a phone holding a verified cached generation on `checking` forever instead of opening it, which is the one case the offline rule exists for. `connecting` and `handshaking` are the first dial and still wait; everything else is unreachable. The mapping moves next to the reducer it feeds, because it is a decision and the runner is supposed to hold none. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build the reachability stub instead of asserting it Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): move the shell session vocabulary into its own module Pure move, no behaviour: the states, events, effects and gates the reducer and its runner share now sit beside the reducer rather than inside it, so the transition rules have room to grow under the file's line budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop a shell effect result whose flow has been superseded Every restart of the flow bumps a number the effects of that run are stamped with, and a result echoes it back: a manifest read still in flight when the socket drops used to reject after the offline path had already opened the cached generation, replacing a displayed workspace with a download failure, and a status refetch arriving mid-check used to run the cache read and the download twice. The gates restart no longer clears the remount latch either; only the retry button does, so a reconnect cannot grant a second remount. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census the flag across modules, not just src and app The native view tree was outside the scan, so a reader added there would have passed an assertion that reads as exhaustive. Proven by adding one to the shell view module: the census fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let only the state that mounted the view hear the view A native batch reports two failures in a row, and the reducer applied both: document-load-failed started the delete-and-refetch, render-process-gone then made it terminal without a new flow, and the cache read the recovery had already asked for dragged the session back to checking behind a failure screen. A report arriving outside `ready` is from a view that is no longer on screen, so it changes nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say a host status could not be read instead of spinning on it A transient status.get failure settles the gate unreadable and nothing probes it again, so the route sat on "Checking host" for as long as anyone left it there and Try again re-read the same settled answer. It now says what happened and offers no retry, and a status that does become readable picks the flow back up on its own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restart the flow on the verdict that changed, not on every gates object A reconnect cycle rebuilds the gates several times a second with the same answer in them, and each one re-swept the staging tree and flipped an offline screen to a spinner and back. Only a changed verdict restarts now, which is also why the gates effect has to depend on the host id: two hosts whose gates read identically would otherwise leave the second session in `checking`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover what only the shell runner can get wrong Three cancellations had no test: the epoch that stops a result reaching a session that is gone, the unmount cleanup that aborts the download, and the retry that does both before starting over. Each is now red under its own mutant. The download also re-checks the abort before it writes, since an abort landing between the fetch's last read and the commit would otherwise still put a generation on disk for a screen nobody is on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the shell runner's refs after the commit, not during render React can replay or discard a render, so a handle written during one can run effects for a session that never existed. The client and the host cache key stop being refs at all; the effect handle is committed in an effect above every effect that dispatches. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the hybrid shell flag unreadable outside a development build Development and release share a bundle id, and the iOS data container survives an install-over, so a flag a developer toggled on would follow the store build in and mount the shell on a deep link. The release read never reaches storage. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the route's flag read as each build kind reads it The route test exercises the real preference read, so it has to say which build it is. A store build whose container kept a development toggle redirects. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let a cache read that lands mid-dial wait for the compat check A connection still being made is not a host that cannot be reached. Opening the cached generation there skips the compat check the landing connection is what makes answerable, so only `unreachable` takes the offline path now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): move the developer toggle only after its write lands The route reads the flag back from storage, so a switch that moved on the tap let the open button race the value that was being persisted. The switch and the button both stay put until the write settles, and a failed write keeps the previous position. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): say which build kind a test runs as without asserting on globalThis Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): undo a staged generation the abort reached before the commit The commit is the write staging cannot take back: it renames into the active slot and moves the host index. An abort landing while the bytes were being staged now removes the staged tree instead of activating it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the cached workspace when the link, not the bundle, cut a read short An RPC rejection can reach the reducer before the reachability change does, so the offline gate never fires and a phone holding a valid generation reads that the workspace could not be downloaded. A read that failed on the link now opens what is on disk, the same path offline takes; a verdict about the bundle, from the host or from the bytes, still fails. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send a hybrid shell recovery through the same gate a start takes A view failure deleted the host cache and went straight back to the manifest check on whatever gates the ready session happened to be holding. Gates that arrive while a generation is on screen are stored without restarting, so after a reconnect whose status probe failed a ready session carried statusReadable false and an empty capability list, and the recovery's manifest check walled the host as bundle-unavailable: terminal, no retry, about a host that never answered. The gate is now one verdict both entries read, and recovery passes its delete through it, so an unreadable status lands on the status-unreadable message that re-arms when a readable gate arrives, and only a readable refusal still walls. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { Redirect, useLocalSearchParams } from 'expo-router'
|
||||
import { MobileWebShellScreen } from '../../../src/mobile-web-shell/MobileWebShellScreen'
|
||||
import { loadMobileWebShellEnabled } from '../../../src/storage/preferences'
|
||||
import { colors } from '../../../src/theme/mobile-theme'
|
||||
|
||||
/**
|
||||
* The hybrid shell route, dark behind a development-only flag.
|
||||
*
|
||||
* The only caller of `loadMobileWebShellEnabled`. With the flag off — which is every store build,
|
||||
* since the only writer is the `__DEV__` Troubleshoot toggle — this redirects and the screen is
|
||||
* never constructed, so nothing is fetched, written or swept. It sits under `app/h/[hostId]` so
|
||||
* `HostProtocolGate` in that group's layout still owns the `desktop-too-old` wall above it.
|
||||
*
|
||||
* Reachable by deep link and from the developer row only; no screen links here.
|
||||
*/
|
||||
export default function MobileWebShellRoute() {
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
void loadMobileWebShellEnabled().then((value) => {
|
||||
if (!stale) {
|
||||
setEnabled(value)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (enabled === null) {
|
||||
// A redirect fired before the read settles would bounce a flag that is on, and a screen mounted
|
||||
// before it settles would fetch on a flag that is off. Neither, until it is known.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Checking host" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (!enabled || !hostId) {
|
||||
return <Redirect href={`/h/${hostId ?? ''}`} />
|
||||
}
|
||||
return <MobileWebShellScreen hostId={hostId} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -54,6 +54,8 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
||||
/>
|
||||
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Changes' }} />
|
||||
<Stack.Screen name="[hostId]/pr/[worktreeId]" options={{ title: 'Pull Request' }} />
|
||||
{/* Dev-flag only: redirects to the host screen unless the hybrid shell flag is on. */}
|
||||
<Stack.Screen name="[hostId]/web" options={{ title: 'Workspace' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from 'expo-router'
|
||||
import { MobileWebBundleProbeRow } from '../src/diagnostics/mobile-web-bundle-probe-row'
|
||||
import { MobileWebShellDevRow } from '../src/diagnostics/mobile-web-shell-dev-row'
|
||||
import { TroubleshootView } from '../src/diagnostics/troubleshoot-view'
|
||||
import { useTroubleshootDiagnostics } from '../src/diagnostics/use-troubleshoot-diagnostics'
|
||||
|
||||
@@ -21,7 +22,14 @@ export default function NativeTroubleshootRoute() {
|
||||
runDiagnostics={() => void runDiagnostics()}
|
||||
onBack={() => router.back()}
|
||||
onConnectionLog={() => router.push('/connection-log')}
|
||||
developerRow={isDevelopmentBuild ? <MobileWebBundleProbeRow /> : null}
|
||||
developerRow={
|
||||
isDevelopmentBuild ? (
|
||||
<>
|
||||
<MobileWebBundleProbeRow />
|
||||
<MobileWebShellDevRow />
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* The developer toggle is the only writer of the hybrid shell flag, and the route it opens reads
|
||||
* that flag back from storage rather than from this screen. So what the switch shows and what the
|
||||
* open button permits must both follow the write, not the tap.
|
||||
*/
|
||||
type Doubles = {
|
||||
stored: boolean
|
||||
saves: { next: boolean; settle: () => void; fail: () => void }[]
|
||||
pushes: string[]
|
||||
}
|
||||
|
||||
const doubles = vi.hoisted((): Doubles => ({ stored: false, saves: [], pushes: [] }))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
Switch: 'Switch',
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: (href: string) => {
|
||||
doubles.pushes.push(href)
|
||||
}
|
||||
})
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({ LayoutTemplate: 'LayoutTemplate' }))
|
||||
vi.mock('../transport/host-store', () => ({ loadHosts: async () => [{ id: 'host-1' }] }))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadMobileWebShellEnabled: async () => doubles.stored,
|
||||
saveMobileWebShellEnabled: (next: boolean) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
doubles.saves.push({
|
||||
next,
|
||||
settle: () => {
|
||||
doubles.stored = next
|
||||
resolve()
|
||||
},
|
||||
fail: () => reject(new Error('storage unavailable'))
|
||||
})
|
||||
})
|
||||
}))
|
||||
vi.mock('./troubleshoot-screen-styles', () => ({ troubleshootScreenStyles: {} }))
|
||||
|
||||
import { MobileWebShellDevRow } from './mobile-web-shell-dev-row'
|
||||
|
||||
function only(tree: ReactTestRenderer, testID: string): ReactTestInstance {
|
||||
const found = tree.root.findAll((node) => node.props.testID === testID)
|
||||
const node = found[0]
|
||||
if (node === undefined || found.length !== 1) {
|
||||
throw new Error(`expected one ${testID}, found ${found.length}`)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
async function mountRow(): Promise<ReactTestRenderer> {
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(MobileWebShellDevRow))
|
||||
})
|
||||
const tree = rendered.tree
|
||||
if (tree === null) {
|
||||
throw new Error('the row did not mount')
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
async function toggle(tree: ReactTestRenderer, next: boolean): Promise<void> {
|
||||
await act(async () => {
|
||||
only(tree, 'mobile-web-shell-flag').props.onValueChange(next)
|
||||
})
|
||||
}
|
||||
|
||||
describe('the hybrid shell developer row', () => {
|
||||
beforeEach(() => {
|
||||
doubles.stored = false
|
||||
doubles.saves.length = 0
|
||||
doubles.pushes.length = 0
|
||||
})
|
||||
|
||||
it('offers neither the new position nor the route until the write lands', async () => {
|
||||
const tree = await mountRow()
|
||||
await toggle(tree, true)
|
||||
|
||||
expect(doubles.saves).toHaveLength(1)
|
||||
expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false)
|
||||
expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(true)
|
||||
expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
doubles.saves[0]?.settle()
|
||||
})
|
||||
expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(true)
|
||||
expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the open button shut while a write that turns the flag off is still in flight', async () => {
|
||||
doubles.stored = true
|
||||
const tree = await mountRow()
|
||||
expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(false)
|
||||
|
||||
await toggle(tree, false)
|
||||
expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves the switch where storage still is when the write fails', async () => {
|
||||
const tree = await mountRow()
|
||||
await toggle(tree, true)
|
||||
await act(async () => {
|
||||
doubles.saves[0]?.fail()
|
||||
})
|
||||
|
||||
expect(only(tree, 'mobile-web-shell-flag').props.value).toBe(false)
|
||||
expect(only(tree, 'mobile-web-shell-flag').props.disabled).toBe(false)
|
||||
expect(only(tree, 'mobile-web-shell-open').props.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Pressable, Switch, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { LayoutTemplate } from 'lucide-react-native'
|
||||
import { loadHosts } from '../transport/host-store'
|
||||
import { loadMobileWebShellEnabled, saveMobileWebShellEnabled } from '../storage/preferences'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { troubleshootScreenStyles as styles } from './troubleshoot-screen-styles'
|
||||
|
||||
/**
|
||||
* Development-only: the one caller of `saveMobileWebShellEnabled`, and the one way into the hybrid
|
||||
* shell route that is not a deep link.
|
||||
*
|
||||
* `app/troubleshoot.tsx` mounts it behind `__DEV__`, exactly as it mounts A5's probe row, so a
|
||||
* shipped build never renders the toggle and the flag it guards can only stay off. The route itself
|
||||
* reads the flag again rather than trusting this screen, because a deep link arrives without it.
|
||||
*/
|
||||
export function MobileWebShellDevRow() {
|
||||
const router = useRouter()
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [hostId, setHostId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
void Promise.all([loadMobileWebShellEnabled(), loadHosts()]).then(([flag, hosts]) => {
|
||||
if (!stale) {
|
||||
setEnabled(flag)
|
||||
setHostId(hosts[0]?.id ?? null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Not while a write is in flight: the route reads the key back from storage, so a button that
|
||||
// opened on the switch's position would mount a shell the persisted flag does not permit yet.
|
||||
const openable = enabled === true && hostId !== null && !saving
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.checkRow}>
|
||||
<Text style={styles.checkLabel}>Hybrid shell (dev)</Text>
|
||||
<Switch
|
||||
testID="mobile-web-shell-flag"
|
||||
value={enabled === true}
|
||||
disabled={enabled === null || saving}
|
||||
onValueChange={(next) => {
|
||||
setSaving(true)
|
||||
void saveMobileWebShellEnabled(next)
|
||||
.then(() => {
|
||||
setEnabled(next)
|
||||
})
|
||||
// A write that never landed leaves the previous position showing, because that is
|
||||
// still what the route will read.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
setSaving(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diagnosticButton,
|
||||
pressed && styles.diagnosticButtonPressed,
|
||||
!openable && styles.diagnosticButtonDisabled
|
||||
]}
|
||||
testID="mobile-web-shell-open"
|
||||
disabled={!openable}
|
||||
onPress={() => {
|
||||
if (hostId !== null) {
|
||||
router.push(`/h/${hostId}/web`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LayoutTemplate size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>
|
||||
Open hybrid shell for the first paired host
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
|
||||
|
||||
type ScreenDependencies = {
|
||||
retry: Mock
|
||||
reportShellFailure: Mock
|
||||
openUrl: Mock
|
||||
lifecycle: string[]
|
||||
state: MobileWebShellSessionState
|
||||
}
|
||||
|
||||
const dependencies = vi.hoisted((): ScreenDependencies => {
|
||||
// Before the module under test is imported, so its `__DEV__` guard is on and the developer facts
|
||||
// are reachable at all — they are the one thing here that must never grow a secret.
|
||||
Object.assign(globalThis, { __DEV__: true })
|
||||
return {
|
||||
retry: vi.fn(),
|
||||
reportShellFailure: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
lifecycle: [],
|
||||
state: { kind: 'checking' }
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Linking: { openURL: dependencies.openUrl },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ router: { replace: vi.fn() } }))
|
||||
// A component rather than a host string: the React key is what makes a retry a rebuilt WebView,
|
||||
// and a mount/unmount log is the only thing that can tell a remount from a prop update.
|
||||
vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
|
||||
const React = await import('react')
|
||||
const loadState = await import('../../modules/orca-mobile-web-shell/src/load-state')
|
||||
return {
|
||||
OrcaMobileWebShellView: (props: { sessionId: string }) => {
|
||||
React.useEffect(() => {
|
||||
dependencies.lifecycle.push(`mount:${props.sessionId}`)
|
||||
return () => {
|
||||
dependencies.lifecycle.push(`unmount:${props.sessionId}`)
|
||||
}
|
||||
}, [props.sessionId])
|
||||
return React.createElement('ShellViewProbe', props)
|
||||
},
|
||||
parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState
|
||||
}
|
||||
})
|
||||
vi.mock('./use-mobile-web-shell-session', () => ({
|
||||
useMobileWebShellSession: () => ({
|
||||
state: dependencies.state,
|
||||
retry: dependencies.retry,
|
||||
reportShellFailure: dependencies.reportShellFailure
|
||||
})
|
||||
}))
|
||||
|
||||
import { MobileWebShellScreen } from './MobileWebShellScreen'
|
||||
|
||||
const BUILD_ID = 'a1b2c3d4e5f6'.repeat(5) + 'abcd'
|
||||
const DIRECTORY = '/var/mobile/Containers/Data/Caches/mobile-web/deadbeef/generations/a1b2'
|
||||
|
||||
async function render(state: MobileWebShellSessionState): Promise<ReactTestRenderer> {
|
||||
dependencies.state = state
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(MobileWebShellScreen, { hostId: 'host-1' }))
|
||||
})
|
||||
if (rendered.tree === null) {
|
||||
throw new Error('screen did not render')
|
||||
}
|
||||
return rendered.tree
|
||||
}
|
||||
|
||||
function readyState(sessionId: string): MobileWebShellSessionState {
|
||||
return {
|
||||
kind: 'ready',
|
||||
generationDirectory: DIRECTORY,
|
||||
sessionId,
|
||||
buildId: BUILD_ID,
|
||||
totalBytes: 4096,
|
||||
elapsedMs: 811
|
||||
}
|
||||
}
|
||||
|
||||
async function update(tree: ReactTestRenderer, state: MobileWebShellSessionState): Promise<void> {
|
||||
dependencies.state = state
|
||||
await act(async () => {
|
||||
tree.update(createElement(MobileWebShellScreen, { hostId: 'host-1' }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit
|
||||
* an arbitrary React Native host name, so the typed form is a predicate. */
|
||||
function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] {
|
||||
return tree.root.findAll((node) => String(node.type) === name)
|
||||
}
|
||||
|
||||
function textOf(tree: ReactTestRenderer): string {
|
||||
return byName(tree, 'Text')
|
||||
.map((node) => node.children.filter((child) => typeof child === 'string').join(''))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
describe('the hybrid shell screen', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.retry.mockReset()
|
||||
dependencies.reportShellFailure.mockReset()
|
||||
dependencies.lifecycle.length = 0
|
||||
})
|
||||
|
||||
it('renders the update wall for a bundle verdict, with no shell view', async () => {
|
||||
const tree = await render({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
expect(textOf(tree)).toContain('Update Orca on your computer')
|
||||
expect(byName(tree, 'ShellViewProbe')).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the refetch wall a cached generation older than the host earns', async () => {
|
||||
const tree = await render({
|
||||
kind: 'wall',
|
||||
verdict: {
|
||||
kind: 'blocked',
|
||||
reason: 'bundle-incompatible',
|
||||
side: 'mobile',
|
||||
bundleRuntimeProtocolVersion: 3,
|
||||
requiredBundleRuntimeProtocolVersion: 9
|
||||
}
|
||||
})
|
||||
expect(textOf(tree)).toContain('Refresh the mobile workspace')
|
||||
})
|
||||
|
||||
it('offers Try again on a failure a retry can clear', async () => {
|
||||
const tree = await render({
|
||||
kind: 'failed',
|
||||
reason: 'document-load-failed',
|
||||
retriedOnce: true
|
||||
})
|
||||
expect(textOf(tree)).toContain('The downloaded workspace could not be opened.')
|
||||
const retry = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')
|
||||
expect(retry).toHaveLength(1)
|
||||
await act(async () => {
|
||||
retry[0].props.onPress()
|
||||
})
|
||||
expect(dependencies.retry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers no retry when the device cannot isolate a WebView', async () => {
|
||||
const tree = await render({
|
||||
kind: 'failed',
|
||||
reason: 'isolation-unavailable',
|
||||
retriedOnce: false
|
||||
})
|
||||
expect(textOf(tree)).toContain("This device's WebView is too old")
|
||||
expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([])
|
||||
})
|
||||
|
||||
it('offers no retry for a status that could not be read, since the gate is settled', async () => {
|
||||
const tree = await render({
|
||||
kind: 'failed',
|
||||
reason: 'status-unreadable',
|
||||
retriedOnce: false
|
||||
})
|
||||
expect(textOf(tree)).toContain("Could not read this host's status")
|
||||
expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')).toEqual([])
|
||||
})
|
||||
|
||||
it('names what is missing when the host is unreachable and nothing is cached', async () => {
|
||||
expect(textOf(await render({ kind: 'offline' }))).toContain(
|
||||
'Connect to this host to download the workspace'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts assets and bytes while downloading', async () => {
|
||||
const tree = await render({
|
||||
kind: 'fetching',
|
||||
completedAssets: 2,
|
||||
totalAssets: 4,
|
||||
receivedBytes: 2048,
|
||||
totalBytes: 4096
|
||||
})
|
||||
expect(textOf(tree)).toContain('2/4 files')
|
||||
expect(textOf(tree)).toContain('2048/4096 bytes')
|
||||
})
|
||||
|
||||
it('hands the shell view the generation path and the session id', async () => {
|
||||
const tree = await render(readyState('session-one'))
|
||||
const view = byName(tree, 'ShellViewProbe')[0]
|
||||
expect(view.props.generationDirectory).toBe(DIRECTORY)
|
||||
expect(view.props.sessionId).toBe('session-one')
|
||||
})
|
||||
|
||||
it('rebuilds the view rather than updating it when the session id changes', async () => {
|
||||
const tree = await render(readyState('session-one'))
|
||||
await update(tree, readyState('session-two'))
|
||||
expect(dependencies.lifecycle).toEqual([
|
||||
'mount:session-one',
|
||||
'unmount:session-one',
|
||||
'mount:session-two'
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards a failure the native view reports and drops a payload it cannot read', async () => {
|
||||
const tree = await render(readyState('session-one'))
|
||||
const view = byName(tree, 'ShellViewProbe')[0]
|
||||
await act(async () => {
|
||||
view.props.onLoadState({ nativeEvent: { state: 'ready' } })
|
||||
view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'invented' } })
|
||||
view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'render-process-gone' } })
|
||||
})
|
||||
expect(dependencies.reportShellFailure.mock.calls).toEqual([['render-process-gone']])
|
||||
})
|
||||
|
||||
it('shows a build id prefix and never the whole one, the cache path, or the host id', async () => {
|
||||
const tree = await render(readyState('session-one'))
|
||||
const text = textOf(tree)
|
||||
expect(text).toContain(BUILD_ID.slice(0, 12))
|
||||
expect(text).toContain('4096 B')
|
||||
expect(text).toContain('811 ms')
|
||||
expect(text).not.toContain(BUILD_ID)
|
||||
expect(text).not.toContain(DIRECTORY)
|
||||
expect(text).not.toContain('host-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import {
|
||||
OrcaMobileWebShellView,
|
||||
parseMobileWebShellLoadState
|
||||
} from '../../modules/orca-mobile-web-shell/src'
|
||||
import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type {
|
||||
MobileWebShellFailureCause,
|
||||
MobileWebShellSessionState
|
||||
} from './mobile-web-shell-session-contract'
|
||||
import {
|
||||
useMobileWebShellSession,
|
||||
type MobileWebShellRuntime
|
||||
} from './use-mobile-web-shell-session'
|
||||
|
||||
// Same guard as the Troubleshoot developer row: `__DEV__` is undefined outside the React Native
|
||||
// runtime, and the facts below are for whoever is bringing the shell up, not for a user.
|
||||
const isDevelopmentBuild = typeof __DEV__ !== 'undefined' && __DEV__
|
||||
|
||||
/** Enough of a build id to tell two generations apart in a screenshot, and not enough to be one. */
|
||||
const BUILD_ID_PREFIX_LENGTH = 12
|
||||
|
||||
function failureMessage(reason: MobileWebShellFailureCause): string {
|
||||
switch (reason) {
|
||||
case 'isolation-unavailable':
|
||||
return "This device's WebView is too old to open the workspace safely."
|
||||
case 'download-failed':
|
||||
return 'The workspace could not be downloaded from this host.'
|
||||
case 'status-unreadable':
|
||||
return "Could not read this host's status. Go back and reopen it."
|
||||
case 'render-process-gone':
|
||||
return 'The workspace stopped responding.'
|
||||
case 'generation-unreadable':
|
||||
case 'document-load-failed':
|
||||
return 'The downloaded workspace could not be opened.'
|
||||
}
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: ReactNode }) {
|
||||
return <View style={styles.centered}>{children}</View>
|
||||
}
|
||||
|
||||
function Waiting({ label }: { label: string }) {
|
||||
return (
|
||||
<Centered>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel={label} />
|
||||
<Text style={styles.waitingLabel}>{label}</Text>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
function Fetching({ state }: { state: Extract<MobileWebShellSessionState, { kind: 'fetching' }> }) {
|
||||
return (
|
||||
<Centered>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Downloading workspace" />
|
||||
<Text style={styles.waitingLabel}>Downloading workspace</Text>
|
||||
<Text style={styles.progress} testID="mobile-web-shell-progress">
|
||||
{`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`}
|
||||
</Text>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
function Failed({
|
||||
state,
|
||||
onRetry
|
||||
}: {
|
||||
state: Extract<MobileWebShellSessionState, { kind: 'failed' }>
|
||||
onRetry: () => void
|
||||
}) {
|
||||
// No retry for the fence, and none for an unread status: a device whose WebView cannot be
|
||||
// isolated will not grow one on a tap, and a retry re-reads the same settled gate it already has.
|
||||
const retryable = state.reason !== 'isolation-unavailable' && state.reason !== 'status-unreadable'
|
||||
return (
|
||||
<Centered>
|
||||
<Text style={styles.failedMessage} testID="mobile-web-shell-failed">
|
||||
{failureMessage(state.reason)}
|
||||
</Text>
|
||||
{retryable ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.retryButton, pressed && styles.pressed]}
|
||||
testID="mobile-web-shell-retry"
|
||||
onPress={onRetry}
|
||||
>
|
||||
<Text style={styles.retryLabel}>Try again</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
/** Never the generation directory, never the whole build id, never the host id: this renders on a
|
||||
* device someone may be screen-sharing, and none of those three tell them anything a prefix does
|
||||
* not. */
|
||||
function DevFacts({ state }: { state: Extract<MobileWebShellSessionState, { kind: 'ready' }> }) {
|
||||
if (!isDevelopmentBuild) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<View style={styles.devFacts} pointerEvents="none">
|
||||
<Text style={styles.devFactsText} testID="mobile-web-shell-dev-facts">
|
||||
{`${state.buildId.slice(0, BUILD_ID_PREFIX_LENGTH)} · ${state.totalBytes} B · ${state.elapsedMs} ms`}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type MobileWebShellScreenProps = {
|
||||
hostId: string
|
||||
runtime?: MobileWebShellRuntime
|
||||
}
|
||||
|
||||
/**
|
||||
* The hybrid shell route's screen: one generation, rendered by the native view, or the plain state
|
||||
* that says why it is not.
|
||||
*
|
||||
* The native view is keyed on the session id, so a remount the reducer asks for is a new key and a
|
||||
* rebuilt WebView with every fence reinstalled — the view has no reload of its own by design.
|
||||
*/
|
||||
export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { state, retry, reportShellFailure } = useMobileWebShellSession({ hostId, runtime })
|
||||
|
||||
if (state.kind === 'wall') {
|
||||
return <ProtocolBlockScreen verdict={state.verdict} />
|
||||
}
|
||||
if (state.kind === 'failed') {
|
||||
return <Failed state={state} onRetry={retry} />
|
||||
}
|
||||
if (state.kind === 'offline') {
|
||||
return (
|
||||
<Centered>
|
||||
<Text style={styles.waitingLabel} testID="mobile-web-shell-offline">
|
||||
Connect to this host to download the workspace
|
||||
</Text>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
if (state.kind === 'fetching') {
|
||||
return <Fetching state={state} />
|
||||
}
|
||||
if (state.kind !== 'ready') {
|
||||
return <Waiting label={state.kind === 'activating' ? 'Opening workspace' : 'Checking host'} />
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[styles.shellRoot, { paddingTop: insets.top, paddingBottom: insets.bottom }]}
|
||||
testID="mobile-web-shell-ready"
|
||||
>
|
||||
<OrcaMobileWebShellView
|
||||
key={state.sessionId}
|
||||
style={styles.shellView}
|
||||
generationDirectory={state.generationDirectory}
|
||||
sessionId={state.sessionId}
|
||||
onLoadState={(event) => {
|
||||
const parsed = parseMobileWebShellLoadState(event.nativeEvent)
|
||||
if (parsed?.state === 'failed') {
|
||||
reportShellFailure(parsed.reason)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DevFacts state={state} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shellRoot: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
shellView: {
|
||||
flex: 1
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
waitingLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center'
|
||||
},
|
||||
progress: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
failedMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
retryLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
devFacts: {
|
||||
position: 'absolute',
|
||||
left: spacing.sm,
|
||||
bottom: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
devFactsText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The module pulls in expo-file-system at import time; only the pure converter is under test here.
|
||||
vi.mock('expo-file-system', () => ({
|
||||
Directory: class {},
|
||||
File: class {},
|
||||
Paths: { cache: '' }
|
||||
}))
|
||||
|
||||
import { generationDirectoryPath } from './generation-store-file-system'
|
||||
|
||||
describe('generationDirectoryPath', () => {
|
||||
it('hands the native view the absolute path both loaders demand', () => {
|
||||
// Both refuse anything without a leading slash, and the store speaks file:// uris.
|
||||
expect(
|
||||
generationDirectoryPath('file:///var/mobile/Caches/mobile-web/abc/generations/def')
|
||||
).toBe('/var/mobile/Caches/mobile-web/abc/generations/def')
|
||||
expect(generationDirectoryPath('file:///data/user/0/com.stably.orca.mobile/cache/mw')).toBe(
|
||||
'/data/user/0/com.stably.orca.mobile/cache/mw'
|
||||
)
|
||||
})
|
||||
|
||||
it('decodes what a uri escaped and a path spells literally', () => {
|
||||
expect(generationDirectoryPath('file:///var/Orca%20Mobile/mobile-web')).toBe(
|
||||
'/var/Orca Mobile/mobile-web'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a value that is already a path alone, so nobody can decode one twice', () => {
|
||||
expect(generationDirectoryPath('/var/mobile/Caches/100%25')).toBe('/var/mobile/Caches/100%25')
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,23 @@ export type GenerationFileSystem = {
|
||||
moveDirectory(fromUri: string, toUri: string): Promise<void>
|
||||
}
|
||||
|
||||
const FILE_URI_PREFIX = 'file://'
|
||||
|
||||
/**
|
||||
* The `file://` uri the store works in, as the absolute path the native shell view requires.
|
||||
*
|
||||
* The two sides speak different dialects of the same location: `expo-file-system` hands out uris,
|
||||
* and both native loaders refuse anything that does not start with `/`. Percent-decoded because a
|
||||
* uri escapes what a path spells literally, and left alone when it is already a path so a caller
|
||||
* cannot double-decode one.
|
||||
*/
|
||||
export function generationDirectoryPath(uri: string): string {
|
||||
if (!uri.startsWith(FILE_URI_PREFIX)) {
|
||||
return uri
|
||||
}
|
||||
return decodeURIComponent(uri.slice(FILE_URI_PREFIX.length))
|
||||
}
|
||||
|
||||
export function createExpoGenerationFileSystem(): GenerationFileSystem {
|
||||
return {
|
||||
rootUri: new Directory(Paths.cache, MOBILE_WEB_CACHE_DIRECTORY_NAME).uri,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* The hybrid shell flag is the whole of what keeps this feature dark, so who touches it is a
|
||||
* product invariant rather than a convention. A second reader is how a dark feature stops being
|
||||
* dark: a launch-time sweep, a prefetch or a menu item that consults the flag would run in a store
|
||||
* build the moment anything flipped it, and none of those would fail a type check.
|
||||
*/
|
||||
const MOBILE_ROOT = join(import.meta.dirname, '..', '..')
|
||||
const FLAG_KEY = 'orca:mobileWebShellEnabled'
|
||||
const DEFINITION = 'src/storage/preferences.ts'
|
||||
const ROUTE = 'app/h/[hostId]/web.tsx'
|
||||
const DEVELOPER_ROW = 'src/diagnostics/mobile-web-shell-dev-row.tsx'
|
||||
/** Every tree that ships in the app bundle, with the floor each must clear. `modules` is two files,
|
||||
* but it is where the native view lives and so the easiest place for a second reader to hide. */
|
||||
const TREES = { src: 200, app: 10, modules: 1 }
|
||||
const SHELL_VIEW = 'modules/orca-mobile-web-shell/src/index.ts'
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
const found: string[] = []
|
||||
for (const entry of readdirSync(join(MOBILE_ROOT, directory), { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...sourceFiles(path))
|
||||
} else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) {
|
||||
found.push(path)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const SOURCES = Object.keys(TREES)
|
||||
.flatMap((tree) => sourceFiles(tree))
|
||||
.map((path) => ({
|
||||
path: path.split('\\').join('/'),
|
||||
text: readFileSync(join(MOBILE_ROOT, path), 'utf8')
|
||||
}))
|
||||
|
||||
function filesContaining(needle: string): string[] {
|
||||
return SOURCES.filter((file) => file.text.includes(needle))
|
||||
.map((file) => file.path)
|
||||
.sort()
|
||||
}
|
||||
|
||||
describe('who touches the hybrid shell flag', () => {
|
||||
it('reaches every shipped tree, so the absence assertions below cannot pass vacuously', () => {
|
||||
const paths = SOURCES.map((file) => file.path)
|
||||
expect(paths).toContain(DEFINITION)
|
||||
expect(paths).toContain(ROUTE)
|
||||
expect(paths).toContain(DEVELOPER_ROW)
|
||||
expect(paths).toContain(SHELL_VIEW)
|
||||
const trees = Object.keys(TREES)
|
||||
for (const [tree, floor] of Object.entries(TREES)) {
|
||||
expect(paths.filter((path) => path.startsWith(`${tree}/`)).length).toBeGreaterThan(floor)
|
||||
}
|
||||
expect(paths.filter((path) => !trees.some((tree) => path.startsWith(`${tree}/`)))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the storage key itself in one module', () => {
|
||||
expect(filesContaining(FLAG_KEY)).toEqual([DEFINITION])
|
||||
})
|
||||
|
||||
it('is read by the route and by the developer row that writes it, and nowhere else', () => {
|
||||
expect(filesContaining('loadMobileWebShellEnabled')).toEqual(
|
||||
[DEFINITION, DEVELOPER_ROW, ROUTE].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('is written only by the developer row', () => {
|
||||
expect(filesContaining('saveMobileWebShellEnabled')).toEqual([DEFINITION, DEVELOPER_ROW].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
import { readMobileWebShellReachability } from './mobile-web-shell-session'
|
||||
|
||||
/** Only `client === null` is read, but a real shape keeps this out of the casting gate. */
|
||||
function fakeClient(): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => 'connected',
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const CLIENT = fakeClient()
|
||||
|
||||
function reachability(state: ConnectionState, client: RpcClient | null = CLIENT): string {
|
||||
return readMobileWebShellReachability(state, client)
|
||||
}
|
||||
|
||||
describe('readMobileWebShellReachability', () => {
|
||||
it('is connected only with a live client on a connected socket', () => {
|
||||
expect(reachability('connected')).toBe('connected')
|
||||
expect(reachability('connected', null)).toBe('connecting')
|
||||
})
|
||||
|
||||
it('waits through the first dial', () => {
|
||||
expect(reachability('connecting')).toBe('connecting')
|
||||
expect(reachability('handshaking')).toBe('connecting')
|
||||
})
|
||||
|
||||
/**
|
||||
* Observed on a simulator with the paired desktop stopped: the client never settles on
|
||||
* `disconnected`. It dials, fails, schedules a retry and cycles `connecting` -> `reconnecting`
|
||||
* with the delay growing to a minute. Reading `reconnecting` as "still dialling" left a phone
|
||||
* holding a verified cached generation spinning on `checking` forever instead of opening it.
|
||||
*/
|
||||
it('treats a scheduled retry as an unreachable host, not as a dial in progress', () => {
|
||||
expect(reachability('reconnecting')).toBe('unreachable')
|
||||
})
|
||||
|
||||
it('treats a settled non-connection as unreachable', () => {
|
||||
expect(reachability('disconnected')).toBe('unreachable')
|
||||
expect(reachability('auth-failed')).toBe('unreachable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type RouteDependencies = { storage: Map<string, string>; mounted: string[] }
|
||||
|
||||
const dependencies = vi.hoisted((): RouteDependencies => ({ storage: new Map(), mounted: [] }))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => dependencies.storage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
dependencies.storage.set(key, value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => ({ hostId: 'host-1' })
|
||||
}))
|
||||
|
||||
vi.mock('./MobileWebShellScreen', () => ({
|
||||
MobileWebShellScreen: (props: { hostId: string }) => {
|
||||
dependencies.mounted.push(props.hostId)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
|
||||
import MobileWebShellRoute from '../../app/h/[hostId]/web'
|
||||
|
||||
/** Host elements are matched by name, not by `findAllByType`: React's `ElementType` does not admit
|
||||
* an arbitrary React Native host name, so the typed form is a predicate. */
|
||||
function byName(tree: ReactTestRenderer, name: string): ReactTestInstance[] {
|
||||
return tree.root.findAll((node) => String(node.type) === name)
|
||||
}
|
||||
|
||||
async function renderRoute(): Promise<ReactTestRenderer> {
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(MobileWebShellRoute))
|
||||
})
|
||||
if (rendered.tree === null) {
|
||||
throw new Error('route did not render')
|
||||
}
|
||||
return rendered.tree
|
||||
}
|
||||
|
||||
/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so
|
||||
* the test says which build kind it is running as without asserting a type on `globalThis`. */
|
||||
function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void {
|
||||
if (isDevelopmentBuild === undefined) {
|
||||
Reflect.deleteProperty(globalThis, '__DEV__')
|
||||
return
|
||||
}
|
||||
Object.assign(globalThis, { __DEV__: isDevelopmentBuild })
|
||||
}
|
||||
|
||||
describe('the hybrid shell route', () => {
|
||||
beforeEach(() => {
|
||||
dependencies.storage.clear()
|
||||
dependencies.mounted.length = 0
|
||||
setDevelopmentBuild(true)
|
||||
})
|
||||
|
||||
it('redirects to the host screen with the flag unset, and mounts nothing', async () => {
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect').map((node) => node.props.href)).toEqual(['/h/host-1'])
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
})
|
||||
|
||||
it('redirects with the flag explicitly off', async () => {
|
||||
dependencies.storage.set('orca:mobileWebShellEnabled', 'false')
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect')).toHaveLength(1)
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
})
|
||||
|
||||
it('mounts the shell screen for this host with the flag on', async () => {
|
||||
dependencies.storage.set('orca:mobileWebShellEnabled', 'true')
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect')).toEqual([])
|
||||
expect(dependencies.mounted).toEqual(['host-1'])
|
||||
})
|
||||
|
||||
it('redirects a store build whose container kept a flag a development build set', async () => {
|
||||
setDevelopmentBuild(undefined)
|
||||
dependencies.storage.set('orca:mobileWebShellEnabled', 'true')
|
||||
const tree = await renderRoute()
|
||||
expect(byName(tree, 'Redirect')).toHaveLength(1)
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
})
|
||||
|
||||
it('neither redirects nor mounts until the flag has been read', async () => {
|
||||
dependencies.storage.set('orca:mobileWebShellEnabled', 'true')
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
// No `await` inside act: the effect's promise is deliberately left unsettled.
|
||||
act(() => {
|
||||
rendered.tree = create(createElement(MobileWebShellRoute))
|
||||
})
|
||||
const tree = rendered.tree
|
||||
expect(tree === null ? [] : byName(tree, 'Redirect')).toEqual([])
|
||||
expect(dependencies.mounted).toEqual([])
|
||||
await act(async () => {})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import type {
|
||||
MobileWebBundleCompatManifest,
|
||||
MobileWebBundleCompatVerdict,
|
||||
MobileWebBundleHostStatus
|
||||
} from '../transport/mobile-web-bundle-compat'
|
||||
|
||||
/**
|
||||
* Whether the host can be asked anything right now.
|
||||
*
|
||||
* Three values, not a boolean: a connection still being made is not an offline host, and opening a
|
||||
* cached generation with no compat check for the second or two before a socket completes would
|
||||
* flash a workspace this host may already have replaced. `connecting` waits; only a settled
|
||||
* non-connection opens the cache unchecked.
|
||||
*/
|
||||
export type MobileWebShellReachability = 'connected' | 'connecting' | 'unreachable'
|
||||
|
||||
/** Everything the gates say that decides a step here, as one value so a transition is a pure
|
||||
* function of it rather than of four separately-arriving props. */
|
||||
export type MobileWebShellGates = {
|
||||
readonly statusPending: boolean
|
||||
/** False for a status nobody answered *and* for one this client could not decode. Both leave the
|
||||
* capability list empty, which would otherwise read as `bundle-unavailable` and wall a host that
|
||||
* simply did not reply. */
|
||||
readonly statusReadable: boolean
|
||||
readonly reachability: MobileWebShellReachability
|
||||
readonly hostCapabilities: readonly string[]
|
||||
readonly hostStatus: MobileWebBundleHostStatus
|
||||
}
|
||||
|
||||
/** The manifest fields a transition reads: the wall's three, plus what names and sizes the
|
||||
* generation the cache is compared against. */
|
||||
export type MobileWebShellManifestFacts = MobileWebBundleCompatManifest & {
|
||||
readonly buildId: string
|
||||
readonly totalBytes: number
|
||||
readonly totalAssets: number
|
||||
}
|
||||
|
||||
/** What `readActiveGeneration` found, reduced to what a transition reads. */
|
||||
export type CachedGeneration = {
|
||||
readonly buildId: string
|
||||
readonly directory: string
|
||||
readonly totalBytes: number
|
||||
}
|
||||
|
||||
export type MobileWebShellBlockedVerdict = Extract<
|
||||
MobileWebBundleCompatVerdict,
|
||||
{ kind: 'blocked' }
|
||||
>
|
||||
|
||||
/** Which side a bundle read failed on. `transport` is the link between phone and host, which says
|
||||
* nothing about the bundle; `bundle` is a verdict about it, from the host or from the bytes. */
|
||||
export type MobileWebShellReadFailure = 'transport' | 'bundle'
|
||||
|
||||
/** The shell's own failures plus the one the view cannot report: a download or a cache write that
|
||||
* never produced a generation to hand it. */
|
||||
export type MobileWebShellFailureCause =
|
||||
| MobileWebShellFailureReason
|
||||
| 'download-failed'
|
||||
| 'status-unreadable'
|
||||
|
||||
export type MobileWebShellSessionState =
|
||||
/** Gates unsettled, cache being read, or a manifest in flight. Nothing is on screen yet. */
|
||||
| { readonly kind: 'checking' }
|
||||
| {
|
||||
readonly kind: 'fetching'
|
||||
readonly completedAssets: number
|
||||
readonly totalAssets: number
|
||||
readonly receivedBytes: number
|
||||
readonly totalBytes: number
|
||||
}
|
||||
/** Bytes are in; the store is staging and committing, or a cache hit is being opened. */
|
||||
| { readonly kind: 'activating' }
|
||||
| {
|
||||
readonly kind: 'ready'
|
||||
readonly generationDirectory: string
|
||||
readonly sessionId: string
|
||||
readonly buildId: string
|
||||
readonly totalBytes: number
|
||||
readonly elapsedMs: number
|
||||
}
|
||||
| { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict }
|
||||
| {
|
||||
readonly kind: 'failed'
|
||||
readonly reason: MobileWebShellFailureCause
|
||||
readonly retriedOnce: boolean
|
||||
}
|
||||
| { readonly kind: 'offline' }
|
||||
|
||||
export type MobileWebShellSessionEffect =
|
||||
/** Sweep every host's staging tree, then read this host's activation. Lazy on purpose: with the
|
||||
* flag off nothing in the app reaches this, so nothing sweeps at launch. */
|
||||
| { readonly kind: 'open-cache' }
|
||||
| { readonly kind: 'read-manifest' }
|
||||
/** Fetch, stage, commit. The runner reports progress, then `download-staged`, then `activated`. */
|
||||
| { readonly kind: 'download' }
|
||||
/** A cache hit: nothing to download, so this only mints a session id and reports the activation. */
|
||||
| {
|
||||
readonly kind: 'open-generation'
|
||||
readonly directory: string
|
||||
readonly buildId: string
|
||||
readonly totalBytes: number
|
||||
}
|
||||
| { readonly kind: 'delete-cache' }
|
||||
/** Mint a new session id for the generation already on screen, which is what remounts the view. */
|
||||
| { readonly kind: 'remount' }
|
||||
|
||||
/**
|
||||
* Events, in two kinds.
|
||||
*
|
||||
* The seven that carry a `flow` are results reported out of an effect, and the number is the flow
|
||||
* the step that asked for them was in. Anything a superseded flow reports is dropped: a manifest
|
||||
* read that was in flight when the socket dropped still rejects afterwards, and applying that
|
||||
* rejection would replace a workspace already on screen with a download failure. The other three
|
||||
* come from outside the flow: the gates and the retry button always apply, and the view's failure
|
||||
* applies only while its generation is the one on screen, which is the only state that mounted it.
|
||||
*/
|
||||
export type MobileWebShellSessionEvent =
|
||||
| { readonly type: 'gates-changed'; readonly gates: MobileWebShellGates }
|
||||
| {
|
||||
readonly type: 'cache-read'
|
||||
readonly flow: number
|
||||
readonly generation: CachedGeneration | null
|
||||
}
|
||||
| {
|
||||
readonly type: 'manifest-read'
|
||||
readonly flow: number
|
||||
readonly manifest: MobileWebShellManifestFacts
|
||||
}
|
||||
| {
|
||||
readonly type: 'fetch-progress'
|
||||
readonly flow: number
|
||||
readonly completedAssets: number
|
||||
readonly totalAssets: number
|
||||
readonly receivedBytes: number
|
||||
readonly totalBytes: number
|
||||
}
|
||||
| { readonly type: 'download-staged'; readonly flow: number }
|
||||
| {
|
||||
readonly type: 'activated'
|
||||
readonly flow: number
|
||||
readonly generationDirectory: string
|
||||
readonly sessionId: string
|
||||
readonly buildId: string
|
||||
readonly totalBytes: number
|
||||
readonly elapsedMs: number
|
||||
}
|
||||
| { readonly type: 'remounted'; readonly flow: number; readonly sessionId: string }
|
||||
| {
|
||||
readonly type: 'download-failed'
|
||||
readonly flow: number
|
||||
readonly failure: MobileWebShellReadFailure
|
||||
}
|
||||
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason }
|
||||
| { readonly type: 'retry-pressed' }
|
||||
|
||||
/** Latches live beside the state because both outlive the state they were set in: `retriedOnce`
|
||||
* spans the delete-and-refetch that puts the state back to `checking`, and `remountedOnce` spans a
|
||||
* `ready` that is replaced by a `ready` under a new session id. */
|
||||
export type MobileWebShellSession = {
|
||||
readonly state: MobileWebShellSessionState
|
||||
readonly retriedOnce: boolean
|
||||
readonly remountedOnce: boolean
|
||||
/** The gates the current step was taken on; null until the first one arrives. */
|
||||
readonly gates: MobileWebShellGates | null
|
||||
readonly cached: CachedGeneration | null
|
||||
/** Which run of the flow the session is on. Bumped by every restart, stamped on the effects that
|
||||
* run belongs to, and echoed back on their results. */
|
||||
readonly flow: number
|
||||
}
|
||||
|
||||
/** A transition: the session it produced and the effects it owes. Every effect belongs to
|
||||
* `session.flow`, which is what the runner echoes back on the result. */
|
||||
export type MobileWebShellStep = {
|
||||
readonly session: MobileWebShellSession
|
||||
readonly effects: readonly MobileWebShellSessionEffect[]
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
|
||||
import {
|
||||
createMobileWebShellSession,
|
||||
reduceMobileWebShellSession
|
||||
} from './mobile-web-shell-session'
|
||||
import type {
|
||||
CachedGeneration,
|
||||
MobileWebShellGates,
|
||||
MobileWebShellManifestFacts,
|
||||
MobileWebShellSession,
|
||||
MobileWebShellSessionEvent,
|
||||
MobileWebShellStep
|
||||
} from './mobile-web-shell-session-contract'
|
||||
|
||||
function gates(overrides: Partial<MobileWebShellGates> = {}): MobileWebShellGates {
|
||||
return {
|
||||
statusPending: false,
|
||||
statusReadable: true,
|
||||
reachability: 'connected',
|
||||
hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY],
|
||||
hostStatus: { protocolVersion: 10, minCompatibleMobileVersion: 1 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const MANIFEST: MobileWebShellManifestFacts = {
|
||||
buildId: 'b'.repeat(64),
|
||||
schemaVersion: 1,
|
||||
runtimeProtocolVersion: 5,
|
||||
minCompatibleRuntimeProtocolVersion: 2,
|
||||
totalBytes: 4096,
|
||||
totalAssets: 4
|
||||
}
|
||||
|
||||
const CACHED: CachedGeneration = {
|
||||
buildId: MANIFEST.buildId,
|
||||
directory: '/cache/mobile-web/host/generations/b',
|
||||
totalBytes: 4096
|
||||
}
|
||||
|
||||
/** An event as a test writes it. An effect result is stamped with the flow the session is on, which
|
||||
* is what an in-order runner does; a test replaying a superseded run pins the flow itself. */
|
||||
type PendingEvent<E = MobileWebShellSessionEvent> = E extends { flow: number }
|
||||
? Omit<E, 'flow'> & { readonly flow?: number }
|
||||
: E
|
||||
|
||||
function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent {
|
||||
switch (event.type) {
|
||||
case 'gates-changed':
|
||||
case 'shell-failed':
|
||||
case 'retry-pressed':
|
||||
return event
|
||||
case 'cache-read':
|
||||
case 'manifest-read':
|
||||
case 'fetch-progress':
|
||||
case 'download-staged':
|
||||
case 'activated':
|
||||
case 'remounted':
|
||||
case 'download-failed':
|
||||
return { ...event, flow: event.flow ?? flow }
|
||||
}
|
||||
}
|
||||
|
||||
function run(
|
||||
session: MobileWebShellSession,
|
||||
...events: readonly PendingEvent[]
|
||||
): MobileWebShellStep {
|
||||
let step: MobileWebShellStep = { session, effects: [] }
|
||||
for (const event of events) {
|
||||
step = reduceMobileWebShellSession(step.session, stamp(step.session.flow, event))
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
function started(overrides: Partial<MobileWebShellGates> = {}): MobileWebShellStep {
|
||||
return run(createMobileWebShellSession(), { type: 'gates-changed', gates: gates(overrides) })
|
||||
}
|
||||
|
||||
/** Connected, capability present, cache read, manifest in flight. */
|
||||
function afterCacheRead(generation: CachedGeneration | null): MobileWebShellStep {
|
||||
return run(started().session, { type: 'cache-read', generation })
|
||||
}
|
||||
|
||||
function readySession(): MobileWebShellStep {
|
||||
return run(
|
||||
afterCacheRead(CACHED).session,
|
||||
{ type: 'manifest-read', manifest: MANIFEST },
|
||||
{
|
||||
type: 'activated',
|
||||
generationDirectory: CACHED.directory,
|
||||
sessionId: 'session-one',
|
||||
buildId: MANIFEST.buildId,
|
||||
totalBytes: MANIFEST.totalBytes,
|
||||
elapsedMs: 12
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** The second half of a recovery: the refetch the delete queued, through to a mounted view. */
|
||||
function readyAgain(session: MobileWebShellSession, sessionId: string): MobileWebShellStep {
|
||||
return run(
|
||||
session,
|
||||
{ type: 'cache-read', generation: null },
|
||||
{ type: 'manifest-read', manifest: MANIFEST },
|
||||
{ type: 'download-staged' },
|
||||
{
|
||||
type: 'activated',
|
||||
generationDirectory: '/cache/gen',
|
||||
sessionId,
|
||||
buildId: MANIFEST.buildId,
|
||||
totalBytes: MANIFEST.totalBytes,
|
||||
elapsedMs: 7
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('the gates decide whether a step is taken at all', () => {
|
||||
it('waits while a connection is still being made', () => {
|
||||
const step = started({ reachability: 'connecting' })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('waits while status.get is still pending rather than reading its empty capabilities', () => {
|
||||
const step = started({ statusPending: true, hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('says a status could not be read rather than walling or waiting on it forever', () => {
|
||||
const step = started({ statusReadable: false, hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'status-unreadable',
|
||||
retriedOnce: false
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('picks the flow back up if that status ever becomes readable', () => {
|
||||
const unreadable = started({ statusReadable: false, hostCapabilities: [] })
|
||||
const step = run(unreadable.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('walls a readable host that serves no bundle', () => {
|
||||
const step = started({ hostCapabilities: [] })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('sweeps and reads the cache once the capability is answered', () => {
|
||||
expect(started().effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('reads the cache for an unreachable host too, before deciding anything', () => {
|
||||
expect(started({ reachability: 'unreachable' }).effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the offline rule', () => {
|
||||
it('opens a cached generation with no compat check when the host is unreachable', () => {
|
||||
const start = started({ reachability: 'unreachable', hostCapabilities: [] })
|
||||
const step = run(start.session, { type: 'cache-read', generation: CACHED })
|
||||
expect(step.session.state).toEqual({ kind: 'activating' })
|
||||
expect(step.effects).toEqual([
|
||||
{
|
||||
kind: 'open-generation',
|
||||
directory: CACHED.directory,
|
||||
buildId: CACHED.buildId,
|
||||
totalBytes: CACHED.totalBytes
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('says so when an unreachable host has nothing cached', () => {
|
||||
const start = started({ reachability: 'unreachable' })
|
||||
const step = run(start.session, { type: 'cache-read', generation: null })
|
||||
expect(step.session.state).toEqual({ kind: 'offline' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('waits on a cache read that lands mid-dial instead of opening it unchecked', () => {
|
||||
const dialling = run(started().session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ reachability: 'connecting' })
|
||||
})
|
||||
const step = run(dialling.session, { type: 'cache-read', generation: CACHED })
|
||||
// Connecting is not unreachable: the compat check is a moment away, and skipping it would put a
|
||||
// generation on screen the host is about to say it no longer serves.
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.session.cached).toEqual(CACHED)
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('restarts the flow when the host becomes reachable while offline is showing', () => {
|
||||
const offline = run(started({ reachability: 'unreachable' }).session, {
|
||||
type: 'cache-read',
|
||||
generation: null
|
||||
})
|
||||
const step = run(offline.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(step.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the connected flow', () => {
|
||||
it('asks the host for a manifest once the cache has been read', () => {
|
||||
expect(afterCacheRead(null).effects).toEqual([{ kind: 'read-manifest' }])
|
||||
expect(afterCacheRead(CACHED).effects).toEqual([{ kind: 'read-manifest' }])
|
||||
})
|
||||
|
||||
it('walls a manifest written in a schema this shell does not know', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: { ...MANIFEST, schemaVersion: 99 }
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-shell-too-old', schemaVersion: 99 }
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('opens the cached generation without paging when the build ids match', () => {
|
||||
const step = run(afterCacheRead(CACHED).session, { type: 'manifest-read', manifest: MANIFEST })
|
||||
expect(step.session.state).toEqual({ kind: 'activating' })
|
||||
expect(step.effects).toEqual([
|
||||
{
|
||||
kind: 'open-generation',
|
||||
directory: CACHED.directory,
|
||||
buildId: CACHED.buildId,
|
||||
totalBytes: CACHED.totalBytes
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('downloads when the cached build id is a different one', () => {
|
||||
const stale = { ...CACHED, buildId: 'c'.repeat(64) }
|
||||
const step = run(afterCacheRead(stale).session, { type: 'manifest-read', manifest: MANIFEST })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'fetching',
|
||||
completedAssets: 0,
|
||||
totalAssets: 4,
|
||||
receivedBytes: 0,
|
||||
totalBytes: 4096
|
||||
})
|
||||
expect(step.effects).toEqual([{ kind: 'download' }])
|
||||
})
|
||||
|
||||
it('downloads when there is no cache at all', () => {
|
||||
const step = run(afterCacheRead(null).session, { type: 'manifest-read', manifest: MANIFEST })
|
||||
expect(step.effects).toEqual([{ kind: 'download' }])
|
||||
})
|
||||
|
||||
it('carries download progress and then stages and activates', () => {
|
||||
const fetching = run(afterCacheRead(null).session, {
|
||||
type: 'manifest-read',
|
||||
manifest: MANIFEST
|
||||
})
|
||||
const progressed = run(fetching.session, {
|
||||
type: 'fetch-progress',
|
||||
completedAssets: 2,
|
||||
totalAssets: 4,
|
||||
receivedBytes: 2048,
|
||||
totalBytes: 4096
|
||||
})
|
||||
expect(progressed.session.state).toMatchObject({ kind: 'fetching', completedAssets: 2 })
|
||||
const staged = run(progressed.session, { type: 'download-staged' })
|
||||
expect(staged.session.state).toEqual({ kind: 'activating' })
|
||||
const ready = run(staged.session, {
|
||||
type: 'activated',
|
||||
generationDirectory: '/cache/gen',
|
||||
sessionId: 'session-one',
|
||||
buildId: MANIFEST.buildId,
|
||||
totalBytes: 4096,
|
||||
elapsedMs: 900
|
||||
})
|
||||
expect(ready.session.state).toEqual({
|
||||
kind: 'ready',
|
||||
generationDirectory: '/cache/gen',
|
||||
sessionId: 'session-one',
|
||||
buildId: MANIFEST.buildId,
|
||||
totalBytes: 4096,
|
||||
elapsedMs: 900
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores progress that arrives after the fetching state is gone', () => {
|
||||
const ready = readySession()
|
||||
const step = run(ready.session, {
|
||||
type: 'fetch-progress',
|
||||
completedAssets: 1,
|
||||
totalAssets: 4,
|
||||
receivedBytes: 1,
|
||||
totalBytes: 4096
|
||||
})
|
||||
expect(step.session.state).toEqual(ready.session.state)
|
||||
})
|
||||
|
||||
it('fails when the download or the cache write never produced a generation', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'download-failed',
|
||||
failure: 'bundle'
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'download-failed',
|
||||
retriedOnce: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('a read the link cut short falls back to what is on disk', () => {
|
||||
/** Connected, a generation cached, the manifest read in flight — where the drop is felt. */
|
||||
function manifestInFlight() {
|
||||
return afterCacheRead(CACHED)
|
||||
}
|
||||
|
||||
it('opens the cached generation when the socket drops before the reachability change does', () => {
|
||||
const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'transport' })
|
||||
expect(step.session.state).toEqual({ kind: 'activating' })
|
||||
expect(step.effects).toEqual([
|
||||
{
|
||||
kind: 'open-generation',
|
||||
directory: CACHED.directory,
|
||||
buildId: CACHED.buildId,
|
||||
totalBytes: CACHED.totalBytes
|
||||
}
|
||||
])
|
||||
const ready = run(step.session, {
|
||||
type: 'activated',
|
||||
generationDirectory: CACHED.directory,
|
||||
sessionId: 'session-one',
|
||||
buildId: CACHED.buildId,
|
||||
totalBytes: CACHED.totalBytes,
|
||||
elapsedMs: 12
|
||||
})
|
||||
expect(ready.session.state).toMatchObject({ kind: 'ready', buildId: CACHED.buildId })
|
||||
})
|
||||
|
||||
it('still says the workspace could not be downloaded when nothing is on disk', () => {
|
||||
const step = run(afterCacheRead(null).session, {
|
||||
type: 'download-failed',
|
||||
failure: 'transport'
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'download-failed',
|
||||
retriedOnce: false
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('fails on a verdict about the bundle even with a generation cached', () => {
|
||||
// A host that refuses the read, or bytes that do not hash, is an answer about the bundle. A
|
||||
// cached generation is no reason to hide it behind a workspace that is merely older.
|
||||
const step = run(manifestInFlight().session, { type: 'download-failed', failure: 'bundle' })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'download-failed',
|
||||
retriedOnce: false
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a displayed generation is not restarted by the gates', () => {
|
||||
it.each(['connected', 'unreachable', 'connecting'] as const)(
|
||||
'keeps a ready session when reachability becomes %s',
|
||||
(reachability) => {
|
||||
const ready = readySession()
|
||||
const step = run(ready.session, { type: 'gates-changed', gates: gates({ reachability }) })
|
||||
expect(step.session.state).toEqual(ready.session.state)
|
||||
expect(step.effects).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a wall and a terminal failure', () => {
|
||||
const wall = started({ hostCapabilities: [] })
|
||||
expect(run(wall.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([])
|
||||
const failed = run(afterCacheRead(null).session, {
|
||||
type: 'download-failed',
|
||||
failure: 'bundle'
|
||||
})
|
||||
expect(run(failed.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recovery follows the shell view contract', () => {
|
||||
it.each(['generation-unreadable', 'document-load-failed'] as const)(
|
||||
'deletes this host cache and runs once more on %s',
|
||||
(reason) => {
|
||||
const step = run(readySession().session, { type: 'shell-failed', reason })
|
||||
expect(step.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }])
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.session.retriedOnce).toBe(true)
|
||||
expect(step.session.cached).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('takes a recovery through the gate rather than back to a manifest check', () => {
|
||||
const ready = readySession()
|
||||
// A reconnect whose status probe failed. Stored, not acted on: a workspace on screen is not
|
||||
// restarted by a gates change, which is how a ready session ends up holding one like this.
|
||||
const stale = run(ready.session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ statusReadable: false, hostCapabilities: [] })
|
||||
})
|
||||
expect(stale.session.state).toMatchObject({ kind: 'ready' })
|
||||
const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' })
|
||||
// Not the wall the empty capability list would have produced, which nothing leaves.
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'status-unreadable',
|
||||
retriedOnce: true
|
||||
})
|
||||
expect(step.effects).toEqual([{ kind: 'delete-cache' }])
|
||||
const rearmed = run(step.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(rearmed.session.state).toEqual({ kind: 'checking' })
|
||||
expect(rearmed.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('still walls a recovery whose host readably serves no bundle', () => {
|
||||
const stale = run(readySession().session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ hostCapabilities: [] })
|
||||
})
|
||||
const step = run(stale.session, { type: 'shell-failed', reason: 'document-load-failed' })
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
expect(step.effects).toEqual([{ kind: 'delete-cache' }])
|
||||
})
|
||||
|
||||
it('deletes the suspect cache and waits when the recovery lands mid-reconnect', () => {
|
||||
const dialling = run(readySession().session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ reachability: 'connecting' })
|
||||
})
|
||||
const step = run(dialling.session, { type: 'shell-failed', reason: 'generation-unreadable' })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.effects).toEqual([{ kind: 'delete-cache' }])
|
||||
expect(run(step.session, { type: 'gates-changed', gates: gates() }).effects).toEqual([
|
||||
{ kind: 'open-cache' }
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['generation-unreadable', 'document-load-failed'] as const)(
|
||||
'is terminal the second time %s is reported',
|
||||
(reason) => {
|
||||
const first = run(readySession().session, { type: 'shell-failed', reason })
|
||||
const refetched = readyAgain(first.session, 'session-two')
|
||||
const second = run(refetched.session, { type: 'shell-failed', reason })
|
||||
expect(second.effects).toEqual([])
|
||||
expect(second.session.state).toEqual({ kind: 'failed', reason, retriedOnce: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('remounts once on render-process-gone and never deletes anything', () => {
|
||||
const ready = readySession()
|
||||
const step = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' })
|
||||
expect(step.effects).toEqual([{ kind: 'remount' }])
|
||||
expect(step.session.state).toEqual(ready.session.state)
|
||||
const remounted = run(step.session, { type: 'remounted', sessionId: 'session-two' })
|
||||
expect(remounted.session.state).toMatchObject({
|
||||
kind: 'ready',
|
||||
sessionId: 'session-two',
|
||||
generationDirectory: CACHED.directory
|
||||
})
|
||||
})
|
||||
|
||||
it('is terminal the second time the render process is gone, still without a delete', () => {
|
||||
const first = run(readySession().session, {
|
||||
type: 'shell-failed',
|
||||
reason: 'render-process-gone'
|
||||
})
|
||||
const remounted = run(first.session, { type: 'remounted', sessionId: 'session-two' })
|
||||
const second = run(remounted.session, { type: 'shell-failed', reason: 'render-process-gone' })
|
||||
expect(second.effects).toEqual([])
|
||||
expect(second.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'render-process-gone',
|
||||
retriedOnce: false
|
||||
})
|
||||
})
|
||||
|
||||
it('is terminal on the first isolation-unavailable, with no retry and no delete', () => {
|
||||
const step = run(readySession().session, {
|
||||
type: 'shell-failed',
|
||||
reason: 'isolation-unavailable'
|
||||
})
|
||||
expect(step.effects).toEqual([])
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'failed',
|
||||
reason: 'isolation-unavailable',
|
||||
retriedOnce: false
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a session id for a generation that is no longer ready', () => {
|
||||
const step = run(started().session, { type: 'remounted', sessionId: 'session-two' })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('try again', () => {
|
||||
it('clears both latches and restarts the flow', () => {
|
||||
const first = run(readySession().session, {
|
||||
type: 'shell-failed',
|
||||
reason: 'document-load-failed'
|
||||
})
|
||||
const refetched = readyAgain(first.session, 'session-two')
|
||||
const failed = run(refetched.session, { type: 'shell-failed', reason: 'document-load-failed' })
|
||||
const retried = run(failed.session, { type: 'retry-pressed' })
|
||||
expect(retried.session.retriedOnce).toBe(false)
|
||||
expect(retried.session.remountedOnce).toBe(false)
|
||||
expect(retried.session.cached).toBeNull()
|
||||
expect(retried.effects).toEqual([{ kind: 'open-cache' }])
|
||||
// And the delete-and-refetch is available again.
|
||||
const again = run(
|
||||
run(retried.session, { type: 'cache-read', generation: CACHED }).session,
|
||||
{ type: 'manifest-read', manifest: MANIFEST },
|
||||
{
|
||||
type: 'activated',
|
||||
generationDirectory: CACHED.directory,
|
||||
sessionId: 'session-three',
|
||||
buildId: MANIFEST.buildId,
|
||||
totalBytes: 4096,
|
||||
elapsedMs: 3
|
||||
},
|
||||
{ type: 'shell-failed', reason: 'document-load-failed' }
|
||||
)
|
||||
expect(again.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('walls again rather than looping when the host still serves no bundle', () => {
|
||||
const wall = started({ hostCapabilities: [] })
|
||||
const retried = run(wall.session, { type: 'retry-pressed' })
|
||||
expect(retried.session.state).toMatchObject({ kind: 'wall' })
|
||||
expect(retried.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing but reset when no gates have arrived yet', () => {
|
||||
const step = run(createMobileWebShellSession(), { type: 'retry-pressed' })
|
||||
expect(step.session.state).toEqual({ kind: 'checking' })
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a result from a superseded flow reports into nothing', () => {
|
||||
it('drops the cache read of a run a reconnect replaced, so nothing opens unchecked', () => {
|
||||
const first = started({ reachability: 'unreachable' })
|
||||
const restarted = run(first.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(restarted.effects).toEqual([{ kind: 'open-cache' }])
|
||||
// The offline read would have opened this generation with no compat check at all.
|
||||
const stale = run(restarted.session, {
|
||||
type: 'cache-read',
|
||||
flow: first.session.flow,
|
||||
generation: CACHED
|
||||
})
|
||||
expect(stale.effects).toEqual([])
|
||||
expect(stale.session.cached).toBeNull()
|
||||
expect(run(stale.session, { type: 'cache-read', generation: CACHED }).effects).toEqual([
|
||||
{ kind: 'read-manifest' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the manifest of a run the socket drop replaced, so no download is asked for', () => {
|
||||
const first = afterCacheRead(null)
|
||||
const restarted = run(first.session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ reachability: 'unreachable' })
|
||||
})
|
||||
const stale = run(restarted.session, {
|
||||
type: 'manifest-read',
|
||||
flow: first.session.flow,
|
||||
manifest: MANIFEST
|
||||
})
|
||||
expect(stale.effects).toEqual([])
|
||||
expect(stale.session.state).toEqual({ kind: 'checking' })
|
||||
const current = run(stale.session, { type: 'cache-read', generation: null })
|
||||
expect(current.session.state).toEqual({ kind: 'offline' })
|
||||
expect(current.effects).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a workspace on screen when the manifest read the drop abandoned finally rejects', () => {
|
||||
// The reproduced sequence: connected, cache read, manifest in flight, socket drops, the offline
|
||||
// path opens the cached generation, and only then does the abandoned RPC settle.
|
||||
const inFlight = afterCacheRead(CACHED)
|
||||
const offline = run(inFlight.session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ reachability: 'unreachable' })
|
||||
})
|
||||
const ready = run(
|
||||
offline.session,
|
||||
{ type: 'cache-read', generation: CACHED },
|
||||
{
|
||||
type: 'activated',
|
||||
generationDirectory: CACHED.directory,
|
||||
sessionId: 'session-one',
|
||||
buildId: CACHED.buildId,
|
||||
totalBytes: CACHED.totalBytes,
|
||||
elapsedMs: 4
|
||||
}
|
||||
)
|
||||
expect(ready.session.state).toMatchObject({ kind: 'ready' })
|
||||
const late = run(ready.session, {
|
||||
type: 'download-failed',
|
||||
failure: 'bundle',
|
||||
flow: inFlight.session.flow
|
||||
})
|
||||
expect(late.session.state).toEqual(ready.session.state)
|
||||
})
|
||||
|
||||
it('applies a remount of the current flow and ignores one from a replaced run', () => {
|
||||
const ready = readySession()
|
||||
const remounting = run(ready.session, { type: 'shell-failed', reason: 'render-process-gone' })
|
||||
const stale = run(remounting.session, {
|
||||
type: 'remounted',
|
||||
flow: remounting.session.flow - 1,
|
||||
sessionId: 'session-stale'
|
||||
})
|
||||
expect(stale.session.state).toEqual(ready.session.state)
|
||||
expect(
|
||||
run(stale.session, { type: 'remounted', sessionId: 'session-two' }).session.state
|
||||
).toMatchObject({ sessionId: 'session-two' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the remount budget is one per session, not one per reconnect', () => {
|
||||
it('keeps the latch set when the gates restart the flow after a load failure', () => {
|
||||
const remounted = run(
|
||||
readySession().session,
|
||||
{ type: 'shell-failed', reason: 'render-process-gone' },
|
||||
{ type: 'remounted', sessionId: 'session-two' },
|
||||
{ type: 'shell-failed', reason: 'document-load-failed' }
|
||||
)
|
||||
expect(remounted.session.remountedOnce).toBe(true)
|
||||
const restarted = run(remounted.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(restarted.session.remountedOnce).toBe(true)
|
||||
expect(run(restarted.session, { type: 'retry-pressed' }).session.remountedOnce).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('only the state that mounted the view hears the view', () => {
|
||||
it('ignores the second failure of one native batch, leaving the first recovery running', () => {
|
||||
const recovering = run(readySession().session, {
|
||||
type: 'shell-failed',
|
||||
reason: 'document-load-failed'
|
||||
})
|
||||
const batched = run(recovering.session, {
|
||||
type: 'shell-failed',
|
||||
reason: 'render-process-gone'
|
||||
})
|
||||
expect(batched.session.state).toEqual({ kind: 'checking' })
|
||||
expect(batched.effects).toEqual([])
|
||||
expect(batched.session.flow).toBe(recovering.session.flow)
|
||||
// And the cache read the recovery already asked for still lands on the recovery.
|
||||
expect(run(batched.session, { type: 'cache-read', generation: null }).effects).toEqual([
|
||||
{ kind: 'read-manifest' }
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a wall standing when a view that is no longer mounted reports a failure', () => {
|
||||
const wall = started({ hostCapabilities: [] })
|
||||
const step = run(wall.session, { type: 'shell-failed', reason: 'isolation-unavailable' })
|
||||
expect(step.session.state).toEqual(wall.session.state)
|
||||
expect(step.effects).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a gates change that says nothing new starts nothing', () => {
|
||||
it('leaves a check in flight alone rather than sweeping and reading a second time', () => {
|
||||
const checking = started()
|
||||
const again = run(checking.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(again.effects).toEqual([])
|
||||
expect(again.session.flow).toBe(checking.session.flow)
|
||||
})
|
||||
|
||||
it('holds the offline screen through a reconnect cycle that never reaches the host', () => {
|
||||
const offline = run(started({ reachability: 'unreachable' }).session, {
|
||||
type: 'cache-read',
|
||||
generation: null
|
||||
})
|
||||
const cycled = run(
|
||||
offline.session,
|
||||
{ type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) },
|
||||
{ type: 'gates-changed', gates: gates({ reachability: 'unreachable' }) }
|
||||
)
|
||||
expect(cycled.effects).toEqual([])
|
||||
expect(cycled.session.state).toEqual({ kind: 'offline' })
|
||||
})
|
||||
|
||||
it('restarts on the verdict that changed, not on the object that was rebuilt', () => {
|
||||
const checking = started({ statusPending: true })
|
||||
const settled = run(checking.session, { type: 'gates-changed', gates: gates() })
|
||||
expect(settled.effects).toEqual([{ kind: 'open-cache' }])
|
||||
})
|
||||
|
||||
it('walls a check in flight the moment the host stops serving a bundle', () => {
|
||||
const checking = started()
|
||||
const step = run(checking.session, {
|
||||
type: 'gates-changed',
|
||||
gates: gates({ hostCapabilities: [] })
|
||||
})
|
||||
expect(step.session.state).toEqual({
|
||||
kind: 'wall',
|
||||
verdict: { kind: 'blocked', reason: 'bundle-unavailable' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat'
|
||||
import type {
|
||||
CachedGeneration,
|
||||
MobileWebShellBlockedVerdict,
|
||||
MobileWebShellGates,
|
||||
MobileWebShellManifestFacts,
|
||||
MobileWebShellReachability,
|
||||
MobileWebShellReadFailure,
|
||||
MobileWebShellSession,
|
||||
MobileWebShellSessionEffect,
|
||||
MobileWebShellSessionEvent,
|
||||
MobileWebShellSessionState,
|
||||
MobileWebShellStep
|
||||
} from './mobile-web-shell-session-contract'
|
||||
|
||||
/**
|
||||
* The host's connection state as the three answers a step here needs.
|
||||
*
|
||||
* `reconnecting` is unreachable, not connecting, and that is the whole point of the distinction: a
|
||||
* host whose desktop is gone never settles on `disconnected`. The client dials, fails, schedules a
|
||||
* retry and cycles `connecting` -> `reconnecting` -> `connecting` with the delay growing to a
|
||||
* minute, so treating `reconnecting` as "still dialling" leaves a phone with a perfectly good
|
||||
* cached workspace spinning forever. `connecting` alone is the first dial, which is worth the wait
|
||||
* because it usually succeeds; a scheduled retry after a failure is evidence the host is not there.
|
||||
*/
|
||||
export function readMobileWebShellReachability(
|
||||
connState: ConnectionState,
|
||||
client: RpcClient | null
|
||||
): MobileWebShellReachability {
|
||||
if (connState === 'connected') {
|
||||
return client === null ? 'connecting' : 'connected'
|
||||
}
|
||||
return connState === 'connecting' || connState === 'handshaking' ? 'connecting' : 'unreachable'
|
||||
}
|
||||
|
||||
const CHECKING: MobileWebShellSessionState = { kind: 'checking' }
|
||||
|
||||
export function createMobileWebShellSession(): MobileWebShellSession {
|
||||
return {
|
||||
state: CHECKING,
|
||||
retriedOnce: false,
|
||||
remountedOnce: false,
|
||||
gates: null,
|
||||
cached: null,
|
||||
flow: 0
|
||||
}
|
||||
}
|
||||
|
||||
function step(
|
||||
session: MobileWebShellSession,
|
||||
patch: Partial<MobileWebShellSession>,
|
||||
effects: readonly MobileWebShellSessionEffect[] = []
|
||||
): MobileWebShellStep {
|
||||
return { session: { ...session, ...patch }, effects }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a gates change may start or restart the flow.
|
||||
*
|
||||
* Only from the two states still waiting on one. A displayed generation is not restarted by a
|
||||
* reconnect: the manifest check that would follow swaps the page out from under whoever is reading
|
||||
* it, and a cached generation stays valid until the route is entered again. A wall and a terminal
|
||||
* failure are both left by acting, so neither reacts either.
|
||||
*/
|
||||
function awaitsGates(state: MobileWebShellSessionState): boolean {
|
||||
if (state.kind === 'failed') {
|
||||
// The one failure the gates can answer: a status that becomes readable is a different host
|
||||
// screen, and it costs nothing to take it rather than make someone walk back out.
|
||||
return state.reason === 'status-unreadable'
|
||||
}
|
||||
return state.kind === 'checking' || state.kind === 'offline'
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gates permit, before any manifest is read.
|
||||
*
|
||||
* One answer for both ways into the flow. A recovery used to keep whatever gates the `ready`
|
||||
* session was holding and go straight back to the manifest check, and gates that arrive while a
|
||||
* generation is on screen are stored without restarting: a reconnect whose status probe failed
|
||||
* therefore left a ready session carrying an unreadable status and an empty capability list, and
|
||||
* the next view failure walled the host as `bundle-unavailable` — terminal, no retry, about a host
|
||||
* that had simply not answered.
|
||||
*/
|
||||
type MobileWebShellGateVerdict =
|
||||
/** Nothing is decidable yet. Two kinds rather than one so a dial that settles into a pending
|
||||
* status still counts as a change worth restarting on. */
|
||||
| { readonly kind: 'dialling' }
|
||||
| { readonly kind: 'pending' }
|
||||
| { readonly kind: 'offline' }
|
||||
| { readonly kind: 'status-unreadable' }
|
||||
| { readonly kind: 'wall'; readonly verdict: MobileWebShellBlockedVerdict }
|
||||
| { readonly kind: 'open' }
|
||||
|
||||
function gateVerdict(gates: MobileWebShellGates): MobileWebShellGateVerdict {
|
||||
if (gates.reachability === 'connecting') {
|
||||
return { kind: 'dialling' }
|
||||
}
|
||||
if (gates.reachability === 'unreachable') {
|
||||
return { kind: 'offline' }
|
||||
}
|
||||
if (gates.statusPending) {
|
||||
return { kind: 'pending' }
|
||||
}
|
||||
// Never a wall on an unreadable status: the empty capability list it leaves behind is
|
||||
// indistinguishable from a desktop that ships no bundle, and that wall tells the wrong story. It
|
||||
// is not a wait either — the gate settles once per host screen and does not probe again — so the
|
||||
// one honest answer is to say the status could not be read and let a fresh gate reopen it.
|
||||
if (!gates.statusReadable) {
|
||||
return { kind: 'status-unreadable' }
|
||||
}
|
||||
const verdict = evaluateMobileWebBundleCompat({
|
||||
hostCapabilities: gates.hostCapabilities,
|
||||
hostStatus: gates.hostStatus,
|
||||
manifest: null
|
||||
})
|
||||
// Which block, not why: any blocked verdict walls, and the wall reads its own reason.
|
||||
return verdict.kind === 'blocked' ? { kind: 'wall', verdict } : { kind: 'open' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate verdict as one comparable value.
|
||||
*
|
||||
* A restart is worth taking only when this changes. The gates object is rebuilt on every status
|
||||
* refetch and every connection event, and most of those say exactly what the last one said: a
|
||||
* reconnect cycle that re-derives the same verdict used to re-sweep the staging tree and flip an
|
||||
* offline screen to a spinner and back for as long as the cycle ran.
|
||||
*/
|
||||
function gateKey(gates: MobileWebShellGates): string {
|
||||
return gateVerdict(gates).kind
|
||||
}
|
||||
|
||||
/**
|
||||
* The step the gate takes, and every entry into the flow goes through it.
|
||||
*
|
||||
* The first run, the one "Try again" returns to, and the recovery a failed view triggers, which
|
||||
* passes the delete it owes as `before` so the cache goes whatever the gate then decides.
|
||||
*/
|
||||
function startFlow(
|
||||
session: MobileWebShellSession,
|
||||
gates: MobileWebShellGates,
|
||||
patch: Partial<MobileWebShellSession> = {},
|
||||
before: readonly MobileWebShellSessionEffect[] = []
|
||||
): MobileWebShellStep {
|
||||
// A new flow, so nothing the replaced one has in flight can land on this one. That is also what
|
||||
// keeps a status refetch arriving mid-check from running the cache read and the download twice.
|
||||
const base = { ...patch, gates, flow: session.flow + 1 }
|
||||
const verdict = gateVerdict(gates)
|
||||
if (verdict.kind === 'wall') {
|
||||
return step(session, { ...base, state: { kind: 'wall', verdict: verdict.verdict } }, before)
|
||||
}
|
||||
if (verdict.kind === 'status-unreadable') {
|
||||
return step(
|
||||
session,
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
kind: 'failed',
|
||||
reason: 'status-unreadable',
|
||||
retriedOnce: patch.retriedOnce ?? session.retriedOnce
|
||||
}
|
||||
},
|
||||
before
|
||||
)
|
||||
}
|
||||
if (verdict.kind === 'dialling' || verdict.kind === 'pending') {
|
||||
return step(session, { ...base, state: CHECKING }, before)
|
||||
}
|
||||
// Offline sweeps and reads the cache exactly as a connected host does. What it skips is the
|
||||
// compat check, and `onCacheRead` is where that shows.
|
||||
return step(session, { ...base, state: CHECKING }, [...before, { kind: 'open-cache' }])
|
||||
}
|
||||
|
||||
/** Puts a generation that is already on disk on screen. The only producer of `open-generation`. */
|
||||
function openCached(
|
||||
session: MobileWebShellSession,
|
||||
generation: CachedGeneration,
|
||||
patch: Partial<MobileWebShellSession> = {}
|
||||
): MobileWebShellStep {
|
||||
return step(session, { ...patch, state: { kind: 'activating' } }, [
|
||||
{
|
||||
kind: 'open-generation',
|
||||
directory: generation.directory,
|
||||
buildId: generation.buildId,
|
||||
totalBytes: generation.totalBytes
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
function onCacheRead(
|
||||
session: MobileWebShellSession,
|
||||
generation: CachedGeneration | null
|
||||
): MobileWebShellStep {
|
||||
const gates = session.gates
|
||||
if (gates === null) {
|
||||
return step(session, { cached: generation })
|
||||
}
|
||||
if (gates.reachability === 'connecting') {
|
||||
// A dial in progress is not a host that cannot be reached: opening the cache here would skip a
|
||||
// compat check the connection about to land is what makes answerable.
|
||||
return step(session, { cached: generation })
|
||||
}
|
||||
if (gates.reachability === 'unreachable') {
|
||||
// No compat check on this path, by design: the generation was compatible when it was cached and
|
||||
// a host nobody can reach cannot have changed since. The next entry while connected re-checks.
|
||||
return generation === null
|
||||
? step(session, { cached: null, state: { kind: 'offline' } })
|
||||
: openCached(session, generation, { cached: generation })
|
||||
}
|
||||
return step(session, { cached: generation, state: CHECKING }, [{ kind: 'read-manifest' }])
|
||||
}
|
||||
|
||||
function onManifestRead(
|
||||
session: MobileWebShellSession,
|
||||
manifest: MobileWebShellManifestFacts
|
||||
): MobileWebShellStep {
|
||||
const gates = session.gates
|
||||
if (gates === null) {
|
||||
return step(session, {})
|
||||
}
|
||||
const verdict = evaluateMobileWebBundleCompat({
|
||||
hostCapabilities: gates.hostCapabilities,
|
||||
hostStatus: gates.hostStatus,
|
||||
manifest
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
return step(session, { state: { kind: 'wall', verdict } })
|
||||
}
|
||||
const cached = session.cached
|
||||
if (cached !== null && cached.buildId === manifest.buildId) {
|
||||
return openCached(session, cached)
|
||||
}
|
||||
return step(
|
||||
session,
|
||||
{
|
||||
state: {
|
||||
kind: 'fetching',
|
||||
completedAssets: 0,
|
||||
totalAssets: manifest.totalAssets,
|
||||
receivedBytes: 0,
|
||||
totalBytes: manifest.totalBytes
|
||||
}
|
||||
},
|
||||
[{ kind: 'download' }]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* B3's contract, and the only place it is interpreted.
|
||||
*
|
||||
* `generation-unreadable` and `document-load-failed` say the bytes on disk are suspect, so the
|
||||
* host's cache goes and the flow runs once more. `render-process-gone` says nothing about the
|
||||
* bytes — renderer memory pressure and a WebView provider update look identical from here — so it
|
||||
* remounts and never deletes. `isolation-unavailable` is terminal on the first report: the fence is
|
||||
* the whole reason this view exists, and a device that cannot install it will not on a retry.
|
||||
*
|
||||
* Only `ready` hears any of it. The view exists in no other state, so a report arriving outside one
|
||||
* is from a view that has already been taken off screen: the second failure of a native batch that
|
||||
* the first one's recovery has already answered, or a mount that a wall or a retry has replaced.
|
||||
* Acting on it would strand the recovery already in flight — the delete-and-refetch would be made
|
||||
* terminal while its own cache read was still coming back, and that read would then drag the
|
||||
* session back to checking behind a failure screen.
|
||||
*/
|
||||
function onShellFailed(
|
||||
session: MobileWebShellSession,
|
||||
reason: MobileWebShellFailureReason
|
||||
): MobileWebShellStep {
|
||||
if (session.state.kind !== 'ready') {
|
||||
return step(session, {})
|
||||
}
|
||||
const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const
|
||||
if (reason === 'isolation-unavailable') {
|
||||
return step(session, { state: failed })
|
||||
}
|
||||
if (reason === 'render-process-gone') {
|
||||
return session.remountedOnce
|
||||
? step(session, { state: failed })
|
||||
: step(session, { remountedOnce: true }, [{ kind: 'remount' }])
|
||||
}
|
||||
if (session.retriedOnce || session.gates === null) {
|
||||
return step(session, { state: failed })
|
||||
}
|
||||
// Through the gate, not straight back to the manifest check: the gates a ready session holds are
|
||||
// whatever the last reconnect stored, so a recovery that trusted them walled hosts whose status
|
||||
// had gone unreadable underneath a workspace that was, until this failure, working.
|
||||
return startFlow(session, session.gates, { retriedOnce: true, cached: null }, [
|
||||
{ kind: 'delete-cache' }
|
||||
])
|
||||
}
|
||||
|
||||
function onDownloadFailed(
|
||||
session: MobileWebShellSession,
|
||||
failure: MobileWebShellReadFailure
|
||||
): MobileWebShellStep {
|
||||
const cached = session.cached
|
||||
if (failure === 'transport' && cached !== null) {
|
||||
// The link went, not the bundle. A generation already on disk was compatible when it was
|
||||
// written, and it is the same one the offline gate would have opened had the reachability
|
||||
// change arrived before this rejection did; which of the two lands first is a race.
|
||||
return openCached(session, cached)
|
||||
}
|
||||
return step(session, {
|
||||
state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One transition of the hybrid shell session: a state and the effects the runner owes it.
|
||||
*
|
||||
* Pure, so every rule above is a table test rather than a simulator run. The runner may drop an
|
||||
* effect's result (an unmount, a host change) but must never invent one, and a result it reports
|
||||
* late is dropped here by its flow rather than by whatever state the session happens to be in.
|
||||
*/
|
||||
export function reduceMobileWebShellSession(
|
||||
session: MobileWebShellSession,
|
||||
event: MobileWebShellSessionEvent
|
||||
): MobileWebShellStep {
|
||||
if ('flow' in event && event.flow !== session.flow) {
|
||||
return step(session, {})
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'gates-changed':
|
||||
return awaitsGates(session.state) &&
|
||||
(session.gates === null || gateKey(session.gates) !== gateKey(event.gates))
|
||||
? startFlow(session, event.gates)
|
||||
: step(session, { gates: event.gates })
|
||||
case 'cache-read':
|
||||
return onCacheRead(session, event.generation)
|
||||
case 'manifest-read':
|
||||
return onManifestRead(session, event.manifest)
|
||||
case 'fetch-progress':
|
||||
return session.state.kind === 'fetching'
|
||||
? step(session, {
|
||||
state: {
|
||||
kind: 'fetching',
|
||||
completedAssets: event.completedAssets,
|
||||
totalAssets: event.totalAssets,
|
||||
receivedBytes: event.receivedBytes,
|
||||
totalBytes: event.totalBytes
|
||||
}
|
||||
})
|
||||
: step(session, {})
|
||||
case 'download-staged':
|
||||
return session.state.kind === 'fetching'
|
||||
? step(session, { state: { kind: 'activating' } })
|
||||
: step(session, {})
|
||||
case 'activated':
|
||||
return step(session, {
|
||||
state: {
|
||||
kind: 'ready',
|
||||
generationDirectory: event.generationDirectory,
|
||||
sessionId: event.sessionId,
|
||||
buildId: event.buildId,
|
||||
totalBytes: event.totalBytes,
|
||||
elapsedMs: event.elapsedMs
|
||||
}
|
||||
})
|
||||
case 'remounted':
|
||||
// Only the session id changes, so the view remounts against the same verified bytes.
|
||||
return session.state.kind === 'ready'
|
||||
? step(session, { state: { ...session.state, sessionId: event.sessionId } })
|
||||
: step(session, {})
|
||||
case 'download-failed':
|
||||
return onDownloadFailed(session, event.failure)
|
||||
case 'shell-failed':
|
||||
return onShellFailed(session, event.reason)
|
||||
case 'retry-pressed':
|
||||
// Clears both latches, so the delete-and-refetch and the remount are each available again.
|
||||
// Only here: a reconnect is not a reason to grant a second remount of the same session.
|
||||
return session.gates === null
|
||||
? step(session, {
|
||||
retriedOnce: false,
|
||||
remountedOnce: false,
|
||||
state: CHECKING,
|
||||
flow: session.flow + 1
|
||||
})
|
||||
: startFlow(session, session.gates, {
|
||||
retriedOnce: false,
|
||||
remountedOnce: false,
|
||||
cached: null
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
|
||||
import type { MobileWebBundleFetchResult } from '../transport/mobile-web-bundle-fetch'
|
||||
import type { MobileWebBundleManifestRead } from '../transport/mobile-web-bundle-reply-schemas'
|
||||
import type { ActiveGeneration, GenerationStore, StagedGeneration } from './generation-store'
|
||||
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
|
||||
/**
|
||||
* The runner, not the rules: what the reducer decides has table tests, and this covers the three
|
||||
* things only the wiring can get wrong — abandoning an effect whose session is gone, aborting the
|
||||
* bytes it was pulling, and doing both again when someone taps Try again. A cancellation that is
|
||||
* merely intended is a download that keeps four of the host's read slots and a cache write that
|
||||
* lands under a host nobody is looking at any more.
|
||||
*
|
||||
* The React Native and Expo modules are mocked at the edge of the import graph rather than stubbed
|
||||
* one deep, because importing any of them pulls the runtime this test does not have.
|
||||
*/
|
||||
type Settle<T> = (value: T) => void
|
||||
|
||||
type Doubles = {
|
||||
connection: { client: object | null; state: string }
|
||||
gates: {
|
||||
statusPending: boolean
|
||||
statusReadable: boolean
|
||||
hostCapabilities: string[]
|
||||
hostProtocolWindow: { protocolVersion: number; minCompatibleMobileVersion: number }
|
||||
}
|
||||
manifestReads: number
|
||||
manifestClients: unknown[]
|
||||
manifestRejection: unknown
|
||||
fetches: { signal: AbortSignal; settle: Settle<MobileWebBundleFetchResult> }[]
|
||||
manifest: MobileWebBundleManifestRead
|
||||
}
|
||||
|
||||
const doubles = vi.hoisted((): Doubles => {
|
||||
const manifest: MobileWebBundleManifestRead = {
|
||||
schemaVersion: 1,
|
||||
buildId: 'b'.repeat(64),
|
||||
minCompatibleRuntimeProtocolVersion: 2,
|
||||
runtimeProtocolVersion: 5,
|
||||
entrypoint: 'index.html',
|
||||
totalBytes: 2048,
|
||||
assets: [
|
||||
{ path: 'index.html', sha256: 'c'.repeat(64), byteLength: 2048, contentType: 'text/html' }
|
||||
]
|
||||
}
|
||||
return {
|
||||
connection: { client: {}, state: 'connected' },
|
||||
gates: {
|
||||
statusPending: false,
|
||||
statusReadable: true,
|
||||
// Filled in `beforeEach`: a hoisted factory runs before this module's imports do.
|
||||
hostCapabilities: [],
|
||||
hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 }
|
||||
},
|
||||
manifestReads: 0,
|
||||
manifestClients: [],
|
||||
manifestRejection: null,
|
||||
fetches: [],
|
||||
manifest
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
|
||||
vi.mock('expo-file-system', () => ({ Directory: class {}, File: class {}, Paths: { cache: '' } }))
|
||||
vi.mock('../transport/mobile-endpoint-supervisor-support', () => ({
|
||||
encodeBase64Url: () => 'session-id'
|
||||
}))
|
||||
vi.mock('../components/HostProtocolGate', () => ({ useHostProtocolGates: () => doubles.gates }))
|
||||
vi.mock('../transport/client-context', () => ({ useHostClient: () => doubles.connection }))
|
||||
vi.mock('../transport/rpc-operation', () => ({
|
||||
defineRpcOperation: (definition: unknown) => definition,
|
||||
runRpcOperation: async (client: unknown) => {
|
||||
doubles.manifestReads += 1
|
||||
doubles.manifestClients.push(client)
|
||||
if (doubles.manifestRejection !== null) {
|
||||
throw doubles.manifestRejection
|
||||
}
|
||||
return { manifest: doubles.manifest }
|
||||
}
|
||||
}))
|
||||
vi.mock('../transport/mobile-web-bundle-fetch', () => ({
|
||||
fetchMobileWebBundle: (args: { signal: AbortSignal }) =>
|
||||
new Promise<MobileWebBundleFetchResult>((resolve) => {
|
||||
doubles.fetches.push({ signal: args.signal, settle: resolve })
|
||||
})
|
||||
}))
|
||||
|
||||
import { useMobileWebShellSession } from './use-mobile-web-shell-session'
|
||||
|
||||
const HOST_ID = 'host-1'
|
||||
const DIRECTORY = 'file:///cache/mobile-web/host/generations/b'
|
||||
|
||||
function activeGeneration(): ActiveGeneration {
|
||||
return { buildId: doubles.manifest.buildId, directory: DIRECTORY, manifest: doubles.manifest }
|
||||
}
|
||||
|
||||
function stagedGeneration(): StagedGeneration {
|
||||
return {
|
||||
hostKey: 'host-key',
|
||||
buildId: doubles.manifest.buildId,
|
||||
directory: DIRECTORY,
|
||||
manifest: doubles.manifest
|
||||
}
|
||||
}
|
||||
|
||||
/** A store whose cache read is held open, so a test can decide when the answer arrives. Staging can
|
||||
* be held open too, which is the only way to stand inside the window between it and the commit. */
|
||||
function createFakeStore(): {
|
||||
store: GenerationStore
|
||||
settleCacheRead: Settle<ActiveGeneration | null>
|
||||
holdStage: () => void
|
||||
settleStage: () => void
|
||||
staged: () => number
|
||||
committed: () => number
|
||||
aborted: () => number
|
||||
} {
|
||||
let settleCacheRead: Settle<ActiveGeneration | null> = () => {}
|
||||
let releaseStage: () => void = () => {}
|
||||
let heldStage = false
|
||||
let staged = 0
|
||||
let committed = 0
|
||||
let aborted = 0
|
||||
const store: GenerationStore = {
|
||||
readActiveGeneration: () =>
|
||||
new Promise<ActiveGeneration | null>((resolve) => {
|
||||
settleCacheRead = resolve
|
||||
}),
|
||||
stageGeneration: async () => {
|
||||
staged += 1
|
||||
if (heldStage) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStage = resolve
|
||||
})
|
||||
}
|
||||
return stagedGeneration()
|
||||
},
|
||||
commitGeneration: async () => {
|
||||
committed += 1
|
||||
return activeGeneration()
|
||||
},
|
||||
abortStagedGeneration: async () => {
|
||||
aborted += 1
|
||||
},
|
||||
sweepStagedGenerations: async () => undefined,
|
||||
deleteHostCache: async () => undefined
|
||||
}
|
||||
return {
|
||||
store,
|
||||
settleCacheRead: (value) => settleCacheRead(value),
|
||||
holdStage: () => {
|
||||
heldStage = true
|
||||
},
|
||||
settleStage: () => releaseStage(),
|
||||
staged: () => staged,
|
||||
committed: () => committed,
|
||||
aborted: () => aborted
|
||||
}
|
||||
}
|
||||
|
||||
type Mounted = {
|
||||
tree: ReactTestRenderer
|
||||
retry: () => void
|
||||
rerender: () => void
|
||||
states: () => readonly MobileWebShellSessionState[]
|
||||
}
|
||||
|
||||
async function mount(store: GenerationStore): Promise<Mounted> {
|
||||
const handle: { retry: () => void; states: MobileWebShellSessionState[] } = {
|
||||
retry: () => {},
|
||||
states: []
|
||||
}
|
||||
function Probe() {
|
||||
const session = useMobileWebShellSession({
|
||||
hostId: HOST_ID,
|
||||
runtime: { createStore: () => store, mintSessionId: () => 'session-id', now: () => 0 }
|
||||
})
|
||||
handle.retry = session.retry
|
||||
handle.states.push(session.state)
|
||||
return null
|
||||
}
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(Probe))
|
||||
})
|
||||
const tree = rendered.tree
|
||||
if (tree === null) {
|
||||
throw new Error('the hook did not mount')
|
||||
}
|
||||
return {
|
||||
tree,
|
||||
retry: () => handle.retry(),
|
||||
rerender: () => tree.update(createElement(Probe)),
|
||||
states: () => handle.states
|
||||
}
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => undefined)
|
||||
}
|
||||
|
||||
describe('the hybrid shell runner', () => {
|
||||
beforeEach(() => {
|
||||
doubles.manifestReads = 0
|
||||
doubles.manifestClients.length = 0
|
||||
doubles.manifestRejection = null
|
||||
doubles.fetches.length = 0
|
||||
doubles.connection = { client: {}, state: 'connected' }
|
||||
doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY]
|
||||
})
|
||||
|
||||
it('abandons the cache read of a session that has been unmounted', async () => {
|
||||
const fake = createFakeStore()
|
||||
const mounted = await mount(fake.store)
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
// The read came back to nobody: had it been applied, the next effect would have asked the host
|
||||
// for a manifest on behalf of a screen that is gone.
|
||||
expect(doubles.manifestReads).toBe(0)
|
||||
})
|
||||
|
||||
it('aborts the download an unmount interrupts, and never writes what it was pulling', async () => {
|
||||
const fake = createFakeStore()
|
||||
const mounted = await mount(fake.store)
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
expect(doubles.fetches).toHaveLength(1)
|
||||
const inFlight = doubles.fetches[0]
|
||||
if (inFlight === undefined) {
|
||||
throw new Error('no download was started')
|
||||
}
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
expect(inFlight.signal.aborted).toBe(true)
|
||||
inFlight.settle({
|
||||
manifest: doubles.manifest,
|
||||
assets: new Map(),
|
||||
totalBytes: 2048,
|
||||
elapsedMs: 1
|
||||
})
|
||||
await flush()
|
||||
expect(fake.staged()).toBe(0)
|
||||
expect(fake.committed()).toBe(0)
|
||||
})
|
||||
|
||||
it('shows the cached workspace when the socket drops the manifest read it was waiting on', async () => {
|
||||
// The device repro: the rejection reaches the reducer before the reachability change does, so
|
||||
// the offline gate never fires and only the error's own marks say the link was what went.
|
||||
doubles.manifestRejection = markRpcDeliveryUnknown(new Error('Connection interrupted'))
|
||||
const fake = createFakeStore()
|
||||
const mounted = await mount(fake.store)
|
||||
fake.settleCacheRead(activeGeneration())
|
||||
await flush()
|
||||
|
||||
expect(doubles.manifestReads).toBe(1)
|
||||
expect(doubles.fetches).toHaveLength(0)
|
||||
expect(mounted.states().map((state) => state.kind)).toContain('ready')
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the staged tree back out when the unmount lands between staging and the commit', async () => {
|
||||
const fake = createFakeStore()
|
||||
fake.holdStage()
|
||||
const mounted = await mount(fake.store)
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
const inFlight = doubles.fetches[0]
|
||||
if (inFlight === undefined) {
|
||||
throw new Error('no download was started')
|
||||
}
|
||||
inFlight.settle({
|
||||
manifest: doubles.manifest,
|
||||
assets: new Map(),
|
||||
totalBytes: 2048,
|
||||
elapsedMs: 1
|
||||
})
|
||||
await flush()
|
||||
expect(fake.staged()).toBe(1)
|
||||
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
await act(async () => {
|
||||
fake.settleStage()
|
||||
})
|
||||
// The commit is the write the staging tree cannot undo: it renames into the active slot and
|
||||
// moves the host index, so a generation nobody asked for would be the one the next mount opens.
|
||||
expect(fake.committed()).toBe(0)
|
||||
expect(fake.aborted()).toBe(1)
|
||||
expect(mounted.states().map((state) => state.kind)).not.toContain('ready')
|
||||
})
|
||||
|
||||
it('reads the manifest through the client the host has now, not the one it opened with', async () => {
|
||||
const fake = createFakeStore()
|
||||
const mounted = await mount(fake.store)
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
expect(doubles.manifestClients).toHaveLength(1)
|
||||
// A reconnect hands the screen a new client object with the same reachability, so nothing the
|
||||
// gates effect watches changes; only the next flow can show which one the runner kept.
|
||||
const reconnected = {}
|
||||
doubles.connection = { client: reconnected, state: 'connected' }
|
||||
await act(async () => {
|
||||
mounted.rerender()
|
||||
})
|
||||
await act(async () => {
|
||||
mounted.retry()
|
||||
})
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
expect(doubles.manifestClients.at(-1)).toBe(reconnected)
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('abandons the download still in flight when Try again starts a new one', async () => {
|
||||
const fake = createFakeStore()
|
||||
const mounted = await mount(fake.store)
|
||||
fake.settleCacheRead(null)
|
||||
await flush()
|
||||
const first = doubles.fetches[0]
|
||||
if (first === undefined) {
|
||||
throw new Error('no download was started')
|
||||
}
|
||||
await act(async () => {
|
||||
mounted.retry()
|
||||
})
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
first.settle({ manifest: doubles.manifest, assets: new Map(), totalBytes: 2048, elapsedMs: 1 })
|
||||
await flush()
|
||||
expect(fake.staged()).toBe(0)
|
||||
await act(async () => {
|
||||
mounted.tree.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
|
||||
import { useHostProtocolGates } from '../components/HostProtocolGate'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { encodeBase64Url } from '../transport/mobile-endpoint-supervisor-support'
|
||||
import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch'
|
||||
import {
|
||||
isMobileWebBundleTransportFailure,
|
||||
mobileWebBundleManifestRead
|
||||
} from '../transport/mobile-web-bundle-operations'
|
||||
import { runRpcOperation } from '../transport/rpc-operation'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { createGenerationStore, type GenerationStore } from './generation-store'
|
||||
import {
|
||||
createExpoGenerationFileSystem,
|
||||
generationDirectoryPath
|
||||
} from './generation-store-file-system'
|
||||
import { deriveHostCacheKey } from './host-cache-key'
|
||||
import {
|
||||
createMobileWebShellSession,
|
||||
readMobileWebShellReachability,
|
||||
reduceMobileWebShellSession
|
||||
} from './mobile-web-shell-session'
|
||||
import type {
|
||||
MobileWebShellReadFailure,
|
||||
MobileWebShellSessionEffect,
|
||||
MobileWebShellSessionEvent,
|
||||
MobileWebShellSessionState
|
||||
} from './mobile-web-shell-session-contract'
|
||||
|
||||
/** 32 bytes, base64url: the session id scopes the view's private origin, so two mounts must never
|
||||
* share one and a remount must never reuse the one that was just on screen. */
|
||||
const SESSION_ID_BYTES = 32
|
||||
|
||||
/** The impure edges, injectable so the wiring is testable without a simulator. */
|
||||
export type MobileWebShellRuntime = {
|
||||
createStore(): GenerationStore
|
||||
mintSessionId(): string
|
||||
now(): number
|
||||
}
|
||||
|
||||
function defaultRuntime(): MobileWebShellRuntime {
|
||||
return {
|
||||
createStore: () => createGenerationStore({ fileSystem: createExpoGenerationFileSystem() }),
|
||||
mintSessionId: () => encodeBase64Url(ExpoCrypto.getRandomBytes(SESSION_ID_BYTES)),
|
||||
now: Date.now
|
||||
}
|
||||
}
|
||||
|
||||
export type MobileWebShellSessionView = {
|
||||
readonly state: MobileWebShellSessionState
|
||||
readonly retry: () => void
|
||||
/** B3's failure reasons, forwarded verbatim; the reducer owns what each one means. */
|
||||
readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives one hybrid shell session for one host: the reducer decides, this runs what it asks for.
|
||||
*
|
||||
* Every effect result is checked against an epoch before it is dispatched, so an unmount, a host
|
||||
* change or a retry abandons work in flight instead of applying it to the next session. Nothing
|
||||
* here decides anything — a rule that lived in this file would be a rule with no table test.
|
||||
*/
|
||||
export function useMobileWebShellSession(args: {
|
||||
hostId: string
|
||||
runtime?: MobileWebShellRuntime
|
||||
}): MobileWebShellSessionView {
|
||||
const { hostId } = args
|
||||
const gates = useHostProtocolGates()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
|
||||
const runtimeRef = useRef<MobileWebShellRuntime | null>(null)
|
||||
runtimeRef.current ??= args.runtime ?? defaultRuntime()
|
||||
const runtime = runtimeRef.current
|
||||
const storeRef = useRef<GenerationStore | null>(null)
|
||||
storeRef.current ??= runtime.createStore()
|
||||
|
||||
const sessionRef = useRef(createMobileWebShellSession())
|
||||
const [state, setState] = useState(sessionRef.current.state)
|
||||
const hostKey = useMemo(() => deriveHostCacheKey(hostId), [hostId])
|
||||
const startedAtRef = useRef(runtime.now())
|
||||
// Bumped by anything that invalidates work in flight; every dispatch out of an effect checks it.
|
||||
const epochRef = useRef(0)
|
||||
// Aborted on the same bump: a download nobody will use still holds four of the host's read slots.
|
||||
const downloadsRef = useRef<Set<AbortController>>(new Set())
|
||||
const runEffectRef = useRef<
|
||||
((epoch: number, flow: number, effect: MobileWebShellSessionEffect) => void) | null
|
||||
>(null)
|
||||
|
||||
const dispatch = useCallback((epoch: number, event: MobileWebShellSessionEvent): void => {
|
||||
if (epoch !== epochRef.current) {
|
||||
return
|
||||
}
|
||||
const stepped = reduceMobileWebShellSession(sessionRef.current, event)
|
||||
sessionRef.current = stepped.session
|
||||
setState(stepped.session.state)
|
||||
for (const effect of stepped.effects) {
|
||||
// Every effect of a step belongs to the flow that step produced, and its result carries that
|
||||
// number back, so a flow the session has since restarted reports into nothing.
|
||||
runEffectRef.current?.(epoch, stepped.session.flow, effect)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const invalidate = useCallback((): void => {
|
||||
epochRef.current += 1
|
||||
for (const controller of downloadsRef.current) {
|
||||
controller.abort()
|
||||
}
|
||||
downloadsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const runEffect = useCallback(
|
||||
async (epoch: number, flow: number, effect: MobileWebShellSessionEffect): Promise<void> => {
|
||||
const store = storeRef.current
|
||||
if (store === null) {
|
||||
return
|
||||
}
|
||||
const send = (event: MobileWebShellSessionEvent) => dispatch(epoch, event)
|
||||
switch (effect.kind) {
|
||||
case 'delete-cache':
|
||||
// Reports nothing: the store serialises its own queue, so the sweep and read the reducer
|
||||
// queued behind this one already run after it.
|
||||
await store.deleteHostCache(hostKey).catch(() => undefined)
|
||||
return
|
||||
case 'open-cache':
|
||||
send({ type: 'cache-read', flow, generation: await openCache(store, hostKey) })
|
||||
return
|
||||
case 'read-manifest':
|
||||
await readManifest(client, flow, send)
|
||||
return
|
||||
case 'download':
|
||||
await download({
|
||||
client,
|
||||
store,
|
||||
hostKey,
|
||||
flow,
|
||||
runtime,
|
||||
startedAt: startedAtRef.current,
|
||||
downloads: downloadsRef.current,
|
||||
send
|
||||
})
|
||||
return
|
||||
case 'open-generation':
|
||||
send({
|
||||
type: 'activated',
|
||||
flow,
|
||||
generationDirectory: effect.directory,
|
||||
sessionId: runtime.mintSessionId(),
|
||||
buildId: effect.buildId,
|
||||
totalBytes: effect.totalBytes,
|
||||
elapsedMs: runtime.now() - startedAtRef.current
|
||||
})
|
||||
return
|
||||
case 'remount':
|
||||
send({ type: 'remounted', flow, sessionId: runtime.mintSessionId() })
|
||||
return
|
||||
}
|
||||
},
|
||||
[client, dispatch, hostKey, runtime]
|
||||
)
|
||||
// Written after the commit, never during render: React may replay or discard a render, and a
|
||||
// closure from one that never committed would run effects for a session that never existed.
|
||||
// Declared above every effect that dispatches, so the first one already finds it.
|
||||
useEffect(() => {
|
||||
runEffectRef.current = (epoch, flow, effect) => {
|
||||
void runEffect(epoch, flow, effect)
|
||||
}
|
||||
}, [runEffect])
|
||||
|
||||
useEffect(() => {
|
||||
// A new host is a new session: the old one's latches, cache handle and in-flight work all go.
|
||||
invalidate()
|
||||
sessionRef.current = createMobileWebShellSession()
|
||||
startedAtRef.current = runtime.now()
|
||||
setState(sessionRef.current.state)
|
||||
return invalidate
|
||||
}, [hostId, invalidate, runtime])
|
||||
|
||||
const { statusPending, statusReadable, hostCapabilities, hostProtocolWindow } = gates
|
||||
const reachability = readMobileWebShellReachability(connState, client)
|
||||
useEffect(() => {
|
||||
dispatch(epochRef.current, {
|
||||
type: 'gates-changed',
|
||||
gates: {
|
||||
statusPending,
|
||||
statusReadable,
|
||||
reachability,
|
||||
hostCapabilities,
|
||||
hostStatus: hostProtocolWindow
|
||||
}
|
||||
})
|
||||
// `hostId` is in the list for the host whose gates read identically to the last one's: the
|
||||
// reducer now starts nothing on a repeat verdict, so a session that never re-armed would sit
|
||||
// in `checking` forever.
|
||||
}, [
|
||||
dispatch,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
hostProtocolWindow,
|
||||
reachability,
|
||||
statusPending,
|
||||
statusReadable
|
||||
])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
// A fresh epoch first: a failed download still in flight must not land on the retried session.
|
||||
invalidate()
|
||||
startedAtRef.current = runtime.now()
|
||||
dispatch(epochRef.current, { type: 'retry-pressed' })
|
||||
}, [dispatch, invalidate, runtime])
|
||||
|
||||
const reportShellFailure = useCallback(
|
||||
(reason: MobileWebShellFailureReason) => {
|
||||
dispatch(epochRef.current, { type: 'shell-failed', reason })
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return { state, retry, reportShellFailure }
|
||||
}
|
||||
|
||||
async function openCache(
|
||||
store: GenerationStore,
|
||||
hostKey: string
|
||||
): Promise<{ buildId: string; directory: string; totalBytes: number } | null> {
|
||||
try {
|
||||
// Here and nowhere earlier: with the flag off no code path reaches this hook, so a store build
|
||||
// never sweeps a cache it never wrote.
|
||||
await store.sweepStagedGenerations()
|
||||
const active = await store.readActiveGeneration(hostKey)
|
||||
return active === null
|
||||
? null
|
||||
: {
|
||||
buildId: active.buildId,
|
||||
directory: generationDirectoryPath(active.directory),
|
||||
totalBytes: active.manifest.totalBytes
|
||||
}
|
||||
} catch {
|
||||
// A cache that cannot be read is not a cache that is wrong: nothing is deleted, and the flow
|
||||
// treats it as absent, which downloads when connected and says so when not.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** A rejection the link caused says nothing about the bundle, and the reducer opens the cache on it
|
||||
* rather than telling a phone that already holds a workspace it could not be downloaded. */
|
||||
function readFailure(error: unknown): MobileWebShellReadFailure {
|
||||
return isMobileWebBundleTransportFailure(error) ? 'transport' : 'bundle'
|
||||
}
|
||||
|
||||
async function readManifest(
|
||||
client: RpcClient | null,
|
||||
flow: number,
|
||||
send: (event: MobileWebShellSessionEvent) => void
|
||||
): Promise<void> {
|
||||
if (client === null) {
|
||||
// No client is no link, and the gates are about to say so.
|
||||
send({ type: 'download-failed', flow, failure: 'transport' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const opened = await runRpcOperation(client, mobileWebBundleManifestRead, null)
|
||||
const manifest = opened.manifest
|
||||
send({
|
||||
type: 'manifest-read',
|
||||
flow,
|
||||
manifest: {
|
||||
buildId: manifest.buildId,
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
runtimeProtocolVersion: manifest.runtimeProtocolVersion,
|
||||
minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion,
|
||||
totalBytes: manifest.totalBytes,
|
||||
totalAssets: manifest.assets.length
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
send({ type: 'download-failed', flow, failure: readFailure(error) })
|
||||
}
|
||||
}
|
||||
|
||||
async function download(args: {
|
||||
client: RpcClient | null
|
||||
store: GenerationStore
|
||||
hostKey: string
|
||||
flow: number
|
||||
runtime: MobileWebShellRuntime
|
||||
startedAt: number
|
||||
downloads: Set<AbortController>
|
||||
send: (event: MobileWebShellSessionEvent) => void
|
||||
}): Promise<void> {
|
||||
const { client, store, hostKey, flow, runtime, send } = args
|
||||
if (client === null) {
|
||||
send({ type: 'download-failed', flow, failure: 'transport' })
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
args.downloads.add(controller)
|
||||
try {
|
||||
const fetched = await fetchMobileWebBundle({
|
||||
client,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => send({ type: 'fetch-progress', flow, ...progress })
|
||||
})
|
||||
// The bytes are in; the session they were for may not be. The fetch throws on an abort it sees,
|
||||
// but an abort landing between its last read and this line would otherwise still write a
|
||||
// generation for a host screen nobody is on any more.
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
send({ type: 'download-staged', flow })
|
||||
const staged = await store.stageGeneration(hostKey, fetched)
|
||||
// Again before the commit, because the commit is the write that is not the staging tree's to
|
||||
// undo: it renames into the active slot and moves the host index. An abort that landed while
|
||||
// the bytes were being staged takes the staged tree back out instead.
|
||||
if (controller.signal.aborted) {
|
||||
await store.abortStagedGeneration(staged).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const committed = await store.commitGeneration(staged)
|
||||
send({
|
||||
type: 'activated',
|
||||
flow,
|
||||
generationDirectory: generationDirectoryPath(committed.directory),
|
||||
sessionId: runtime.mintSessionId(),
|
||||
buildId: committed.buildId,
|
||||
totalBytes: committed.manifest.totalBytes,
|
||||
elapsedMs: runtime.now() - args.startedAt
|
||||
})
|
||||
} catch (error) {
|
||||
send({ type: 'download-failed', flow, failure: readFailure(error) })
|
||||
} finally {
|
||||
args.downloads.delete(controller)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
clampHostSidebarWidth,
|
||||
loadDisabledTerminalLiveInputHandles,
|
||||
loadHostSidebarWidth,
|
||||
loadMobileWebShellEnabled,
|
||||
loadPushNotificationsEnabled,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
@@ -504,3 +505,40 @@ describe('terminal link open mode preference', () => {
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalLinkOpenMode', 'phone-browser')
|
||||
})
|
||||
})
|
||||
|
||||
/** `__DEV__` is a React Native global, absent outside that runtime; assigned rather than cast so
|
||||
* the test says which build kind it is running as without asserting a type on `globalThis`. */
|
||||
function setDevelopmentBuild(isDevelopmentBuild: boolean | undefined): void {
|
||||
if (isDevelopmentBuild === undefined) {
|
||||
Reflect.deleteProperty(globalThis, '__DEV__')
|
||||
return
|
||||
}
|
||||
Object.assign(globalThis, { __DEV__: isDevelopmentBuild })
|
||||
}
|
||||
|
||||
describe('hybrid shell flag', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
setDevelopmentBuild(undefined)
|
||||
})
|
||||
|
||||
it('reads the developer toggle in a development build', async () => {
|
||||
setDevelopmentBuild(true)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('true')
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(true)
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:mobileWebShellEnabled')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a release build', false],
|
||||
['a runtime with no __DEV__ at all', undefined]
|
||||
])('is off in %s even with the key left on, and never reads it', async (_label, isDev) => {
|
||||
setDevelopmentBuild(isDev)
|
||||
// The value a development build left behind in a container the install-over kept.
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('true')
|
||||
|
||||
await expect(loadMobileWebShellEnabled()).resolves.toBe(false)
|
||||
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -117,6 +117,30 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
|
||||
}
|
||||
|
||||
const MOBILE_WEB_SHELL_KEY = 'orca:mobileWebShellEnabled'
|
||||
|
||||
// Why: the hybrid shell route is dark. Default-off means a store build never fetches, writes or
|
||||
// sweeps a bundle cache, and the only writer is the __DEV__ Troubleshoot toggle — anything but
|
||||
// `'true'`, including an unreadable store, is off.
|
||||
export async function loadMobileWebShellEnabled(): Promise<boolean> {
|
||||
// A release build never reads the key at all: it shares its bundle id with the development build
|
||||
// and the iOS data container survives an install-over, so a flag a developer left on would
|
||||
// otherwise follow the store build in and mount the shell on a deep link.
|
||||
if (typeof __DEV__ === 'undefined' || !__DEV__) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(MOBILE_WEB_SHELL_KEY)
|
||||
return raw === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMobileWebShellEnabled(enabled: boolean): Promise<void> {
|
||||
await AsyncStorage.setItem(MOBILE_WEB_SHELL_KEY, String(enabled))
|
||||
}
|
||||
|
||||
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
|
||||
|
||||
export type DisabledTerminalLiveInputHandlesPreference = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
import { hostStatusProbe, readHostStatusGates } from './host-status-probe-operations'
|
||||
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
|
||||
import type { HostStatusReply } from './host-status-reply-schema'
|
||||
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
|
||||
|
||||
export type HostStatusGates = {
|
||||
@@ -10,7 +11,17 @@ export type HostStatusGates = {
|
||||
floatingWorkspaceEnabled: boolean
|
||||
desktopAppVersion: string | null
|
||||
compatVerdict: CompatVerdict
|
||||
/** The two protocol numbers the status carried, for callers that evaluate a compat window this
|
||||
* hook does not own — the mobile web bundle's. Kept as the reply's own fields rather than a
|
||||
* restated shape so a rename upstream is a build error here. */
|
||||
hostProtocolWindow: HostProtocolWindow
|
||||
statusPending: boolean
|
||||
/** Whether the settled answer came from a status this host actually returned and this client
|
||||
* could decode. Both failure paths below settle the same closed gates an old host with no
|
||||
* capabilities would produce, so without this a caller cannot tell "this desktop does not have
|
||||
* the feature" from "nobody answered" — and the mobile web shell's wall is terminal, so it must
|
||||
* never be shown for the second. */
|
||||
statusReadable: boolean
|
||||
}
|
||||
|
||||
// statusPending is not stored: pending-ness belongs to the live connection, not to the answer.
|
||||
@@ -19,7 +30,19 @@ type LoadedHostStatusGates = Omit<HostStatusGates, 'statusPending'> & {
|
||||
client: RpcClient
|
||||
}
|
||||
|
||||
export type HostProtocolWindow = Pick<
|
||||
HostStatusReply,
|
||||
'protocolVersion' | 'minCompatibleMobileVersion'
|
||||
>
|
||||
|
||||
const EMPTY_HOST_CAPABILITIES: string[] = []
|
||||
// Stable identities: consumers compare gates by reference to decide whether to re-run a step.
|
||||
// Both keys stated: the reply schema salvages them as present-and-possibly-undefined, and
|
||||
// `evaluateMobileWebBundleCompat` reads an absent number as "oldest host" and "no floor".
|
||||
const EMPTY_HOST_PROTOCOL_WINDOW: HostProtocolWindow = {
|
||||
protocolVersion: undefined,
|
||||
minCompatibleMobileVersion: undefined
|
||||
}
|
||||
|
||||
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
|
||||
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
|
||||
@@ -57,7 +80,9 @@ export function useHostStatusGates(args: {
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
compatVerdict: { kind: 'ok' },
|
||||
hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW,
|
||||
statusReadable: false
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -73,7 +98,12 @@ export function useHostStatusGates(args: {
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
desktopAppVersion,
|
||||
compatVerdict: verdict
|
||||
compatVerdict: verdict,
|
||||
hostProtocolWindow: {
|
||||
protocolVersion: status.protocolVersion,
|
||||
minCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
},
|
||||
statusReadable: true
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
@@ -91,7 +121,9 @@ export function useHostStatusGates(args: {
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
compatVerdict: { kind: 'ok' },
|
||||
hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW,
|
||||
statusReadable: false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,7 +141,9 @@ export function useHostStatusGates(args: {
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: connState === 'connected' && client !== null
|
||||
hostProtocolWindow: EMPTY_HOST_PROTOCOL_WINDOW,
|
||||
statusPending: connState === 'connected' && client !== null,
|
||||
statusReadable: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -117,6 +151,8 @@ export function useHostStatusGates(args: {
|
||||
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
|
||||
desktopAppVersion: proven.desktopAppVersion,
|
||||
compatVerdict: proven.compatVerdict,
|
||||
hostProtocolWindow: proven.hostProtocolWindow,
|
||||
statusReadable: proven.statusReadable,
|
||||
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
|
||||
// longer blanks the capabilities this same host already proved.
|
||||
statusPending: connState === 'connected' && unverified
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
MobileWebBundleChunkReplySchema,
|
||||
MobileWebBundleManifestReplySchema
|
||||
} from './mobile-web-bundle-reply-schemas'
|
||||
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { defineRpcOperation } from './rpc-operation'
|
||||
import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
|
||||
import { rpcResultVariant } from './rpc-operation-result-reader'
|
||||
|
||||
// The two reads that hand a paired phone the desktop's mobile web bundle. Both are
|
||||
@@ -75,3 +77,16 @@ export function readMobileWebBundleErrorCode(error: unknown): MobileWebBundleErr
|
||||
const parsed = MobileWebBundleErrorCodeSchema.safeParse(nested)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a bundle read failed on the link to the host rather than on the bundle it serves.
|
||||
*
|
||||
* Both marks come from the transport itself: delivery-unknown on every request a socket close, a
|
||||
* relay drop or a timeout cut off, and the cutover error on a connection migration. Nothing else
|
||||
* qualifies, on purpose — the fetch raises plain errors for a hash mismatch, a short asset and a
|
||||
* build that changed mid-fetch, and every one of those is a verdict about the bytes that arrived.
|
||||
* `readMobileWebBundleErrorCode` above reads the host's own refusals, which are verdicts too.
|
||||
*/
|
||||
export function isMobileWebBundleTransportFailure(error: unknown): boolean {
|
||||
return isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
|
||||
import { evaluateMobileWebBundleCompat } from './mobile-web-bundle-compat'
|
||||
import {
|
||||
isMobileWebBundleTransportFailure,
|
||||
mobileWebBundleChunkRead,
|
||||
mobileWebBundleManifestRead,
|
||||
readMobileWebBundleErrorCode
|
||||
} from './mobile-web-bundle-operations'
|
||||
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import { MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas'
|
||||
import type { RpcReadResult } from './rpc-operation-contract'
|
||||
|
||||
@@ -329,3 +331,30 @@ describe('mobile web bundle operation descriptors', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('which side a bundle read failed on', () => {
|
||||
it('reads the transport marks the transport itself sets', () => {
|
||||
// Every socket close, relay drop and request timeout rejects in-flight requests with this mark.
|
||||
expect(
|
||||
isMobileWebBundleTransportFailure(markRpcDeliveryUnknown(new Error('Connection closed')))
|
||||
).toBe(true)
|
||||
// The cutover error matches by message as well as by class, across bundle copies.
|
||||
expect(
|
||||
isMobileWebBundleTransportFailure(new Error('RPC interrupted by connection migration'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a host refusal', `invalid_argument: ${MOBILE_WEB_BUNDLE_ERROR_CODES[0]}`],
|
||||
['bytes that do not hash', 'bundle asset index.html hashed aa, not bb'],
|
||||
['a build that changed mid-fetch', 'bundle build changed mid-fetch: asked aa, served bb'],
|
||||
['an unread reply', 'The host sent a reply this app could not read (mobileWeb.bundle.manifest)']
|
||||
])('treats %s as a verdict about the bundle', (_label, message) => {
|
||||
expect(isMobileWebBundleTransportFailure(new Error(message))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats anything that is not an error as a verdict too, rather than guessing', () => {
|
||||
expect(isMobileWebBundleTransportFailure('Connection closed')).toBe(false)
|
||||
expect(isMobileWebBundleTransportFailure(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user