Skip to content
Merged
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
132 changes: 132 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
141 changes: 141 additions & 0 deletions app/.pa11yci.js
Original file line number Diff line number Diff line change
@@ -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 → <a href="/<tenant>/konto" ...>Konto unter',
"„Meine Anliegen“</a>. 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;
Loading