/** * Setup file for frontend tests with Vitest + jsdom. * * This file: * - Configures the DOM environment * - Mocks fetch API * - Mocks WebSocket * - Mocks localStorage * - Provides utility functions for tests */ import { vi, beforeEach, afterEach } from 'vitest'; // ============================================================================ // MOCK: localStorage // ============================================================================ const localStorageMock = (() => { let store = {}; return { getItem: vi.fn((key) => store[key] || null), setItem: vi.fn((key, value) => { store[key] = String(value); }), removeItem: vi.fn((key) => { delete store[key]; }), clear: vi.fn(() => { store = {}; }), get length() { return Object.keys(store).length; }, key: vi.fn((i) => Object.keys(store)[i] || null), }; })(); Object.defineProperty(global, 'localStorage', { value: localStorageMock, writable: true, }); // ============================================================================ // MOCK: fetch API // ============================================================================ /** * Create a mock fetch response. * @param {object} data - Response data * @param {number} status - HTTP status code * @returns {Response} Mock Response object */ export function createMockResponse(data, status = 200) { return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : 'Error', json: vi.fn().mockResolvedValue(data), text: vi.fn().mockResolvedValue(JSON.stringify(data)), headers: new Headers({ 'Content-Type': 'application/json' }), }; } /** * Setup fetch mock with default responses. * @param {object} responses - Map of URL patterns to responses */ export function setupFetchMock(responses = {}) { const defaultResponses = { '/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } }, '/api/hosts': [], '/api/tasks': [], '/api/schedules': [], '/api/playbooks': [], '/api/health': { status: 'healthy' }, ...responses, }; global.fetch = vi.fn((url, options = {}) => { const urlPath = new URL(url, 'http://localhost').pathname; for (const [pattern, response] of Object.entries(defaultResponses)) { if (urlPath.includes(pattern) || urlPath === pattern) { return Promise.resolve(createMockResponse(response)); } } // Default 404 for unmatched URLs return Promise.resolve(createMockResponse({ detail: 'Not found' }, 404)); }); return global.fetch; } // ============================================================================ // MOCK: WebSocket // ============================================================================ export class MockWebSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; constructor(url) { this.url = url; this.readyState = MockWebSocket.CONNECTING; this.onopen = null; this.onclose = null; this.onmessage = null; this.onerror = null; this._messageQueue = []; // Auto-connect after a tick setTimeout(() => { this.readyState = MockWebSocket.OPEN; if (this.onopen) { this.onopen({ type: 'open' }); } }, 0); } send(data) { if (this.readyState !== MockWebSocket.OPEN) { throw new Error('WebSocket is not open'); } this._messageQueue.push(data); } close(code = 1000, reason = '') { this.readyState = MockWebSocket.CLOSED; if (this.onclose) { this.onclose({ type: 'close', code, reason }); } } // Test helper: simulate receiving a message _receiveMessage(data) { if (this.onmessage) { this.onmessage({ type: 'message', data: typeof data === 'string' ? data : JSON.stringify(data), }); } } // Test helper: simulate an error _triggerError(error) { if (this.onerror) { this.onerror({ type: 'error', error }); } } } global.WebSocket = MockWebSocket; // ============================================================================ // DOM UTILITIES // ============================================================================ /** * Create a minimal DOM structure for testing. */ export function setupMinimalDOM() { document.body.innerHTML = `