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() 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() 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() 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() 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() 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() 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() 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() 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(
) 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('.border-t')?.style.minHeight).toBe('') }) it('tests a connection through the bridge', async () => { render() 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() }) })