mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* fix(mobile): break the transport and terminal-document import cycles and gate them after mobile's install client-context re-exported the host-client hooks, which import client-context back: the re-export barrel is gone and importers (and their test mocks) name host-client-hooks directly. In the terminal document, the cell-metric getters move to a leaf cell-metrics module and select-mode entry/exit to selection-mode, so fit, transform, overlay and range no longer import each other. pr.yml lints imports before mobile's install, when mobile's tsconfig can't load, so Mobile verify now runs the same import rules over mobile after its install with zero warnings allowed. * fix(mobile): the browser-pane render harness imports useHostClient from host-client-hooks Its route source is a template string, so no typecheck saw the removed client-context re-export; the mobile web bundle check did. * fix(ci): the e2e typecheck ratchet counts the same with or without mobile's install Specs reach mobile/src/transport, whose expo-crypto and async-storage imports resolve only after mobile's install and then load React Native's globals (numeric timers, a required NODE_ENV), adding errors in unrelated specs. An empty stub pins both, so the static job reads 232 either way.
273 lines
9.8 KiB
TypeScript
273 lines
9.8 KiB
TypeScript
import { createElement, useEffect } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import { HostProtocolGate } from './HostProtocolGate'
|
|
import { useHostProtocolGates } from './host-protocol-gates-context'
|
|
|
|
const nativeTestState = vi.hoisted(() => ({
|
|
openUrl: vi.fn(),
|
|
platform: { OS: 'ios' as 'ios' | 'android' }
|
|
}))
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Linking: { openURL: nativeTestState.openUrl },
|
|
Platform: nativeTestState.platform,
|
|
Pressable: 'Pressable',
|
|
StyleSheet: {
|
|
create: <T>(styles: T) => styles,
|
|
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
|
|
},
|
|
Text: 'Text',
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('expo-router', () => ({
|
|
router: { replace: vi.fn() },
|
|
// `ProtocolBlockScreen` reaches the router through the navigation handoff now, and the handoff's
|
|
// native form is this hook. Its web form is what posts the target to the shell.
|
|
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), dismissTo: vi.fn() })
|
|
}))
|
|
|
|
// Why: mock only client acquisition; the gate must exercise the real
|
|
// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring.
|
|
const hostClient = vi.hoisted(() => ({
|
|
current: { client: null as RpcClient | null, state: 'disconnected' as string }
|
|
}))
|
|
vi.mock('../transport/host-client-hooks', () => ({
|
|
useHostClient: () => hostClient.current
|
|
}))
|
|
// The wall's release offer: the native hook imports the update checker module.
|
|
vi.mock('../app-update/use-wall-app-update', () => ({ useWallAppUpdate: () => null }))
|
|
// Descriptor bookkeeping only; the real recorder reaches the native keychain through host-store.
|
|
vi.mock('../transport/host-descriptor-recorder', () => ({
|
|
recordHostDescriptorFromStatus: vi.fn()
|
|
}))
|
|
|
|
function clientWithStatus(result: Record<string, unknown>): RpcClient {
|
|
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
|
|
}
|
|
|
|
function GateConsumer() {
|
|
const { hostCapabilities } = useHostProtocolGates()
|
|
return createElement('GateStatus', null, hostCapabilities.join(','))
|
|
}
|
|
|
|
// Counts mounts so a test can prove the routes were never torn down, which presence alone can't.
|
|
const probeMounts = { count: 0 }
|
|
function MountProbe() {
|
|
useEffect(() => {
|
|
probeMounts.count += 1
|
|
}, [])
|
|
return createElement('MountProbe')
|
|
}
|
|
|
|
function gateElement() {
|
|
return createElement(
|
|
HostProtocolGate,
|
|
{ hostId: 'host-1' },
|
|
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
|
|
)
|
|
}
|
|
|
|
async function renderGate(): Promise<ReactTestRenderer> {
|
|
let renderer: ReactTestRenderer | null = null
|
|
await act(async () => {
|
|
renderer = create(gateElement())
|
|
await Promise.resolve()
|
|
})
|
|
return renderer as unknown as ReactTestRenderer
|
|
}
|
|
|
|
function renderedText(renderer: ReactTestRenderer): string {
|
|
return JSON.stringify(renderer.toJSON())
|
|
}
|
|
|
|
describe('HostProtocolGate', () => {
|
|
let renderer: ReactTestRenderer | null = null
|
|
|
|
beforeEach(() => {
|
|
nativeTestState.openUrl.mockClear()
|
|
nativeTestState.platform.OS = 'ios'
|
|
probeMounts.count = 0
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('replaces the host UI with the block screen when mobile is too old', async () => {
|
|
// Why: blocked warns to console; keep test output clean without hiding other errors.
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
hostClient.current = {
|
|
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
|
state: 'connected'
|
|
}
|
|
renderer = await renderGate()
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('Update Orca Mobile')
|
|
expect(output).toContain('Open App Store')
|
|
expect(output).not.toContain('HostContent')
|
|
})
|
|
|
|
it('routes Android mobile updates to GitHub Releases', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
nativeTestState.platform.OS = 'android'
|
|
hostClient.current = {
|
|
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
|
state: 'connected'
|
|
}
|
|
renderer = await renderGate()
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('Update Orca Mobile')
|
|
expect(output).toContain('Update Orca Mobile from GitHub Releases')
|
|
expect(output).toContain('Open GitHub Releases')
|
|
expect(output).not.toContain('mobile app store')
|
|
expect(output).not.toContain('HostContent')
|
|
act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress())
|
|
expect(nativeTestState.openUrl).toHaveBeenCalledWith(
|
|
'https://github.com/stablyai/orca/releases'
|
|
)
|
|
})
|
|
|
|
it('replaces the host UI with the block screen when desktop is too old', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
hostClient.current = {
|
|
client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }),
|
|
state: 'connected'
|
|
}
|
|
renderer = await renderGate()
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('Update Orca on your computer')
|
|
expect(output).toContain('Open GitHub Releases')
|
|
expect(output).not.toContain('HostContent')
|
|
})
|
|
|
|
it('renders the host UI when the verdict is ok', async () => {
|
|
const client = clientWithStatus({
|
|
protocolVersion: 5,
|
|
minCompatibleMobileVersion: 0,
|
|
capabilities: ['browser.screencast.v1']
|
|
})
|
|
hostClient.current = {
|
|
client,
|
|
state: 'connected'
|
|
}
|
|
renderer = await renderGate()
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('HostContent')
|
|
expect(output).toContain('browser.screencast.v1')
|
|
expect(output).not.toContain('Update Orca')
|
|
expect(client.sendRequest).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('renders the host UI while the host connection is still pending', async () => {
|
|
hostClient.current = { client: null, state: 'connecting' }
|
|
renderer = await renderGate()
|
|
expect(renderedText(renderer)).toContain('HostContent')
|
|
})
|
|
|
|
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
|
|
const client = {
|
|
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
hostClient.current = { client, state: 'connected' }
|
|
renderer = await renderGate()
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('Checking host compatibility')
|
|
expect(output).not.toContain('HostContent')
|
|
expect(probeMounts.count).toBe(0)
|
|
expect(client.sendRequest).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
|
|
hostClient.current = { client: null, state: 'connecting' }
|
|
renderer = await renderGate()
|
|
expect(renderedText(renderer)).toContain('HostContent')
|
|
expect(probeMounts.count).toBe(1)
|
|
|
|
const client = {
|
|
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
await act(async () => {
|
|
hostClient.current = { client, state: 'connected' }
|
|
renderer?.update(gateElement())
|
|
await Promise.resolve()
|
|
})
|
|
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('HostContent')
|
|
expect(output).toContain('Checking host compatibility')
|
|
// Why: the cold-start remount this replaces is exactly what destroys in-flight deep navigation.
|
|
expect(probeMounts.count).toBe(1)
|
|
const overlay = renderer.root
|
|
.findAllByType('View')
|
|
.find((node) => node.props.accessibilityViewIsModal === true)
|
|
expect(overlay?.props.pointerEvents).toBe('auto')
|
|
})
|
|
|
|
it('still replaces mounted routes when the verdict comes back blocked', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
hostClient.current = { client: null, state: 'connecting' }
|
|
renderer = await renderGate()
|
|
expect(renderedText(renderer)).toContain('HostContent')
|
|
|
|
await act(async () => {
|
|
hostClient.current = {
|
|
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
|
state: 'connected'
|
|
}
|
|
renderer?.update(gateElement())
|
|
await Promise.resolve()
|
|
})
|
|
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('Update Orca Mobile')
|
|
expect(output).not.toContain('HostContent')
|
|
})
|
|
|
|
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
|
|
const client = {
|
|
sendRequest: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
|
|
})
|
|
.mockReturnValueOnce(new Promise(() => {}))
|
|
} as unknown as RpcClient
|
|
hostClient.current = { client, state: 'connected' }
|
|
renderer = await renderGate()
|
|
|
|
await act(async () => {
|
|
hostClient.current = { client, state: 'disconnected' }
|
|
renderer?.update(gateElement())
|
|
})
|
|
await act(async () => {
|
|
hostClient.current = { client, state: 'connected' }
|
|
renderer?.update(gateElement())
|
|
await Promise.resolve()
|
|
})
|
|
|
|
const output = renderedText(renderer)
|
|
expect(output).toContain('HostContent')
|
|
// Why: the host already answered once, so a reconnect probe must not dim the UI it validated.
|
|
expect(output).not.toContain('Checking host compatibility')
|
|
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('fails open when a connected host cannot answer the status probe', async () => {
|
|
hostClient.current = {
|
|
client: {
|
|
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
|
|
} as unknown as RpcClient,
|
|
state: 'connected'
|
|
}
|
|
renderer = await renderGate()
|
|
expect(renderedText(renderer)).toContain('HostContent')
|
|
})
|
|
})
|