diff --git a/.github/instructions/frontend-test.instructions.md b/.github/instructions/frontend-test.instructions.md index d051329ae5..c74b7cea95 100644 --- a/.github/instructions/frontend-test.instructions.md +++ b/.github/instructions/frontend-test.instructions.md @@ -325,6 +325,10 @@ The following are already mocked globally in `src/setupTests.ts` — do NOT re-m - `Element.prototype.scrollTo` / `scrollIntoView` - `URL.createObjectURL` / `revokeObjectURL` - `import.meta.env` variables (`VITE_API_URL`, `MODE`) +- JSDOM focus layout (`offsetParent` and viewport-sized body bounds). Disconnected elements and `display: none` ancestors have no simulated layout; visibility and disabled checks remain intact. This does not simulate positioning or actual dimensions. + +For modal timer regressions, use fake timers with `userEvent.setup({ advanceTimers: jest.advanceTimersByTime })` and advance timers inside `act`. Keep normal accessible-role queries; querying hidden dialogs or extending timeouts can mask focus-management failures. +After navigating from a modal, wait for an accessible element on the destination page, not just a test ID. Restoring background accessibility is also deferred. ## What to Test diff --git a/frontend/src/components/Configuration/Configuration.test.tsx b/frontend/src/components/Configuration/Configuration.test.tsx index 9d56c837e7..5ecbfeda43 100644 --- a/frontend/src/components/Configuration/Configuration.test.tsx +++ b/frontend/src/components/Configuration/Configuration.test.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react' import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, useLocation, useNavigate } from 'react-router' @@ -29,9 +29,6 @@ jest.mock('@/services/api', () => ({ const mockedConfigurationApi = jest.mocked(configurationApi) const mockedInitializersApi = jest.mocked(initializersApi) -// Fluent UI dialogs can render slowly in JSDOM under full test load. -jest.setTimeout(60_000) - function RouterProbe(): ReactElement { const location = useLocation() const navigate = useNavigate() @@ -228,20 +225,10 @@ describe('Configuration', () => { { selector: 'label' }, )).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Add initializer' })) - const dialog = await screen.findByRole( - 'dialog', - { name: 'Add custom initializer' }, - { timeout: 15_000 }, - ) - const nameInput = await within(dialog).findByRole( - 'textbox', - { name: /Initializer name/ }, - { timeout: 15_000 }, - ) + const dialog = await screen.findByRole('dialog', { name: 'Add custom initializer' }) + const nameInput = within(dialog).getByRole('textbox', { name: /Initializer name/ }) await user.type(nameInput, 'new_custom') - fireEvent.change(within(dialog).getByRole('textbox', { name: 'Python source' }), { - target: { value: 'class NewCustom: pass' }, - }) + await user.type(within(dialog).getByRole('textbox', { name: 'Python source' }), 'class NewCustom: pass') await user.click(within(dialog).getByRole('button', { name: 'Add' })) await waitFor(() => { @@ -252,6 +239,36 @@ describe('Configuration', () => { }) }) + it('should keep the add initializer dialog accessible after modal housekeeping', async () => { + jest.useFakeTimers() + try { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Custom Initializers' })) + expect(await screen.findByText( + 'C:/Users/test/.pyrit/custom_initializers/custom_target.py', + { selector: 'label' }, + )).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + + // Tabster defers its modal aria-hidden update by 250 ms. + await act(async () => { + jest.advanceTimersByTime(250) + }) + + const dialog = screen.getByRole('dialog', { name: 'Add custom initializer' }) + expect(within(dialog).getByRole('textbox', { name: /Initializer name/ })).toHaveFocus() + expect(within(dialog).getByRole('button', { name: 'Add' })).toBeDisabled() + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Add custom initializer' })).not.toBeInTheDocument() + } finally { + cleanup() + jest.runOnlyPendingTimers() + jest.useRealTimers() + } + }) + it('should show configured initializers without a runtime apply action', async () => { const user = userEvent.setup() renderPage() diff --git a/frontend/src/components/Scenarios/ScenarioFlow.test.tsx b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx index 5f9f4dc9a0..b313537696 100644 --- a/frontend/src/components/Scenarios/ScenarioFlow.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioFlow.test.tsx @@ -200,7 +200,7 @@ describe('Scenario catalog-to-run integration', () => { expect(within(estimate).getByText('Total atomic attacks').parentElement).toHaveTextContent('2') await user.click(screen.getByTestId('launch-scenario-btn')) - const preview = await screen.findByRole('dialog', { hidden: true }) + const preview = await screen.findByRole('dialog', { name: 'Run preview' }) await user.click(within(preview).getByTestId('confirm-launch-scenario-btn')) await waitFor(() => expect(mockStartRun).toHaveBeenCalledWith({ @@ -216,6 +216,6 @@ describe('Scenario catalog-to-run integration', () => { expect(screen.getByLabelText('Current route')).toHaveTextContent( `/scanner-history/${RUN_ID}`, ) - expect(screen.getByRole('heading', { level: 1, name: SCENARIO_NAME })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: SCENARIO_NAME })).toBeInTheDocument() }) }) diff --git a/frontend/src/setupTests.test.tsx b/frontend/src/setupTests.test.tsx new file mode 100644 index 0000000000..e272a24fb6 --- /dev/null +++ b/frontend/src/setupTests.test.tsx @@ -0,0 +1,87 @@ +import type { CSSProperties } from 'react' + +import { Dialog, DialogSurface, FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +describe('JSDOM focus layout', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should provide layout for displayed controls', () => { + render() + + expect(screen.getByRole('button', { name: 'Visible control' }).offsetParent).not.toBeNull() + expect(document.body.getBoundingClientRect().width).toBe(window.innerWidth) + expect(document.body.getBoundingClientRect().height).toBe(window.innerHeight) + }) + + it('should not provide layout for detached controls', () => { + const button = document.createElement('button') + + expect(button.offsetParent).toBeNull() + }) + + it('should not provide layout for hidden controls or their descendants', () => { + render( + <> + +
+ >, + ) + + expect(screen.getByText('Hidden control').offsetParent).toBeNull() + expect(screen.getByText('Hidden descendant').offsetParent).toBeNull() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it.each