diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33ec0dc5..af3e00a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -142,3 +142,99 @@ jobs: - name: Run end-to-end tests run: python manage.py test website.tests.test_member_e2e --settings=makeabilitylab.settings_test --verbosity=2 + + # Accessibility sweep (Pa11y + Axe, WCAG 2.0 AA). CONTRIBUTING requires Pa11y + # on UI changes but nothing enforced it (#1278 item 6). This wires it in. + # + # Like `test` and `e2e`, it is REPORT-ONLY — it surfaces violations in the run + # Summary but never fails the build (so a pre-existing violation doesn't sit + # red next to every master deploy). Tighten to blocking later once the current + # findings are triaged. + # + # The local `.pa11yci.json` targets the docker-compose host with the + # maintainer's real DB snapshot; CI has no snapshot, so we build a fresh DB + # from models, seed deterministic demo content (seed_demo_projects + + # seed_demo_news), serve it with a native runserver, and scan the localhost + # URLs in `.pa11yci.ci.json`. + a11y: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: makeability + POSTGRES_USER: admin + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U admin -d makeability" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + # DJANGO_ENV=DEBUG -> DEBUG=True, so the dev runserver serves media/static + # and renders real error pages while pa11y scans. + DJANGO_ENV: DEBUG + DJANGO_SETTINGS_MODULE: makeabilitylab.settings_test + + steps: + - uses: actions/checkout@v4 + + # ImageMagick + Ghostscript power the PDF->thumbnail path the demo + # publications hit on save; libpq-dev builds psycopg2. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends imagemagick ghostscript libpq-dev + sudo cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install Python dependencies + run: pip install -r requirements.txt + + # Build the website schema from models (migrations are gitignored; + # settings_test sets MIGRATION_MODULES={'website': None}, so --run-syncdb + # creates the tables directly) and seed deterministic demo content. + - name: Build schema and seed demo data + run: | + python manage.py migrate --run-syncdb + python manage.py seed_demo_projects + python manage.py seed_demo_news + + - name: Start dev server + run: | + python manage.py runserver 0.0.0.0:8000 --noreload & + echo "Waiting for the server to come up…" + for i in $(seq 1 30); do + if curl -sf -o /dev/null http://localhost:8000/; then + echo "Server is up."; exit 0 + fi + sleep 1 + done + echo "Server did not start in time"; exit 1 + + - name: Install pa11y-ci + run: npm install -g pa11y-ci + + # Report-only: `|| true` so violations never fail the job. Output is teed + # to the log and a trimmed tail posted to the run Summary. + - name: Run Pa11y accessibility sweep + run: | + pa11y-ci --config .pa11yci.ci.json 2>&1 | tee pa11y-output.txt || true + { + echo "## Accessibility (Pa11y + Axe, WCAG2AA) — report-only" + echo + echo '```' + tail -n 60 pa11y-output.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.pa11yci.ci.json b/.pa11yci.ci.json new file mode 100644 index 00000000..85483114 --- /dev/null +++ b/.pa11yci.ci.json @@ -0,0 +1,24 @@ +{ + "defaults": { + "standard": "WCAG2AA", + "runners": ["axe", "htmlcs"], + "timeout": 60000, + "chromeLaunchConfig": { + "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] + }, + "hideElements": "#js-toc", + "ignore": [] + }, + "_comment": "CI variant of .pa11yci.json (#1278 item 6). The local-dev config targets the docker-compose 'website:8000' host with the maintainer's real DB snapshot (jonfroehlich, sidewalk, ...). CI has no snapshot, so this config targets a native runserver on localhost:8000 seeded with deterministic demo content via `manage.py seed_demo_projects` + `seed_demo_news`. Keep the two URL lists in sync with what those seed commands create.", + "urls": [ + "http://localhost:8000/", + "http://localhost:8000/people/", + "http://localhost:8000/publications/", + "http://localhost:8000/projects/", + "http://localhost:8000/news/", + "http://localhost:8000/awards/", + "http://localhost:8000/project/demo-active-tall/", + "http://localhost:8000/member/demolovelace/", + "http://localhost:8000/news/demo-news-one/" + ] +} diff --git a/website/management/commands/seed_demo_news.py b/website/management/commands/seed_demo_news.py new file mode 100644 index 00000000..3e60b90e --- /dev/null +++ b/website/management/commands/seed_demo_news.py @@ -0,0 +1,58 @@ +""" +Local-dev / CI helper: seed a few demo news items so the news listing +(``/news/``) and a news detail page render with real content. + +Run inside the website container (or in CI): + + python manage.py seed_demo_news + +Idempotent — deletes any prior demo news (title starting with "Demo News") +and recreates from scratch, so it's safe to re-run. Pairs with +``seed_demo_projects``: together they populate enough content for the Pa11y +accessibility sweep (#1278 item 6) to scan real pages rather than empty ones. +If demo people exist (from ``seed_demo_projects``), the first one is set as the +author so the byline path renders too. + +News.save() derives the slug from the title, so the detail URLs are stable: +"Demo News One" -> /news/demo-news-one/. + +This file lives in management/commands/ so Django auto-discovers it, but it's +explicitly a dev/test tool — don't wire it into docker-entrypoint.sh. +""" + +from datetime import date + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Seed a few demo news items (for local visual testing and the Pa11y CI sweep)." + + _TITLES = ["Demo News One", "Demo News Two", "Demo News Three"] + + def handle(self, *args, **opts): + from website.models import News, Person + + wiped = News.objects.filter(title__startswith="Demo News").count() + if wiped: + self.stdout.write(self.style.WARNING(f"Removing {wiped} prior demo news item(s).")) + News.objects.filter(title__startswith="Demo News").delete() + + # Reuse a demo author if seed_demo_projects has run; otherwise authorless + # (News.author is nullable) — both paths should render. + author = Person.objects.filter(first_name="Demo").order_by("last_name").first() + + self.stdout.write(self.style.NOTICE("Creating demo news items…")) + for i, title in enumerate(self._TITLES): + news = News.objects.create( + title=title, + content=( + f"

This is demo news item #{i + 1}, created for local visual " + f"testing and the automated accessibility (Pa11y) sweep.

" + ), + date=date(2024, 1, i + 1), + author=author, + ) + self.stdout.write(f" ✓ /news/{news.slug}/") + + self.stdout.write(self.style.SUCCESS("Done."))