mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2) Focus/app-resume nudges probe the active relay instead of suspending it; network-change nudges replace it make-before-break, suspending only after a failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of 'disconnected'; the host list keeps last-known worktrees for every not-connected state and spins instead of rendering nothing. * feat(mobile): surface the pairing relay path in the pairing log (F3) The relay candidate was silent during pairing: dialing, E2EE handshake, director recovery, and the winning path now emit redacted phase lines through the same connectOptions.onLog the direct path already used. * docs(mobile): relay UX investigation findings and F0-F10 fix plan * feat(mobile): name and narrate relay dials while they happen (F5) migrateTo forwards the dialing session's connecting/handshaking/reconnecting phases whenever the client is suspended or disconnected — never downgrading a live session — and exposes getPendingPath so the host card can say '· Orca Relay' during the dial instead of only after it. * feat(mobile): race a relay dial when the direct dial stalls (F6) A 2.5s grace timer starts relay recovery while an unauthenticated direct dial is still inside its 12s connect window; the race gets one attempt through the existing mutex/cooldown machinery, cancels when direct authenticates, and never arms for hosts without a relay endpoint. * fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9) A pending status.get used to swap the mounted HostStack for a spinner at the moment the socket connected, destroying in-flight nested navigation. Once children have rendered for a host they stay mounted under an opaque touch-blocking overlay; first visits and blocked verdicts keep the old behavior. * fix(mobile): keep loaded data through transient connection blips (F10) Git history no longer blanks on reconnect (and commit files refetch instead of caching an offline empty answer), the repo picker keeps its last-good list when an in-flight repo.list rejects, the diff review's ready-state preservation actually runs, and proven host capabilities survive a drop flagged unverified instead of being wiped. * feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8) Notification taps, the Accounts card, and host-edit now use the shared mount-then-replace transition (with a focused-route walker so root-layout scope works); the Resume card renders from the snapshot in a disabled state so its late arrival can't shift Tasks under the thumb; resume targets are validated against proven catalog data, and a session route whose worktree the host proves missing bounces to the host index with a notice banner instead of stranding on a dead screen. * test(mobile): cover the resume-target and notice policies (F7) Key notice dismissal by code so closing one banner cannot swallow a later, different one, and move the visibility rule into host-route-notice.ts where it is testable without a screen. Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing (unproven catalog is silence, synthetic routes exempt), the validating last-visited reader, and the notice visibility rule. * fix(mobile): review-pass hardening for the gate overlay and diff preservation Adversarial review findings: the reader's hunk position now survives a connection blip (reset only on item change), the covered stack is hidden from TalkBack while the gate overlay is up, and the overlay's hit-test comment is scoped honestly to in-tree views (native-Modal drawers present above it — follow-up). * fix(mobile): CI + CodeRabbit review fixes for #12609 Move the findings doc under docs/ (root directory guard), drop two unused eslint-disable directives, and address review findings: an unproven snapshot seed can no longer downgrade a proven worktree catalog; a locally-aborted relay dial skips the director fallback; post-migration bookkeeping failures log instead of masquerading as dial failures (which could suspend the healthy session); the auth wait arms its timeout before subscribing; forwarded dial phases stop at close(); the legacy selector_not_found fallback requires runtime_error; the diff-loading effect depends on the fields it reads; and host-edit auto-cancellation is now pinned by a test. * fix(mobile): second review round — queued replacements, race fence, confirmed bounces A network-change replacement now survives the recovery mutex and cooldowns as a queued intent instead of being dropped or suspending a healthy session — only a failed dial or a dead probe tears one down. The happy-eyeballs migration withdraws when direct authenticated during the relay dial (first-authenticated-wins). A worktree bounce requires two consecutive host-proven misses, since a transient desktop repo-scan rejection answers selector_not_found for a live worktree. Background network flaps no longer wake a billed relay splice, the lifecycle foreground flag stays in sync, a screen unmount cancels only its own pending host-stack transition, and diff review keeps the loaded review when its reconnect refresh rejects. Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible pass, and split the supervisor nudge tests, to stay under max-lines. * fix(mobile): satisfy the React Doctor changed-code gate Render-phase ref writes move into effects: the protocol gate's resolved/mounted latches now record committed outcomes only (a discarded children render can no longer count as mounted), and the bounce hook syncs its callback ref in an effect. Array<T> annotations become T[] in the extracted modules. * fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10) The diff-loading hook's catch was the one path still erasing a ready diff — the same keepLoadedDiff guard its disconnect and loading branches already use, now pinned by a reject-after-ready test. * fix(mobile): process foreground revival nudges --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
264 lines
9.2 KiB
TypeScript
264 lines
9.2 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, useHostProtocolGates } from './HostProtocolGate'
|
|
|
|
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() }
|
|
}))
|
|
|
|
// 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/client-context', () => ({
|
|
useHostClient: () => hostClient.current
|
|
}))
|
|
|
|
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(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
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')
|
|
})
|
|
})
|