feat(mobile): edit saved host endpoints (#8294)
* feat(mobile): edit saved host endpoints * fix(mobile): reject ambiguous numeric host addresses * fix(mobile): label edit host inputs * fix(mobile): make host edit save atomic and remove superseded mutators Two independent review rounds found the same class of foot-gun: a superseded mutator (updateHostEndpoint, then renameHost) left in host-store.ts after the atomic updateHostNameAndEndpoint refactor, with zero remaining callers. Either could be reintroduced by a future caller and silently regress the non-atomic name/endpoint race the atomic function was written to close, so both are removed. Also covers reconnect-rejection and endpoint-only save paths that were missing test coverage, and merges origin/main (#8789) so this lands without reverting the mobile terminal restore fix. Co-authored-by: Orca <help@stably.ai> * Simplify save-race comment and reword host-removed error message - Trims the redundant comment explaining the savingRef race guard down to one line. - Changes the "no longer saved" load-error copy to "was removed" for clearer phrasing, updating the matching test expectation. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f7ef467cc9
commit
79551c38f5
|
|
@ -0,0 +1,382 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
|
||||
export default function EditHostScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
const primeHosts = usePrimeHosts()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
|
||||
const [host, setHost] = useState<HostProfile | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Why: setSaving is async, so a second trigger before the re-render could
|
||||
// still read stale state and re-enter handleSave; the ref closes that race.
|
||||
const savingRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!hostId) {
|
||||
setLoadError('Missing host.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
const found = hosts.find((h) => h.id === hostId) ?? null
|
||||
if (!found) {
|
||||
setLoadError('This host was removed from this phone.')
|
||||
setHost(null)
|
||||
return
|
||||
}
|
||||
setHost(found)
|
||||
setName(found.name)
|
||||
setAddress(displayHostEndpoint(found.endpoint))
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
|
||||
setHost(null)
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const canSave =
|
||||
host != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
if (!nextName) {
|
||||
setSaveError('Enter a name.')
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
// Why: a single mutateStoredHosts pass so name + endpoint commit
|
||||
// atomically — a mid-save failure can never persist one without the
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: the write already committed above; a re-prime failure here
|
||||
// must not be reported as a save failure — the next loadHosts() call
|
||||
// elsewhere in the app picks up the fresh state regardless.
|
||||
const hosts = await loadHosts()
|
||||
primeHosts(hosts)
|
||||
} catch {
|
||||
// best-effort re-prime; persisted data is unaffected
|
||||
}
|
||||
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
void forceReconnectHost(host.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Edit host</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.saveButton,
|
||||
(!canSave || pressed) && styles.saveButtonDisabled
|
||||
]}
|
||||
onPress={() => void handleSave()}
|
||||
disabled={!canSave}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save host"
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{loadError ? (
|
||||
<View style={styles.errorState}>
|
||||
<Text style={styles.errorText}>{loadError}</Text>
|
||||
<Pressable style={styles.secondaryButton} onPress={() => router.back()}>
|
||||
<Text style={styles.secondaryButtonText}>Go back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !host ? (
|
||||
<View style={styles.loadingState}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
) : (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.form, { paddingBottom: insets.bottom + spacing.xl }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.help}>
|
||||
Change the display name or connection address. Address edits only switch where this
|
||||
phone connects — they do not re-pair. Use this when the same desktop is reachable at a
|
||||
different IP (for example home LAN vs Tailscale).
|
||||
</Text>
|
||||
|
||||
<Text style={styles.label}>Name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
accessibilityLabel="Name"
|
||||
value={name}
|
||||
onChangeText={(value) => {
|
||||
setName(value)
|
||||
setSaveError(null)
|
||||
}}
|
||||
placeholder="Host name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Address</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
accessibilityLabel="Address"
|
||||
value={address}
|
||||
onChangeText={(value) => {
|
||||
setAddress(value)
|
||||
setSaveError(null)
|
||||
}}
|
||||
placeholder="192.168.1.10:6768"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => {
|
||||
if (canSave) {
|
||||
void handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port
|
||||
(or 6768).
|
||||
</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
Connects to {normalizedEndpoint.endpoint}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
flex: {
|
||||
flex: 1
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
heading: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: '700'
|
||||
},
|
||||
saveButton: {
|
||||
minWidth: 64,
|
||||
height: 34,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.surfaceBright,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
form: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm
|
||||
},
|
||||
help: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '500',
|
||||
marginTop: spacing.sm,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.row,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: Platform.OS === 'ios' ? 12 : 10
|
||||
},
|
||||
hint: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 16
|
||||
},
|
||||
preview: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily
|
||||
},
|
||||
previewError: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.md
|
||||
},
|
||||
errorState: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xl,
|
||||
gap: spacing.md
|
||||
},
|
||||
loadingState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
secondaryButton: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
|
|
@ -39,6 +39,7 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
|||
}}
|
||||
>
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/edit" options={{ title: 'Edit host' }} />
|
||||
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
|
||||
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts, renameHost } from '../src/transport/host-store'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
|
|
@ -42,7 +42,6 @@ import { triggerMediumImpact } from '../src/platform/haptics'
|
|||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { MobileHostCard } from '../src/components/MobileHostCard'
|
||||
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
|
|
@ -306,7 +305,6 @@ export default function HomeScreen() {
|
|||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
|
|
@ -708,19 +706,6 @@ export default function HomeScreen() {
|
|||
</Pressable>
|
||||
)
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!renameTarget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renameHost(renameTarget.id, newName)
|
||||
setRenameTarget(null)
|
||||
setHosts(await loadHosts())
|
||||
} catch {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirmRemove) {
|
||||
return
|
||||
|
|
@ -1068,11 +1053,12 @@ export default function HomeScreen() {
|
|||
})
|
||||
}
|
||||
items.push({
|
||||
label: 'Rename',
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setRenameTarget(host)
|
||||
setActionTarget(null)
|
||||
router.push(`/h/${host.id}/edit`)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
|
|
@ -1088,16 +1074,6 @@ export default function HomeScreen() {
|
|||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
|
||||
<TextInputModal
|
||||
visible={renameTarget != null}
|
||||
title="Rename Host"
|
||||
message="Enter a new name for this host."
|
||||
defaultValue={renameTarget?.name ?? ''}
|
||||
placeholder="Host name"
|
||||
onSubmit={(name) => void handleRename(name)}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={confirmRemove != null}
|
||||
title="Remove Host"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
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(),
|
||||
forceReconnectHost: vi.fn(),
|
||||
loadHosts: vi.fn(),
|
||||
primeHosts: vi.fn(),
|
||||
updateHostNameAndEndpoint: vi.fn()
|
||||
}))
|
||||
|
||||
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: 'host-1' }),
|
||||
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', () => ({
|
||||
useForceReconnect: () => dependencies.forceReconnectHost,
|
||||
usePrimeHosts: () => dependencies.primeHosts
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('Edit host route did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
describe('edit host route accessibility', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([
|
||||
{
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key',
|
||||
lastConnected: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes stable accessible names for both editable fields', async () => {
|
||||
const renderer = await renderEditHostRoute()
|
||||
|
||||
const inputs = renderer.root.findAllByType('TextInput')
|
||||
expect(inputs).toHaveLength(2)
|
||||
expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address'])
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
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(),
|
||||
forceReconnectHost: 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', () => ({
|
||||
useForceReconnect: () => dependencies.forceReconnectHost,
|
||||
usePrimeHosts: () => dependencies.primeHosts
|
||||
}))
|
||||
|
||||
const HOST_FIXTURE = {
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key',
|
||||
lastConnected: 1
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(EditHostScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
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(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
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 renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
name: 'Home Desk'
|
||||
})
|
||||
expect(dependencies.forceReconnectHost).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.forceReconnectHost).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', {
|
||||
name: 'Home Desk',
|
||||
endpoint: 'ws://192.168.1.20:6768'
|
||||
})
|
||||
expect(dependencies.forceReconnectHost).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.forceReconnectHost).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.forceReconnectHost).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('still navigates back and shows no error when the post-save reconnect rejects', async () => {
|
||||
dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed'))
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
|
||||
await pressSave(renderer)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
|
||||
expect(dependencies.back).toHaveBeenCalledTimes(1)
|
||||
expect(findText(renderer, 'connect failed')).toBe(false)
|
||||
|
||||
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(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
dependencies.hostId = 'host-1'
|
||||
dependencies.back.mockReset()
|
||||
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from './host-endpoint'
|
||||
|
||||
describe('displayHostEndpoint', () => {
|
||||
it('shows host:port for websocket URLs', () => {
|
||||
expect(displayHostEndpoint('ws://192.168.1.10:6768')).toBe('192.168.1.10:6768')
|
||||
// Why: URL omits default scheme ports (443 for wss); use a non-default port.
|
||||
expect(displayHostEndpoint('wss://desk.example:8443')).toBe('desk.example:8443')
|
||||
})
|
||||
|
||||
it('returns the raw string when not a URL', () => {
|
||||
expect(displayHostEndpoint('not-a-url')).toBe('not-a-url')
|
||||
})
|
||||
|
||||
it('brackets IPv6 hostnames for round-trip safety', () => {
|
||||
expect(displayHostEndpoint('ws://[fd7a:115c:a1e0::1]:6768')).toBe('[fd7a:115c:a1e0::1]:6768')
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpointPort', () => {
|
||||
it('reads the port when present', () => {
|
||||
expect(endpointPort('ws://192.168.1.10:6768')).toBe('6768')
|
||||
})
|
||||
|
||||
it('returns undefined when the port is omitted', () => {
|
||||
expect(endpointPort('ws://192.168.1.10')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves scheme-default ports that URL.port hides', () => {
|
||||
expect(endpointPort('ws://192.168.1.10:80')).toBe('80')
|
||||
expect(endpointPort('wss://desk.example:443')).toBe('443')
|
||||
})
|
||||
})
|
||||
|
||||
describe('endpointScheme', () => {
|
||||
it('returns wss for a wss:// endpoint', () => {
|
||||
expect(endpointScheme('wss://desk.example:8443')).toBe('wss')
|
||||
})
|
||||
|
||||
it('returns ws for a ws:// endpoint', () => {
|
||||
expect(endpointScheme('ws://192.168.1.10:6768')).toBe('ws')
|
||||
})
|
||||
|
||||
it('falls back to ws for a non-URL endpoint', () => {
|
||||
expect(endpointScheme('not-a-url')).toBe('ws')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHostEndpoint', () => {
|
||||
it('accepts a full ws URL', () => {
|
||||
expect(normalizeHostEndpoint('ws://100.64.0.5:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://100.64.0.5:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts wss and preserves scheme', () => {
|
||||
expect(normalizeHostEndpoint('wss://desk.example:8443')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts bare host:port', () => {
|
||||
expect(normalizeHostEndpoint('192.168.1.10:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults missing port to 6768', () => {
|
||||
expect(normalizeHostEndpoint('192.168.1.10')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fallbackPort when the user omits the port', () => {
|
||||
expect(normalizeHostEndpoint('mac-mini.local', { fallbackPort: '7777' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://mac-mini.local:7777'
|
||||
})
|
||||
})
|
||||
|
||||
it('fills missing port on scheme URLs from fallbackPort', () => {
|
||||
expect(normalizeHostEndpoint('ws://192.168.1.10', { fallbackPort: '9000' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:9000'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves explicit ws :80 and wss :443 instead of rewriting to fallback', () => {
|
||||
expect(normalizeHostEndpoint('ws://192.168.1.10:80', { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://192.168.1.10:80'
|
||||
})
|
||||
expect(normalizeHostEndpoint('wss://desk.example:443', { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:443'
|
||||
})
|
||||
expect(displayHostEndpoint('ws://192.168.1.10:80')).toBe('192.168.1.10:80')
|
||||
expect(displayHostEndpoint('wss://desk.example:443')).toBe('desk.example:443')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeHostEndpoint(' 10.0.0.2:6768 ')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://10.0.0.2:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(normalizeHostEndpoint(' ')).toEqual({
|
||||
ok: false,
|
||||
error: 'Enter a host address.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-websocket schemes', () => {
|
||||
expect(normalizeHostEndpoint('http://192.168.1.10:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Use ws:// or wss:// (or host:port).'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects explicit invalid ports without falling back', () => {
|
||||
for (const input of [
|
||||
'192.168.1.10:0',
|
||||
'192.168.1.10:99999',
|
||||
'ws://192.168.1.10:0',
|
||||
'ws://192.168.1.10:99999'
|
||||
]) {
|
||||
expect(normalizeHostEndpoint(input, { fallbackPort: '6768' })).toEqual({
|
||||
ok: false,
|
||||
error: 'Port must be 1–65535.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bare hosts with path, query, or spaces', () => {
|
||||
expect(normalizeHostEndpoint('desk/path')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('desk?route')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('desk name')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects scheme URLs with path or query', () => {
|
||||
expect(normalizeHostEndpoint('ws://desk.example/path')).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
expect(normalizeHostEndpoint('ws://desk.example?route=1')).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts bracketed IPv6 with port', () => {
|
||||
expect(normalizeHostEndpoint('[fd7a:115c:a1e0::1]:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://[fd7a:115c:a1e0::1]:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed IPv6 that a WebSocket URL cannot parse', () => {
|
||||
expect(normalizeHostEndpoint('[1::2::3]:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-canonical numeric IPv4 forms in bare addresses', () => {
|
||||
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
|
||||
expect(normalizeHostEndpoint(`${host}:6768`)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-canonical numeric IPv4 forms before URL normalization', () => {
|
||||
for (const host of ['999.999.999.999', '010.0.0.1', '127.1', '0x7f000001']) {
|
||||
expect(normalizeHostEndpoint(`ws://${host}:6768`)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects encoded and trailing-dot numeric IPv4 aliases', () => {
|
||||
for (const input of ['ws://127.1.:6768', 'wss://127.1.:6768', 'ws://%31%32%37.1:6768']) {
|
||||
expect(normalizeHostEndpoint(input)).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid hostname.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty userinfo before URL normalization', () => {
|
||||
expect(normalizeHostEndpoint('ws://@127.1:6768')).toEqual({
|
||||
ok: false,
|
||||
error: 'Not a valid address.'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a path or query before validating a numeric hostname alias', () => {
|
||||
for (const input of ['ws://127.1/path', 'ws://127.1?route=1']) {
|
||||
expect(normalizeHostEndpoint(input)).toEqual({
|
||||
ok: false,
|
||||
error: 'Host must not include a path or query.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps ordinary DNS names that contain numeric labels', () => {
|
||||
expect(normalizeHostEndpoint('desk123.local:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://desk123.local:6768'
|
||||
})
|
||||
expect(normalizeHostEndpoint('ws://123.example:6768')).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'ws://123.example:6768'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves wss when re-normalizing bare host:port via fallbackScheme', () => {
|
||||
expect(
|
||||
normalizeHostEndpoint('desk.example:8443', { fallbackScheme: 'wss', fallbackPort: '8443' })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
endpoint: 'wss://desk.example:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips displayed IPv6 endpoints without port corruption', () => {
|
||||
const original = 'ws://[fd7a:115c:a1e0::1]:6768'
|
||||
const displayed = displayHostEndpoint(original)
|
||||
expect(displayed).toBe('[fd7a:115c:a1e0::1]:6768')
|
||||
expect(normalizeHostEndpoint(displayed, { fallbackPort: '6768' })).toEqual({
|
||||
ok: true,
|
||||
endpoint: original
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
// Why: mobile host profiles store a single websocket endpoint fixed at pair
|
||||
// time. Edit-host lets the user rewrite host/port without re-pairing; this
|
||||
// helper accepts phone-friendly input (bare IP, host:port, or full ws URL)
|
||||
// and normalizes to the ws(s):// form RpcClient expects.
|
||||
|
||||
export type NormalizeHostEndpointResult =
|
||||
| { ok: true; endpoint: string }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type WebsocketUrlPortResolution =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'valid'; port: string }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
type RawSchemeAuthority = {
|
||||
hostname: string | null
|
||||
hasUserInfo: boolean
|
||||
hasPathOrQuery: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = '6768'
|
||||
const NUMERIC_IPV4_CANDIDATE = /^(?:0[xX][0-9a-fA-F]+|\d+)(?:\.(?:0[xX][0-9a-fA-F]+|\d+))*$/
|
||||
|
||||
export function displayHostEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
// Why: some URL parsers leave IPv6 brackets on hostname, others strip them.
|
||||
// Normalize once so round-trip through normalizeHostEndpoint stays stable.
|
||||
const host = formatHostForUrl(unwrapHostname(url.hostname))
|
||||
const port = resolveWebsocketUrlPort(endpoint, url)
|
||||
if (port.kind === 'invalid') {
|
||||
return endpoint
|
||||
}
|
||||
return port.kind === 'valid' ? `${host}:${port.port}` : host
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapHostname(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an explicitly written port from a ws(s) URL authority.
|
||||
* Why: `new URL('ws://host:80').port` and `wss://host:443` are empty — the
|
||||
* URL parser hides scheme-default ports, so callers that need the user's
|
||||
* literal :80/:443 must re-parse the original string.
|
||||
*/
|
||||
function extractExplicitPortFromWebsocketUrl(input: string): string | null {
|
||||
const withoutScheme = input.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
|
||||
if (withoutScheme.startsWith('[')) {
|
||||
const close = withoutScheme.indexOf(']')
|
||||
if (close <= 1) {
|
||||
return null
|
||||
}
|
||||
const rest = withoutScheme.slice(close + 1)
|
||||
const match = /^:(\d+)(?=[/?#]|$)/.exec(rest)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
const end = withoutScheme.search(/[/?#]/)
|
||||
const authority = end === -1 ? withoutScheme : withoutScheme.slice(0, end)
|
||||
const at = authority.lastIndexOf('@')
|
||||
const hostPort = at === -1 ? authority : authority.slice(at + 1)
|
||||
const match = /:(\d+)$/.exec(hostPort)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function resolveWebsocketUrlPort(input: string, url?: URL): WebsocketUrlPortResolution {
|
||||
const explicit = extractExplicitPortFromWebsocketUrl(input)
|
||||
// Why: missing and invalid are different states. Treating both as null lets
|
||||
// an explicit :0/:99999 silently inherit fallbackPort on permissive parsers.
|
||||
if (explicit !== null && !isValidPort(explicit)) {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
if (url?.port) {
|
||||
return isValidPort(url.port) ? { kind: 'valid', port: url.port } : { kind: 'invalid' }
|
||||
}
|
||||
if (explicit !== null) {
|
||||
return { kind: 'valid', port: explicit }
|
||||
}
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
|
||||
export function endpointPort(endpoint: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
const port = resolveWebsocketUrlPort(endpoint, url)
|
||||
return port.kind === 'valid' ? port.port : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointScheme(endpoint: string): 'ws' | 'wss' {
|
||||
try {
|
||||
const protocol = new URL(endpoint).protocol.replace(':', '')
|
||||
return protocol === 'wss' ? 'wss' : 'ws'
|
||||
} catch {
|
||||
return 'ws'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHostEndpoint(
|
||||
input: string,
|
||||
options?: { fallbackPort?: string | number; fallbackScheme?: 'ws' | 'wss' }
|
||||
): NormalizeHostEndpointResult {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: 'Enter a host address.' }
|
||||
}
|
||||
|
||||
const fallbackPort = resolveFallbackPort(options?.fallbackPort)
|
||||
const fallbackScheme = options?.fallbackScheme === 'wss' ? 'wss' : 'ws'
|
||||
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
||||
return normalizeSchemeUrl(trimmed, fallbackPort)
|
||||
}
|
||||
|
||||
return normalizeHostPort(trimmed, fallbackPort, fallbackScheme)
|
||||
}
|
||||
|
||||
function resolveFallbackPort(value: string | number | undefined): string {
|
||||
if (value == null) {
|
||||
return DEFAULT_PORT
|
||||
}
|
||||
const asString = String(value).trim()
|
||||
if (!asString || !isValidPort(asString)) {
|
||||
return DEFAULT_PORT
|
||||
}
|
||||
return asString
|
||||
}
|
||||
|
||||
function normalizeSchemeUrl(input: string, fallbackPort: string): NormalizeHostEndpointResult {
|
||||
const explicitPort = resolveWebsocketUrlPort(input)
|
||||
if (explicitPort.kind === 'invalid') {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(input)?.[1]?.toLowerCase()
|
||||
if (scheme !== 'ws' && scheme !== 'wss') {
|
||||
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
|
||||
}
|
||||
|
||||
const rawAuthority = parseRawSchemeAuthority(input)
|
||||
if (rawAuthority.hasUserInfo) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
if (rawAuthority.hasPathOrQuery) {
|
||||
return { ok: false, error: 'Host must not include a path or query.' }
|
||||
}
|
||||
if (
|
||||
rawAuthority.hostname &&
|
||||
validateNumericIpv4Candidate(normalizeRawNumericIpv4Candidate(rawAuthority.hostname))
|
||||
) {
|
||||
return { ok: false, error: 'Not a valid hostname.' }
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
return { ok: false, error: 'Use ws:// or wss:// (or host:port).' }
|
||||
}
|
||||
if (!url.hostname) {
|
||||
return { ok: false, error: 'Missing hostname.' }
|
||||
}
|
||||
|
||||
// Why: edit-host persists a bare host:port WebSocket endpoint. Path/query/
|
||||
// userinfo are not part of the pairing contract — reject rather than strip
|
||||
// so typos like desk/path or desk?route cannot be saved silently.
|
||||
if (url.username || url.password) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
if ((url.pathname && url.pathname !== '/') || url.search || url.hash) {
|
||||
return { ok: false, error: 'Host must not include a path or query.' }
|
||||
}
|
||||
|
||||
const hostname = unwrapHostname(url.hostname)
|
||||
// Why: WHATWG URL accepts legacy aliases and rewrites them to a different
|
||||
// IPv4 address. Only an already-canonical raw dotted quad may become IPv4.
|
||||
if (rawAuthority.hostname && isCanonicalIpv4(hostname) && rawAuthority.hostname !== hostname) {
|
||||
return { ok: false, error: 'Not a valid hostname.' }
|
||||
}
|
||||
const hostError = validateHostname(hostname)
|
||||
if (hostError) {
|
||||
return { ok: false, error: hostError }
|
||||
}
|
||||
|
||||
// Why: keep explicit :80/:443 (URL.port is empty for scheme defaults) instead
|
||||
// of rewriting them to fallbackPort (usually 6768).
|
||||
const resolvedPort = resolveWebsocketUrlPort(input, url)
|
||||
if (resolvedPort.kind === 'invalid') {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
const port = resolvedPort.kind === 'valid' ? resolvedPort.port : fallbackPort
|
||||
|
||||
// Why: rebuild so accidental whitespace never reaches the WebSocket constructor.
|
||||
return { ok: true, endpoint: `${url.protocol}//${formatHostForUrl(hostname)}:${port}` }
|
||||
}
|
||||
|
||||
function parseRawSchemeAuthority(input: string): RawSchemeAuthority {
|
||||
const schemeEnd = input.indexOf('://')
|
||||
const remainder = input.slice(schemeEnd + 3)
|
||||
const authorityEnd = remainder.search(/[/?#]/)
|
||||
const authority = authorityEnd === -1 ? remainder : remainder.slice(0, authorityEnd)
|
||||
const suffix = authorityEnd === -1 ? '' : remainder.slice(authorityEnd)
|
||||
const hasUserInfo = authority.includes('@')
|
||||
const hostPort = hasUserInfo ? authority.slice(authority.lastIndexOf('@') + 1) : authority
|
||||
if (!hostPort) {
|
||||
return { hostname: null, hasUserInfo, hasPathOrQuery: suffix !== '' && suffix !== '/' }
|
||||
}
|
||||
if (hostPort.startsWith('[')) {
|
||||
const close = hostPort.indexOf(']')
|
||||
return {
|
||||
hostname: close > 1 ? hostPort.slice(1, close) : null,
|
||||
hasUserInfo,
|
||||
hasPathOrQuery: suffix !== '' && suffix !== '/'
|
||||
}
|
||||
}
|
||||
const lastColon = hostPort.lastIndexOf(':')
|
||||
return {
|
||||
hostname: lastColon === -1 ? hostPort : hostPort.slice(0, lastColon),
|
||||
hasUserInfo,
|
||||
hasPathOrQuery: suffix !== '' && suffix !== '/'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostPort(
|
||||
input: string,
|
||||
fallbackPort: string,
|
||||
fallbackScheme: 'ws' | 'wss'
|
||||
): NormalizeHostEndpointResult {
|
||||
let host: string
|
||||
let port: string | undefined
|
||||
|
||||
if (input.startsWith('[')) {
|
||||
const close = input.indexOf(']')
|
||||
if (close <= 1) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
host = input.slice(1, close)
|
||||
const rest = input.slice(close + 1)
|
||||
if (rest.startsWith(':')) {
|
||||
port = rest.slice(1)
|
||||
} else if (rest.length > 0) {
|
||||
return { ok: false, error: 'Not a valid address.' }
|
||||
}
|
||||
} else {
|
||||
const firstColon = input.indexOf(':')
|
||||
const lastColon = input.lastIndexOf(':')
|
||||
if (firstColon !== -1 && firstColon === lastColon) {
|
||||
host = input.slice(0, firstColon)
|
||||
port = input.slice(firstColon + 1)
|
||||
} else {
|
||||
// No port, or bare IPv6 (multiple colons, no brackets).
|
||||
host = input
|
||||
}
|
||||
}
|
||||
|
||||
host = host.trim()
|
||||
if (!host) {
|
||||
return { ok: false, error: 'Missing hostname.' }
|
||||
}
|
||||
|
||||
// Why: bare input is not a URL, so characters that only make sense in a URL
|
||||
// (path, query, fragment, whitespace) must not be treated as hostname bytes.
|
||||
const hostError = validateHostname(host)
|
||||
if (hostError) {
|
||||
return { ok: false, error: hostError }
|
||||
}
|
||||
|
||||
if (port !== undefined) {
|
||||
port = port.trim()
|
||||
if (!isValidPort(port)) {
|
||||
return { ok: false, error: 'Port must be 1–65535.' }
|
||||
}
|
||||
}
|
||||
|
||||
const finalPort = port ?? fallbackPort
|
||||
return { ok: true, endpoint: `${fallbackScheme}://${formatHostForUrl(host)}:${finalPort}` }
|
||||
}
|
||||
|
||||
function formatHostForUrl(host: string): string {
|
||||
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject hostnames that would be illegal or ambiguous in a websocket URL.
|
||||
* Allows DNS labels, `.local` mDNS, IPv4, and IPv6 hex forms.
|
||||
*/
|
||||
function validateHostname(host: string): string | null {
|
||||
if (!host) {
|
||||
return 'Missing hostname.'
|
||||
}
|
||||
// Spaces, path/query/fragment separators, userinfo separators, brackets.
|
||||
if (/[\s/?#@[\]]/.test(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
const numericIpv4Error = validateNumericIpv4Candidate(host)
|
||||
if (numericIpv4Error) {
|
||||
return numericIpv4Error
|
||||
}
|
||||
if (host.includes(':')) {
|
||||
// Why: a hex/colon regex accepts malformed forms such as two `::` runs.
|
||||
// Reuse the URL parser that WebSocket will ultimately use.
|
||||
if (!/^[0-9a-fA-F:]+$/.test(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
try {
|
||||
new URL(`ws://[${host}]:${DEFAULT_PORT}`)
|
||||
} catch {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
// DNS / IPv4 / mDNS: labels of alnum and hyphen, dots between, no empty labels.
|
||||
if (
|
||||
!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
|
||||
host
|
||||
)
|
||||
) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateNumericIpv4Candidate(host: string): string | null {
|
||||
if (!NUMERIC_IPV4_CANDIDATE.test(host)) {
|
||||
return null
|
||||
}
|
||||
if (!isCanonicalIpv4(host)) {
|
||||
return 'Not a valid hostname.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeRawNumericIpv4Candidate(host: string): string {
|
||||
let decoded = host
|
||||
try {
|
||||
decoded = decodeURIComponent(host)
|
||||
} catch {
|
||||
// The URL parser will reject malformed escapes; keep them untouched here.
|
||||
}
|
||||
return decoded.endsWith('.') ? decoded.slice(0, -1) : decoded
|
||||
}
|
||||
|
||||
function isCanonicalIpv4(host: string): boolean {
|
||||
const octets = host.split('.')
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)
|
||||
)
|
||||
}
|
||||
|
||||
function isValidPort(port: string): boolean {
|
||||
if (!/^\d+$/.test(port)) {
|
||||
return false
|
||||
}
|
||||
const n = Number(port)
|
||||
return n >= 1 && n <= 65535
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { updateHostNameAndEndpoint } from './host-store'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
getItemAsync: vi.fn(),
|
||||
setItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn(),
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY'
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
}))
|
||||
|
||||
describe('updateHostNameAndEndpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
const stored = [
|
||||
{
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://100.64.0.5:6768',
|
||||
publicKeyB64: 'pk',
|
||||
lastConnected: 1
|
||||
},
|
||||
{
|
||||
id: 'host-2',
|
||||
name: 'Laptop',
|
||||
endpoint: 'wss://laptop.example:8443',
|
||||
publicKeyB64: 'pk-2',
|
||||
lastConnected: 2
|
||||
}
|
||||
]
|
||||
|
||||
it('commits name and endpoint together in a single write', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', {
|
||||
name: 'Home Desk',
|
||||
endpoint: 'ws://192.168.1.10:6768'
|
||||
})
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([
|
||||
{ ...stored[0], name: 'Home Desk', endpoint: 'ws://192.168.1.10:6768' },
|
||||
stored[1]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('updates only the provided field', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', { name: 'Home Desk' })
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([{ ...stored[0], name: 'Home Desk' }, stored[1]])
|
||||
)
|
||||
})
|
||||
|
||||
it('rewrites only the endpoint when name is omitted', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' })
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:hosts',
|
||||
JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]])
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and writes nothing when the host is missing', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('[]')
|
||||
|
||||
await expect(updateHostNameAndEndpoint('missing', { name: 'Renamed' })).rejects.toThrow(
|
||||
'Host not found'
|
||||
)
|
||||
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -37,11 +37,11 @@ import {
|
|||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
removeHost,
|
||||
renameHost,
|
||||
resolvePairingHostIdentity,
|
||||
resetHostStoreForTests,
|
||||
saveHost,
|
||||
saveExistingHostRelayUpgrade,
|
||||
updateHostNameAndEndpoint,
|
||||
updateLastConnected
|
||||
} from './host-store'
|
||||
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
|
||||
|
|
@ -273,7 +273,7 @@ describe('host-store list mutations', () => {
|
|||
return storedHostsRaw
|
||||
})
|
||||
|
||||
const rename = renameHost(HOST_ONE.id, 'Renamed Host')
|
||||
const rename = updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Renamed Host' })
|
||||
const remove = removeHost(HOST_TWO.id)
|
||||
// Both writers have started their RMW and are blocked on the shared read
|
||||
// gate; without a mutation queue the second would clobber the first.
|
||||
|
|
@ -294,9 +294,9 @@ describe('host-store list mutations', () => {
|
|||
it('preserves a rename when lastConnected updates race it', async () => {
|
||||
const before = Date.now()
|
||||
await Promise.all([
|
||||
renameHost(HOST_ONE.id, 'Alpha'),
|
||||
updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Alpha' }),
|
||||
updateLastConnected(HOST_ONE.id),
|
||||
renameHost(HOST_TWO.id, 'Beta')
|
||||
updateHostNameAndEndpoint(HOST_TWO.id, { name: 'Beta' })
|
||||
])
|
||||
|
||||
const stored = JSON.parse(storedHostsRaw) as Array<typeof HOST_ONE>
|
||||
|
|
@ -312,7 +312,9 @@ describe('host-store list mutations', () => {
|
|||
|
||||
it('does not wipe the host list when storage is unreadable during mutation', async () => {
|
||||
storedHostsRaw = '{'
|
||||
await expect(renameHost(HOST_ONE.id, 'Nope')).rejects.toThrow(/unreadable/)
|
||||
await expect(updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Nope' })).rejects.toThrow(
|
||||
/unreadable/
|
||||
)
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
expect(storedHostsRaw).toBe('{')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -333,14 +333,25 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
|
|||
return retryPendingHostCredentialCleanups(deleteHostCredentials)
|
||||
}
|
||||
|
||||
export async function renameHost(hostId: string, newName: string): Promise<void> {
|
||||
// Why: Edit host can change name and endpoint together; a single
|
||||
// mutateStoredHosts pass keeps both fields committed atomically so a
|
||||
// mid-save failure can never persist one change without the other, and a
|
||||
// host removed mid-edit throws consistently instead of silently no-oping.
|
||||
export async function updateHostNameAndEndpoint(
|
||||
hostId: string,
|
||||
updates: { name?: string; endpoint?: string }
|
||||
): Promise<void> {
|
||||
await mutateStoredHosts((hosts) => {
|
||||
const index = hosts.findIndex((h) => h.id === hostId)
|
||||
const index = hosts.findIndex((host) => host.id === hostId)
|
||||
if (index < 0) {
|
||||
return hosts
|
||||
throw new Error('Host not found')
|
||||
}
|
||||
const next = hosts.slice()
|
||||
next[index] = { ...next[index]!, name: newName }
|
||||
next[index] = {
|
||||
...next[index]!,
|
||||
...(updates.name !== undefined ? { name: updates.name } : {}),
|
||||
...(updates.endpoint !== undefined ? { endpoint: updates.endpoint } : {})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue