Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 225 additions & 1 deletion e2e/access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
searchField,
uniqueName,
} from './access';
import { AUDITED_WRITES_SKIP_REASON, LIVE, MOCK_FIXTURES_SKIP_REASON } from './live';
import { AUDITED_WRITES_SKIP_REASON, LIVE, MOCK_FIXTURES_SKIP_REASON, THEMES } from './live';

const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];

Expand Down Expand Up @@ -136,6 +136,167 @@ test.describe('Access page accessibility', () => {
});
});

/** The open dialog (Edit role, Edit group, Delete role). */
const dialogOf = (page: Page) => page.getByRole('dialog');

/** axe on the open dialog only: the page behind it is inert while it is open. */
async function scanDialog(page: Page) {
return new AxeBuilder({ page }).withTags(WCAG_TAGS).include('[role="dialog"]').analyze();
}

/** Opens a role's Edit role dialog from its row. */
async function openEditRole(page: Page, role: string) {
await page.goto('/access?tab=roles');
await page.waitForLoadState('networkidle');
await searchField(page).fill(role);
await page.getByText(role, { exact: true }).click();
await expect(dialogOf(page)).toBeVisible();
return dialogOf(page);
}

for (const theme of THEMES) {
test.describe(`Access page Edit role dialog accessibility: ${theme}`, () => {
test.use({ colorScheme: theme });

for (const tab of ['Details', 'Permissions', 'Members']) {
test(`has zero WCAG 2.1 AA violations on the ${tab} tab`, async ({ page }) => {
const dialog = await openEditRole(page, 'ADMIN');
await dialog.getByRole('tab', { name: tab }).click();
await expect(dialog.getByRole('tab', { name: tab })).toHaveAttribute(
'data-state',
'active',
);
await expect(dialog.getByText('Loading…').filter({ visible: true })).toHaveCount(0);

expect((await scanDialog(page)).violations).toEqual([]);
});
}
});
}

test.describe('Access page Edit role dialog reflow', () => {
for (const width of [390, 320]) {
test(`keeps the Permissions tab's feature grid inside the dialog at ${width}px`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 844 });
const dialog = await openEditRole(page, 'ADMIN');
await dialog.getByRole('tab', { name: 'Permissions' }).click();
const features = dialog.getByText('Allowed features', { exact: true });
await expect(features).toBeVisible();
const grid = features.locator('xpath=following-sibling::div[1]');
const [gridBox, dialogBox] = await Promise.all([grid.boundingBox(), dialog.boundingBox()]);
expect(gridBox && dialogBox).toBeTruthy();
expect(gridBox!.x + gridBox!.width).toBeLessThanOrEqual(dialogBox!.x + dialogBox!.width);
});
}
});

