fix: replace stale agent tests with UnifiedAgents tests
DockerAgents.tsx and HostAgents.tsx were consolidated into UnifiedAgents.tsx, but the old test files remained and referenced non-existent components, breaking CI. - Remove DockerAgents.test.tsx and HostAgents.test.tsx - Add UnifiedAgents.test.tsx covering token generation, host lookup, managed agents table, and platform-specific install commands
This commit is contained in:
parent
e467523a61
commit
c110a3b803
3 changed files with 396 additions and 534 deletions
|
|
@ -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>): 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>): 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(() => <DockerAgents />);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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<string, boolean> };
|
|
||||||
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>): 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(() => <HostAgents />);
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>): 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>): 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(() => <UnifiedAgents />);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue