import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness')) const dependencies = vi.hoisted(() => harness.createScreenDependencies()) const { screenModuleMocks } = await vi.hoisted( async () => await import('./mobile-web-shell-screen-test-mocks') ) const mocks = vi.hoisted(() => screenModuleMocks(dependencies)) vi.mock('react-native', mocks['react-native']) vi.mock('expo-clipboard', mocks['expo-clipboard']) vi.mock('expo-haptics', mocks['expo-haptics']) vi.mock('expo-document-picker', mocks['expo-document-picker']) vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio']) vi.mock('expo-keep-awake', mocks['expo-keep-awake']) vi.mock('expo-image-picker', mocks['expo-image-picker']) vi.mock('expo-file-system', mocks['expo-file-system']) vi.mock('lucide-react-native', mocks['lucide-react-native']) vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context']) vi.mock('expo-router', mocks['expo-router']) vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src']) vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update']) vi.mock('../transport/host-client-hooks', mocks['../transport/host-client-hooks']) vi.mock('./use-page-host-snapshot', mocks['./use-page-host-snapshot']) vi.mock('./use-mobile-web-shell-session', mocks['./use-mobile-web-shell-session']) import { createElement } from 'react' import { act, create } from 'react-test-renderer' import { bridgeId, clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' import { byName, trackRenderedScreen, SCREEN_BUILD_ID as BUILD_ID, SCREEN_DIRECTORY as DIRECTORY, hostParentOf, readyState, renderScreen as mountScreen, textOf, updateScreen as reRenderScreen } from './mobile-web-shell-screen-test-harness' import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY, readBridgeHostMessage } from './bridge/bridge-envelope' import { MobileWebShellScreen } from './MobileWebShellScreen' import { connectionLogStore } from '../transport/persisted-connection-log-store' import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import type { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer' const fetching = (receivedBytes: number, totalBytes: number): MobileWebShellSessionState => ({ kind: 'fetching', completedAssets: 0, totalAssets: 128, receivedBytes, totalBytes }) /** How far the bar's fill is scaled across the track, which is what the native driver moves. */ function fillScaleOf(tree: ReactTestRenderer): unknown { return fillOf(tree).value } function fillOf(tree: ReactTestRenderer): { readonly value: unknown } { const bar = tree.root.find( (node) => node.props.testID === 'mobile-web-shell-progress-bar' && String(node.type) === 'View' ) const [fill] = bar.findAll((node) => String(node.type) === 'Animated.View') return fill.props.style[1].transform[0].scaleX } const renderScreen = (state: MobileWebShellSessionState): Promise => mountScreen(MobileWebShellScreen, dependencies, state) const updateScreen = (tree: ReactTestRenderer, state: MobileWebShellSessionState): Promise => reRenderScreen(MobileWebShellScreen, dependencies, tree, state) afterEach(harness.unmountRenderedScreens) beforeEach(() => { harness.resetScreenDependencies(dependencies) }) describe('the hybrid shell screen', () => { it('renders the update wall for a bundle verdict, with no shell view', async () => { const tree = await renderScreen({ 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 renderScreen({ 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 renderScreen({ kind: 'failed', reason: 'document-load-failed', retriedOnce: true }) expect(textOf(tree)).toContain('The downloaded app 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 renderScreen({ 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 renderScreen({ 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 renderScreen({ kind: 'offline' }))).toContain( 'Connect to this host to download the latest app' ) }) it('shows the download as a progress bar with megabytes from the paired host', async () => { const tree = await renderScreen({ kind: 'fetching', completedAssets: 0, totalAssets: 128, receivedBytes: 2_020_592, totalBytes: 8_082_368 }) expect(textOf(tree)).toContain('Downloading the latest app') expect(textOf(tree)).toContain('from Host One ยท 2.0 of 8.1 MB') const bar = tree.root.findAll( (node) => node.props.testID === 'mobile-web-shell-progress-bar' && String(node.type) === 'View' ) expect(bar).toHaveLength(1) expect(bar[0].props.accessibilityRole).toBe('progressbar') expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 25 }) expect(fillScaleOf(tree)).toBe(0.25) expect(byName(tree, 'ActivityIndicator')).toEqual([]) }) it('moves the fill with the download feed, without a render to carry it', async () => { const tree = await renderScreen(fetching(2_020_592, 8_082_368)) expect(fillScaleOf(tree)).toBe(0.25) dependencies.downloadProgress.publish(0.5) expect(fillScaleOf(tree)).toBe(0.5) expect(textOf(tree)).toContain('2.0 of 8.1 MB') }) it('starts one ease per window toward the latest value, and lands full at once', async () => { const tree = await renderScreen(fetching(0, 8_082_368)) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) try { dependencies.downloadProgress.publish(0.1) dependencies.downloadProgress.publish(0.2) dependencies.downloadProgress.publish(0.3) expect(fillScaleOf(tree)).toBe(0.1) vi.advanceTimersByTime(50) expect(fillScaleOf(tree)).toBe(0.3) dependencies.downloadProgress.publish(0.4) dependencies.downloadProgress.publish(1) expect(fillScaleOf(tree)).toBe(1) } finally { vi.useRealTimers() } }) it('starts nothing after unmount for a value still waiting on its window', async () => { const tree = await renderScreen(fetching(0, 8_082_368)) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) try { dependencies.downloadProgress.publish(0.1) dependencies.downloadProgress.publish(0.5) const fill = fillOf(tree) act(() => tree.unmount()) vi.advanceTimersByTime(50) expect(fill.value).toBe(0.1) } finally { vi.useRealTimers() } }) it('caps the bar at full when more bytes arrive than the total named', async () => { const tree = await renderScreen(fetching(9_000_000, 8_082_368)) expect(fillScaleOf(tree)).toBe(1) }) it('reads an empty download as no progress rather than dividing by zero', async () => { const tree = await renderScreen({ kind: 'fetching', completedAssets: 0, totalAssets: 0, receivedBytes: 0, totalBytes: 0 }) const bar = tree.root.findAll( (node) => node.props.testID === 'mobile-web-shell-progress-bar' && String(node.type) === 'View' ) expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 0 }) expect(fillScaleOf(tree)).toBe(0) expect(textOf(tree)).toContain('0.0 of 0.0 MB') }) it('hands the shell view the generation path and the session id', async () => { const tree = await renderScreen(readyState('session-one')) const view = byName(tree, 'ShellViewProbe')[0] expect(view.props.generationDirectory).toBe(DIRECTORY) expect(view.props.sessionId).toBe('session-one') }) it('delivers an init through the ready view bridge', async () => { dependencies.client = createFakeRpcClient() const tree = await renderScreen(readyState('session-one')) const view = byName(tree, 'ShellViewProbe')[0] expect(view.props.bridgeEnabled).toBe(true) await act(async () => view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } }) ) expect(dependencies.posted.map((raw) => readBridgeHostMessage(raw))).toContainEqual({ ok: true, message: expect.objectContaining({ type: 'init', sessionId: 'session-one', host: expect.objectContaining({ id: 'host-1' }) }) }) }) it('rebuilds the view rather than updating it when the session id changes', async () => { const tree = await renderScreen(readyState('session-one')) await updateScreen(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 renderScreen(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 renderScreen(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) // The document that started is what drops the previous one's paint, so it is reported too. expect(dependencies.reportDocumentStarted).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) const logged = () => connectionLogStore.get('host-1').map((entry) => entry.message) const before = logged().length await renderScreen(readyState('session-one')) expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']]) // Named in the exported log, not only as the generic failure it causes. expect(logged().slice(before)).toEqual(['Web shell: host profile unreadable']) warned.mockRestore() }) it('requests an app-store refresh on every ready ask', async () => { dependencies.client = createFakeRpcClient() const tree = await renderScreen(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) }) /** * One screen whose route this case moves, and every frame that went out for it. * * The shell tracks nothing about delivery (ruling 34): what a case can see here is what reached * the wire, and the request a frame carried is spent by the page, not by this screen. */ async function renderForRoute(params: Record): Promise<{ tree: ReactTestRenderer initRoutes: () => (Record | undefined)[] move: (next: Record) => Promise ready: () => Promise }> { const element = (next: Record) => createElement(MobileWebShellScreen, { hostId: 'host-1', route: { pathname: '/h/host-1', params: next } }) dependencies.state = readyState('session-one') const rendered: { tree: ReactTestRenderer | null } = { tree: null } await act(async () => { rendered.tree = create(element(params)) }) const tree = rendered.tree if (tree === null) { throw new Error('screen did not render') } trackRenderedScreen(tree) return { tree, // Read with the page's own reader rather than parsed loose: a frame this refuses is one the // page would have refused too, and a case counting inits must not count one of those. initRoutes: () => dependencies.posted .map((json) => readBridgeHostMessage(json)) .flatMap((read) => (read.ok && read.message.type === 'init' ? [read.message] : [])) .map((frame) => frame.route?.params), move: async (next) => { await act(async () => { tree.update(element(next)) }) }, ready: async () => { await act(async () => { byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) } } } /** * A route that moved under a screen that stayed mounted (ruling 33.1, as ruling 34 leaves it). * * One frame per move and none for a render that moved nothing. Whether it arrived is not asked * here and is not asked anywhere: the page's next `ready` is answered with the route the shell * holds then, which is the whole repair path. */ it('posts one init for a route that moved, and none for a render that moved nothing', async () => { dependencies.client = createFakeRpcClient() const page = await renderForRoute({ paneKey: '' }) await page.ready() expect(page.initRoutes()).toEqual([{ paneKey: '' }]) await page.move({ paneKey: 'pane-1' }) expect(page.initRoutes()).toEqual([{ paneKey: '' }, { paneKey: 'pane-1' }]) await page.move({ paneKey: 'pane-1' }) expect(page.initRoutes()).toHaveLength(2) }) it('answers every ask with the route it holds then, which is how a lost frame is repaired', async () => { dependencies.client = createFakeRpcClient() dependencies.postFails = true const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const page = await renderForRoute({ paneKey: '' }) await page.ready() await page.move({ paneKey: 'pane-1' }) // Both frames were refused by the view, and nothing here is holding either of them. expect(dependencies.posted).toHaveLength(2) dependencies.postFails = false await page.ready() expect(page.initRoutes().at(-1)).toEqual({ paneKey: 'pane-1' }) warned.mockRestore() }) it('ends that wait on the page asking for a session', async () => { dependencies.client = createFakeRpcClient() const tree = await renderScreen(readyState('session-one')) await act(async () => { byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) expect(dependencies.reportPageReady).toHaveBeenCalled() }) /** * The host is rebuilt when the client under it changes, and the page is never told: the session * id does not move, so it neither handshakes again nor hears that the shell was replaced. The * screen hands over what its reducer already knows about the session rather than the bridge * remembering it for the life of one mount. */ it('serves a page whose session handshook before this host was built', async () => { const client = createFakeRpcClient() dependencies.client = client dependencies.pageReady = true const tree = await renderScreen(readyState('session-one')) // No `ready` first, which is exactly what a page that was never told cannot send. await act(async () => { byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'request', id: bridgeId(1), method: 'status.get' }) } }) }) expect(client.requests.map((request) => request.method)).toEqual(['status.get']) }) it('reports a ready page fault as a document-load failure', async () => { dependencies.client = createFakeRpcClient() const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const tree = await renderScreen(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() }) 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 renderScreen(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 renderScreen(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() }) /** * The screen's end of the Back lane. `Platform.OS` is pinned to `ios` for this file, so what is * readable here is the swipe the shell takes away; which key each platform uses is * `use-shell-page-back.test.tsx`. */ it('carries the page taking the device Back key up to the session', async () => { dependencies.client = createFakeRpcClient() const tree = await renderScreen(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_BACK_CLAIM_NOTIFY, claimed: true }) } }) }) expect(dependencies.reportPageBackClaim).toHaveBeenCalledWith(true) }) it('takes the stack swipe away while the session says the page is holding the key', async () => { dependencies.backClaimed = true await renderScreen(readyState('session-one')) expect(dependencies.setScreenOptions).toHaveBeenCalledWith({ gestureEnabled: false }) }) it('leaves the swipe alone while the page is holding nothing', async () => { await renderScreen(readyState('session-one')) expect(dependencies.setScreenOptions).toHaveBeenCalledWith({ gestureEnabled: true }) }) 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 renderScreen(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 renderScreen(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('a route the page does not serve and no side can be blamed for', () => { it('paints the unavailable screen itself, with no view and no spinner', async () => { const tree = await renderScreen({ kind: 'route-unavailable' }) expect(textOf(tree)).toContain('This workspace screen is not available on this host.') 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 { 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 { const emit = dependencies.client?.streams[0]?.emitBinary if (emit === undefined || emit === null) { throw new Error('binary subscription was never opened') } for (let index = 0; index < times; index += 1) { await act(async () => { emit({ ...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 renderScreen(readyState('session-one')) await openBinaryStream(tree) await drop(2) expect(devFactsText(tree)).toContain('2 frames dropped') await updateScreen(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 renderScreen(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 }) } }) }) /** * An update the session refused, over a workspace the session opened anyway. * * The decision is the reducer's; what this pins is that the screen keeps the two apart โ€” the * refusal is a line above the page, so a dismissed notice leaves the same document mounted rather * than reloading it, and the copy claims only what happened. */ describe('a refused update is said beside the page, not in front of it', () => { function dismissControl(tree: ReactTestRenderer): ReactTestInstance | undefined { return byName(tree, 'Pressable').find( (node) => node.props.accessibilityLabel === 'Dismiss notice' ) } it('serves the page and says the update did not happen, promising no retry', async () => { dependencies.updateNotice = 'update-failed' const tree = await renderScreen(readyState('session-one')) expect(byName(tree, 'ShellViewProbe')).toHaveLength(1) const text = textOf(tree) expect(text).toContain("Couldn't download the latest app from this host") expect(text).toContain('Showing the last version that worked') expect(text).not.toContain('Try again') }) it('carries the notice to a reader who never arrives at the top of the page', async () => { // The banner is inserted into a screen already on screen. Assertive because the shell passes // the failure tone: what it reports is an update that did not happen. dependencies.updateNotice = 'update-failed' const tree = await renderScreen(readyState('session-one')) const alert = byName(tree, 'View').find((node) => node.props.accessibilityRole === 'alert') expect(alert).toBeDefined() expect(alert?.props.accessibilityLiveRegion).toBe('assertive') }) it('says nothing when the generation on screen is the one the host serves', async () => { const tree = await renderScreen(readyState('session-one')) expect(dismissControl(tree)).toBeUndefined() expect(textOf(tree)).not.toContain("Couldn't update") }) it('keeps the same document mounted when the notice is dismissed', async () => { dependencies.updateNotice = 'update-failed' const tree = await renderScreen(readyState('session-one')) dependencies.lifecycle.length = 0 await act(async () => { tree.root .find( (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Dismiss notice' ) .props.onPress() }) expect(dismissControl(tree)).toBeUndefined() expect(textOf(tree)).not.toContain("Couldn't update") // The page is the point: a notice that reloaded the workspace to get out of the way would // cost the user exactly what the fallback was for. expect(byName(tree, 'ShellViewProbe')).toHaveLength(1) expect(dependencies.lifecycle).toEqual([]) }) it('shows a later refusal rather than staying dismissed for the rest of the host', async () => { dependencies.updateNotice = 'update-failed' const tree = await renderScreen(readyState('session-one')) await act(async () => { tree.root .find( (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Dismiss notice' ) .props.onPress() }) // The next flow refused too, and opened its own fallback: a new document, so the tap on the // one before it is not an answer about this one. await updateScreen(tree, readyState('session-two')) expect(dismissControl(tree)).toBeDefined() }) }) /** * What is on screen between the generation being mounted and the page having a frame. * * Before this the answer was nothing: the shell tore its own frame down at `ready` and the WebView * draws nothing until its document paints, so the surface behind it was the whole picture for the * length of the page's boot โ€” measured at 1.42 s on a cached generation. */ describe('the frame under a page that has not painted', () => { it('keeps the shell frame over a mounted view, with the view underneath it', async () => { dependencies.pageFrame = 'unpainted' const tree = await renderScreen(readyState('session-a')) expect( tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-cover') ).toHaveLength(1) // Over, not instead of: the document is loading the whole time the cover is up. expect(byName(tree, 'ShellViewProbe')).toHaveLength(1) expect(textOf(tree)).toContain('Opening workspace') }) it('carries the same label the screen was already painting while it opened the generation', async () => { const opening = await renderScreen({ kind: 'activating', source: 'cache' }) expect(textOf(opening)).toContain('Opening workspace') dependencies.pageFrame = 'unpainted' await updateScreen(opening, readyState('session-a')) // The frame does not change when the state does, which is what makes the handover invisible. expect(textOf(opening)).toContain('Opening workspace') }) it('takes the frame down once the page reports one of its own', async () => { dependencies.pageFrame = 'unpainted' const tree = await renderScreen(readyState('session-a')) dependencies.pageFrame = 'painted' await updateScreen(tree, readyState('session-a')) expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-cover')).toEqual([]) }) it('covers the same box the view gets, which the keyboard does not shorten', async () => { // Both are children of the padded root: the view is `flex: 1` and the cover is an absolute // fill, so Yoga lays each of them out against the same content box, and the cover cannot leave // a gap the view fills. The keyboard covers that box rather than taking a strip off it. dependencies.pageFrame = 'unpainted' const tree = await renderScreen(readyState('session-keyboard-cover')) const root = tree.root.find((node) => node.props.testID === 'mobile-web-shell-ready') await act(async () => { dependencies.keyboardListeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } }) }) expect(root.props.style[1]).toEqual({ paddingTop: 0 }) const cover = tree.root.find((node) => node.props.testID === 'mobile-web-shell-cover') expect(cover.props.style[0]).toMatchObject({ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }) // Both sit directly in that root with no box in between, so neither carries a padding of its // own for the two to disagree about. expect(hostParentOf(cover)).toBe('mobile-web-shell-ready') expect(hostParentOf(byName(tree, 'ShellViewProbe')[0])).toBe('mobile-web-shell-ready') }) it('hands the page report to the session', async () => { dependencies.pageFrame = 'unpainted' dependencies.client = createFakeRpcClient() const tree = await renderScreen(readyState('session-a')) const probe = byName(tree, 'ShellViewProbe')[0] await act(async () => { probe.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) expect(dependencies.reportPageReady).toHaveBeenCalledTimes(1) await act(async () => { probe.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'notify', name: BRIDGE_PAGE_PAINTED }) } }) }) expect(dependencies.reportPagePainted).toHaveBeenCalledTimes(1) }) })