Files
aiturk-hermes-ide/apps/desktop/src/app/settings/connections-registry.test.tsx
T

396 lines
14 KiB
TypeScript

import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
import { $connection } from '@/store/session'
import {
ConnectionsRegistrySection,
findDuplicateConnection,
normalizeGatewayUrl,
sameBackendPeerLabel,
sshCompositeKey
} from './connections-registry'
import { renderWithEnglish as render } from './test-locale'
const list = vi.fn()
const save = vi.fn()
const remove = vi.fn()
const setLaunchMode = vi.fn()
const setPrimary = vi.fn()
const test = vi.fn()
const registry: DesktopConnectionsRegistry = {
connections: [
{ id: 'local', kind: 'local', label: 'This device', tokenPreview: null, tokenSet: false },
{
authMode: 'token',
id: 'homelab',
kind: 'remote',
label: 'Homelab',
tokenPreview: '...abc123',
tokenSet: true,
url: 'http://homelab.lan:9119'
}
],
primary: 'local',
secureTokenStorage: true,
version: 2
}
beforeEach(() => {
$connection.set({
baseUrl: 'http://homelab.lan:9119',
connectionId: 'homelab',
isFullscreen: false,
logs: [],
mode: 'remote',
nativeOverlayWidth: 0,
token: 'test-token',
windowButtonPosition: null,
wsUrl: 'ws://homelab.lan:9119/ws'
})
list.mockResolvedValue(registry)
save.mockResolvedValue({ connection: registry.connections[1], ok: true, registry })
remove.mockResolvedValue({ ok: true, registry: { ...registry, connections: [registry.connections[0]] } })
setLaunchMode.mockResolvedValue({ ok: true, registry: { ...registry, launchMode: 'last-used' } })
setPrimary.mockResolvedValue({ ok: true, registry: { ...registry, primary: 'homelab' } })
test.mockResolvedValue({ ok: true, reachable: true })
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { connections: { list, remove, save, setLaunchMode, setPrimary, test } }
})
})
afterEach(() => {
$connection.set(null)
cleanup()
vi.clearAllMocks()
})
describe('ConnectionsRegistrySection', () => {
it('distinguishes the current connection from the registry primary', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
// Label and the managed pill share the copy, so expect both instances.
expect(screen.getAllByText('This device').length).toBeGreaterThan(0)
expect(screen.getByText('Current')).toBeTruthy()
expect(screen.getAllByText('Primary').length).toBeGreaterThan(0)
expect(list).toHaveBeenCalledTimes(1)
})
it('opens the add-connection editor and saves with a required label', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
// Save is disabled until a label is present.
const saveButton = screen.getByText('Save connection').closest('button')!
expect(saveButton.disabled).toBe(true)
fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'Spark box' } })
fireEvent.change(screen.getByPlaceholderText('http://homelab.lan:9119'), {
target: { value: 'http://spark.lan:9119' }
})
expect(saveButton.disabled).toBe(false)
fireEvent.click(saveButton)
await waitFor(() => expect(save).toHaveBeenCalledTimes(1))
expect(save.mock.calls[0][0]).toMatchObject({
kind: 'remote',
label: 'Spark box',
url: 'http://spark.lan:9119'
})
})
it('offers every kind on create and disables Local while the managed entry exists', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
const localKind = screen.getByRole('button', { name: 'Local' }) as HTMLButtonElement
expect(localKind.disabled).toBe(true)
expect(screen.getByRole('button', { name: 'Hermes Cloud' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Remote gateway' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'SSH' })).toBeTruthy()
})
it('rejects a duplicate gateway URL in the save path with an inline error', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getByText('Add connection'))
fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'Homelab twin' } })
// Same URL modulo case + trailing slash: normalized-dupe of the existing entry.
fireEvent.change(screen.getByPlaceholderText('http://homelab.lan:9119'), {
target: { value: 'HTTP://HOMELAB.LAN:9119/' }
})
fireEvent.click(screen.getByText('Save connection').closest('button')!)
await waitFor(() =>
expect(screen.getByText('A connection to this gateway URL already exists (“Homelab”).')).toBeTruthy()
)
expect(save).not.toHaveBeenCalled()
})
it('keeps the primary fallback configurable while last-used restore is enabled', async () => {
list.mockResolvedValueOnce({ ...registry, launchMode: 'last-used' })
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
const makePrimary = screen.getByText('Make primary').closest('button')!
expect(makePrimary.disabled).toBe(false)
fireEvent.click(makePrimary)
await waitFor(() => expect(setPrimary).toHaveBeenCalledWith('homelab'))
})
it('lets users opt into restoring the last-used source', async () => {
render(<ConnectionsRegistrySection />)
const launchSetting = await screen.findByText('At startup, return to Sessions on the last-used gateway')
const addConnection = screen.getByText('Add connection')
expect(addConnection.compareDocumentPosition(launchSetting) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(screen.getByRole('switch', { name: 'At startup, return to Sessions on the last-used gateway' }))
await waitFor(() => expect(setLaunchMode).toHaveBeenCalledWith('last-used'))
})
it('offers the launch preference even for a single source', async () => {
// A local-only registry is the drift state from #90174, and the launch
// toggle is the control that lets a user out of it. Hiding it there left
// hand-editing connections.json as the only recourse.
list.mockResolvedValueOnce({ ...registry, connections: [registry.connections[0]] })
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(list).toHaveBeenCalledTimes(1))
expect(screen.getByText('At startup, return to Sessions on the last-used gateway')).toBeTruthy()
})
it('keeps search out of the way for a small registry', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
expect(screen.queryByRole('searchbox', { name: 'Search gateways…' })).toBeNull()
})
it('sorts a large registry and searches names and endpoints', async () => {
const largeRegistry: DesktopConnectionsRegistry = {
...registry,
connections: [
{
authMode: 'token',
id: 'zulu',
kind: 'remote',
label: 'Zulu',
tokenPreview: null,
tokenSet: false,
url: 'https://zulu.example.test'
},
registry.connections[0],
...Array.from({ length: 6 }, (_, index) => ({
authMode: 'token' as const,
id: `gateway-${index}`,
kind: 'remote' as const,
label: index === 0 ? 'Alpha' : `Gateway ${index}`,
tokenPreview: null,
tokenSet: false,
url:
index === 4
? 'https://studio.example.test'
: index === 5
? 'https://studio-archive.example.test'
: `https://gateway-${index}.example.test`
}))
]
}
list.mockResolvedValueOnce(largeRegistry)
render(
<div data-testid="settings-scroller" style={{ height: 400, overflowY: 'auto' }}>
<ConnectionsRegistrySection />
</div>
)
const search = await screen.findByRole('searchbox', { name: 'Search gateways…' })
expect(search.parentElement?.className).toContain('mt-3')
expect(search.parentElement?.className).toContain('mb-0')
const settingsScroller = screen.getByTestId('settings-scroller')
settingsScroller.scrollTop = 200
vi.spyOn(search, 'getBoundingClientRect')
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValueOnce({
bottom: 152,
height: 32,
left: 0,
right: 0,
top: 120,
width: 0,
x: 0,
y: 120,
toJSON: () => ({})
})
.mockReturnValue({
bottom: 182,
height: 32,
left: 0,
right: 0,
top: 150,
width: 0,
x: 0,
y: 150,
toJSON: () => ({})
})
const alpha = screen.getByText('Alpha')
const zulu = screen.getByText('Zulu')
expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.change(search, { target: { value: 'studio' } })
expect(settingsScroller.scrollTop).toBe(200)
expect(screen.getByText('Gateway 4')).toBeTruthy()
expect(screen.getByText('Gateway 5')).toBeTruthy()
expect(screen.queryByText('Alpha')).toBeNull()
settingsScroller.scrollTop = 260
fireEvent.change(search, { target: { value: 'studio.example' } })
expect(settingsScroller.scrollTop).toBe(290)
expect(screen.getByText('Gateway 4')).toBeTruthy()
expect(screen.queryByText('Gateway 5')).toBeNull()
fireEvent.change(search, { target: { value: '' } })
expect(search.closest<HTMLElement>('.border-t')?.style.minHeight).toBe('')
})
it('tests a connection through the bridge', async () => {
render(<ConnectionsRegistrySection />)
await waitFor(() => expect(screen.getByText('Homelab')).toBeTruthy())
fireEvent.click(screen.getAllByText('Test')[0])
await waitFor(() => expect(test).toHaveBeenCalled())
})
})
describe('dedupe helpers', () => {
it('normalizes gateway URLs (trim, trailing slashes, lowercase)', () => {
expect(normalizeGatewayUrl(' HTTP://Homelab.LAN:9119// ')).toBe('http://homelab.lan:9119')
})
it('normalizes ssh composites and defaults the port', () => {
expect(sshCompositeKey('alice@Box')).toBe('alice@box:22')
expect(sshCompositeKey('alice@box:22')).toBe('alice@box:22')
expect(sshCompositeKey('box:2222')).toBe('@box:2222')
expect(sshCompositeKey(' ')).toBe('')
})
it('finds at most one local entry', () => {
expect(
findDuplicateConnection({ host: '', id: null, kind: 'local', remoteProfile: '', url: '' }, registry.connections)
).toMatchObject({ id: 'local' })
// Editing the local entry itself is not a self-collision.
expect(
findDuplicateConnection(
{ host: '', id: 'local', kind: 'local', remoteProfile: '', url: '' },
registry.connections
)
).toBeNull()
})
it('keys remote/cloud dupes on the normalized URL across both kinds', () => {
expect(
findDuplicateConnection(
{ host: '', id: null, kind: 'cloud', remoteProfile: '', url: 'http://HOMELAB.lan:9119/' },
registry.connections
)
).toMatchObject({ id: 'homelab' })
expect(
findDuplicateConnection(
{ host: '', id: null, kind: 'remote', remoteProfile: '', url: 'http://other.lan:9119' },
registry.connections
)
).toBeNull()
// Editing the entry itself is not a self-collision.
expect(
findDuplicateConnection(
{ host: '', id: 'homelab', kind: 'remote', remoteProfile: '', url: 'http://homelab.lan:9119' },
registry.connections
)
).toBeNull()
})
it('keys ssh dupes on user@host:port + remote profile', () => {
const connections = [
...registry.connections,
{
host: 'box',
id: 'box',
kind: 'ssh' as const,
label: 'Box',
port: 22,
remoteProfile: 'work',
tokenPreview: null,
tokenSet: false,
user: 'alice'
}
]
expect(
findDuplicateConnection(
{ host: 'alice@box:22', id: null, kind: 'ssh', remoteProfile: 'work', url: '' },
connections
)
).toMatchObject({ id: 'box' })
// Different profile on the same host is a distinct agent source.
expect(
findDuplicateConnection(
{ host: 'alice@box:22', id: null, kind: 'ssh', remoteProfile: 'other', url: '' },
connections
)
).toBeNull()
})
it('hints "Same backend as" only on later rows sharing an install_id', () => {
const spark = { id: 'spark', installId: 'aaa', label: 'Spark' }
const sparkTs = { id: 'spark-ts', installId: 'aaa', label: 'Spark TS' }
const mini = { id: 'mini', installId: 'bbb', label: 'Mini' }
const legacy = { id: 'old', label: 'Old box' }
const connections = [spark, sparkTs, mini, legacy]
// The first occurrence carries no hint; the later duplicate points back.
expect(sameBackendPeerLabel(spark, connections)).toBeNull()
expect(sameBackendPeerLabel(sparkTs, connections)).toBe('Spark')
// Unique ids and id-less (older backend) rows never hint.
expect(sameBackendPeerLabel(mini, connections)).toBeNull()
expect(sameBackendPeerLabel(legacy, connections)).toBeNull()
})
})