Files
sencho/frontend/src/components/StackAnatomyPanel.doctor.test.tsx
T
Anso 9ea2864d60 feat: per-stack storage inventory and portability guardrails (#1399)
* feat: per-stack storage inventory and portability guardrails

Add a Storage tab to the stack Anatomy panel that derives a per-stack mount
inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket;
read-only vs read-write; host-path existence, type, and owner) from the
effective Compose model, and classifies the stack as Portable, Partially
portable, Node-bound, or Unknown with the reasons behind it.

- New GET /api/stacks/:stackName/storage route (stack:read, Community), served
  by an on-demand, non-persisted service that renders the effective model,
  probes within-stack bind sources (symlink-escape aware), and runs the
  deterministic portability classifier.
- Extend the effective-model parser additively with a full per-mount inventory
  and service-level tmpfs, leaving the rule-facing binds/namedVolumes
  byte-identical for the existing preflight rules.
- New anonymous-volume preflight finding.
- Admin-visible "no recent snapshot" warning that reuses the existing hub-local
  snapshot-coverage endpoint, plus a static note distinguishing config
  snapshots from application-data backups.
- Surface storage assumptions in the Stack Dossier markdown export.
- Gate the tab behind a new compose-storage capability on both sides.

* docs: phrase the Storage tab availability as current behavior

Replace the "older Sencho version / until it is updated" wording in the
Storage feature page with present-tense, capability-based phrasing.
2026-06-20 15:06:26 -04:00

86 lines
3.1 KiB
TypeScript

/**
* Covers the capability-gated Doctor tab and its severity dot in
* StackAnatomyPanel when the active node advertises compose-doctor. The
* capability-off case (tab hidden, no badge fetch) is covered in
* StackAnatomyPanel.test.tsx.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('./stack/StackActivityTimeline', () => ({ StackActivityTimeline: () => <div /> }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => true }) }));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
let badgeSeverity: string | null = 'blocker';
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 404, json: async () => body, text: async () => '' } as unknown as Response;
}
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/preflight')) {
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
}
return jsonRes(null, false); // git-source, update-preview, scan-status
});
});
function panel() {
return (
<StackAnatomyPanel
stackName="web"
content={'services:\n web:\n image: nginx:1.25\n'}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit
/>
);
}
describe('StackAnatomyPanel Doctor tab (capability on)', () => {
it('renders the Doctor tab with a destructive dot for a blocker result', async () => {
badgeSeverity = 'blocker';
render(panel());
expect(await screen.findByTestId('doctor-tab')).toBeInTheDocument();
const dot = await screen.findByTestId('doctor-tab-dot');
expect(dot.className).toContain('bg-destructive');
});
it('uses the warning color for a high-risk result', async () => {
badgeSeverity = 'high';
render(panel());
const dot = await screen.findByTestId('doctor-tab-dot');
expect(dot.className).toContain('bg-warning');
});
it('shows no dot for a warning-only result', async () => {
badgeSeverity = 'warning';
render(panel());
await screen.findByTestId('doctor-tab');
await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/preflight'))).toBe(true));
expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument();
});
it('renders the Networking tab when the capability is present', async () => {
badgeSeverity = 'warning';
render(panel());
expect(await screen.findByTestId('networking-tab')).toBeInTheDocument();
});
it('renders the Storage tab when the capability is present', async () => {
badgeSeverity = 'warning';
render(panel());
expect(await screen.findByTestId('storage-tab')).toBeInTheDocument();
});
});