mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
* feat(mobile): give the page a haptics notify and the grant that gates it `native.haptics.trigger` joins the envelope's notify union with a `kind` of exactly the five `src/platform/haptics.ts` has, and the single token `haptics` joins `BRIDGE_NOTIFY_GRANTS` and `MOBILE_WEB_SHELL_GRANTS`. A notify rather than a verb because nothing is owed back: a reply would spend a slot in the same 64-deep in-flight window a forwarded request does, and there are 90 call sites in this app, some of them one per row of a scrolling list (rulings-ota-c7.md ruling 30). The arm's fields live in their own module because `bridge-envelope.ts` is at its line cap, as `bridge-event-envelope-bytes.ts` already is; the version literal stays in the envelope, so the fields are spread in beside it rather than reading it back through an import cycle. The shell's half rides `onHaptic` on `BridgeHostOptions`, as every other device-local notify does: the host is the protocol's side of the bridge and a static import of the app's haptics would put `react-native` and `expo-haptics` in its graph, which breaks every test that loads it. `page-haptics.ts` is the one mapping — `haptics.ts`'s own functions, its `Platform.OS` split and its Android `HapticFeedbackConstants` untouched. The dispatch branch rides along with the union rather than waiting for the page side: `Record<BridgeNotifyName, …>` and the `notify` fall-through are total over that union, so the shell does not compile without it. That is the totality working, and `bridge-notify-grants.test.ts` shows it as the TS2741 a missing row is. Red first: the envelope cases per kind, the ungranted refusal, the grant-list pin and the missing-row type error all failed against the tree before this. Control on the dispatch: neutering `options.onHaptic` reds 2 of the 29 cases. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): post the page's haptics over the notify instead of doing nothing `haptics.web.ts` stops being five no-ops. Each of the five posts its own kind through the notify seam the entry publishes — the same shape `publishExternalLinkOpener` has, and for the same reason: every caller is a plain function inside a row's press handler that no provider wraps. `notifyHaptics` joins the page client beside the other gated notifies and answers whether the frame left, which nothing reads: a tap that did not buzz is what the page did before this, and a warning per refusal would be one per row of a scrolling list. Measured off the frame the client posted rather than a written copy of its shape, which is what drifts: 77 / 74 / 72 / 70 / 73 bytes for mediumImpact / selection / success / error / edgeBump, the widest under 0.012% of `BRIDGE_MAX_MESSAGE_BYTES`, and a twelve-row scroll 888 bytes across twelve frames. The `web-overrides.json` reason now says what the file does instead of what it declines to do. Red first: the nine web-seam cases failed on `publishHapticsNotifier is not a function`, and the six client cases on `notifyHaptics is not a function`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): grant haptics on every page route, with a census that derives the list All five declared page routes carry the `haptics` grant, and the list is a measurement rather than a hand choice: `WorktreeListRow` is in every page closure and calls the seam, so a route without the grant is a page whose taps stop buzzing with nothing on screen to say why. Grants are resolved once from the route the shell opened and held for the session, so the declaration is the only place to fix it. `mobile-web-app-haptics-seam.mjs` is the shared walk, beside the external-link one: it reads the kinds off the tuple that declares them, finds every exported `trigger…` function in a haptics module, and reports the kind each one posts. The posting call is found through the binding `publishHapticsNotifier` assigns rather than a local spelled `post`, because a rename would otherwise turn every posting site into a non-posting one and leave this green on a page with no haptics at all. The census proper holds each route's closure to the `.web.ts` sibling, asserts at least one importer so the grant is not idle, and derives the granted-route list from the closures. The control is the design's: the same walk over the native sibling finds the same five functions and no posting site, so "all five post" is a number rather than an empty scan. Controls run: dropping `haptics` from one route reds 1 of 23; neutering one web post reds 1 of 23. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record what the haptics notify costs a page closure One module. Every page closure grew by exactly `bridge-haptics-notify.ts`, and it arrives through `page-route-policy.ts` reading the grant token rather than through the seam, whose import of the kind type is erased; its only dependency is `zod`, which the envelope already put in every closure, so the module total moved by the same one. Local counts per route went 294 → 295, 379 → 380, 435 → 436, 309 → 310, 335 → 336. Pinned structurally rather than as a total, because an absolute closure count is main's to move and a number that drifts for unrelated reasons is one nobody reads. The call sites this replaces, measured over product modules: `triggerError` 43, `triggerSuccess` 24, `triggerSelection` 12, `triggerMediumImpact` 10, `triggerEdgeBump` 1 — 90 across 35 importing modules, which is the design's count plus `page-haptics.ts` itself. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry the haptics grant into the shell's two grant pins `bridge-host-init.test.ts` names the grants `init` issues, so the token belongs in that list. `MobileWebShellScreen.test.tsx` now mocks `expo-haptics` for the reason it already mocks the clipboard and both pickers: the screen hands `playPageHaptic` over and reaching the real module pulls in an Expo runtime this test does not have, which failed the whole suite at import. Which expo member each kind reaches stays in `page-haptics.test.ts`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): map each haptic kind to a named import, not a namespace index The changed-code gate refuses a computed reference into an imported namespace, in both the mapping and its test, and it is right to: `haptics[NAME_BY_KIND[kind]]()` is a call nothing can follow. Each function is a named import instead, which also keeps the second compile-time direction — a row naming something `haptics.ts` does not export is now an import error rather than a `keyof` mismatch. The third direction moves with it, from a namespace read in the test to the census that already reads both files' text: `hapticsImportedNames` names what the shell's mapping takes from the app's haptics, and the census holds that to the five the native file exports. So a haptic added there with no kind of its own still fails, and now it fails where the other two siblings' names are already compared. The test's two `as` assertions become one annotated hoisted type, the shape `MobileWebShellScreen.test.tsx` uses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the true reason the haptics grant is one token `GRANT_NAME_PATTERN` accepts `native.haptics.trigger` — it admits `native.<a>.<b>` with lowercase segments, which is why it rejected `native.media.readChunk` and rejects `navigate-back`, not a dotted name as such. So four comments claiming a route declaring the notify's own name would have its bundle refused were false, and they are gone: the grant is a token because the notify table's grants are tokens, a notify not being a verb, and the dotted names in `MOBILE_WEB_SHELL_GRANTS` are spread from the verb table alone. Also folded, with the false claim: `implementedPageRoutes` filters on `grants.every(implementsGrant)`, so a token every page route declares couples the whole set to a shell that carries it — against one without it, no page route is served at all and the phone renders five native screens. Stated in the function's docstring and beside the census's derived list, and pinned: the same declaration under a grant this build does not implement comes back empty, with the token-free route as the control. Removing `BRIDGE_HAPTICS_GRANT` from `MOBILE_WEB_SHELL_GRANTS` reds that case. `%#` consumes no argument, so the web seam's five cases were titled with the whole function body; the kind is the first element now and `%s` names it. One 110-char comment line in `bridge-client-notifications.ts` wrapped to the file's 100; the two still over it there are main's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
649 lines
25 KiB
TypeScript
649 lines
25 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
import type { FakeRpcClient } from './bridge-host-test-fakes'
|
|
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
|
|
|
|
type ScreenDependencies = {
|
|
retry: Mock
|
|
reportShellFailure: Mock
|
|
reportDocumentLoaded: Mock
|
|
reportPageReady: Mock
|
|
/** The profile read rejected, which is the one state that has no host to build against. */
|
|
snapshotUnreadable: boolean
|
|
storageRefreshes: number
|
|
openUrl: Mock
|
|
push: Mock
|
|
back: Mock
|
|
/** What the native stack answers: false is a page opened as the first screen on it. */
|
|
canGoBack: boolean
|
|
pathname: string
|
|
pageRoutes: readonly string[]
|
|
routeGrants: readonly string[]
|
|
lifecycle: string[]
|
|
/** Every render of the shell view, which is one per render of the screen above it. */
|
|
viewRenders: number
|
|
state: MobileWebShellSessionState
|
|
/** Null for every case but the bridge's: with no client the hook builds no host at all. */
|
|
client: FakeRpcClient | null
|
|
}
|
|
|
|
const SNAPSHOT = vi.hoisted(() => ({
|
|
host: { id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 }
|
|
}))
|
|
|
|
const DEFAULT_ROUTE_GRANTS = vi.hoisted((): readonly string[] => [
|
|
'navigate',
|
|
'storage',
|
|
'externalLink',
|
|
'native.clipboard.write'
|
|
])
|
|
|
|
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(),
|
|
reportDocumentLoaded: vi.fn(),
|
|
reportPageReady: vi.fn(),
|
|
snapshotUnreadable: false,
|
|
storageRefreshes: 0,
|
|
openUrl: vi.fn(),
|
|
push: vi.fn(),
|
|
back: vi.fn(),
|
|
canGoBack: true,
|
|
pathname: '/h/host-1',
|
|
pageRoutes: ['/h/[hostId]'],
|
|
routeGrants: DEFAULT_ROUTE_GRANTS,
|
|
lifecycle: [],
|
|
viewRenders: 0,
|
|
state: { kind: 'checking' },
|
|
client: null
|
|
}
|
|
})
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Linking: { openURL: dependencies.openUrl },
|
|
Platform: { OS: 'ios' },
|
|
Pressable: 'Pressable',
|
|
StyleSheet: { create: (styles: unknown) => styles },
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
// Reaching the real one imports the Expo runtime this test does not have. The screen only passes
|
|
// the handler through; what it does with a verb is `native-clipboard.test.ts`.
|
|
vi.mock('expo-clipboard', () => ({
|
|
setStringAsync: () => Promise.resolve(true),
|
|
getStringAsync: () => Promise.resolve('')
|
|
}))
|
|
// Same reason, and the screen only hands `playPageHaptic` over: which expo member each kind
|
|
// reaches is `page-haptics.test.ts`. `Platform.OS` above is pinned to `ios`, so the Android
|
|
// members are never evaluated and are not listed.
|
|
vi.mock('expo-haptics', () => ({
|
|
impactAsync: () => Promise.resolve(),
|
|
notificationAsync: () => Promise.resolve(),
|
|
selectionAsync: () => Promise.resolve(),
|
|
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' },
|
|
NotificationFeedbackType: { Error: 'error', Success: 'success' }
|
|
}))
|
|
vi.mock('expo-document-picker', () => ({ getDocumentAsync: () => Promise.resolve(null) }))
|
|
vi.mock('expo-image-picker', () => ({
|
|
launchImageLibraryAsync: () => Promise.resolve({ canceled: true }),
|
|
requestMediaLibraryPermissionsAsync: () => Promise.resolve({ granted: false })
|
|
}))
|
|
vi.mock('expo-file-system', () => ({
|
|
File: class {
|
|
readonly size = 0
|
|
delete(): void {}
|
|
},
|
|
Paths: { cache: 'file:///cache' }
|
|
}))
|
|
vi.mock('react-native-safe-area-context', () => ({
|
|
useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
|
|
}))
|
|
vi.mock('expo-router', () => ({
|
|
router: { replace: vi.fn() },
|
|
useRouter: () => ({
|
|
push: dependencies.push,
|
|
back: dependencies.back,
|
|
canGoBack: () => dependencies.canGoBack
|
|
}),
|
|
// Read by the pop latch, which clears on the route this shell is mounted at changing.
|
|
usePathname: () => dependencies.pathname
|
|
}))
|
|
// 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 }) => {
|
|
dependencies.viewRenders += 1
|
|
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
|
|
}
|
|
})
|
|
// The real bridge hook runs, so the props it owns are the ones the view is handed here; only the
|
|
// client lookup is stubbed, because reaching it imports the Expo runtime this test does not have.
|
|
vi.mock('../transport/client-context', () => ({
|
|
useHostClient: () => ({ client: dependencies.client })
|
|
}))
|
|
// Reaching the real one imports the host store and expo-secure-store, whose module touches an Expo
|
|
// global this test does not have. What it answers is the screen's input, not its behaviour.
|
|
vi.mock('./use-page-host-snapshot', () => ({
|
|
usePageHostSnapshot: () => ({
|
|
// One object for the life of the file, as the real hook's `useState` gives. A fresh literal per
|
|
// render changes the identity the host effect is keyed on, so the bridge host was being torn
|
|
// down and rebuilt on every render of this screen — and every pending request settled with it.
|
|
snapshot: SNAPSHOT,
|
|
unreadable: dependencies.snapshotUnreadable,
|
|
readStorage: () => ({}),
|
|
refreshStorage: () => {
|
|
dependencies.storageRefreshes += 1
|
|
},
|
|
writeStorage: () => {}
|
|
})
|
|
}))
|
|
vi.mock('./use-mobile-web-shell-session', () => ({
|
|
useMobileWebShellSession: () => ({
|
|
state: dependencies.state,
|
|
pageRoutes: dependencies.pageRoutes,
|
|
routeGrants: dependencies.routeGrants,
|
|
retry: dependencies.retry,
|
|
reportShellFailure: dependencies.reportShellFailure,
|
|
reportDocumentLoaded: dependencies.reportDocumentLoaded,
|
|
reportPageReady: dependencies.reportPageReady
|
|
})
|
|
}))
|
|
|
|
import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
|
|
import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge/bridge-envelope'
|
|
import { MobileWebShellScreen } from './MobileWebShellScreen'
|
|
|
|
/** The caller's native screen, as a component so `findAllByType` can name it without a host string. */
|
|
function NativeFallback(): null {
|
|
return null
|
|
}
|
|
|
|
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',
|
|
route: { pathname: '/h/host-1' },
|
|
fallback: createElement(NativeFallback)
|
|
})
|
|
)
|
|
})
|
|
if (rendered.tree === null) {
|
|
throw new Error('screen did not render')
|
|
}
|
|
mounted.push(rendered.tree)
|
|
return rendered.tree
|
|
}
|
|
|
|
/** Unmounted between cases: the shell's stack latch is one per stack, so a screen left mounted is
|
|
* a screen still holding whatever pop it took. */
|
|
const mounted: ReactTestRenderer[] = []
|
|
|
|
function unmountRenderedScreens(): void {
|
|
act(() => {
|
|
for (const tree of mounted.splice(0)) {
|
|
tree.unmount()
|
|
}
|
|
})
|
|
}
|
|
|
|
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',
|
|
route: { pathname: '/h/host-1' },
|
|
fallback: createElement(NativeFallback)
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
/** 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')
|
|
}
|
|
|
|
afterEach(unmountRenderedScreens)
|
|
|
|
/**
|
|
* File-level, not per describe: every block here shares one mutable `dependencies`, so a reset
|
|
* scoped to one of them leaves whatever the others set. `routeGrants` is reset for that reason —
|
|
* a case that grants the screencast lane would otherwise hand it to every case that follows.
|
|
*/
|
|
beforeEach(() => {
|
|
dependencies.retry.mockReset()
|
|
dependencies.reportShellFailure.mockReset()
|
|
dependencies.reportDocumentLoaded.mockReset()
|
|
dependencies.reportPageReady.mockReset()
|
|
dependencies.snapshotUnreadable = false
|
|
dependencies.storageRefreshes = 0
|
|
dependencies.lifecycle.length = 0
|
|
dependencies.viewRenders = 0
|
|
dependencies.client = null
|
|
dependencies.routeGrants = DEFAULT_ROUTE_GRANTS
|
|
dependencies.back.mockReset()
|
|
dependencies.openUrl.mockReset()
|
|
dependencies.openUrl.mockImplementation(() => Promise.resolve(true))
|
|
dependencies.canGoBack = true
|
|
dependencies.pathname = '/h/host-1'
|
|
})
|
|
|
|
describe('the hybrid shell screen', () => {
|
|
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('opens the bridge channel on a ready session and hands it a receiver', async () => {
|
|
const tree = await render(readyState('session-one'))
|
|
const view = byName(tree, 'ShellViewProbe')[0]
|
|
expect(view.props.bridgeEnabled).toBe(true)
|
|
expect(typeof view.props.onBridgeMessage).toBe('function')
|
|
// Delivered with no client behind it: there is no host to answer, and nothing throws.
|
|
await act(async () => {
|
|
view.props.onBridgeMessage({ nativeEvent: { json: '{"v":1,"type":"ready"}' } })
|
|
})
|
|
})
|
|
|
|
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('starts the wait for the page when the native view says the document finished', async () => {
|
|
const tree = await render(readyState('session-one'))
|
|
const view = byName(tree, 'ShellViewProbe')[0]
|
|
await act(async () => {
|
|
view.props.onLoadState({ nativeEvent: { state: 'loading' } })
|
|
view.props.onLoadState({ nativeEvent: { state: 'ready' } })
|
|
view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'document-load-failed' } })
|
|
})
|
|
// Once, for the one finished document, and never for the failure: a view that reported a
|
|
// failure has nothing left to wait for.
|
|
expect(dependencies.reportDocumentLoaded).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('fails the session when this host could not be read from the app store', async () => {
|
|
// Without this the session stays `ready` with the view un-hidden, no host behind it, and the
|
|
// page re-posting `ready` on its backoff for as long as the screen is open.
|
|
dependencies.snapshotUnreadable = true
|
|
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
await render(readyState('session-one'))
|
|
expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']])
|
|
warned.mockRestore()
|
|
})
|
|
|
|
it('re-reads the app store on every ask, so the next init is not the first one again', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
const tree = await render(readyState('session-one'))
|
|
const view = byName(tree, 'ShellViewProbe')[0]
|
|
await act(async () => {
|
|
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
|
|
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
|
|
})
|
|
// A document that reloads inside one mount asks again; a refresh per ask is what lets a key
|
|
// the app changed meanwhile reach the `init` after it.
|
|
expect(dependencies.storageRefreshes).toBe(2)
|
|
})
|
|
|
|
it('ends that wait on the page asking for a session', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
const tree = await render(readyState('session-one'))
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
})
|
|
expect(dependencies.reportPageReady).toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails the session on a page fault, so a blank page becomes the failure screen', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const tree = await render(readyState('session-one'))
|
|
await act(async () => {
|
|
// The page asks for its session first, which is what earns it the `fault` grant: a host that
|
|
// has told a page nothing refuses the name.
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: {
|
|
json: clientFrame({
|
|
type: 'notify',
|
|
name: BRIDGE_FAULT_GRANT,
|
|
error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
|
|
})
|
|
}
|
|
})
|
|
})
|
|
expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']])
|
|
warned.mockRestore()
|
|
// The reducer's answer to that reason, rendered: this is what the page's blank turns into.
|
|
expect(
|
|
textOf(await render({ kind: 'failed', reason: 'document-load-failed', retriedOnce: true }))
|
|
).toContain('The downloaded workspace could not be opened.')
|
|
})
|
|
|
|
it('reports a URL nothing on this phone could open, which is the dead tap that survives', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
const failure = new Error('no activity found')
|
|
// A fresh rejection per call, not one built here: `mockReturnValue(Promise.reject(...))` builds
|
|
// it now and nothing attaches a handler until the frame arrives, which is an unhandled
|
|
// rejection in the window between.
|
|
dependencies.openUrl.mockImplementation(() => Promise.reject(failure))
|
|
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const tree = await render(readyState('session-one'))
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: {
|
|
json: clientFrame({
|
|
type: 'notify',
|
|
name: 'externalLink',
|
|
url: 'mailto:someone@example.com'
|
|
})
|
|
}
|
|
})
|
|
})
|
|
// Nothing crosses back for a notify, so silence here is the one dead tap this verb does not
|
|
// rule out: the page was told the frame left and the phone opened nothing.
|
|
expect(warned.mock.calls).toContainEqual([
|
|
'[web-shell] could not open a URL for the page',
|
|
{ url: 'mailto:someone@example.com', error: failure }
|
|
])
|
|
warned.mockRestore()
|
|
})
|
|
|
|
it('pops its own stack when the page hands its back button over', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
const tree = await render(readyState('session-one'))
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'notify', name: BRIDGE_NAVIGATE_BACK_NOTIFY }) }
|
|
})
|
|
})
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
expect(dependencies.push).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('pops nothing when this page is the first screen on the stack, rather than dismissing it', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
dependencies.canGoBack = false
|
|
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const tree = await render(readyState('session-one'))
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'notify', name: BRIDGE_NAVIGATE_BACK_NOTIFY }) }
|
|
})
|
|
})
|
|
expect(dependencies.back).not.toHaveBeenCalled()
|
|
// The page is told nothing either way, so the log is the only thing a dead Back button leaves.
|
|
expect(warned.mock.calls).toContainEqual([
|
|
'[web-shell-bridge] did not pop the stack for a page going back',
|
|
{ why: 'nothing-to-pop' }
|
|
])
|
|
warned.mockRestore()
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
describe('the route the shell was not asked to render', () => {
|
|
it('hands the screen back to the caller rather than painting anything of its own', async () => {
|
|
const tree = await render({ kind: 'native-route' })
|
|
expect(tree.root.findAllByType(NativeFallback)).toHaveLength(1)
|
|
expect(byName(tree, 'ShellViewProbe')).toEqual([])
|
|
expect(byName(tree, 'ActivityIndicator')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('the dropped-frame count on the dev facts line', () => {
|
|
/** 500,000 bytes encodes past the frame cap, so every one of these is dropped. */
|
|
const oversized = {
|
|
opcode: 1 as const,
|
|
seq: 1,
|
|
format: 'jpeg' as const,
|
|
metadata: {},
|
|
image: new Uint8Array(500_000)
|
|
}
|
|
|
|
async function openBinaryStream(tree: ReactTestRenderer): Promise<void> {
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({
|
|
nativeEvent: { json: clientFrame({ type: 'ready' }) }
|
|
})
|
|
})
|
|
await act(async () => {
|
|
byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({
|
|
nativeEvent: {
|
|
json: clientFrame({
|
|
type: 'subscribe',
|
|
id: 'a'.repeat(22),
|
|
method: 'browser.screencast',
|
|
params: {},
|
|
wantsBinary: true
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async function drop(times: number): Promise<void> {
|
|
for (let index = 0; index < times; index += 1) {
|
|
await act(async () => {
|
|
dependencies.client?.streams[0]?.emitBinary?.({ ...oversized, seq: index + 1 })
|
|
})
|
|
}
|
|
}
|
|
|
|
function devFactsText(tree: ReactTestRenderer): string | null {
|
|
const line = byName(tree, 'Text').find(
|
|
(node) => node.props.testID === 'mobile-web-shell-dev-facts'
|
|
)
|
|
return line === undefined ? null : String(line.props.children)
|
|
}
|
|
|
|
it('shows the running total and resets it when the host is rebuilt', async () => {
|
|
dependencies.client = createFakeRpcClient()
|
|
dependencies.routeGrants = ['navigate', 'screencastBinary']
|
|
const tree = await render(readyState('session-one'))
|
|
await openBinaryStream(tree)
|
|
await drop(2)
|
|
expect(devFactsText(tree)).toContain('2 frames dropped')
|
|
await update(tree, readyState('session-two'))
|
|
expect(devFactsText(tree)).not.toContain('dropped')
|
|
})
|
|
|
|
/**
|
|
* The line renders null outside a development build, so state behind it is a re-render of the
|
|
* whole screen for a fact nobody can see — at up to ten a second on a page the desktop cannot
|
|
* compress. Counted rather than reasoned about.
|
|
*/
|
|
it('renders the screen not once more per dropped frame in a production build', async () => {
|
|
Object.assign(globalThis, { __DEV__: false })
|
|
try {
|
|
dependencies.client = createFakeRpcClient()
|
|
dependencies.routeGrants = ['navigate', 'screencastBinary']
|
|
const tree = await render(readyState('session-one'))
|
|
await openBinaryStream(tree)
|
|
expect(devFactsText(tree)).toBeNull()
|
|
const before = dependencies.viewRenders
|
|
await drop(5)
|
|
expect({ extraRenders: dependencies.viewRenders - before }).toEqual({ extraRenders: 0 })
|
|
expect(devFactsText(tree)).toBeNull()
|
|
} finally {
|
|
Object.assign(globalThis, { __DEV__: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Last in the file on purpose: it is the case the block above would have poisoned.
|
|
*
|
|
* Those cases grant the screencast lane and install a client, and before the shared setup reset
|
|
* them both, whatever ran next inherited a route granted a lane it never asked for. Deleting the
|
|
* reset fails here and nowhere else, because nothing else runs after a case that mutates them.
|
|
*/
|
|
describe('what one case mutates does not reach the next', () => {
|
|
it('starts from the shared route grants and no client', () => {
|
|
expect({ grants: dependencies.routeGrants, client: dependencies.client }).toEqual({
|
|
grants: DEFAULT_ROUTE_GRANTS,
|
|
client: null
|
|
})
|
|
})
|
|
})
|