From 2c0c98b7c8fdd3cf1c7bd258f045fa3707388420 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Tue, 2 Jun 2026 14:30:14 +0200 Subject: [PATCH 01/24] fix(ui): fix Safari badge overflow and align badges to bottom of stat cards - StatCard: add `dynamicHeight` prop (default false) to toggle between fixed height (240px) and flexible height (auto with min-height 240px) - StatCard: use flex layout with `justifyContent: flex-end` to push subtitle content (badges) to the bottom of the card - StatCard: wrap subtitle in inner div with `overflowY: auto` and `maxHeight: 100%` to enable scrolling when many badges are present; add `minHeight: 0` for Safari overflow compatibility - GridNamespaces & GridKinds: set `dynamicHeight={true}` to allow their cards to grow vertically when needed - Other grids (Nodes, Objects, Heartbeats, Pools, Networks) keep fixed height (240px) - NamespaceChip and KindChip retain absolute positioning for status indicators (colored circles) to preserve original design This resolves the Safari-specific issue where badges would overflow outside the cards and ensures all badge content stays within the card boundaries. Badges are now consistently aligned at the bottom of each card, with scrollbars appearing only when necessary. --- src/components/ClusterStatGrids.jsx | 319 ++++++++----------------- src/components/StatCard.jsx | 54 +++-- src/components/tests/StatCard.test.jsx | 11 +- 3 files changed, 132 insertions(+), 252 deletions(-) diff --git a/src/components/ClusterStatGrids.jsx b/src/components/ClusterStatGrids.jsx index 486b907..ae3c66a 100644 --- a/src/components/ClusterStatGrids.jsx +++ b/src/components/ClusterStatGrids.jsx @@ -11,7 +11,6 @@ const ClickLoader = memo(({isLoading}) => ( export const GridNodes = memo(({nodeCount, frozenCount, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -22,13 +21,7 @@ export const GridNodes = memo(({nodeCount, frozenCount, onClick}) => { }, [onClick]); const subtitle = useMemo(() => ( - + { ), [frozenCount, isLoading, handleClick]); - return ( - - ); + return ; }); export const GridObjects = memo(({objectCount, statusCount, onClick}) => { @@ -79,7 +65,6 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { const subtitle = useMemo(() => { const chips = []; const statuses = ['up', 'warn', 'down', 'unprovisioned']; - for (const status of statuses) { const count = statusCount[status] || 0; if (count > 0) { @@ -94,7 +79,6 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { ); } } - return ( {chips} @@ -103,31 +87,15 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { ); }, [statusCount, loadingStatus, handleChipClick]); - return ( - - ); + return ; }); const StatusChip = memo(({status, count, isLoading, onClick}) => { - const colors = { - up: 'green', - warn: 'orange', - down: 'red', - unprovisioned: 'red' - }; - + const colors = {up: 'green', warn: 'orange', down: 'red', unprovisioned: 'red'}; const handleClick = useCallback((e) => { e.stopPropagation(); - if (!isLoading) { - onClick(); - } + if (!isLoading) onClick(); }, [onClick, isLoading]); - return ( { ); }); +// ==================== NAMESPACES ==================== export const GridNamespaces = memo(({namespaceCount, namespaceSubtitle, onClick}) => { const [loadingNamespace, setLoadingNamespace] = useState(''); const [isCardLoading, setIsCardLoading] = useState(false); @@ -160,30 +129,26 @@ export const GridNamespaces = memo(({namespaceCount, namespaceSubtitle, onClick} }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {namespaceSubtitle.map(({namespace, status}) => ( - - ))} - - ); - }, [namespaceSubtitle, loadingNamespace, onClick]); + const subtitle = useMemo(() => ( + + {namespaceSubtitle.map(({namespace, status}) => ( + + ))} + + ), [namespaceSubtitle, loadingNamespace, onClick]); return ( { const elements = []; const statusTypes = ['up', 'warn', 'down', 'unprovisioned']; - for (const stat of statusTypes) { const count = status[stat] || 0; if (count > 0) { @@ -313,11 +277,8 @@ const NamespaceChip = memo(({namespace, status, isLoading, onClick, onLoadingCha ); }); -export const GridHeartbeats = memo(({ - heartbeatCount, - perHeartbeatStats = {}, - onClick - }) => { +// ==================== HEARTBEATS ==================== +export const GridHeartbeats = memo(({heartbeatCount, perHeartbeatStats = {}, onClick}) => { const [loadingId, setLoadingId] = useState(''); const handleCardClick = useCallback(() => { @@ -342,34 +303,20 @@ export const GridHeartbeats = memo(({ const subtitle = useMemo(() => { const groups = new Map(); - for (const [fullId, {running, beating}] of Object.entries(perHeartbeatStats)) { if (running === 0 && beating === 0) continue; - let baseId = fullId; - if (fullId.endsWith('.rx')) { - baseId = fullId.slice(0, -3); - } else if (fullId.endsWith('.tx')) { - baseId = fullId.slice(0, -3); - } - - if (!groups.has(baseId)) { - groups.set(baseId, {total: 0, healthy: 0}); - } + if (fullId.endsWith('.rx')) baseId = fullId.slice(0, -3); + else if (fullId.endsWith('.tx')) baseId = fullId.slice(0, -3); + if (!groups.has(baseId)) groups.set(baseId, {total: 0, healthy: 0}); const group = groups.get(baseId); group.total += 1; - - const isHealthy = running > 0 && beating === running; - if (isHealthy) { - group.healthy += 1; - } + if (running > 0 && beating === running) group.healthy += 1; } - const chips = []; for (const [baseId, {total, healthy}] of groups.entries()) { const isHealthy = (total === healthy); const isLoading = loadingId === baseId; - chips.push( ); } - return ( - + {chips} ); }, [perHeartbeatStats, loadingId, handleChipClick]); - return ( - - ); + return ; }); +// ==================== POOLS ==================== export const GridPools = memo(({poolCount, pools, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -426,67 +359,36 @@ export const GridPools = memo(({poolCount, pools, onClick}) => { }, [onClick]); const subtitle = useMemo(() => { - if (!pools || pools.length === 0) { - return null; - } - + if (!pools || pools.length === 0) return null; return ( - + {pools.map((pool) => { - const usagePercentage = pool.size && pool.used >= 0 - ? ((pool.used / pool.size) * 100).toFixed(1) - : "N/A"; + const usagePercentage = pool.size && pool.used >= 0 ? ((pool.used / pool.size) * 100).toFixed(1) : "N/A"; const free = pool.size - pool.used; const freePercent = pool.size ? (free / pool.size) * 100 : 100; const isLowStorage = freePercent < 10; - return ( - - - + ); })} ); }, [pools]); - return ( - - ); + return ; }); +// ==================== NETWORKS ==================== export const GridNetworks = memo(({networks, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleCardClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -496,61 +398,33 @@ export const GridNetworks = memo(({networks, onClick}) => { }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {networks.map((network) => { - const usagePercentage = network.size - ? ((network.used / network.size) * 100).toFixed(1) - : 0; - const free = network.size - network.used; - const freePercent = network.size ? (free / network.size) * 100 : 100; - const isLowStorage = freePercent < 10; - - return ( - - - - ); - })} - - ); - }, [networks]); + const subtitle = useMemo(() => ( + + {networks.map((network) => { + const usagePercentage = network.size ? ((network.used / network.size) * 100).toFixed(1) : 0; + const free = network.size - network.used; + const freePercent = network.size ? (free / network.size) * 100 : 100; + const isLowStorage = freePercent < 10; + return ( + + ); + })} + + ), [networks]); - return ( - - ); + return ; }); +// ==================== KINDS ==================== export const GridKinds = memo(({kindCount, kindSubtitle, onClick}) => { const [loadingKind, setLoadingKind] = useState(''); const [isCardLoading, setIsCardLoading] = useState(false); @@ -564,30 +438,26 @@ export const GridKinds = memo(({kindCount, kindSubtitle, onClick}) => { }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {kindSubtitle.map(({kind, status}) => ( - - ))} - - ); - }, [kindSubtitle, loadingKind, onClick]); + const subtitle = useMemo(() => ( + + {kindSubtitle.map(({kind, status}) => ( + + ))} + + ), [kindSubtitle, loadingKind, onClick]); return ( { const statusElements = useMemo(() => { const elements = []; const statusTypes = ['up', 'warn', 'down', 'unprovisioned']; - for (const stat of statusTypes) { const count = status[stat] || 0; if (count > 0) { diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 04521fd..2bbec8c 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -10,45 +10,55 @@ const StatCard = memo(({title, value, subtitle, onClick, dynamicHeight = false, {isLoading && ( - + )} {title} - - {isLoading ? ( - - ) : ( - {value} - )} - + + {isLoading ? : value} + {subtitle && ( - e.stopPropagation()}> - {typeof subtitle === 'string' ? ( - {subtitle} - ) : ( - subtitle - )} + e.stopPropagation()} + > + + {typeof subtitle === 'string' ? ( + {subtitle} + ) : ( + subtitle + )} + )} diff --git a/src/components/tests/StatCard.test.jsx b/src/components/tests/StatCard.test.jsx index abb427d..7de178f 100644 --- a/src/components/tests/StatCard.test.jsx +++ b/src/components/tests/StatCard.test.jsx @@ -68,8 +68,9 @@ describe('StatCard Component', () => { }); test('does not call onClick when not provided', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - render(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { + }); + render(); // eslint-disable-next-line testing-library/no-node-access const card = screen.getByText('Test').closest('div'); fireEvent.click(card); @@ -78,14 +79,14 @@ describe('StatCard Component', () => { }); test('does not have pointer cursor when onClick is not provided', () => { - render(); + render(); // eslint-disable-next-line testing-library/no-node-access const paper = screen.getByText('Test').closest('div'); expect(paper).toHaveStyle('cursor: default'); }); test('has pointer cursor when onClick is provided', () => { - render(); + render(); // eslint-disable-next-line testing-library/no-node-access const paper = screen.getByText('Test').closest('div'); expect(paper).toHaveStyle('cursor: pointer'); @@ -112,7 +113,7 @@ describe('StatCard Component', () => { }); test('renders without onClick and without dynamicHeight', () => { - render(); + render(); expect(screen.getByText('Test')).toBeInTheDocument(); expect(screen.getByText('42')).toBeInTheDocument(); }); From be2b7fd14b74228e62de643142776ca67232976b Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Tue, 2 Jun 2026 14:41:55 +0200 Subject: [PATCH 02/24] Delete patch action --- src/components/tests/NodeRow.test.jsx | 1 - src/constants/actions.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/tests/NodeRow.test.jsx b/src/components/tests/NodeRow.test.jsx index 678b57c..dd8839f 100644 --- a/src/components/tests/NodeRow.test.jsx +++ b/src/components/tests/NodeRow.test.jsx @@ -26,7 +26,6 @@ jest.mock('../../constants/actions', () => ({ {name: 'drain', icon: jest.fn(() => )}, {name: 'push/asset', icon: jest.fn(() => )}, {name: 'push/disk', icon: jest.fn(() => )}, - {name: 'push/patch', icon: jest.fn(() => )}, {name: 'push/pkg', icon: jest.fn(() => )}, {name: 'scan/capabilities', icon: jest.fn(() => )}, {name: 'sysreport', icon: jest.fn(() => )}, diff --git a/src/constants/actions.js b/src/constants/actions.js index 5c806d2..b56825b 100644 --- a/src/constants/actions.js +++ b/src/constants/actions.js @@ -67,7 +67,6 @@ export const NODE_ACTIONS = [ {name: "drain", icon: }, {name: "asset", icon: }, {name: "disk", icon: }, - {name: "patch", icon: }, {name: "pkg", icon: }, {name: "capabilities", icon: }, {name: "sysreport", icon: }, From 50edb34a7d7aa29c5521e9bf715a0f5c4d7c7299 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Tue, 2 Jun 2026 16:13:30 +0200 Subject: [PATCH 03/24] refactor(test): reorganize ObjectDetails.test and reduce duplication - Group mocks and helpers for better readability - Add reusable helpers (renderReadySvc, executeObjectAction, mockNetworkFailure, captureSubscription) - Replace repetitive test blocks with test.each and describe.each - Consolidate console and action error handling tests - Remove redundant assertions and improve overall test structure --- src/components/tests/ObjectDetails.test.jsx | 1137 +++++++------------ 1 file changed, 416 insertions(+), 721 deletions(-) diff --git a/src/components/tests/ObjectDetails.test.jsx b/src/components/tests/ObjectDetails.test.jsx index 2ba9657..e5d7845 100644 --- a/src/components/tests/ObjectDetails.test.jsx +++ b/src/components/tests/ObjectDetails.test.jsx @@ -1,63 +1,16 @@ import React, {act} from 'react'; import {render, screen, fireEvent, waitFor, within} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; import ObjectDetail, {getResourceType, parseProvisionedState} from '../ObjectDetails'; import useEventStore from '../../hooks/useEventStore.js'; import {closeEventSource, startEventReception} from '../../eventSourceManager.jsx'; -import userEvent from '@testing-library/user-event'; import logger from '../../utils/logger'; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn(), - useNavigate: jest.fn(), -})); -jest.mock('../../hooks/useEventStore.js'); -jest.mock('../../eventSourceManager.jsx', () => ({ - closeEventSource: jest.fn(), - startEventReception: jest.fn(), - startLoggerReception: jest.fn(), - closeLoggerEventSource: jest.fn(), -})); -jest.mock('../../context/DarkModeContext', () => ({ - useDarkMode: () => ({isDarkMode: false, toggleDarkMode: jest.fn()}), -})); -jest.mock("../../utils/logger", () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -})); - -jest.mock('../ConfigSection', () => ({ - __esModule: true, - default: ({decodedObjectName, configNode, setConfigNode, openSnackbar, configDialogOpen, setConfigDialogOpen}) => ( -
- - {configDialogOpen && ( -
-
Configuration for {decodedObjectName}
- {configNode &&
Node: {configNode}
} -
- )} -
- ), -})); - jest.mock('@mui/material', () => { const actual = jest.requireActual('@mui/material'); return { ...actual, - Accordion: ({children, expanded, onChange, ...props}) => ( -
{children}
- ), - AccordionSummary: ({children, id, onChange, expanded, expandIcon, ...props}) => ( -
onChange?.({}, !expanded)} {...props}>{children}
- ), - AccordionDetails: ({children, ...props}) =>
{children}
, Menu: ({children, open, anchorEl, onClose, disablePortal, ...props}) => open ?
{children}
: null, MenuItem: ({children, onClick, ...props}) => ( @@ -79,7 +32,7 @@ jest.mock('@mui/material', () => { ), Checkbox: ({checked, onChange, sx, ...props}) => ( - + ), IconButton: ({children, onClick, disabled, sx, ...props}) => ( @@ -102,23 +55,23 @@ jest.mock('@mui/material', () => {
+ disabled={disabled} {...(multiline ? {'data-multiline': true, rows} : {})} {...props} />
); }, Input: ({type, onChange, disabled, ...props}) => ( - + ), CircularProgress: () =>
Loading...
, Box: ({children, sx, ...props}) =>
{children}
, Typography: ({children, sx, ...props}) => {children}, - FiberManualRecordIcon: ({sx, ...props}) => , + FiberManualRecordIcon: ({sx, ...props}) => , Tooltip: ({children, title, ...props}) => {children}, Button: ({children, onClick, disabled, variant, component, htmlFor, sx, startIcon, ...props}) => ( ), - Popper: ({open, anchorEl, children, ...props}) => open ?
{children}
: null, + Popper: ({open, anchorEl, children, ...props}) => (open ?
{children}
: null), Paper: ({elevation, children, ...props}) =>
{children}
, ClickAwayListener: ({onClickAway, children, ...props}) =>
{children}
, @@ -133,12 +86,43 @@ jest.mock('@mui/icons-material/Edit', () => () => Edit); jest.mock('@mui/icons-material/AcUnit', () => () => AcUnit); jest.mock('@mui/icons-material/MoreVert', () => () => MoreVertIcon); -const mockLocalStorage = { - getItem: jest.fn(() => 'mock-token'), - setItem: jest.fn(), - removeItem: jest.fn(), -}; -Object.defineProperty(global, 'localStorage', {value: mockLocalStorage}); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), + useNavigate: jest.fn(), +})); +jest.mock('../../hooks/useEventStore.js'); +jest.mock('../../eventSourceManager.jsx', () => ({ + closeEventSource: jest.fn(), + startEventReception: jest.fn(), + startLoggerReception: jest.fn(), + closeLoggerEventSource: jest.fn(), +})); +jest.mock('../../context/DarkModeContext', () => ({ + useDarkMode: () => ({isDarkMode: false, toggleDarkMode: jest.fn()}), +})); +jest.mock('../../utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../ConfigSection', () => ({ + __esModule: true, + default: ({decodedObjectName, configNode, setConfigNode, openSnackbar, configDialogOpen, setConfigDialogOpen}) => ( +
+ + {configDialogOpen && ( +
+
Configuration for {decodedObjectName}
+ {configNode &&
Node: {configNode}
} +
+ )} +
+ ), +})); jest.mock('../../constants/actions', () => ({ OBJECT_ACTIONS: [ @@ -166,37 +150,35 @@ jest.mock('../../constants/actions', () => ({ })); jest.mock('../LogsViewer.jsx', () => ({nodename, height}) => ( -
Logs Viewer Mock
+
+ Logs Viewer Mock +
)); -// ─── Shared helpers ─────────────────────────────────────────────────────────── +// ─── localStorage mock ───────────────────────────────────────────────────── +const mockLocalStorage = { + getItem: jest.fn(() => 'mock-token'), + setItem: jest.fn(), + removeItem: jest.fn(), +}; +Object.defineProperty(global, 'localStorage', {value: mockLocalStorage}); const buildState = (overrides = {}) => ({ objectStatus: {'root/svc/svc1': {avail: 'up', frozen: null}}, objectInstanceStatus: { 'root/svc/svc1': { node1: { - avail: 'up', frozen_at: null, + avail: 'up', + frozen_at: null, resources: { - res1: { - status: 'up', - label: 'R1', - type: 'disk', - provisioned: {state: 'true'}, - running: true - } + res1: {status: 'up', label: 'R1', type: 'disk', provisioned: {state: 'true'}, running: true}, }, }, node2: { - avail: 'down', frozen_at: null, + avail: 'down', + frozen_at: null, resources: { - res2: { - status: 'warn', - label: 'R2', - type: 'compute', - provisioned: {state: 'true'}, - running: false - } + res2: {status: 'warn', label: 'R2', type: 'compute', provisioned: {state: 'true'}, running: false}, }, }, }, @@ -223,7 +205,8 @@ const fullMockState = { objectInstanceStatus: { 'root/cfg/cfg1': { node1: { - avail: 'up', frozen_at: null, + avail: 'up', + frozen_at: null, resources: { res1: { status: 'up', @@ -242,7 +225,8 @@ const fullMockState = { }, }, node2: { - avail: 'down', frozen_at: null, + avail: 'down', + frozen_at: null, resources: { res3: { status: 'warn', @@ -250,13 +234,14 @@ const fullMockState = { type: 'compute', provisioned: {state: 'true', mtime: '2023-01-01T12:00:00Z'}, running: false - } + }, }, }, }, 'root/svc/svc1': { node1: { - avail: 'up', frozen_at: null, + avail: 'up', + frozen_at: null, resources: { res1: { status: 'up', @@ -283,13 +268,14 @@ const fullMockState = { type: 'container', provisioned: {state: 'true', mtime: '2023-01-01T12:00:00Z'}, running: true - } - } + }, + }, }, }, }, node2: { - avail: 'down', frozen_at: null, + avail: 'down', + frozen_at: null, resources: { res3: { status: 'warn', @@ -297,7 +283,7 @@ const fullMockState = { type: 'compute', provisioned: {state: 'true', mtime: '2023-01-01T12:00:00Z'}, running: false - } + }, }, }, }, @@ -329,6 +315,7 @@ const fullMockState = { clearConfigUpdate: jest.fn(), }; +// ─── Helpers ─────────────────────────────────────────────────────────────── const renderComponent = (objectName) => { require('react-router-dom').useParams.mockReturnValue({objectName}); return render( @@ -341,19 +328,50 @@ const renderComponent = (objectName) => { }; const renderSvc = (objectName = 'root/svc/svc1') => renderComponent(objectName); +const waitForNode = async (nodeName) => screen.findByText(nodeName, {}, {timeout: 10000}); -const waitForObjectName = async (name = 'root/svc/svc1') => - screen.findByText(new RegExp(name.replace(/\//g, '\\/'), 'i'), {}, {timeout: 10000}); +const renderReadySvc = async () => { + renderSvc(); + await waitForNode('node1'); + await waitForNode('node2'); + return {node1: screen.getByText('node1'), node2: screen.getByText('node2')}; +}; -const waitForNode = async (nodeName) => screen.findByText(nodeName, {}, {timeout: 10000}); +const executeObjectAction = async (actionName) => { + await userEvent.click(screen.getByRole('button', {name: /object actions/i})); + await screen.findByRole('menu'); + await userEvent.click(screen.getByRole('menuitem', {name: new RegExp(actionName, 'i')})); + const dialog = await screen.findByRole('dialog'); + const confirmBtn = within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept/i}); + await userEvent.click(confirmBtn); +}; -const openLogsDrawer = async (user) => { - const logsButtons = screen.getAllByRole('button', {name: /logs/i}); - const nodeLogsButton = logsButtons.find(b => b.textContent?.includes('Logs') && !b.textContent?.includes('Resource')) - || logsButtons[0]; - if (!nodeLogsButton) return null; - await user.click(nodeLogsButton); - return nodeLogsButton; +const mockNetworkFailure = (urlPattern) => { + global.fetch.mockImplementation((url, options) => { + if (url.includes(urlPattern)) return Promise.reject(new Error('Network error')); + return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + }); +}; + +const mockActionFailure = (status = 500, message = 'Server error') => { + global.fetch.mockImplementation((url, options) => { + if (options?.method === 'POST' && url.includes('/action/')) + return Promise.resolve({ok: false, status, text: () => Promise.resolve(message)}); + return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + }); +}; + +const captureSubscription = () => { + let callback; + useEventStore.subscribe = jest.fn((selector, cb) => { + callback = cb; + return jest.fn(); + }); + return { + get callback() { + return callback; + }, + }; }; const withConsole = async (fn) => { @@ -364,7 +382,7 @@ const withConsole = async (fn) => { await fn(); } finally { INSTANCE_ACTIONS.length = 0; - orig.forEach(a => INSTANCE_ACTIONS.push(a)); + orig.forEach((a) => INSTANCE_ACTIONS.push(a)); } }; @@ -377,13 +395,17 @@ const openConsoleDialogFn = async () => { if (consoleItems.length === 0) return null; await userEvent.click(consoleItems[0]); await waitFor(() => { - expect(screen.queryAllByRole('dialog').some(d => - d.textContent.includes('terminal console') || d.textContent.includes('Open Console') - )).toBe(true); + expect( + screen.queryAllByRole('dialog').some( + (d) => d.textContent.includes('terminal console') || d.textContent.includes('Open Console') + ) + ).toBe(true); }, {timeout: 5000}); - return screen.queryAllByRole('dialog').find(d => - d.textContent.includes('terminal console') || d.textContent.includes('Open Console') - ) || null; + return ( + screen.queryAllByRole('dialog').find( + (d) => d.textContent.includes('terminal console') || d.textContent.includes('Open Console') + ) || null + ); }; const defaultFetchMock = (url, options) => { @@ -391,11 +413,7 @@ const defaultFetchMock = (url, options) => { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ - items: [{name: 'key1', node: 'node1', size: 2626}, { - name: 'key2', - node: 'node1', - size: 6946 - }] + items: [{name: 'key1', node: 'node1', size: 2626}, {name: 'key2', node: 'node1', size: 6946}] }), text: () => Promise.resolve(''), }); @@ -418,7 +436,7 @@ const defaultFetchMock = (url, options) => { if (url.includes('/console') && options?.method === 'POST') { return Promise.resolve({ ok: true, - headers: {get: (h) => h === 'Location' ? 'http://console.example.com/session123' : null} + headers: {get: (h) => (h === 'Location' ? 'http://console.example.com/session123' : null)} }); } if (options?.method === 'POST' && url.includes('/action/')) { @@ -427,8 +445,7 @@ const defaultFetchMock = (url, options) => { return Promise.resolve({ok: true, status: 200, json: () => Promise.resolve({}), text: () => Promise.resolve('')}); }; -// ─── Tests ──────────────────────────────────────────────────────────────────── - +// ─── Tests ───────────────────────────────────────────────────────────────── describe('ObjectDetail Component', () => { const user = userEvent.setup(); const mockNavigate = jest.fn(); @@ -445,96 +462,41 @@ describe('ObjectDetail Component', () => { afterEach(() => jest.clearAllMocks()); - // ── Pure unit tests ──────────────────────────────────────────────────────── - describe('getResourceType', () => { - test('returns empty for null/empty/missing inputs', () => { - expect(getResourceType(null, {})).toBe(''); - expect(getResourceType('', {})).toBe(''); - expect(getResourceType('rid1', null)).toBe(''); - expect(getResourceType('rid1', undefined)).toBe(''); - expect(getResourceType(null, null)).toBe(''); - expect(getResourceType(undefined, undefined)).toBe(''); - expect(getResourceType('notfound', {resources: {}, encap: {c1: {resources: {}}}})).toBe(''); - expect(getResourceType('rid1', {resources: {}, encap: {}})).toBe(''); - }); - - test('returns top-level resource type', () => { - const nodeData = {resources: {rid1: {type: 'disk.disk'}, rid2: {type: 'fs.flag'}}}; - expect(getResourceType('rid1', nodeData)).toBe('disk.disk'); - expect(getResourceType('rid2', nodeData)).toBe('fs.flag'); - }); - - test('searches encap containers when top-level missing', () => { - const nodeData = { + test.each([ + [null, {}, ''], ['', {}, ''], ['rid1', null, ''], ['rid1', undefined, ''], [null, null, ''], [undefined, undefined, ''], + ['notfound', {resources: {}, encap: {c1: {resources: {}}}}, ''], + ['rid1', {resources: {}, encap: {}}, ''], + ['rid1', {resources: {rid1: {type: 'disk.disk'}}}, 'disk.disk'], + ['rid2', {resources: {rid2: {type: 'fs.flag'}}}, 'fs.flag'], + ['rid2', { resources: {}, encap: {container1: {resources: {rid2: {type: 'container.docker'}}}} - }; - expect(getResourceType('rid2', nodeData)).toBe('container.docker'); - }); - - test('returns empty when rid not found anywhere', () => { - const nodeData = { - resources: {r1: {type: 'disk'}}, - encap: {c1: {resources: {r2: {type: 'container'}}}} - }; - expect(getResourceType('r3', nodeData)).toBe(''); - }); + }, 'container.docker'], + ['r3', {resources: {r1: {type: 'disk'}}, encap: {c1: {resources: {r2: {type: 'container'}}}}}, ''], + ])('getResourceType(%p, %p) => %p', (rid, nodeData, expected) => expect(getResourceType(rid, nodeData)).toBe(expected)); }); describe('parseProvisionedState', () => { - test('handles string "true" variants (case-insensitive)', () => { - ['true', 'True', 'TRUE', 'tRuE'].forEach(v => expect(parseProvisionedState(v)).toBe(true)); - }); - - test('handles string "false" variants (case-insensitive)', () => { - ['false', 'False', 'FALSE', 'fAlSe'].forEach(v => expect(parseProvisionedState(v)).toBe(false)); - }); - - test('treats non-boolean strings as false', () => { - ['yes', 'no', '', 'abc', '1', '0'].forEach(v => expect(parseProvisionedState(v)).toBe(false)); - }); - - test('coerces non-string values correctly', () => { - expect(parseProvisionedState(true)).toBe(true); - expect(parseProvisionedState(false)).toBe(false); - expect(parseProvisionedState(1)).toBe(true); - expect(parseProvisionedState(0)).toBe(false); - expect(parseProvisionedState(42)).toBe(true); - expect(parseProvisionedState(-1)).toBe(true); - expect(parseProvisionedState({})).toBe(true); - expect(parseProvisionedState({state: true})).toBe(true); - expect(parseProvisionedState([])).toBe(true); - expect(parseProvisionedState(new Date())).toBe(true); - expect(parseProvisionedState(null)).toBe(false); - expect(parseProvisionedState(undefined)).toBe(false); - expect(parseProvisionedState(NaN)).toBe(false); - }); - }); - - // ── Mount/unmount lifecycle ──────────────────────────────────────────────── - - test('calls startEventReception on mount', () => { - renderComponent('root/cfg/cfg1'); - expect(localStorage.getItem).toHaveBeenCalledWith('authToken'); - expect(startEventReception).toHaveBeenCalledWith('mock-token', [ - "ObjectStatusUpdated,path=root/cfg/cfg1", - "InstanceStatusUpdated,path=root/cfg/cfg1", - "ObjectDeleted,path=root/cfg/cfg1", - "InstanceMonitorUpdated,path=root/cfg/cfg1", - "InstanceConfigUpdated,path=root/cfg/cfg1", - "CONNECTION_OPENED", "CONNECTION_ERROR", "RECONNECTION_ATTEMPT", - "MAX_RECONNECTIONS_REACHED", "CONNECTION_CLOSED" - ]); + test.each([ + ['true', true], ['True', true], ['TRUE', true], ['tRuE', true], + ['false', false], ['False', false], ['FALSE', false], ['fAlSe', false], + ['yes', false], ['no', false], ['', false], ['abc', false], ['1', false], ['0', false], + [true, true], [false, false], [1, true], [0, false], [42, true], [-1, true], + [{}, true], [{state: true}, true], [[], true], [new Date(), true], + [null, false], [undefined, false], [NaN, false], + ])('%p => %p', (input, expected) => expect(parseProvisionedState(input)).toBe(expected)); }); - test('calls closeEventSource on unmount', () => { + test('mount/unmount lifecycle', () => { const {unmount} = renderComponent('root/cfg/cfg1'); + expect(localStorage.getItem).toHaveBeenCalledWith('authToken'); + expect(startEventReception).toHaveBeenCalled(); unmount(); expect(closeEventSource).toHaveBeenCalled(); }); - test('handles component unmount during async operations gracefully', async () => { + test('unmount during async fetch', async () => { let resolveFetch; global.fetch.mockImplementationOnce(() => new Promise(r => { resolveFetch = r; @@ -545,44 +507,13 @@ describe('ObjectDetail Component', () => { await new Promise(r => setTimeout(r, 100)); }); - // ── Rendering ───────────────────────────────────────────────────────────── - - test('renders object name in mock component', async () => { - require('react-router-dom').useParams.mockReturnValue({objectName: 'root/cfg/cfg1'}); - const MockObjectDetail = () => { - const {useParams} = require('react-router-dom'); - const {objectName} = useParams(); - return ( -
- {decodeURIComponent(objectName)} - No information available for object -
- ); - }; - render( - - }/> - - ); - await waitFor(() => expect(screen.getByText(/root\/cfg\/cfg1/)).toBeInTheDocument()); - await waitFor(() => expect(screen.getByText(/No information available for object/i)).toBeInTheDocument()); - }, 10000); - - test('renders nodes and monitor state for svc kind', async () => { + test('renders svc with nodes and monitor', async () => { renderSvc(); - const objectNames = await screen.findAllByText(/root\/svc\/svc1/i); - expect(objectNames.length).toBeGreaterThan(0); - await waitFor(() => expect(screen.getByText('node1')).toBeInTheDocument(), {timeout: 10000}); - await waitFor(() => expect(screen.getByText('node2')).toBeInTheDocument(), {timeout: 10000}); - await waitFor(() => expect(screen.getByText(/running/i)).toBeInTheDocument(), {timeout: 10000}); - await waitFor(() => expect(screen.getByText(/placed@node1/i)).toBeInTheDocument(), {timeout: 10000}); + await waitForNode('node1'); + await waitForNode('node2'); + expect(screen.getByText(/running/i)).toBeInTheDocument(); + expect(screen.getByText(/placed@node1/i)).toBeInTheDocument(); expect(screen.queryByText(/Resources \(\d+\)/i)).not.toBeInTheDocument(); - }, 15000); - - test('shows loading state then resolves', async () => { - renderComponent('root/cfg/cfg1'); - // Loading may appear briefly for cfg kind with empty state - just verify body has content - await waitFor(() => expect(document.body.textContent).toBeTruthy(), {timeout: 5000}); }); test('shows no data message when object data is empty', async () => { @@ -590,108 +521,79 @@ describe('ObjectDetail Component', () => { objectStatus: {}, objectInstanceStatus: {}, instanceMonitor: {}, instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), })); + global.fetch.mockImplementation((url) => { + if (url.includes('/data/keys')) return Promise.resolve({ + ok: true, + json: () => Promise.resolve({items: []}) + }); + return Promise.resolve({ok: true, json: () => Promise.resolve({}), text: () => ''}); + }); renderComponent('root/cfg/cfg1'); await waitFor(() => { - const noInfo = screen.queryByText(/No information available for object\./i); - if (noInfo) { - expect(noInfo).toBeInTheDocument(); - expect(screen.queryByTestId('open-config-dialog')).toBeInTheDocument(); - } else { - expect(document.body.textContent).toBeTruthy(); - } - }, {timeout: 5000}); + expect(screen.getByText(/No keys available/i)).toBeInTheDocument(); + }); }); - test('does not render node cards or batch actions for cfg kind', async () => { + test('cfg kind hides node cards and batch actions', async () => { renderComponent('root/cfg/cfg1'); - await screen.findByText(/root\/cfg\/cfg1/i, {selector: 'span[font-weight="bold"]'}); + await screen.findByText(/root\/cfg\/cfg1/i); await waitFor(() => { expect(document.querySelectorAll('[class*="MuiCard"], [role="region"][class*="node"]')).toHaveLength(0); expect(screen.queryByRole('button', {name: /Actions on Selected Nodes/i})).not.toBeInTheDocument(); - }, {timeout: 10000}); - }, 15000); - - test('does not render batch actions for sec and usr kinds', async () => { - for (const [path, objectName] of [['root/sec/sec1', 'root/sec/sec1'], ['root/usr/usr1', 'root/usr/usr1']]) { - jest.clearAllMocks(); - useEventStore.mockImplementation(s => s({ - objectStatus: {[objectName]: {avail: 'up', frozen: null}}, - objectInstanceStatus: {[objectName]: {node1: {avail: 'up', resources: {}}}}, - instanceMonitor: {}, instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), - })); - renderComponent(objectName); - await waitFor(() => expect(screen.getByText(new RegExp(objectName.replace(/\//g, '\\/'), 'i'))).toBeInTheDocument(), {timeout: 5000}); - await waitFor(() => expect(screen.queryByRole('button', {name: /Actions on Selected Nodes/i})).not.toBeInTheDocument(), {timeout: 5000}); - } + }); }); - test('displays warn status color for object', async () => { - const warnState = buildState(); - warnState.objectStatus['root/svc/svc1'].avail = 'warn'; - useEventStore.mockImplementation(s => s(warnState)); + test.each([['root/sec/sec1'], ['root/usr/usr1']])('batch actions hidden for %s', async (objectName) => { + useEventStore.mockImplementation(s => s({ + objectStatus: {[objectName]: {avail: 'up', frozen: null}}, + objectInstanceStatus: {[objectName]: {node1: {avail: 'up', resources: {}}}}, + instanceMonitor: {}, instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), + })); + renderComponent(objectName); + await screen.findByText(new RegExp(objectName.replace(/\//g, '\\/'), 'i')); + expect(screen.queryByRole('button', {name: /Actions on Selected Nodes/i})).not.toBeInTheDocument(); + }); + + test('warn status color', async () => { + const state = buildState(); + state.objectStatus['root/svc/svc1'].avail = 'warn'; + useEventStore.mockImplementation(s => s(state)); renderSvc(); await waitFor(() => expect(screen.getByTitle('warn')).toBeInTheDocument()); }); - // ── Config fetch ─────────────────────────────────────────────────────────── - - test('fetches config on mount and opens config dialog', async () => { + test('config fetch opens dialog', async () => { renderComponent('root/cfg/cfg1'); - const configButton = await screen.findByTestId('open-config-dialog'); - fireEvent.click(configButton); - await waitFor(() => expect(screen.getByTestId('config-dialog')).toBeInTheDocument(), {timeout: 10000}); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/node/name/node1/instance/path/root/cfg/cfg1/config/file'), - expect.any(Object) - ); - }, 15000); - - test('fetches config from first node for svc kind', async () => { - renderSvc(); - const configButton = await screen.findByTestId('open-config-dialog'); - fireEvent.click(configButton); - await waitFor(() => expect(screen.getByTestId('config-dialog')).toBeInTheDocument(), {timeout: 5000}); - await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/config/file'), - expect.any(Object) - ), {timeout: 5000}); - }, 10000); + fireEvent.click(await screen.findByTestId('open-config-dialog')); + await waitFor(() => expect(screen.getByTestId('config-dialog')).toBeInTheDocument()); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/config/file'), expect.any(Object)); + }); + test('fetches config from first node for svc', async () => { + renderSvc(); + fireEvent.click(await screen.findByTestId('open-config-dialog')); + await waitFor(() => expect(screen.getByTestId('config-dialog')).toBeInTheDocument()); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/config/file'), expect.any(Object))); + }); test('handles fetchConfig HTTP error response', async () => { global.fetch.mockImplementation((url) => { - if (url.includes('/config/file')) { - return Promise.resolve({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve('Internal Server Error') - }); - } - return Promise.resolve({ - ok: true, - text: () => Promise.resolve(''), - json: () => Promise.resolve({items: []}) + if (url.includes('/config/file')) return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => 'Internal Server Error' }); + return Promise.resolve({ok: true, text: () => '', json: () => ({items: []})}); }); renderSvc(); - await waitForObjectName(); - await waitFor(() => { - const errorText = screen.queryByText(/Failed to fetch config: HTTP error! status: 500/i); - if (errorText) expect(errorText).toBeInTheDocument(); - else expect(screen.getByText(/root\/svc\/svc1/i)).toBeInTheDocument(); - }, {timeout: 5000}); + await waitFor(() => expect(screen.getAllByText(/root\/svc\/svc1/i).length).toBeGreaterThan(0)); }); test('handles fetchConfig with no auth token', async () => { mockLocalStorage.getItem.mockReturnValue(null); renderSvc(); - await waitForObjectName(); - await waitFor(() => { - const errorText = screen.queryByText(/Auth token not found/i); - if (errorText) expect(errorText).toBeInTheDocument(); - else expect(screen.getByText(/root\/svc\/svc1/i)).toBeInTheDocument(); - }, {timeout: 5000}); + await waitFor(() => expect(screen.getAllByText(/root\/svc\/svc1/i).length).toBeGreaterThan(0)); }); test('loads initial config when node has encap resources', async () => { @@ -711,32 +613,17 @@ describe('ObjectDetail Component', () => { instanceMonitor: {}, instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), })); renderSvc(); - await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/config/file'), - expect.any(Object) - )); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/config/file'), expect.any(Object))); }); - test('handles fetchConfig with no nodes available', async () => { - useEventStore.mockImplementation(s => s({ - objectStatus: {'root/svc/svc1': {avail: 'up', frozen: null}}, - objectInstanceStatus: {'root/svc/svc1': {}}, - instanceMonitor: {}, instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), - })); - renderSvc(); - await waitFor(() => expect(screen.getAllByText(/root\/svc\/svc1/i).length).toBeGreaterThan(0)); - }); - - // ── Keys section ─────────────────────────────────────────────────────────── - - test('renders keys section for cfg kind', async () => { + test('renders keys section for cfg', async () => { renderComponent('root/cfg/cfg1'); - await waitFor(() => expect(screen.getByText(/Keys/i)).toBeInTheDocument(), {timeout: 5000}); - await waitFor(() => expect(screen.getByText('key1')).toBeInTheDocument(), {timeout: 5000}); - await waitFor(() => expect(screen.getByText('key2')).toBeInTheDocument(), {timeout: 5000}); - }, 10000); + expect(await screen.findByText(/Keys/i)).toBeInTheDocument(); + expect(await screen.findByText('key1')).toBeInTheDocument(); + expect(screen.getByText('key2')).toBeInTheDocument(); + }); - test('displays no keys message when keys array is empty', async () => { + test('displays no keys message when empty', async () => { global.fetch.mockImplementation((url) => { if (url.includes('/data/keys')) return Promise.resolve({ ok: true, @@ -745,38 +632,31 @@ describe('ObjectDetail Component', () => { return Promise.resolve({ok: true, text: () => Promise.resolve(''), json: () => Promise.resolve({})}); }); renderComponent('root/cfg/cfg1'); - await waitFor(() => expect(screen.getByText(/No keys available/i)).toBeInTheDocument(), {timeout: 5000}); + expect(await screen.findByText(/No keys available/i)).toBeInTheDocument(); }); - // ── Node interactions ────────────────────────────────────────────────────── - - test('handles node selection toggle (checkbox)', async () => { - renderSvc(); - await waitForNode('node1'); - const node1Checkbox = screen.getByLabelText(/select node node1/i); - expect(node1Checkbox.checked).toBe(false); - await user.click(node1Checkbox); - expect(node1Checkbox.checked).toBe(true); - await user.click(node1Checkbox); - expect(node1Checkbox.checked).toBe(false); + test('node selection toggle', async () => { + await renderReadySvc(); + const checkbox = screen.getByLabelText(/select node node1/i); + expect(checkbox.checked).toBe(false); + await user.click(checkbox); + expect(checkbox.checked).toBe(true); + await user.click(checkbox); + expect(checkbox.checked).toBe(false); }); test('disables batch actions button when no nodes are selected', async () => { - renderSvc(); - await waitForNode('node1'); + await renderReadySvc(); expect(screen.getByRole('button', {name: /Actions on selected nodes/i}).disabled).toBe(true); }); - test('handles view instance navigation on node click', async () => { - renderSvc(); - await waitForNode('node1'); - const nodeText = screen.getByText('node1'); - const card = nodeText.closest('div[role="region"]') || nodeText.closest('div'); - fireEvent.click(card || nodeText); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/nodes/node1/objects/root%2Fsvc%2Fsvc1'), {timeout: 5000}); + test('view instance navigation on node click', async () => { + await renderReadySvc(); + fireEvent.click(screen.getByText('node1')); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/nodes/node1/objects/root%2Fsvc%2Fsvc1')); }); - test('handles getNodeState with frozen node', async () => { + test('frozen node state display', async () => { useEventStore.mockImplementation(s => s({ objectStatus: {'root/svc/svc1': {avail: 'up', frozen: null}}, objectInstanceStatus: { @@ -792,26 +672,7 @@ describe('ObjectDetail Component', () => { instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), })); renderSvc(); - await waitFor(() => expect(screen.getByText('node1')).toBeInTheDocument(), {timeout: 10000}); - }); - - test('handles getObjectStatus with global_expect on second node', async () => { - useEventStore.mockImplementation(s => s({ - objectStatus: {'root/svc/svc1': {avail: 'up', frozen: null}}, - objectInstanceStatus: { - 'root/svc/svc1': { - node1: {avail: 'up', resources: {}}, - node2: {avail: 'down', resources: {}} - } - }, - instanceMonitor: { - 'node1:root/svc/svc1': {state: 'idle', global_expect: 'none'}, - 'node2:root/svc/svc1': {state: 'running', global_expect: 'placed@node2'}, - }, - instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), - })); - renderSvc(); - await waitFor(() => expect(screen.queryAllByText(/node/).length).toBeGreaterThan(0), {timeout: 10000}); + await waitForNode('node1'); }); test('getObjectStatus handles missing global_expect (none)', async () => { @@ -821,215 +682,141 @@ describe('ObjectDetail Component', () => { instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), })); renderComponent('root/cfg/cfg1'); - await waitFor(() => expect(screen.queryByText(/placed@node1/i)).not.toBeInTheDocument(), {timeout: 10000}); - }, 15000); - - // ── Batch node actions ───────────────────────────────────────────────────── - - test('handles batch node actions: select nodes and execute start', async () => { - renderSvc(); - await waitFor(() => expect(screen.getByText('node1')).toBeInTheDocument(), {timeout: 15000}); - await waitFor(() => expect(screen.getByText('node2')).toBeInTheDocument(), {timeout: 15000}); + await waitFor(() => expect(screen.queryByText(/placed@node1/i)).not.toBeInTheDocument()); + }); + test('batch node actions: select nodes and execute start', async () => { + await renderReadySvc(); await user.click(screen.getByLabelText(/select node node1/i)); await user.click(screen.getByLabelText(/select node node2/i)); - const batchBtn = screen.getByRole('button', {name: /Actions on selected nodes/i}); expect(batchBtn).not.toBeDisabled(); await user.click(batchBtn); - - await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0), {timeout: 10000}); - const menus = await screen.findAllByRole('menu'); - const startAction = within(menus[0]).getAllByRole('menuitem').find(i => i.textContent.match(/Start/i)); - await user.click(startAction); - - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument(), {timeout: 10000}); - const dialog = screen.getAllByRole('dialog')[0]; - const checkbox = within(dialog).queryByRole('checkbox', {name: /confirm/i}); - if (checkbox) await user.click(checkbox); - await user.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept|add/i})); - - await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/action/start'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({Authorization: 'Bearer mock-token'}) - }) - ), {timeout: 20000}); - }, 35000); + await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0)); + const menu = screen.getAllByRole('menu')[0]; + await user.click(within(menu).getByRole('menuitem', {name: /start/i})); + const dialog = await screen.findByRole('dialog'); + const confirmCheckbox = within(dialog).queryByRole('checkbox', {name: /confirm/i}); + if (confirmCheckbox) await user.click(confirmCheckbox); + await user.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept/i})); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/action/start'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({Authorization: 'Bearer mock-token'}) + }) + ); + }); + }); test('batch actions menu closes after item click', async () => { - renderSvc(); - await waitForNode('node1'); + await renderReadySvc(); await user.click(screen.getByLabelText(/select node node1/i)); await user.click(screen.getByRole('button', {name: /Actions on selected nodes/i})); - await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0), {timeout: 5000}); - const menus = screen.getAllByRole('menu'); - await user.click(within(menus[0]).getAllByRole('menuitem')[0]); + await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0)); + await user.click(within(screen.getAllByRole('menu')[0]).getAllByRole('menuitem')[0]); await waitFor(() => { const dialogs = screen.queryAllByRole('dialog'); const menusAfter = screen.queryAllByRole('menu'); expect(dialogs.length > 0 || menusAfter.length === 0).toBe(true); - }, {timeout: 5000}); + }); }); - // ── Individual node actions ──────────────────────────────────────────────── - - test('handles individual node stop action', async () => { - renderSvc(); - await waitFor(() => expect(screen.getByText('node1')).toBeInTheDocument(), {timeout: 10000}); - + test('individual node stop action', async () => { + await renderReadySvc(); await user.click(screen.getByRole('button', {name: /Node node1 actions/i})); - await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0), {timeout: 10000}); - - const menus = await screen.findAllByRole('menu'); - await user.click(within(menus[0]).getAllByRole('menuitem').find(i => i.textContent.match(/Stop/i))); - - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument(), {timeout: 10000}); - const dialog = screen.getAllByRole('dialog')[0]; - const checkbox = within(dialog).queryByRole('checkbox', {name: /confirm/i}); - if (checkbox) await user.click(checkbox); - await user.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept|add/i})); - - await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/action/stop'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({Authorization: 'Bearer mock-token'}) - }) - ), {timeout: 20000}); - }, 35000); - - test('handles post action with no token (node action)', async () => { - mockLocalStorage.getItem.mockReturnValue(null); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getAllByRole('button', {name: /Node node1 actions/i})[0]); - const menus = await screen.findAllByRole('menu'); - await user.click(within(menus[0]).getByRole('menuitem', {name: /Start/i})); + await waitFor(() => expect(screen.queryAllByRole('menu').length).toBeGreaterThan(0)); + const menu = screen.getAllByRole('menu')[0]; + await user.click(within(menu).getByRole('menuitem', {name: /stop/i})); const dialog = await screen.findByRole('dialog'); - await user.click(within(dialog).getByRole('button', {name: /confirm/i})); + const confirmCheckbox = within(dialog).queryByRole('checkbox', {name: /confirm/i}); + if (confirmCheckbox) await user.click(confirmCheckbox); + await user.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept/i})); await waitFor(() => { - const alerts = screen.getAllByRole('alert'); - expect(alerts.find(a => a.textContent.includes('Auth token not found'))).toBeInTheDocument(); - }); - }, 15000); - - // ── Object-level actions ─────────────────────────────────────────────────── - - test('postObjectAction: handles no token', async () => { - mockLocalStorage.getItem.mockReturnValue(null); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(screen.getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => { - const alerts = screen.queryAllByRole('alert'); - expect(alerts.length).toBeGreaterThan(0); - expect(alerts.find(a => a.textContent.includes('Auth token not found'))).toBeInTheDocument(); - }); - }); - - test('postObjectAction: handles non-ok HTTP response', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/action/')) { - return Promise.resolve({ok: false, status: 403, text: () => Promise.resolve('Forbidden')}); - } - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/node/name/node1/instance/path/root/svc/svc1/action/stop'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({Authorization: 'Bearer mock-token'}) + }) + ); }); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(screen.getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => { - expect(screen.getAllByRole('alert').find(a => a.textContent.includes('HTTP error! status: 403'))).toBeInTheDocument(); - }, {timeout: 5000}); }); - test('postObjectAction: handles fetch exception', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/action/')) return Promise.reject(new Error('Network error')); - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + describe.each([ + { + label: 'object', + openMenu: async () => { + await userEvent.click(screen.getByRole('button', {name: /object actions/i})); + }, + }, + { + label: 'node', + openMenu: async () => { + await userEvent.click(screen.getByRole('button', {name: /Node node1 actions/i})); + }, + }, + ])('$label actions', ({openMenu}) => { + test('fetch exception', async () => { + mockNetworkFailure('/action/'); + await renderReadySvc(); + await openMenu(); + await userEvent.click(screen.getByRole('menuitem', {name: /start/i})); + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept/i})); + await waitFor(() => { + expect(screen.getAllByRole('alert').some(a => a.textContent.includes('Network error'))).toBe(true); + }); }); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(screen.getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => { - expect(screen.getAllByRole('alert').find(a => a.textContent.includes('Network error'))).toBeInTheDocument(); - }, {timeout: 5000}); - }); - test('postNodeAction: handles non-ok HTTP response', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/action/')) { - return Promise.resolve({ok: false, status: 500, text: () => Promise.resolve('Server error')}); - } - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + test.each([[403, 'Forbidden'], [500, 'Server error']])('HTTP %i', async (status, msg) => { + mockActionFailure(status, msg); + await renderReadySvc(); + await openMenu(); + await userEvent.click(screen.getByRole('menuitem', {name: /start/i})); + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', {name: /confirm|submit|ok|execute|apply|proceed|accept/i})); + await waitFor(() => { + expect(screen.getAllByRole('alert').some(a => a.textContent.includes(`HTTP error! status: ${status}`))).toBe(true); + }); }); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /node node1 actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(within(screen.getByRole('menu')).getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => { - expect(screen.getAllByRole('alert').find(a => a.textContent.includes('HTTP error! status: 500'))).toBeInTheDocument(); - }, {timeout: 5000}); - }); - test('postNodeAction: handles fetch exception', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/action/')) return Promise.reject(new Error('Network error')); - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); + test('missing auth token', async () => { + mockLocalStorage.getItem.mockReturnValue(null); + await renderReadySvc(); + await openMenu(); + await userEvent.click(screen.getByRole('menuitem', {name: /start/i})); + const dialog = await screen.findByRole('dialog'); + await userEvent.click(within(dialog).getByRole('button', {name: /confirm/i})); + await waitFor(() => { + expect(screen.getAllByRole('alert').some(a => a.textContent.includes('Auth token not found'))).toBe(true); + }); }); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /node node1 actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(within(screen.getByRole('menu')).getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => { - expect(screen.getAllByRole('alert').find(a => a.textContent.includes('Network error'))).toBeInTheDocument(); - }, {timeout: 5000}); }); - // ── Dialog interactions ──────────────────────────────────────────────────── - test('dialog cancel closes without action', async () => { - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), {timeout: 5000}); - await user.click(within(screen.getAllByRole('menu')[0]).getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument(), {timeout: 5000}); - const cancelButton = within(screen.getByRole('dialog')).queryByRole('button', {name: /cancel/i}); + await renderReadySvc(); + await userEvent.click(screen.getByRole('button', {name: /object actions/i})); + await screen.findByRole('menu'); + await userEvent.click(screen.getByRole('menuitem', {name: /start/i})); + const dialog = await screen.findByRole('dialog'); + const cancelButton = within(dialog).queryByRole('button', {name: /cancel/i}); if (cancelButton) { - await user.click(cancelButton); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), {timeout: 5000}); + await userEvent.click(cancelButton); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); } }); - test('all object action dialogs (freeze/stop/unprovision/purge) open and cancel', async () => { + test('all object action dialogs open and cancel', async () => { useEventStore.mockImplementation(s => s(buildState())); renderSvc(); for (const action of ['freeze', 'stop', 'unprovision', 'purge']) { await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + await screen.findByRole('menu'); await user.click(screen.getByRole('menuitem', {name: new RegExp(action, 'i')})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - const cancelBtn = within(screen.getByRole('dialog')).queryByRole('button', {name: /cancel/i}); + const dialog = await screen.findByRole('dialog'); + const cancelBtn = within(dialog).queryByRole('button', {name: /cancel/i}); if (cancelBtn) { await user.click(cancelBtn); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); @@ -1043,21 +830,19 @@ describe('ObjectDetail Component', () => { const manageBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('Manage')); if (!manageBtn) return; await user.click(manageBtn); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('button', {name: /confirm/i})); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - // ── Logs drawer ──────────────────────────────────────────────────────────── - test('logs drawer resize with mouse events adds/removes listeners', async () => { const addSpy = jest.spyOn(document, 'addEventListener'); const removeSpy = jest.spyOn(document, 'removeEventListener'); renderSvc(); await waitForNode('node1'); - await openLogsDrawer(user); + const logsBtn = screen.getAllByRole('button', {name: /logs/i})[0]; + await user.click(logsBtn); await waitFor(() => expect(screen.getByLabelText('Resize drawer')).toBeInTheDocument()); - const handle = screen.getByLabelText('Resize drawer'); fireEvent.mouseDown(handle, {clientX: 100}); expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); @@ -1067,30 +852,27 @@ describe('ObjectDetail Component', () => { expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function)); expect(removeSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)); expect(document.body.style.cursor).toBe('default'); - addSpy.mockRestore(); removeSpy.mockRestore(); }); - test('logs drawer resize with touch events adds/removes listeners', async () => { + test('logs drawer resize with touch events', async () => { const addSpy = jest.spyOn(document, 'addEventListener'); const removeSpy = jest.spyOn(document, 'removeEventListener'); renderSvc(); await waitForNode('node1'); - await openLogsDrawer(user); + const logsBtn = screen.getAllByRole('button', {name: /logs/i})[0]; + await user.click(logsBtn); await waitFor(() => expect(screen.getByLabelText('Resize drawer')).toBeInTheDocument()); - const handle = screen.getByLabelText('Resize drawer'); fireEvent.touchStart(handle, {touches: [{clientX: 100}]}); - const touchMoveCall = addSpy.mock.calls.find(c => c[0] === 'touchmove' && c[2]?.passive === false); - expect(touchMoveCall).toBeDefined(); + expect(addSpy.mock.calls.find(c => c[0] === 'touchmove' && c[2]?.passive === false)).toBeDefined(); expect(addSpy).toHaveBeenCalledWith('touchend', expect.any(Function)); fireEvent.touchMove(document, {touches: [{clientX: 150}]}); fireEvent.touchEnd(document); expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function)); expect(removeSpy).toHaveBeenCalledWith('touchend', expect.any(Function)); expect(document.body.style.cursor).toBe('default'); - addSpy.mockRestore(); removeSpy.mockRestore(); }); @@ -1099,8 +881,9 @@ describe('ObjectDetail Component', () => { Object.defineProperty(window, 'innerWidth', {writable: true, configurable: true, value: 1000}); renderSvc(); await waitForNode('node1'); - await openLogsDrawer(user); - await waitFor(() => expect(screen.getByLabelText('Resize drawer')).toBeInTheDocument(), {timeout: 5000}); + const logsBtn = screen.getAllByRole('button', {name: /logs/i})[0]; + await user.click(logsBtn); + await waitFor(() => expect(screen.getByLabelText('Resize drawer')).toBeInTheDocument()); const handle = screen.getByLabelText('Resize drawer'); fireEvent.mouseDown(handle, {clientX: 100}); fireEvent.mouseMove(document, {clientX: 50}); @@ -1111,15 +894,9 @@ describe('ObjectDetail Component', () => { Object.defineProperty(window, 'innerWidth', {writable: true, configurable: true, value: 1024}); }); - // ── Config updates subscription ──────────────────────────────────────────── - test('config updates subscription: update without node skips fetchConfig', async () => { - let configCb; + const sub = captureSubscription(); let fetchCount = 0; - useEventStore.subscribe = jest.fn((sel, cb) => { - if (sel.toString().includes('configUpdates')) configCb = cb; - return jest.fn(); - }); global.fetch = jest.fn((url) => { if (url.includes('/config/file')) fetchCount++; return Promise.resolve({ @@ -1131,57 +908,40 @@ describe('ObjectDetail Component', () => { renderSvc(); await waitForNode('node1'); const before = fetchCount; - if (configCb) { - await act(async () => { - await configCb([{name: 'svc1', fullName: 'root/svc/svc1'}]); - }); - } + await act(async () => { + await sub.callback([{name: 'svc1', fullName: 'root/svc/svc1'}]); + }); expect(fetchCount).toBe(before); }); test('config updates subscription: non-matching name does not call clearConfigUpdate', async () => { const clearConfigUpdate = jest.fn(); useEventStore.mockImplementation(s => s(buildState({clearConfigUpdate}))); - let configCb; - useEventStore.subscribe = jest.fn((sel, cb) => { - if (sel.toString().includes('configUpdates')) configCb = cb; - return jest.fn(); - }); + const sub = captureSubscription(); renderSvc(); await waitForNode('node1'); - if (configCb) { - await act(async () => { - await configCb([{name: 'other', fullName: 'root/svc/other', node: 'n1'}]); - }); - } + await act(async () => { + await sub.callback([{name: 'other', fullName: 'root/svc/other', node: 'n1'}]); + }); expect(clearConfigUpdate).not.toHaveBeenCalled(); }); test('config updates subscription: unmounted component causes early return', async () => { - let configCb; - useEventStore.subscribe = jest.fn((sel, cb) => { - if (sel.toString().includes('configUpdates')) configCb = cb; - return jest.fn(); - }); + const sub = captureSubscription(); const {unmount} = renderSvc(); await waitForNode('node1'); unmount(); - if (configCb) { + if (sub.callback) { await act(async () => { - await configCb([{name: 'svc1', fullName: 'root/svc/svc1', node: 'n1'}]).catch(() => { - }); + sub.callback([{name: 'svc1', fullName: 'root/svc/svc1', node: 'n1'}]); }); } }); - test('config updates subscription: isProcessingConfigUpdate deduplicates concurrent calls', async () => { - let configCb; + test('config updates subscription: isProcessingConfigUpdate deduplicates', async () => { + const sub = captureSubscription(); let fetchCount = 0; let resolveFirst; - useEventStore.subscribe = jest.fn((sel, cb) => { - if (sel.toString().includes('configUpdates')) configCb = cb; - return jest.fn(); - }); global.fetch = jest.fn((url) => { if (url.includes('/config/file')) { fetchCount++; @@ -1194,16 +954,12 @@ describe('ObjectDetail Component', () => { }); renderSvc(); await waitForNode('node1'); - await waitFor(() => expect(fetchCount).toBe(1), {timeout: 3000}); - if (configCb) { - act(() => { - configCb([{name: 'svc1', fullName: 'root/svc/svc1', node: 'node2'}]); - }); - act(() => { - configCb([{name: 'svc1', fullName: 'root/svc/svc1', node: 'node2'}]); - }); - } - if (resolveFirst) resolveFirst({ok: true, text: () => Promise.resolve('[DEFAULT]')}); + await waitFor(() => expect(fetchCount).toBe(1)); + act(() => { + sub.callback([{name: 'svc1', fullName: 'root/svc/svc1', node: 'node2'}]); + sub.callback([{name: 'svc1', fullName: 'root/svc/svc1', node: 'node2'}]); + }); + resolveFirst({ok: true, text: () => Promise.resolve('[DEFAULT]')}); await act(async () => { await new Promise(r => setTimeout(r, 200)); }); @@ -1216,11 +972,7 @@ describe('ObjectDetail Component', () => { objectInstanceStatus: {'root/svc/svc1': {node1: {avail: 'up', resources: {}}}}, configUpdates: [], clearConfigUpdate, })); - let configUpdatesCb; - useEventStore.subscribe = jest.fn((sel, cb) => { - if (sel.toString().includes('configUpdates')) configUpdatesCb = cb; - return jest.fn(); - }); + const sub = captureSubscription(); let fetchCallCount = 0; global.fetch.mockImplementation((url) => { fetchCallCount++; @@ -1237,25 +989,21 @@ describe('ObjectDetail Component', () => { }); renderSvc(); await waitForNode('node1'); - await waitFor(() => expect(fetchCallCount).toBeGreaterThan(0), {timeout: 5000}); - if (configUpdatesCb) { - await act(async () => { - await configUpdatesCb([{ - name: 'svc1', - fullName: 'root/svc/svc1', - node: 'node2', - type: 'InstanceConfigUpdated' - }]); - }); - await waitFor(() => { - const alerts = screen.queryAllByRole('alert'); - const errAlert = alerts.find(a => - a.textContent.includes('Failed to load updated configuration') || a.getAttribute('data-severity') === 'error' - ); - if (errAlert) expect(errAlert).toBeInTheDocument(); - expect(clearConfigUpdate).toHaveBeenCalled(); - }, {timeout: 10000}); - } + await waitFor(() => expect(fetchCallCount).toBeGreaterThan(0)); + await act(async () => { + await sub.callback([{ + name: 'svc1', + fullName: 'root/svc/svc1', + node: 'node2', + type: 'InstanceConfigUpdated' + }]); + }); + await waitFor(() => { + const alerts = screen.queryAllByRole('alert'); + const errAlert = alerts.find(a => a.textContent.includes('Failed to load updated configuration') || a.getAttribute('data-severity') === 'error'); + if (errAlert) expect(errAlert).toBeInTheDocument(); + expect(clearConfigUpdate).toHaveBeenCalled(); + }); }); test('configUpdates subscription: selector captures state.configUpdates correctly', async () => { @@ -1279,19 +1027,13 @@ describe('ObjectDetail Component', () => { }); test('handles config updates subscription triggered by store', async () => { - const mockSubscribe = jest.fn(); useEventStore.subscribe = jest.fn((selector, callback) => { callback([{name: 'cfg1', fullName: 'root/cfg/cfg1', type: 'InstanceConfigUpdated', node: 'node1'}]); - return mockSubscribe; + return jest.fn(); }); renderComponent('root/cfg/cfg1'); - await waitFor(() => expect(screen.getByText(/Configuration updated/i)).toBeInTheDocument(), {timeout: 10000}); - const {unmount} = renderComponent('root/cfg/cfg1'); - unmount(); - expect(mockSubscribe).toHaveBeenCalled(); - }, 20000); - - // ── Instance config subscription ─────────────────────────────────────────── + await waitFor(() => expect(screen.getByText(/Configuration updated/i)).toBeInTheDocument()); + }); test('instanceConfig subscription triggers snackbar', async () => { useEventStore.mockImplementation(s => s({ @@ -1306,41 +1048,26 @@ describe('ObjectDetail Component', () => { return jest.fn(); }); renderSvc(); - if (instanceConfigCallback) { - act(() => { - instanceConfigCallback({'root/svc/svc1': {node1: {resources: {res1: {is_monitored: false}}}}}); - }); - } - await waitFor(() => { - const alerts = screen.queryAllByRole('alert'); - expect(alerts.find(a => a.textContent?.includes('Instance configuration updated'))).toBeInTheDocument(); - }); - }); - - // ── Subscription error handling ──────────────────────────────────────────── - - test('configUpdates subscription error triggers logger.warn', async () => { - const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); - useEventStore.subscribe = jest.fn(() => { - throw new Error('Subscription failed'); + act(() => { + instanceConfigCallback({'root/svc/svc1': {node1: {resources: {res1: {is_monitored: false}}}}}); }); - renderSvc(); - await waitForObjectName(); await waitFor(() => { - expect(warnSpy).toHaveBeenCalledWith('[ObjectDetail] Failed to subscribe to configUpdates:', expect.any(Error)); + expect(screen.queryAllByRole('alert').find(a => a.textContent?.includes('Instance configuration updated'))).toBeInTheDocument(); }); - warnSpy.mockRestore(); }); - test('instanceConfig subscription error triggers logger.warn', async () => { + test.each([ + ['configUpdates', '[ObjectDetail] Failed to subscribe to configUpdates:'], + ['instanceConfig', '[ObjectDetail] Failed to subscribe to instanceConfig:'], + ])('%s subscription error triggers logger.warn', async (subName, expectedMsg) => { const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); useEventStore.subscribe = jest.fn(() => { - throw new Error('InstanceConfig subscription failed'); + throw new Error('Subscription failed'); }); renderSvc(); - await waitForObjectName(); + await screen.findAllByText(/root\/svc\/svc1/i); await waitFor(() => { - expect(warnSpy).toHaveBeenCalledWith('[ObjectDetail] Failed to subscribe to instanceConfig:', expect.any(Error)); + expect(warnSpy).toHaveBeenCalledWith(expectedMsg, expect.any(Error)); }); warnSpy.mockRestore(); }); @@ -1352,32 +1079,21 @@ describe('ObjectDetail Component', () => { await waitForNode('node1'); await waitFor(() => { expect(warnSpy).toHaveBeenCalledWith('[ObjectDetail] Subscription is not a function:', 'not-a-function'); - }, {timeout: 5000}); + }); warnSpy.mockRestore(); - }, 10000); - - // ── Snackbar ─────────────────────────────────────────────────────────────── + }); test('closes snackbar via close button', async () => { - global.fetch.mockResolvedValue({ok: true, status: 200, text: () => Promise.resolve('Success')}); - renderSvc(); - await waitForNode('node1'); - await user.click(screen.getByRole('button', {name: /object actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - await user.click(screen.getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})); - await waitFor(() => expect(screen.queryAllByRole('alert').length).toBeGreaterThan(0), {timeout: 5000}); + await renderReadySvc(); + await executeObjectAction('start'); const closeButtons = screen.getAllByTestId('alert-close-button'); if (closeButtons.length > 0) await user.click(closeButtons[0]); }); - // ── Console dialog ───────────────────────────────────────────────────────── - test('console dialog not shown by default (no console action in INSTANCE_ACTIONS)', async () => { renderSvc(); await waitForNode('node1'); - await waitFor(() => expect(screen.queryByText(/Open Console/i)).not.toBeInTheDocument(), {timeout: 5000}); + await waitFor(() => expect(screen.queryByText(/Open Console/i)).not.toBeInTheDocument()); }); test('handleConsoleConfirm: cancel closes dialog', async () => { @@ -1390,7 +1106,7 @@ describe('ObjectDetail Component', () => { await user.click(cancelBtn); await waitFor(() => { expect(screen.queryAllByRole('dialog').filter(d => d.textContent.includes('terminal console')).length).toBe(0); - }, {timeout: 3000}); + }); } }); }); @@ -1437,11 +1153,10 @@ describe('ObjectDetail Component', () => { const openDialog = await screen.findByRole('dialog'); await user.click(within(openDialog).getByRole('button', {name: /Open Console/i})); await waitFor(() => { - const urlDialog = screen.getByRole('dialog'); - const tabBtn = within(urlDialog).getByRole('button', {name: /Open in New Tab/i}); + const tabBtn = within(screen.getByRole('dialog')).getByRole('button', {name: /Open in New Tab/i}); fireEvent.click(tabBtn); expect(openSpy).toHaveBeenCalled(); - }, {timeout: 8000}); + }); } openSpy.mockRestore(); }); @@ -1455,43 +1170,25 @@ describe('ObjectDetail Component', () => { const openDialog = await screen.findByRole('dialog'); await user.click(within(openDialog).getByRole('button', {name: /Open Console/i})); await waitFor(() => { - const urlDialog = screen.getByRole('dialog'); - fireEvent.click(within(urlDialog).getByRole('button', {name: /Close/i})); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: /Close/i})); expect(screen.queryByText(/Console URL/i)).not.toBeInTheDocument(); - }, {timeout: 8000}); - } - }); - - test('postConsoleAction: handles non-ok HTTP response', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/console')) { - return Promise.resolve({ok: false, status: 500, text: () => Promise.resolve('Server error')}); - } - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); - }); - renderSvc(); - await waitForNode('node1'); - const resourceButtons = screen.queryAllByRole('button', {name: /resource .* actions/i}); - if (resourceButtons.length > 0) { - await user.click(resourceButtons[0]); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); - const consoleItem = screen.queryByRole('menuitem', {name: /console/i}); - if (consoleItem) { - await user.click(consoleItem); - const dialog = await screen.findByRole('dialog'); - await user.click(within(dialog).getByRole('button', {name: /open console/i})); - await waitFor(() => { - expect(screen.getAllByRole('alert').some(a => a.textContent.includes('HTTP error! status: 500'))).toBe(true); - }, {timeout: 5000}); - } + }); } }); - test('postConsoleAction: handles missing Location header', async () => { + test.each([ + ['non-ok HTTP response', (url, options) => options?.method === 'POST' && url.includes('/console'), 'HTTP error! status: 500', { + ok: false, + status: 500, + text: () => Promise.resolve('Server error') + }], + ['missing Location header', (url, options) => options?.method === 'POST' && url.includes('/console'), 'Console URL not found', { + ok: true, + headers: {get: () => null} + }], + ])('postConsoleAction: handles %s', async (label, matchFn, expectedMsg, failResponse) => { global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/console')) { - return Promise.resolve({ok: true, headers: {get: () => null}}); - } + if (matchFn(url, options)) return Promise.resolve(failResponse); return Promise.resolve({ok: true, text: () => Promise.resolve('')}); }); renderSvc(); @@ -1506,17 +1203,14 @@ describe('ObjectDetail Component', () => { const dialog = await screen.findByRole('dialog'); await user.click(within(dialog).getByRole('button', {name: /open console/i})); await waitFor(() => { - expect(screen.getAllByRole('alert').some(a => a.textContent.includes('Console URL not found'))).toBe(true); - }, {timeout: 5000}); + expect(screen.getAllByRole('alert').some(a => a.textContent.includes(expectedMsg))).toBe(true); + }); } } }); test('postConsoleAction: handles fetch exception', async () => { - global.fetch.mockImplementation((url, options) => { - if (options?.method === 'POST' && url.includes('/console')) return Promise.reject(new Error('Network failure')); - return Promise.resolve({ok: true, text: () => Promise.resolve('')}); - }); + mockNetworkFailure('/console'); renderSvc(); await waitForNode('node1'); const resourceButtons = screen.queryAllByRole('button', {name: /resource .* actions/i}); @@ -1530,22 +1224,19 @@ describe('ObjectDetail Component', () => { await user.click(within(dialog).getByRole('button', {name: /open console/i})); await waitFor(() => { expect(screen.getAllByRole('alert').some(a => a.textContent.includes('Network failure'))).toBe(true); - }, {timeout: 5000}); + }); } } }); - // ── Misc ─────────────────────────────────────────────────────────────────── - test('handleIndividualNodeActionClick does not warn in normal flow', async () => { const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); - renderSvc(); - await waitForNode('node1'); + await renderReadySvc(); await user.click(screen.getByRole('button', {name: /Node node1 actions/i})); - await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument(), {timeout: 3000}); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); await user.click(within(screen.getByRole('menu')).getByRole('menuitem', {name: /start/i})); - await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument(), {timeout: 3000}); - await user.click(within(screen.getByRole('dialog')).getByRole('button', {name: /cancel/i})); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('button', {name: /cancel/i})); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); @@ -1557,8 +1248,12 @@ describe('ObjectDetail Component', () => { return unsubscribeMock; }); useEventStore.mockImplementation(sel => sel({ - objectStatus: {}, objectInstanceStatus: {}, instanceMonitor: {}, - instanceConfig: {}, configUpdates: [], clearConfigUpdate: jest.fn(), + objectStatus: {}, + objectInstanceStatus: {}, + instanceMonitor: {}, + instanceConfig: {}, + configUpdates: [], + clearConfigUpdate: jest.fn(), })); renderSvc(); expect(typeof unsubscribeMock).toBe('function'); @@ -1572,6 +1267,6 @@ describe('ObjectDetail Component', () => { clearConfigUpdate, })); renderComponent('root/cfg/cfg1'); - await waitFor(() => expect(clearConfigUpdate).not.toHaveBeenCalled(), {timeout: 10000}); - }, 15000); + await waitFor(() => expect(clearConfigUpdate).not.toHaveBeenCalled()); + }); }); From a2f2fea2bca2679144e8803ebfbab6f389c67578 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Wed, 3 Jun 2026 09:04:42 +0200 Subject: [PATCH 04/24] test(eventSourceManager): refactor test suite for brevity and maintainability - Extract `getHandler` helper to reduce repetitive mock access patterns - Consolidate multiple similar test cases into single, data-driven tests - Simplify `afterEach` state restoration (use `Object.assign` for console) - Remove redundant assertions and duplicated coverage scenarios - Improve test naming and grouping for better readability --- src/tests/eventSourceManager.test.jsx | 1583 ++++++------------------- 1 file changed, 336 insertions(+), 1247 deletions(-) diff --git a/src/tests/eventSourceManager.test.jsx b/src/tests/eventSourceManager.test.jsx index 10e9cf5..29d856f 100644 --- a/src/tests/eventSourceManager.test.jsx +++ b/src/tests/eventSourceManager.test.jsx @@ -4,15 +4,11 @@ import useEventStore from '../hooks/useEventStore.js'; import useEventLogStore from '../hooks/useEventLogStore.js'; import {URL_NODE_EVENT} from '../config/apiPath.js'; -// Mock the external dependencies jest.mock('event-source-polyfill'); jest.mock('../hooks/useEventStore.js'); jest.mock('../hooks/useEventLogStore.js'); - -// Mock timers jest.useFakeTimers(); -// Mock performance.now to control flush timing let mockNow = 0; const originalPerformance = global.performance; beforeAll(() => { @@ -22,166 +18,85 @@ afterAll(() => { global.performance = originalPerformance; }); -// Helper function to simulate shallowEqual logic const mockShallowEqual = (a, b) => { if (a === b) return true; if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; - const keysA = Object.keys(a); - const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; - + if (keysA.length !== Object.keys(b).length) return false; return keysA.every(key => a[key] === b[key]); }; +// Helper: get handler for a given event type from a mock eventSource +const getHandler = (eventSource, eventType) => + eventSource.addEventListener.mock.calls.find(c => c[0] === eventType)[1]; + describe('eventSourceManager', () => { - let mockStore; - let mockLogStore; - let mockEventSource; - let mockLoggerEventSource; - let originalConsole; - let localStorageMock; - let originalDebug; - let originalLocation; + let mockStore, mockLogStore, mockEventSource, mockLoggerEventSource; + let originalConsole, localStorageMock, originalDebug, originalLocation; beforeEach(() => { - // Reset all mocks jest.clearAllMocks(); - // Advance mockNow by a large amount each test so lastFlushTime (module-level, persists - // between tests) is always far enough in the past: now - lastFlushTime >= MIN_FLUSH_INTERVAL(10). - // Using += 1_000_000 guarantees this even if the previous test flushed at the previous mockNow. mockNow += 1_000_000; - // Setup complete mock store with shallowEqual simulation mockStore = { - nodeStatus: {}, - nodeMonitor: {}, - nodeStats: {}, - objectStatus: {}, - objectInstanceStatus: {}, - heartbeatStatus: {}, - instanceMonitor: {}, - instanceConfig: {}, - configUpdates: [], - - // Mock setters with shallowEqual logic - setNodeStatuses: jest.fn((newStatus) => { - if (mockShallowEqual(mockStore.nodeStatus, newStatus)) { - return; // Skip update - } - mockStore.nodeStatus = newStatus; + nodeStatus: {}, nodeMonitor: {}, nodeStats: {}, objectStatus: {}, + objectInstanceStatus: {}, heartbeatStatus: {}, instanceMonitor: {}, + instanceConfig: {}, configUpdates: [], + setNodeStatuses: jest.fn((v) => { + if (!mockShallowEqual(mockStore.nodeStatus, v)) mockStore.nodeStatus = v; }), - - setNodeMonitors: jest.fn((newMonitor) => { - if (mockShallowEqual(mockStore.nodeMonitor, newMonitor)) { - return; - } - mockStore.nodeMonitor = newMonitor; + setNodeMonitors: jest.fn((v) => { + if (!mockShallowEqual(mockStore.nodeMonitor, v)) mockStore.nodeMonitor = v; }), - - setNodeStats: jest.fn((newStats) => { - if (mockShallowEqual(mockStore.nodeStats, newStats)) { - return; - } - mockStore.nodeStats = newStats; + setNodeStats: jest.fn((v) => { + if (!mockShallowEqual(mockStore.nodeStats, v)) mockStore.nodeStats = v; }), - - setObjectStatuses: jest.fn((newStatus) => { - if (mockShallowEqual(mockStore.objectStatus, newStatus)) { - return; - } - mockStore.objectStatus = newStatus; + setObjectStatuses: jest.fn((v) => { + if (!mockShallowEqual(mockStore.objectStatus, v)) mockStore.objectStatus = v; }), - - setInstanceStatuses: jest.fn((newStatus) => { - // For nested object comparison - const currentStr = JSON.stringify(mockStore.objectInstanceStatus); - const newStr = JSON.stringify(newStatus); - if (currentStr === newStr) { - return; - } - mockStore.objectInstanceStatus = newStatus; + setInstanceStatuses: jest.fn((v) => { + if (JSON.stringify(mockStore.objectInstanceStatus) !== JSON.stringify(v)) mockStore.objectInstanceStatus = v; }), - - setHeartbeatStatuses: jest.fn((newStatus) => { - if (mockShallowEqual(mockStore.heartbeatStatus, newStatus)) { - return; - } - mockStore.heartbeatStatus = newStatus; + setHeartbeatStatuses: jest.fn((v) => { + if (!mockShallowEqual(mockStore.heartbeatStatus, v)) mockStore.heartbeatStatus = v; }), - - setInstanceMonitors: jest.fn((newMonitor) => { - if (mockShallowEqual(mockStore.instanceMonitor, newMonitor)) { - return; - } - mockStore.instanceMonitor = newMonitor; + setInstanceMonitors: jest.fn((v) => { + if (!mockShallowEqual(mockStore.instanceMonitor, v)) mockStore.instanceMonitor = v; }), - - removeObject: jest.fn((objectName) => { - delete mockStore.objectStatus[objectName]; - delete mockStore.objectInstanceStatus[objectName]; - delete mockStore.instanceConfig[objectName]; + removeObject: jest.fn((name) => { + delete mockStore.objectStatus[name]; + delete mockStore.objectInstanceStatus[name]; + delete mockStore.instanceConfig[name]; }), - - setConfigUpdated: jest.fn((updates) => { - mockStore.configUpdates = updates || []; + setConfigUpdated: jest.fn((v) => { + mockStore.configUpdates = v || []; }), - setInstanceConfig: jest.fn((path, node, config) => { - if (!mockStore.instanceConfig[path]) { - mockStore.instanceConfig[path] = {}; - } - if (mockShallowEqual(mockStore.instanceConfig[path][node], config)) { - return; - } - mockStore.instanceConfig[path][node] = config; + if (!mockStore.instanceConfig[path]) mockStore.instanceConfig[path] = {}; + if (!mockShallowEqual(mockStore.instanceConfig[path][node], config)) + mockStore.instanceConfig[path][node] = config; }), }; - useEventStore.getState.mockReturnValue(mockStore); - mockLogStore = { - addEventLog: jest.fn(), - }; + mockLogStore = {addEventLog: jest.fn()}; useEventLogStore.getState.mockReturnValue(mockLogStore); - // Create a consistent mock EventSource mockEventSource = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, // OPEN state + onopen: jest.fn(), onerror: null, addEventListener: jest.fn(), + close: jest.fn(), readyState: 1, url: URL_NODE_EVENT + '?cache=true&filter=NodeStatusUpdated', }; - mockLoggerEventSource = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, + onopen: jest.fn(), onerror: null, addEventListener: jest.fn(), + close: jest.fn(), readyState: 1, url: URL_NODE_EVENT + '?cache=true&filter=ObjectStatusUpdated', }; + EventSourcePolyfill.mockImplementation(() => mockEventSource); - // Mock EventSourcePolyfill to return our mock - EventSourcePolyfill.mockImplementation(() => { - return mockEventSource; - }); + localStorageMock = {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn()}; + Object.defineProperty(global, 'localStorage', {value: localStorageMock, writable: true}); - // Mock localStorage properly - localStorageMock = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), - }; - Object.defineProperty(global, 'localStorage', { - value: localStorageMock, - writable: true - }); - - // Store original console methods originalConsole = { log: console.log, error: console.error, @@ -189,30 +104,18 @@ describe('eventSourceManager', () => { info: console.info, debug: console.debug }; - - // Mock console methods - capture all debug calls + originalDebug = console.debug; console.log = jest.fn(); console.error = jest.fn(); console.warn = jest.fn(); console.info = jest.fn(); - originalDebug = console.debug; console.debug = jest.fn(); - // Mock window.location in a non-destructive way originalLocation = window.location; - Object.defineProperty(window, 'location', { - configurable: true, - value: {href: ''}, - }); + Object.defineProperty(window, 'location', {configurable: true, value: {href: ''}}); window.oidcUserManager = null; - - // Mock dispatchEvent window.dispatchEvent = jest.fn(); - - // Mock EventSource for CLOSED global.EventSource = {CLOSED: 2}; - - // Mock requestAnimationFrame global.requestAnimationFrame = jest.fn((cb) => { setTimeout(cb, 0); return 1; @@ -220,30 +123,13 @@ describe('eventSourceManager', () => { }); afterEach(() => { - // Run all pending timers first so isFlushing/flushTimeoutId are properly reset jest.runAllTimers(); - - // setPageActive(false) calls clearBuffers() which resets flushTimeoutId, eventCount, - // isFlushing, needsFlush — prevents stale state leaking between tests eventSourceManager.setPageActive(false); eventSourceManager.setPageActive(true); - jest.clearAllTimers(); - - // Restore console methods - console.log = originalConsole.log; - console.error = originalConsole.error; - console.warn = originalConsole.warn; - console.info = originalConsole.info; + Object.assign(console, originalConsole); console.debug = originalDebug; - - // Restore window.location - Object.defineProperty(window, 'location', { - configurable: true, - value: originalLocation, - }); - - // Reset module state + Object.defineProperty(window, 'location', {configurable: true, value: originalLocation}); eventSourceManager.closeEventSource(); eventSourceManager.closeLoggerEventSource(); delete window.oidcUserManager; @@ -251,49 +137,38 @@ describe('eventSourceManager', () => { describe('EventSource lifecycle and management', () => { test('should create an EventSource and attach event listeners', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); expect(EventSourcePolyfill).toHaveBeenCalled(); - expect(eventSource.addEventListener).toHaveBeenCalledTimes(9); + expect(es.addEventListener).toHaveBeenCalledTimes(9); }); test('should close existing EventSource before creating a new one', () => { - // Create first EventSource - const firstEventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - expect(firstEventSource.close).not.toHaveBeenCalled(); - - // Create second EventSource - EventSourcePolyfill.mockImplementationOnce(() => ({ - ...mockEventSource, - })); + eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + EventSourcePolyfill.mockImplementationOnce(() => ({...mockEventSource})); eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); expect(mockEventSource.close).toHaveBeenCalled(); }); - test('should not create EventSource if no token is provided', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, ''); - expect(eventSource).toBeNull(); + test('should not create EventSource if no token', () => { + expect(eventSourceManager.createEventSource(URL_NODE_EVENT, '')).toBeNull(); expect(console.error).toHaveBeenCalledWith('❌ Missing token for EventSource!'); }); - test('should close the EventSource when closeEventSource is called', () => { + test('should close EventSource and not throw on missing source', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); eventSourceManager.closeEventSource(); expect(mockEventSource.close).toHaveBeenCalled(); - }); - - test('should not throw error when closing non-existent EventSource', () => { expect(() => eventSourceManager.closeEventSource()).not.toThrow(); }); - test('should call _cleanup if present', () => { - const cleanupSpy = jest.fn(); + test('should call and handle errors in _cleanup', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const cleanupSpy = jest.fn(); mockEventSource._cleanup = cleanupSpy; eventSourceManager.closeEventSource(); expect(cleanupSpy).toHaveBeenCalled(); - }); - test('should handle error in _cleanup', () => { + // Error case eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); mockEventSource._cleanup = () => { throw new Error('cleanup error'); @@ -302,309 +177,169 @@ describe('eventSourceManager', () => { expect(console.debug).toHaveBeenCalledWith('Error during eventSource cleanup', expect.any(Error)); }); - test('should return token from localStorage', () => { + test('should return token from localStorage or currentToken', () => { localStorageMock.getItem.mockReturnValue('local-storage-token'); - const token = eventSourceManager.getCurrentToken(); - expect(token).toBe('local-storage-token'); - }); + expect(eventSourceManager.getCurrentToken()).toBe('local-storage-token'); - test('should return currentToken if localStorage is empty', () => { localStorageMock.getItem.mockReturnValue(null); eventSourceManager.createEventSource(URL_NODE_EVENT, 'current-token'); - const token = eventSourceManager.getCurrentToken(); - expect(token).toBe('current-token'); + expect(eventSourceManager.getCurrentToken()).toBe('current-token'); }); - test('should not update if no new token provided', () => { + test('should not update if no new token in updateEventSourceToken', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); eventSourceManager.updateEventSourceToken(''); expect(mockEventSource.close).not.toHaveBeenCalled(); }); - test('should configure EventSource with objectName and custom filters', () => { - const customFilters = ['NodeStatusUpdated', 'ObjectStatusUpdated']; - eventSourceManager.configureEventSource('fake-token', 'test-object', customFilters); + test('should configure EventSource with and without objectName', () => { + eventSourceManager.configureEventSource('fake-token', 'test-object', ['NodeStatusUpdated']); expect(EventSourcePolyfill).toHaveBeenCalled(); - }); - test('should handle missing token in configureEventSource', () => { - eventSourceManager.configureEventSource(''); - expect(console.error).toHaveBeenCalledWith('❌ No token provided for SSE!'); - }); - - test('should configure EventSource without objectName', () => { + jest.clearAllMocks(); eventSourceManager.configureEventSource('fake-token'); - expect(EventSourcePolyfill).toHaveBeenCalled(); expect(EventSourcePolyfill.mock.calls[0][0]).toContain('cache=true'); + expect(EventSourcePolyfill.mock.calls[0][0]).not.toContain('path='); }); - test('should create an EventSource with valid token via startEventReception', () => { - eventSourceManager.startEventReception('fake-token'); - expect(EventSourcePolyfill).toHaveBeenCalledWith( - expect.stringContaining(URL_NODE_EVENT), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer fake-token', - }), - }) - ); - }); - - test('should close previous EventSource before creating a new one via startEventReception', () => { - eventSourceManager.startEventReception('fake-token'); - const secondMockEventSource = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, - }; - EventSourcePolyfill.mockImplementationOnce(() => secondMockEventSource); - eventSourceManager.startEventReception('fake-token'); - expect(mockEventSource.close).toHaveBeenCalled(); - expect(EventSourcePolyfill).toHaveBeenCalledTimes(2); - }); - - test('should handle missing token in startEventReception', () => { + test('should handle missing token in configureEventSource and startEventReception', () => { + eventSourceManager.configureEventSource(''); + expect(console.error).toHaveBeenCalledWith('❌ No token provided for SSE!'); eventSourceManager.startEventReception(''); expect(console.error).toHaveBeenCalledWith('❌ No token provided for SSE!'); }); - test('should start event reception with custom filters', () => { - const customFilters = ['NodeStatusUpdated', 'ObjectStatusUpdated']; - eventSourceManager.startEventReception('fake-token', customFilters); - expect(EventSourcePolyfill).toHaveBeenCalled(); + test('should startEventReception with valid and custom filters', () => { + eventSourceManager.startEventReception('fake-token', ['NodeStatusUpdated', 'ObjectStatusUpdated']); expect(EventSourcePolyfill.mock.calls[0][0]).toContain('filter=NodeStatusUpdated'); expect(EventSourcePolyfill.mock.calls[0][0]).toContain('filter=ObjectStatusUpdated'); }); - test('should handle connection open event', () => { + test('should handle connection open event and log it', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onopen) { - mockEventSource.onopen(); - } + mockEventSource.onopen(); expect(console.info).toHaveBeenCalledWith('✅ EventSource connection established'); - }); - - test('should log connection opened with correct data', () => { - eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onopen) { - mockEventSource.onopen(); - } expect(mockLogStore.addEventLog).toHaveBeenCalledWith('CONNECTION_OPENED', { - url: expect.any(String), - timestamp: expect.any(String) + url: expect.any(String), timestamp: expect.any(String) }); }); test('should log connection error with correct data', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const error = {status: 500, message: 'Test error'}; - if (mockEventSource.onerror) { - mockEventSource.onerror(error); - } + mockEventSource.onerror({status: 500, message: 'Test error'}); expect(mockLogStore.addEventLog).toHaveBeenCalledWith('CONNECTION_ERROR', { - error: 'Test error', - status: 500, - url: expect.any(String), - timestamp: expect.any(String) + error: 'Test error', status: 500, url: expect.any(String), timestamp: expect.any(String) }); }); test('should ignore onerror if not current EventSource', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); eventSourceManager.closeEventSource(); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } + mockEventSource.onerror({status: 500}); expect(console.info).not.toHaveBeenCalledWith(expect.stringContaining('Reconnecting')); }); - test('should log reconnection attempt', () => { + test('should log reconnection attempt and max reconnections reached', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } + mockEventSource.onerror({status: 500}); expect(mockLogStore.addEventLog).toHaveBeenCalledWith('RECONNECTION_ATTEMPT', expect.any(Object)); - }); - test('should log max reconnections reached', () => { - eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - - for (let i = 0; i < 11; i++) { - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } + for (let i = 0; i < 10; i++) { + mockEventSource.onerror({status: 500}); jest.advanceTimersByTime(2000); } - expect(mockLogStore.addEventLog).toHaveBeenCalledWith('MAX_RECONNECTIONS_REACHED', expect.any(Object)); }); - test('should handle auth error when new token same as old', () => { + test('should handle auth error: same token, no oidcUserManager', () => { localStorageMock.getItem.mockReturnValue('old-token'); eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 401}); - } + mockEventSource.onerror({status: 401}); expect(console.warn).toHaveBeenCalledWith('🔐 Authentication error detected'); expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); }); - test('should handle auth error without oidcUserManager', () => { + test('should handle auth error: new token from storage', () => { + localStorageMock.getItem.mockReturnValue('new-token'); eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 401}); - } - expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); + mockEventSource.onerror({status: 401}); + expect(console.info).toHaveBeenCalledWith('🔄 New token available, updating EventSource'); }); - test('should handle silent renew failure', async () => { - window.oidcUserManager = {signinSilent: jest.fn().mockRejectedValue(new Error('renew fail'))}; + test('should handle silent renew success and failure', async () => { + // Success + const mockUser = {access_token: 'silent-renewed-token', expires_at: Date.now() + 3600000}; + window.oidcUserManager = {signinSilent: jest.fn().mockResolvedValue(mockUser)}; + localStorageMock.getItem.mockReturnValue(null); eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); + mockEventSource.onerror({status: 401}); + await Promise.resolve(); + expect(localStorageMock.setItem).toHaveBeenCalledWith('authToken', 'silent-renewed-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 401}); - } - + // Failure + window.oidcUserManager = {signinSilent: jest.fn().mockRejectedValue(new Error('renew fail'))}; + eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); + mockEventSource.onerror({status: 401}); await Promise.resolve(); await Promise.resolve(); - expect(console.error).toHaveBeenCalledWith('❌ Silent renew failed:', expect.any(Error)); expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); }); - test('should update with new token from storage on auth error', () => { - localStorageMock.getItem.mockReturnValue('new-token'); - eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 401}); - } - expect(console.info).toHaveBeenCalledWith('🔄 New token available, updating EventSource'); - }); - test('should log connection closed on closeEventSource', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); eventSourceManager.closeEventSource(); expect(mockLogStore.addEventLog).toHaveBeenCalledWith('CONNECTION_CLOSED', expect.any(Object)); }); - // Coverage for lines 726-727, 745: updateEventSourceToken with open EventSource test('should restart EventSource when updateEventSourceToken called with open connection', () => { - // Create initial EventSource — mockEventSource needs a url for updateEventSourceToken to parse - mockEventSource.url = `${URL_NODE_EVENT}?cache=true&filter=NodeStatusUpdated`; eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - - // Prepare mock for the recreated EventSource - const recreatedMock = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, - url: `${URL_NODE_EVENT}?cache=true&filter=NodeStatusUpdated`, - }; + const recreatedMock = {...mockEventSource, close: jest.fn(), addEventListener: jest.fn()}; EventSourcePolyfill.mockImplementation(() => recreatedMock); - - // updateEventSourceToken closes current source and schedules recreation via setTimeout localStorageMock.getItem.mockReturnValue('new-token'); eventSourceManager.updateEventSourceToken('new-token'); - - // Current EventSource should be closed immediately expect(mockEventSource.close).toHaveBeenCalled(); - - // Advance timers to trigger the setTimeout(..., 100) callback on line 745 jest.advanceTimersByTime(200); - - // A new EventSource should have been created (line 745 executed) expect(EventSourcePolyfill).toHaveBeenCalledTimes(2); }); - test('should not restart EventSource when updateEventSourceToken called but connection is closed', () => { + test('should not restart EventSource when connection is already closed', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); - mockEventSource.readyState = 2; // CLOSED + mockEventSource.readyState = 2; eventSourceManager.updateEventSourceToken('new-token'); - // Should not create a new one since existing is closed expect(EventSourcePolyfill).toHaveBeenCalledTimes(1); }); }); describe('Event processing and buffer management', () => { - test('should process NodeStatusUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeStatusUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}; - nodeStatusHandler(mockEvent); + test('should process NodeStatusUpdated events and skip if unchanged', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + handler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.runAllTimers(); expect(mockStore.setNodeStatuses).toHaveBeenCalledWith(expect.objectContaining({node1: {status: 'up'}})); - }); - test('should skip NodeStatusUpdated if status unchanged', () => { mockStore.nodeStatus = {node1: {status: 'up'}}; - useEventStore.getState.mockReturnValue(mockStore); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeStatusUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}; - nodeStatusHandler(mockEvent); + handler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.runAllTimers(); - - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); expect(mockStore.nodeStatus).toEqual({node1: {status: 'up'}}); }); - test('should process NodeMonitorUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeMonitorHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeMonitorUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node2', node_monitor: {monitor: 'active'}})}; - nodeMonitorHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.setNodeMonitors).toHaveBeenCalledWith(expect.objectContaining({node2: {monitor: 'active'}})); - }); - - test('should flush nodeMonitorBuffer correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeMonitorHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeMonitorUpdated' - )[1]; - nodeMonitorHandler({data: JSON.stringify({node: 'node1', node_monitor: {monitor: 'active'}})}); - nodeMonitorHandler({data: JSON.stringify({node: 'node2', node_monitor: {monitor: 'inactive'}})}); + test('should process NodeMonitorUpdated and flush multiple entries', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeMonitorUpdated'); + handler({data: JSON.stringify({node: 'node1', node_monitor: {monitor: 'active'}})}); + handler({data: JSON.stringify({node: 'node2', node_monitor: {monitor: 'inactive'}})}); jest.runAllTimers(); expect(mockStore.setNodeMonitors).toHaveBeenCalledWith(expect.objectContaining({ - node1: {monitor: 'active'}, - node2: {monitor: 'inactive'}, + node1: {monitor: 'active'}, node2: {monitor: 'inactive'}, })); }); - test('should process NodeStatsUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatsHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeStatsUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node3', node_stats: {cpu: 75, memory: 60}})}; - nodeStatsHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.setNodeStats).toHaveBeenCalledWith(expect.objectContaining({ - node3: { - cpu: 75, - memory: 60 - } - })); - }); - - test('should flush nodeStatsBuffer correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatsHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeStatsUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node1', node_stats: {cpu: 50, memory: 70}})}; - nodeStatsHandler(mockEvent); + test('should process NodeStatsUpdated events', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatsUpdated'); + handler({data: JSON.stringify({node: 'node1', node_stats: {cpu: 50, memory: 70}})}); jest.runAllTimers(); expect(mockStore.setNodeStats).toHaveBeenCalledWith(expect.objectContaining({ node1: { @@ -614,323 +349,160 @@ describe('eventSourceManager', () => { })); }); - test('should process ObjectStatusUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectStatusUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({path: 'object1', object_status: {status: 'active'}})}; - objectStatusHandler(mockEvent); + test('should process ObjectStatusUpdated with path, labels.path, and handle missing fields', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'ObjectStatusUpdated'); + handler({data: JSON.stringify({path: 'object1', object_status: {status: 'active'}})}); + handler({data: JSON.stringify({labels: {path: 'object2'}, object_status: {status: 'ok'}})}); jest.runAllTimers(); - expect(mockStore.setObjectStatuses).toHaveBeenCalledWith(expect.objectContaining({object1: {status: 'active'}})); - }); - - test('should handle ObjectStatusUpdated with labels path', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectStatusUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({labels: {path: 'object1'}, object_status: {status: 'active'}})}; - objectStatusHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.setObjectStatuses).toHaveBeenCalledWith(expect.objectContaining({object1: {status: 'active'}})); - }); + expect(mockStore.setObjectStatuses).toHaveBeenCalledWith(expect.objectContaining({ + object1: {status: 'active'}, object2: {status: 'ok'}, + })); - test('should handle ObjectStatusUpdated with missing name or status', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectStatusUpdated' - )[1]; - objectStatusHandler({data: JSON.stringify({object_status: {status: 'active'}})}); - objectStatusHandler({data: JSON.stringify({path: 'object1'})}); + jest.clearAllMocks(); + handler({data: JSON.stringify({object_status: {status: 'active'}})}); + handler({data: JSON.stringify({path: 'object1'})}); jest.runAllTimers(); expect(mockStore.setObjectStatuses).not.toHaveBeenCalled(); }); - test('should process InstanceStatusUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceStatusUpdated' - )[1]; - const mockEvent = { - data: JSON.stringify({ - path: 'object2', - node: 'node1', - instance_status: {status: 'inactive'}, - }) - }; - instanceStatusHandler(mockEvent); + test('should merge objectStatus updates in buffer preserving all fields', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'ObjectStatusUpdated'); + handler({data: JSON.stringify({path: 'obj1', object_status: {avail: 'up', frozen: false}})}); + handler({data: JSON.stringify({path: 'obj1', object_status: {avail: 'down', provisioned: true}})}); jest.runAllTimers(); - expect(mockStore.setInstanceStatuses).toHaveBeenCalledWith(expect.objectContaining({ - object2: {node1: {status: 'inactive'}}, + expect(mockStore.setObjectStatuses).toHaveBeenCalledWith(expect.objectContaining({ + 'obj1': expect.objectContaining({avail: 'down', frozen: false, provisioned: true}) })); }); - test('should handle InstanceStatusUpdated with labels path', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceStatusUpdated' - )[1]; - const mockEvent = { + test('should process InstanceStatusUpdated with path, labels.path, handle missing and skip equal', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'InstanceStatusUpdated'); + handler({data: JSON.stringify({path: 'object1', node: 'node1', instance_status: {status: 'running'}})}); + handler({ data: JSON.stringify({ labels: {path: 'object1'}, - node: 'node1', - instance_status: {status: 'running'}, - }) - }; - instanceStatusHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.setInstanceStatuses).toHaveBeenCalledWith(expect.objectContaining({ - object1: {node1: {status: 'running'}}, - })); - }); - - test('should flush instanceStatusBuffer with nested object updates', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceStatusUpdated' - )[1]; - instanceStatusHandler({ - data: JSON.stringify({ - path: 'object1', - node: 'node1', - instance_status: {status: 'running'}, - }) - }); - instanceStatusHandler({ - data: JSON.stringify({ - path: 'object1', node: 'node2', - instance_status: {status: 'stopped'}, + instance_status: {status: 'stopped'} }) }); jest.runAllTimers(); expect(mockStore.setInstanceStatuses).toHaveBeenCalledWith(expect.objectContaining({ - object1: { - node1: {status: 'running'}, - node2: {status: 'stopped'}, - }, + object1: {node1: {status: 'running'}, node2: {status: 'stopped'}} })); - }); - test('should handle InstanceStatusUpdated with missing fields', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceStatusUpdated' - )[1]; - instanceStatusHandler({data: JSON.stringify({node: 'node1', instance_status: {status: 'running'}})}); - instanceStatusHandler({data: JSON.stringify({path: 'object1', instance_status: {status: 'running'}})}); - instanceStatusHandler({data: JSON.stringify({path: 'object1', node: 'node1'})}); + // Missing fields + jest.clearAllMocks(); + handler({data: JSON.stringify({node: 'node1', instance_status: {status: 'running'}})}); + handler({data: JSON.stringify({path: 'object1', instance_status: {status: 'running'}})}); + handler({data: JSON.stringify({path: 'object1', node: 'node1'})}); jest.runAllTimers(); expect(mockStore.setInstanceStatuses).not.toHaveBeenCalled(); - }); - test('should flush heartbeatStatusBuffer correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const heartbeatHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'DaemonHeartbeatUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({node: 'node1', heartbeat: {status: 'alive'}})}; - heartbeatHandler(mockEvent); + // Skip equal + mockStore.objectInstanceStatus = {'object1': {'node1': {status: 'running'}}}; + handler({data: JSON.stringify({path: 'object1', node: 'node1', instance_status: {status: 'running'}})}); jest.runAllTimers(); - - expect(mockStore.setHeartbeatStatuses).toHaveBeenCalledWith(expect.objectContaining({node1: {status: 'alive'}})); - - const flushCalls = console.debug.mock.calls.filter(call => - call[0] && call[0].includes('Flushed') - ); - expect(flushCalls.length).toBeGreaterThan(0); + expect(mockStore.objectInstanceStatus).toEqual({'object1': {'node1': {status: 'running'}}}); }); - test('should handle DaemonHeartbeatUpdated with labels node', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const heartbeatHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'DaemonHeartbeatUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({labels: {node: 'node1'}, heartbeat: {status: 'alive'}})}; - heartbeatHandler(mockEvent); - jest.runAllTimers(); + test('should process DaemonHeartbeatUpdated with node, labels.node, and handle missing', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'DaemonHeartbeatUpdated'); + + handler({data: JSON.stringify({node: 'node1', heartbeat: {status: 'alive'}})}); + eventSourceManager.forceFlush(); expect(mockStore.setHeartbeatStatuses).toHaveBeenCalledWith(expect.objectContaining({node1: {status: 'alive'}})); - }); - test('should handle DaemonHeartbeatUpdated with missing node or status', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const heartbeatHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'DaemonHeartbeatUpdated' - )[1]; - heartbeatHandler({data: JSON.stringify({heartbeat: {status: 'alive'}})}); - heartbeatHandler({data: JSON.stringify({node: 'node1'})}); - jest.runAllTimers(); + jest.clearAllMocks(); + mockNow += 100; + handler({data: JSON.stringify({labels: {node: 'node2'}, heartbeat: {status: 'alive'}})}); + eventSourceManager.forceFlush(); + expect(mockStore.setHeartbeatStatuses).toHaveBeenCalledWith(expect.objectContaining({node2: {status: 'alive'}})); + + jest.clearAllMocks(); + mockNow += 100; + handler({data: JSON.stringify({heartbeat: {status: 'alive'}})}); + handler({data: JSON.stringify({node: 'node1'})}); + eventSourceManager.forceFlush(); expect(mockStore.setHeartbeatStatuses).not.toHaveBeenCalled(); }); - test('should handle ObjectDeleted with missing name', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectDeletedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectDeleted' - )[1]; - objectDeletedHandler({data: JSON.stringify({})}); + test('should process ObjectDeleted with path, labels.path, and handle missing name', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'ObjectDeleted'); + handler({data: JSON.stringify({})}); expect(console.warn).toHaveBeenCalledWith('⚠️ ObjectDeleted event missing objectName:', {}); expect(mockStore.removeObject).not.toHaveBeenCalled(); - }); - test('should process ObjectDeleted events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectDeletedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectDeleted' - )[1]; - const mockEvent = {data: JSON.stringify({path: 'object1'})}; - objectDeletedHandler(mockEvent); - jest.runAllTimers(); - expect(console.debug).toHaveBeenCalledWith('📩 Received ObjectDeleted event:', expect.any(String)); + handler({data: JSON.stringify({path: 'object1'})}); expect(mockStore.removeObject).toHaveBeenCalledWith('object1'); - }); - test('should handle ObjectDeleted with labels path', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectDeletedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'ObjectDeleted' - )[1]; - const mockEvent = {data: JSON.stringify({labels: {path: 'object1'}})}; - objectDeletedHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.removeObject).toHaveBeenCalledWith('object1'); + handler({data: JSON.stringify({labels: {path: 'object2'}})}); + expect(mockStore.removeObject).toHaveBeenCalledWith('object2'); }); - test('should process InstanceMonitorUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceMonitorHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceMonitorUpdated' - )[1]; - const mockEvent = { - data: JSON.stringify({ - node: 'node1', - path: 'object1', - instance_monitor: {monitor: 'active'}, - }) - }; - instanceMonitorHandler(mockEvent); + test('should process InstanceMonitorUpdated with and without missing fields, skip equal', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'InstanceMonitorUpdated'); + handler({data: JSON.stringify({node: 'node1', path: 'object1', instance_monitor: {monitor: 'active'}})}); jest.runAllTimers(); expect(mockStore.setInstanceMonitors).toHaveBeenCalledWith( expect.objectContaining({'node1:object1': {monitor: 'active'}}) ); - }); - test('should handle InstanceMonitorUpdated with missing fields', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceMonitorHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceMonitorUpdated' - )[1]; - instanceMonitorHandler({data: JSON.stringify({path: 'object1', instance_monitor: {monitor: 'active'}})}); - instanceMonitorHandler({data: JSON.stringify({node: 'node1', instance_monitor: {monitor: 'active'}})}); - instanceMonitorHandler({data: JSON.stringify({node: 'node1', path: 'object1'})}); + jest.clearAllMocks(); + handler({data: JSON.stringify({path: 'object1', instance_monitor: {monitor: 'active'}})}); + handler({data: JSON.stringify({node: 'node1', instance_monitor: {monitor: 'active'}})}); + handler({data: JSON.stringify({node: 'node1', path: 'object1'})}); jest.runAllTimers(); expect(mockStore.setInstanceMonitors).not.toHaveBeenCalled(); - }); - test('should process InstanceConfigUpdated events correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceConfigUpdated' - )[1]; - const mockEvent = { - data: JSON.stringify({ - path: 'object1', - node: 'node1', - instance_config: {config: 'test'} - }) - }; - configUpdatedHandler(mockEvent); + // Skip equal + mockStore.instanceMonitor = {'node1:object1': {monitor: 'active'}}; + handler({data: JSON.stringify({node: 'node1', path: 'object1', instance_monitor: {monitor: 'active'}})}); jest.runAllTimers(); - expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node1', {config: 'test'}); - expect(mockStore.setConfigUpdated).toHaveBeenCalled(); + expect(mockStore.instanceMonitor).toEqual({'node1:object1': {monitor: 'active'}}); }); - test('should handle InstanceConfigUpdated with labels path', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceConfigUpdated' - )[1]; - const mockEvent = {data: JSON.stringify({labels: {path: 'object1'}, node: 'node1'})}; - configUpdatedHandler(mockEvent); - jest.runAllTimers(); + test('should process InstanceConfigUpdated with config, labels.path, missing fields, and multiple nodes', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'InstanceConfigUpdated'); + + handler({data: JSON.stringify({path: 'object1', node: 'node1', instance_config: {config: 'v1'}})}); + handler({data: JSON.stringify({path: 'object1', node: 'node2', instance_config: {config: 'v2'}})}); + eventSourceManager.forceFlush(); + expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node1', {config: 'v1'}); + expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node2', {config: 'v2'}); + expect(mockStore.setConfigUpdated).toHaveBeenCalled(); + + jest.clearAllMocks(); + mockNow += 100; + handler({data: JSON.stringify({labels: {path: 'object1'}, node: 'node1'})}); + eventSourceManager.forceFlush(); expect(mockStore.setConfigUpdated).toHaveBeenCalledWith(expect.arrayContaining([expect.any(String)])); - }); - test('should handle InstanceConfigUpdated with missing name or node', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceConfigUpdated' - )[1]; - configUpdatedHandler({data: JSON.stringify({node: 'node1'})}); - configUpdatedHandler({data: JSON.stringify({path: 'object1'})}); + jest.clearAllMocks(); + mockNow += 100; + handler({data: JSON.stringify({node: 'node1'})}); + handler({data: JSON.stringify({path: 'object1'})}); + eventSourceManager.forceFlush(); expect(console.warn).toHaveBeenCalledWith('⚠️ InstanceConfigUpdated event missing name or node:', expect.any(Object)); expect(mockStore.setConfigUpdated).not.toHaveBeenCalled(); }); test('should handle invalid JSON in events', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: 'invalid json'}); - expect(console.warn).toHaveBeenCalledWith('⚠️ Invalid JSON in NodeStatusUpdated event:', 'invalid json'); - }); - - test('should process multiple events and flush buffers correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'ObjectStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - objectStatusHandler({data: JSON.stringify({path: 'obj1', object_status: {status: 'active'}})}); - jest.runAllTimers(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - expect(mockStore.setObjectStatuses).toHaveBeenCalled(); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: 'invalid json {['}); + expect(console.warn).toHaveBeenCalledWith('⚠️ Invalid JSON in NodeStatusUpdated event:', 'invalid json {['); }); test('should handle empty buffers gracefully', () => { eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); jest.runAllTimers(); expect(mockStore.setNodeStatuses).not.toHaveBeenCalled(); - }); - - test('should handle instanceConfig buffer correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - (call) => call[0] === 'InstanceConfigUpdated' - )[1]; - const mockEvent = { - data: JSON.stringify({ - path: 'object1', - node: 'node1', - instance_config: {config: 'test-value'} - }) - }; - configUpdatedHandler(mockEvent); - jest.runAllTimers(); - expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node1', {config: 'test-value'}); - }); - - test('should handle multiple buffers correctly', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'ObjectStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - objectStatusHandler({data: JSON.stringify({path: 'obj1', object_status: {status: 'active'}})}); - jest.runAllTimers(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - expect(mockStore.setObjectStatuses).toHaveBeenCalled(); - }); - - test('should handle empty buffers without errors', () => { eventSourceManager.forceFlush(); expect(console.error).not.toHaveBeenCalled(); }); @@ -939,544 +511,160 @@ describe('eventSourceManager', () => { mockStore.setNodeStatuses.mockImplementation(() => { throw new Error('Test error'); }); - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.runAllTimers(); expect(console.error).toHaveBeenCalledWith('Error during buffer flush:', expect.any(Error)); }); - test('should not flush when already flushing', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - eventSourceManager.forceFlush(); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('should handle configUpdated buffer type', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'InstanceConfigUpdated' - )[1]; - configUpdatedHandler({ - data: JSON.stringify({ - path: 'object1', - node: 'node1' - }) - }); - jest.runAllTimers(); - expect(mockStore.setConfigUpdated).toHaveBeenCalled(); - }); - - test('should skip update when instanceStatus values are equal', () => { - mockStore.objectInstanceStatus = {'object1': {'node1': {status: 'running'}}}; - useEventStore.getState.mockReturnValue(mockStore); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'InstanceStatusUpdated' - )[1]; - instanceStatusHandler({ - data: JSON.stringify({ - path: 'object1', - node: 'node1', - instance_status: {status: 'running'} - }) - }); - jest.runAllTimers(); - - expect(mockStore.setInstanceStatuses).toHaveBeenCalled(); - expect(mockStore.objectInstanceStatus).toEqual({'object1': {'node1': {status: 'running'}}}); - }); - - test('should skip update when instanceMonitor values are equal', () => { - mockStore.instanceMonitor = {'node1:object1': {monitor: 'active'}}; - useEventStore.getState.mockReturnValue(mockStore); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const instanceMonitorHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'InstanceMonitorUpdated' - )[1]; - instanceMonitorHandler({ - data: JSON.stringify({ - node: 'node1', - path: 'object1', - instance_monitor: {monitor: 'active'} - }) - }); - jest.runAllTimers(); - - expect(mockStore.setInstanceMonitors).toHaveBeenCalled(); - expect(mockStore.instanceMonitor).toEqual({'node1:object1': {monitor: 'active'}}); - }); - - test('should clear existing timeout when eventCount reaches BATCH_SIZE', () => { - const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - for (let i = 0; i < 100; i++) { - nodeStatusHandler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); - } - expect(clearTimeoutSpy).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); - }); - - test('should handle invalid JSON in event data', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - const invalidEvent = {data: 'invalid json {['}; - nodeStatusHandler(invalidEvent); - expect(console.warn).toHaveBeenCalledWith('⚠️ Invalid JSON in NodeStatusUpdated event:', 'invalid json {['); - }); - - test('should clear all buffers and reset state', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + test('should clear all buffers and reset state via setPageActive', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); eventSourceManager.setPageActive(false); eventSourceManager.setPageActive(true); eventSourceManager.forceFlush(); expect(mockStore.setNodeStatuses).not.toHaveBeenCalled(); }); - test('should handle multiple instance config updates', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const configUpdatedHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'InstanceConfigUpdated' - )[1]; - configUpdatedHandler({ - data: JSON.stringify({ - path: 'object1', - node: 'node1', - instance_config: {config: 'v1'} - }) - }); - configUpdatedHandler({ - data: JSON.stringify({ - path: 'object1', - node: 'node2', - instance_config: {config: 'v2'} - }) - }); - jest.runAllTimers(); - expect(mockStore.setInstanceConfig).toHaveBeenCalledTimes(2); - expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node1', {config: 'v1'}); - expect(mockStore.setInstanceConfig).toHaveBeenCalledWith('object1', 'node2', {config: 'v2'}); - }); - test('should not flush when page not active', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); eventSourceManager.setPageActive(false); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.runAllTimers(); expect(mockStore.setNodeStatuses).not.toHaveBeenCalled(); }); - test('should reschedule flush if too soon', () => { - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - eventSourceManager.forceFlush(); - jest.advanceTimersByTime(0); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - expect(setTimeoutSpy).toHaveBeenCalled(); - setTimeoutSpy.mockRestore(); - }); - - test('should flush immediately on reconnect if buffered', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - mockEventSource.onopen(); - jest.runAllTimers(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - }); - - test('should handle Safari batch updates', async () => { - const originalUserAgent = navigator.userAgent; - Object.defineProperty(navigator, 'userAgent', { - value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', - writable: true - }); - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - await Promise.resolve(); - jest.runAllTimers(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - Object.defineProperty(navigator, 'userAgent', {value: originalUserAgent}); - }); - - test('should use Safari constants', () => { - const originalUserAgent = navigator.userAgent; - Object.defineProperty(navigator, 'userAgent', { - value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', - writable: true - }); - eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = mockEventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - for (let i = 0; i < 150; i++) { - nodeStatusHandler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); - } - jest.runAllTimers(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - Object.defineProperty(navigator, 'userAgent', {value: originalUserAgent}); - }); - - test('should flush large batches immediately in Safari', () => { - const originalUserAgent = navigator.userAgent; - Object.defineProperty(navigator, 'userAgent', { - value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', - writable: true - }); - - jest.isolateModules(async () => { - const eventSourceManager = require('../eventSourceManager'); - const mockSafariEventSource = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, - }; - EventSourcePolyfill.mockImplementation(() => mockSafariEventSource); - - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); - eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = mockSafariEventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - for (let i = 0; i < 150; i++) { - nodeStatusHandler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); - } - - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); - setTimeoutSpy.mockRestore(); - }); - - Object.defineProperty(navigator, 'userAgent', {value: originalUserAgent}); + test('should clear existing timeout when eventCount reaches BATCH_SIZE', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + for (let i = 0; i < 100; i++) + handler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); }); - test('should reschedule flush when MIN_FLUSH_INTERVAL not elapsed and no existing timeout', () => { + test('should reschedule flush when MIN_FLUSH_INTERVAL not elapsed', () => { const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // First flush at current mockNow to set lastFlushTime = mockNow - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + handler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.clearAllTimers(); const flushTime = mockNow; - eventSourceManager.forceFlush(); // lastFlushTime = flushTime - - // Move only 2ms forward (< MIN_FLUSH_INTERVAL=10) so next flush is "too soon" + eventSourceManager.forceFlush(); mockNow = flushTime + 2; - - nodeStatusHandler({data: JSON.stringify({node: 'node2', node_status: {status: 'down'}})}); + handler({data: JSON.stringify({node: 'node2', node_status: {status: 'down'}})}); jest.clearAllTimers(); setTimeoutSpy.mockClear(); - // forceFlush -> flushBuffers: elapsed=2 < 10 -> must reschedule via setTimeout eventSourceManager.forceFlush(); - expect(setTimeoutSpy).toHaveBeenCalled(); - - // Advance time so interval passes and flush completes mockNow = flushTime + 100000; jest.runAllTimers(); expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - setTimeoutSpy.mockRestore(); }); test('should clear pending flushTimeoutId when flushBuffers starts', () => { const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Send an event — schedules a debounce timeout (flushTimeoutId is set) - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - - // Now call forceFlush directly — it first clears flushTimeoutId (lines 285-288 path), - // then calls flushBuffers which also checks/clears flushTimeoutId + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); clearTimeoutSpy.mockClear(); eventSourceManager.forceFlush(); - - // clearTimeout should have been called to cancel the pending debounce timer expect(clearTimeoutSpy).toHaveBeenCalled(); expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); }); test('should set needsFlush when event arrives during active flush', () => { - let handlerRef; - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - handlerRef = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Replace setNodeStatuses to capture calls and send a new event while isFlushing=true + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); let callCount = 0; mockStore.setNodeStatuses = jest.fn(() => { - callCount++; - if (callCount === 1) { - // isFlushing=true here — scheduleFlush hits the needsFlush branch - // and logs the debug message, sets needsFlush=true, eventCount++, returns early - handlerRef({data: JSON.stringify({node: 'node99', node_status: {status: 'up'}})}); - } + if (++callCount === 1) + handler({data: JSON.stringify({node: 'node99', node_status: {status: 'up'}})}); }); - - handlerRef({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + handler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.clearAllTimers(); eventSourceManager.forceFlush(); - - // The debug log proves the isFlushing branch was entered - // The log is a single template string with no second argument - expect(console.debug).toHaveBeenCalledWith( - expect.stringContaining('Event arrived during flush') - ); + expect(console.debug).toHaveBeenCalledWith(expect.stringContaining('Event arrived during flush')); }); test('should debounce multiple rapid events into a single flush', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Send 3 events rapidly — all land in the buffer before any timer fires - nodeStatusHandler({data: JSON.stringify({node: 'n1', node_status: {status: 'up'}})}); - nodeStatusHandler({data: JSON.stringify({node: 'n2', node_status: {status: 'down'}})}); - nodeStatusHandler({data: JSON.stringify({node: 'n3', node_status: {status: 'up'}})}); - - // Only one flush should occur when timer fires + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + handler({data: JSON.stringify({node: 'n1', node_status: {status: 'up'}})}); + handler({data: JSON.stringify({node: 'n2', node_status: {status: 'down'}})}); + handler({data: JSON.stringify({node: 'n3', node_status: {status: 'up'}})}); jest.runAllTimers(); expect(mockStore.setNodeStatuses).toHaveBeenCalledTimes(1); expect(mockStore.setNodeStatuses).toHaveBeenCalledWith( - expect.objectContaining({ - n1: {status: 'up'}, - n2: {status: 'down'}, - n3: {status: 'up'}, - }) + expect.objectContaining({n1: {status: 'up'}, n2: {status: 'down'}, n3: {status: 'up'}}) ); }); - test('should skip flush in debounce callback when eventCount already drained', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Send event — schedules debounce timeout, eventCount=1 - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - - // forceFlush drains the buffer synchronously: eventCount becomes 0 + test('should skip debounce callback when eventCount already drained', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + handler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); eventSourceManager.forceFlush(); mockStore.setNodeStatuses.mockClear(); - - // Now fire the debounce timeout — eventCount=0 so flushBuffers is NOT called jest.runAllTimers(); expect(mockStore.setNodeStatuses).not.toHaveBeenCalled(); }); - test('should merge nested objectStatus updates preserving all fields', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'ObjectStatusUpdated' - )[1]; - - // Two updates to the same path — updateBuffer merges with spread operator - objectStatusHandler({ - data: JSON.stringify({ - path: 'svc/test', - object_status: {avail: 'up', frozen: false, instances: {n1: 'running'}} - }) - }); - objectStatusHandler({ - data: JSON.stringify({ - path: 'svc/test', - object_status: {avail: 'down', provisioned: true} - }) - }); - - jest.runAllTimers(); - - // Merged result must contain fields from both events - expect(mockStore.setObjectStatuses).toHaveBeenCalledWith( - expect.objectContaining({ - 'svc/test': expect.objectContaining({ - avail: 'down', - frozen: false, - provisioned: true, - }) - }) - ); - }); - - test('should use requestAnimationFrame for non-Safari batch size flush', () => { + test('should use requestAnimationFrame for non-Safari BATCH_SIZE flush', () => { const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // BATCH_SIZE for non-Safari = 50; the 50th event hits eventCount >= BATCH_SIZE - // which calls requestAnimationFrame(flushBuffers) instead of a debounced setTimeout - for (let i = 0; i < 50; i++) { - nodeStatusHandler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); - } - + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + for (let i = 0; i < 50; i++) + handler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); expect(rafSpy).toHaveBeenCalled(); - rafSpy.mockRestore(); jest.runAllTimers(); }); - test('should schedule re-flush when new events arrive during flush', () => { - let flushCallCount = 0; - const originalSetNodeStatuses = mockStore.setNodeStatuses; - mockStore.setNodeStatuses = jest.fn(() => { - flushCallCount++; - // During flush, simulate a new event arriving by directly incrementing eventCount - // This is tested indirectly via the needsFlush flag behavior - }); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Send initial event - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - // Send a second event to trigger needsFlush scenario - nodeStatusHandler({data: JSON.stringify({node: 'node2', node_status: {status: 'down'}})}); - + test('should flush immediately on reconnect if buffered', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + mockEventSource.onopen(); jest.runAllTimers(); expect(mockStore.setNodeStatuses).toHaveBeenCalled(); - mockStore.setNodeStatuses = originalSetNodeStatuses; }); - test('should deeply compare nested objects in isEqual', () => { - // The isEqual function is exercised internally during buffer merging - // Test by updating the same key twice with nested objects - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'ObjectStatusUpdated' - )[1]; - - // First event with nested object - objectStatusHandler({ - data: JSON.stringify({ - path: 'object1', - object_status: { - availability: 'up', - instances: {node1: {status: 'running'}, node2: {status: 'idle'}} - } - }) - }); - - // Second event that partially updates the same object (merging behavior) - objectStatusHandler({ - data: JSON.stringify({ - path: 'object1', - object_status: { - availability: 'down', - instances: {node1: {status: 'stopped'}, node2: {status: 'idle'}} - } - }) + test('should handle Safari batch updates', () => { + const originalUA = navigator.userAgent; + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 AppleWebKit/605.1.15 Version/14.0 Safari/605.1.15', writable: true }); - + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + const handler = getHandler(es, 'NodeStatusUpdated'); + for (let i = 0; i < 150; i++) + handler({data: JSON.stringify({node: `node${i}`, node_status: {status: 'up'}})}); jest.runAllTimers(); - expect(mockStore.setObjectStatuses).toHaveBeenCalled(); - }); - - test('should merge object status updates in buffer before flush', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const objectStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'ObjectStatusUpdated' - )[1]; - - objectStatusHandler({data: JSON.stringify({path: 'obj1', object_status: {field1: 'a', field2: 'b'}})}); - objectStatusHandler({data: JSON.stringify({path: 'obj1', object_status: {field2: 'c', field3: 'd'}})}); - - jest.runAllTimers(); - // The merged result should contain all fields - expect(mockStore.setObjectStatuses).toHaveBeenCalledWith( - expect.objectContaining({ - obj1: expect.objectContaining({field1: 'a', field2: 'c', field3: 'd'}) - }) - ); + expect(mockStore.setNodeStatuses).toHaveBeenCalled(); + Object.defineProperty(navigator, 'userAgent', {value: originalUA}); }); - test('should handle isEqual comparison with null and non-null values', () => { - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Initially store has null/empty status + test('should handle isEqual comparison with null values', () => { + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); mockStore.nodeStatus = {node1: null}; - - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.runAllTimers(); expect(mockStore.setNodeStatuses).toHaveBeenCalled(); }); }); describe('Error handling and reconnection', () => { - test('should handle errors and try to reconnect', () => { + test('should handle errors and try to reconnect with exponential backoff', () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } + mockEventSource.onerror({status: 500}); expect(console.error).toHaveBeenCalled(); expect(console.info).toHaveBeenCalledWith(expect.stringContaining('Reconnecting in')); - }); - - test('should handle 401 error with silent token renewal', async () => { - const mockUser = { - access_token: 'silent-renewed-token', - expires_at: Date.now() + 3600000 - }; - window.oidcUserManager = {signinSilent: jest.fn().mockResolvedValue(mockUser)}; - localStorageMock.getItem.mockReturnValue(null); - eventSourceManager.createEventSource(URL_NODE_EVENT, 'old-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 401}); - } - await Promise.resolve(); - expect(window.oidcUserManager.signinSilent).toHaveBeenCalled(); - expect(localStorageMock.setItem).toHaveBeenCalledWith('authToken', 'silent-renewed-token'); + const delay = setTimeoutSpy.mock.calls[0][1]; + expect(delay).toBeGreaterThanOrEqual(1000); + expect(delay).toBeLessThanOrEqual(30000); + setTimeoutSpy.mockRestore(); }); test('should not redirect if page not active on max attempts', () => { @@ -1491,7 +679,7 @@ describe('eventSourceManager', () => { onerror: jest.fn(), addEventListener: jest.fn(), close: jest.fn(), - readyState: 1, + readyState: 1 }; EventSourcePolyfill.mockImplementation(() => currentMock); } @@ -1499,63 +687,31 @@ describe('eventSourceManager', () => { expect(window.dispatchEvent).not.toHaveBeenCalled(); }); - test('should schedule reconnection with exponential backoff', () => { - const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); - eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); - const delay = setTimeoutSpy.mock.calls[0][1]; - expect(delay).toBeGreaterThanOrEqual(1000); - expect(delay).toBeLessThanOrEqual(30000); - setTimeoutSpy.mockRestore(); - }); - test('should not reconnect when no current token', () => { localStorageMock.getItem.mockReturnValue(null); eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - if (mockEventSource.onerror) { - mockEventSource.onerror({status: 500}); - } + mockEventSource.onerror({status: 500}); jest.advanceTimersByTime(2000); expect(EventSourcePolyfill).toHaveBeenCalledTimes(1); }); }); describe('Utility functions and helpers', () => { - test('should create query string with default filters', () => { + test('should handle valid, invalid, and empty filters in createQueryString', () => { eventSourceManager.configureEventSource('fake-token'); - expect(EventSourcePolyfill).toHaveBeenCalledWith(expect.stringContaining('cache=true'), expect.any(Object)); - }); + expect(EventSourcePolyfill.mock.calls[0][0]).toContain('cache=true'); - test('should handle invalid filters in createQueryString', () => { + jest.clearAllMocks(); eventSourceManager.configureEventSource('fake-token', null, ['InvalidFilter', 'NodeStatusUpdated']); expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid filters detected')); expect(EventSourcePolyfill.mock.calls[0][0]).toContain('filter=NodeStatusUpdated'); - }); - - test('should handle invalid filters and fallback to defaults', () => { - eventSourceManager.configureEventSource('fake-token', null, ['InvalidFilter1', 'InvalidFilter2']); - expect(console.warn).toHaveBeenCalledWith( - 'Invalid filters detected: InvalidFilter1, InvalidFilter2. Using only valid ones.' - ); - expect(EventSourcePolyfill).toHaveBeenCalled(); - }); - test('should handle empty filters array', () => { + jest.clearAllMocks(); eventSourceManager.configureEventSource('fake-token', null, []); expect(console.warn).toHaveBeenCalledWith('No valid API event filters provided, using default filters'); expect(EventSourcePolyfill).toHaveBeenCalled(); }); - test('should create query string without objectName', () => { - eventSourceManager.configureEventSource('fake-token'); - const url = EventSourcePolyfill.mock.calls[0][0]; - expect(url).toContain('cache=true'); - expect(url).not.toContain('path='); - }); - test('should dispatch auth redirect event', () => { eventSourceManager.navigationService.redirectToAuth(); expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); @@ -1565,31 +721,17 @@ describe('eventSourceManager', () => { }); test('should export prepareForNavigation as alias for forceFlush', () => { - expect(eventSourceManager.prepareForNavigation).toBeDefined(); expect(typeof eventSourceManager.prepareForNavigation).toBe('function'); - - const eventSource = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); - const nodeStatusHandler = eventSource.addEventListener.mock.calls.find( - call => call[0] === 'NodeStatusUpdated' - )[1]; - - // Send event to populate buffer (eventCount > 0) - nodeStatusHandler({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); - - // Cancel the debounce timer so only prepareForNavigation triggers the flush + const es = eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); + getHandler(es, 'NodeStatusUpdated')({data: JSON.stringify({node: 'node1', node_status: {status: 'up'}})}); jest.clearAllTimers(); - eventSourceManager.prepareForNavigation(); - expect(mockStore.setNodeStatuses).toHaveBeenCalled(); }); test('should configure EventSource with objectName - adds path to filter URL', () => { eventSourceManager.configureEventSource('fake-token', 'my-service/svc1'); - const url = EventSourcePolyfill.mock.calls[0][0]; - // objectName is encoded in the filter param as "EventType,path=objectName" - // after encodeURIComponent: %2Cpath%3D - expect(url).toContain('path%3D'); + expect(EventSourcePolyfill.mock.calls[0][0]).toContain('path%3D'); }); }); @@ -1598,56 +740,64 @@ describe('eventSourceManager', () => { EventSourcePolyfill.mockImplementation(() => mockLoggerEventSource); }); - test('should create logger EventSource and attach listeners based on filters', () => { - const filters = ['ObjectStatusUpdated', 'InstanceStatusUpdated']; - const loggerSource = eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', filters); - expect(EventSourcePolyfill).toHaveBeenCalledTimes(1); - expect(loggerSource.addEventListener).toHaveBeenCalledTimes(2); - expect(loggerSource.addEventListener.mock.calls[0][0]).toBe('ObjectStatusUpdated'); - expect(loggerSource.addEventListener.mock.calls[1][0]).toBe('InstanceStatusUpdated'); + test('should create logger EventSource and attach listeners for valid filters only', () => { + const ls = eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', + ['ObjectStatusUpdated', 'InstanceStatusUpdated', 'Invalid']); + expect(ls.addEventListener).toHaveBeenCalledTimes(2); + expect(ls.addEventListener.mock.calls[0][0]).toBe('ObjectStatusUpdated'); }); - test('should close existing logger EventSource before creating new', () => { + test('should close existing logger before creating new, and not throw if none', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); - expect(mockLoggerEventSource.close).not.toHaveBeenCalled(); EventSourcePolyfill.mockImplementationOnce(() => ({...mockLoggerEventSource})); eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); expect(mockLoggerEventSource.close).toHaveBeenCalled(); + expect(() => eventSourceManager.closeLoggerEventSource()).not.toThrow(); }); test('should not create logger if no token', () => { - const source = eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, '', []); - expect(source).toBeNull(); + expect(eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, '', [])).toBeNull(); expect(console.error).toHaveBeenCalledWith('❌ Missing token for Logger EventSource!'); }); - test('should handle open event but not log it', () => { + test('should handle open and error events without logging them', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); mockLoggerEventSource.onopen(); expect(console.info).toHaveBeenCalledWith('✅ Logger EventSource connection established'); expect(mockLogStore.addEventLog).not.toHaveBeenCalledWith('CONNECTION_OPENED', expect.any(Object)); - }); - test('should handle error but not log connection error', () => { - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); - const error = {message: 'test error', status: 500}; - mockLoggerEventSource.onerror(error); + mockLoggerEventSource.onerror({message: 'test error', status: 500}); expect(console.error).toHaveBeenCalled(); expect(mockLogStore.addEventLog).not.toHaveBeenCalledWith('CONNECTION_ERROR', expect.any(Object)); }); - test('should handle 401 error in logger with silent renew', async () => { + test('should handle 401 in logger: new token, silent renew success and failure', async () => { + // New token from storage + localStorageMock.getItem.mockReturnValue('new-token'); + eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); + mockLoggerEventSource.onerror({status: 401}); + expect(console.info).toHaveBeenCalledWith('🔄 New token available, updating Logger EventSource'); + + // Silent renew success const mockUser = {access_token: 'new-logger-token', expires_at: Date.now() + 3600000}; window.oidcUserManager = {signinSilent: jest.fn().mockResolvedValue(mockUser)}; localStorageMock.getItem.mockReturnValue(null); eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); mockLoggerEventSource.onerror({status: 401}); await Promise.resolve(); - expect(window.oidcUserManager.signinSilent).toHaveBeenCalled(); expect(localStorageMock.setItem).toHaveBeenCalledWith('authToken', 'new-logger-token'); + + // Silent renew failure + window.oidcUserManager = {signinSilent: jest.fn().mockRejectedValue(new Error('logger renew fail'))}; + localStorageMock.getItem.mockReturnValue(null); + eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); + mockLoggerEventSource.onerror({status: 401}); + await Promise.resolve(); + await Promise.resolve(); + expect(console.error).toHaveBeenCalledWith('❌ Silent renew failed for logger:', expect.any(Error)); }); - test('should handle max reconnections in logger without logging', () => { + test('should handle max reconnections in logger without connection logging', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); for (let i = 0; i < 15; i++) { mockLoggerEventSource.onerror({status: 500}); @@ -1658,28 +808,33 @@ describe('eventSourceManager', () => { expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); }); - test('should process events and log them', () => { + test('should process events and log them, handle invalid JSON', () => { + eventSourceManager.setPageActive(true); eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', ['ObjectStatusUpdated']); const handler = mockLoggerEventSource.addEventListener.mock.calls[0][1]; handler({data: JSON.stringify({path: 'test'})}); expect(mockLogStore.addEventLog).toHaveBeenCalledWith('ObjectStatusUpdated', expect.any(Object)); + + handler({data: 'not valid json'}); + expect(mockLogStore.addEventLog).toHaveBeenCalledWith('ObjectStatusUpdated_PARSE_ERROR', + expect.objectContaining({error: expect.any(String), rawData: 'not valid json'}) + ); }); - test('should ignore invalid filters', () => { - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', ['Invalid', 'ObjectStatusUpdated']); - expect(mockLoggerEventSource.addEventListener).toHaveBeenCalledTimes(1); - expect(mockLoggerEventSource.addEventListener.mock.calls[0][0]).toBe('ObjectStatusUpdated'); + test('should ignore events when page not active', () => { + eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', ['ObjectStatusUpdated']); + eventSourceManager.setPageActive(false); + mockLoggerEventSource.addEventListener.mock.calls[0][1]({data: JSON.stringify({path: 'test'})}); + expect(mockLogStore.addEventLog).not.toHaveBeenCalled(); }); - test('should call _cleanup on close', () => { - const cleanupSpy = jest.fn(); + test('should call and handle errors in logger _cleanup', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); + const cleanupSpy = jest.fn(); mockLoggerEventSource._cleanup = cleanupSpy; eventSourceManager.closeLoggerEventSource(); expect(cleanupSpy).toHaveBeenCalled(); - }); - test('should handle error in _cleanup for logger', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); mockLoggerEventSource._cleanup = () => { throw new Error('logger cleanup error'); @@ -1688,41 +843,19 @@ describe('eventSourceManager', () => { expect(console.debug).toHaveBeenCalledWith('Error during logger eventSource cleanup', expect.any(Error)); }); - test('should not throw if no logger source', () => { - expect(() => eventSourceManager.closeLoggerEventSource()).not.toThrow(); - }); - - test('should not update if no new token', () => { + test('should not update logger if no new token', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); eventSourceManager.updateLoggerEventSourceToken(''); expect(mockLoggerEventSource.close).not.toHaveBeenCalled(); }); - test('should handle missing token in configureLoggerEventSource', () => { + test('should handle missing token in configureLoggerEventSource and startLoggerReception', () => { eventSourceManager.configureLoggerEventSource(''); expect(console.error).toHaveBeenCalledWith('❌ No token provided for Logger SSE!'); - }); - - test('should handle missing token in startLoggerReception', () => { eventSourceManager.startLoggerReception(''); expect(console.error).toHaveBeenCalledWith('❌ No token provided for Logger SSE!'); }); - test('should ignore events when page not active', () => { - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', ['ObjectStatusUpdated']); - eventSourceManager.setPageActive(false); - const handler = mockLoggerEventSource.addEventListener.mock.calls[0][1]; - handler({data: JSON.stringify({path: 'test'})}); - expect(mockLogStore.addEventLog).not.toHaveBeenCalled(); - }); - - test('should update logger with new token from storage on auth error', () => { - localStorageMock.getItem.mockReturnValue('new-token'); - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); - mockLoggerEventSource.onerror({status: 401}); - expect(console.info).toHaveBeenCalledWith('🔄 New token available, updating Logger EventSource'); - }); - test('should ignore onerror if not current logger EventSource', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', []); eventSourceManager.closeLoggerEventSource(); @@ -1732,73 +865,29 @@ describe('eventSourceManager', () => { test('should restart logger EventSource when updateLoggerEventSourceToken called with open connection', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', ['ObjectStatusUpdated']); - - const nextLoggerMock = { - onopen: jest.fn(), - onerror: null, - addEventListener: jest.fn(), - close: jest.fn(), - readyState: 1, - url: `${URL_NODE_EVENT}?cache=true&filter=ObjectStatusUpdated`, - }; - EventSourcePolyfill.mockImplementation(() => nextLoggerMock); - - // getCurrentToken() is called inside the setTimeout callback — - // make localStorage return the new token so the callback proceeds + const nextMock = {...mockLoggerEventSource, close: jest.fn(), addEventListener: jest.fn()}; + EventSourcePolyfill.mockImplementation(() => nextMock); localStorageMock.getItem.mockReturnValue('new-token'); - eventSourceManager.updateLoggerEventSourceToken('new-token'); expect(mockLoggerEventSource.close).toHaveBeenCalled(); - - // Fire the setTimeout(..., 100) callback — calls createLoggerEventSource jest.advanceTimersByTime(200); expect(EventSourcePolyfill).toHaveBeenCalledTimes(2); }); test('should not restart logger EventSource when connection is already closed', () => { eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); - mockLoggerEventSource.readyState = 2; // CLOSED + mockLoggerEventSource.readyState = 2; eventSourceManager.updateLoggerEventSourceToken('new-token'); expect(EventSourcePolyfill).toHaveBeenCalledTimes(1); }); - test('should configure logger EventSource with objectName', () => { + + test('should configure and start logger with objectName', () => { eventSourceManager.configureLoggerEventSource('fake-token', 'my-service/svc1'); - const url = EventSourcePolyfill.mock.calls[0][0]; - expect(url).toContain('path%3D'); - }); + expect(EventSourcePolyfill.mock.calls[0][0]).toContain('path%3D'); - test('should start logger reception with objectName', () => { + jest.clearAllMocks(); eventSourceManager.startLoggerReception('fake-token', eventSourceManager.DEFAULT_FILTERS, 'my-service/svc1'); - const url = EventSourcePolyfill.mock.calls[0][0]; - expect(url).toContain('path%3D'); - }); - - test('should handle invalid JSON in logger event and log parse error', () => { - // Ensure page is active (setPageActive(false) in other tests may have left it false) - eventSourceManager.setPageActive(true); - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'fake-token', ['ObjectStatusUpdated']); - const handler = mockLoggerEventSource.addEventListener.mock.calls[0][1]; - handler({data: 'not valid json'}); - expect(mockLogStore.addEventLog).toHaveBeenCalledWith( - 'ObjectStatusUpdated_PARSE_ERROR', - expect.objectContaining({ - error: expect.any(String), - rawData: 'not valid json' - }) - ); - }); - - test('should handle silent renew failure in logger', async () => { - window.oidcUserManager = {signinSilent: jest.fn().mockRejectedValue(new Error('logger renew fail'))}; - localStorageMock.getItem.mockReturnValue(null); - eventSourceManager.createLoggerEventSource(URL_NODE_EVENT, 'old-token', []); - mockLoggerEventSource.onerror({status: 401}); - - await Promise.resolve(); - await Promise.resolve(); - - expect(console.error).toHaveBeenCalledWith('❌ Silent renew failed for logger:', expect.any(Error)); - expect(window.dispatchEvent).toHaveBeenCalledWith(expect.any(CustomEvent)); + expect(EventSourcePolyfill.mock.calls[0][0]).toContain('path%3D'); }); }); }); From bd65aefbb9f2c99f13d4df8f2435eb54992d52b0 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Wed, 3 Jun 2026 09:36:33 +0200 Subject: [PATCH 05/24] refactor(tests): rewrite Objects tests for clarity and maintainability - Extract a shared `setup` helper and a `defaultState` object to eliminate repetitive mock and state initialization - Add utility functions (`waitForLoad`, `selectFilter`, `selectRow`, `openActionsMenu`, `clickMenuItem`, `confirmDialog`) to reduce boilerplate - Parametrize filter tests using `test.each` instead of duplicating test cases - Group related tests into logical `describe` blocks (filtering, actions, sorting, etc.) - Remove redundant or overly specific tests while preserving coverage - Simplify console.error suppression and mock lifecycle management --- src/components/tests/Objects.test.jsx | 2600 +++++-------------------- 1 file changed, 457 insertions(+), 2143 deletions(-) diff --git a/src/components/tests/Objects.test.jsx b/src/components/tests/Objects.test.jsx index 8e666be..8294dcd 100644 --- a/src/components/tests/Objects.test.jsx +++ b/src/components/tests/Objects.test.jsx @@ -12,7 +12,6 @@ jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), useLocation: jest.fn(), })); - jest.mock('../../hooks/useEventStore'); jest.mock('../../hooks/useFetchDaemonStatus'); jest.mock('../../eventSourceManager'); @@ -21,2215 +20,530 @@ jest.mock('@mui/material/Collapse', () => ({in: inProp, children}) => inProp ? children : null ); -const AVAILABLE_ACTIONS = [ - 'start', - 'stop', - 'restart', - 'freeze', - 'unfreeze', - 'delete', - 'provision', - 'unprovision', - 'purge', - 'switch', - 'giveback', - 'abort', -]; - expect.extend(toHaveNoViolations); -const selectFilterOption = async (labelText, optionText) => { - const filter = screen.getByLabelText(labelText); +// ---------- helpers ---------- +const mockNavigate = jest.fn(); +const mockRemoveObject = jest.fn(); +let originalConsoleError; + +const defaultState = { + objectStatus: { + 'test-ns/svc/test1': {avail: 'up', frozen: 'unfrozen'}, + 'test-ns/svc/test2': {avail: 'down', frozen: 'frozen'}, + 'root/svc/test3': {avail: 'warn', frozen: 'unfrozen'}, + 'test-ns/svc/test4': {avail: 'n/a', frozen: 'unfrozen'}, + 'test-ns/svc/unprovisioned': {avail: 'n/a', frozen: 'unfrozen', provisioned: 'false'}, + }, + objectInstanceStatus: { + 'test-ns/svc/test1': { + node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, + node2: {avail: 'down', frozen_at: '2025-05-16T10:00:00Z'}, + }, + 'test-ns/svc/test2': { + node1: {avail: 'down', frozen_at: '2025-05-16T10:00:00Z'}, + }, + 'root/svc/test3': {node2: {avail: 'warn', frozen_at: '0001-01-01T00:00:00Z'}}, + 'test-ns/svc/test4': {}, + 'test-ns/svc/unprovisioned': { + node1: {avail: 'n/a', frozen_at: '0001-01-01T00:00:00Z', provisioned: 'false'}, + }, + }, + instanceMonitor: { + 'node1:test-ns/svc/test1': {state: 'running', global_expect: 'frozen'}, + 'node2:test-ns/svc/test1': {state: 'idle', global_expect: 'none'}, + 'node1:test-ns/svc/test2': {state: 'failed', global_expect: 'none'}, + 'node2:root/svc/test3': {state: 'idle', global_expect: 'started'}, + }, + removeObject: mockRemoveObject, +}; - fireEvent.mouseDown(filter); +const setup = (customState = {}, locationSearch = '', mediaQuery = true, {daemon} = {}) => { + const state = {...defaultState, ...customState}; + useEventStore.mockImplementation((sel) => sel(state)); + useFetchDaemonStatus.mockReturnValue({daemon: daemon || {cluster: {object: {}}}}); + startEventReception.mockClear(); + closeEventSource.mockClear(); + global.fetch = jest.fn(() => Promise.resolve({ok: true, json: () => Promise.resolve({})})); + require('react-router-dom').useLocation.mockReturnValue({search: locationSearch, pathname: '/objects'}); + require('react-router-dom').useNavigate.mockReturnValue(mockNavigate); + require('@mui/material/useMediaQuery').mockReturnValue(mediaQuery); + + const utils = render( + + + + ); + return {...utils, state}; +}; - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); +const waitForLoad = () => + waitFor(() => expect(screen.getByLabelText('Global State')).toBeInTheDocument()); +const selectFilter = async (label, optionText) => { + const filter = screen.getByLabelText(label); + fireEvent.mouseDown(filter); + await waitFor(() => expect(screen.getByRole('listbox')).toBeInTheDocument()); const listbox = screen.getByRole('listbox'); - const options = within(listbox).getAllByRole('option'); - const option = options.find(opt => - opt.textContent.toLowerCase().includes(optionText.toLowerCase()) - ); - - if (!option) { - throw new Error(`Option with text "${optionText}" not found`); - } - - const checkbox = within(option).getByRole('checkbox'); - fireEvent.click(checkbox); - - const backdrop = document.querySelector('.MuiBackdrop-root, .MuiModal-backdrop'); - if (backdrop) { - fireEvent.click(backdrop); - } else { - fireEvent.keyDown(listbox, {key: 'Escape', code: 'Escape'}); - } + const option = options.find((o) => o.textContent.toLowerCase().includes(optionText.toLowerCase())); + if (!option) throw new Error(`Option "${optionText}" not found`); + fireEvent.click(within(option).getByRole('checkbox')); + fireEvent.keyDown(listbox, {key: 'Escape', code: 'Escape'}); + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument(), {timeout: 1000}); }; -const waitForFilterApplied = async () => { - await waitFor(() => { - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByRole('table')).toBeInTheDocument(); - }, {timeout: 1000}); +const selectRow = (name) => { + const row = screen.getByRole('row', {name: new RegExp(name, 'i')}); + const cb = within(row).getByRole('checkbox'); + fireEvent.click(cb); + return cb; }; -describe('Objects Component', () => { - const mockNavigate = jest.fn(); - const mockStartEventReception = jest.fn(); - const mockCloseEventSource = jest.fn(); - const mockRemoveObject = jest.fn(); - const allNodes = ['node1', 'node2']; - - let originalConsoleError; - - beforeEach(() => { - originalConsoleError = console.error; - console.error = jest.fn((message, ...args) => { - if (typeof message === 'string' && - (message.includes('A props object containing a "key" prop is being spread into JSX') || - message.includes('
  • cannot appear as a descendant of
  • '))) { - return; - } - originalConsoleError.call(console, message, ...args); - }); - - jest.clearAllMocks(); - - require('react-router-dom').useLocation.mockReturnValue({ - search: '', - pathname: '/objects', - }); - require('react-router-dom').useNavigate.mockReturnValue(mockNavigate); - - require('@mui/material/useMediaQuery').mockReturnValue(true); - - const mockState = { - objectStatus: { - 'test-ns/svc/test1': {avail: 'up', frozen: 'unfrozen'}, - 'test-ns/svc/test2': {avail: 'down', frozen: 'frozen'}, - 'root/svc/test3': {avail: 'warn', frozen: 'unfrozen'}, - 'test-ns/svc/test4': {avail: 'n/a', frozen: 'unfrozen'}, - 'test-ns/svc/unprovisioned': {avail: 'n/a', frozen: 'unfrozen', provisioned: 'false'}, - }, - objectInstanceStatus: { - 'test-ns/svc/test1': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, - node2: {avail: 'down', frozen_at: '2025-05-16T10:00:00Z'}, - }, - 'test-ns/svc/test2': { - node1: {avail: 'down', frozen_at: '2025-05-16T10:00:00Z'}, - }, - 'root/svc/test3': { - node2: {avail: 'warn', frozen_at: '0001-01-01T00:00:00Z'}, - }, - 'test-ns/svc/test4': {}, - 'test-ns/svc/unprovisioned': { - node1: {avail: 'n/a', frozen_at: '0001-01-01T00:00:00Z', provisioned: 'false'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/test1': {state: 'running', global_expect: 'frozen'}, - 'node2:test-ns/svc/test1': {state: 'idle', global_expect: 'none'}, - 'node1:test-ns/svc/test2': {state: 'failed', global_expect: 'none'}, - 'node2:root/svc/test3': {state: 'idle', global_expect: 'started'}, - }, - removeObject: mockRemoveObject, - }; - - useEventStore.mockImplementation((selector) => selector(mockState)); - - useFetchDaemonStatus.mockReturnValue({ - daemon: {cluster: {object: {}}}, - }); - - startEventReception.mockImplementation(mockStartEventReception); - closeEventSource.mockImplementation(mockCloseEventSource); - - jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('mock-token'); - - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }) - ); - }); - - afterEach(() => { - console.error = originalConsoleError; - jest.restoreAllMocks(); - require('@mui/material/useMediaQuery').mockReturnValue(true); - }); - - const setupComponent = () => { - return render( - - - - ); - }; - - const waitForComponentToLoad = async () => { - await waitFor(() => { - expect(screen.getByLabelText('Global State')).toBeInTheDocument(); - }); - }; +const openActionsMenu = () => + fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - const verifyStatusColumn = (row, expectedIcons, expectedCaption = null) => { - const cells = within(row).getAllByRole('cell'); - const statusCell = cells[1]; - - expectedIcons.forEach((icon) => { - const label = icon === 'warn' ? 'Object has warning' : `Object is ${icon}`; - expect(within(statusCell).getByLabelText(label)).toBeInTheDocument(); - }); - - if (expectedCaption) { - expect(within(statusCell).getByText(expectedCaption)).toBeInTheDocument(); - } - }; +const clickMenuItem = async (text) => { + const menu = await screen.findByRole('menu'); + fireEvent.click(within(menu).getByText(new RegExp(`^${text}$`, 'i'))); +}; - const verifyNodeColumn = (row, node, expectedIcons) => { - const nodeIndex = allNodes.indexOf(node); - const nodeCell = within(row).getAllByRole('cell')[nodeIndex + 3]; +const confirmDialog = async (buttonName = /Confirm|Stop|Delete/i) => { + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', {name: buttonName})); +}; - if (expectedIcons.length === 0) { - expect(within(nodeCell).queryByLabelText(/Node .+ is .+/)).toBeNull(); - expect(within(nodeCell).queryByLabelText(/Node .+ has warning/)).toBeNull(); - expect(within(nodeCell).queryByLabelText(/Node .+ is frozen/)).toBeNull(); - expect(within(nodeCell).queryByLabelText(/Node .+ is not provisioned/)).toBeNull(); +beforeEach(() => { + originalConsoleError = console.error; + console.error = jest.fn((msg, ...args) => { + if ( + typeof msg === 'string' && + (msg.includes('A props object containing a "key" prop is being spread into JSX') || + msg.includes('
  • cannot appear as a descendant of
  • ')) + ) return; - } - - expectedIcons.forEach((icon) => { - const label = icon === 'warn' ? `Node ${node} has warning` : `Node ${node} is ${icon}`; - expect(within(nodeCell).getByLabelText(label)).toBeInTheDocument(); - }); - }; - - const selectObject = async (objectName) => { - const row = screen.getByRole('row', {name: new RegExp(objectName, 'i')}); - const checkbox = within(row).getByRole('checkbox'); - fireEvent.click(checkbox); - return checkbox; - }; - - test('renders correctly with initial state', async () => { - setupComponent(); + originalConsoleError.call(console, msg, ...args); + }); + jest.clearAllMocks(); + jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('mock-token'); + mockRemoveObject.mockClear(); +}); - await waitForComponentToLoad(); +afterEach(() => { + console.error = originalConsoleError; + jest.restoreAllMocks(); +}); - expect(screen.getByLabelText('Global State')).toBeInTheDocument(); - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); - expect(screen.getByLabelText('Kind')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toBeInTheDocument(); +// ---------- tests ---------- +describe('Objects Component', () => { + test('initial render and data fetch', async () => { + const {unmount} = setup(); + await waitForLoad(); expect(screen.getByText('Status')).toBeInTheDocument(); expect(screen.getByText('Object')).toBeInTheDocument(); expect(screen.getByRole('columnheader', {name: /node1/i})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: /node2/i})).toBeInTheDocument(); + expect(startEventReception).toHaveBeenCalledWith('mock-token', expect.any(Array)); + unmount(); + expect(closeEventSource).toHaveBeenCalled(); + }); + + test('displays objects with correct status and node data', async () => { + setup(); + await waitForLoad(); + ['test-ns/svc/test1', 'test-ns/svc/test2', 'root/svc/test3'].forEach((name) => + expect(screen.getByRole('row', {name: new RegExp(name)})).toBeInTheDocument() + ); + const row1 = screen.getByRole('row', {name: /test1/}); + expect(within(row1).getByLabelText('Object is up')).toBeInTheDocument(); + expect(within(row1).getByText('frozen')).toBeInTheDocument(); + expect(within(row1).getByLabelText('Node node2 is down')).toBeInTheDocument(); + expect(within(row1).getByLabelText('Node node2 is frozen')).toBeInTheDocument(); + }); + + test('selection and select all', async () => { + setup(); + await waitForLoad(); + const cb = selectRow('test-ns/svc/test1'); + expect(cb).toBeChecked(); + const selectAll = screen.getAllByRole('checkbox')[0]; + fireEvent.click(selectAll); + screen.getAllByRole('checkbox').slice(1).forEach((c) => expect(c).toBeChecked()); + fireEvent.click(selectAll); + screen.getAllByRole('checkbox').slice(1).forEach((c) => expect(c).not.toBeChecked()); + }); + + test('actions menu opens and lists actions', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + const menu = await screen.findByRole('menu'); + ['Restart', 'Stop', 'Freeze', 'Delete'].forEach((a) => + expect(within(menu).getByText(a)).toBeInTheDocument() + ); }); - test('fetches data on mount and cleans up on unmount', async () => { - const {unmount} = setupComponent(); - - await waitForComponentToLoad(); - - expect(startEventReception).toHaveBeenCalledWith( - "mock-token", - [ - "ObjectStatusUpdated", - "InstanceStatusUpdated", - "ObjectDeleted", - "InstanceMonitorUpdated" - ] + describe('filtering', () => { + const filterTests = [ + { + label: 'Namespace', + option: 'test-ns', + visible: ['test-ns/svc/test1', 'test-ns/svc/test2'], + hidden: ['root/svc/test3'] + }, + {label: 'Global State', option: 'Up', visible: ['test-ns/svc/test1'], hidden: ['test-ns/svc/test2']}, + {label: 'Kind', option: 'svc', visible: ['test-ns/svc/test1'], hidden: []}, + { + label: 'Name', + option: 'test1', + visible: ['test-ns/svc/test1'], + hidden: ['test-ns/svc/test2', 'root/svc/test3'], + isSearch: true + }, + ]; + + test.each(filterTests)( + '$label filter', + async ({label, option, visible, hidden, isSearch}) => { + setup(); + await waitForLoad(); + if (isSearch) { + fireEvent.change(screen.getByLabelText('Name'), {target: {value: option}}); + } else { + await selectFilter(label, option); + } + await waitFor(() => { + visible.forEach((n) => expect(screen.getByRole('row', {name: new RegExp(n)})).toBeInTheDocument()); + hidden.forEach((n) => expect(screen.queryByRole('row', {name: new RegExp(n)})).not.toBeInTheDocument()); + }); + } ); - - unmount(); - - expect(closeEventSource).toHaveBeenCalledTimes(1); }); - test('displays objects table with correct data', async () => { - setupComponent(); - await waitForComponentToLoad(); - + test('multiple filters combined', async () => { + setup(); + await waitForLoad(); + await selectFilter('Namespace', 'test-ns'); + await selectFilter('Global State', 'Up'); await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: /test1/})).toBeInTheDocument(); + expect(screen.queryByRole('row', {name: /test2/})).not.toBeInTheDocument(); }); - - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /root\/svc\/test3/i})).toBeInTheDocument(); - - const rows = screen.getAllByRole('row').slice(1); - const rowTexts = rows.map((row) => row.textContent); - - const test1Row = rows[rowTexts.findIndex((text) => text.includes('test-ns/svc/test1'))]; - const test2Row = rows[rowTexts.findIndex((text) => text.includes('test-ns/svc/test2'))]; - const test3Row = rows[rowTexts.findIndex((text) => text.includes('root/svc/test3'))]; - - verifyStatusColumn(test1Row, ['up'], 'frozen'); - verifyStatusColumn(test2Row, ['down', 'frozen']); - verifyStatusColumn(test3Row, ['warn'], 'started'); - - verifyNodeColumn(test1Row, 'node1', ['up']); - verifyNodeColumn(test1Row, 'node2', ['down', 'frozen']); - verifyNodeColumn(test2Row, 'node1', ['down', 'frozen']); - verifyNodeColumn(test2Row, 'node2', []); - verifyNodeColumn(test3Row, 'node1', []); - verifyNodeColumn(test3Row, 'node2', ['warn']); }); - test('handles object selection', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const checkbox = await selectObject('test-ns/svc/test1'); - - expect(checkbox).toBeChecked(); + test('chips remove filters', async () => { + setup(); + await waitForLoad(); + await selectFilter('Namespace', 'test-ns'); + const chip = screen.getByText('test-ns').closest('.MuiChip-root'); + fireEvent.click(within(chip).getByTestId('CloseIcon')); + await waitFor(() => expect(screen.getByRole('row', {name: /root\/svc\/test3/})).toBeInTheDocument()); + }); + + test('empty message when nothing matches', async () => { + setup(); + await waitForLoad(); + fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'nonexistent'}}); + await waitFor(() => expect(screen.getByText(/No objects found/)).toBeInTheDocument()); + }); + + describe('actions execution', () => { + test('restart succeeds', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/action/restart'), expect.any(Object) + )); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i)); + }); + + test('unfreeze succeeds on frozen object', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test2'); + openActionsMenu(); + await clickMenuItem('Unfreeze'); + await confirmDialog(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/action/unfreeze'), expect.any(Object) + )); + }); + + test('delete succeeds with confirmations', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Delete'); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + fireEvent.click(screen.getByLabelText(/Confirm configuration loss/i)); + fireEvent.click(screen.getByLabelText(/Confirm clusterwide orchestration/i)); + fireEvent.click(screen.getByRole('button', {name: /Delete/i})); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/action/delete'), expect.any(Object) + )); + expect(mockRemoveObject).toHaveBeenCalledWith('test-ns/svc/test1'); + }); + + test('failed action shows error alert', async () => { + setup(); + global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 500})); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/failed/i)); + }); + + test('partial success shows warning', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + await selectRow('test-ns/svc/test2'); + global.fetch = jest.fn() + .mockResolvedValueOnce({ok: true}) + .mockResolvedValueOnce({ok: false, status: 500}); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/partially succeeded: 1 ok, 1 errors/i)); + }); + + test('network error', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/failed on all 1 object\(s\)/i)); + }); + + test('token missing prevents action', async () => { + Storage.prototype.getItem.mockReturnValue(null); + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Authentication token not found')); + }); + }); + + test('row click navigates', async () => { + setup(); + await waitForLoad(); + fireEvent.click(screen.getByRole('row', {name: /test-ns\/svc\/test1/})); + expect(mockNavigate).toHaveBeenCalledWith('/objects/test-ns%2Fsvc%2Ftest1'); }); - test('handles select all objects', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const selectAllCheckbox = screen.getAllByRole('checkbox')[0]; - fireEvent.click(selectAllCheckbox); - - const allCheckboxes = screen.getAllByRole('checkbox').slice(1); - allCheckboxes.forEach((checkbox) => { - expect(checkbox).toBeChecked(); - }); + test('no navigation if no instance status', async () => { + setup({objectInstanceStatus: {}}); + await waitForLoad(); + fireEvent.click(screen.getByRole('row', {name: /test-ns\/svc\/test1/})); + expect(mockNavigate).not.toHaveBeenCalled(); }); - test('handles deselect all when all are selected', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const selectAllCheckbox = screen.getAllByRole('checkbox')[0]; - fireEvent.click(selectAllCheckbox); - - await waitFor(() => { - const allCheckboxes = screen.getAllByRole('checkbox').slice(1); - allCheckboxes.forEach((checkbox) => { - expect(checkbox).toBeChecked(); - }); - }); - - fireEvent.click(selectAllCheckbox); - - await waitFor(() => { - const allCheckboxes = screen.getAllByRole('checkbox').slice(1); - allCheckboxes.forEach((checkbox) => { - expect(checkbox).not.toBeChecked(); - }); - }); + test('row context menu shows correct actions', async () => { + setup(); + await waitForLoad(); + const row = screen.getByRole('row', {name: /test1/}); + fireEvent.click(within(row).getByRole('button', {name: /more actions/i})); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + expect(screen.getByText('Freeze')).toBeInTheDocument(); + expect(screen.queryByText('Unfreeze')).not.toBeInTheDocument(); }); - test('opens actions menu', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - - const menu = await screen.findByRole('menu'); - expect(menu).toBeInTheDocument(); - - AVAILABLE_ACTIONS.forEach((action) => { - expect( - within(menu).getByText(action.charAt(0).toUpperCase() + action.slice(1)) - ).toBeInTheDocument(); - }); + test('row context menu for frozen object', async () => { + setup(); + await waitForLoad(); + const row = screen.getByRole('row', {name: /test2/}); + fireEvent.click(within(row).getByRole('button', {name: /more actions/i})); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + expect(screen.queryByText('Freeze')).not.toBeInTheDocument(); + expect(screen.getByText('Unfreeze')).toBeInTheDocument(); }); - test('filters objects by namespace', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Namespace', 'test-ns'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - }); - - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /root\/svc\/test3/i})).not.toBeInTheDocument(); + test('global actions disabled when none selected', async () => { + setup(); + await waitForLoad(); + expect(screen.getByRole('button', {name: /actions on selected objects/i})).toBeDisabled(); }); - test('filters objects by search query', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const searchInput = screen.getByLabelText('Name'); - fireEvent.change(searchInput, {target: {value: 'test1'}}); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); + test('toggles filters visibility', async () => { + setup(); + await waitForLoad(); + const btn = screen.getByRole('button', {name: /filters/i}); + expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); + fireEvent.click(btn); + await waitFor(() => expect(btn).toHaveAttribute('aria-label', 'Show filters')); + expect(screen.queryByLabelText('Namespace')).not.toBeInTheDocument(); + fireEvent.click(btn); + await waitFor(() => expect(screen.getByLabelText('Namespace')).toBeInTheDocument()); + }); + + describe('sorting', () => { + const clickHeader = (text) => fireEvent.click(screen.getByText(text)); + test.each(['Status', 'Object', 'node1'])('%s sorting works', async (col) => { + setup(); + await waitForLoad(); + if (col === 'node1') { + fireEvent.click(screen.getByRole('columnheader', {name: /node1/i})); + } else { + clickHeader(col); + } + await waitFor(() => expect(screen.getAllByRole('row').length).toBeGreaterThan(1)); }); - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /root\/svc\/test3/i})).not.toBeInTheDocument(); + test('sort direction toggles', async () => { + setup(); + await waitForLoad(); + clickHeader('Object'); + clickHeader('Object'); + await waitFor(() => expect(screen.getAllByRole('row').length).toBeGreaterThan(1)); + }); }); - test('handles object click navigation', async () => { - setupComponent(); - await waitForComponentToLoad(); - - fireEvent.click(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})); - - expect(mockNavigate).toHaveBeenCalledWith('/objects/test-ns%2Fsvc%2Ftest1'); + test('infinite scroll loads more', async () => { + const many = {}; + const manyInst = {}; + for (let i = 0; i < 50; i++) { + const name = `test-ns/svc/obj${i}`; + many[name] = {avail: 'up', frozen: 'unfrozen'}; + manyInst[name] = {node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}}; + } + setup({objectStatus: many, objectInstanceStatus: manyInst}); + await waitForLoad(); + expect(screen.getAllByRole('row').slice(1)).toHaveLength(30); + const container = document.querySelector('.MuiTableContainer-root'); + Object.defineProperty(container, 'scrollHeight', {value: 1000, configurable: true}); + Object.defineProperty(container, 'clientHeight', {value: 500, configurable: true}); + Object.defineProperty(container, 'scrollTop', {value: 500, configurable: true}); + fireEvent.scroll(container); + await waitFor(() => expect(screen.getAllByRole('row').slice(1).length).toBeGreaterThan(30)); + }); + + test('scroll does nothing when no more items', async () => { + setup({objectStatus: {'a/b': {avail: 'up'}}, objectInstanceStatus: {}}); + await waitForLoad(); + const container = document.querySelector('.MuiTableContainer-root'); + fireEvent.scroll(container); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); }); - test('executes action and shows snackbar', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText(/Restart/i)); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); + test('URL sync debounced', async () => { + jest.useFakeTimers(); + setup(); + await waitForLoad(); + fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'sync'}}); + jest.advanceTimersByTime(300); + expect(mockNavigate).toHaveBeenCalledWith('/objects?name=sync', {replace: true}); + jest.useRealTimers(); + }); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/restart'), - expect.any(Object) - ); + test('URL filters update state on location change', async () => { + const {rerender} = setup(); + await waitForLoad(); + require('react-router-dom').useLocation.mockReturnValue({ + search: '?namespace=test-ns&kind=svc&name=test1', + pathname: '/objects', }); - + rerender( + + + + ); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i); + expect(screen.getByText('test-ns')).toBeInTheDocument(); + expect(screen.getByText('svc')).toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toHaveValue('test1'); }); }); - test('handles failed action execution', async () => { - const originalConsoleErrorInTest = console.error; - - console.error = jest.fn((message, ...args) => { - if (typeof message === 'string' && - (message.includes('Failed to execute') || - message.includes('HTTP error!'))) { - return; - } - originalConsoleErrorInTest.call(console, message, ...args); - }); - - try { - global.fetch.mockImplementation(() => - Promise.resolve({ - ok: false, - status: 500, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText(/Restart/i)); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/failed/i); - }); - } finally { - console.error = originalConsoleErrorInTest; + test('snackbar closes on alert close', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + const closeBtn = screen.getByRole('alert').querySelector('button[aria-label="Close"]'); + if (closeBtn) { + fireEvent.click(closeBtn); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); } }); - test('executes delete action and removes object', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - const menu = screen.getByRole('menu'); - const deleteOption = within(menu).getByText(/^Delete$/i); - fireEvent.click(deleteOption); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - expect(screen.getByText(/Confirm Delete/i)).toBeInTheDocument(); - - const configLossCheckbox = screen.getByLabelText(/Confirm configuration loss/i); - const clusterwideCheckbox = screen.getByLabelText(/Confirm clusterwide orchestration/i); - - fireEvent.click(configLossCheckbox); - fireEvent.click(clusterwideCheckbox); - - fireEvent.click(screen.getByRole('button', {name: /Delete/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/delete'), - expect.any(Object) - ); - }); - - expect(mockRemoveObject).toHaveBeenCalledWith('test-ns/svc/test1'); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i); - }); + test('cancel action dialog', async () => { + setup(); + await waitForLoad(); + await selectRow('test-ns/svc/test1'); + openActionsMenu(); + await clickMenuItem('Restart'); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', {name: /cancel/i})); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(global.fetch).not.toHaveBeenCalled(); }); - test('executes purge action with confirmation', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText(/Purge/i)); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('checkbox', {name: /Confirm data loss/i})); - fireEvent.click(screen.getByRole('checkbox', {name: /Confirm configuration loss/i})); - fireEvent.click(screen.getByRole('checkbox', {name: /Confirm service interruption/i})); - - const confirmButton = screen.getByRole('button', {name: /Confirm/i}); - expect(confirmButton).not.toBeDisabled(); - - fireEvent.click(confirmButton); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/purge'), - expect.any(Object) - ); - }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i); - }); + test('narrow screen hides node columns', async () => { + setup({}, '', false); + await waitForLoad(); + expect(screen.queryByRole('columnheader', {name: /node1/})).not.toBeInTheDocument(); }); - test('executes stop action with confirmation', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText(/Stop/i)); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByLabelText(/I understand that this may interrupt services/i)); - - const confirmButton = screen.getByRole('button', {name: /Stop/i}); - expect(confirmButton).not.toBeDisabled(); - - fireEvent.click(confirmButton); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/stop'), - expect.any(Object) - ); - }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i); - }); + test('daemon fallback when store empty', async () => { + setup( + {objectStatus: {}, objectInstanceStatus: {}}, + '', + true, + {daemon: {cluster: {object: {'daemon/svc/obj1': {avail: 'up', frozen: 'unfrozen'}}}}} + ); + await waitFor(() => + expect(screen.getByRole('row', {name: /daemon\/svc\/obj1/})).toBeInTheDocument() + ); }); - test('executes unprovision action with confirmation', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - - const menu = await screen.findByRole('menu'); - fireEvent.click(within(menu).getByText(/Unprovision/i)); + test('unprovisioned object and node', async () => { + setup(); + await waitForLoad(); + expect(screen.getByLabelText('Object is not provisioned')).toBeInTheDocument(); + expect(screen.getByLabelText('Node node1 is not provisioned')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByRole('dialog', {name: /Confirm Unprovision/i})).toBeInTheDocument(); + test('cluster object parsed as ccfg', async () => { + setup({ + objectStatus: {cluster: {avail: 'up', frozen: 'unfrozen'}}, + objectInstanceStatus: {cluster: {node1: {avail: 'up'}}}, }); - - fireEvent.click(screen.getByLabelText(/I understand data will be lost/i)); - fireEvent.click(screen.getByLabelText(/I understand this action will be orchestrated clusterwide/i)); - fireEvent.click(screen.getByLabelText(/I understand the selected services may be temporarily interrupted during failover, or durably interrupted if no failover is configured/i)); - - const confirmButton = screen.getByRole('button', {name: /Confirm/i}); - expect(confirmButton).not.toBeDisabled(); - - fireEvent.click(confirmButton); - - await waitFor(() => { + await waitForLoad(); + await selectRow('cluster'); + openActionsMenu(); + await clickMenuItem('Restart'); + await confirmDialog(); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/unprovision'), + expect.stringContaining('/root/ccfg/cluster/action/restart'), expect.any(Object) - ); - }); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/succeeded/i); - }); - }); - - test('toggles filters visibility', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const toggleButton = await screen.findByRole('button', {name: /filters/i}); - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); - - fireEvent.click(toggleButton); - - await waitFor(() => { - expect(toggleButton).toHaveAttribute('aria-label', 'Show filters'); - }); - expect(screen.queryByLabelText('Namespace')).not.toBeInTheDocument(); - - fireEvent.click(toggleButton); - - await waitFor(() => { - expect(toggleButton).toHaveAttribute('aria-label', 'Hide filters'); - }); - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); + ) + ); }); - test('filters objects by global state', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - }); - - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /root\/svc\/test3/i})).not.toBeInTheDocument(); - }); - - test('filters objects by kind', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Kind', 'svc'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - }); - - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /root\/svc\/test3/i})).toBeInTheDocument(); - }); - - test('displays no objects when objectStatus is empty', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {}, - objectInstanceStatus: {}, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - - await waitFor(() => { - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); - }); - - expect(screen.getAllByRole('row')).toHaveLength(1); - }); - - test('accessibility check', async () => { - const {container} = setupComponent(); - - await waitForComponentToLoad(); - - const results = await axe(container, { - rules: { - 'aria-prohibited-attr': {enabled: false}, - 'label': {enabled: false} - } + test('accessibility', async () => { + const {container} = setup(); + await waitForLoad(); + const results = await axe(container, { + rules: {'aria-prohibited-attr': {enabled: false}, label: {enabled: false}}, }); expect(results).toHaveNoViolations(); }); - - test('handles objects without instance status', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/test4': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: {}, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test4/i})).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/test4/i}); - expect(row).toBeInTheDocument(); - }); - - test('handles objects with string provisioned status', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/unprovisioned': {avail: 'n/a', frozen: 'unfrozen', provisioned: 'false'}, - }, - objectInstanceStatus: { - 'test-ns/svc/unprovisioned': { - node1: {avail: 'n/a', frozen_at: '0001-01-01T00:00:00Z', provisioned: 'false'}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/unprovisioned/i})).toBeInTheDocument(); - }); - - expect(screen.getByLabelText('Object is not provisioned')).toBeInTheDocument(); - }); - - test('handles objects with boolean provisioned status', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/unprovisioned-bool': {avail: 'n/a', frozen: 'unfrozen', provisioned: false}, - }, - objectInstanceStatus: { - 'test-ns/svc/unprovisioned-bool': { - node1: {avail: 'n/a', frozen_at: '0001-01-01T00:00:00Z', provisioned: false}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/unprovisioned-bool/i})).toBeInTheDocument(); - }); - - expect(screen.getByLabelText('Object is not provisioned')).toBeInTheDocument(); - }); - - test('handles nodes with not provisioned status', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/node-unprovisioned': {avail: 'up', frozen: 'unfrozen', provisioned: 'true'}, - }, - objectInstanceStatus: { - 'test-ns/svc/node-unprovisioned': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z', provisioned: 'false'}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/node-unprovisioned/i})).toBeInTheDocument(); - }); - - expect(screen.getByLabelText('Node node1 is not provisioned')).toBeInTheDocument(); - }); - - test('handles nodes with non-idle state', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/non-idle': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/non-idle': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/non-idle': {state: 'running', global_expect: 'none'}, - }, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/non-idle/i})).toBeInTheDocument(); - }); - - expect(screen.getByText('running')).toBeInTheDocument(); - }); - - test('disables freeze action for frozen objects', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/test1': {avail: 'up', frozen: 'unfrozen'}, - 'test-ns/svc/test2': {avail: 'down', frozen: 'frozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/test1': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, - }, - 'test-ns/svc/test2': { - node1: {avail: 'down', frozen_at: '2025-05-16T10:00:00Z'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/test1': {state: 'running', global_expect: 'none'}, - 'node1:test-ns/svc/test2': {state: 'idle', global_expect: 'none'}, - }, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - const frozenObjectRow = screen.getByRole('row', {name: /test-ns\/svc\/test2/i}); - const menuButton = within(frozenObjectRow).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton); - - await waitFor(() => { - const menu = screen.getByRole('menu'); - const freezeAction = within(menu).queryByText('Freeze'); - expect(freezeAction).not.toBeInTheDocument(); - }); - }); - - test('disables actions in global menu when not allowed', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/test1/i}); - const checkbox = within(row).getByRole('checkbox'); - fireEvent.click(checkbox); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - - const menu = await screen.findByRole('menu'); - - const deleteAction = within(menu).getByText('Delete'); - expect(deleteAction).toBeInTheDocument(); - }); - - test('handles sorting by status column', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const statusHeader = screen.getByText('Status'); - fireEvent.click(statusHeader); - - await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); - }); - }); - - test('handles sorting by object name', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const objectHeader = screen.getByText('Object'); - fireEvent.click(objectHeader); - - await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); - }); - }); - - test('handles sorting by node status', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const nodeHeader = screen.getByRole('columnheader', {name: /node1/i}); - fireEvent.click(nodeHeader); - - await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); - }); - }); - - test('handles sort direction change', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const objectHeader = screen.getByText('Object'); - - fireEvent.click(objectHeader); - - fireEvent.click(objectHeader); - - await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); - }); - }); - - test('prevents navigation for objects without instance status', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/no-instance': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: {}, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/no-instance/i})).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('row', {name: /test-ns\/svc\/no-instance/i})); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - test('handles narrow screen layout', async () => { - const mockUseMediaQuery = require('@mui/material/useMediaQuery'); - mockUseMediaQuery.mockReturnValue(false); - - setupComponent(); - await waitForComponentToLoad(); - - expect(screen.queryByRole('columnheader', {name: /node1/i})).not.toBeInTheDocument(); - expect(screen.queryByRole('columnheader', {name: /node2/i})).not.toBeInTheDocument(); - }); - - test('handles URL parameter synchronization with invalid globalState', async () => { - require('react-router-dom').useLocation.mockReturnValue({ - search: '?globalState=invalid&namespace=test&kind=svc&name=obj1', - pathname: '/objects' - }); - - setupComponent(); - - await waitFor(() => { - expect(screen.getByLabelText('Global State')).toBeInTheDocument(); - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); - }); - }); - - test('handles event source setup without auth token', async () => { - jest.spyOn(Storage.prototype, 'getItem').mockReturnValue(null); - - setupComponent(); - - await waitFor(() => { - expect(screen.getByLabelText('Namespace')).toBeInTheDocument(); - }); - - expect(startEventReception).not.toHaveBeenCalled(); - }); - - test('handles action execution with single object target', async () => { - setupComponent(); - - await waitForComponentToLoad(); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/test1/i}); - const menuButton = within(row).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton); - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test1/action/restart'), - expect.any(Object) - ); - }); - }); - - test('handles scroll loading more objects', async () => { - const manyObjects = {}; - const manyInstanceStatus = {}; - for (let i = 0; i < 50; i++) { - const name = `test-ns/svc/obj${i}`; - manyObjects[name] = {avail: i % 2 === 0 ? 'up' : 'down', frozen: 'unfrozen'}; - manyInstanceStatus[name] = { - node1: {avail: i % 2 === 0 ? 'up' : 'down', frozen_at: '0001-01-01T00:00:00Z'} - }; - } - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: manyObjects, - objectInstanceStatus: manyInstanceStatus, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - const rows = screen.getAllByRole('row').slice(1); - expect(rows.length).toBe(30); - - const tableContainer = document.querySelector('.MuiTableContainer-root'); - Object.defineProperty(tableContainer, 'scrollHeight', {value: 1000}); - Object.defineProperty(tableContainer, 'clientHeight', {value: 500}); - Object.defineProperty(tableContainer, 'scrollTop', {value: 500}); - - fireEvent.scroll(tableContainer); - - await waitFor(() => { - const newRows = screen.getAllByRole('row').slice(1); - expect(newRows.length).toBeGreaterThan(30); - }); - }); - - test('uses daemon objects as fallback when objectStatus is empty', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {}, - objectInstanceStatus: {}, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - useFetchDaemonStatus.mockReturnValue({ - daemon: { - cluster: { - object: { - 'daemon/svc/obj1': {avail: 'up', frozen: 'unfrozen'}, - 'daemon/svc/obj2': {avail: 'down', frozen: 'frozen'}, - } - } - }, - }); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /daemon\/svc\/obj1/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /daemon\/svc\/obj2/i})).toBeInTheDocument(); - }); - }); - - test('filters by unprovisioned global state and finds objects', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Unprovisioned'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/unprovisioned/i})).toBeInTheDocument(); - }); - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test1/i})).not.toBeInTheDocument(); - }); - - test('freeze action is available for non-frozen object and unfreeze for frozen', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const row1 = screen.getByRole('row', {name: /test-ns\/svc\/test1/i}); - const menuButton1 = within(row1).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton1); - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - let menu = screen.getByRole('menu'); - expect(within(menu).getByText('Freeze')).toBeInTheDocument(); - expect(within(menu).queryByText('Unfreeze')).not.toBeInTheDocument(); - - fireEvent.keyDown(menu, {key: 'Escape', code: 'Escape'}); - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - }); - - const row2 = screen.getByRole('row', {name: /test-ns\/svc\/test2/i}); - const menuButton2 = within(row2).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton2); - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - menu = screen.getByRole('menu'); - expect(within(menu).queryByText('Freeze')).not.toBeInTheDocument(); - expect(within(menu).getByText('Unfreeze')).toBeInTheDocument(); - }); - - test('executes unfreeze action on frozen object', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test2'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Unfreeze')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-ns/svc/test2/action/unfreeze'), - expect.any(Object) - ); - }); - }); - - test('handles partial success during action execution', async () => { - global.fetch - .mockResolvedValueOnce({ok: true}) - .mockResolvedValueOnce({ok: false, status: 500}); - - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - await selectObject('test-ns/svc/test2'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/partially succeeded: 1 ok, 1 errors/i); - }); - }); - - test('handles network error during action', async () => { - global.fetch.mockRejectedValue(new Error('Network error')); - - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/failed on all 1 object\(s\)/i); - }); - }); - - test('removes filter chip by clicking delete icon', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Namespace', 'test-ns'); - await waitForFilterApplied(); - - const chip = screen.getByText('test-ns').closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - - const deleteIcon = within(chip).getByTestId('CloseIcon'); - fireEvent.click(deleteIcon); - - await waitFor(() => { - expect(screen.queryByText('test-ns')).not.toBeInTheDocument(); - expect(screen.getByRole('row', {name: /root\/svc\/test3/i})).toBeInTheDocument(); - }); - }); - - test('global actions button is disabled when no objects selected', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const actionsButton = screen.getByRole('button', {name: /actions on selected objects/i}); - expect(actionsButton).toBeDisabled(); - }); - - test('row menu closes when clicking outside (backdrop)', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/test1/i}); - const menuButton = within(row).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton); - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - const backdrop = document.querySelector('.MuiModal-backdrop'); - expect(backdrop).toBeInTheDocument(); - fireEvent.click(backdrop); - - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - }); - }); - - test('sorts by status with n/a values', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const statusHeader = screen.getByText('Status'); - fireEvent.click(statusHeader); - - await waitFor(() => { - const rows = screen.getAllByRole('row').slice(1); - const firstRow = rows[0]; - expect(firstRow).toHaveTextContent(/test-ns\/svc\/test4/i); - }); - }); - - test('sorts by node column when some objects lack node data', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const nodeHeader = screen.getByRole('columnheader', {name: /node1/i}); - fireEvent.click(nodeHeader); - - await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); - }); - }); - - test('scroll does not load more when already loading', async () => { - const manyObjects = {}; - const manyInstanceStatus = {}; - for (let i = 0; i < 50; i++) { - const name = `test/svc/obj${i}`; - manyObjects[name] = {avail: 'up', frozen: 'unfrozen'}; - manyInstanceStatus[name] = { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'} - }; - } - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: manyObjects, - objectInstanceStatus: manyInstanceStatus, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getAllByRole('row').slice(1)).toHaveLength(30); - }); - - const tableContainer = document.querySelector('.MuiTableContainer-root'); - Object.defineProperty(tableContainer, 'scrollHeight', {value: 1000}); - Object.defineProperty(tableContainer, 'clientHeight', {value: 500}); - Object.defineProperty(tableContainer, 'scrollTop', {value: 500}); - - fireEvent.scroll(tableContainer); - - await waitFor(() => { - expect(screen.getByRole('progressbar')).toBeInTheDocument(); - }); - - fireEvent.scroll(tableContainer); - - await new Promise(r => setTimeout(r, 50)); - const rows = screen.getAllByRole('row').slice(1); - expect(rows.length).toBe(30); - }); - - test('scroll does not load more when no more objects', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {'test/svc/obj1': {avail: 'up'}}, - objectInstanceStatus: {}, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - const tableContainer = document.querySelector('.MuiTableContainer-root'); - Object.defineProperty(tableContainer, 'scrollHeight', {value: 1000}); - Object.defineProperty(tableContainer, 'clientHeight', {value: 500}); - Object.defineProperty(tableContainer, 'scrollTop', {value: 500}); - - fireEvent.scroll(tableContainer); - - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - const rows = screen.getAllByRole('row').slice(1); - expect(rows.length).toBe(1); - }); - - test('synchronizes URL with debounce', async () => { - jest.useFakeTimers(); - setupComponent(); - await waitForComponentToLoad(); - - const searchInput = screen.getByLabelText('Name'); - fireEvent.change(searchInput, {target: {value: 'test'}}); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - '/objects?name=test', - {replace: true} - ); - }); - - jest.useRealTimers(); - }); - - test('updates filters from URL on location change', async () => { - const {rerender} = setupComponent(); - await waitForComponentToLoad(); - - require('react-router-dom').useLocation.mockReturnValue({ - search: '?namespace=test-ns&kind=svc&name=test1', - pathname: '/objects' - }); - - rerender( - - - - ); - - await waitFor(() => { - expect(screen.getByLabelText('Namespace')).toHaveTextContent('test-ns'); - expect(screen.getByText('test-ns')).toBeInTheDocument(); - expect(screen.getByText('svc')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toHaveValue('test1'); - }); - }); - - test('handles parseObjectName with different formats', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'simpleName': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'simpleName': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('simpleName'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/root/svc/simpleName/action/restart'), - expect.any(Object) - ); - }); - }); - - test('shows snackbar when token missing on action', async () => { - jest.spyOn(Storage.prototype, 'getItem').mockReturnValue(null); - - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('Authentication token not found'); - }); - }); - - test('closes snackbar after autoHideDuration', async () => { - jest.useFakeTimers(); - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toBeInTheDocument(); - }); - - jest.advanceTimersByTime(4000); - - await waitFor(() => { - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); - - jest.useRealTimers(); - }); - - test('closes snackbar via Alert onClose callback', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toBeInTheDocument(); - }); - - const alertCloseButton = screen.getByRole('alert').querySelector('button[aria-label="Close"]') || - document.querySelector('.MuiAlert-action button'); - - if (alertCloseButton) { - fireEvent.click(alertCloseButton); - await waitFor(() => { - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - }); - } - }); - - test('closes pending action dialog via onClose (cancel button)', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectObject('test-ns/svc/test1'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - const cancelButton = screen.getByRole('button', {name: /cancel/i}); - fireEvent.click(cancelButton); - - await waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - test('handles parseObjectName with two-part format (kind/name)', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'svc/myservice': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'svc/myservice': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /svc\/myservice/i})).toBeInTheDocument(); - }); - - await selectObject('svc/myservice'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/root/svc/myservice/action/restart'), - expect.any(Object) - ); - }); - }); - - test('skips action when objectStatus is missing for target object', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {}, - objectInstanceStatus: { - 'test-ns/svc/ghost': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - useFetchDaemonStatus.mockReturnValue({ - daemon: { - cluster: { - object: { - 'test-ns/svc/ghost': {avail: 'up', frozen: 'unfrozen'}, - } - } - }, - }); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/ghost/i})).toBeInTheDocument(); - }); - - await selectObject('test-ns/svc/ghost'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent(/failed on all 1 object\(s\)/i); - }); - }); - - test('skips freeze action when object is already frozen', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/already-frozen': {avail: 'up', frozen: 'frozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/already-frozen': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /already-frozen/i})).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /already-frozen/i}); - const menuButton = within(row).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton); - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - const menu = screen.getByRole('menu'); - expect(within(menu).queryByText('Freeze')).not.toBeInTheDocument(); - expect(within(menu).getByText('Unfreeze')).toBeInTheDocument(); - }); - - test('skips unfreeze action when object is not frozen', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/not-frozen': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/not-frozen': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /not-frozen/i})).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /not-frozen/i}); - const menuButton = within(row).getByRole('button', {name: /more actions/i}); - fireEvent.click(menuButton); - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument(); - }); - - const menu = screen.getByRole('menu'); - expect(within(menu).getByText('Freeze')).toBeInTheDocument(); - expect(within(menu).queryByText('Unfreeze')).not.toBeInTheDocument(); - }); - - test('toggles global state filter off by clicking chip delete', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - }); - - const chip = screen.getByText(/^Up$/i).closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - const deleteIcon = within(chip).getByTestId('CloseIcon'); - fireEvent.click(deleteIcon); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - }); - }); - - test('toggles kind filter off by clicking chip delete', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Kind', 'svc'); - await waitForFilterApplied(); - - const chip = screen.getByText(/^svc$/i).closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - const deleteIcon = within(chip).getByTestId('CloseIcon'); - fireEvent.click(deleteIcon); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - }); - }); - - test('handles Kind select onChange directly', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const kindSelect = screen.getByLabelText('Kind'); - fireEvent.mouseDown(kindSelect); - - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - const listbox = screen.getByRole('listbox'); - const option = within(listbox).getByText('svc'); - fireEvent.click(option); - - fireEvent.keyDown(listbox, {key: 'Escape', code: 'Escape'}); - - await waitFor(() => { - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - }); - }); - - test('node status shows n/a state for missing avail', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/noavail': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/noavail': { - node1: {}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/noavail/i})).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/noavail/i}); - const cells = within(row).getAllByRole('cell'); - const nodeCell = cells[3]; - expect(within(nodeCell).queryByText('-')).not.toBeInTheDocument(); - expect(within(nodeCell).queryByLabelText(/Node .+ is .+/)).not.toBeInTheDocument(); - expect(within(nodeCell).queryByLabelText(/Node .+ has warning/)).not.toBeInTheDocument(); - }); - - test('global state filter chip click toggles selection', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - }); - - const chip = screen.getByText(/^Up$/i).closest('.MuiChip-root'); - fireEvent.click(chip); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - }); - }); - - test('renders cluster object name with ccfg kind', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'cluster': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'cluster': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /cluster/i})).toBeInTheDocument(); - }); - - await selectObject('cluster'); - fireEvent.click(screen.getByRole('button', {name: /actions on selected objects/i})); - fireEvent.click(screen.getByText('Restart')); - - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByRole('button', {name: /Confirm/i})); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/root/ccfg/cluster/action/restart'), - expect.any(Object) - ); - }); - }); - - test('displays GlobalExpectDisplay as empty when globalExpect is falsy', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/no-expect': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/no-expect': { - node1: {avail: 'up'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/no-expect': {state: 'idle', global_expect: ''}, - }, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/no-expect/i})).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/no-expect/i}); - expect(row).toBeInTheDocument(); - }); - - test('multiple filters combined reduce results correctly', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Namespace', 'test-ns'); - await waitForFilterApplied(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /root\/svc\/test3/i})).not.toBeInTheDocument(); - }); - }); - - test('shows empty message when no objects match filters', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const searchInput = screen.getByLabelText('Name'); - fireEvent.change(searchInput, {target: {value: 'xxxxxxxxxnonexistent'}}); - - await waitFor(() => { - expect(screen.getByText(/No objects found matching the current filters/i)).toBeInTheDocument(); - }); - }); - - test('URL contains multiple filter values joined by comma', async () => { - jest.useFakeTimers(); - setupComponent(); - await waitForComponentToLoad(); - - const globalStateSelect = screen.getByLabelText('Global State'); - fireEvent.mouseDown(globalStateSelect); - - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - const listbox = screen.getByRole('listbox'); - const upOption = within(listbox).getAllByRole('option').find(o => o.textContent.toLowerCase().includes('up')); - const downOption = within(listbox).getAllByRole('option').find(o => o.textContent.toLowerCase().includes('down')); - - fireEvent.click(within(upOption).getByRole('checkbox')); - fireEvent.click(within(downOption).getByRole('checkbox')); - - fireEvent.keyDown(listbox, {key: 'Escape'}); - - jest.advanceTimersByTime(300); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/globalState=.*up.*down|globalState=.*down.*up/), - {replace: true} - ); - }); - - jest.useRealTimers(); - }); - - test('NodeStatusIcons memo comparator covered by rendering down state', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {'test-ns/svc/memo-cov': {avail: 'up', frozen: 'unfrozen'}}, - objectInstanceStatus: { - 'test-ns/svc/memo-cov': { - node1: {avail: 'warn', frozen_at: '0001-01-01T00:00:00Z'}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByLabelText('Node node1 has warning')).toBeInTheDocument(); - }); - }); - - test('NodeStatusIcons renders down state correctly', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {'test-ns/svc/node-down': {avail: 'up', frozen: 'unfrozen'}}, - objectInstanceStatus: { - 'test-ns/svc/node-down': { - node1: {avail: 'down', frozen_at: '2025-01-01T00:00:00Z'}, - }, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByLabelText('Node node1 is down')).toBeInTheDocument(); - }); - - const row = screen.getByRole('row', {name: /test-ns\/svc\/node-down/i}); - const cells = within(row).getAllByRole('cell'); - expect(cells.length).toBeGreaterThan(3); - }); - - test('NodeStateDisplay memo comparator covered by rendering non-idle state', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {'test-ns/svc/memo-state-cov': {avail: 'up', frozen: 'unfrozen'}}, - objectInstanceStatus: { - 'test-ns/svc/memo-state-cov': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/memo-state-cov': {state: 'starting', global_expect: 'none'}, - }, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByText('starting')).toBeInTheDocument(); - }); - }); - - test('NodeStateDisplay renders idle state correctly', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: {'test-ns/svc/node-idle': {avail: 'up', frozen: 'unfrozen'}}, - objectInstanceStatus: { - 'test-ns/svc/node-idle': { - node1: {avail: 'up', frozen_at: '0001-01-01T00:00:00Z'}, - }, - }, - instanceMonitor: { - 'node1:test-ns/svc/node-idle': {state: 'running', global_expect: 'none'}, - }, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/node-idle/i})).toBeInTheDocument(); - }); - - expect(screen.getByText('running')).toBeInTheDocument(); - }); - - test('filteredObjectNames skips objects not matching namespace filter', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const namespaceSelect = screen.getByLabelText('Namespace'); - fireEvent.mouseDown(namespaceSelect); - - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - const listbox = screen.getByRole('listbox'); - const rootOption = within(listbox).getAllByRole('option').find(o => - o.textContent.toLowerCase().includes('root') - ); - fireEvent.click(within(rootOption).getByRole('checkbox')); - fireEvent.keyDown(listbox, {key: 'Escape'}); - - await waitFor(() => { - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /root\/svc\/test3/i})).toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test1/i})).not.toBeInTheDocument(); - }); - }); - - test('filteredObjectNames skips objects not matching kind filter', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/svcobj': {avail: 'up', frozen: 'unfrozen'}, - 'test-ns/cfg/cfgobj': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/svcobj': {node1: {avail: 'up'}}, - 'test-ns/cfg/cfgobj': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - const kindSelect = screen.getByLabelText('Kind'); - fireEvent.mouseDown(kindSelect); - - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - const listbox = screen.getByRole('listbox'); - const cfgOption = within(listbox).getAllByRole('option').find(o => - o.textContent.toLowerCase().includes('cfg') - ); - fireEvent.click(within(cfgOption).getByRole('checkbox')); - fireEvent.keyDown(listbox, {key: 'Escape'}); - - await waitFor(() => { - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/cfg\/cfgobj/i})).toBeInTheDocument(); - expect(screen.queryByRole('row', {name: /test-ns\/svc\/svcobj/i})).not.toBeInTheDocument(); - }); - }); - - test('adds global state to filter when clicking chip label (onClick)', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.queryByRole('row', {name: /test-ns\/svc\/test2/i})).not.toBeInTheDocument(); - }); - - await selectFilterOption('Global State', 'Down'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /test-ns\/svc\/test2/i})).toBeInTheDocument(); - }); - }); - - test('adds namespace to filter when using select (add branch)', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Namespace', 'test-ns'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.queryByRole('row', {name: /root\/svc\/test3/i})).not.toBeInTheDocument(); - }); - - await selectFilterOption('Namespace', 'root'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/test1/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /root\/svc\/test3/i})).toBeInTheDocument(); - }); - }); - - test('adds kind to filter when using select (add branch)', async () => { - useEventStore.mockImplementation((selector) => - selector({ - objectStatus: { - 'test-ns/svc/svcobj': {avail: 'up', frozen: 'unfrozen'}, - 'test-ns/cfg/cfgobj': {avail: 'up', frozen: 'unfrozen'}, - }, - objectInstanceStatus: { - 'test-ns/svc/svcobj': {node1: {avail: 'up'}}, - 'test-ns/cfg/cfgobj': {node1: {avail: 'up'}}, - }, - instanceMonitor: {}, - removeObject: mockRemoveObject, - }) - ); - - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Kind', 'svc'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.queryByRole('row', {name: /test-ns\/cfg\/cfgobj/i})).not.toBeInTheDocument(); - }); - - await selectFilterOption('Kind', 'cfg'); - await waitForFilterApplied(); - - await waitFor(() => { - expect(screen.getByRole('row', {name: /test-ns\/svc\/svcobj/i})).toBeInTheDocument(); - expect(screen.getByRole('row', {name: /test-ns\/cfg\/cfgobj/i})).toBeInTheDocument(); - }); - }); - - test('GlobalState renderValue shows warn and n/a icons', async () => { - setupComponent(); - await waitForComponentToLoad(); - - const globalStateSelect = screen.getByLabelText('Global State'); - fireEvent.mouseDown(globalStateSelect); - - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument(); - }); - - const listbox = screen.getByRole('listbox'); - - const warnOption = within(listbox).getAllByRole('option').find(o => - o.textContent.toLowerCase().includes('warn') - ); - fireEvent.click(within(warnOption).getByRole('checkbox')); - - const naOption = within(listbox).getAllByRole('option').find(o => - o.textContent.toLowerCase().includes('n/a') - ); - fireEvent.click(within(naOption).getByRole('checkbox')); - - fireEvent.keyDown(listbox, {key: 'Escape'}); - - await waitFor(() => { - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - await waitFor(() => { - const chips = document.querySelectorAll('.MuiChip-root'); - expect(chips.length).toBeGreaterThanOrEqual(2); - }); - }); - - test('chip onMouseDown stopPropagation prevents select from reopening', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Global State', 'Up'); - await waitForFilterApplied(); - - const chip = screen.getByText(/^Up$/i).closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - - fireEvent.mouseDown(chip); - - await new Promise(r => setTimeout(r, 50)); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - test('namespace chip onMouseDown stopPropagation prevents select from reopening', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Namespace', 'test-ns'); - await waitForFilterApplied(); - - const chip = screen.getByText('test-ns').closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - - fireEvent.mouseDown(chip); - - await new Promise(r => setTimeout(r, 50)); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - test('kind chip onMouseDown stopPropagation prevents select from reopening', async () => { - setupComponent(); - await waitForComponentToLoad(); - - await selectFilterOption('Kind', 'svc'); - await waitForFilterApplied(); - - const chip = screen.getByText(/^svc$/i).closest('.MuiChip-root'); - expect(chip).toBeInTheDocument(); - - fireEvent.mouseDown(chip); - - await new Promise(r => setTimeout(r, 50)); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); }); From 1dfd20fbd26c492b51173ba2554352e1239a714b Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Wed, 3 Jun 2026 10:08:02 +0200 Subject: [PATCH 06/24] Improve test coverage for ActionDialogs --- src/components/tests/ActionDialogs.test.jsx | 592 +++++++++++++------- 1 file changed, 387 insertions(+), 205 deletions(-) diff --git a/src/components/tests/ActionDialogs.test.jsx b/src/components/tests/ActionDialogs.test.jsx index da61432..af709a2 100644 --- a/src/components/tests/ActionDialogs.test.jsx +++ b/src/components/tests/ActionDialogs.test.jsx @@ -9,24 +9,30 @@ import { // Mock MUI components to simplify rendering and add accessibility labels jest.mock('@mui/material', () => ({ - Dialog: ({open, children}) => (open ?
    {children}
    : null), + Dialog: ({open, children}) => (open ?
    {children}
    : null), DialogTitle: ({children}) =>

    {children}

    , DialogContent: ({children}) =>
    {children}
    , DialogActions: ({children}) =>
    {children}
    , - Button: ({onClick, disabled, children, 'aria-label': ariaLabel}) => - , + Button: ({onClick, disabled, children, 'aria-label': ariaLabel, variant}) => + , Checkbox: ({checked, onChange, 'aria-label': ariaLabel}) => , FormControlLabel: ({control, label}) => , Typography: ({children}) => {children}, - TextField: ({value, onChange, 'aria-label': ariaLabel}) => - , + TextField: ({value, onChange, 'aria-label': ariaLabel, multiline, placeholder, rows, sx}) => { + if (multiline) { + return