Files
orca/mobile/src/host-edit-save-flow.test.ts
T
Neil 41fb3c7a9c fix(mobile): retire host edit navigation after leaving the route (#31692)
Saving an endpoint after the user leaves can pop a second screen. Retire navigation ownership while allowing the endpoint save and client refresh to finish.
2026-10-21 16:41:13 -07:00

334 lines
11 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/host-client-hooks', () => ({
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
.findAll((node) => String(node.type) === '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
.findAll((node) => String(node.type) === '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
.findAll((node) => String(node.type) === '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('previews and saves the secure default port when switching a LAN host to WSS', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', 'wss://tunnel.example.com')
expect(findText(renderer, 'Connects to wss://tunnel.example.com:443')).toBe(true)
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
endpoint: 'wss://tunnel.example.com:443'
})
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())
})
it('does not navigate again if the user leaves while the save is pending', async () => {
let resolveSave = () => {}
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveSave = resolve
})
)
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
act(() => {
renderer.root.findByProps({ accessibilityLabel: 'Back' }).props.onPress()
renderer.unmount()
})
expect(dependencies.back).toHaveBeenCalledTimes(1)
await act(async () => {
resolveSave()
})
expect(dependencies.back).toHaveBeenCalledTimes(1)
expect(dependencies.refreshHostClient).toHaveBeenCalledWith('host-1')
})
})
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.findAll((node) => String(node.type) === '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.findAll((node) => String(node.type) === '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.findAll((node) => String(node.type) === 'TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
})