From 98ab2df5fecdeeded98713922a5700b52e106192 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Sun, 18 Oct 2026 15:28:48 +0200 Subject: [PATCH 1/7] f-1579 Improve accessibility across Assignment Builder and Grader --- .github/workflows/test-assignment-builder.yml | 102 +++++++++++++++- .../assignment_builder/.eslintrc.a11y.cjs | 6 +- .../assignment_builder/.eslintrc.cjs | 10 +- .../assignment_builder/README.md | 8 +- .../e2e/consolidation/a11y-scan.spec.ts | 70 ++++++++--- .../e2e/playwright.config.ts | 15 ++- .../assignment_builder/package.json | 1 + .../components/edit/AssignmentEdit.module.css | 7 +- .../components/edit/AssignmentEdit.tsx | 29 +++-- .../components/edit/VisibilityControl.tsx | 4 +- .../AssignmentExercisesTable.tsx | 6 +- .../ExercisesToolbar.tsx | 2 +- .../components/SortableBlock.tsx | 2 + .../components/BlankManager.tsx | 4 +- .../components/IframeUrlInput.tsx | 2 +- .../components/ImportQuestionJsonModal.tsx | 23 ++-- .../components/SortableBlock.tsx | 2 + .../MultiChoiceExerciseSettings.tsx | 2 + .../MultiChoiceOptions.tsx | 16 ++- .../ParsonsExercise/components/BlockItem.tsx | 21 +++- .../components/PollExercise/ScaleSettings.tsx | 4 +- .../shared/BaseExerciseSettingsContent.tsx | 5 + .../connections/ConnectTargetButton.spec.tsx | 14 +-- .../connections/ConnectionList.spec.tsx | 3 +- .../shared/connections/ConnectionList.tsx | 2 +- .../EditAllExercises/withEditAllExercises.tsx | 2 +- .../SearchExercises/SmartSearchExercises.tsx | 9 +- .../exercises/components/TipTap/Editor.tsx | 9 +- .../TipTap/components/InsertFormPopover.tsx | 32 +++-- .../ImportAssignmentModal.module.css | 12 ++ .../ImportAssignmentModal.tsx | 20 +-- .../importAssignment/ImportPreviewPanel.tsx | 1 + .../components/list/AssignmentList.tsx | 12 +- .../list/VisibilityDropdown.spec.tsx | 113 +++++++++++++++++ .../components/list/VisibilityDropdown.tsx | 73 +++++++++-- .../list/VisibilityStatusBadge.module.css | 5 + .../list/VisibilityStatusBadge.spec.tsx | 7 +- .../components/list/VisibilityStatusBadge.tsx | 9 +- .../reading/AssignmentReadingsTable.tsx | 11 +- .../components/reading/ReadingsToolbar.tsx | 1 + .../EditAllReadings/withEditAllReadings.tsx | 2 +- .../wizard/AssignmentWizard.module.css | 3 +- .../components/wizard/AssignmentWizard.tsx | 15 ++- .../routes/Grader/Grader.module.css | 34 ++++-- .../components/DeadlineExceptionDialog.tsx | 21 +++- .../routes/Grader/components/GradePanel.tsx | 34 +++--- .../Grader/components/MultiGradeDialog.tsx | 11 +- .../Grader/components/RegradeWizard.tsx | 1 + .../components/ReleaseGradesControl.tsx | 3 + .../Grader/components/ShortcutsHelpDialog.tsx | 39 +++--- .../components/StudentListSidebar.module.css | 2 +- .../Grader/components/StudentListSidebar.tsx | 1 + .../Grader/components/StudentMultiSelect.tsx | 6 +- .../ActiveCodeAnswerView.tsx | 2 +- .../AnswerRenderer/AnswerRenderer.tsx | 4 +- .../AssociationAnswerView.tsx | 2 +- .../DefaultAnswerView/DefaultAnswerView.tsx | 2 +- .../FitbAnswerView/FitbAnswerView.tsx | 2 +- .../McqAnswerView/McqAnswerView.tsx | 2 +- .../ParsonsAnswerView/ParsonsAnswerView.tsx | 2 +- .../RunestonePreview/RunestonePreview.tsx | 4 +- .../ShortAnswerView/ShortAnswerView.tsx | 2 +- .../GraderAssignmentsPage.tsx | 5 +- .../spec/GraderAssignmentsPage.test.tsx | 20 +++ .../GraderQuestionsPage.tsx | 8 +- .../spec/GraderQuestionsPage.test.tsx | 8 ++ .../routes/Grader/state/graderSelectors.ts | 10 +- .../src/components/shell/AppNavBar.spec.tsx | 13 +- .../src/components/shell/AppNavBar.tsx | 2 - .../src/components/ui/DataGrid.spec.tsx | 13 ++ .../src/components/ui/DataGrid.tsx | 4 +- .../ui/DateTimePicker/DateTimePicker.spec.tsx | 91 +++++++++++++- .../ui/DateTimePicker/DateTimePicker.tsx | 114 +++++++++++++++++- .../ui/EditableTable/EditableDataTable.tsx | 12 +- .../FilterMultiSelect/FilterMultiSelect.tsx | 18 +-- .../ui/TabToolbar/TabToolbar.module.css | 2 +- .../components/ui/TabToolbar/TabToolbar.tsx | 6 +- .../src/components/ui/TreeTable.tsx | 4 +- .../assignment_builder/vite.config.ts | 5 +- components/rsptx/templates/_react_base.html | 2 +- 80 files changed, 952 insertions(+), 255 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.spec.tsx diff --git a/.github/workflows/test-assignment-builder.yml b/.github/workflows/test-assignment-builder.yml index dabf34b8b..acc6245b5 100644 --- a/.github/workflows/test-assignment-builder.yml +++ b/.github/workflows/test-assignment-builder.yml @@ -3,8 +3,8 @@ name: Assignment Builder Tests on: pull_request: paths: - - 'bases/rsptx/assignment_server_api/assignment_builder/**' - - '.github/workflows/test-assignment-builder.yml' + - "bases/rsptx/assignment_server_api/assignment_builder/**" + - ".github/workflows/test-assignment-builder.yml" defaults: run: @@ -24,11 +24,11 @@ jobs: # - node version: newer Node ships a native globalThis.localStorage # that shadows the one jsdom provides (see vitest.setup.ts). include: - - node: '22' + - node: "22" timezone: UTC - - node: '24' + - node: "24" timezone: America/Chicago - - node: '24' + - node: "24" timezone: Asia/Tokyo env: @@ -56,3 +56,95 @@ jobs: - name: Run tests run: npx vitest run --coverage.enabled=false + + accessibility: + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + ADMIN_PROXY_URL: http://localhost:8115 + ASSIGNMENT_PROXY_URL: http://localhost:8113 + BOOK_PATH: ${{ github.workspace }}/.ci-books + CI_A11Y: "true" + COMPOSE_PROFILES: basic + DC_DEV_DBURL: postgresql://runestone:runestone@db/runestone_dev + DEV_DBURL: postgresql://runestone:runestone@localhost:2345/runestone_dev + FERNET_SECRET: hRjuISXQoWd2kQBT8g-2LkmLw4jdv8NCGXUE_GfFZ1w= + JWT_SECRET: ci-only-secret + REDIS_HOST: redis + RUNESTONE_HOST: localhost + SERVER_CONFIG: development + WEB2PY_PRIVATE_KEY: sha512:ci-only-key + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: bases/rsptx/assignment_server_api/assignment_builder/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Accessibility lint + run: npm run lint:a11y + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Prepare backend directories + working-directory: . + run: mkdir -p "$BOOK_PATH" + + - name: Pull backend images + working-directory: . + run: docker compose pull db redis jobe preflight rsmanage assignment admin + + - name: Initialize database + working-directory: . + run: | + docker compose up -d --no-build db + for attempt in {1..30}; do + docker compose exec -T db pg_isready -U runestone -d runestone_dev && break + if [ "$attempt" -eq 30 ]; then exit 1; fi + sleep 2 + done + docker compose run --rm --no-deps --pull never rsmanage rsmanage initdb + + - name: Start API services + working-directory: . + run: | + docker compose up -d --no-build redis jobe assignment admin + for attempt in {1..60}; do + if curl --fail --silent http://localhost:8115/admin/auth/login >/dev/null \ + && curl --fail --silent http://localhost:8113/openapi.json >/dev/null; then + exit 0 + fi + if [ "$attempt" -eq 60 ]; then exit 1; fi + sleep 2 + done + + - name: Run blocking axe checks + run: npm run test:e2e:a11y:ci + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: assignment-builder-a11y-report + path: bases/rsptx/assignment_server_api/assignment_builder/e2e/playwright-report + if-no-files-found: ignore + + - name: Show backend logs on failure + if: failure() + working-directory: . + run: docker compose logs --no-color --tail 200 assignment admin + + - name: Stop backend services + if: always() + working-directory: . + run: docker compose stop diff --git a/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs index 52ba370eb..191463a3f 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs +++ b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs @@ -9,5 +9,9 @@ const warningRules = Object.fromEntries( module.exports = { extends: ["./.eslintrc.cjs", "plugin:jsx-a11y/recommended"], - rules: warningRules + rules: { + ...warningRules, + // Deprecated duplicate of label-has-associated-control. + "jsx-a11y/label-has-for": "off" + } }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.cjs b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.cjs index 760f23309..2d536ab4b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.cjs +++ b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.cjs @@ -10,7 +10,15 @@ module.exports = { jsx: true } }, - plugins: ["react", "import", "@typescript-eslint", "prettier", "react-hooks", "only-warn"], + plugins: [ + "react", + "import", + "@typescript-eslint", + "prettier", + "react-hooks", + "jsx-a11y", + "only-warn" + ], extends: [ "airbnb-typescript", "plugin:@typescript-eslint/recommended", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/README.md b/bases/rsptx/assignment_server_api/assignment_builder/README.md index 52a007e63..0f347dba3 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/README.md +++ b/bases/rsptx/assignment_server_api/assignment_builder/README.md @@ -32,11 +32,13 @@ Your app is ready to be deployed! ### Accessibility checks Run `npm run lint:a11y` to report static JSX accessibility warnings. The command is -currently non-blocking so existing warnings can be addressed incrementally. +currently non-blocking so warnings can be addressed incrementally. The Assignment +Builder CI workflow runs it for visibility. Run `npm run test:e2e:a11y` to scan the assignment builder and grader with axe. Axe -violations are attached to the Playwright report and annotated, but do not fail the -test yet. Navigation and page-loading assertions still fail normally. +violations fail the test and are attached to the Playwright report as JSON. CI runs +the screens supported by its fresh database; the full local command additionally +checks seeded grader question and split views. ### Production diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts index 5f096a583..bb6e47cf3 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts @@ -1,11 +1,13 @@ import { AxeBuilder } from "@axe-core/playwright"; +import { gotoApp } from "../fixtures/appNav"; +import { findGradableQuestion, gotoSplitView } from "../fixtures/grader"; import { expect, test } from "../fixtures/scratch"; const scan = (page: import("@playwright/test").Page) => new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); -const reportViolations = async ( +const assertNoViolations = async ( testInfo: import("@playwright/test").TestInfo, results: Awaited> ) => { @@ -27,40 +29,78 @@ const reportViolations = async ( contentType: "application/json" }); - if (violations.length > 0) { - testInfo.annotations.push({ - type: "accessibility", - description: `${violations.length} axe violation(s) reported; enforcement is not enabled yet` - }); - console.warn(`axe reported ${violations.length} accessibility violation(s)`); - } + expect(violations, "Expected the page to have no WCAG A/AA axe violations").toEqual([]); }; test( - "reports assignment list axe violations", + "assignment list has no axe violations", { tag: ["@p2", "@a11y", "@assignments"] }, async ({ page }, testInfo) => { - await page.goto("/builder"); + await gotoApp(page, "/builder"); await expect(page.getByRole("heading", { level: 1, name: "Assignments" })).toBeVisible(); const results = await scan(page); - await reportViolations(testInfo, results); + await assertNoViolations(testInfo, results); } ); test( - "reports grader assignment list axe violations", + "grader assignment list has no axe violations", { tag: ["@p2", "@a11y", "@grader"] }, async ({ page }, testInfo) => { - await page.goto("/grader"); + await gotoApp(page, "/grader"); await expect(page.getByRole("heading", { level: 1, name: "Assignment grader" })).toBeVisible(); await expect(page.getByRole("heading", { level: 2, name: "Assignments" })).toBeAttached(); await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toContainText("Assignments"); - await expect(page.getByRole("region", { name: "Assignments" })).toBeVisible(); + await expect(page.locator('[data-tour="grader-assignment-picker"]')).toBeVisible(); + + const results = await scan(page); + + await assertNoViolations(testInfo, results); + } +); + +test( + "grader question list has no axe violations", + { tag: ["@p2", "@a11y", "@grader", "@seeded"] }, + async ({ page }, testInfo) => { + const target = await findGradableQuestion(page.request); + + await gotoApp(page, `/grader/${target.assignmentId}`); + await expect(page.getByRole("region", { name: "Questions" })).toBeVisible(); + + const results = await scan(page); + + await assertNoViolations(testInfo, results); + } +); + +test( + "grader split view has no axe violations", + { tag: ["@p2", "@a11y", "@grader", "@seeded"] }, + async ({ page }, testInfo) => { + const target = await findGradableQuestion(page.request); + + await gotoSplitView(page, target); + await expect(page.getByRole("complementary", { name: "Students" })).toBeVisible(); + await expect(page.locator('[data-tour="grader-grade-panel"]')).toBeVisible(); + + const results = await scan(page); + + await assertNoViolations(testInfo, results); + } +); + +test( + "grader gradebook has no axe violations", + { tag: ["@p2", "@a11y", "@grader", "@gradebook"] }, + async ({ page }, testInfo) => { + await gotoApp(page, "/gradebook"); + await expect(page.getByRole("heading", { level: 2, name: "Gradebook" })).toBeVisible(); const results = await scan(page); - await reportViolations(testInfo, results); + await assertNoViolations(testInfo, results); } ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/playwright.config.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/playwright.config.ts index 801664b86..7eb823e92 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/playwright.config.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/playwright.config.ts @@ -6,10 +6,15 @@ const e2eDir = path.dirname(fileURLToPath(import.meta.url)); const E2E_TARGET = process.env.E2E_TARGET ?? "vite"; const IS_DOCKER = E2E_TARGET === "docker"; +const IS_CI_A11Y = process.env.CI_A11Y === "true"; export const AUTH_FILE = path.join(e2eDir, ".auth/user.json"); -export const BACKEND_URL = IS_DOCKER ? "http://localhost" : "http://localhost:80"; export const APP_URL = IS_DOCKER ? "http://localhost" : "http://localhost:5173"; +export const BACKEND_URL = IS_CI_A11Y + ? APP_URL + : IS_DOCKER + ? "http://localhost" + : "http://localhost:80"; export const APP_BASE = IS_DOCKER ? "/assignment/instructor" : ""; export default defineConfig({ @@ -24,6 +29,14 @@ export default defineConfig({ ["line"], ["html", { open: "never", outputFolder: path.join(e2eDir, "playwright-report") }] ], + webServer: IS_CI_A11Y + ? { + command: "npm start -- --host 127.0.0.1", + url: APP_URL, + reuseExistingServer: false, + timeout: 120_000 + } + : undefined, use: { baseURL: APP_URL, trace: "retain-on-failure", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index b147bba83..c25103560 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -87,6 +87,7 @@ "lint:a11y": "eslint src --config .eslintrc.a11y.cjs", "test:e2e": "playwright test", "test:e2e:a11y": "playwright test consolidation/a11y-scan.spec.ts --grep @a11y", + "test:e2e:a11y:ci": "playwright test consolidation/a11y-scan.spec.ts --grep @a11y --grep-invert @seeded", "test:e2e:smoke": "playwright test --grep @p0", "test:e2e:p1": "playwright test --grep \"@p0|@p1\"", "test:e2e:docker": "E2E_TARGET=docker playwright test e2e/grader", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.module.css index 6bad390ea..b645ddf8e 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.module.css @@ -166,7 +166,8 @@ gap: var(--rs-space-2); } -.field > label { +.field > label, +.field > .controlLabel { font-size: var(--rs-font-size-sm); font-weight: var(--rs-font-weight-medium); color: var(--rs-text); @@ -180,7 +181,8 @@ gap: var(--rs-space-4); } -.settingRow > label { +.settingRow > label, +.settingRow > .controlLabel { font-size: var(--rs-font-size-sm); font-weight: var(--rs-font-weight-medium); color: var(--rs-text); @@ -208,4 +210,3 @@ display: none; } } - diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx index 72ba9f90c..2d3393fe0 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx @@ -99,7 +99,7 @@ export const AssignmentEdit = ({ const renderDetailsSection = () => ( -

Details

+

Details

@@ -127,15 +127,14 @@ export const AssignmentEdit = ({ {...field} id="edit-assignment-description" value={field.value || ""} - autosize - minRows={4} + rows={4} placeholder="What is this assignment about?" /> )} />
- + Assignment type ( -

Schedule

+

Schedule

@@ -215,7 +214,7 @@ export const AssignmentEdit = ({ const renderScoringSection = () => ( -

Scoring

+

Scoring

@@ -241,10 +240,10 @@ export const AssignmentEdit = ({ const renderBehaviorSection = () => ( -

Behavior

+

Behavior

- + Allow pause
- + Allow feedback ( -

Peer settings

+

Peer settings

- + Show async peer {asyncConditionsEnabled && (
- + Randomize study conditions ( -

Visibility

+

Visibility

); @@ -337,7 +336,7 @@ export const AssignmentEdit = ({ return ( -

LTI 1.1 link

+

LTI 1.1 link