mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
* 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>
377 lines
12 KiB
TypeScript
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"
|
|
)
|
|
})
|
|
})
|