// @vitest-environment happy-dom import { render, screen, waitFor, act } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Run } from '../screens/run.js'; import { useRunSocket } from '../lib/ws.js'; function RunWithSocket(props: { onNavigateAccounts?: () => void }) { const socket = useRunSocket(); return ; } // ── WebSocket mock ──────────────────────────────────────────────────────────── type MessageHandler = (event: { data: string }) => void; let wsInstance: MockWs | null = null; class MockWs { readyState = WebSocket.OPEN; onmessage: MessageHandler | null = null; sent: unknown[] = []; send(data: string) { this.sent.push(JSON.parse(data)); } close() {} /** Push a server message into the component */ push(msg: object) { this.onmessage?.({ data: JSON.stringify(msg) }); } } beforeEach(() => { wsInstance = new MockWs(); const MockWebSocketCtor = function MockWebSocketCtor() { return wsInstance; }; MockWebSocketCtor.OPEN = 1; MockWebSocketCtor.CONNECTING = 0; MockWebSocketCtor.CLOSING = 2; MockWebSocketCtor.CLOSED = 3; vi.stubGlobal('WebSocket', MockWebSocketCtor); // Stub fetch → empty sources list vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => [] }) as Response), ); }); afterEach(() => { wsInstance = null; vi.unstubAllGlobals(); }); function ws(): MockWs { if (!wsInstance) throw new Error('no ws'); return wsInstance; } // ── Helpers ─────────────────────────────────────────────────────────────────── function push(msg: object) { act(() => ws().push(msg)); } // ── Tests ───────────────────────────────────────────────────────────────────── describe('Run screen', () => { it('"Run" button fires run-start message', async () => { // provide one source so the button is not disabled due to no selection vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => [{ id: 'src-1', type: 'poalim', userCode: 'u', password: 'p' }], }) as Response), ); render(); await waitFor(() => expect(screen.queryByRole('button', { name: /^run$/i })).toBeTruthy()); await userEvent.click(screen.getByRole('button', { name: /^run$/i })); expect(ws().sent).toContainEqual( expect.objectContaining({ type: 'run-start', sourceIds: ['src-1'] }), ); }); it('task-pending → task-running → TaskRow shows correct states', async () => { render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); push({ type: 'task-pending', sourceId: 'src-1' }); await waitFor(() => expect(screen.getAllByText(/pending/i).length).toBeGreaterThan(0)); push({ type: 'task-running', sourceId: 'src-1' }); // The badge text contains "Running…" (with ellipsis), distinct from the button "Running…" await waitFor(() => expect(screen.getAllByText(/running/i).length).toBeGreaterThan(0)); }); it('task-done → TaskRow shows green with counts', async () => { render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); push({ type: 'task-pending', sourceId: 'src-1' }); push({ type: 'task-done', sourceId: 'src-1', inserted: 5, skipped: 2, insertedIds: [] }); await waitFor(() => { expect(screen.getByText(/done/i)).toBeTruthy(); expect(screen.getByText(/5 new/i)).toBeTruthy(); expect(screen.getByText(/2 skipped/i)).toBeTruthy(); }); }); it('task-error → TaskRow shows red with message', async () => { render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); push({ type: 'task-pending', sourceId: 'src-1' }); push({ type: 'task-error', sourceId: 'src-1', message: 'Login failed' }); await waitFor(() => { expect(screen.getByText(/error/i)).toBeTruthy(); }); // Expand details to see message await userEvent.click(screen.getByRole('button', { name: /details/i })); await waitFor(() => expect(screen.getByText(/Login failed/)).toBeTruthy()); }); it('task-blocked → TaskRow shows yellow with account IDs', async () => { render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); push({ type: 'task-blocked', sourceId: 'src-1', sourceType: 'poalim', unknownAccounts: ['ACC-001', 'ACC-002'], }); await waitFor(() => { expect(screen.getByText(/blocked/i)).toBeTruthy(); expect(screen.getByText(/ACC-001/)).toBeTruthy(); expect(screen.getByText(/ACC-002/)).toBeTruthy(); }); }); it('otp-required → OtpModal appears; submit closes modal and sends otp-submit', async () => { render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); push({ type: 'task-pending', sourceId: 'src-1' }); push({ type: 'otp-required', sourceId: 'src-1' }); await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy()); await userEvent.type(screen.getByLabelText(/otp code/i), '123456'); await userEvent.click(screen.getByRole('button', { name: /submit otp/i })); expect(ws().sent).toContainEqual({ type: 'otp-submit', sourceId: 'src-1', otp: '123456' }); }); it('run-complete → summary panel renders; Run button re-enabled', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => [{ id: 'src-1', type: 'poalim', userCode: 'u', password: 'p' }], }) as Response), ); render(); await waitFor(() => screen.getByRole('button', { name: /^run$/i })); // Simulate a running state then completion push({ type: 'task-pending', sourceId: 'src-1' }); push({ type: 'task-running', sourceId: 'src-1' }); push({ type: 'task-done', sourceId: 'src-1', inserted: 3, skipped: 1, insertedIds: [] }); push({ type: 'run-complete', totalInserted: 3, totalSkipped: 1, errors: 0 }); await waitFor(() => { expect(screen.getByRole('region', { name: /run summary/i })).toBeTruthy(); }); // Run button should be enabled again const runBtn = screen.getByRole('button', { name: /^run$/i }); expect((runBtn as HTMLButtonElement).disabled).toBe(false); }); });