diff --git a/e2e/a11y/config.spec.ts b/e2e/a11y/config.spec.ts index 2c20984d..710e184f 100644 --- a/e2e/a11y/config.spec.ts +++ b/e2e/a11y/config.spec.ts @@ -1,7 +1,17 @@ import AxeBuilder from '@axe-core/playwright'; import { test, expect } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { CONFIG_TABS, LIVE, LIVE_SKIP_REASON, THEMES, openConfigTab } from '../live'; +import { + LIVE, + THEMES, + WCAG_TAGS, + CONFIG_TABS, + openConfigTab, + lockUserProfile, + LIVE_SKIP_REASON, + selectUserRoleProfile, + deleteUserRoleProfile, +} from '../live'; /** * Accessibility and visual-system guardrails for the configuration page (VS-12): @@ -362,3 +372,318 @@ test.describe('custom endpoint drawer', () => { await expect.poll(scrolled).toBeGreaterThan(0); }); }); + +/** Every WCAG 2.0 and 2.1 A and AA violation axe finds, in `selector` when given. */ +async function wcagViolations(page: Page, selector?: string) { + const builder = new AxeBuilder({ page }).withTags(WCAG_TAGS); + const results = await (selector ? builder.include(selector) : builder).analyze(); + return results.violations.map((violation) => ({ + rule: violation.id, + targets: violation.nodes.slice(0, 5).map((node) => node.target.join(' ')), + })); +} + +const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' }); +const activeTab = (page: Page) => page.locator('[role="tab"][data-state="active"]'); + +/** Marks the status regions on the page now, so a test can tell a message put into one from a region mounted with it. */ +async function markStatusRegions(page: Page) { + await page + .locator('[role="status"]') + .evaluateAll((regions) => + regions.forEach((region) => region.setAttribute('data-e2e-before', '')), + ); + return page.locator('[role="status"][data-e2e-before]'); +} + +/** Waits two frames, so focus a deferred restore would move has moved. */ +async function settleFrames(page: Page) { + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); +} + +async function reviewAndSave(page: Page) { + await unsavedBar(page) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0); + await expect(unsavedBar(page)).toHaveCount(0); +} + +/** Opens Create endpoint and starts a blank endpoint; returns the drawer. */ +async function startBlankEndpoint(page: Page) { + await openConfigTab(page, 'custom'); + await page.getByRole('button', { name: 'Create endpoint' }).click(); + const drawer = page.getByRole('dialog'); + const blank = drawer.getByRole('button', { name: /^Blank endpoint/ }); + await blank.focus(); + await page.keyboard.press('Enter'); + return drawer; +} + +/** + * Views a dialog swaps in place of another: the control that swapped them is + * gone, so focus must land in the new view (not on the dialog itself), and + * the new view must pass the WCAG scan. + */ +for (const theme of THEMES) { + test.describe(`views swapped in place: ${theme}`, () => { + test.use({ colorScheme: theme, viewport: { width: 1440, height: 900 } }); + + test('Create endpoint, its blank form: focus on Name, no WCAG violations', async ({ page }) => { + const drawer = await startBlankEndpoint(page); + await expect(drawer.locator('#endpoints-custom-9999-name')).toBeFocused(); + expect(await wcagViolations(page, '[role="dialog"]')).toEqual([]); + }); + + test('the Preview drawer after Change: focus in the search, no WCAG violations', async ({ + page, + }) => { + await openConfigTab(page, 'system'); + await page.getByRole('button', { name: 'Preview', exact: true }).click(); + const drawer = page.getByRole('dialog'); + await drawer.getByRole('button', { name: 'Change' }).focus(); + await page.keyboard.press('Enter'); + await expect(drawer.getByRole('combobox')).toBeFocused(); + expect(await wcagViolations(page, '[role="dialog"]')).toEqual([]); + }); + + test('Import YAML, its second step: focus on its first choice, no WCAG violations', async ({ + page, + }) => { + await openConfigTab(page, 'system'); + await page.getByRole('button', { name: 'Import YAML' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('tab', { name: 'Paste YAML' }).click(); + await dialog + .getByRole('textbox', { name: 'Paste YAML' }) + .fill('balance:\n startBalance: 777\n'); + await dialog.getByRole('button', { name: 'Continue' }).focus(); + await page.keyboard.press('Enter'); + await expect(dialog.getByText('Where should these settings go?')).toBeVisible(); + await expect(dialog.getByRole('radio', { name: /^Base configuration/ })).toBeFocused(); + expect(await wcagViolations(page, '[role="dialog"]')).toEqual([]); + }); + }); +} + +/** Starting credits and its row's action: Remove override where the open profile sets its own. */ +function startBalanceRow(page: Page) { + const field = page.locator('#balance-startBalance'); + const action = page + .locator('.settings-row', { has: field }) + .locator('button.settings-row-action'); + return { field, action }; +} + +/** Waits until the USER role's profile is open with its own values loaded. */ +async function expectUserProfileOpen(page: Page) { + await expect(page.getByRole('region', { name: 'Profile being edited' })).toContainText( + 'Role: USER', + ); + await expect(startBalanceRow(page).field).toBeVisible(); +} + +/** Removes the USER role profile's own Starting credits, if it has one, and saves. */ +async function removeUserStartBalance(page: Page) { + await openConfigTab(page, 'system', 'role:USER'); + await expectUserProfileOpen(page); + const { action } = startBalanceRow(page); + if ((await action.count()) === 0) return; + await action.click(); + await reviewAndSave(page); +} + +test.describe('a role profile with an override of its own', () => { + test.use({ viewport: { width: 1440, height: 900 } }); + test.describe.configure({ timeout: 180_000 }); + /** + * What the test saved to the USER role's profile, for cleanup to undo in a + * page of its own (a failure can leave the test's page mid-edit): the + * profile, when it created it, or Starting credits, on a profile that had + * none of its own. A Starting credits the profile already had is never + * written: the test uses it as it is and discards its removal. + */ + let saved: 'profile' | 'override' | null = null; + + lockUserProfile(async (page) => { + const undo = saved; + saved = null; + if (undo == null) return; + const own = await page.context().newPage(); + try { + if (undo === 'profile') await deleteUserRoleProfile(own, true); + else await removeUserStartBalance(own); + } finally { + await own.close(); + } + }); + + test('has no WCAG violations with its sections open, and Remove override keeps focus on the field', async ({ + page, + }) => { + await openConfigTab(page, 'system'); + if (await selectUserRoleProfile(page)) saved = 'profile'; + await expect(page).toHaveURL(/scope=role(%3A|:)USER/); + await expectUserProfileOpen(page); + const { field: start, action: remove } = startBalanceRow(page); + if ((await remove.count()) === 0) { + saved ??= 'override'; + await start.fill('4321'); + await start.press('Tab'); + await reviewAndSave(page); + } + + await hideDevtools(page); + await expandSections(page); + await expect(remove).toHaveText(/^Remove override \(inherits /); + for (const colorScheme of THEMES) { + await page.emulateMedia({ colorScheme }); + await page.waitForFunction(() => document.getAnimations().length === 0); + expect(await wcagViolations(page)).toEqual([]); + } + + await remove.focus(); + await page.keyboard.press('Enter'); + await expect(remove).toHaveCount(0); + await expect(start).toBeFocused(); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + await expect(remove).toHaveText(/^Remove override \(inherits /); + }); +}); + +test.describe('focus stays on the page when a control takes itself away', () => { + test.use({ colorScheme: 'light', viewport: { width: 1440, height: 900 } }); + + test('Discard in the unsaved-changes bar puts focus on the open tab', async ({ page }) => { + await openConfigTab(page, 'system'); + const regions = await markStatusRegions(page); + const field = page.locator('#balance-refillAmount'); + await field.fill('12345'); + await field.press('Tab'); + await expect(regions.filter({ hasText: /^1 unsaved change/ })).toHaveCount(1); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).focus(); + await page.keyboard.press('Enter'); + await expect(unsavedBar(page)).toHaveCount(0); + await expect(activeTab(page)).toBeFocused(); + }); + + test('Jump to puts focus on the heading of the section it scrolls to', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 800 }); + await openConfigTab(page, 'features'); + await page.getByRole('button', { name: 'Jump to…' }).focus(); + await page.keyboard.press('Enter'); + for (let i = 0; i < 3; i++) await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect + .poll(() => + page.evaluate( + () => + document.activeElement?.matches( + 'form :is(h2, h3, h4, h5, h6) > button, form button[data-section-id]', + ) ?? false, + ), + ) + .toBe(true); + }); + + test('an MCP server rename gives focus back to its header on Escape or Enter, and leaves it where a click or Tab moved it', async ({ + page, + }) => { + await openConfigTab(page, 'mcp'); + await page.getByRole('button', { name: 'Create MCP server' }).click(); + const create = page.getByRole('dialog'); + await create.getByLabel('Server name').fill('e2e-focus'); + await create.locator('#mcpDraft-url').fill('https://mcp.e2e.example/mcp'); + await create.locator('#mcpDraft-url').press('Tab'); + await create.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(create).toHaveCount(0); + + const card = page.locator('[data-mcp-server="e2e-focus"]'); + const header = card.locator('button[data-section-id]'); + const rename = page.getByRole('textbox', { name: 'Rename e2e-focus' }); + const startRename = async () => { + await card.getByRole('button', { name: 'Actions for e2e-focus' }).click(); + await page.getByRole('menuitem', { name: /^Rename/ }).click(); + await expect(rename).toBeFocused(); + }; + + for (const key of ['Escape', 'Enter']) { + await startRename(); + await page.keyboard.press(key); + await expect(rename).toHaveCount(0); + await expect(header).toBeFocused(); + } + + const filter = page.getByRole('textbox', { name: 'Filter this tab…' }); + await startRename(); + await filter.click(); + await expect(rename).toHaveCount(0); + await settleFrames(page); + await expect(filter).toBeFocused(); + await page.keyboard.type('e2e'); + await expect(filter).toHaveValue('e2e'); + await filter.fill(''); + + const other = page + .locator('[data-mcp-server]:not([data-mcp-server="e2e-focus"]) button[data-section-id]') + .first(); + await startRename(); + await other.click(); + await expect(rename).toHaveCount(0); + await settleFrames(page); + await expect(other).toBeFocused(); + + await startRename(); + await page.keyboard.press('Tab'); + await expect(rename).toHaveCount(0); + await settleFrames(page); + await expect(header).not.toBeFocused(); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false); + + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + await expect(card).toHaveCount(0); + }); + + test("an MCP card's chips and summary show their tooltips and still open and close the card", async ({ + page, + }) => { + await openConfigTab(page, 'mcp'); + const card = page.locator('[data-mcp-server]').first(); + const header = card.locator('button[data-section-id]'); + const facts = card.locator(`[id="${await header.getAttribute('aria-describedby')}"]`); + const chip = facts.locator('.state-chip').first(); + await expect(chip).toHaveAttribute('title', /\S/); + const hits = await facts.evaluate((line) => + [line.querySelector('.state-chip'), line.querySelector('[title]:not(.state-chip)')].map( + (el) => { + if (!el) return 'missing'; + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return hit != null && el.contains(hit) + ? 'itself' + : (hit?.outerHTML.slice(0, 80) ?? 'none'); + }, + ), + ); + expect(hits).toEqual(['itself', 'itself']); + + const expanded = await header.getAttribute('aria-expanded'); + await chip.click(); + await expect(header).not.toHaveAttribute('aria-expanded', expanded ?? ''); + await chip.click(); + await expect(header).toHaveAttribute('aria-expanded', expanded ?? ''); + }); + + test('Escape asks before discarding a name typed into a blank endpoint', async ({ page }) => { + const drawer = await startBlankEndpoint(page); + const name = drawer.locator('#endpoints-custom-9999-name'); + await name.fill('Draft endpoint'); + await page.keyboard.press('Escape'); + await expect(drawer.getByText('Discard this new endpoint?')).toBeVisible(); + await expect(name).toHaveValue('Draft endpoint'); + }); +}); diff --git a/e2e/access.spec.ts b/e2e/access.spec.ts index a46218da..83c1cb8c 100644 --- a/e2e/access.spec.ts +++ b/e2e/access.spec.ts @@ -12,9 +12,13 @@ import { searchField, uniqueName, } from './access'; -import { AUDITED_WRITES_SKIP_REASON, LIVE, MOCK_FIXTURES_SKIP_REASON, THEMES } from './live'; - -const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +import { + LIVE, + THEMES, + WCAG_TAGS, + MOCK_FIXTURES_SKIP_REASON, + AUDITED_WRITES_SKIP_REASON, +} from './live'; async function hideTanStackDevtools(page: Page) { await page.evaluate(() => { diff --git a/e2e/config-shell.spec.ts b/e2e/config-shell.spec.ts index b2d18b16..060db39b 100644 --- a/e2e/config-shell.spec.ts +++ b/e2e/config-shell.spec.ts @@ -1,16 +1,25 @@ import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; import type { Page, Route } from '@playwright/test'; -import { LIVE, LIVE_SKIP_REASON, lockUserProfile } from './live'; +import { + LIVE, + WCAG_TAGS, + lockUserProfile, + LIVE_SKIP_REASON, + selectUserRoleProfile, + deleteUserRoleProfile, + AUDITED_WRITES_SKIP_REASON, +} from './live'; +import { createGroup, deleteFromList, uniqueName } from './access'; /** * Configuration page shell (WS5): loading and error routes, Expand all, * field-level search with deep links, URL-driven profile scope, the - * unsaved-changes guard and the 390 px layout. - * - * Needs a LibreChat backend with admin config routes (the default mock backend - * has none): run with E2E_LIVE=1 against a real stack. + * unsaved-changes guard and the 390 px layout. Those need a LibreChat backend + * with admin config routes (the default mock backend has none): run with + * E2E_LIVE=1 against a real stack. The app shell around every page (the + * command menu, page titles, toasts) runs against either backend. */ -test.skip(!LIVE, LIVE_SKIP_REASON); const TABS = ['providers', 'custom', 'specs', 'mcp', 'features', 'files', 'system']; /** Collapsed sections, groups and cards (not a model chip's inline editor or a select). */ @@ -37,6 +46,7 @@ function failServerFn(marker: string) { } test.describe('Configuration shell: loading and errors', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); test('a slow load shows a skeleton and never an editable form before the data', async ({ page, }) => { @@ -77,9 +87,47 @@ test.describe('Configuration shell: loading and errors', () => { await alert.getByRole('button', { name: 'Retry' }).click(); await expect(page.locator('form[aria-label]')).toBeVisible({ timeout: 30_000 }); }); + + const RETRIES = [ + { what: 'schema', marker: '"totalFields"', fromSidebar: true }, + { what: 'base config', marker: 'yamlPaths', fromSidebar: false }, + ] as const; + + for (const { what, marker, fromSidebar } of RETRIES) { + test(`Retry on a failing ${what} gives focus back to Retry if it fails again, then to the open tab once it loads`, async ({ + page, + }) => { + await page.route('**/_serverFn/**', failServerFn(marker)); + if (fromSidebar) { + await page.goto('/'); + await page.locator('aside a[href*="/configuration"]').first().click(); + } else { + await page.goto('/configuration?tab=system'); + } + const alert = page.getByRole('alert').filter({ hasText: "Couldn't load the configuration" }); + await expect(alert).toBeVisible({ timeout: 30_000 }); + await alert.evaluate((card) => card.setAttribute('data-e2e-retried', '')); + await alert.getByRole('button', { name: 'Retry' }).focus(); + await page.keyboard.press('Enter'); + + const again = page + .locator('[role="alert"]:not([data-e2e-retried])') + .filter({ hasText: "Couldn't load the configuration" }); + await expect(again).toBeVisible({ timeout: 30_000 }); + const retry = again.getByRole('button', { name: 'Retry' }); + await expect(retry).toBeFocused(); + await expect(page.locator('header h1')).not.toBeFocused(); + + await page.unroute('**/_serverFn/**'); + await page.keyboard.press('Enter'); + await expect(page.locator('form[aria-label]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[role="tab"][data-state="active"]')).toBeFocused(); + }); + } }); test.describe('Configuration shell: toolbar', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); test.describe.configure({ timeout: 120_000 }); for (const tab of TABS) { @@ -94,6 +142,32 @@ test.describe('Configuration shell: toolbar', () => { }); } + test("the filter's clear button is named in the panel's language", async ({ page }) => { + await openTab(page, 'system'); + const filter = page.getByRole('textbox', { name: 'Filter this tab…' }); + await page + .locator('[role="status"]') + .evaluateAll((regions) => + regions.forEach((region) => region.setAttribute('data-e2e-before', '')), + ); + await filter.fill('zzzzxx'); + await expect( + page + .getByText('No settings on this tab match “zzzzxx”') + .and(page.locator(':not([role="status"])')), + ).toBeVisible(); + await expect( + page + .locator('[role="status"][data-e2e-before]') + .filter({ hasText: 'No settings on this tab match “zzzzxx”' }), + ).toHaveCount(1); + const clear = page.getByRole('button', { name: 'Clear', exact: true }); + await expect(clear).toBeVisible(); + await expect(page.getByRole('button', { name: 'clear input' })).toHaveCount(0); + await clear.click(); + await expect(filter).toHaveValue(''); + }); + test('the toolbar never covers an Add button scrolled to the top', async ({ page }) => { await openTab(page, 'features'); await page.getByRole('button', { name: 'Expand all' }).click(); @@ -131,6 +205,7 @@ test.describe('Configuration shell: toolbar', () => { }); test.describe('Configuration shell: search and deep links', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); async function search(page: Page, query: string) { await page.keyboard.press('Control+k'); await page.keyboard.type(query); @@ -180,57 +255,19 @@ test.describe('Configuration shell: search and deep links', () => { }); test.describe('Configuration shell: Back from the USER role profile', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); /** Set once a test creates the USER role's profile, so cleanup never deletes one it found. */ let createdUserProfile = false; - /** Opens the scope selector once its profile list has loaded. */ - async function openScopeSelector(page: Page) { - await page.getByRole('button', { name: /^Select scope/ }).click(); - const dialog = page.getByRole('dialog'); - await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible(); - return dialog; - } - - /** Opens the USER role's profile; on a clean database, creating it opens it. */ - async function selectUserRole(page: Page) { - const dialog = await openScopeSelector(page); - const user = dialog.getByRole('option', { name: /^USER / }); - if ((await user.count()) > 0) { - await user.click(); - return; - } - await dialog.getByRole('button', { name: 'Create' }).click(); - await dialog.getByRole('option', { name: 'USER', exact: true }).click(); - createdUserProfile = true; - } - - /** - * Deletes the USER role's profile unless something has saved overrides to it since. - * The confirm view lists no options and stays up until the delete succeeds, so the - * profile list coming back is what proves the delete finished. - */ - async function deleteUserProfile(page: Page) { - await openTab(page, 'system'); - const dialog = await openScopeSelector(page); - const user = dialog.getByRole('option', { name: /^USER / }); - if ((await user.filter({ hasText: 'No overrides' }).count()) === 0) return; - await dialog.getByRole('option', { name: 'Delete profile USER', exact: true }).click(); - await dialog.getByRole('button', { name: 'Delete configuration' }).click(); - await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible({ - timeout: 10_000, - }); - await expect(user).toHaveCount(0); - } - lockUserProfile(async (page) => { if (!createdUserProfile) return; createdUserProfile = false; - await deleteUserProfile(page); + await deleteUserRoleProfile(page); }); test('Back after selecting a profile restores Base in the URL and the UI', async ({ page }) => { await openTab(page, 'system'); - await selectUserRole(page); + createdUserProfile = await selectUserRoleProfile(page); await expect(page).toHaveURL(/scope=role(%3A|:)USER/); await expect(page.getByRole('button', { name: /^Select scope: USER/ })).toBeVisible(); await page.goBack(); @@ -240,6 +277,7 @@ test.describe('Configuration shell: Back from the USER role profile', () => { }); test.describe('Configuration shell: profile scope and guard', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); test('a fresh visit opens Base, an unknown profile says so', async ({ page }) => { await openTab(page, 'system'); await expect(page.getByRole('button', { name: /^Select scope: Base/ })).toBeVisible(); @@ -276,6 +314,7 @@ test.describe('Configuration shell: profile scope and guard', () => { }); test.describe('Configuration shell: 390 px', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); test.use({ viewport: { width: 390, height: 844 } }); test('header actions never overlap the title and Import YAML is reachable', async ({ @@ -309,3 +348,116 @@ test.describe('Configuration shell: 390 px', () => { await expect(page.getByRole('dialog')).toBeVisible(); }); }); + +test.describe('App shell: command menu', () => { + test('says "No results" without an empty listbox, with no WCAG violations', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('header h1')).toBeVisible(); + await page.keyboard.press('Control+k'); + const menu = page.getByRole('dialog'); + await expect(menu).toBeVisible(); + await page.keyboard.type('zzzzxx'); + await expect(menu.getByRole('status')).toHaveText('No results found.'); + await expect(menu.getByRole('listbox')).toHaveCount(0); + const results = await new AxeBuilder({ page }) + .withTags(WCAG_TAGS) + .include('[role="dialog"]') + .analyze(); + expect(results.violations).toEqual([]); + }); + + test('a command that opens a page puts focus on its title, not on ', async ({ page }) => { + await page.goto('/'); + const search = page.getByRole('button', { name: /^Search/ }); + await search.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.type('Access'); + await expect(page.locator('[cmdk-item]').first()).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/access/); + const title = page.locator('header h1'); + await expect(title).toHaveText('Access management'); + await expect(title).toBeFocused(); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false); + }); +}); + +test.describe('App shell: page titles', () => { + const PAGES = [ + { path: '/', title: /^Dashboard · LibreChat Admin Panel$/ }, + { path: '/configuration', title: /^Configuration · LibreChat Admin Panel$/ }, + { path: '/access', title: /^Access management · LibreChat Admin Panel$/ }, + { path: '/grants', title: /^System grants · LibreChat Admin Panel$/ }, + { path: '/help', title: /^Help · LibreChat Admin Panel$/ }, + { path: '/no-such-page', title: /^Page not found · LibreChat Admin Panel$/ }, + ] as const; + + for (const { path, title } of PAGES) { + test(`${path} names the browser tab after the page`, async ({ page }) => { + await page.goto(path); + await expect(page).toHaveTitle(title); + }); + } + + test('a page opened from the sidebar renames the tab', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/^Dashboard/); + await page.locator('aside a[href*="/grants"]').first().click(); + await expect(page).toHaveTitle(/^System grants · /); + }); + + test.describe('signed out', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('/login names the browser tab "Sign in"', async ({ page }) => { + await page.goto('/login'); + await expect(page).toHaveTitle(/^Sign in · LibreChat Admin Panel$/); + }); + + test('a failed SSO callback names the browser tab "Authentication failed"', async ({ + page, + }) => { + await page.goto( + '/auth/openid/callback?error=auth_failed&error_description=Authentication+failed', + ); + await expect(page.getByRole('heading', { name: 'Authentication failed' })).toBeVisible(); + await expect(page).toHaveTitle(/^Authentication failed · LibreChat Admin Panel$/); + }); + }); +}); + +test.describe('App shell: toasts', () => { + let created: string | undefined; + + test.afterEach(async ({ page }) => { + if (!created) return; + await deleteFromList(page, 'groups', created); + created = undefined; + }); + + test("a toast's close button is named Close", async ({ page }) => { + test.skip(LIVE, AUDITED_WRITES_SKIP_REASON); + created = uniqueName('Toast'); + await createGroup(page, created); + const toasts = page.getByRole('region', { name: /^Notifications/ }); + await expect(toasts.getByRole('listitem')).not.toHaveCount(0); + await expect(toasts.getByRole('button', { name: 'Close' }).first()).toBeVisible(); + await expect(toasts.getByRole('button', { name: 'cross' })).toHaveCount(0); + }); + + test('the Undo toast of Discard names its close button Close', async ({ page }) => { + test.skip(!LIVE, LIVE_SKIP_REASON); + await openTab(page, 'system'); + const field = page.locator('#balance-refillAmount'); + await field.fill('12345'); + await field.press('Tab'); + await page + .getByRole('region', { name: 'Unsaved changes' }) + .getByRole('button', { name: 'Discard' }) + .click(); + const toasts = page.getByRole('region', { name: /^Notifications/ }); + await expect(toasts.getByRole('button', { name: 'Undo' })).toBeVisible(); + await expect(toasts.getByRole('button', { name: 'Close' })).toBeVisible(); + }); +}); diff --git a/e2e/features.spec.ts b/e2e/features.spec.ts index 2e866910..09d170c5 100644 --- a/e2e/features.spec.ts +++ b/e2e/features.spec.ts @@ -63,9 +63,7 @@ test.describe('Features editors', () => { ); await expect(memory.getByLabel('Model', { exact: true })).toHaveValue('gpt-4o-mini'); - await memory - .getByRole('button', { name: /^Revert Agent to its librechat\.yaml value/ }) - .click(); + await memory.getByRole('button', { name: /^Revert to librechat\.yaml \(.*\): Agent$/ }).click(); await review(page); await saveAndWait(page); await expect(memory.getByRole('button', { name: 'Existing agent' })).toHaveAttribute( @@ -119,8 +117,12 @@ test.describe('Features editors', () => { web.getByText('Stored encrypted. Only the last characters are shown.'), ).toBeVisible(); - await web.getByRole('button', { name: /^Revert Serper API key/ }).click(); - await web.getByRole('button', { name: /^Revert Search provider/ }).click(); + await web + .getByRole('button', { name: /^(Revert to .+|Remove override): Serper API key$/ }) + .click(); + await web + .getByRole('button', { name: /^(Revert to .+|Remove override): Search provider$/ }) + .click(); await review(page); await saveAndWait(page); }); diff --git a/e2e/grants.spec.ts b/e2e/grants.spec.ts index 8c6ec2b0..55d534b9 100644 --- a/e2e/grants.spec.ts +++ b/e2e/grants.spec.ts @@ -5,14 +5,13 @@ import type { Page } from '@playwright/test'; import { createRole, deleteFromList, grantReadUsage, searchField, uniqueName } from './access'; import { LIVE, + WCAG_TAGS, MOCK_BACKEND_URL, MOCK_ADMIN_HEADERS, MOCK_FIXTURES_SKIP_REASON, AUDITED_WRITES_SKIP_REASON, } from './live'; -const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; - async function hideTanStackDevtools(page: Page) { await page.evaluate(() => { document diff --git a/e2e/live.ts b/e2e/live.ts index 7a8edc89..da3a369b 100644 --- a/e2e/live.ts +++ b/e2e/live.ts @@ -53,9 +53,15 @@ export const CONFIG_TABS = [ export const THEMES = ['light', 'dark'] as const; -/** Opens a configuration tab and waits until its form has rendered real fields. */ -export async function openConfigTab(page: Page, tab: string) { - await page.goto(`/configuration?tab=${tab}`); +/** The WCAG 2.0 and 2.1 level A and AA rules the panel's axe scans run. */ +export const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +/** + * Opens a configuration tab, in the profile of `scope` (e.g. `role:USER`) if + * given, and waits until its form has rendered real fields. + */ +export async function openConfigTab(page: Page, tab: string, scope?: string) { + await page.goto(`/configuration?tab=${tab}${scope ? `&scope=${scope}` : ''}`); const form = page.getByRole('form', { name: 'Configuration' }); await expect(form).toBeVisible({ timeout: 30_000 }); await page.waitForLoadState('networkidle'); @@ -187,6 +193,49 @@ export function lockUserProfile(cleanup?: (page: Page) => Promise) { }); } +/** Opens the scope selector once its profile list has loaded. */ +export async function openScopeSelector(page: Page) { + await page.getByRole('button', { name: /^Select scope/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible(); + return dialog; +} + +/** + * Opens the USER role's profile; on a clean database, creating it opens it. + * Returns whether it created the profile. Call it under `lockUserProfile`. + */ +export async function selectUserRoleProfile(page: Page): Promise { + const dialog = await openScopeSelector(page); + const user = dialog.getByRole('option', { name: /^USER / }); + if ((await user.count()) > 0) { + await user.click(); + return false; + } + await dialog.getByRole('button', { name: 'Create' }).click(); + await dialog.getByRole('option', { name: 'USER', exact: true }).click(); + return true; +} + +/** + * Deletes the USER role's profile, unless something has saved overrides to it + * since and `force` is unset (a test deleting a profile it created passes it). + * The confirm view lists no options and stays up until the delete succeeds, + * so the profile list coming back is what proves the delete finished. + */ +export async function deleteUserRoleProfile(page: Page, force = false) { + await openConfigTab(page, 'system'); + const dialog = await openScopeSelector(page); + const user = dialog.getByRole('option', { name: /^USER / }); + if (!force && (await user.filter({ hasText: 'No overrides' }).count()) === 0) return; + await dialog.getByRole('option', { name: 'Delete profile USER', exact: true }).click(); + await dialog.getByRole('button', { name: 'Delete configuration' }).click(); + await expect(dialog.getByRole('option', { name: /^Base configuration/ })).toBeVisible({ + timeout: 10_000, + }); + await expect(user).toHaveCount(0); +} + /** click-ui Selects for the a11y specs: each `open` loads the Select's page and returns its trigger. */ export const SELECTS = [ { diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index c4261107..6b9f6688 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,8 +1,7 @@ import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import type { Page, Route } from '@playwright/test'; - -const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +import { WCAG_TAGS } from './live'; // Login tests must be unauthenticated test.use({ storageState: { cookies: [], origins: [] } }); diff --git a/e2e/sidebar.spec.ts b/e2e/sidebar.spec.ts index d182b685..39a69322 100644 --- a/e2e/sidebar.spec.ts +++ b/e2e/sidebar.spec.ts @@ -192,4 +192,15 @@ test.describe('Sidebar rail', () => { expect(fit.ellipsized).toBe(true); } }); + + test('gives focus back to the avatar when its menu closes with Escape', async ({ page }) => { + await openSidebar(page, false); + const avatar = page.getByRole('button', { name: RINGED[0][1] }); + await avatar.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toBeHidden(); + await expect(avatar).toBeFocused(); + }); }); diff --git a/src/components/CommandMenu.tsx b/src/components/CommandMenu.tsx index a80e2e02..870f15ff 100644 --- a/src/components/CommandMenu.tsx +++ b/src/components/CommandMenu.tsx @@ -8,11 +8,18 @@ import type * as t from '@/types'; import { matchesQuery, searchConfigIndex } from './configuration/search'; import { useSearchIndex, useLocalize, useReturnFocus } from '@/hooks'; import { CONFIG_TABS } from './configuration/configMeta'; +import { pageTitle, restoreFocusLater } from '@/utils'; import { CommandDialog } from '@/components/shared'; import { useTheme } from '@/contexts/ThemeContext'; const MAX_SETTING_HITS = 30; +/** + * The command menu (Ctrl+K): pages, tabs, settings and theme actions. "No + * results" is a status message beside the listbox, which may hold only + * options; the list stays in the DOM, hidden, as the target of the input's + * `aria-controls`. + */ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { const localize = useLocalize(); const router = useRouter(); @@ -22,7 +29,8 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { const [search, setSearch] = useState(''); const listRef = useRef(null); const returnFocus = useReturnFocus(open); - const navigatedRef = useRef(false); + /** The navigation a command started, while its menu closes. */ + const navigationRef = useRef | null>(null); const handleSearchChange = useCallback((value: string) => { setSearch(value); @@ -41,29 +49,38 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { const close = useCallback(() => handleOpenChange(false), [handleOpenChange]); - /** Closes for a command that opens a page: where focus goes is then up to that page. */ - const closeToNavigate = useCallback(() => { - navigatedRef.current = true; - close(); - }, [close]); + /** Closes for a command that opens a page: focus then goes to that page. */ + const closeToNavigate = useCallback( + (navigate: () => Promise) => { + close(); + navigationRef.current = navigate(); + }, + [close], + ); - /** Gives focus back to the opener, unless a command opened a page. */ + /** + * Gives focus back to the opener; after a command opened a page, to that + * page's title once it has rendered (WCAG 2.4.3), unless the page put focus + * somewhere itself (a setting it reveals takes it from the title). + */ const handleCloseAutoFocus = useCallback( (event: Event) => { - if (navigatedRef.current) { - navigatedRef.current = false; - event.preventDefault(); + const navigation = navigationRef.current; + navigationRef.current = null; + if (!navigation) { + returnFocus(event); return; } - returnFocus(event); + event.preventDefault(); + const focusTitle = () => restoreFocusLater([pageTitle]); + void navigation.then(focusTitle, focusTitle); }, [returnFocus], ); const navigateTo = useCallback( (path: string, searchParams?: Record) => { - closeToNavigate(); - router.navigate({ to: path, search: searchParams }); + closeToNavigate(() => router.navigate({ to: path, search: searchParams })); }, [closeToNavigate, router], ); @@ -71,16 +88,17 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { /** Opens a setting on the configuration page (revealed and focused there), keeping the open profile. */ const openSetting = useCallback( (item: t.ConfigSearchItem) => { - closeToNavigate(); const { pathname, search: current } = router.state.location; const scope = pathname.startsWith('/configuration') && typeof current.scope === 'string' ? current.scope : undefined; - router.navigate({ - to: '/configuration', - search: { tab: item.tab, field: item.path, ...(scope && { scope }) }, - }); + closeToNavigate(() => + router.navigate({ + to: '/configuration', + search: { tab: item.tab, field: item.path, ...(scope && { scope }) }, + }), + ); }, [closeToNavigate, router], ); @@ -221,13 +239,18 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { placeholder={localize('com_cmdk_placeholder')} className="flex w-full border-b border-(--cui-color-stroke-default) bg-transparent px-4 py-3 text-sm text-(--cui-color-text-default) outline-none placeholder:text-(--cui-color-text-muted)" /> - +
{isEmpty && ( -
+

{localize('com_cmdk_no_results')} -

+

)} - +
+