// @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);
});
});