mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 22:01:11 +00:00
[AZ-458] [AZ-467] [AZ-468] [AZ-482] Batch 3 - SSE/RBAC/Header/security tests
Implements 4 blackbox-test tasks for AZ-455 Phase A baseline:
- AZ-458 SSE lifecycle + bearer rotation: 9 fast tests (8 pass, 1
QUARANTINE for annotation-status); 4 e2e scenarios (gated by suite
stack). Uses tests/helpers/sse-mock.ts with globalThis.EventSource
monkey-patch per AC-3 (no stub of src/api/sse.ts). AC-2 bearer
rotation captured as documented drift via it.fails() — FlightsPage
useEffect deps do not include the token today.
- AZ-467 ProtectedRoute spinner + timeout + RBAC: 9 new fast tests
extending the AZ-457 file (6 pass, 3 QUARANTINE), plus 3 e2e
scenarios. FT-P-32 spinner a11y is it.fails() drift; FT-P-33 timeout
and FT-N-03/05 RBAC redirects are it.skip QUARANTINE (no production
behavior today). Positive control: admin_carol reaches /admin.
- AZ-468 Header flight-dropdown a11y: 6 fast tests (5 pass, 1
QUARANTINE). FT-P-30/31 are it.fails() drift (aria-expanded /
role=listbox / aria-activedescendant currently missing); FT-N-09
is it.skip QUARANTINE (no document keydown handler exists).
- AZ-482 Secrets + banned-libs + AC-N1 anti-criterion: 3 new static
checks (STC-SEC13 legacy integrations, STC-SEC14 concurrent-edit,
STC-SEC1B dist/ OWM key) plus refactor of 4 existing checks
(STC-N2/N4/S13/S6) to read from tests/security/banned-deps.json
via scripts/check-banned-deps.mjs per AZ-482 constraint
("deny-list lives in tests/security/banned-deps.json so additions
are visible in code review"). All 22 static checks PASS.
Totals: 57 fast tests pass + 9 skipped; 22/22 static checks pass.
Self-review verdict PASS_WITH_WARNINGS — all five findings are
documented drifts captured by it.fails() / it.skip QUARANTINE +
control tests. See _docs/03_implementation/batch_03_report.md
for the per-task / per-AC matrix and recommended Phase B follow-up
production tasks (Header a11y; ProtectedRoute spinner/timeout/RBAC;
SSE bearer-rotation reconnect; AnnotationsPage SSE).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { jsonResponse } from '../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import ProtectedRoute from './ProtectedRoute'
|
||||
import { clearBearer } from '../../tests/helpers/auth'
|
||||
import { opAlice, opBob, adminCarol, integratorDave, seedPermissions } from '../../tests/fixtures/seed_users'
|
||||
|
||||
// AZ-457 — <ProtectedRoute> behavior at the React boundary.
|
||||
// FT-N-04 / row 09 — unauthenticated /admin → redirect to /login
|
||||
@@ -12,10 +14,23 @@ import { clearBearer } from '../../tests/helpers/auth'
|
||||
// (apiClient half lives in src/api/client.test.ts; this
|
||||
// file asserts the React-router-level redirect path)
|
||||
//
|
||||
// AZ-467 — Spinner a11y, 10s timeout fallback, and RBAC route gating.
|
||||
// FT-P-32 / NFT-SEC-05 — spinner role=status + aria-live=polite + label
|
||||
// FT-P-33 / NFT-RES-04 — 10s timeout fallback (Vitest fake-timers)
|
||||
// FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights
|
||||
// FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects (no SETTINGS perm)
|
||||
//
|
||||
// Production status (today): <ProtectedRoute> renders a plain spinner div
|
||||
// without any aria-* attributes, has no timeout fallback, and does NOT check
|
||||
// route-level permissions (it only gates on `user != null`). Those four ACs
|
||||
// therefore fail today; the spinner a11y test uses `it.fails()` to track the
|
||||
// drift, and the timeout / RBAC tests are `it.skip` (QUARANTINE) because the
|
||||
// behavior is entirely absent.
|
||||
//
|
||||
// Black-box discipline: we import only the public ProtectedRoute component
|
||||
// and react-router primitives; no internal state of <AuthContext> is read.
|
||||
// Assertions are observable on the rendered DOM — the /login route renders
|
||||
// a sentinel that lets us confirm the redirect happened.
|
||||
// Assertions are observable on the rendered DOM — sentinel components let us
|
||||
// confirm which route the router settled on.
|
||||
|
||||
function LoginSentinel() {
|
||||
return <div data-testid="login-route">login-route</div>
|
||||
@@ -25,6 +40,22 @@ function AdminSentinel() {
|
||||
return <div data-testid="admin-route">admin-route</div>
|
||||
}
|
||||
|
||||
function FlightsSentinel() {
|
||||
return <div data-testid="flights-route">flights-route</div>
|
||||
}
|
||||
|
||||
function SettingsSentinel() {
|
||||
return <div data-testid="settings-route">settings-route</div>
|
||||
}
|
||||
|
||||
function withUser(user: typeof opAlice) {
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () =>
|
||||
jsonResponse({ token: 'test-bearer-default', user: { ...user, permissions: seedPermissions[user.id] ?? [] } }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
@@ -115,7 +146,7 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
|
||||
path="/flights"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="flights-route">flights-route</div>
|
||||
<FlightsSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -130,3 +161,239 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () => {
|
||||
beforeEach(() => {
|
||||
// Each test wires its own auth response; nothing global needed.
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('FT-P-32 / NFT-SEC-05 — spinner a11y while bootstrap is loading', () => {
|
||||
it.fails(
|
||||
'spinner element carries role="status" + aria-live="polite" + an accessible name (drift: aria attributes currently missing)',
|
||||
async () => {
|
||||
// Arrange — keep bootstrap pending forever so the spinner stays mounted.
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never resolves */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FlightsSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
|
||||
// Assert AC-1: the loading element advertises its status role and a
|
||||
// localized accessible name (i18n key TBD; for the drift assertion we
|
||||
// accept any non-empty accessible name).
|
||||
const status = await screen.findByRole('status')
|
||||
expect(status).toHaveAttribute('aria-live', 'polite')
|
||||
const name = status.getAttribute('aria-label') ?? status.textContent ?? ''
|
||||
expect(name.trim().length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('control — spinner renders today as a bare animate-spin div with no aria role (drift seen)', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never resolves */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
const { container } = renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
|
||||
// Assert AC-1 evidence: the spinner exists, but is NOT a status role today.
|
||||
const spinner = container.querySelector('.animate-spin')
|
||||
expect(spinner).not.toBeNull()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-33 / NFT-RES-04 — 10s loading timeout fallback', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): after 10s the spinner is replaced with a fallback that offers a retry affordance',
|
||||
async () => {
|
||||
// When ProtectedRoute gains a timeout (`useEffect` + setTimeout, or a
|
||||
// useTimeout hook) and a fallback render path, this test:
|
||||
// 1. Mocks bootstrap to never resolve.
|
||||
// 2. Renders the ProtectedRoute tree.
|
||||
// 3. Advances Vitest fake-timers by 10_000 ms.
|
||||
// 4. Asserts the fallback element is present with a retry affordance
|
||||
// (a button / link whose accessible name matches /retry|reload/i).
|
||||
// The test is skipped today because no timeout / fallback path exists
|
||||
// in src/auth/ProtectedRoute.tsx — asserting absent UI would produce
|
||||
// noise. Once the production path lands the assertion shape is below.
|
||||
vi.useFakeTimers()
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
const retry = await screen.findByRole('button', { name: /retry|reload/i })
|
||||
expect(retry).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — bootstrap stuck at >10s today shows ONLY the spinner; no fallback (drift seen)', async () => {
|
||||
vi.useFakeTimers()
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
const { container } = renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
// QUARANTINE evidence: still showing the spinner; no retry surface.
|
||||
expect(container.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /retry|reload/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): an authenticated Operator hitting /admin is redirected to /flights',
|
||||
async () => {
|
||||
// When ProtectedRoute gains a `requirePermission` prop (or wrapper) and
|
||||
// the /admin route opts in, this test:
|
||||
// 1. Boots auth as op_alice (Operator) with seedPermissions['user-alice']
|
||||
// (which intentionally lacks 'ADMIN_WRITE').
|
||||
// 2. Navigates to /admin.
|
||||
// 3. Asserts the router settled on /flights, not /admin or /login.
|
||||
withUser(opAlice)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('admin-route')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — an authenticated Operator reaches /admin today (no RBAC gate; drift seen)', async () => {
|
||||
withUser(opBob) // op_bob lacks 'ADMIN_WRITE' and 'SETTINGS'
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
|
||||
// Today the admin sentinel renders — ProtectedRoute does not check
|
||||
// permissions, only `user != null`.
|
||||
await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('flights-route')).toBeNull()
|
||||
})
|
||||
|
||||
it('Admin reaches /admin normally (positive control — same path, role permitted)', async () => {
|
||||
withUser(adminCarol)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): an authenticated user without SETTINGS is redirected away from /settings',
|
||||
async () => {
|
||||
// When ProtectedRoute gains permission gating, this test:
|
||||
// 1. Boots auth as integrator_dave (whose seedPermissions lacks SETTINGS).
|
||||
// 2. Navigates to /settings.
|
||||
// 3. Asserts the router settled on /flights (or wherever policy says).
|
||||
withUser(integratorDave)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/settings'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('settings-route')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — integrator-dave reaches /settings today (no RBAC gate; drift seen)', async () => {
|
||||
withUser(integratorDave)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/settings'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('settings-route')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { jsonResponse, paginate } from '../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||
import { seedFlights } from '../../tests/fixtures/seed_flights'
|
||||
import { opAlice, seedPermissions } from '../../tests/fixtures/seed_users'
|
||||
import { FlightProvider } from './FlightContext'
|
||||
import Header from './Header'
|
||||
|
||||
// AZ-468 — Header flight-dropdown a11y + Escape handler.
|
||||
// FT-P-30 closed-state a11y — aria-expanded=false, accessible trigger name
|
||||
// FT-P-31 open-state a11y — aria-expanded=true, role=listbox/menu,
|
||||
// aria-activedescendant points to a real id
|
||||
// FT-N-09 Escape close + detach — Escape closes the dropdown and the
|
||||
// document-level Escape handler is removed
|
||||
// (no leakage into other components).
|
||||
//
|
||||
// Production status (today): src/components/Header.tsx renders a plain
|
||||
// <button> trigger and a <div> menu without any aria-* attributes, and the
|
||||
// dropdown has NO Escape key handler (only a `mousedown` listener for
|
||||
// click-outside). All three task ACs therefore fail today; FT-P-30/31 are
|
||||
// captured as documented DRIFT via `it.fails()` (flips green when production
|
||||
// gains the attributes); FT-N-09 is QUARANTINEd via `it.skip` because the
|
||||
// behavior is wholly absent — there is no addEventListener('keydown', ...) in
|
||||
// the dropdown to assert against.
|
||||
|
||||
function HeaderHarness({ children }: { children?: ReactNode }) {
|
||||
return <FlightProvider>{children}<Header /></FlightProvider>
|
||||
}
|
||||
|
||||
function mountHeader() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<HeaderHarness><div data-testid="content" /></HeaderHarness>}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-route" />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
}
|
||||
|
||||
function wireAuthAndFlights() {
|
||||
server.use(
|
||||
http.get('/api/admin/auth/refresh', () =>
|
||||
jsonResponse({ token: 'test-bearer-default', user: { ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] } }),
|
||||
),
|
||||
http.get('/api/flights', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '50')
|
||||
return jsonResponse(paginate(seedFlights, 1, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/settings/user', () =>
|
||||
jsonResponse({
|
||||
id: 'us-1', userId: opAlice.id, selectedFlightId: null,
|
||||
annotationsLeftPanelWidth: null, annotationsRightPanelWidth: null,
|
||||
datasetLeftPanelWidth: null, datasetRightPanelWidth: null,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/user', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-468 / src/components/Header.tsx — flight dropdown', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
wireAuthAndFlights()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('FT-P-30 — closed-state a11y', () => {
|
||||
it.fails(
|
||||
'trigger advertises aria-expanded=false when the menu is closed (drift: attribute currently missing)',
|
||||
async () => {
|
||||
mountHeader()
|
||||
|
||||
// Wait for the flights list to have been fetched so the trigger is hydrated.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
// AC-1 contract: aria-expanded=false when closed; no aria-activedescendant.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
},
|
||||
)
|
||||
|
||||
it('control — closed trigger today lacks aria-expanded entirely (drift seen)', async () => {
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
expect(trigger).not.toHaveAttribute('aria-expanded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-31 — open-state a11y', () => {
|
||||
it.fails(
|
||||
'opened dropdown advertises aria-expanded=true and listbox/menu role with a real aria-activedescendant (drift: attributes missing today)',
|
||||
async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
// AC-2 contract.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(listbox).toBeInTheDocument()
|
||||
const optionId = trigger.getAttribute('aria-activedescendant')
|
||||
expect(optionId).toBeTruthy()
|
||||
expect(document.getElementById(optionId as string)).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — opened dropdown today exposes options but with no role and no aria wiring (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
|
||||
// The filter input renders on open, so the panel is visibly open.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
// But none of the listbox roles or aria-activedescendant wiring exists yet.
|
||||
expect(screen.queryByRole('listbox')).toBeNull()
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-09 — Escape close + document-level handler detached', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): Escape closes the dropdown and the document keydown handler is removed',
|
||||
async () => {
|
||||
// When the production code lands a document-level keydown listener that
|
||||
// handles Escape, this test asserts:
|
||||
// 1. Pressing Escape closes the dropdown (filter input gone)
|
||||
// 2. The document.addEventListener('keydown', ...) call made when the
|
||||
// dropdown opened is paired with a removeEventListener('keydown', ...)
|
||||
// with the SAME handler reference when the dropdown closes (verified
|
||||
// via spies on document.addEventListener/removeEventListener).
|
||||
// The test below is a sketch of the assertion shape — left skipped because
|
||||
// Header has no keydown listener today and asserting against absent code
|
||||
// would produce noise, not signal.
|
||||
const addSpy = vi.spyOn(document, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
const keydownAdds = addSpy.mock.calls.filter(([type]) => type === 'keydown')
|
||||
expect(keydownAdds.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByPlaceholderText(/filter/i)).toBeNull()
|
||||
|
||||
const keydownRemoves = removeSpy.mock.calls.filter(([type, fn]) =>
|
||||
type === 'keydown' && fn === keydownAdds[0]?.[1],
|
||||
)
|
||||
expect(keydownRemoves.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('control — Escape today is a no-op; the dropdown stays open (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
// QUARANTINE evidence: the filter input is still present — Escape did
|
||||
// nothing because the Header has no keydown handler today.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user