diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 524bd99..4187ccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,3 +145,135 @@ jobs: env: DATABASE_URL: postgres://partizip:partizip@localhost:5433/partizip_upgrade_test run: npm run test:upgrade + + # Eigenständiger Job (Issue #60): prüft das GERENDERTE DOM von vier + # unangemeldet erreichbaren Seiten mit axe — Kontraste, Landmarks, + # Formular-Labels, Heading-Reihenfolge. Das ergänzt das statische + # jsx-a11y-Lint-Gate im ci-Job, das genau diese Klasse von Verstößen + # prinzipbedingt nicht sehen kann (sie entsteht erst beim Rendern). + # + # REICHWEITE — bitte nicht überdehnen: geprüft werden ausschließlich die + # anonymen Sichten /, /umfragen, /anliegen und /anmelden. Die eigentlichen + # Beteiligungsflüsse (/umfrage/[id], /mitmachen, /konto, /verifizieren, + # Admin) sind NICHT abgedeckt, ebenso wenig Tastaturbedienung, Fokusführung + # und alles, was erst nach einer Interaktion entsteht. Gegenüber Kommunen + # trägt dieser Job also genau diese vier Seiten und die von axe automatisiert + # entscheidbaren Regeln — keine Aussage über die Anwendung als Ganzes. + # Ausweitung auf die Kernflüsse ist ein eigener Schritt. + # + # Getrennter Job + eigene Service-DB, dem Muster von upgrade-path folgend → + # bricht die bestehenden Jobs nicht und läuft parallel. + # + # ZENTRAL: der Server MUSS mit PILOT_TENANT_SLUG=taunusstein starten. Die + # Tenant-Auflösung ist host-basiert (app/src/middleware.ts); localhost ist eine + # HAUPT-Domain und liefert ohne diese Variable die neutrale Landing-Page statt + # der Tenant-App. Ein a11y-Lauf dagegen wäre grün und wertlos. Der Schritt + # "a11y-Lauf" ruft deshalb scripts/a11y-preflight.ts VOR pa11y auf: HTTP-Poll + # als Readiness (kein sleep), 200-Prüfung aller vier Seiten, eine harte + # Assertion, dass /umfragen den Wortlaut einer geseedeten Umfrage aus + # db/seeds/polls.json enthält, und eine ebenso harte Assertion, dass die + # Stylesheets der Seiten wirklich ausgeliefert werden (ohne CSS findet axe + # keine visuellen Verstöße mehr — der Lauf wäre erneut grün und wertlos). + # Schlägt eine davon fehl, bricht der Job ab. + # + # `npm run a11y` startet danach ZWEI pa11y-ci-Läufe mit unterschiedlichen + # Configs. Warum zwei nötig sind — ein einzelner Lauf kann die unentscheidbaren + # Kontrastfälle des Verlaufs-Heros nicht dämpfen, ohne gleichzeitig belegte + # Verstöße wie duplicate-id-aria stumm zu schalten — steht ausführlich im + # Kopfkommentar von app/.pa11yci.js. + a11y: + name: a11y (axe) — Kernseiten + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: partizip + POSTGRES_PASSWORD: partizip + POSTGRES_DB: partizip_a11y_test + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U partizip -d partizip_a11y_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgres://partizip:partizip@localhost:5433/partizip_a11y_test + PILOT_TENANT_SLUG: taunusstein + # A11Y_BASE_URL ist die EINE Quelle: app/.pa11yci.js baut daraus die + # pa11y-Ziele, scripts/a11y-preflight.ts importiert dieselbe Konstante. + # PORT muss dazu passen — der Standalone-Server liest ihn. + PORT: "3000" + HOSTNAME: 127.0.0.1 + A11Y_BASE_URL: http://127.0.0.1:3000 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node 22 + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: app/package-lock.json + + # Lädt zugleich das Chromium, das pa11y über Puppeteer startet. + - name: Install dependencies + working-directory: app + run: npm ci + + # Die Seeds sind die Datengrundlage der geprüften Seiten UND der + # Tenant-Assertion (db/seeds/polls.json). + - name: Migrate + Seed + working-directory: app + run: | + npm run db:migrate + npm run db:seed + + - name: Build + working-directory: app + run: npm run build + + # `npm run start:standalone` statt `npm run start`: app/next.config.ts setzt + # `output: "standalone"`, und Next 16 warnt ausdrücklich, dass `next start` + # damit nicht unterstützt ist. Produktion fährt .next/standalone/server.js + # (siehe Dockerfile, Stage `runner`) — der a11y-Job prüft damit denselben + # Server wie die Kommunen ihn sehen. Das Skript kopiert vorher .next/static + # und public neben server.js, genau wie das Dockerfile. + # + # Der Server läuft in einer EIGENEN Prozessgruppe (setsid) und meldet deren + # PGID selbst. So beendet der Cleanup die ganze Gruppe (npm-Wrapper + + # node-Server) statt nur des npm-Prozesses; ein `$!` des aufrufenden Shells + # wäre je nach Job-Control nicht zuverlässig die PGID. + # Auf Readiness wird hier NICHT gewartet — das macht der Preflight per + # HTTP-Poll im nächsten Schritt (kein sleep als Synchronisation). + - name: Server starten (Pilot-Tenant, Standalone wie in Produktion) + working-directory: app + run: | + setsid bash -c 'echo $$ > /tmp/a11y-server.pgid; exec npm run start:standalone' \ + > /tmp/a11y-server.log 2>&1 < /dev/null & + for _ in $(seq 1 100); do + [ -s /tmp/a11y-server.pgid ] && break + sleep 0.1 + done + [ -s /tmp/a11y-server.pgid ] || { echo "Server-PGID wurde nicht geschrieben"; exit 1; } + echo "Server-Prozessgruppe: $(cat /tmp/a11y-server.pgid)" + + - name: a11y-Lauf (Preflight + beide pa11y-ci-Läufe) + working-directory: app + run: npm run a11y + + - name: Server-Log (nur bei Fehlschlag) + if: failure() + run: tail -n 100 /tmp/a11y-server.log || true + + - name: Server beenden + if: always() + run: | + if [ -f /tmp/a11y-server.pgid ]; then + kill -TERM -"$(cat /tmp/a11y-server.pgid)" 2>/dev/null || true + fi diff --git a/app/.pa11yci.js b/app/.pa11yci.js new file mode 100644 index 0000000..9cbf34a --- /dev/null +++ b/app/.pa11yci.js @@ -0,0 +1,141 @@ +/** + * .pa11yci.js — LAUF 1 des a11y-Gates (Issue #60). + * + * Warum eine .js- und keine .json-Datei: die Basis-URL und die Liste der + * geprüften Seiten sind sonst zweimal im Repo hinterlegt (hier und in + * scripts/a11y-preflight.ts). Driftet eine der beiden, prüft der Preflight + * einen anderen Server als pa11y — das Gate wäre still grün. pa11y-ci lädt + * `.cjs`/`.js`-Configs über `loadConfigModule` (bin/pa11y-ci.js), deshalb sind + * BASIS_URL und PFADE hier definiert und werden von .pa11yci.streng.js sowie + * von scripts/a11y-preflight.ts importiert. EINE Quelle für beide Läufe und + * den Preflight. + * + * ─── Warum ZWEI Läufe ─────────────────────────────────────────────────────── + * axe unterscheidet `violations` (belegter Verstoß) und `incomplete` (axe konnte + * es nicht entscheiden). pa11y bildet beides auf Meldungen ab und deckelt die + * incomplete-Klasse über `levelCapWhenNeedsReview` — aber PAUSCHAL, nie + * regelbezogen (pa11y/lib/runners/axe.js, `choosePa11yLevel` prüft nur das Flag + * `issueNeedsReview`, nie die Regel-ID). Zusätzlich bildet pa11y die gesamte + * axe-Impact-Klasse `moderate` auf `warning` ab, und pa11y zählt Warnungen + * per Default nicht (`includeWarnings: false`). + * + * Ein einziger Lauf kann deshalb nicht gleichzeitig + * (a) die unberechenbaren Kontrast-`incomplete`s des Verlaufs-Heros dämpfen und + * (b) belegte Befunde wie `duplicate-id-aria` (das axe wegen `reviewOnFail` + * ebenfalls nach `incomplete` schreibt) hart blocken. + * Genau daran ist die erste Fassung dieses Gates gescheitert: sie meldete 0 + * Befunde, während 3 belegte Verstöße bestanden. + * + * Deshalb: + * LAUF 1 (diese Datei) — `levelCapWhenNeedsReview: "warning"`, Kontrast- + * regel auf ALLEN Seiten aktiv. Blockt belegte + * `violations` mit Impact critical/serious, + * insbesondere echte `color-contrast`-Verstöße. + * LAUF 2 (.pa11yci.streng.js) — KEINE Deckelung, `includeWarnings`/ + * `includeNotices` an, dafür `color-contrast` auf + * der Startseite regel- und seitengenau aus. + * Blockt alles Übrige: incomplete-Befunde jeder + * Impact-Klasse und moderate/minor-Verstöße. + * Beide zusammen laufen als `npm run a11y`; jeder Lauf beendet die Kette bei + * Exit != 0. + * + * ─── Tenant-Vorbedingung ──────────────────────────────────────────────────── + * Der Server MUSS mit PILOT_TENANT_SLUG=taunusstein laufen, sonst liefert die + * Haupt-Domain die neutrale Landing-Page statt der Tenant-App (src/middleware.ts) + * und der Lauf wäre grün und wertlos. `npm run a11y` erzwingt das über + * scripts/a11y-preflight.ts (Status-200-Prüfung, Seed-Inhalts-Assertion, + * CSS-Assertion). + * + * ─── Zur Notation `"ignore": []` ──────────────────────────────────────────── + * Das leere Array liest sich wie ein Opt-out aus globalen Ignores, ist aber + * keines: pa11y-ci merged URL-Optionen über lodash `defaultsDeep`, und das + * merged Arrays INDEXWEISE statt sie zu ersetzen. Solange `defaults` hier kein + * `ignore` setzt (tut es nicht), ist das folgenlos — dokumentiert, damit es + * niemanden beißt, der später ein globales `defaults.ignore` ergänzt. + */ + +/** Basis-URL des laufenden Servers. Identisch zu scripts/a11y-preflight.ts. */ +const BASIS_URL = ( + process.env.A11Y_BASE_URL || "http://127.0.0.1:3000" +).replace(/\/+$/, ""); + +/** Die geprüften Kernseiten. Einzige Quelle für beide Läufe und den Preflight. */ +const PFADE = ["/", "/umfragen", "/anliegen", "/anmelden"]; + +/** Pfad → absolute URL des laufenden Servers. */ +const url = (pfad) => `${BASIS_URL}${pfad}`; + +/** + * Von beiden Läufen geteilte Grundeinstellungen. + * + * `concurrency: 1`: pa11y startet je Ziel einen eigenen Inkognito-Browser- + * Kontext; parallel dazu kam es reproduzierbar zu `Protocol error + * (Target.closeTarget)`-Abbrüchen. Die Richtung wäre zwar sicher (rot statt + * grün), aber ein flackerndes Pflicht-Gate wird weggeklickt. Bei vier URLs + * bringt Parallelität ohnehin fast nichts. + */ +const BASIS_DEFAULTS = { + runners: ["axe"], + standard: "WCAG2AA", + timeout: 60000, + wait: 1000, + concurrency: 1, + chromeLaunchConfig: { + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }, +}; + +module.exports = { + defaults: { + ...BASIS_DEFAULTS, + + // Deckelt die axe-`incomplete`-Klasse auf `warning`; pa11y zählt Warnungen + // hier nicht mit (`includeWarnings` bleibt aus). Grund: `.pz-hero` in + // globals.css hat einen Verlauf als Hintergrund, und bei Verlaufs- + // hintergründen kann axe den Kontrast prinzipiell nicht berechnen — 21 + // Textknoten der Startseite landen deshalb als `color-contrast`/incomplete. + // Wichtig: die Deckelung wirkt pauschal auf ALLE incomplete-Befunde, nicht + // nur auf Kontrast. Was sie hier durchlässt, fängt LAUF 2 wieder ein. + levelCapWhenNeedsReview: "warning", + }, + + urls: [ + { + url: url("/"), + ignore: [], + }, + { + url: url("/umfragen"), + ignore: [], + }, + { + _kommentar_ignore: [ + "ALTLAST, eingefroren am 2026-07-26 — abzubauen, nicht auszuweiten.", + "Regel: link-in-text-block · Seite: /anliegen · Element:", + '#main-content > main > p > a → Konto unter', + "„Meine Anliegen“. Der Link im Fließtext ist nur farblich (--pz-brand-strong)", + "vom umgebenden Text unterschieden; 'hover:underline' greift erst beim Hover.", + "Grund für die Ausnahme: dieser PR friert den Bestand ein und ändert", + "bewusst KEINE UI (Issue #60). Fix gehört in einen eigenen PR (Unterstreichung", + "im Ruhezustand oder ausreichender Nicht-Farb-Unterschied); danach diese", + "Ausnahme in BEIDEN Configs ersatzlos entfernen.", + "Die Ausnahme gilt regel- UND seitengenau: auf allen anderen URLs blockt", + "link-in-text-block weiterhin hart.", + ], + url: url("/anliegen"), + ignore: ["link-in-text-block"], + }, + { + url: url("/anmelden"), + ignore: [], + }, + ], +}; + +// Geteilte Konstanten für .pa11yci.streng.js und scripts/a11y-preflight.ts. +// pa11y-ci liest nur `defaults` und `urls` (bin/pa11y-ci.js, `defaultConfig`), +// zusätzliche Exporte stören es nicht. +module.exports.BASIS_URL = BASIS_URL; +module.exports.PFADE = PFADE; +module.exports.BASIS_DEFAULTS = BASIS_DEFAULTS; +module.exports.url = url; diff --git a/app/.pa11yci.streng.js b/app/.pa11yci.streng.js new file mode 100644 index 0000000..3a3c3b7 --- /dev/null +++ b/app/.pa11yci.streng.js @@ -0,0 +1,122 @@ +/** + * .pa11yci.streng.js — LAUF 2 des a11y-Gates (Issue #60). + * + * Ergänzt LAUF 1 (.pa11yci.js) um genau die Befundklassen, die dort + * konstruktionsbedingt stumm bleiben. Die Aufteilung und ihre Begründung + * stehen im Kopfkommentar von .pa11yci.js — hier nur, was DIESER Lauf tut: + * + * 1. `levelCapWhenNeedsReview` wird NICHT gesetzt (pa11y-Default: "error"). + * Damit behalten axe-`incomplete`-Befunde ihre vom Impact abgeleitete + * Stufe. Nötig, weil axe belegte Verstöße wie `duplicate-id-aria` wegen + * `reviewOnFail: true` (axe-core) in die incomplete-Klasse schreibt — die + * Deckelung in LAUF 1 macht daraus eine Warnung, die niemand zählt. + * + * 2. `includeWarnings: true` + `includeNotices: true`. pa11y bildet die + * gesamte axe-Impact-Klasse `moderate` auf `warning` und `minor` auf + * `notice` ab (pa11y/lib/runners/axe.js, `axeImpactToPa11yLevel`) und + * zählt beides per Default NICHT (pa11y/lib/option.js schiebt sonst + * 'warning'/'notice' in die Ignore-Liste). Ohne diese zwei Schalter wären + * z. B. `heading-order` und `form-field-multiple-labels` dauerhaft stumm — + * beides reale Befunde auf den geprüften Seiten. pa11y-ci zählt jede + * verbliebene Meldung als Fehler (lib/pa11y-ci.js: `results.issues.length`), + * also blocken sie hier. + * + * 3. `color-contrast` ist auf `/` regel- und seitengenau abgeschaltet. + * Der Verlaufshintergrund von `.pz-hero` macht die Kontrastberechnung dort + * prinzipiell unentscheidbar (21 incomplete-Meldungen). ES ENTSTEHT KEINE + * LÜCKE: LAUF 1 lässt `color-contrast` auf allen Seiten aktiv, und ein + * belegter Kontrast-*Verstoß* hat Impact `serious` → Stufe `error` → blockt + * dort weiterhin hart. Abgeschaltet ist hier nur das Unentscheidbare. + * + * ─── Reichweite dieses Gates (ehrlich) ────────────────────────────────────── + * Beide Läufe zusammen decken auf den vier geprüften Seiten alle axe-Regeln des + * Standards WCAG2AA ab, in jeder Impact-Klasse, sowohl `violations` als auch + * `incomplete` — mit der einen dokumentierten Ausnahme `color-contrast`/`/` aus + * Punkt 3 und den unten eingefrorenen Altlasten. Was das Gate NICHT sieht: + * andere Seiten (siehe .pa11yci.js: nur vier anonyme Sichten), alles was + * Interaktion braucht (Fokus-Reihenfolge, Tastaturbedienung, Live-Regionen nach + * einer Aktion), und alles, was automatisierte Prüfung grundsätzlich nicht + * entscheiden kann (Verständlichkeit, sinnvolle Alternativtexte). + * + * ─── Zur Notation `"ignore": []` ──────────────────────────────────────────── + * Siehe .pa11yci.js: kein Opt-out aus globalen Ignores, nur Dokumentation. + * `defaults` setzt hier bewusst kein `ignore`. + */ + +const { PFADE, BASIS_DEFAULTS, url } = require("./.pa11yci.js"); + +// Absicherung gegen stilles Auseinanderlaufen der beiden Configs: wenn jemand +// PFADE in .pa11yci.js ändert, ohne die Ausnahmen hier nachzuziehen, prüft +// dieser Lauf sonst eine Seite ohne die für sie gedachten Ausnahmen (oder +// umgekehrt eine Ausnahme ohne Seite). Beides fällt hier sofort auf. +const AUSNAHMEN = { + /** + * `color-contrast` — technisch bedingt, KEINE Altlast, bleibt dauerhaft. + * Regel: color-contrast · Seite: / · Element: alle Textknoten in `.pz-hero` + * Grund: Verlaufshintergrund (globals.css), axe kann den Kontrast dort + * prinzipiell nicht berechnen → 21x incomplete. + * Abbau: nur mit einem einfarbigen Hero-Hintergrund. Kein Ziel für sich — + * echte Kontrast-VERSTÖSSE blockt LAUF 1 auf dieser Seite weiterhin. + * + * `duplicate-id-aria` — ALTLAST, eingefroren am 2026-07-26. + * Regel: duplicate-id-aria · Seite: / · Elemente: id="plz" und + * id="plz-funktion" existieren auf der Startseite je zweimal + * (Hero-Formular und zweites Formular weiter unten). + * Grund: 2 belegte Verstöße (Impact critical). Dieser PR baut das GATE und + * ändert bewusst keine UI-Datei; der Fix ist ein eigener PR. + * Abbau: IDs eindeutig machen (z. B. Suffix je Formular-Instanz) und diese + * Ausnahme dann ERSATZLOS entfernen — der Folge-PR nimmt sie mit raus. + * + * `form-field-multiple-labels` — ALTLAST, eingefroren am 2026-07-26. + * Regel: form-field-multiple-labels · Seite: / · Elemente: dieselben zwei + * PLZ-Felder, die durch die doppelten IDs je zwei