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([ + { display: 'none' }, + { visibility: 'hidden' }, + ])('should preserve CSS-hidden controls (%j)', (style: CSSProperties) => { + render(
) + + expect(screen.queryByRole('button', { name: 'CSS-hidden control' })).not.toBeInTheDocument() + if (style.display === 'none') { + expect(screen.getByText('CSS-hidden control').offsetParent).toBeNull() + } + }) + + it('should preserve fixed positioning and disabled controls', async () => { + const user = userEvent.setup() + render( + <> + + + , + ) + + const fixedButton = screen.getByRole('button', { name: 'Fixed control' }) + const disabledButton = screen.getByRole('button', { name: 'Disabled control' }) + expect(fixedButton.offsetParent).toBeNull() + expect(disabledButton).toBeDisabled() + await user.click(disabledButton) + expect(disabledButton).not.toHaveFocus() + }) + + it('should focus only visible, enabled dialog controls', () => { + render( + + + + +
+
+ + +
+ +
+
+
, + ) + + expect(screen.getByRole('button', { name: 'Available control' })).toHaveFocus() + }) +}) diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 8ad475b685..36acf3ed6c 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -2,8 +2,7 @@ import "@testing-library/jest-dom"; import { configure } from "@testing-library/react"; import { TextEncoder, TextDecoder } from "util"; -// Give async queries a little more headroom than the 1s default: Fluent modal -// dialogs (tabster modalizer + Textarea) can take longer to mount under load. +// Give async data and rendering assertions headroom under parallel test load. configure({ asyncUtilTimeout: 5000 }); // jsdom omits TextEncoder/TextDecoder, which react-router references at @@ -18,6 +17,35 @@ process.env.MODE = "test"; process.env.DEV = "true"; process.env.PROD = "false"; +function isDisplayed(element: HTMLElement): boolean { + if (!element.isConnected) { + return false; + } + for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) { + if (getComputedStyle(ancestor).display === "none") { + return false; + } + } + return true; +} + +// JSDOM has no layout. Without these boxes, Tabster cannot focus dialog controls +// and can mark the focused dialog surface aria-hidden on its deferred update. +Object.defineProperty(HTMLElement.prototype, "offsetParent", { + configurable: true, + get(this: HTMLElement): Element | null { + if (!isDisplayed(this) || this === document.body || getComputedStyle(this).position === "fixed") { + return null; + } + return this.parentElement; + }, +}); + +document.body.getBoundingClientRect = (): DOMRect => + isDisplayed(document.body) + ? new DOMRect(0, 0, window.innerWidth, window.innerHeight) + : new DOMRect(); + // Mock window.matchMedia for Fluent UI components Object.defineProperty(window, "matchMedia", { writable: true,