Files
orca/mobile/src/components/MobileHostCard.test.ts
T
02a5594434 fix(mobile): give each host field one writer so relay routing can't revert edits (#22956)
* fix(mobile): give each host field one writer so relay routing can't revert edits

Relay learners (director re-resolution, credential rotation, direct-to-relay
upgrade) saved the whole HostProfile snapshot their connection opened with,
so a late relay write reverted an Edit Host endpoint and rewrote the device
token. The relay overlay also stored a copy of the paired address, which the
direct probe kept dialling after an edit.

Pairing is now the only full-profile writer (savePairedHost, fenced by a
census). Learners call setRelayRouting(hostId, relay), which never touches the
row or keychain, refuses a removed host, and skips unchanged routing. The
overlay is relay-only in memory; one serializer writes the v2 shape older
builds parse, without the direct entry. Saving a new endpoint rebuilds even a
Relay-active client from the saved row.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for relay-only host routing

Baseline moves to the product commit. adapterSha256 moves for the pairing and
relay-credential adapters, which now read relay.relayHostId and inject
saveRelayRouting. Only the four direct-upgrade bodies change: the settled host
no longer carries the overlay's endpoints copy (direct-primary, relay-primary)
or the duplicate relayHostId; relay and every effect are unchanged.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): relay learners own only relay routing

Follow-up to the field-ownership split. The supervisor keeps its host
read-only and holds the relay in a small owner that persists moves through
setRelayRouting; the direct upgrade returns { relay, bundle } and the
lifecycle composes the profile once. With one direct endpoint left, the probe
takes a bound openDirect and the lifecycle computes the direct path once.

One name per writer: setRelayRouting and savePairedHost are also the
dependency keys, and the removed-host error is RelayRoutingHostRemovedError.
The census now counts real value imports of savePairedHost, not mentions.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for the { relay, bundle } upgrade result

Baseline moves to the refactor commit, and adapterSha256 moves for the
pairing and relay-credential adapters (dependency keys renamed to
savePairedHost and setRelayRouting). Only the four direct-upgrade bodies
change: the settled upgrade result is now { relay, bundle } instead of
{ host, bundle }, with identical relay, bundle, state and effects.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): refresh edited hosts and let the establisher own relay

Edit Host now reuses refreshHostClient, which already rebuilds an owned
client (relay-active included) from the saved row after re-pairing, instead
of a savedAddressChanged flag on forceReconnect.

The session establisher, the only relay dialer, holds the relay and adopts
learned routing through setRelayRouting; the supervisor takes a host id and a
required relay, so the no-relay guards and the synthetic upgraded profile go.
enqueueHostListMutation returns its operation's value, and the overlay store
names its API after routing.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

---------

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-25 23:15:12 -04:00

377 lines
12 KiB
TypeScript

