Files
sencho/frontend/src/components/stack/PreflightPanel.test.tsx
T
Anso b5810a9b55 feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501)
A batch of UI/UX polish:

- New independent "Reduced motion" appearance setting (separate from Reduced
  effects). Drives framer-motion via MotionConfig and clamps CSS transitions via
  data-motion on <html>; toasts are unaffected. Defaults off (OS preference
  still honored).
- Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted
  word-wrap toggle to the file viewer (default on), and add a fullscreen toggle
  that collapses the Command Center + Logs column so the editor fills the width.
- Create Stack > From Git: remove the nested scroll clamp so the deploy toggle
  and footer are reachable.
- Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only
  Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state
  headings (calm drops the italic), and fix the Actions card body overlapping
  the action-row divider.
- Snapshots: restyle Restore and Restore all to the ghost button design used by
  View / Preview / Download, and right-align the per-stack Restore.
- Settings sidebar: App Store gradient active style and standard font size.
- Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the
  findings change, via a shared fingerprint-keyed hook.
- Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet
  Snapshots tab (FleetView tabs are now controlled to support the deep link).
2026-06-28 06:18:02 -04:00

126 lines
5.7 KiB
TypeScript

/**
* 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>): 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(); localStorage.clear(); });
describe('PreflightPanel', () => {
it('shows the never-run empty state', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'never-run', ranAt: null })));
render(<PreflightPanel stackName="web" />);
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(<PreflightPanel stackName="web" />);
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(<PreflightPanel stackName="web" />);
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('dismisses only the result banner, keeping the finding rows', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'high', highestSeverity: 'high',
findings: [{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' }],
})));
render(<PreflightPanel stackName="web" />);
expect(await screen.findByTestId('preflight-status')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('preflight-dismiss-btn'));
expect(screen.queryByTestId('preflight-status')).not.toBeInTheDocument();
// Only the summary banner is dismissed; the finding row remains.
expect(screen.getByText('Privileged container')).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(<PreflightPanel stackName="web" />);
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(<PreflightPanel stackName="web" />);
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(<PreflightPanel stackName="web" />);
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);
});
});
});