mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +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>
295 lines
9.2 KiB
TypeScript
295 lines
9.2 KiB
TypeScript
import { createElement } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import EditHostScreen from '../app/h/[hostId]/edit'
|
|
|
|
const dependencies = vi.hoisted(() => ({
|
|
back: vi.fn(),
|
|
refreshHostClient: vi.fn(),
|
|
loadHosts: vi.fn(),
|
|
primeHosts: vi.fn(),
|
|
updateHostNameAndEndpoint: vi.fn(),
|
|
hostId: 'host-1' as string | undefined
|
|
}))
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
KeyboardAvoidingView: 'KeyboardAvoidingView',
|
|
Platform: { OS: 'ios' },
|
|
Pressable: 'Pressable',
|
|
ScrollView: 'ScrollView',
|
|
StyleSheet: { create: (styles: unknown) => styles },
|
|
Text: 'Text',
|
|
TextInput: 'TextInput',
|
|
View: 'View'
|
|
}))
|
|
|
|
vi.mock('react-native-safe-area-context', () => ({
|
|
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
|
}))
|
|
|
|
vi.mock('expo-router', () => ({
|
|
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
|
|
useRouter: () => ({ back: dependencies.back })
|
|
}))
|
|
|
|
vi.mock('lucide-react-native', () => ({
|
|
ChevronLeft: 'ChevronLeft'
|
|
}))
|
|
|
|
vi.mock('./transport/host-store', () => ({
|
|
loadHosts: dependencies.loadHosts,
|
|
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
|
|
}))
|
|
|
|
vi.mock('./transport/client-context', () => ({
|
|
usePrimeHosts: () => dependencies.primeHosts,
|
|
useRefreshHostClient: () => dependencies.refreshHostClient
|
|
}))
|
|
|
|
const HOST_FIXTURE = {
|
|
id: 'host-1',
|
|
name: 'Desk',
|
|
endpoint: 'ws://192.168.1.10:6768',
|
|
deviceToken: 'token',
|
|
publicKeyB64: 'public-key',
|
|
lastConnected: 1
|
|
}
|
|
|
|
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
|
let renderer: ReactTestRenderer | null = null
|
|
await act(async () => {
|
|
renderer = create(createElement(EditHostScreen))
|
|
await Promise.resolve()
|
|
})
|
|
if (!renderer) {
|
|
throw new Error('Edit host route did not render')
|
|
}
|
|
return renderer
|
|
}
|
|
|
|
function setFieldValue(
|
|
renderer: ReactTestRenderer,
|
|
accessibilityLabel: 'Name' | 'Address',
|
|
value: string
|
|
): void {
|
|
const input = renderer.root
|
|
.findAllByType('TextInput')
|
|
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
|
if (!input) {
|
|
throw new Error(`${accessibilityLabel} input not found`)
|
|
}
|
|
act(() => {
|
|
input.props.onChangeText(value)
|
|
})
|
|
}
|
|
|
|
function findSaveButton(renderer: ReactTestRenderer) {
|
|
const button = renderer.root
|
|
.findAllByType('Pressable')
|
|
.find((node) => node.props.accessibilityLabel === 'Save host')
|
|
if (!button) {
|
|
throw new Error('Save button not found')
|
|
}
|
|
return button
|
|
}
|
|
|
|
async function pressSave(renderer: ReactTestRenderer): Promise<void> {
|
|
const button = findSaveButton(renderer)
|
|
await act(async () => {
|
|
button.props.onPress()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
function findText(renderer: ReactTestRenderer, match: string): boolean {
|
|
return renderer.root.findAllByType('Text').some((node) => {
|
|
const children = node.props.children
|
|
if (typeof children === 'string') {
|
|
return children.includes(match)
|
|
}
|
|
if (Array.isArray(children)) {
|
|
return children.join('').includes(match)
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
describe('edit host handleSave', () => {
|
|
beforeEach(() => {
|
|
dependencies.hostId = 'host-1'
|
|
dependencies.back.mockReset()
|
|
dependencies.refreshHostClient.mockReset()
|
|
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
|
|
dependencies.primeHosts.mockReset()
|
|
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('rename-only save updates only the name and does not reconnect', async () => {
|
|
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
|
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
|
|
setFieldValue(renderer, 'Name', 'Home Desk')
|
|
|
|
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
|
|
await pressSave(renderer)
|
|
|
|
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
|
personalName: 'Home Desk'
|
|
})
|
|
expect(dependencies.refreshHostClient).not.toHaveBeenCalled()
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('endpoint-only save updates only the endpoint and reconnects', async () => {
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
|
await pressSave(renderer)
|
|
|
|
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
|
endpoint: 'ws://192.168.1.20:6768'
|
|
})
|
|
expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1')
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('saves name and endpoint together in one call, then reconnects', async () => {
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Name', 'Home Desk')
|
|
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
|
await pressSave(renderer)
|
|
|
|
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
|
|
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
|
personalName: 'Home Desk',
|
|
endpoint: 'ws://192.168.1.20:6768'
|
|
})
|
|
expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1')
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('navigates back without saving or reconnecting when nothing changed', async () => {
|
|
const renderer = await renderEditHostRoute()
|
|
await pressSave(renderer)
|
|
|
|
expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled()
|
|
expect(dependencies.refreshHostClient).not.toHaveBeenCalled()
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('shows the error and does not navigate back or reconnect when the save rejects', async () => {
|
|
dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found'))
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Name', 'Home Desk')
|
|
await pressSave(renderer)
|
|
|
|
expect(findText(renderer, 'Host not found')).toBe(true)
|
|
expect(dependencies.refreshHostClient).not.toHaveBeenCalled()
|
|
expect(dependencies.back).not.toHaveBeenCalled()
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('still navigates back when the post-save re-prime fails', async () => {
|
|
dependencies.loadHosts
|
|
.mockResolvedValueOnce([HOST_FIXTURE])
|
|
.mockRejectedValueOnce(new Error('boom'))
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Name', 'Home Desk')
|
|
await pressSave(renderer)
|
|
|
|
expect(dependencies.primeHosts).not.toHaveBeenCalled()
|
|
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('ignores a second Save trigger while a save is already in flight', async () => {
|
|
let resolveSave: () => void = () => {}
|
|
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveSave = resolve
|
|
})
|
|
)
|
|
const renderer = await renderEditHostRoute()
|
|
setFieldValue(renderer, 'Name', 'Home Desk')
|
|
const button = findSaveButton(renderer)
|
|
|
|
await act(async () => {
|
|
button.props.onPress()
|
|
button.props.onPress()
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
resolveSave()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
})
|
|
|
|
describe('edit host load() error states', () => {
|
|
beforeEach(() => {
|
|
dependencies.hostId = 'host-1'
|
|
dependencies.back.mockReset()
|
|
dependencies.refreshHostClient.mockReset()
|
|
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
|
|
dependencies.primeHosts.mockReset()
|
|
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('shows "Missing host." when hostId is absent', async () => {
|
|
dependencies.hostId = undefined
|
|
const renderer = await renderEditHostRoute()
|
|
|
|
expect(findText(renderer, 'Missing host.')).toBe(true)
|
|
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('shows a not-saved message when the host is not in the loaded list', async () => {
|
|
dependencies.loadHosts.mockReset().mockResolvedValue([])
|
|
const renderer = await renderEditHostRoute()
|
|
|
|
expect(findText(renderer, 'This host was removed from this phone.')).toBe(true)
|
|
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
|
|
it('surfaces the error message when loadHosts rejects', async () => {
|
|
dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable'))
|
|
const renderer = await renderEditHostRoute()
|
|
|
|
expect(findText(renderer, 'storage unreadable')).toBe(true)
|
|
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
|
|
|
|
act(() => renderer.unmount())
|
|
})
|
|
})
|