import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileHostCard } from './MobileHostCard'
import {
recordHostDescriptor,
resetHostDescriptorStoreForTests
} from '../transport/host-descriptor-store'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Monitor: 'Monitor',
MoreVertical: 'MoreVertical'
}))
vi.mock('./StatusDot', () => ({
StatusDot: 'StatusDot'
}))
function suppressRendererDeprecation() {
return vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
}
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
resetHostDescriptorStoreForTests()
vi.restoreAllMocks()
})
it('keeps host navigation and actions as separate accessible controls', async () => {
const onPress = vi.fn()
const onLongPress = vi.fn()
const onOpenActions = vi.fn()
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'disconnected',
verdict: { kind: 'normal', label: 'Disconnected' },
path: 'lan',
onPress,
onLongPress,
onOpenActions
})
)
})
consoleError.mockRestore()
const buttons = renderer.root.findAllByType('Pressable')
expect(buttons).toHaveLength(2)
expect(buttons[0].props.accessibilityRole).toBe('button')
expect(buttons[0].props.accessibilityLabel).toBe('Open Desk, Disconnected')
expect(buttons[1].props.accessibilityRole).toBe('button')
expect(buttons[1].props.accessibilityLabel).toBe('Actions for Desk')
expect(buttons[1].props.hitSlop).toBe(8)
expect(buttons[1].props.style({ pressed: false })[0]).toMatchObject({ width: 40, height: 40 })
expect(renderer.root.findAllByType('MoreVertical')).toHaveLength(1)
expect(renderer.root.findAllByType('ChevronRight')).toHaveLength(0)
act(() => buttons[1].props.onPress())
expect(onOpenActions).toHaveBeenCalledOnce()
expect(onPress).not.toHaveBeenCalled()
act(() => buttons[0].props.onPress())
act(() => buttons[0].props.onLongPress())
expect(onPress).toHaveBeenCalledOnce()
expect(onLongPress).toHaveBeenCalledOnce()
})
it('announces the connection path without the visual separator', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'tailscale',
worktreeInfo: {
hostId: 'desk',
totalWorktrees: 3,
activeCount: 2,
lastActiveWorktree: null,
countsProvenAt: Date.now()
},
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
'Open Desk, Connected, Direct via Tailscale, 3 worktrees, 2 active'
)
})
it('keeps a phone rename above a disagreeing machine descriptor', async () => {
const consoleError = suppressRendererDeprecation()
recordHostDescriptor('desk', { machineName: 'm4airs-Air', platform: 'darwin' })
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Windows-Low Spec',
personalName: 'Windows-Low Spec',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const texts = renderer.root.findAllByType('Text').map((node) => node.children.join(''))
expect(texts).toContain('Windows-Low Spec')
expect(texts).toContain('macOS · m4airs-Air')
})
it('titles an unrenamed host with the live machine name before its stored name catches up', async () => {
// The desktop was renamed; the stored `name` still holds the old machine name until reload.
const consoleError = suppressRendererDeprecation()
recordHostDescriptor('desk', { machineName: 'Studio 2', platform: 'darwin' })
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Studio',
lastKnownMachineName: 'Studio',
lastKnownHostPlatform: 'darwin',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const texts = renderer.root.findAllByType('Text').map((node) => node.children.join(''))
expect(texts).toContain('Studio 2')
expect(texts).toContain('macOS')
expect(texts).not.toContain('Studio')
})
it('shows the stored descriptor when the host is offline, as after a restart', async () => {
// No live descriptor is recorded: the row has only what the stored profile carries.
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
personalName: 'Desk',
lastKnownMachineName: 'm4airs-Air',
lastKnownHostPlatform: 'darwin',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'disconnected',
verdict: { kind: 'normal', label: 'Disconnected' },
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
expect(renderer.root.findAllByType('Text').map((node) => node.children.join(''))).toContain(
'macOS · m4airs-Air'
)
})
it('collapses the machine name into the OS line when it is the shown name', async () => {
const consoleError = suppressRendererDeprecation()
recordHostDescriptor('desk', { machineName: 'Desk', platform: 'darwin' })
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const texts = renderer.root.findAllByType('Text').map((node) => node.children.join(''))
expect(texts).toContain('macOS')
expect(texts).not.toContain('macOS · Desk')
})
it('preserves the connected worktree-catalog failure state', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'relay',
worktreeInfo: {
hostId: 'desk',
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null,
catalogUnavailable: true
},
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
'Open Desk, Connected, Orca Relay, Worktree list unavailable'
)
expect(
renderer.root
.findAllByType('Text')
.some((node) => node.children.includes('Worktree list unavailable'))
).toBe(true)
})
it('includes visible offline recovery guidance in the navigation label', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'reconnecting',
verdict: {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'never-connected'
},
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
"Open Desk, Can't reach desktop, Update desktop Orca and sign in to connect from anywhere"
)
})
it('renders the verdict detail as a second line and announces it', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Host 1',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1,
relay: {
v: 1 as const,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
},
state: 'connecting',
verdict: {
kind: 'unreachable',
label: 'Host 1 is offline',
reason: 'never-connected',
detail: "Check it's awake, Orca is running, and you're signed in"
},
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const texts = renderer.root.findAllByType('Text').map((node) => node.props.children)
expect(texts).toContainEqual("Check it's awake, Orca is running, and you're signed in")
expect(renderer.root.findAllByType('Pressable')[0]?.props.accessibilityLabel).toBe(
"Open Host 1, Host 1 is offline, Check it's awake, Orca is running, and you're signed in"
)
})
})