Skip to content
Open
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
104 changes: 99 additions & 5 deletions .github/workflows/test-assignment-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof scan>>
) => {
Expand All @@ -29,47 +29,45 @@ 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");
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 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);

Expand All @@ -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);

Expand All @@ -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);
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<EBookConfig>) => {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading