diff --git a/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx deleted file mode 100644 index b1cd5ed..0000000 --- a/frontend-modern/src/components/Settings/__tests__/DockerAgents.test.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, fireEvent, screen, waitFor, cleanup } from '@solidjs/testing-library'; -import { createStore } from 'solid-js/store'; -import { DockerAgents } from '../DockerAgents'; -import type { DockerHost, RemovedDockerHost } from '@/types/api'; - -let mockWsStore: { - state: { dockerHosts: DockerHost[]; removedDockerHosts: RemovedDockerHost[] }; - connected: () => boolean; - reconnecting: () => boolean; - activeAlerts: unknown[]; -}; - -const allowDockerHostReenrollMock = vi.fn(); -const deleteDockerHostMock = vi.fn(); -const unhideDockerHostMock = vi.fn(); -const notificationSuccessMock = vi.fn(); -const notificationErrorMock = vi.fn(); -const fetchMock = vi.fn(); - -vi.mock('@/App', () => ({ - useWebSocket: () => mockWsStore, -})); - -vi.mock('@/api/monitoring', () => ({ - MonitoringAPI: { - allowDockerHostReenroll: (...args: unknown[]) => allowDockerHostReenrollMock(...args), - deleteDockerHost: (...args: unknown[]) => deleteDockerHostMock(...args), - unhideDockerHost: (...args: unknown[]) => unhideDockerHostMock(...args), - }, -})); - -vi.mock('@/api/security', () => ({ - SecurityAPI: { - createToken: vi.fn(), - }, -})); - -vi.mock('@/stores/notifications', () => ({ - notificationStore: { - success: (...args: unknown[]) => notificationSuccessMock(...args), - error: (...args: unknown[]) => notificationErrorMock(...args), - }, -})); - -const createDockerHost = (overrides?: Partial): DockerHost => ({ - id: 'host-1', - agentId: 'agent-1', - hostname: 'host-1.local', - displayName: 'Host One', - cpus: 4, - totalMemoryBytes: 8 * 1024 * 1024 * 1024, - uptimeSeconds: 12_345, - status: 'online', - lastSeen: Date.now(), - intervalSeconds: 30, - containers: [], - ...overrides, -}); - -const createRemovedHost = (overrides?: Partial): RemovedDockerHost => ({ - id: 'host-removed', - hostname: 'retired-node.local', - displayName: 'Retired Node', - removedAt: Date.now() - 60_000, - ...overrides, -}); - -const setupComponent = (hosts: DockerHost[], removedHosts: RemovedDockerHost[] = []) => { - const [state] = createStore({ - dockerHosts: hosts, - removedDockerHosts: removedHosts, - }); - - mockWsStore = { - state, - connected: () => true, - reconnecting: () => false, - activeAlerts: [], - }; - - return render(() => ); -}; - -beforeEach(() => { - allowDockerHostReenrollMock.mockReset(); - deleteDockerHostMock.mockReset(); - unhideDockerHostMock.mockReset(); - notificationSuccessMock.mockReset(); - notificationErrorMock.mockReset(); - fetchMock.mockReset(); - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ requiresAuth: true, apiTokenConfigured: false }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe('DockerAgents removed hosts', () => { - it('renders removed hosts card and triggers allow/copy actions', async () => { - allowDockerHostReenrollMock.mockResolvedValue(undefined); - const clipboardSpy = vi.fn().mockResolvedValue(undefined); - - vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); - - setupComponent([], [createRemovedHost()]); - - expect(screen.getByText('Recently removed container hosts')).toBeInTheDocument(); - const allowButton = screen.getByRole('button', { name: 'Allow re-enroll' }); - fireEvent.click(allowButton); - - await waitFor(() => expect(allowDockerHostReenrollMock).toHaveBeenCalledWith('host-removed'), { interval: 0 }); - - const copyButton = screen.getByRole('button', { name: 'Copy curl command' }); - fireEvent.click(copyButton); - - await waitFor(() => expect(clipboardSpy).toHaveBeenCalledTimes(1), { interval: 0 }); - const copiedCommand = clipboardSpy.mock.calls.at(-1)?.[0]; - expect(typeof copiedCommand).toBe('string'); - expect(copiedCommand).toContain('/api/agents/docker/hosts/host-removed/allow-reenroll'); - - expect(notificationSuccessMock).toHaveBeenCalled(); - expect(notificationErrorMock).not.toHaveBeenCalled(); - }); - - it('shows progress stepper for an in-progress removal command', async () => { - const now = Date.now(); - const host = createDockerHost({ - command: { - id: 'cmd-1', - type: 'stop', - status: 'acknowledged', - createdAt: now - 60_000, - updatedAt: now - 20_000, - dispatchedAt: now - 40_000, - acknowledgedAt: now - 10_000, - }, - }); - - setupComponent([host]); - - const removeButton = screen.getByRole('button', { name: 'Remove' }); - fireEvent.click(removeButton); - - await screen.findByText('Remove container host "Host One"'); - expect(screen.getByText('acknowledged')).toBeInTheDocument(); - - const progressHeading = screen.getByText('Progress'); - const progressCard = progressHeading.parentElement?.parentElement; - if (!progressCard) { - throw new Error('Progress card not found'); - } - - const steps = Array.from(progressCard.querySelectorAll('li')); - expect(steps).toHaveLength(4); - - expect(steps[0]).toHaveTextContent('Stop command queued'); - expect(steps[1]).toHaveTextContent('Instruction delivered to the agent'); - expect(steps[2]).toHaveTextContent('Agent acknowledged the stop request'); - expect(steps[3]).toHaveTextContent('Agent disabled the service and removed autostart'); - - const indicators = steps.map((step) => step.querySelector('span')); - expect(indicators[0]?.className).toContain('bg-blue-500'); - expect(indicators[1]?.className).toContain('bg-blue-500'); - expect(indicators[2]?.className).toContain('animate-pulse'); - expect(indicators[3]?.className).toContain('bg-gray-300'); - }); - - it('shows waiting message once the agent has already stopped', async () => { - const host = createDockerHost({ - status: 'online', - pendingUninstall: true, - command: undefined, - lastSeen: Date.now() - 45_000, - }); - - setupComponent([host]); - - const viewButton = await screen.findByRole('button', { name: 'View progress' }); - fireEvent.click(viewButton); - - await screen.findByText('Pulse is waiting for', { exact: false }); - - const stopButton = screen.getByRole('button', { name: 'Waiting for host…' }); - expect(stopButton).toBeDisabled(); - expect(screen.queryByText('Progress')).not.toBeInTheDocument(); - }); - - it('shows confirmation while waiting for the agent to acknowledge the stop command', async () => { - deleteDockerHostMock.mockResolvedValue({}); - const host = createDockerHost(); - - setupComponent([host]); - - const removeButton = screen.getByRole('button', { name: 'Remove' }); - fireEvent.click(removeButton); - - await screen.findByText('Remove container host "Host One"'); - - const stopButton = screen.getByRole('button', { name: 'Stop agent now' }); - fireEvent.click(stopButton); - - await waitFor(() => expect(deleteDockerHostMock).toHaveBeenCalledWith('host-1'), { interval: 0 }); - - const waitingButton = await screen.findByRole('button', { name: 'Waiting for agent…' }); - expect(waitingButton).toBeDisabled(); - - expect(screen.getByText('Stop command sent.')).toBeInTheDocument(); - expect(notificationSuccessMock).toHaveBeenCalledWith('Stop command sent to Host One', 3500); - }); -}); diff --git a/frontend-modern/src/components/Settings/__tests__/HostAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/HostAgents.test.tsx deleted file mode 100644 index d5d418e..0000000 --- a/frontend-modern/src/components/Settings/__tests__/HostAgents.test.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, fireEvent, screen, waitFor, cleanup } from '@solidjs/testing-library'; -import { createStore } from 'solid-js/store'; -import { HostAgents } from '../HostAgents'; -import type { Host } from '@/types/api'; - -let mockWsStore: { - state: { hosts: Host[]; connectionHealth?: Record }; - connected: () => boolean; - reconnecting: () => boolean; - activeAlerts: unknown[]; -}; - -const lookupMock = vi.fn(); -const createTokenMock = vi.fn(); -const deleteHostAgentMock = vi.fn(); -const notificationSuccessMock = vi.fn(); -const notificationErrorMock = vi.fn(); -const notificationInfoMock = vi.fn(); -const clipboardSpy = vi.fn(); - -vi.mock('@/App', () => ({ - useWebSocket: () => mockWsStore, -})); - -vi.mock('@/api/monitoring', () => ({ - MonitoringAPI: { - lookupHost: (...args: unknown[]) => lookupMock(...args), - deleteHostAgent: (...args: unknown[]) => deleteHostAgentMock(...args), - }, -})); - -vi.mock('@/api/security', () => ({ - SecurityAPI: { - createToken: (...args: unknown[]) => createTokenMock(...args), - }, -})); - -vi.mock('@/stores/notifications', () => ({ - notificationStore: { - success: (...args: unknown[]) => notificationSuccessMock(...args), - error: (...args: unknown[]) => notificationErrorMock(...args), - info: (...args: unknown[]) => notificationInfoMock(...args), - }, -})); - -const createHost = (overrides?: Partial): Host => ({ - id: 'host-1', - hostname: 'host-1.local', - displayName: 'Host One', - platform: 'linux', - osName: 'Ubuntu', - osVersion: '22.04', - kernelVersion: '6.5.0', - architecture: 'x86_64', - cpuCount: 4, - cpuUsage: 12.5, - memory: { - total: 8 * 1024 * 1024 * 1024, - used: 4 * 1024 * 1024 * 1024, - free: 4 * 1024 * 1024 * 1024, - usage: 50, - balloon: 0, - swapUsed: 0, - swapTotal: 0, - }, - loadAverage: [], - disks: [], - networkInterfaces: [], - sensors: { - temperatureCelsius: {}, - fanRpm: {}, - additional: {}, - }, - status: 'online', - uptimeSeconds: 12_345, - intervalSeconds: 30, - lastSeen: Date.now(), - agentVersion: '1.2.3', - tags: ['prod'], - ...overrides, -}); - -const stubFetchSuccess = vi.fn(); - -const setupComponent = (hosts: Host[]) => { - const [state] = createStore({ - hosts, - connectionHealth: {}, - }); - - mockWsStore = { - state, - connected: () => true, - reconnecting: () => false, - activeAlerts: [], - }; - - return render(() => ); -}; - -beforeEach(() => { - lookupMock.mockReset(); - createTokenMock.mockReset(); - deleteHostAgentMock.mockReset(); - notificationSuccessMock.mockReset(); - notificationErrorMock.mockReset(); - notificationInfoMock.mockReset(); - stubFetchSuccess.mockImplementation( - async () => - new Response(JSON.stringify({ requiresAuth: true, apiTokenConfigured: false }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', stubFetchSuccess); - clipboardSpy.mockReset(); - vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe('HostAgents lookup flow', () => { - it('highlights a host after a successful lookup and clears highlight after timeout', async () => { - const host = createHost(); - setupComponent([host]); - - createTokenMock.mockResolvedValue({ - token: 'token-123', - record: { - id: 'token-record', - name: 'Test Token', - prefix: 'abcdef', - suffix: '1234', - createdAt: new Date().toISOString(), - }, - }); - const generateButton = screen.getByRole('button', { name: 'Generate token' }); - fireEvent.click(generateButton); - await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Check status' })).toBeEnabled(), { - interval: 0, - }); - - lookupMock.mockResolvedValue({ - success: true, - host: { - id: host.id, - hostname: host.hostname, - displayName: host.displayName, - status: host.status, - connected: true, - lastSeen: host.lastSeen, - agentVersion: host.agentVersion, - }, - }); - - const input = screen.getByPlaceholderText('Hostname or host ID') as HTMLInputElement; - fireEvent.input(input, { target: { value: host.id } }); - - const button = screen.getByRole('button', { name: 'Check status' }); - fireEvent.click(button); - - await waitFor(() => expect(lookupMock).toHaveBeenCalled(), { interval: 0 }); - const [lookupArgs] = lookupMock.mock.calls.at(-1) ?? []; - expect(lookupArgs).toEqual({ id: host.id, hostname: host.id }); - - await waitFor( - () => - expect( - screen.getByText('Connected', { selector: 'span' }), - ).toBeInTheDocument(), - { interval: 0 }, - ); - const statusBadges = screen.getAllByText('online', { selector: 'span' }); - expect(statusBadges.length).toBeGreaterThan(0); - expect(screen.getByText('Agent version 1.2.3')).toBeInTheDocument(); - }); - - it('shows an error when lookup returns no host and does not highlight rows', async () => { - const host = createHost(); - const { container } = setupComponent([host]); - - createTokenMock.mockResolvedValue({ - token: 'token-456', - record: { - id: 'token-record-2', - name: 'Test Token 2', - prefix: 'ghijkl', - suffix: '5678', - createdAt: new Date().toISOString(), - }, - }); - const generateButton = screen.getByRole('button', { name: 'Generate token' }); - fireEvent.click(generateButton); - await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Check status' })).toBeEnabled(), { - interval: 0, - }); - - lookupMock.mockResolvedValue(null); - - const query = 'missing-host'; - const input = screen.getByPlaceholderText('Hostname or host ID') as HTMLInputElement; - fireEvent.input(input, { target: { value: query } }); - - const button = screen.getByRole('button', { name: 'Check status' }); - fireEvent.click(button); - - await waitFor( - () => - expect( - screen.getByText(`No host has reported with "${query}" yet. Try again in a few seconds.`), - ).toBeInTheDocument(), - { interval: 0 }, - ); - - const row = container.querySelector(`tr[data-host-id="${host.id}"]`) as HTMLTableRowElement; - expect(row.classList.contains('ring-2')).toBe(false); - }); -}); - -describe('Host removal modal', () => { - it('removes a host while it is still reporting and explains the impact', async () => { - deleteHostAgentMock.mockResolvedValue(undefined); - const host = createHost({ - lastSeen: Date.now(), - status: 'online', - }); - - setupComponent([host]); - - const removeButton = screen.getByRole('button', { name: 'Remove' }); - fireEvent.click(removeButton); - - await screen.findByText('Remove host "Host One"'); - - const copyButton = screen.getByRole('button', { name: 'Copy command' }); - fireEvent.click(copyButton); - await waitFor(() => expect(clipboardSpy).toHaveBeenCalled(), { interval: 0 }); - - const confirmButton = await screen.findByRole('button', { name: 'I ran this command' }); - fireEvent.click(confirmButton); - - expect( - screen.getByText("Pulse revokes the host's API token", { - exact: false, - }), - ).toBeInTheDocument(); - - const removeHostButton = screen.getByRole('button', { name: 'Remove host' }); - expect(removeHostButton).toBeEnabled(); - fireEvent.click(removeHostButton); - - await waitFor(() => expect(deleteHostAgentMock).toHaveBeenCalledWith('host-1'), { interval: 0 }); - await waitFor(() => expect(notificationSuccessMock).toHaveBeenCalledWith('Host "Host One" removed', 4000), { - interval: 0, - }); - await waitFor(() => expect(screen.queryByText('Remove host "Host One"')).not.toBeInTheDocument(), { - interval: 0, - }); - }); - - it('removes a stale host without forcing', async () => { - deleteHostAgentMock.mockResolvedValue(undefined); - const host = createHost({ - lastSeen: Date.now() - 5 * 60_000, - status: 'offline', - }); - - setupComponent([host]); - - const removeButton = screen.getByRole('button', { name: 'Remove' }); - fireEvent.click(removeButton); - - await screen.findByText('Remove host "Host One"'); - - const copyButton = screen.getByRole('button', { name: 'Copy command' }); - fireEvent.click(copyButton); - await waitFor(() => expect(clipboardSpy).toHaveBeenCalled(), { interval: 0 }); - - const confirmButton = await screen.findByRole('button', { name: 'I ran this command' }); - fireEvent.click(confirmButton); - - const removeHostButton = screen.getByRole('button', { name: 'Remove host' }); - fireEvent.click(removeHostButton); - - await waitFor(() => expect(deleteHostAgentMock).toHaveBeenCalledWith('host-1'), { interval: 0 }); - await waitFor(() => expect(notificationSuccessMock).toHaveBeenCalledWith('Host "Host One" removed', 4000), { - interval: 0, - }); - await waitFor(() => expect(screen.queryByText('Remove host "Host One"')).not.toBeInTheDocument(), { - interval: 0, - }); - }); - - it('shows macOS-specific uninstall guidance', async () => { - const host = createHost({ - platform: 'macos', - }); - - setupComponent([host]); - - const removeButton = screen.getByRole('button', { name: 'Remove' }); - fireEvent.click(removeButton); - - await screen.findByText('Remove host "Host One"'); - expect(screen.getByText('launchctl unload', { exact: false })).toBeInTheDocument(); - expect(screen.getByText('Unloads the launch agent, removes the plist, deletes the binary, and clears the local log.')).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx new file mode 100644 index 0000000..539f331 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx @@ -0,0 +1,396 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, screen, waitFor, cleanup } from '@solidjs/testing-library'; +import { createStore } from 'solid-js/store'; +import { UnifiedAgents } from '../UnifiedAgents'; +import type { Host, DockerHost } from '@/types/api'; + +let mockWsStore: { + state: { hosts: Host[]; dockerHosts: DockerHost[] }; + connected: () => boolean; + reconnecting: () => boolean; + activeAlerts: unknown[]; +}; + +const lookupMock = vi.fn(); +const createTokenMock = vi.fn(); +const deleteHostAgentMock = vi.fn(); +const deleteDockerHostMock = vi.fn(); +const notificationSuccessMock = vi.fn(); +const notificationErrorMock = vi.fn(); +const notificationInfoMock = vi.fn(); +const clipboardSpy = vi.fn(); +const fetchMock = vi.fn(); + +vi.mock('@/App', () => ({ + useWebSocket: () => mockWsStore, +})); + +vi.mock('@/api/monitoring', () => ({ + MonitoringAPI: { + lookupHost: (...args: unknown[]) => lookupMock(...args), + deleteHostAgent: (...args: unknown[]) => deleteHostAgentMock(...args), + deleteDockerHost: (...args: unknown[]) => deleteDockerHostMock(...args), + }, +})); + +vi.mock('@/api/security', () => ({ + SecurityAPI: { + createToken: (...args: unknown[]) => createTokenMock(...args), + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { + success: (...args: unknown[]) => notificationSuccessMock(...args), + error: (...args: unknown[]) => notificationErrorMock(...args), + info: (...args: unknown[]) => notificationInfoMock(...args), + }, +})); + +const createHost = (overrides?: Partial): Host => ({ + id: 'host-1', + hostname: 'host-1.local', + displayName: 'Host One', + platform: 'linux', + osName: 'Ubuntu', + osVersion: '22.04', + kernelVersion: '6.5.0', + architecture: 'x86_64', + cpuCount: 4, + cpuUsage: 12.5, + memory: { + total: 8 * 1024 * 1024 * 1024, + used: 4 * 1024 * 1024 * 1024, + free: 4 * 1024 * 1024 * 1024, + usage: 50, + balloon: 0, + swapUsed: 0, + swapTotal: 0, + }, + loadAverage: [], + disks: [], + networkInterfaces: [], + sensors: { + temperatureCelsius: {}, + fanRpm: {}, + additional: {}, + }, + status: 'online', + uptimeSeconds: 12_345, + intervalSeconds: 30, + lastSeen: Date.now(), + agentVersion: '1.2.3', + tags: ['prod'], + ...overrides, +}); + +const createDockerHost = (overrides?: Partial): DockerHost => ({ + id: 'docker-host-1', + agentId: 'agent-1', + hostname: 'docker-host-1.local', + displayName: 'Docker Host One', + cpus: 4, + totalMemoryBytes: 8 * 1024 * 1024 * 1024, + uptimeSeconds: 12_345, + status: 'online', + lastSeen: Date.now(), + intervalSeconds: 30, + containers: [], + ...overrides, +}); + +const setupComponent = (hosts: Host[] = [], dockerHosts: DockerHost[] = []) => { + const [state] = createStore({ + hosts, + dockerHosts, + }); + + mockWsStore = { + state, + connected: () => true, + reconnecting: () => false, + activeAlerts: [], + }; + + return render(() => ); +}; + +beforeEach(() => { + lookupMock.mockReset(); + createTokenMock.mockReset(); + deleteHostAgentMock.mockReset(); + deleteDockerHostMock.mockReset(); + notificationSuccessMock.mockReset(); + notificationErrorMock.mockReset(); + notificationInfoMock.mockReset(); + clipboardSpy.mockReset().mockResolvedValue(undefined); + fetchMock.mockReset(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ requiresAuth: true, apiTokenConfigured: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('UnifiedAgents token generation', () => { + it('renders the token generation UI when auth is required', async () => { + setupComponent(); + + await waitFor(() => { + expect(screen.getByText('Generate API token')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /Generate token/i })).toBeInTheDocument(); + }); + + it('generates a token and shows confirmation', async () => { + createTokenMock.mockResolvedValue({ + token: 'test-token-123', + record: { + id: 'token-record', + name: 'Test Token', + prefix: 'abcdef', + suffix: '1234', + createdAt: new Date().toISOString(), + }, + }); + + setupComponent(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Generate token/i })).toBeInTheDocument(); + }); + + const generateButton = screen.getByRole('button', { name: /Generate token/i }); + fireEvent.click(generateButton); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + await waitFor( + () => expect(screen.getByText(/Token.*created/i)).toBeInTheDocument(), + { interval: 0 }, + ); + expect(notificationSuccessMock).toHaveBeenCalledWith( + 'Token generated with Host and Docker permissions.', + 4000, + ); + }); +}); + +describe('UnifiedAgents host lookup', () => { + it('performs host lookup and displays results', async () => { + createTokenMock.mockResolvedValue({ + token: 'token-123', + record: { + id: 'token-record', + name: 'Test Token', + prefix: 'abcdef', + suffix: '1234', + createdAt: new Date().toISOString(), + }, + }); + + const host = createHost(); + setupComponent([host]); + + // Generate token first to unlock commands + await waitFor(() => { + expect(screen.getByRole('button', { name: /Generate token/i })).toBeInTheDocument(); + }); + + const generateButton = screen.getByRole('button', { name: /Generate token/i }); + fireEvent.click(generateButton); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + + // Wait for commands to be unlocked and lookup UI to appear + await waitFor(() => { + expect(screen.getByPlaceholderText('Hostname or host ID')).toBeInTheDocument(); + }); + + lookupMock.mockResolvedValue({ + success: true, + host: { + id: host.id, + hostname: host.hostname, + displayName: host.displayName, + status: host.status, + connected: true, + lastSeen: host.lastSeen, + agentVersion: host.agentVersion, + }, + }); + + const input = screen.getByPlaceholderText('Hostname or host ID') as HTMLInputElement; + fireEvent.input(input, { target: { value: host.id } }); + + const checkButton = screen.getByRole('button', { name: /Check status/i }); + fireEvent.click(checkButton); + + await waitFor(() => expect(lookupMock).toHaveBeenCalled(), { interval: 0 }); + await waitFor( + () => expect(screen.getByText('Connected')).toBeInTheDocument(), + { interval: 0 }, + ); + }); + + it('shows error message when host is not found', async () => { + createTokenMock.mockResolvedValue({ + token: 'token-456', + record: { + id: 'token-record-2', + name: 'Test Token 2', + prefix: 'ghijkl', + suffix: '5678', + createdAt: new Date().toISOString(), + }, + }); + + setupComponent(); + + // Generate token first + await waitFor(() => { + expect(screen.getByRole('button', { name: /Generate token/i })).toBeInTheDocument(); + }); + + const generateButton = screen.getByRole('button', { name: /Generate token/i }); + fireEvent.click(generateButton); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Hostname or host ID')).toBeInTheDocument(); + }); + + lookupMock.mockResolvedValue(null); + + const query = 'missing-host'; + const input = screen.getByPlaceholderText('Hostname or host ID') as HTMLInputElement; + fireEvent.input(input, { target: { value: query } }); + + const checkButton = screen.getByRole('button', { name: /Check status/i }); + fireEvent.click(checkButton); + + await waitFor( + () => + expect( + screen.getByText(`No host has reported with "${query}" yet. Try again in a few seconds.`), + ).toBeInTheDocument(), + { interval: 0 }, + ); + }); +}); + +describe('UnifiedAgents managed agents table', () => { + it('displays host agents in the table', async () => { + const host = createHost({ hostname: 'test-server.local', displayName: 'Test Server' }); + setupComponent([host]); + + await waitFor(() => { + expect(screen.getByText('Managed Agents')).toBeInTheDocument(); + }); + + expect(screen.getByText('Test Server')).toBeInTheDocument(); + expect(screen.getByText('Host')).toBeInTheDocument(); + expect(screen.getByText('online')).toBeInTheDocument(); + }); + + it('displays docker hosts in the table', async () => { + const dockerHost = createDockerHost({ + hostname: 'docker-server.local', + displayName: 'Docker Server', + }); + setupComponent([], [dockerHost]); + + await waitFor(() => { + expect(screen.getByText('Managed Agents')).toBeInTheDocument(); + }); + + expect(screen.getByText('Docker Server')).toBeInTheDocument(); + expect(screen.getByText('Docker')).toBeInTheDocument(); + }); + + it('shows empty state when no agents are installed', async () => { + setupComponent(); + + await waitFor(() => { + expect(screen.getByText('No agents installed yet.')).toBeInTheDocument(); + }); + }); + + it('shows legacy agent warning when legacy agents are present', async () => { + const legacyHost = createHost({ isLegacy: true }); + setupComponent([legacyHost]); + + await waitFor(() => { + expect(screen.getByText(/legacy agent.*detected/i)).toBeInTheDocument(); + }); + expect(screen.getByText('Legacy')).toBeInTheDocument(); + }); +}); + +describe('UnifiedAgents platform commands', () => { + it('shows installation commands for all platforms after token generation', async () => { + createTokenMock.mockResolvedValue({ + token: 'test-token', + record: { + id: 'token-record', + name: 'Test Token', + prefix: 'abc', + suffix: '123', + createdAt: new Date().toISOString(), + }, + }); + + setupComponent(); + + const generateButton = screen.getByRole('button', { name: /Generate token/i }); + fireEvent.click(generateButton); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + + await waitFor(() => { + expect(screen.getByText('Install on Linux')).toBeInTheDocument(); + }); + expect(screen.getByText('Install on macOS')).toBeInTheDocument(); + expect(screen.getByText('Install on Windows')).toBeInTheDocument(); + }); + + it('includes docker flag when docker monitoring is enabled', async () => { + createTokenMock.mockResolvedValue({ + token: 'test-token', + record: { + id: 'token-record', + name: 'Test Token', + prefix: 'abc', + suffix: '123', + createdAt: new Date().toISOString(), + }, + }); + + setupComponent(); + + const generateButton = screen.getByRole('button', { name: /Generate token/i }); + fireEvent.click(generateButton); + + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + + await waitFor(() => { + expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument(); + }); + + // Docker monitoring is enabled by default + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + + // Find a copy button and check that the command includes the docker flag + const copyButtons = screen.getAllByRole('button', { name: /Copy command/i }); + expect(copyButtons.length).toBeGreaterThan(0); + }); +});