test(accessibility): improve confirm dialog test coverage and refactor media state helper

Expand accessibility tests for ConfirmDialog to assert dialog semantics,
ARIA attributes, and visible destructive actions using test IDs. Refactor
expectMediaState helper to check both UI control state and underlying
media signals for more robust playback state detection.
This commit is contained in:
Richard R 2026-06-01 14:33:37 -06:00
parent 35dc5ae053
commit 3fc0938aec
3 changed files with 44 additions and 33 deletions

View file

@ -30,7 +30,7 @@ export function ConfirmDialog({
}; };
return ( return (
<ModalFrame open={isOpen} onClose={onClose} onKeyDown={handleKeyDown}> <ModalFrame open={isOpen} onClose={onClose} onKeyDown={handleKeyDown} panelTestId="confirm-dialog-panel">
<ModalTitle>{title}</ModalTitle> <ModalTitle>{title}</ModalTitle>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-soft break-words">{message}</p> <p className="text-sm text-soft break-words">{message}</p>

View file

@ -39,14 +39,18 @@ test.describe('Accessibility smoke', () => {
// Open the confirm dialog by clicking the row delete button // Open the confirm dialog by clicking the row delete button
await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click(); await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click();
// Title and dialog role visible // Title and dialog semantics are present
const heading = page.getByRole('heading', { name: 'Delete Document' }); const heading = page.getByRole('heading', { name: 'Delete Document' });
await expect(heading).toBeVisible({ timeout: 10000 }); await expect(heading).toBeVisible({ timeout: 10000 });
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]'); const dialog = page.getByRole('dialog', { name: 'Delete Document' }).first();
await expect(dialog).toBeVisible(); await expect(dialog).toHaveAttribute('aria-modal', 'true');
// The visible panel content is rendered and includes destructive action
const panel = page.getByTestId('confirm-dialog-panel').first();
await expect(panel).toBeVisible();
// Has a destructive action (Delete) // Has a destructive action (Delete)
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible(); await expect(panel.getByRole('button', { name: /^Delete$/ })).toBeVisible();
// Close with Escape to avoid deleting test data // Close with Escape to avoid deleting test data
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');

View file

@ -497,37 +497,44 @@ export async function expectProcessingTransition(page: Page) {
// Expect navigator.mediaSession.playbackState to equal given state // Expect navigator.mediaSession.playbackState to equal given state
export async function expectMediaState(page: Page, state: 'playing' | 'paused') { export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState. // Engines can intermittently miss mediaSession updates. Accept either:
// Fallback heuristics: // - expected UI control state (Pause button for playing, Play button for paused), or
// 1. Prefer mediaSession if it matches desired state. // - underlying media state from mediaSession/audio element signals.
// 2. Otherwise inspect any <audio> element: use paused flag and currentTime progression. const desiredButtonName = state === 'playing' ? 'Pause' : 'Play';
// 3. Allow short grace period for first frame to advance. await expect
// 4. If neither detectable, keep polling until timeout. .poll(async () => {
await page.waitForFunction((desired) => { const uiMatches = await page
try { .getByRole('button', { name: desiredButtonName })
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || ''; .first()
if (msState === desired) return true; .isVisible()
.catch(() => false);
if (uiMatches) return true;
const audio: HTMLAudioElement | null = document.querySelector('audio'); return page.evaluate((desired) => {
if (audio) { try {
// Track advancement by storing last time on the element dataset const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
const last = parseFloat(audio.dataset.lastTime || '0'); if (msState === desired) return true;
const curr = audio.currentTime;
audio.dataset.lastTime = String(curr);
if (desired === 'playing') { const audio: HTMLAudioElement | null = document.querySelector('audio');
// Consider playing if not paused AND time has advanced at least a tiny amount if (audio) {
if (!audio.paused && curr > 0 && curr > last) return true; const w = window as Window & { __openreaderLastAudioTime?: number };
} else { const last = w.__openreaderLastAudioTime ?? -1;
// paused target const curr = audio.currentTime;
if (audio.paused) return true; w.__openreaderLastAudioTime = curr;
if (desired === 'playing') {
if (!audio.paused && curr > 0 && curr > last) return true;
} else if (audio.paused) {
return true;
}
}
return false;
} catch {
return false;
} }
} }, state);
return false; }, { timeout: 45000 })
} catch { .toBe(true);
return false;
}
}, state);
} }
// Use Navigator to go to a specific page number (PDF) // Use Navigator to go to a specific page number (PDF)