import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedbackContext'; import { DeployFeedbackModal } from '../DeployFeedbackModal'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/lib/serviceUpdate', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, requestServiceRestore: vi.fn(), }; }); import { apiFetch } from '@/lib/api'; import { requestServiceRestore } from '@/lib/serviceUpdate'; // Lets a test simulate a mid-stream drop (onReady then onError) so the panel // reaches 'streaming' with progressUnavailable set. const ctl = vi.hoisted(() => ({ drop: false, lastNodeId: undefined as number | null | undefined })); // The real Terminal mounts xterm + a WebSocket; mock it to a no-op that signals // the stream connected on mount so the panel reaches the 'streaming' state, and // records the captured nodeId it was mounted with. vi.mock('@/components/Terminal', () => { const MockTerminal = ({ onReady, onError, nodeId, deploySessionId, }: { onReady?: () => void; onError?: () => void; nodeId?: number | null; deploySessionId?: string | null; }) => { ctl.lastNodeId = nodeId; // Re-fire on each deploy session so a Restore (second runWithLog) can // release its progress-stream gate the same way the first update does. React.useEffect(() => { onReady?.(); if (ctl.drop) onError?.(); }, [onReady, onError, deploySessionId]); return null; }; return { default: MockTerminal }; }); // Resolver for the in-flight operation, assigned inside the run callback (async, // after render) so the test can leave it pending or settle it on demand. let resolveRun: ((r: { ok: boolean; errorMessage?: string; healthGateId?: string | null; recoveryId?: string | null; }) => void) | null = null; // The runWithLog promise itself, so a test can await full result propagation. let runOuter: Promise | null = null; // Node the driver captures for the operation; default local, overridden per test. let driverNodeId: number | null = null; let driverServiceName: string | undefined; function Driver() { const { runWithLog } = useDeployFeedback(); React.useEffect(() => { runOuter = runWithLog( { stackName: 'web', action: 'update', nodeId: driverNodeId, serviceName: driverServiceName }, async (started) => { await started; return new Promise((res) => { resolveRun = res; }); }, ); }, [runWithLog]); return null; } async function renderStreaming() { await act(async () => { render( {}} /> , ); // The mocked Terminal calls onReady on mount; flush the 50ms handshake. await vi.advanceTimersByTimeAsync(60); }); } type GateStatus = 'observing' | 'passed' | 'failed' | 'unknown'; function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?: string | null; serviceName?: string | null; targetScope?: 'stack' | 'service'; failureSource?: 'primary' | 'collateral' | null; }>) { let call = 0; vi.mocked(apiFetch).mockImplementation((url: string) => { if (!String(url).includes('/health-gate')) { return Promise.resolve(new Response('{}', { status: 200 })); } const r = responses[Math.min(call, responses.length - 1)]; call += 1; return Promise.resolve(new Response(JSON.stringify({ stack: 'web', id: r.id, status: r.status, trigger: 'update', reason: r.reason ?? null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [], targetScope: r.targetScope ?? 'stack', serviceName: r.serviceName ?? null, failureSource: r.failureSource ?? null, }), { status: 200 })); }); } describe('DeployFeedbackModal health gate', () => { beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); resolveRun = null; ctl.drop = false; ctl.lastNodeId = undefined; driverNodeId = null; driverServiceName = undefined; vi.mocked(apiFetch).mockReset(); vi.mocked(apiFetch).mockResolvedValue(new Response('{}', { status: 200 })); vi.mocked(requestServiceRestore).mockReset(); }); afterEach(() => { vi.useRealTimers(); }); async function succeedWithGate(gateId: string | null) { await renderStreaming(); // The Terminal onReady effect flushes at the end of renderStreaming's act, // scheduling the 50ms handshake timer after that act's advance already // ran; fire it here so the run reaches its resolver. await act(async () => { await vi.advanceTimersByTimeAsync(60); }); expect(resolveRun).not.toBeNull(); await act(async () => { resolveRun?.({ ok: true, healthGateId: gateId }); await runOuter; }); } it('binds the modal progress terminal to the captured panel node', async () => { driverNodeId = 5; await renderStreaming(); expect(ctl.lastNodeId).toBe(5); }); it('shows the observing banner and suspends auto-close while the gate observes', async () => { routeGateApi([{ id: 'gate-1', status: 'observing' }]); await succeedWithGate('gate-1'); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing'); expect(screen.queryByText(/closes in/)).toBeNull(); // The verdict is withheld while observing: no green Succeeded yet. expect(screen.queryByText('Succeeded')).toBeNull(); expect(screen.getByText('Verifying health')).toBeInTheDocument(); // Far past the normal 4s auto-close: the modal must still be open. await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument(); }); it('resumes the auto-close countdown once the gate passes', async () => { routeGateApi([{ id: 'gate-1', status: 'observing' }, { id: 'gate-1', status: 'passed' }]); await succeedWithGate('gate-1'); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing'); // The next 4s poll returns passed; the countdown then runs to auto-close. await act(async () => { await vi.advanceTimersByTimeAsync(4_100); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'passed'); expect(screen.getByText(/closes in/)).toBeInTheDocument(); await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); // onPanelClose resets the panel (Radix may keep the dialog DOM mounted // briefly under fake timers, so assert on the reset, not the unmount). expect(screen.queryByText('Succeeded')).toBeNull(); expect(screen.queryByTestId('health-gate-banner')).toBeNull(); }); it('keeps the modal open and shows the reason when the gate fails', async () => { routeGateApi([{ id: 'gate-1', status: 'failed', reason: 'container web-app-1 exited during observation' }]); await succeedWithGate('gate-1'); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'failed'); expect(screen.getByText(/exited during observation/)).toBeInTheDocument(); // The headline indicator reports the gate verdict, never a green Succeeded. expect(screen.queryByText('Succeeded')).toBeNull(); expect(screen.getAllByText(/Health gate failed/).length).toBeGreaterThan(0); await act(async () => { await vi.advanceTimersByTimeAsync(20_000); }); expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument(); }); it('names the service in the banner for a service-scoped gate and notes a collateral failure', async () => { routeGateApi([{ id: 'gate-1', status: 'failed', reason: 'service web has no running replicas to observe', serviceName: 'web', targetScope: 'service', failureSource: 'collateral', }]); await succeedWithGate('gate-1'); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'failed'); expect(screen.getByText(/A dependent service triggered the failure/)).toBeInTheDocument(); }); it('offers Restore for a failed service gate and calls requestServiceRestore with the recovery id', async () => { driverServiceName = 'api'; vi.mocked(requestServiceRestore).mockResolvedValue({ ok: true, mode: 'update', serviceName: 'api', healthGateId: null, observing: false, recoveryId: 'rec-1', recoveryAvailable: false, }); routeGateApi([{ id: 'gate-1', status: 'failed', reason: 'service api reported unhealthy', serviceName: 'api', targetScope: 'service', failureSource: 'primary', }]); await renderStreaming(); await act(async () => { await vi.advanceTimersByTimeAsync(60); }); await act(async () => { resolveRun?.({ ok: true, healthGateId: 'gate-1', recoveryId: 'rec-1' }); await runOuter; }); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); const restoreBtn = screen.getByTestId('service-restore-from-gate'); expect(restoreBtn).toBeInTheDocument(); await act(async () => { restoreBtn.click(); }); // Restore starts a fresh runWithLog; Terminal remounts for the new // deploySessionId and releases the gate after the 50ms handshake. await act(async () => { await vi.advanceTimersByTimeAsync(60); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(requestServiceRestore).toHaveBeenCalledWith(expect.objectContaining({ stackName: 'web', serviceName: 'api', recoveryId: 'rec-1', })); }); it('gives up with an unknown verdict after repeated poll failures', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { if (String(url).includes('/health-gate')) { return Promise.resolve(new Response('{"error":"boom"}', { status: 500 })); } return Promise.resolve(new Response('{}', { status: 200 })); }); await succeedWithGate('gate-1'); // Four strikes at the 4s poll cadence flip the gate to a client-side // unknown and stop the interval (gateHoldsOpen keeps the modal up). await act(async () => { await vi.advanceTimersByTimeAsync(17_000); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'unknown'); expect(screen.getByText(/could not be retrieved/)).toBeInTheDocument(); const callsAfterGiveUp = vi.mocked(apiFetch).mock.calls.length; await act(async () => { await vi.advanceTimersByTimeAsync(20_000); }); expect(vi.mocked(apiFetch).mock.calls.length).toBe(callsAfterGiveUp); }); it('ignores a report for a different gate id', async () => { routeGateApi([{ id: 'some-other-gate', status: 'failed', reason: 'stale' }]); await succeedWithGate('gate-1'); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); // The mismatched report never replaces the optimistic observing state. expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing'); }); it('polls single-flight and latches the terminal verdict against a late response', async () => { // Hold each health-gate response open so we can release it deliberately and // count how many requests overlap. const release: Array<(body: object) => void> = []; let healthGateCalls = 0; vi.mocked(apiFetch).mockImplementation((url: string) => { if (!String(url).includes('/health-gate')) { return Promise.resolve(new Response('{}', { status: 200 })); } healthGateCalls += 1; return new Promise((resolve) => { release.push((body) => resolve(new Response(JSON.stringify(body), { status: 200 }))); }); }); await succeedWithGate('gate-1'); // The first poll is still pending; advancing several intervals must not // start a second one (single-flight), so no two responses can race. await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); expect(healthGateCalls).toBe(1); // Release the first poll as a terminal passed; the latch stops the interval. await act(async () => { release[0]({ stack: 'web', id: 'gate-1', status: 'passed', trigger: 'update', reason: null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [], }); }); expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'passed'); // No further polls after a terminal verdict: a stale observing response can // never arrive to roll the UI back. await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); expect(healthGateCalls).toBe(1); }); it('does not poll the gate or show the panel when deploy feedback is disabled', async () => { // Turning off the deploy progress panel opts out of the live gate UI: there // is no surface to render it on. The backend gate still runs and records // timeline events; only the in-browser verifying/recovery view is skipped. localStorage.setItem('sencho.deploy-feedback.enabled', 'false'); await act(async () => { render( {}} /> , ); }); await act(async () => { expect(resolveRun).not.toBeNull(); resolveRun?.({ ok: true, healthGateId: 'gate-1' }); await runOuter; }); expect(screen.queryByTestId('deploy-feedback-modal')).toBeNull(); expect(screen.queryByTestId('health-gate-banner')).toBeNull(); // No poll is ever issued for the gate even though the backend returned an id. await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); expect(apiFetch).not.toHaveBeenCalled(); }); it('renders no gate banner and auto-closes normally without a healthGateId', async () => { await succeedWithGate(null); await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(screen.getByText('Succeeded')).toBeInTheDocument(); expect(screen.getByText(/closes in/)).toBeInTheDocument(); expect(screen.queryByTestId('health-gate-banner')).toBeNull(); expect(apiFetch).not.toHaveBeenCalled(); await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); // onPanelClose resets the panel (Radix may keep the dialog DOM mounted // briefly under fake timers, so assert on the reset, not the unmount). expect(screen.queryByText('Succeeded')).toBeNull(); }); }); describe('DeployFeedbackModal stalled-output warning', () => { beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); resolveRun = null; ctl.drop = false; }); afterEach(() => { vi.useRealTimers(); }); it('warns after the stall threshold when streaming produces no output', async () => { await renderStreaming(); // Well under the threshold: no warning yet. await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull(); // Past the threshold with zero output: the warning appears. await act(async () => { await vi.advanceTimersByTimeAsync(80_000); }); expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument(); expect(screen.getByText(/No output received yet/)).toBeInTheDocument(); }); it('clears the stall warning once the operation fails', async () => { await renderStreaming(); await act(async () => { await vi.advanceTimersByTimeAsync(80_000); }); expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument(); // The operation finishes as a failure; status leaves 'streaming' so the // stall warning must clear rather than sit next to the failed state. await act(async () => { resolveRun?.({ ok: false, errorMessage: 'boom' }); await Promise.resolve(); }); expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull(); }); it('suppresses the stall warning when the progress stream is unavailable', async () => { ctl.drop = true; // the stream connects then immediately drops mid-operation await renderStreaming(); // Past the threshold, but with the stream gone the warning would be noise. await act(async () => { await vi.advanceTimersByTimeAsync(80_000); }); expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull(); }); });