mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
553 lines
24 KiB
TypeScript
553 lines
24 KiB
TypeScript
/**
|
|
* Coverage for ResourcesView hardening.
|
|
*
|
|
* Locks correctness fixes that manual smoke testing cannot reliably catch:
|
|
* - M-1: a slow resource fetch for a previously-active node must not overwrite
|
|
* the newly-selected node's data (node-switch generation guard).
|
|
* - M-2: a failed prune must surface the server error, never a false success.
|
|
* - Reclaim banner: "Review & prune" reclaims every advertised category and a
|
|
* partial failure reports a warning, never a false success; dismiss snoozes
|
|
* the banner until the reclaimable total grows past the dismissed snapshot.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
|
|
|
vi.mock('@/components/ui/toast-store', () => ({
|
|
toast: {
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
warning: vi.fn(),
|
|
info: vi.fn(),
|
|
loading: vi.fn(() => 'toast-id'),
|
|
dismiss: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const licenseState = { isPaid: true };
|
|
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState }));
|
|
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) }));
|
|
|
|
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
|
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
|
|
|
|
vi.mock('@/hooks/useTrivyStatus', () => ({
|
|
useTrivyStatus: () => ({
|
|
status: { available: false, version: null, source: 'none', autoUpdate: false, busy: false },
|
|
updateCheck: null,
|
|
refresh: vi.fn(),
|
|
refreshUpdateCheck: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Heavy or portal-bound children are not under test; stub them to keep the
|
|
// render tree light and deterministic.
|
|
vi.mock('../VulnerabilityScanSheet', () => ({ VulnerabilityScanSheet: () => null }));
|
|
// Testable stub: surfaces visibility (testid) and the two callbacks so the
|
|
// snooze and reclaim-all flows can be driven without the real banner styling.
|
|
// Mirrors the real component's bytes<=0 guard.
|
|
vi.mock('../resources/ReclaimHero', () => ({
|
|
ReclaimHero: ({ bytes, onReview, onDismiss }: { bytes: number; onReview: () => void; onDismiss: () => void }) =>
|
|
bytes <= 0 ? null : (
|
|
<div data-testid="reclaim-hero">
|
|
<button onClick={onReview}>Review & prune</button>
|
|
<button onClick={onDismiss}>Dismiss hero</button>
|
|
</div>
|
|
),
|
|
}));
|
|
vi.mock('../resources/FootprintTreemap', () => ({ FootprintTreemap: () => null }));
|
|
vi.mock('../resources/ImageDetailsSheet', () => ({ ImageDetailsSheet: () => null }));
|
|
vi.mock('../resources/VolumeBrowserSheet', () => ({ VolumeBrowserSheet: () => null }));
|
|
vi.mock('../NetworkTopologyView', () => ({ default: () => null }));
|
|
vi.mock('../CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
|
|
vi.mock('../LazyBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
|
|
vi.mock('../NodeManager', () => ({ SENCHO_NAVIGATE_EVENT: 'sencho-navigate' }));
|
|
|
|
import { apiFetch } from '@/lib/api';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import ResourcesView from '../ResourcesView';
|
|
|
|
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
|
|
return {
|
|
ok: init.ok ?? true,
|
|
status: init.status ?? 200,
|
|
json: async () => body,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
function image(repoTag: string) {
|
|
return {
|
|
Id: `sha256:${repoTag}`,
|
|
RepoTags: [repoTag],
|
|
Size: 1000,
|
|
Containers: 0,
|
|
usedByStacks: [],
|
|
managedBy: null,
|
|
managedStatus: 'unmanaged' as const,
|
|
isSencho: false,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedFetch.mockReset();
|
|
licenseState.isPaid = true;
|
|
nodesState.activeNode = { id: 1 };
|
|
localStorage.clear();
|
|
});
|
|
|
|
// Reclaimable usage shape with a non-zero total so the banner is shown.
|
|
function reclaimableUsage(images: number, volumes: number) {
|
|
return {
|
|
reclaimableImages: images,
|
|
reclaimableContainers: 0,
|
|
reclaimableVolumes: volumes,
|
|
reclaimableImageCount: images > 0 ? 1 : 0,
|
|
reclaimableContainerCount: 0,
|
|
reclaimableVolumeCount: volumes > 0 ? 1 : 0,
|
|
managedImageBytes: 0,
|
|
unmanagedImageBytes: 0,
|
|
managedVolumeBytes: 0,
|
|
unmanagedVolumeBytes: 0,
|
|
};
|
|
}
|
|
|
|
|
|
function samplePrunePlan(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
scope: 'managed',
|
|
targets: ['images'],
|
|
items: [{
|
|
target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000,
|
|
managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] },
|
|
}],
|
|
reclaimableBytes: 1000,
|
|
fingerprint: 'fp-test',
|
|
createdAt: Date.now(),
|
|
nodeId: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function reclaimPlan() {
|
|
return samplePrunePlan({
|
|
scope: 'all',
|
|
targets: ['volumes', 'containers', 'images'],
|
|
items: [
|
|
{
|
|
target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500,
|
|
managed: true, reason: 'Volume is not referenced by any container', volume: {},
|
|
},
|
|
{
|
|
target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000,
|
|
managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] },
|
|
},
|
|
],
|
|
reclaimableBytes: 1500,
|
|
fingerprint: 'fp-reclaim',
|
|
});
|
|
}
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe('ResourcesView', () => {
|
|
it('drops a stale node fetch so it cannot overwrite the newly selected node (M-1)', async () => {
|
|
// Hold every /system/resources response open so we control resolution order.
|
|
const resourcesResolvers: Array<(r: Response) => void> = [];
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/resources') {
|
|
return new Promise<Response>((resolve) => resourcesResolvers.push(resolve));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const { rerender } = render(<ResourcesView />);
|
|
await waitFor(() => expect(resourcesResolvers).toHaveLength(1));
|
|
|
|
// Switch nodes before the first fetch resolves; the effect re-runs and
|
|
// claims a newer generation.
|
|
nodesState.activeNode = { id: 2 };
|
|
rerender(<ResourcesView />);
|
|
await waitFor(() => expect(resourcesResolvers).toHaveLength(2));
|
|
|
|
// Resolve the newer (node 2) fetch first; its data should render.
|
|
resourcesResolvers[1](jsonResponse({ images: [image('node2-img:latest')], volumes: [], networks: [] }));
|
|
expect(await screen.findByText('node2-img:latest')).toBeInTheDocument();
|
|
|
|
// Now resolve the stale (node 1) fetch. Its generation is old, so the guard
|
|
// must drop it rather than stomp node 2's resources.
|
|
resourcesResolvers[0](jsonResponse({ images: [image('node1-img:latest')], volumes: [], networks: [] }));
|
|
await waitFor(() => {
|
|
expect(screen.getByText('node2-img:latest')).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText('node1-img:latest')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not surface a load failure that resolves after the view unmounts', async () => {
|
|
let rejectResources: ((e: unknown) => void) | undefined;
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/resources') {
|
|
return new Promise<Response>((_resolve, reject) => { rejectResources = reject; });
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const { unmount } = render(<ResourcesView />);
|
|
await waitFor(() => expect(rejectResources).toBeDefined());
|
|
|
|
unmount();
|
|
rejectResources!(new Error('network down'));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fetches a prune plan before enabling confirm, then sends the fingerprint', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(samplePrunePlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ success: true, reclaimedBytes: 1000, outcomes: [] }));
|
|
}
|
|
if (url === '/system/resources') {
|
|
return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources'));
|
|
|
|
await user.click(screen.getByRole('button', { name: /Prune Unused Images/ }));
|
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(
|
|
'/system/prune/plan',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
));
|
|
expect(await screen.findByText(/old:v1/)).toBeInTheDocument();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /Prune/ }));
|
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
|
const pruneCall = mockedFetch.mock.calls.find(
|
|
([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST',
|
|
);
|
|
expect(pruneCall).toBeTruthy();
|
|
const body = JSON.parse(String((pruneCall![1] as RequestInit).body));
|
|
expect(body.planFingerprint).toBe('fp-test');
|
|
});
|
|
|
|
it('discloses extra repository tags in the prune confirm list', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(samplePrunePlan({
|
|
items: [{
|
|
target: 'images',
|
|
id: 'img-multi',
|
|
name: 'ghcr.io/example/very-long-app-name:1.5.2-build.1844',
|
|
sizeBytes: 1000,
|
|
managed: false,
|
|
reason: 'Image is not used by any container',
|
|
image: {
|
|
references: [
|
|
'ghcr.io/example/very-long-app-name:1.5.2-build.1844',
|
|
'ghcr.io/example/very-long-app-name:latest',
|
|
],
|
|
},
|
|
}],
|
|
})));
|
|
}
|
|
if (url === '/system/resources') {
|
|
return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources'));
|
|
await user.click(screen.getByRole('button', { name: /Prune Unused Images/ }));
|
|
|
|
const extraRef = await screen.findByText('ghcr.io/example/very-long-app-name:latest');
|
|
expect(extraRef).toHaveClass('break-all');
|
|
expect(extraRef).not.toHaveClass('truncate');
|
|
expect(screen.getByText(/1\.5\.2-build\.1844/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('surfaces the server error on a failed prune instead of a false success (M-2)', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(samplePrunePlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ error: 'Prune blew up' }, { ok: false, status: 500 }));
|
|
}
|
|
if (url === '/system/resources') {
|
|
return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources'));
|
|
|
|
await user.click(screen.getByRole('button', { name: /Prune Unused Images/ }));
|
|
await waitFor(() => expect(screen.getByText(/old:v1/)).toBeInTheDocument());
|
|
await user.click(await screen.findByRole('button', { name: /Prune/ }));
|
|
|
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Prune blew up'));
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports partial failure from "Review & prune" without a false success', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(reclaimPlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 }));
|
|
}
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
|
|
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
|
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
|
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
|
|
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
const pruned = mockedFetch.mock.calls
|
|
.filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST');
|
|
expect(pruned).toHaveLength(1);
|
|
const body = JSON.parse(String((pruned[0][1] as RequestInit).body));
|
|
expect(body.targets).toEqual(['volumes', 'containers', 'images']);
|
|
expect(body.planFingerprint).toBe('fp-reclaim');
|
|
});
|
|
|
|
it('reports an error when every prune fails, with no success or warning', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(reclaimPlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ error: 'daemon down' }, { ok: false, status: 500 }));
|
|
}
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
|
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
|
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
|
|
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('daemon down'));
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
expect(toast.warning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('omits the reclaimed figure on full success when the daemon reports zero bytes', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(reclaimPlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ reclaimedBytes: 0, outcomes: [] }));
|
|
}
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
|
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
|
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
|
|
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
|
const msg = (toast.success as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
expect(msg).not.toMatch(/Freed/);
|
|
expect(toast.warning).not.toHaveBeenCalled();
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the reclaimed figure on full success when the daemon reports bytes', async () => {
|
|
mockedFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
if (url === '/system/prune/plan' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse(reclaimPlan()));
|
|
}
|
|
if (url === '/system/prune/system' && opts?.method === 'POST') {
|
|
return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576, outcomes: [] }));
|
|
}
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
render(<ResourcesView />);
|
|
await user.click(await screen.findByRole('button', { name: /Review & prune/ }));
|
|
await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument());
|
|
await user.click(await screen.findByRole('button', { name: /^Reclaim/ }));
|
|
|
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
|
const msg = (toast.success as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
expect(msg).toMatch(/Freed/);
|
|
});
|
|
|
|
it('snoozes the banner on dismiss and brings it back when more space is reclaimable', async () => {
|
|
let usage = reclaimableUsage(1000, 500); // 1500 B total
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(usage));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<ResourcesView />);
|
|
await screen.findByTestId('reclaim-hero');
|
|
|
|
// Dismiss snapshots the current total; the banner hides.
|
|
await user.click(screen.getByRole('button', { name: /Dismiss hero/ }));
|
|
await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument());
|
|
|
|
// A larger reclaimable total on the same node pushes past the snapshot, so
|
|
// the banner returns. Same node id keeps the snapshot; the new activeNode
|
|
// object reference triggers a refetch.
|
|
usage = reclaimableUsage(8000, 2000); // 10000 B total
|
|
nodesState.activeNode = { id: 1 };
|
|
rerender(<ResourcesView />);
|
|
await screen.findByTestId('reclaim-hero');
|
|
});
|
|
|
|
it('keeps the banner hidden after dismiss when the reclaimable total does not grow', async () => {
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '1' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<ResourcesView />);
|
|
await screen.findByTestId('reclaim-hero');
|
|
|
|
await user.click(screen.getByRole('button', { name: /Dismiss hero/ }));
|
|
await waitFor(() => expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument());
|
|
|
|
// A stable residue (the same total on the same node) must stay dismissed
|
|
// across a refetch, not re-nag. Force a refetch via a new activeNode ref.
|
|
const dfCalls = () => mockedFetch.mock.calls.filter(([u]) => u === '/system/docker-df').length;
|
|
const before = dfCalls();
|
|
nodesState.activeNode = { id: 1 };
|
|
rerender(<ResourcesView />);
|
|
await waitFor(() => expect(dfCalls()).toBeGreaterThan(before));
|
|
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('hides the banner on a node switch when /settings fails, instead of inheriting the previous node opt-in', async () => {
|
|
// Both responses key off the active node, so the switch cannot desync them:
|
|
// node A opts in and serves its own image, node B fails /settings.
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
const isNodeA = nodesState.activeNode?.id === 1;
|
|
if (url === '/settings') {
|
|
return isNodeA
|
|
? Promise.resolve(jsonResponse({ reclaim_hero: '1' }))
|
|
: Promise.resolve(jsonResponse({}, { ok: false, status: 500 }));
|
|
}
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') {
|
|
return Promise.resolve(jsonResponse({
|
|
images: [image(isNodeA ? 'node-a-img:latest' : 'node-b-img:latest')],
|
|
volumes: [],
|
|
networks: [],
|
|
}));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
// Node A has the banner turned on; once loaded it is visible.
|
|
const { rerender } = render(<ResourcesView />);
|
|
await screen.findByText('node-a-img:latest');
|
|
await screen.findByTestId('reclaim-hero');
|
|
|
|
// Switch to node B with a failing /settings: the banner must hide (fail
|
|
// closed), not carry over node A's enabled state.
|
|
nodesState.activeNode = { id: 2 };
|
|
rerender(<ResourcesView />);
|
|
await screen.findByText('node-b-img:latest');
|
|
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the banner hidden when the setting is explicitly off, even with reclaimable space', async () => {
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500)));
|
|
if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [image('off-img:latest')], volumes: [], networks: [] }));
|
|
if (url === '/settings') return Promise.resolve(jsonResponse({ reclaim_hero: '0' }));
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
// Wait on rendered data, not the request: /settings lands in the same batch,
|
|
// so a visible image proves the setting was applied before this assertion.
|
|
render(<ResourcesView />);
|
|
await screen.findByText('off-img:latest');
|
|
expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('badges a rollback-protected image without changing its managed/unused status', async () => {
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/resources') {
|
|
return Promise.resolve(jsonResponse({
|
|
images: [{ ...image('nginx:1.25'), managedStatus: 'unused', rollbackProtected: true, rollbackProtectionKind: 'stack' }],
|
|
volumes: [],
|
|
networks: [],
|
|
}));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
render(<ResourcesView />);
|
|
await screen.findByText('nginx:1.25');
|
|
expect(screen.getByText('Rollback protected')).toBeInTheDocument();
|
|
expect(screen.getByText('Unused')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows rollback generations in the Rollback tab, admin-gated release button included', async () => {
|
|
mockedFetch.mockImplementation((url: string) => {
|
|
if (url === '/system/rollback/generations') {
|
|
return Promise.resolve(jsonResponse([
|
|
{ id: 'gen-1', shortId: 'abc123456789', stackName: 'seerr', status: 'active', isCurrent: true, phase: 'immediate_verified', createdAt: Date.now(), artifactExpiresAt: null, releasable: true },
|
|
]));
|
|
}
|
|
return Promise.resolve(jsonResponse({}));
|
|
});
|
|
|
|
render(<ResourcesView />);
|
|
await userEvent.click(await screen.findByRole('tab', { name: /rollback/i }));
|
|
|
|
expect(await screen.findByText('seerr')).toBeInTheDocument();
|
|
expect(screen.getByText('abc123456789')).toBeInTheDocument();
|
|
// State badge and the Current filter pill both render this label.
|
|
expect(screen.getAllByText('Current').length).toBeGreaterThanOrEqual(2);
|
|
expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeInTheDocument();
|
|
});
|
|
});
|