/** * Covers the Compose Doctor panel: the never-run empty state, the all-clear and * graded-findings summaries, the unrenderable banner, a load-failure retry * state, and running preflight on demand. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) })); import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import PreflightPanel from './PreflightPanel'; interface Finding { ruleId: string; severity: 'blocker' | 'high' | 'warning' | 'info'; title: string; message: string; sourcePath?: string; remediation?: string; service?: string; } interface Report { stack: string; ranAt: number | null; ranBy: string | null; renderable: boolean; renderError: string | null; status: string; highestSeverity: string | null; findings: Finding[]; } function report(partial: Partial): Report { return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial }; } function jsonRes(body: unknown, ok = true) { return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response; } beforeEach(() => { vi.clearAllMocks(); }); describe('PreflightPanel', () => { it('shows the never-run empty state', async () => { vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'never-run', ranAt: null }))); render(); expect(await screen.findByText(/Run preflight to render the effective model/i)).toBeInTheDocument(); }); it('renders the all-clear summary when there are no findings', async () => { vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'pass' }))); render(); const status = await screen.findByTestId('preflight-status'); expect(status).toHaveAttribute('data-status', 'pass'); expect(status).toHaveTextContent(/all clear/i); }); it('groups findings and reflects the highest severity', async () => { vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'high', highestSeverity: 'high', findings: [ { ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' }, { ruleId: 'image-latest', severity: 'warning', title: 'Image uses a moving tag', message: 'latest tag', service: 'web' }, ], }))); render(); const status = await screen.findByTestId('preflight-status'); expect(status).toHaveAttribute('data-status', 'high'); expect(screen.getByText('Privileged container')).toBeInTheDocument(); expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument(); }); it('surfaces the unrenderable state with the render error', async () => { vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ renderable: false, status: 'unrenderable', highestSeverity: 'blocker', renderError: 'Sencho could not render the effective Compose model.', findings: [{ ruleId: 'render-failed', severity: 'blocker', title: 'Compose model could not be rendered', message: 'Sencho could not render the effective Compose model.' }], }))); render(); const status = await screen.findByTestId('preflight-status'); expect(status).toHaveAttribute('data-status', 'unrenderable'); expect(status).toHaveTextContent(/cannot render/i); }); it('shows a retry state and toasts when the load fails', async () => { vi.mocked(apiFetch).mockResolvedValue(jsonRes(null, false)); render(); expect(await screen.findByText(/Could not load the preflight report/i)).toBeInTheDocument(); expect(toast.error).toHaveBeenCalled(); }); it('runs preflight on demand and shows the new findings', async () => { vi.mocked(apiFetch) .mockResolvedValueOnce(jsonRes(report({ status: 'never-run', ranAt: null }))) .mockResolvedValueOnce(jsonRes(report({ status: 'blocker', highestSeverity: 'blocker', findings: [{ ruleId: 'port-conflict-node', severity: 'blocker', title: 'Host port 8080 is already in use', message: 'taken', service: 'web' }], }))); render(); fireEvent.click(await screen.findByTestId('preflight-run-btn')); expect(await screen.findByText('Host port 8080 is already in use')).toBeInTheDocument(); await waitFor(() => { const calls = vi.mocked(apiFetch).mock.calls; expect(calls.some(([url, opts]) => String(url).includes('/preflight/run') && (opts as RequestInit | undefined)?.method === 'POST')).toBe(true); }); }); });