Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/instructions/frontend-test.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 34 additions & 17 deletions frontend/src/components/Configuration/Configuration.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Scenarios/ScenarioFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
})
})
87 changes: 87 additions & 0 deletions frontend/src/setupTests.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<button type="button">Visible control</button>)

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(
<>
<button type="button" hidden>Hidden control</button>
<div hidden><button type="button">Hidden descendant</button></div>
</>,
)

expect(screen.getByText('Hidden control').offsetParent).toBeNull()
expect(screen.getByText('Hidden descendant').offsetParent).toBeNull()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

it.each<CSSProperties>([
{ display: 'none' },
{ visibility: 'hidden' },
])('should preserve CSS-hidden controls (%j)', (style: CSSProperties) => {
render(<div style={style}><button type="button">CSS-hidden control</button></div>)

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(
<>
<button type="button" style={{ position: 'fixed' }}>Fixed control</button>
<button type="button" disabled>Disabled control</button>
</>,
)

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(
<FluentProvider theme={webLightTheme}>
<Dialog open>
<DialogSurface aria-label="Focus test">
<button type="button" hidden>Hidden control</button>
<div style={{ display: 'none' }}><button type="button">Hidden descendant</button></div>
<div style={{ visibility: 'hidden' }}><button type="button">Invisible descendant</button></div>
<button type="button" aria-hidden="true">Accessibility-hidden control</button>
<button type="button" disabled>Disabled control</button>
<fieldset disabled><button type="button">Disabled descendant</button></fieldset>
<button type="button">Available control</button>
</DialogSurface>
</Dialog>
</FluentProvider>,
)

expect(screen.getByRole('button', { name: 'Available control' })).toHaveFocus()
})
})
32 changes: 30 additions & 2 deletions frontend/src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down