/**
* These open the mock's non-system Support role and its Engineering group, and
* search the mock's users; a fresh LibreChat has none of them.
*/
test.describe('Access page dialog keyboard and contrast', () => {
test.skip(LIVE, MOCK_FIXTURES_SKIP_REASON);
test.use({ colorScheme: 'light' });

test('Delete role gives focus back to its Delete button on Escape and on Cancel', async ({
page,
}) => {
await page.goto('/access?tab=roles');
await page.waitForLoadState('networkidle');
const opener = page.getByRole('button', { name: 'Delete Support', exact: true });

for (const close of ['Escape', 'Cancel'] as const) {
await opener.focus();
await page.keyboard.press('Enter');
const dialog = dialogOf(page);
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
if (close === 'Escape') await page.keyboard.press('Escape');
else await dialog.getByRole('button', { name: 'Cancel' }).press('Enter');
await expect(dialog).not.toBeVisible();
await expect(opener).toBeFocused();
}
});

test('Delete role has zero WCAG 2.1 AA violations with Delete focused', async ({ page }) => {
await page.goto('/access?tab=roles');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Delete Support', exact: true }).focus();
await page.keyboard.press('Enter');
const confirm = dialogOf(page).getByRole('button', { name: 'Delete', exact: true });
await confirm.focus();
await expect(confirm).toBeFocused();

expect((await scanDialog(page)).violations).toEqual([]);
});

/** Opens Support's Members tab and highlights the first user the search suggests. */
async function highlightFirstSuggestion(page: Page) {
const dialog = await openEditRole(page, 'Support');
await dialog.getByRole('tab', { name: 'Members' }).click();
const search = dialog.getByRole('combobox', { name: /search users to add/i });
await search.fill('al');
const first = page.getByRole('listbox').getByRole('option').first();
await expect(first).toBeVisible();
await search.press('ArrowDown');
await expect(first).toHaveAttribute('aria-selected', 'true');
return { dialog, search, first };
}

test('member search: the highlighted suggestion passes axe', async ({ page }) => {
await highlightFirstSuggestion(page);

const results = await new AxeBuilder({ page })
.withTags(WCAG_TAGS)
.include('[role="listbox"]')
.analyze();
expect(results.violations).toEqual([]);
});

test('member search: the field points at the highlighted suggestion', async ({ page }) => {
const { search, first } = await highlightFirstSuggestion(page);

const id = await first.getAttribute('id');
expect(id).toBeTruthy();
await expect(search).toHaveAttribute('aria-activedescendant', id ?? '');
});

test('member search: Escape closes the suggestions first, then the dialog', async ({ page }) => {
const { dialog, search } = await highlightFirstSuggestion(page);

await page.keyboard.press('Escape');
await expect(page.getByRole('listbox')).toHaveCount(0);
await expect(dialog).toBeVisible();
await expect(search).toBeFocused();

await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});

test('a member staged for removal stays legible, says so, and offers a focused Undo', async ({
page,
}) => {
await page.goto('/access?tab=groups');
await page.waitForLoadState('networkidle');
await page.getByText('Engineering', { exact: true }).click();
const dialog = dialogOf(page);
await dialog.getByRole('tab', { name: 'Members' }).click();
await dialog.getByRole('button', { name: 'Remove Alice Chen' }).click();
await expect(dialog.getByText(/^Removing 1 members?$/)).toBeVisible();

expect((await scanDialog(page)).violations).toEqual([]);
await expect(dialog.getByRole('button', { name: 'Undo removing Alice Chen' })).toBeFocused();
await expect(dialog.getByText('Removing 1 member', { exact: true })).toBeVisible();
await expect(dialog.getByText('Will be removed', { exact: true })).toBeVisible();

await page.keyboard.press('Enter');
await expect(dialog.getByRole('button', { name: 'Remove Alice Chen' })).toBeFocused();
await expect(dialog.getByText('Will be removed', { exact: true })).toHaveCount(0);
});
});

