feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DashboardPage, { formatShareTime } from './DashboardPage';
|
||||
import { mockAgent, mockShare } from '../test/fixtures';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/3D/FleetTopologyMap', () => ({
|
||||
default: () => <div data-testid="fleet-topology-map" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/MatrixStreamOverlay', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('formatShareTime', () => {
|
||||
it('formats ISO timestamps as locale time strings', () => {
|
||||
const iso = '2026-05-30T12:00:00.000Z';
|
||||
expect(formatShareTime(iso)).toBe(new Date(iso).toLocaleTimeString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
useWebSocketMock.mockReturnValue(wsValue());
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue({
|
||||
port: 8080,
|
||||
data_dir: '',
|
||||
pool: {} as never,
|
||||
wallet: {} as never,
|
||||
server: { dashboard_subtitle: 'custom subtitle from server' },
|
||||
alerts: {} as never,
|
||||
});
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getPoolStatus').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getAIActivity').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getXmrPrice').mockResolvedValue({ usd: 165.5, updated_at: '' });
|
||||
vi.spyOn(api, 'getEarningsEstimate').mockResolvedValue({
|
||||
xmr_per_day: 0.01,
|
||||
usd_per_day: 1.65,
|
||||
network_hashrate: 1e9,
|
||||
});
|
||||
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
|
||||
success: true,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
action: 'restart',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders hero heading and key section titles', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Command Deck' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Pipeline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share Activity Pulse')).toBeInTheDocument();
|
||||
expect(screen.getByText('Machine Roster')).toBeInTheDocument();
|
||||
expect(screen.getByText('PERSONAL NETWORK · LIVE TELEMETRY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reconnecting label when websocket is down', () => {
|
||||
useWebSocketMock.mockReturnValue(wsValue({ isConnected: false }));
|
||||
renderDashboard();
|
||||
expect(screen.getByText('RECONNECTING')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows signal locked when websocket is connected', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByText('SIGNAL LOCKED')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 nodes registered')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads dashboard subtitle from config API', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('custom subtitle from server')).toBeInTheDocument();
|
||||
expect(api.getConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows empty roster state when no agents', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat labels and top agent card', async () => {
|
||||
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
|
||||
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
|
||||
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
|
||||
expect(within(roster).getByText('online')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles advanced mode and reveals share log section', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([mockShare()]);
|
||||
renderDashboard();
|
||||
expect(screen.queryByText('Share Log')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '[ADVANCED]' }));
|
||||
expect(screen.getByText('Share Log')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('aether-dash-advanced')).toBe('1');
|
||||
});
|
||||
|
||||
it('shows share log rows in advanced mode with stable keys', async () => {
|
||||
const share = mockShare({ id: undefined as unknown as number, hash: 'deadbeef' });
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([share]);
|
||||
localStorage.setItem('aether-dash-advanced', '1');
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [mockAgent()] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Share Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accepted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alerts when bulk command API fails', async () => {
|
||||
const agent = mockAgent();
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('network down'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderDashboard();
|
||||
const roster = await screen.findByText('Machine Roster');
|
||||
const section = roster.closest('section') as HTMLElement;
|
||||
await waitFor(() => expect(within(section).getByText(agent.name)).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
const grid = section.querySelector('.agent-grid') as HTMLElement;
|
||||
await user.click(within(grid).getByRole('checkbox'));
|
||||
const bulkBar = section.querySelector('.fleet-bulk-bar') as HTMLElement;
|
||||
await user.click(within(bulkBar).getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('network down');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers live websocket alerts over REST fallback', async () => {
|
||||
useWebSocketMock.mockReturnValue(
|
||||
wsValue({
|
||||
fleetAlerts: [{ id: '1', level: 'warn', type: 'pool_down', message: 'live alert', timestamp: '' }],
|
||||
})
|
||||
);
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([
|
||||
{ id: '2', level: 'warn', type: 'stale', message: 'rest alert', timestamp: '' },
|
||||
]);
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('live alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('rest alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user