LibreChat/e2e/specs/a11y.spec.ts
James Todaro e49e264487
fix: Resolve axe Violations in Sidebar, Tools Dropdown and Footer (#14979)
*  fix: Resolve axe Violations in Sidebar, Tools Dropdown and Footer

- give virtualized conversation rows the row/gridcell roles their grid and rowgroup parents require
- make the conversation row a non-interactive container, moving its accessible name, aria-current and focus ring onto the title control so it no longer wraps the options button
- open the tools menu non-modally and portal it into the main landmark, dropping Ariakit's injected dismiss button and keeping menu content inside a landmark
- expose aria-valuenow, aria-valuemin and aria-valuemax on the sidebar resize handle
- drop role="contentinfo" from the chat footer, which is never rendered outside main
- scan the loaded app in a11y.spec.ts, and cover seeded conversation rows, a hovered row and the open tools menu

*  fix: Keep the Resize Handle's ARIA Range Valid at Every Viewport

- floor the announced maximum at the aside's own min-width, so viewports where 40% falls under it no longer report a maximum below the minimum
- track the viewport so the announced range follows a resize instead of a render-time snapshot
2026-08-20 11:52:53 -04:00

106 lines
3.9 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright'; // 1
import { deleteConversations, seedConversations } from './mock/db';
import { getE2EUser } from '../setup/user';
const SEEDED_IDS = ['a11y-spec-convo-1', 'a11y-spec-convo-2'];
/** A fresh e2e user has no conversations, so without seeding the sidebar renders no rows
* and no scan below reaches the conversation row markup. The pre-delete keeps the suite
* idempotent: the ids are fixed and conversations are uniquely indexed, so a run that
* dies before afterAll would otherwise leave the next one to fail on insert. */
test.beforeAll(async () => {
await deleteConversations(SEEDED_IDS);
await seedConversations(
getE2EUser().email,
SEEDED_IDS.map((conversationId, i) => ({
conversationId,
title: `A11y conversation ${i + 1}`,
updatedAt: new Date(),
})),
);
});
test.afterAll(async () => {
await deleteConversations(SEEDED_IDS);
});
/** Scanning straight after navigation catches a pre-render DOM with no main landmark and
* no composer, so waiting for the composer keeps every scan on the loaded app. Navigate
* relative to the config `baseURL` rather than a hardcoded port. */
async function loadApp(page: Page) {
await page.goto('/', { timeout: 30000 });
await page.getByTestId('text-input').waitFor({ state: 'visible', timeout: 30000 });
}
test('Landing page should not have any automatically detectable accessibility issues', async ({
page,
}) => {
await loadApp(page);
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('Conversation page should be accessible', async ({ page }) => {
await loadApp(page);
// Create a conversation (you may need to adjust this based on your app's behavior)
const input = await page.locator('form').getByRole('textbox');
await input.click();
await input.fill('Hi!');
await page.locator('form').getByRole('button').nth(1).click();
await page.waitForTimeout(3500);
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('Navigation elements should be accessible', async ({ page }) => {
await loadApp(page);
const navAccessibilityScanResults = await new AxeBuilder({ page }).include('nav').analyze();
expect(navAccessibilityScanResults.violations).toEqual([]);
});
test('Input form should be accessible', async ({ page }) => {
await loadApp(page);
const formAccessibilityScanResults = await new AxeBuilder({ page }).include('form').analyze();
expect(formAccessibilityScanResults.violations).toEqual([]);
});
/** Hovering reveals the row's options button, which is what makes the row an interactive
* control containing another interactive control. Wait on that button by id rather than
* on any button in the row: the row's title control is always present, so a role match
* would be satisfied with the options control still unmounted. */
test('Conversation list rows should be accessible with their controls revealed', async ({
page,
}) => {
await loadApp(page);
const row = page.getByTestId('convo-item').first();
await expect(row).toBeVisible({ timeout: 15000 });
await row.hover();
await expect(row.locator('[id^="conversation-menu-"]')).toBeVisible();
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('Tools menu should be accessible when open', async ({ page }) => {
await loadApp(page);
await page.locator('#tools-dropdown-button').first().click();
await expect(page.locator('#tools-dropdown-menu')).toBeVisible({ timeout: 10000 });
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});