test.describe('Access page tabs', () => {
test('defaults to Roles tab', async ({ page }) => {
await page.goto('/access');
Expand Down Expand Up @@ -236,6 +397,24 @@ test.describe('Access page Create Group dialog', () => {
await expect(page.getByText(name, { exact: true })).toBeVisible({ timeout: 5000 });
});

test('placeholders use the muted text color, not the disabled one', async ({ page }) => {
await page.goto('/access?tab=groups');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /create group/i }).click();
const name = page.locator('#create-group-name');
await expect(name).toBeVisible();

const colors = await name.evaluate((input) => {
const probe = document.createElement('span');
probe.style.color = 'var(--cui-color-text-muted)';
document.body.append(probe);
const muted = getComputedStyle(probe).color;
probe.remove();
return { placeholder: getComputedStyle(input, '::placeholder').color, muted };
});
expect(colors.placeholder).toBe(colors.muted);
});

test('Create Group button is disabled when name is empty', async ({ page }) => {
await page.goto('/access?tab=groups');
await page.waitForLoadState('networkidle');
Expand Down Expand Up @@ -383,6 +562,51 @@ test.describe('Access page delete', () => {
});
});

/**
* A delete removes its row only when the list refetches, after the dialog has
* closed and given focus back, so focus left on the row's Delete would be lost
* with the row. These delete a role and a group from the keyboard alone. They
* focus the dialog's Delete directly: which button it opens on is checked above.
*/
test.describe('Access page delete from the keyboard', () => {
test.skip(LIVE, AUDITED_WRITES_SKIP_REASON);
const tabs = [
{ tab: 'roles', kind: 'role', create: createRole },
{ tab: 'groups', kind: 'group', create: createGroup },
] as const;
let created: { tab: (typeof tabs)[number]['tab']; name: string } | undefined;

test.afterEach(async ({ page }) => {
if (!created) return;
await deleteFromList(page, created.tab, created.name);
created = undefined;
});

for (const { tab, kind, create } of tabs) {
test(`gives focus to the search field once a ${kind} is deleted`, async ({ page }) => {
const name = uniqueName(`Keyboard ${kind}`);
created = { tab, name };
await create(page, name);
await searchField(page).fill(name);
const opener = page.getByRole('button', { name: `Delete ${name}`, exact: true });
await expect(opener).toBeVisible();

await opener.focus();
await page.keyboard.press('Enter');
const dialog = dialogOf(page);
const confirm = dialog.getByRole('button', { name: 'Delete', exact: true });
await confirm.focus();
await expect(confirm).toBeFocused();
await page.keyboard.press('Enter');

await expect(dialog).not.toBeVisible();
await expect(opener).toHaveCount(0);
created = undefined;
await expect(searchField(page)).toBeFocused();
});
}
});

/**
* LibreChat keys system grants by role name and its role rename (`renameRole`
* in `packages/api/src/admin/roles.ts`) moves only the role's users, so the
Expand Down
81 changes: 80 additions & 1 deletion e2e/grants.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ test.describe('Grants page - Management tab', () => {
const adminRow = page.locator('tbody tr').filter({ hasText: 'Admin' }).first();
await adminRow.click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('dialog').locator('.badge-role')).toBeVisible();
const badge = page.getByRole('dialog').locator('.badge-role');
await expect(badge).toBeVisible();
await expect(badge.getByRole('img')).toHaveCount(0);
});

test('dialog cancel discards changes', async ({ page }) => {
Expand Down Expand Up @@ -166,6 +168,41 @@ test.describe('Grants page - Audit Log tab', () => {
await expect(page.getByRole('table')).toBeVisible();
});

test('Clear dates gives focus to the From date picker', async ({ page }) => {
await page.getByRole('button', { name: 'From', exact: true }).click();
await page.getByRole('gridcell').filter({ hasText: /^1$/ }).first().click();
const clear = page.getByRole('button', { name: 'Clear date filters' });
await clear.focus();
await page.keyboard.press('Enter');

await expect(clear).toHaveCount(0);
await expect(page.locator('#audit-date-from')).toBeFocused();
await expect(page.locator('#audit-date-from')).toHaveAccessibleName('From');
});

for (const width of [390, 320]) {
test(`the filters wrap at ${width}px: no sideways scroll, Export on screen`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 844 });
const filters = page.getByRole('group', { name: 'Filters' });
await expect(filters).toBeVisible();

const overflow = await filters.evaluate((el) => {
let box = el.parentElement;
while (box && getComputedStyle(box).overflowY !== 'auto') box = box.parentElement;
return box ? box.scrollWidth - box.clientWidth : 0;
});
expect(overflow).toBeLessThanOrEqual(0);
const exportBox = await page
.getByRole('button', { name: /export all matching/i })
.boundingBox();
expect(exportBox).not.toBeNull();
expect(exportBox!.x).toBeGreaterThanOrEqual(0);
expect(exportBox!.x + exportBox!.width).toBeLessThanOrEqual(width);
});
}

test('empty state when no entries match filters', async ({ page }) => {
await searchField(page).fill('zzzznonexistent');
await page.waitForTimeout(500);
Expand Down Expand Up @@ -220,6 +257,17 @@ test.describe('Grants page - Audit Log tab', () => {
}
});

test('Export keeps focus on its button while it runs', async ({ page }) => {
const exportButton = page.getByRole('button', { name: /export all matching/i });
await exportButton.focus();
const download = page.waitForEvent('download');
await page.keyboard.press('Enter');
await download;

await expect(exportButton).toBeFocused();
await expect(exportButton).not.toHaveAttribute('aria-busy');
});

test('Export writes the seeded entry the filters match', async ({ page }) => {
await searchField(page).fill('Alice');
await page.getByRole('button', { name: /^Revoked$/i }).click();
Expand Down Expand Up @@ -262,6 +310,37 @@ test.describe('Grants page - Audit Log tab', () => {
await expect(drawer.getByText('Alice Chen').first()).toBeVisible();
await expect(drawer.getByText('read:agents').first()).toBeVisible();
await expect(drawer.getByText(/entry not found/i)).toHaveCount(0);

const results = await new AxeBuilder({ page })
.withTags(WCAG_TAGS)
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});

test('copying a value from the drawer is announced', async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Open audit log entry for USER' }).click();
const copy = page.getByRole('dialog').locator('button', { hasText: 'read:agents' });
await copy.focus();
await page.keyboard.press('Enter');

await expect(page.getByRole('status').filter({ hasText: 'Copied' })).toHaveText(
'Copied capability',
);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('read:agents');
});

test('closing a drawer opened from a permalink gives focus to its row', async ({ page }) => {
const row = page.getByRole('button', { name: 'Open audit log entry for USER' });
await row.click();
const entryId = new URL(page.url()).searchParams.get('entryId');
await page.goto(`/grants?tab=audit-log&entryId=${entryId}`);
await expect(page.getByRole('dialog')).toBeVisible();

await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(row).toBeFocused();
});

test('the mock serves one entry by id, as LibreChat does', async ({ request }) => {
Expand Down
Loading
Loading