Files
sencho/frontend/src/components/__tests__/DeployFeedbackModal.test.tsx
T
Anso 63213c0960 feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
2026-07-19 02:42:29 -04:00

387 lines
17 KiB
TypeScript

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<typeof import('@/lib/serviceUpdate')>();
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<unknown> | 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(
<DeployFeedbackProvider>
<Driver />
<DeployFeedbackModal isMinimized={false} onMinimize={() => {}} />
</DeployFeedbackProvider>,
);
// 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<Response>((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(
<DeployFeedbackProvider>
<Driver />
<DeployFeedbackModal isMinimized={false} onMinimize={() => {}} />
</DeployFeedbackProvider>,
);
});
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();
});
});