mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip (#1178)
* refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip Replace the simple notification ticker with a derived activity summary that picks one of six states (active-op, failure, automation, recent-event, quiet-live, disconnected) and routes per-state clicks to logs, schedules, or activity. The hook owns the cascade; the component is pure presentation; EditorLayout owns wiring. Failure detection covers unread errors in the last 24h; recent-event is limited to non-error stack notifications in the last hour; automation reads the next /scheduled-tasks?action=update run and a debounced state-invalidate listener; the deploy-panel composite key is used for elapsed-time tracking so close-then-immediately-reopen counts as a new session. * refactor(sidebar): apply Ops Pulse audit fixes - countEnabledAutoUpdates now defaults missing autoUpdateSettings entries to enabled, matching the backend's getStackAutoUpdateSettingsForNode contract. Previously the automation state could not render even with the documented per-row default-true. - findFailure now requires a stack_name so the sidebar does not select a system-level error whose click would no-op through navigateToNotification. System errors continue to surface via the top-bar NotificationPanel. - DeployPanelState gains a monotonic sessionId sourced from the existing internal counter, and the new usePanelSessionStartedAt hook keys the elapsed-time tracker off it so a same-stack rerun always resets even when isOpen stays true across succeeded then preparing. - buildConfig splits quiet-live out of the default and adds an exhaustiveness guard so future SidebarActivitySummary variants fail to compile. - New unit tests cover the default-true aggregation, the same-stack session reset, the non-stack failure guard, and the useNextAutoUpdateRun debounce and cleanup paths. Frontend suite: 276 / 276 pass.
This commit is contained in:
@@ -1,44 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SidebarActivityTicker } from '../SidebarActivityTicker';
|
||||
import type { SidebarActivitySummary } from '../useSidebarActivitySummary';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 1,
|
||||
level: 'info',
|
||||
message: 'web deployed',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 12,
|
||||
is_read: 0,
|
||||
stack_name: 'web',
|
||||
...overrides,
|
||||
};
|
||||
const baseNotif: NotificationItem = {
|
||||
id: 42,
|
||||
level: 'info',
|
||||
message: 'web deployed',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 12,
|
||||
is_read: 0,
|
||||
stack_name: 'web',
|
||||
};
|
||||
|
||||
function renderWith(summary: SidebarActivitySummary) {
|
||||
const onAction = vi.fn();
|
||||
const utils = render(<SidebarActivityTicker summary={summary} onAction={onAction} />);
|
||||
return { ...utils, onAction };
|
||||
}
|
||||
|
||||
describe('SidebarActivityTicker', () => {
|
||||
it('shows idle fallback when no recent stack events', () => {
|
||||
render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
|
||||
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stack name + message when a recent event exists', () => {
|
||||
render(
|
||||
<SidebarActivityTicker notifications={[notif()]} connected onNavigate={() => {}} />
|
||||
);
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText(/deployed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks connected dot when connected, amber dot when disconnected', () => {
|
||||
const { rerender } = render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
|
||||
it('renders the quiet-live idle copy with a green dot', () => {
|
||||
renderWith({ kind: 'quiet-live' });
|
||||
expect(screen.getByText(/Live · no stack changes in 1h/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-success');
|
||||
rerender(<SidebarActivityTicker notifications={[]} connected={false} onNavigate={() => {}} />);
|
||||
});
|
||||
|
||||
it('renders the disconnected state with an amber dot and notifications-paused kicker', () => {
|
||||
renderWith({ kind: 'disconnected' });
|
||||
expect(screen.getByText(/Notifications reconnecting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/NOTIFICATIONS PAUSED/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
|
||||
});
|
||||
|
||||
it('falls back to idle when events are older than 1 hour', () => {
|
||||
const old = notif({ timestamp: Math.floor(Date.now() / 1000) - 60 * 60 - 1 });
|
||||
render(<SidebarActivityTicker notifications={[old]} connected onNavigate={() => {}} />);
|
||||
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
|
||||
it('renders an active deploy with pulsing brand dot and stack name', () => {
|
||||
renderWith({ kind: 'active-op', stackName: 'api', action: 'deploy', startedAt: Date.now() - 1000 });
|
||||
expect(screen.getByText('api')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Deploying/)).toBeInTheDocument();
|
||||
const dot = screen.getByTestId('ticker-dot');
|
||||
expect(dot).toHaveClass('bg-brand');
|
||||
expect(dot).toHaveClass('animate-pulse');
|
||||
});
|
||||
|
||||
it('renders failure state with destructive dot and routes click to open-stack-notification', () => {
|
||||
const errNotif = { ...baseNotif, level: 'error' as const, message: 'deploy failed' };
|
||||
const { onAction } = renderWith({ kind: 'failure', notif: errNotif });
|
||||
expect(screen.getByText(/Failed/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-destructive');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
kind: 'open-stack-notification',
|
||||
summary: { kind: 'failure', notif: errNotif },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders automation state with counts, next-run time, and routes click to open-auto-updates', () => {
|
||||
const nextRun = Math.floor(Date.now() / 1000) + 600;
|
||||
const { onAction } = renderWith({ kind: 'automation', enabledCount: 3, totalCount: 8, nextRunAt: nextRun });
|
||||
expect(screen.getByText(/Auto-update/)).toBeInTheDocument();
|
||||
expect(screen.getByText('3/8')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({ kind: 'open-auto-updates' });
|
||||
});
|
||||
|
||||
it('renders recent-event with stack name and routes click to open-stack-notification', () => {
|
||||
const { onAction } = renderWith({ kind: 'recent-event', notif: baseNotif });
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText(/deployed/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
kind: 'open-stack-notification',
|
||||
summary: { kind: 'recent-event', notif: baseNotif },
|
||||
});
|
||||
});
|
||||
|
||||
it('quiet-live click opens activity', () => {
|
||||
const { onAction } = renderWith({ kind: 'quiet-live' });
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onAction).toHaveBeenCalledWith({ kind: 'open-activity' });
|
||||
});
|
||||
|
||||
it('active-op and disconnected are non-clickable noops', () => {
|
||||
const { onAction, rerender } = renderWith({ kind: 'active-op', stackName: 'api', action: 'deploy', startedAt: Date.now() });
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).toBeDisabled();
|
||||
fireEvent.click(btn);
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
rerender(<SidebarActivityTicker summary={{ kind: 'disconnected' }} onAction={onAction} />);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
// Module-scope spy that the hook will hit through apiFetch.
|
||||
const apiFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
import { useNextAutoUpdateRun } from '../useNextAutoUpdateRun';
|
||||
|
||||
function okResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function fireInvalidate(detail: { action?: string; scope?: string }) {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
|
||||
}
|
||||
|
||||
describe('useNextAutoUpdateRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
apiFetchMock.mockReset();
|
||||
apiFetchMock.mockImplementation(() => Promise.resolve(okResponse([])));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fires once on mount', () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetchMock).toHaveBeenCalledWith(
|
||||
'/scheduled-tasks?action=update',
|
||||
expect.objectContaining({ localOnly: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the earliest enabled next_run_at across tasks', async () => {
|
||||
apiFetchMock.mockImplementationOnce(() => Promise.resolve(okResponse([
|
||||
{ enabled: 1, next_run_at: 1_900 },
|
||||
{ enabled: 1, next_run_at: 1_700 },
|
||||
{ enabled: 0, next_run_at: 1_500 },
|
||||
{ enabled: 1, next_run_at: null },
|
||||
])));
|
||||
|
||||
const { result } = renderHook(() => useNextAutoUpdateRun());
|
||||
// Drain microtasks from the in-flight fetch without advancing fake timers
|
||||
// (vi.runAllTimersAsync would loop forever on the 60s poll interval).
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(result.current).toBe(1_700);
|
||||
});
|
||||
|
||||
it('debounces rapid invalidations into a single refetch', async () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
});
|
||||
// Debounce window not yet elapsed: still only the mount call.
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(260); });
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores unrelated state-invalidate events', () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
apiFetchMock.mockClear();
|
||||
act(() => {
|
||||
fireInvalidate({ action: 'something-else' });
|
||||
fireInvalidate({ scope: 'unrelated' });
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('polls every 60s', async () => {
|
||||
renderHook(() => useNextAutoUpdateRun());
|
||||
await act(async () => { await vi.runAllTicks(); });
|
||||
apiFetchMock.mockClear();
|
||||
await act(async () => { vi.advanceTimersByTime(60_000); });
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { vi.advanceTimersByTime(60_000); });
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleans up listener and interval on unmount (no further fetches)', async () => {
|
||||
const { unmount } = renderHook(() => useNextAutoUpdateRun());
|
||||
apiFetchMock.mockClear();
|
||||
unmount();
|
||||
await act(async () => {
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
vi.advanceTimersByTime(120_000);
|
||||
});
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePanelSessionStartedAt } from '../usePanelSessionStartedAt';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
return {
|
||||
isOpen: false,
|
||||
stackName: '',
|
||||
action: 'deploy',
|
||||
status: 'preparing',
|
||||
sessionId: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelSessionStartedAt', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null while the panel is closed', () => {
|
||||
const { result } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
|
||||
initialProps: panel(),
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('captures Date.now() when the panel opens', () => {
|
||||
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
|
||||
initialProps: panel(),
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
const opened = Date.now();
|
||||
rerender(panel({ isOpen: true, stackName: 'web', sessionId: 1 }));
|
||||
expect(result.current).toBe(opened);
|
||||
});
|
||||
|
||||
it('resets the timestamp when sessionId changes even if isOpen stays true (same stack rerun)', () => {
|
||||
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
|
||||
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
|
||||
});
|
||||
const first = result.current;
|
||||
expect(first).not.toBeNull();
|
||||
|
||||
// Status flows to succeeded but the panel stays visible.
|
||||
rerender(panel({ isOpen: true, stackName: 'web', status: 'succeeded', sessionId: 1 }));
|
||||
expect(result.current).toBe(first);
|
||||
|
||||
// Advance the clock and trigger a same-stack rerun under a new sessionId.
|
||||
vi.advanceTimersByTime(5_000);
|
||||
rerender(panel({ isOpen: true, stackName: 'web', status: 'preparing', sessionId: 2 }));
|
||||
|
||||
expect(result.current).not.toBe(first);
|
||||
expect(result.current).toBe((first ?? 0) + 5_000);
|
||||
});
|
||||
|
||||
it('does not flap when the same panel session re-renders with no changes', () => {
|
||||
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
|
||||
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
|
||||
});
|
||||
const captured = result.current;
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
rerender(panel({ isOpen: true, stackName: 'web', sessionId: 1, status: 'streaming' }));
|
||||
expect(result.current).toBe(captured);
|
||||
});
|
||||
|
||||
it('clears the timestamp when the panel closes', () => {
|
||||
const { result, rerender } = renderHook((p: DeployPanelState) => usePanelSessionStartedAt(p), {
|
||||
initialProps: panel({ isOpen: true, stackName: 'web', sessionId: 1 }),
|
||||
});
|
||||
expect(result.current).not.toBeNull();
|
||||
rerender(panel({ isOpen: false, sessionId: 1 }));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { __testing, countEnabledAutoUpdates } from '../useSidebarActivitySummary';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
const { deriveSummary } = __testing;
|
||||
|
||||
const NOW_SECS = 1_700_000_000;
|
||||
|
||||
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 1,
|
||||
level: 'info',
|
||||
message: 'web deployed',
|
||||
timestamp: NOW_SECS - 30,
|
||||
is_read: 0,
|
||||
stack_name: 'web',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', sessionId: 0 };
|
||||
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', sessionId: 1 };
|
||||
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', sessionId: 1 };
|
||||
|
||||
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||
return {
|
||||
notifications: [],
|
||||
tickerConnected: true,
|
||||
panelState: IDLE_PANEL,
|
||||
panelStartedAt: null,
|
||||
autoUpdateEnabledCount: 0,
|
||||
totalStackCount: 0,
|
||||
nextAutoUpdateRunAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSidebarActivitySummary.deriveSummary', () => {
|
||||
it('returns quiet-live when nothing is happening and the WS is connected', () => {
|
||||
expect(deriveSummary(inputs(), NOW_SECS)).toEqual({ kind: 'quiet-live' });
|
||||
});
|
||||
|
||||
it('returns disconnected when the notification WS is down and there is no recent event', () => {
|
||||
expect(deriveSummary(inputs({ tickerConnected: false }), NOW_SECS)).toEqual({ kind: 'disconnected' });
|
||||
});
|
||||
|
||||
it('returns active-op while the deploy panel is streaming, preempting everything else', () => {
|
||||
const failure = notif({ level: 'error', message: 'deploy failed', timestamp: NOW_SECS - 10 });
|
||||
const r = deriveSummary(inputs({
|
||||
panelState: STREAMING_PANEL,
|
||||
panelStartedAt: Date.now(),
|
||||
notifications: [failure],
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('active-op');
|
||||
if (r.kind === 'active-op') {
|
||||
expect(r.stackName).toBe('api');
|
||||
expect(r.action).toBe('deploy');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores the panel once it has finished (status: succeeded)', () => {
|
||||
const r = deriveSummary(inputs({ panelState: SUCCEEDED_PANEL, panelStartedAt: Date.now() }), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
|
||||
it('returns failure when an unread error is within 24h, preempting automation and recent-event', () => {
|
||||
const failure = notif({ id: 9, level: 'error', message: 'deploy failed', timestamp: NOW_SECS - 60 });
|
||||
const recent = notif({ id: 10, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [failure, recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('failure');
|
||||
if (r.kind === 'failure') expect(r.notif.id).toBe(9);
|
||||
});
|
||||
|
||||
it('skips failure that is older than 24h or already read', () => {
|
||||
const oldErr = notif({ id: 9, level: 'error', stack_name: 'web', timestamp: NOW_SECS - 25 * 60 * 60 });
|
||||
const readErr = notif({ id: 10, level: 'error', stack_name: 'web', is_read: 1, timestamp: NOW_SECS - 60 });
|
||||
const r = deriveSummary(inputs({ notifications: [oldErr, readErr] }), NOW_SECS);
|
||||
expect(r.kind).not.toBe('failure');
|
||||
});
|
||||
|
||||
it('returns automation when auto-update is enabled and no recent event exists', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 2,
|
||||
totalStackCount: 4,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('automation');
|
||||
if (r.kind === 'automation') {
|
||||
expect(r.enabledCount).toBe(2);
|
||||
expect(r.totalCount).toBe(4);
|
||||
expect(r.nextRunAt).toBe(NOW_SECS + 3600);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers recent-event over automation when a fresh event is available', () => {
|
||||
const recent = notif({ id: 7, timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
notifications: [recent],
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: NOW_SECS + 3600,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('recent-event');
|
||||
if (r.kind === 'recent-event') expect(r.notif.id).toBe(7);
|
||||
});
|
||||
|
||||
it('drops automation when no next-run is known, even with auto-update settings present', () => {
|
||||
const r = deriveSummary(inputs({
|
||||
autoUpdateEnabledCount: 1,
|
||||
totalStackCount: 1,
|
||||
nextAutoUpdateRunAt: null,
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
|
||||
it('does NOT classify a read error notification as a recent-event (severity mis-signal guard)', () => {
|
||||
const readErr = notif({ id: 11, level: 'error', is_read: 1, timestamp: NOW_SECS - 60 });
|
||||
const r = deriveSummary(inputs({ notifications: [readErr] }), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
|
||||
it('treats a panel that already finished (succeeded) as not active, so a fresh failure preempts', () => {
|
||||
const failure = notif({ id: 12, level: 'error', timestamp: NOW_SECS - 5 });
|
||||
const r = deriveSummary(inputs({
|
||||
panelState: SUCCEEDED_PANEL,
|
||||
panelStartedAt: Date.now(),
|
||||
notifications: [failure],
|
||||
}), NOW_SECS);
|
||||
expect(r.kind).toBe('failure');
|
||||
});
|
||||
|
||||
it('treats stack events older than 1h as not recent', () => {
|
||||
const stale = notif({ timestamp: NOW_SECS - 60 * 60 - 1 });
|
||||
const r = deriveSummary(inputs({ notifications: [stale] }), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
|
||||
it('ignores notifications without a stack_name for recent-event detection', () => {
|
||||
const systemNotif = notif({ stack_name: undefined, timestamp: NOW_SECS - 10 });
|
||||
const r = deriveSummary(inputs({ notifications: [systemNotif] }), NOW_SECS);
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
|
||||
it('failure preempts disconnected fallback', () => {
|
||||
const failure = notif({ level: 'error', timestamp: NOW_SECS - 30 });
|
||||
const r = deriveSummary(inputs({ notifications: [failure], tickerConnected: false }), NOW_SECS);
|
||||
expect(r.kind).toBe('failure');
|
||||
});
|
||||
|
||||
it('does NOT classify a stackless system error as a sidebar failure (router would no-op)', () => {
|
||||
const systemErr = notif({ id: 21, level: 'error', stack_name: undefined, timestamp: NOW_SECS - 30 });
|
||||
const r = deriveSummary(inputs({ notifications: [systemErr] }), NOW_SECS);
|
||||
expect(r.kind).not.toBe('failure');
|
||||
expect(r.kind).toBe('quiet-live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countEnabledAutoUpdates', () => {
|
||||
it('counts a stack with no explicit row as enabled (backend default-true contract)', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api'], {})).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit false', () => {
|
||||
expect(countEnabledAutoUpdates(['web', 'api', 'db'], { api: false })).toBe(2);
|
||||
});
|
||||
|
||||
it('respects an explicit true', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { web: true })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty file list', () => {
|
||||
expect(countEnabledAutoUpdates([], { web: true })).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores settings rows that do not correspond to known files', () => {
|
||||
expect(countEnabledAutoUpdates(['web'], { ghost: false, web: true })).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user