From 2606219eff8b9182d406ff8f29fd17a40160b4bf Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:36:26 -0700 Subject: [PATCH 1/2] FIX: resolve frontend accessibility audit findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e4044304-01aa-420b-97f3-b3b2c5962922 --- frontend/e2e/accessibility.spec.ts | 161 +++++++++++++++++- .../src/components/Chat/ChatWindow.styles.ts | 11 ++ .../src/components/Chat/ChatWindow.test.tsx | 1 + frontend/src/components/Chat/ChatWindow.tsx | 1 + .../components/Config/TargetConfig.styles.ts | 1 + .../components/Config/TargetConfig.test.tsx | 3 + .../src/components/Config/TargetConfig.tsx | 2 +- .../components/Config/TargetTable.test.tsx | 2 +- .../src/components/Config/TargetTable.tsx | 8 +- .../components/History/AttackHistory.test.tsx | 2 +- .../src/components/History/AttackHistory.tsx | 2 +- frontend/src/components/Home/Home.styles.ts | 14 ++ frontend/src/components/Home/Home.test.tsx | 9 +- frontend/src/components/Home/Home.tsx | 11 +- .../src/components/Labels/LabelsBar.styles.ts | 3 + .../components/Layout/MainLayout.styles.ts | 5 + frontend/src/components/Layout/MainLayout.tsx | 1 + .../components/Sidebar/Navigation.styles.ts | 6 + .../components/Sidebar/Navigation.test.tsx | 13 ++ .../src/components/Sidebar/Navigation.tsx | 78 +++++---- .../src/components/Tour/TourTooltip.styles.ts | 29 +++- frontend/src/components/Tour/TourTooltip.tsx | 13 +- frontend/src/hooks/useTour.test.ts | 7 + frontend/src/hooks/useTour.ts | 9 +- 24 files changed, 335 insertions(+), 57 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 958727fdeb..ed3221bf2d 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -1,6 +1,59 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Locator, type Page } from "@playwright/test"; import { makeTarget } from "./_targets"; +async function expectMinimumTouchTarget(locator: Locator, minimum = 44): Promise { + await expect(locator).toBeVisible(); + + const box = await locator.boundingBox(); + + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThanOrEqual(minimum); + expect(box!.height).toBeGreaterThanOrEqual(minimum); +} + +async function expectTourContained(page: Page, dialog: Locator, checkTouchTargets: boolean): Promise { + await expect(dialog).toBeVisible(); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }) + ); + + await expect + .poll(async () => { + const box = await dialog.boundingBox(); + const viewport = page.viewportSize(); + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + + return ( + box !== null && + viewport !== null && + box.x >= 0 && + box.x + box.width <= viewport.width + 1 && + dimensions.scrollWidth <= dimensions.clientWidth + 1 + ); + }) + .toBe(true); + + const actions = dialog.getByRole("button"); + const actionCount = await actions.count(); + + expect(actionCount).toBeGreaterThan(0); + + for (let index = 0; index < actionCount; index += 1) { + const action = actions.nth(index); + await expect(action).toBeVisible(); + + if (checkTouchTargets) { + await expectMinimumTouchTarget(action); + } + } +} + test.describe("Accessibility", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); @@ -159,6 +212,12 @@ test.describe("Accessibility", () => { endpoint: "https://test.com", model_name: "gpt-4o", }), + makeTarget({ + target_registry_name: "a11y-second-target", + target_type: "TextTarget", + endpoint: "https://test.com/text", + model_name: "text-model", + }), ], pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null }, }), @@ -172,7 +231,107 @@ test.describe("Accessibility", () => { // Table should exist const table = page.getByRole("table"); await expect(table).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Filter by type:" })).toBeVisible(); + }); + + test("major views expose page headings and one primary navigation landmark", async ({ page }) => { + const navigation = page.getByRole("navigation", { name: "Primary" }); + + await expect(navigation).toHaveCount(1); + await expect( + page.getByRole("heading", { level: 1, name: "Welcome to Co-PyRIT" }) + ).toBeVisible(); + await expect(navigation.getByRole("button", { name: "Home" })).toHaveAttribute( + "aria-current", + "page" + ); + + const views = [ + { button: "Attack History", heading: "Attack History" }, + { button: "Configuration", heading: "Target Configuration" }, + { button: "Chat", heading: "Chat" }, + ]; + + for (const view of views) { + await navigation.getByRole("button", { name: view.button }).click(); + await expect( + page.getByRole("heading", { level: 1, name: view.heading }) + ).toBeAttached(); + await expect(page.locator("main h1")).toHaveCount(1); + await expect(navigation.getByRole("button", { name: view.button })).toHaveAttribute( + "aria-current", + "page" + ); + } + }); + + test("mobile audit controls provide 44px touch targets", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + await expectMinimumTouchTarget(page.getByRole("button", { name: "Labels" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Configure a target" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Take a tour" })); + + await page.route(/\/api\/targets/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + makeTarget({ + target_registry_name: "mobile-touch-target", + target_type: "OpenAIChatTarget", + endpoint: "https://test.com", + model_name: "gpt-4o", + }), + ], + pagination: { limit: 200, has_more: false, next_cursor: null, prev_cursor: null }, + }), + }); + }); + + await page.getByRole("button", { name: "Configuration" }).click(); + await expect( + page.getByRole("heading", { level: 1, name: "Target Configuration" }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh" })).toBeEnabled(); + await expectMinimumTouchTarget(page.getByRole("button", { name: "Refresh" })); + await expectMinimumTouchTarget(page.getByRole("button", { name: "New Target" })); }); + + for (const viewport of [ + { name: "mobile", width: 390, height: 844 }, + { name: "desktop", width: 1280, height: 800 }, + ]) { + test(`tour remains contained and actionable on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.getByRole("button", { name: "Take a tour" }).click(); + + const dialog = page.getByRole("alertdialog"); + + for (let step = 0; step < 5; step += 1) { + await expect(dialog).toContainText(`${step + 1} of 5`); + await expectTourContained(page, dialog, viewport.name === "mobile"); + + if (viewport.name === "desktop" && step === 0) { + const targetBox = await page.locator('[data-tour="sidebar-nav"]').boundingBox(); + const dialogBox = await dialog.boundingBox(); + + expect(targetBox).not.toBeNull(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.x).toBeGreaterThanOrEqual(targetBox!.x + targetBox!.width); + } + + if (step < 4) { + await dialog.getByRole("button", { name: "Next", exact: true }).click(); + } + } + + await dialog.getByRole("button", { name: "Anchors Away!", exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/$/); + }); + } }); test.describe("Visual Consistency", () => { diff --git a/frontend/src/components/Chat/ChatWindow.styles.ts b/frontend/src/components/Chat/ChatWindow.styles.ts index 21a361c204..35edca0562 100644 --- a/frontend/src/components/Chat/ChatWindow.styles.ts +++ b/frontend/src/components/Chat/ChatWindow.styles.ts @@ -7,6 +7,17 @@ export const useChatWindowStyles = makeStyles({ width: '100%', overflow: 'hidden', }, + pageHeading: { + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, + }, chatArea: { display: 'flex', flexDirection: 'column', diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 011abec648..ab26757e2e 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -305,6 +305,7 @@ describe("ChatWindow Integration", () => { // The ribbon no longer shows the "PyRIT Attack" prefix; the target // badge stands on its own as the leftmost element. + expect(screen.getByRole("heading", { level: 1, name: "Chat" })).toBeInTheDocument(); expect(screen.queryByText("PyRIT Attack")).not.toBeInTheDocument(); expect(screen.getByTestId("target-badge")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /new attack/i })).toBeInTheDocument(); diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index f9d2096c5c..5f9b0b5363 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -616,6 +616,7 @@ export default function ChatWindow({ return (
+

Chat

{isConverterPanelOpen && ( setIsConverterPanelOpen(false)} diff --git a/frontend/src/components/Config/TargetConfig.styles.ts b/frontend/src/components/Config/TargetConfig.styles.ts index b7080a6129..9d90bffdc2 100644 --- a/frontend/src/components/Config/TargetConfig.styles.ts +++ b/frontend/src/components/Config/TargetConfig.styles.ts @@ -47,6 +47,7 @@ export const useTargetConfigStyles = makeStyles({ headerAction: { '@media (max-width: 600px)': { flex: '1 1 8rem', + minHeight: '44px', }, }, emptyState: { diff --git a/frontend/src/components/Config/TargetConfig.test.tsx b/frontend/src/components/Config/TargetConfig.test.tsx index 8aed790042..80a909ad17 100644 --- a/frontend/src/components/Config/TargetConfig.test.tsx +++ b/frontend/src/components/Config/TargetConfig.test.tsx @@ -82,6 +82,9 @@ describe("TargetConfig", () => { ); + expect( + screen.getByRole("heading", { level: 1, name: "Target Configuration" }) + ).toBeInTheDocument(); expect(screen.getByText("Loading targets...")).toBeInTheDocument(); }); diff --git a/frontend/src/components/Config/TargetConfig.tsx b/frontend/src/components/Config/TargetConfig.tsx index 6b496954f7..624adc7a19 100644 --- a/frontend/src/components/Config/TargetConfig.tsx +++ b/frontend/src/components/Config/TargetConfig.tsx @@ -76,7 +76,7 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
- Target Configuration + Target Configuration Manage targets for attack sessions. Select a target to use in the chat view. diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 3cdd3b5025..916c7e6d38 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -312,7 +312,7 @@ describe('TargetTable', () => { expect(screen.getByText('dall-e-3')).toBeInTheDocument() // Filter to OpenAIChatTarget - const select = screen.getByRole('combobox') + const select = screen.getByRole('combobox', { name: 'Filter by type:' }) fireEvent.change(select, { target: { value: 'OpenAIChatTarget' } }) expect(screen.getByText('gpt-4')).toBeInTheDocument() diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index bddbe47b7a..062dc4132d 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, forwardRef } from 'react' +import React, { useState, useMemo, forwardRef, useId } from 'react' import { Table, TableHeader, @@ -242,6 +242,7 @@ function InnerTargetRows({ parentKey, innerTargets, weights }: { export default function TargetTable({ targets, activeTarget, onSetActiveTarget }: TargetTableProps) { const styles = useTargetTableStyles() + const typeFilterId = useId() const [typeFilter, setTypeFilter] = useState('') // Tracks which RoundRobinTarget rows are expanded to show inner targets. // We use a Set of target_registry_name strings — when a name is in the set, @@ -334,8 +335,11 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {targetTypes.length > 1 && (
- Filter by type: +