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')}
-
+
)}
-
+
+
{staticGroups.map(
(group) =>
group.commands.length > 0 && (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 34376733..04bbdaf8 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -8,6 +8,7 @@ import { useLocalize } from '@/hooks';
* and the title truncates instead of being overlapped. The search button is
* named by what it shows ("Search Ctrl+K"; on phones, where only its icon
* shows, by its screen-reader-only "Search"), so its name holds its label.
+ * The title takes focus from script (`pageTitle`) after a command opens a page.
*/
export function Header({ title, onSearchClick, children }: t.HeaderProps) {
const localize = useLocalize();
@@ -21,7 +22,8 @@ export function Header({ title, onSearchClick, children }: t.HeaderProps) {
{title ? (
{title}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 9c0ea612..a21b0004 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -1,14 +1,14 @@
-import { useState } from 'react';
import { flushSync } from 'react-dom';
+import { useRef, useState } from 'react';
import { Icon, Dropdown } from '@clickhouse/click-ui';
import { Link, useRouter } from '@tanstack/react-router';
import type * as t from '@/types';
import { useStripAriaExpanded, useCapabilities, useLocalize } from '@/hooks';
+import { getInitials, isFocusLost, cn } from '@/utils';
import { DecorativeIcon } from '@/components/shared';
import libreChatLogo from '@/assets/librechat.svg';
import { SettingsDialog } from './SettingsDialog';
import { SystemCapabilities } from '@/constants';
-import { getInitials, cn } from '@/utils';
import { adminLogoutFn } from '@/server';
const navItems: t.NavItem[] = [
@@ -60,6 +60,8 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
const userMenuRef = useStripAriaExpanded();
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
+ /** An item was chosen: it may move focus itself (Settings opens a dialog), so closing leaves it be. */
+ const chosenRef = useRef(false);
const visibleItems = navItems.filter((item) => {
if (!item.capability) return true;
@@ -93,11 +95,30 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
* focus, and the dialog gives focus back to that button when it closes.
*/
const openSettings = () => {
+ chosenRef.current = true;
flushSync(() => setMenuOpen(false));
userMenuRef.current?.focus();
setSettingsOpen(true);
};
+ /**
+ * Gives focus back to the avatar once the menu closes (Escape, a click
+ * outside): Radix returns it to click-ui's trigger wrapper, which can't take
+ * it, so it fell to ``. After a chosen item, focus it moved stays.
+ */
+ const handleMenuCloseAutoFocus = (event: Event) => {
+ event.preventDefault();
+ const chosen = chosenRef.current;
+ chosenRef.current = false;
+ if (!chosen) {
+ userMenuRef.current?.focus();
+ return;
+ }
+ requestAnimationFrame(() => {
+ if (isFocusLost()) userMenuRef.current?.focus();
+ });
+ };
+
/** The avatar shows the initials, so its name starts with them (WCAG 2.5.3, label in name). */
const initials = getUserInitials(user);
const userName = user?.name || user?.email || '';
@@ -185,7 +206,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
-
+
diff --git a/src/components/SsoError.tsx b/src/components/SsoError.tsx
index f43d0d10..c18a3981 100644
--- a/src/components/SsoError.tsx
+++ b/src/components/SsoError.tsx
@@ -1,11 +1,13 @@
import { Link } from '@tanstack/react-router';
import type * as t from '@/types';
import { AUTH_ERROR_KEYS } from '@/constants';
-import { useLocalize } from '@/hooks';
+import { useDocumentTitle, useLocalize } from '@/hooks';
/** An SSO callback's failure: the panel's own wording of `code`, and the way back to sign-in. */
export function SsoError({ code }: t.SsoErrorProps) {
const localize = useLocalize();
+ const title = localize('com_auth_sso_error_title');
+ useDocumentTitle(title);
return (