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
327 changes: 326 additions & 1 deletion e2e/a11y/config.spec.ts
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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');
Comment thread
danny-avila marked this conversation as resolved.
Resolved
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');
});
});
10 changes: 7 additions & 3 deletions e2e/access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading
Loading