/** * Tests for services/socket.js * * Verifies the page-load subscribe/connect race fixes: * - subscribeToResearch with a mid-connect socket does NOT call * fallbackToPolling and does NOT emit (the connect handler will). * - The 'connect' event clears any leftover polling intervals. * - subscribeToResearch uses the canonical 'subscribe_to_research' * event name, not the legacy 'join'. */ let socketModule; // Mock socket factory that lets tests fire connect/disconnect manually. function createMockSocket() { const handlers = {}; return { connected: false, emit: vi.fn(), on: vi.fn((event, cb) => { handlers[event] ||= []; handlers[event].push(cb); }), off: vi.fn(), // Test helper — simulate an event from the server. _fire(event, ...args) { (handlers[event] || []).forEach((cb) => cb(...args)); }, }; } let mockSocket; beforeAll(async () => { // The socket module checks window.location.pathname for a research page. Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, pathname: '/progress/abc-123', protocol: 'http:', host: 'localhost' }, }); mockSocket = createMockSocket(); globalThis.io = vi.fn(() => mockSocket); // Stub the API + URLBuilder helpers used by polling fallback. window.api = { getResearchStatus: vi.fn(() => Promise.resolve({ status: 'in_progress' })), getCsrfToken: () => '', }; window.ResearchStates = { isTerminal: () => false, logLevel: () => 'info' }; await import('@js/services/socket.js'); socketModule = window.socket; }); beforeEach(() => { // Reset polling state and the mock socket for each test. window.pollIntervals = {}; mockSocket.emit.mockClear(); mockSocket.on.mockClear(); mockSocket.off.mockClear(); mockSocket.connected = false; }); describe('subscribeToResearch — page-load race', () => { it('does not fall back to polling when socket exists but is mid-connect', () => { // Simulate the page-load state: io() has been called (so socket // exists) but the websocket handshake hasn't completed yet. socketModule.subscribeToResearch('research-1', () => {}); // No emit should have happened — the connect handler will subscribe. expect(mockSocket.emit).not.toHaveBeenCalled(); // Polling should not have been kicked off either. expect(window.pollIntervals['research-1']).toBeUndefined(); }); it('emits subscribe_to_research (not join) when socket is connected', () => { mockSocket.connected = true; socketModule.subscribeToResearch('research-2', () => {}); // Should use the canonical event name that the server handles directly. const emittedEvents = mockSocket.emit.mock.calls.map((c) => c[0]); expect(emittedEvents).toContain('subscribe_to_research'); expect(emittedEvents).not.toContain('join'); }); it('clears stale polling intervals when the socket connects', () => { // Simulate a leftover polling interval from a fallback path. const intervalId = setInterval(() => {}, 9999); window.pollIntervals = { 'research-3': intervalId }; // Manually fire 'connect' on the mock socket. mockSocket.connected = true; mockSocket._fire('connect'); // The interval should have been cleared and the entry removed. expect(window.pollIntervals).toEqual({}); }); it('re-subscribes to the deferred research id once connect fires', () => { // Subscribe while the socket is mid-connect — must NOT emit yet. socketModule.subscribeToResearch('research-deferred', () => {}); expect(mockSocket.emit).not.toHaveBeenCalled(); // The websocket completes the handshake and the server fires connect. mockSocket.connected = true; mockSocket._fire('connect'); // Exactly one subscribe_to_research must have been emitted, with // the deferred id — the page-load race fix depends on this // follow-through. A regression that drops currentResearchId before // the connect handler runs would silently break the progress page. const subscribeCalls = mockSocket.emit.mock.calls.filter( (c) => c[0] === 'subscribe_to_research' ); expect(subscribeCalls.length).toBe(1); expect(subscribeCalls[0][1]).toEqual({ research_id: 'research-deferred' }); }); }); describe('unsubscribeFromResearch', () => { it('emits unsubscribe_from_research (not legacy leave)', () => { mockSocket.connected = true; // First subscribe so there's something to leave. socketModule.subscribeToResearch('research-4', () => {}); mockSocket.emit.mockClear(); socketModule.unsubscribeFromResearch('research-4'); const emittedEvents = mockSocket.emit.mock.calls.map((c) => c[0]); expect(emittedEvents).toContain('unsubscribe_from_research'); expect(emittedEvents).not.toContain('leave'); }); }); describe('addLogEntry — delegation routing (window._socketAddLogEntry)', () => { // The IIFE-private addLogEntry is reachable from outside only via the // exported window._socketAddLogEntry. The function delegates in three // tiers: (1) if window._socketAddLogEntry was replaced by something // OTHER than itself (logpanel.js does this in production), call that; // (2) else if window.addConsoleLog exists, call it with adapted args; // (3) else fall back to inline DOM template work — NOT tested here // (would mostly assert CSS class names we'd type in the test setup). let originalAddLogEntry; let originalAddConsoleLog; beforeAll(() => { // Capture the original (which IS the function we want to invoke) // BEFORE any test reassigns window._socketAddLogEntry. originalAddLogEntry = window._socketAddLogEntry; }); beforeEach(() => { originalAddConsoleLog = window.addConsoleLog; }); afterEach(() => { // Restore both globals so the next test starts clean. window._socketAddLogEntry = originalAddLogEntry; if (originalAddConsoleLog === undefined) { delete window.addConsoleLog; } else { window.addConsoleLog = originalAddConsoleLog; } }); it('delegates to a replaced window._socketAddLogEntry (logpanel override)', () => { const spy = vi.fn(); window._socketAddLogEntry = spy; originalAddLogEntry({ message: 'hi', type: 'info' }); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith({ message: 'hi', type: 'info' }); }); it('falls back to window.addConsoleLog when _socketAddLogEntry was not overridden', () => { const consoleSpy = vi.fn(); window.addConsoleLog = consoleSpy; // _socketAddLogEntry intentionally NOT overridden — it === originalAddLogEntry, // so the first branch is skipped. originalAddLogEntry({ message: 'm', type: 'warning', metadata: { foo: 'bar' } }); expect(consoleSpy).toHaveBeenCalledTimes(1); expect(consoleSpy).toHaveBeenCalledWith('m', 'warning', { foo: 'bar' }); }); it('derives logLevel from metadata.type when top-level type is missing', () => { const consoleSpy = vi.fn(); window.addConsoleLog = consoleSpy; originalAddLogEntry({ message: 'm', metadata: { type: 'error' } }); expect(consoleSpy).toHaveBeenCalledWith('m', 'error', { type: 'error' }); }); it('defaults logLevel to "info" when neither type nor metadata.type is present', () => { const consoleSpy = vi.fn(); window.addConsoleLog = consoleSpy; originalAddLogEntry({ message: 'm' }); expect(consoleSpy).toHaveBeenCalledWith('m', 'info', undefined); }); });