Files
sencho/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx
T
Anso b06dfd7175 fix: prevent false empty states during stack hydration (#1659)
* fix: prevent false empty states during stack hydration

Only show confirmed-empty UI after successful stack, status, and container
fetches. Distinguish loading and recoverable error states in the sidebar,
dashboard, and container health panel.

* fix: arbitrate overlapping stack status and container fetches

Prevent older dashboard status and same-owner container responses from overwriting newer load state after concurrent poll, invalidation, retry, or lifecycle refresh.

* fix: do not let soft status polls starve slow foreground loads

Skip soft /stacks/statuses poll and invalidation while a statuses request is already in flight so a deferred foreground hydration can still commit after the ten-second cadence.

* fix(stacks): surface recoverable errors for confirmed-empty soft failures

Sidebar and dashboard soft (background) refresh failures after a
confirmed-empty state silently kept showing the empty/adopt prompt
instead of a recoverable error, since only the error message was set
without flipping the load status. Also reject malformed non-array
/stacks responses instead of coercing them into a confirmed-empty list,
and drop malformed per-stack status entries before they reach the
dashboard table, which previously crashed the entire app on a null
entry.

* fix(stacks): close two review-found gaps in the load-failure fix

A non-empty stack-statuses map where every entry failed validation was
still committed as a confirmed-empty success; it now surfaces as a
recoverable error instead, and dropped entries are logged. The sidebar's
background-failure helper also checked a stale closure snapshot of the
file list, which could wipe a list that had just loaded non-empty in the
same attempt if the follow-up statuses fetch then failed; it now tracks
the freshest committed list for that decision. Also collapses two refs
tracking dashboard status-map emptiness into one.
2026-07-22 08:01:59 -04:00

386 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
// Stable mock for the node context: ID `1` is the active node so the hook can
// resolve a nodeId on mount without rendering the full provider tree.
const useNodesMock = vi.fn();
vi.mock('@/context/NodeContext', () => ({
useNodes: () => useNodesMock(),
}));
// Statuses soft-poll uses visibilityInterval (setInterval). Use a real timer so
// tests can advance past the 10s cadence; skip document.visibility wiring.
vi.mock('@/lib/utils', async () => {
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
return {
...actual,
visibilityInterval: (fn: () => void, ms: number) => {
const id = setInterval(fn, ms);
return () => clearInterval(id);
},
};
});
import { useDashboardData } from '../useDashboardData';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function fireInvalidate(detail: { scope?: string; action?: string } = {}) {
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
}
const STATS_PAYLOAD = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
const SYS_PAYLOAD = {
cpu: { usage: '0', cores: 4 },
memory: { total: 0, used: 0, free: 0, usagePercent: '0' },
disk: null,
};
beforeEach(() => {
vi.useFakeTimers();
apiFetchMock.mockReset();
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
return Promise.resolve(okJson(null));
});
useNodesMock.mockReset();
useNodesMock.mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
nodes: [{ id: 1, name: 'Local', type: 'local' }],
});
});
afterEach(() => {
vi.useRealTimers();
});
function countFetchCalls(endpoint: string): number {
return apiFetchMock.mock.calls.filter((call) => call[0] === endpoint).length;
}
describe('useDashboardData state-invalidate handling', () => {
it('debounces a burst of state-invalidate events into a single refetch', async () => {
renderHook(() => useDashboardData());
// Drain mount-time polls.
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
const baselineStats = countFetchCalls('/stats');
const baselineSys = countFetchCalls('/system/stats');
const baselineStatuses = countFetchCalls('/stacks/statuses');
act(() => {
// Burst: 10 container events in rapid succession.
for (let i = 0; i < 10; i += 1) fireInvalidate({ scope: 'container' });
});
// Before debounce window elapses, no new fetches.
expect(countFetchCalls('/stats') - baselineStats).toBe(0);
expect(countFetchCalls('/system/stats') - baselineSys).toBe(0);
expect(countFetchCalls('/stacks/statuses') - baselineStatuses).toBe(0);
await act(async () => { vi.advanceTimersByTime(300); });
// After debounce: exactly one refetch per endpoint, regardless of burst size.
expect(countFetchCalls('/stats') - baselineStats).toBe(1);
expect(countFetchCalls('/system/stats') - baselineSys).toBe(1);
expect(countFetchCalls('/stacks/statuses') - baselineStatuses).toBe(1);
});
it('cleans up the debounce timer on unmount so no late refetch fires', async () => {
const { unmount } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
apiFetchMock.mockClear();
act(() => { fireInvalidate({ scope: 'container' }); });
unmount();
await act(async () => { vi.advanceTimersByTime(500); });
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
describe('useDashboardData stackStatuses load states', () => {
it('reaches success with an empty map without treating deferral as empty UI state', async () => {
let resolveStatuses: ((r: Response) => void) | null = null;
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolveStatuses = resolve; });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('loading');
await act(async () => {
resolveStatuses?.(okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
});
it('surfaces error on failed statuses fetch and recovers on retry', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(new Response('nope', { status: 500 }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
return Promise.resolve(okJson(null));
});
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
});
it('ignores an older soft success after a newer foreground retry', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const softMap = { 'old.yml': { status: 'exited' as const } };
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[1](okJson(softMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual({});
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('ignores an older soft failure after a newer foreground retry success', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual(retryMap);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('lets a slow foreground statuses response commit after soft poll and invalidate ticks', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
expect(result.current.stackStatusesLoadStatus).toBe('loading');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
const settled = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[0](okJson(settled));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(settled);
});
it('turns a soft poll failure into a recoverable error when the prior success was empty', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
// Foreground load settles on confirmed-empty.
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
// A subsequent soft poll fails: this must not stay a silent empty state.
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('error');
});
it('drops a malformed per-stack entry instead of crashing, keeping the rest of a valid response', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({
'web.yml': { status: 'running' },
'broken.yml': null,
'also-broken.yml': 'running',
}));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({ 'web.yml': { status: 'running' } });
});
it('treats a non-empty response where every entry is malformed as an error, not confirmed-empty', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({ 'a.yml': null, 'b.yml': 'running' }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
expect(result.current.stackStatuses).toEqual({});
});
});