diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 94f826ee3..e797bf45d 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -25,6 +25,22 @@ vi.mock('../../../src/utils/date', async (importOriginal: any) => { } }) +const mockScrollToIndex = vi.fn() + +vi.mock('@tanstack/react-virtual', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useVirtualizer: (options: any) => { + const virtualizer = actual.useVirtualizer(options) + return { + ...virtualizer, + scrollToIndex: mockScrollToIndex, + } + }, + } +}) + // @tanstack/react-virtual needs ResizeObserver + scrollHeight in jsdom if (typeof global.ResizeObserver === 'undefined') { global.ResizeObserver = class ResizeObserver { @@ -34,7 +50,6 @@ if (typeof global.ResizeObserver === 'undefined') { } as any } - Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) @@ -451,3 +466,72 @@ describe('Findings — severity legend help affordance', () => { ) }) }) + +describe('Findings — virtualizer scrolling', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('changing the selected finding triggers the expected scroll call', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'high' }), + makeFinding({ id: 'f3', title: 'Finding Gamma', severity: 'medium' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Clear initial scroll call on mount + mockScrollToIndex.mockClear() + + // Click Finding Beta to select it + const betaOption = await screen.findByRole('option', { name: /Finding Beta/i }) + await userEvent.click(betaOption) + + // Assert it called scrollToIndex with correct index (1) + expect(mockScrollToIndex).toHaveBeenCalledWith(1, { align: 'auto', behavior: 'smooth' }) + }) + + it('filtering/sorting does not scroll to stale indexes', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'high' }), + makeFinding({ id: 'f3', title: 'Finding Gamma', severity: 'medium' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) + + render() + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + + // Select Beta (triggers scroll) and then clear mocks + const betaOption = await screen.findByRole('option', { name: /Finding Beta/i }) + await userEvent.click(betaOption) + expect(mockScrollToIndex).toHaveBeenCalled() + mockScrollToIndex.mockClear() + + // Filter using search query that includes Beta + const searchInput = screen.getByPlaceholderText(/Title, target, CVE/i) + await userEvent.type(searchInput, 'Beta') + + // Expect no scrollToIndex call because selectedFindingId did not change + expect(mockScrollToIndex).not.toHaveBeenCalled() + + // Clear search query + await userEvent.clear(searchInput) + expect(mockScrollToIndex).not.toHaveBeenCalled() + + // Change sorting mode (while Beta remains visible/selected) + const selects = screen.getAllByRole('combobox') + const sortSelect = selects.find((s) => + Array.from(s.querySelectorAll('option')).some((o) => /Newest First/i.test(o.textContent || '')), + ) + expect(sortSelect).toBeDefined() + await userEvent.selectOptions(sortSelect!, 'newest') + + // Expect no scrollToIndex call + expect(mockScrollToIndex).not.toHaveBeenCalled() + }) +})