feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge
This commit is contained in:
55
server/web/src/context/ForgeContext.test.tsx
Normal file
55
server/web/src/context/ForgeContext.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ForgeProvider, useForge } from './ForgeContext';
|
||||
|
||||
describe('ForgeContext', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ForgeProvider>{children}</ForgeProvider>
|
||||
);
|
||||
|
||||
it('starts with idle forge state', () => {
|
||||
const { result } = renderHook(() => useForge(), { wrapper });
|
||||
expect(result.current.forging).toBe(false);
|
||||
expect(result.current.stage).toBe('');
|
||||
expect(result.current.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('startForge sets initializing state', () => {
|
||||
const { result } = renderHook(() => useForge(), { wrapper });
|
||||
|
||||
act(() => result.current.startForge());
|
||||
|
||||
expect(result.current.forging).toBe(true);
|
||||
expect(result.current.stage).toBe('Initializing forge...');
|
||||
expect(result.current.progress).toBe(0);
|
||||
});
|
||||
|
||||
it('setStage updates stage and progress while forging', () => {
|
||||
const { result } = renderHook(() => useForge(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.startForge();
|
||||
result.current.setStage('Compiling', 42);
|
||||
});
|
||||
|
||||
expect(result.current.stage).toBe('Compiling');
|
||||
expect(result.current.progress).toBe(42);
|
||||
});
|
||||
|
||||
it('endForge resets state', () => {
|
||||
const { result } = renderHook(() => useForge(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.startForge();
|
||||
result.current.setStage('Done', 100);
|
||||
result.current.endForge();
|
||||
});
|
||||
|
||||
expect(result.current.forging).toBe(false);
|
||||
expect(result.current.stage).toBe('');
|
||||
expect(result.current.progress).toBe(0);
|
||||
});
|
||||
});
|
||||
43
server/web/src/context/WebSocketContext.test.tsx
Normal file
43
server/web/src/context/WebSocketContext.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { WebSocketContext, useWebSocketContext } from './WebSocketContext';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
describe('WebSocketContext', () => {
|
||||
it('useWebSocketContext returns default value outside provider', () => {
|
||||
const { result } = renderHook(() => useWebSocketContext());
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.agents).toEqual([]);
|
||||
expect(result.current.commandResults).toEqual([]);
|
||||
expect(result.current.latestMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('useWebSocketContext reads provider value', () => {
|
||||
const value = {
|
||||
isConnected: true,
|
||||
agents: [mockAgent()],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: { 'agent-001-uuid': 'log line' },
|
||||
commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
|
||||
latestMessage: null,
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
expect(result.current.agents).toHaveLength(1);
|
||||
expect(result.current.agentLogs['agent-001-uuid']).toBe('log line');
|
||||
expect(result.current.commandResults[0]._seq).toBe(1);
|
||||
});
|
||||
});
|
||||
180
server/web/src/context/WebSocketProvider.test.tsx
Normal file
180
server/web/src/context/WebSocketProvider.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { WebSocketProvider } from './WebSocketProvider';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { setStoredAuth, clearStoredAuth } from '../api/auth';
|
||||
import { mockAgent, mockShare } from '../test/fixtures';
|
||||
|
||||
type WSListener = ((event: { data: string }) => void) | null;
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onmessage: WSListener = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
emitMessage(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
emitOpen() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
}
|
||||
|
||||
describe('WebSocketProvider', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:8080' },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function latestSocket() {
|
||||
return MockWebSocket.instances.at(-1)!;
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <WebSocketProvider>{children}</WebSocketProvider>;
|
||||
}
|
||||
|
||||
it('connects to ws dashboard with auth token query param', () => {
|
||||
setStoredAuth('drjones', 'secret');
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
const ws = latestSocket();
|
||||
const token = btoa('drjones:secret');
|
||||
expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
|
||||
|
||||
act(() => ws.emitOpen());
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('connects without token when logged out', () => {
|
||||
clearStoredAuth();
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
|
||||
});
|
||||
|
||||
it('useWebSocket re-exports context hook', () => {
|
||||
expect(useWebSocket).toBe(useWebSocketContext);
|
||||
});
|
||||
|
||||
it('handles init and agent_online messages', () => {
|
||||
const agent = mockAgent({ id: 'live-1' });
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
latestSocket().emitMessage({
|
||||
type: 'init',
|
||||
payload: { agents: [agent] },
|
||||
});
|
||||
});
|
||||
expect(result.current.agents).toEqual([agent]);
|
||||
|
||||
const updated = { ...agent, hashrate_15s: 999 };
|
||||
act(() =>
|
||||
latestSocket().emitMessage({
|
||||
type: 'agent_online',
|
||||
payload: updated,
|
||||
})
|
||||
);
|
||||
expect(result.current.agents[0].hashrate_15s).toBe(999);
|
||||
});
|
||||
|
||||
it('marks agent offline and caps recent shares', () => {
|
||||
const agent = mockAgent({ id: 'a-offline' });
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
|
||||
latestSocket().emitMessage({
|
||||
type: 'agent_offline',
|
||||
payload: { agent_id: 'a-offline' },
|
||||
});
|
||||
});
|
||||
expect(result.current.agents[0].status).toBe('offline');
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 55; i++) {
|
||||
latestSocket().emitMessage({
|
||||
type: 'new_share',
|
||||
payload: mockShare({ id: i }),
|
||||
});
|
||||
}
|
||||
});
|
||||
expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('assigns monotonic _seq on command_result', () => {
|
||||
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
latestSocket().emitOpen();
|
||||
latestSocket().emitMessage({
|
||||
type: 'command_result',
|
||||
payload: { agent_id: 'a1', action: 'pause', success: true },
|
||||
});
|
||||
latestSocket().emitMessage({
|
||||
type: 'command_result',
|
||||
payload: { agent_id: 'a1', action: 'get_log', success: true, message: 'log data' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.commandResults).toHaveLength(2);
|
||||
expect(result.current.commandResults[0]._seq).toBe(1);
|
||||
expect(result.current.commandResults[1]._seq).toBe(2);
|
||||
expect(result.current.agentLogs.a1).toBe('log data');
|
||||
});
|
||||
|
||||
it('schedules reconnect after close', () => {
|
||||
vi.useFakeTimers();
|
||||
renderHook(() => useWebSocketContext(), { wrapper });
|
||||
const first = latestSocket();
|
||||
|
||||
act(() => first.close());
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('closes socket on unmount', () => {
|
||||
const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
|
||||
const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>);
|
||||
unmount();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
|
||||
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
|
||||
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
|
||||
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
|
||||
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
|
||||
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
|
||||
|
||||
Reference in New Issue
Block a user