diff --git a/.github/workflows/test-assignment-builder.yml b/.github/workflows/test-assignment-builder.yml index dabf34b8b..5c6991f24 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,97 @@ 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 + docker compose run --rm --no-deps --pull never rsmanage \ + rsmanage addinstructor testuser1 overview + + - 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/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 f05ff0261..139144835 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 @@ -7,7 +7,7 @@ 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> ) => { @@ -29,17 +29,11 @@ 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 gotoApp(page, "/builder"); @@ -47,29 +41,33 @@ test( 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 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.locator('[data-tour="grader-assignment-picker"]')).toBeVisible(); + await expect( + page + .locator('[data-tour="grader-assignment-picker"]') + .or(page.getByText("No assignments yet")) + ).toBeVisible(); const results = await scan(page); - await reportViolations(testInfo, results); + await assertNoViolations(testInfo, results); } ); test( - "reports grader question list axe violations", - { tag: ["@p2", "@a11y", "@grader"] }, + "grader question list has no axe violations", + { tag: ["@p2", "@a11y", "@grader", "@seeded"] }, async ({ page }, testInfo) => { const target = await findGradableQuestion(page.request); @@ -78,13 +76,13 @@ test( const results = await scan(page); - await reportViolations(testInfo, results); + await assertNoViolations(testInfo, results); } ); test( - "reports grader split view axe violations", - { tag: ["@p2", "@a11y", "@grader"] }, + "grader split view has no axe violations", + { tag: ["@p2", "@a11y", "@grader", "@seeded"] }, async ({ page }, testInfo) => { const target = await findGradableQuestion(page.request); @@ -94,19 +92,24 @@ test( const results = await scan(page); - await reportViolations(testInfo, results); + await assertNoViolations(testInfo, results); } ); test( - "reports grader gradebook axe violations", + "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(); + await expect( + page.getByRole("heading", { level: 2, name: "Gradebook", exact: true }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Nothing to grade yet" }).or(page.getByRole("table")) + ).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/fixtures/scratch.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/scratch.ts index 959d3a702..2a37505b8 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/scratch.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/scratch.ts @@ -63,7 +63,37 @@ export const deleteAssignmentViaApi = async ( } }; -export const test = base.extend<{ scratchAssignment: ScratchAssignment }>({ +export const test = base.extend<{ + ciCourseContext: void; + scratchAssignment: ScratchAssignment; +}>({ + ciCourseContext: [ + async ({ page }, use) => { + if (process.env.CI_A11Y === "true") { + await page.addInitScript(() => { + const ciConfig = { + basecourse: "overview", + course: "overview", + isInstructor: true, + isLoggedIn: true, + username: "testuser1" + }; + let stored = ciConfig; + + Object.defineProperty(window, "eBookConfig", { + configurable: true, + get: () => stored, + set: (next: Partial) => { + stored = { ...next, ...ciConfig }; + } + }); + }); + } + + await use(); + }, + { auto: true } + ], scratchAssignment: async ({ page }, use, testInfo) => { const name = scratchName(testInfo.workerIndex, slugify(testInfo.title)); const id = await createAssignmentViaWizard(page, name); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/global.setup.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/global.setup.ts index 3c5b291d6..9235067b2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/global.setup.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/global.setup.ts @@ -7,7 +7,10 @@ const STACK_DOWN_MESSAGE = (url: string) => `(docker compose up -d at the repo root for the backend on :80, npm start in ` + `assignment_builder for the Vite dev server on :5173).`; -const LOGIN_URL = `${BACKEND_URL}/admin/auth/login`; +const LOGIN_URL = + process.env.CI_A11Y === "true" + ? `${BACKEND_URL}/admin/auth/login?next=${encodeURIComponent("/builder")}` + : `${BACKEND_URL}/admin/auth/login`; const E2E_USER = "testuser1"; const E2E_PASSWORD = "xxx"; 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/e2e/tiptap/tiptap.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/tiptap/tiptap.spec.ts index 02819d923..833b7f215 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/tiptap/tiptap.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/tiptap/tiptap.spec.ts @@ -145,7 +145,7 @@ test( ); await page.keyboard.press("Enter"); - const form = page.getByRole("form", { name: "Insert code block" }); + const form = page.getByRole("dialog", { name: "Insert code block" }); await expect(form).toBeVisible(); await form.getByLabel("Language (optional)").fill("python"); @@ -245,7 +245,7 @@ test( await arrowToOption(page, /Math expression/); await page.keyboard.press("Enter"); - const form = page.getByRole("form", { name: "Insert math" }); + const form = page.getByRole("dialog", { name: "Insert math" }); await expect(form).toBeVisible(); await form.getByRole("button", { name: "Insert" }).click(); 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/App.css b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css index a488e31d8..1312f9b16 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css @@ -1,4 +1,6 @@ -html, body, #root { +html, +body, +#root { margin: 0; padding: 0; height: 100vh; 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..0e03e9e69 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