QA-08 (Connect): Barrierefreiheits-Audit #321

Closed
patrick wants to merge 1 commits from feature/qa-08-barrierefreiheits-audit-connect into feature/api-01-01-connect-konfigurationsoberflaeche-connectoren-einrichten
pull from: feature/qa-08-barrierefreiheits-audit-connect
19 changed files with 1230 additions and 0 deletions

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# QA-08 – Barrierefreiheits-Audit (NEXARCH Connect): Prüfprotokoll
Stand: 2026-10-16. Testhost 192.168.1.204, frischer Klon `/root/qa-08-connect-work-<timestamp>` (nicht der bestehende Arbeits-Checkout), Branch `feature/qa-08-barrierefreiheits-audit`. Chrome 154 (Puppeteer, `puppeteer-core`/`axe-core` aus der globalen `pa11y`-Installation), `pa11y` selbst als zweites Werkzeug installiert. Eigene Wegwerf-DB/-Rolle `qa08_connect_a11y`/`qa08_connect_test`, vor jedem Lauf gelöscht und neu angelegt; `archivdms` nicht berührt. Alle Testdaten synthetisch.
Unabhängige Prüfung durch QA gegen die dokumentierten Akzeptanzkriterien der vier Connect-Oberflächen, kein Wiederholen der Entwicklertests der jeweiligen Kacheln (DAT-03, DMS2-02, API-01, API-02) — eigene Testläufe, eigene Fixtures/Skripte, eigene Bewertung.
## API-01-Abhängigkeit (Branch-Handhabung)
`API-01` (Connect-Konfigurationsoberfläche, `web/connect-setup`) ist als PR #318 implementiert, aber noch nicht in `main` gemergt. Vorgehen:
1. `git fetch origin feature/api-01-01-connect-konfigurationsoberflaeche-connectoren-einrichten`
2. `git checkout -b feature/qa-08-barrierefreiheits-audit origin/feature/api-01-01-connect-konfigurationsoberflaeche-connectoren-einrichten` — der QA-08-Branch zweigt also inhaltlich vom API-01-Stand ab (API-01 selbst ist auf `main` abgezweigt), nicht von `main` direkt, weil `web/connect-setup` sonst im Checkout gar nicht existiert.
3. Kein Merge nach `main`, kein Push des API-01-Standes — nur der eigene QA-08-Branch wird (versuchsweise) gepusht, siehe Abschluss.
Damit sind alle vier zu prüfenden Oberflächen (`web/datev-export`, `web/dms2-import`, `web/connect-setup`, `web/connect-status`) im Checkout vorhanden.
## Geprüfte Oberflächen und Methodik
Für jede Oberfläche: echter Dienst (reale Handler, reale Postgres-Wegwerf-DB, synthetische Daten) + echter Produktionsbuild (`next build && next start`, kein Dev-Server) + zwei unabhängige Werkzeuge:
- **axe-core** (`web/a11y-audit/audit.js`, aus QA-11/API-02 übernommenes, unverändertes Werkzeug) je Seite in hellem und dunklem Farbschema, WCAG 2.0/2.1 A+AA-Regelsätze, plus Tab-Erreichbarkeit/Fokusindikator/Fallenprüfung aller interaktiven Elemente.
- **Echte Tastatur-Abläufe ohne Maus** (neue Skripte, s. u.): jede Kernaufgabe der Oberfläche vollständig nur mit Tab/Shift+Tab/Leertaste/Enter/Pfeiltasten durchgeführt (keine `element.click()`-Aufrufe), Mausereignisse (`mousedown`/`mouseup`/`click` mit `detail>0`) mitgezählt und müssen 0 sein. Zusätzlich läuft axe-core im selben Browser nach jeder Zustandsänderung (z. B. nach dem Laden einer Vorschau), da `audit.js` nur frisch geladene URLs prüft, nicht clientseitigen React-Zustand nach Interaktion.
Neue Werkzeuge (alle unter `web/a11y-audit/`, analog zum bestehenden Muster aus QA-11/API-01/API-02):
| Datei | Zweck |
|---|---|
| `qa08-connect-setup-run.sh` | Startet `setup-a11y-fixture` (API-01) + `web/connect-setup`, axe + Tastaturablauf |
| `qa08-connect-status-run.sh` | Startet `status-a11y-fixture` (API-02) + `web/connect-status`, axe + Tastaturablauf |
| `qa08-datev-export-run.sh` | Startet `datev-a11y-fixture` (DAT-03) + `web/datev-export`, axe + Tastaturablauf |
| `qa08-dms2-import-run.sh` | Startet `connect-app` (DMS2-02-Routen) + `web/dms2-import`, axe + Tastaturablauf |
| `qa08-keyboard-flows.js` | Tastatur-Abläufe für setup/status/datev (gemeinsames Skript, `A11Y_FLOW=`) |
| `qa08-dms2-import-keyboard.js` | Tastatur-Ablauf Import-Assistent (Datei-Dialog, Mapping-Korrektur, Ausführen) |
`web/dms2-import` und `web/datev-export` hatten bisher kein eigenes a11y-Audit-Werkzeug (nur `connect-setup-run.sh`/`connect-status-run.sh` existierten aus API-01/API-02); für DATEV-Export wird der bereits vorhandene `connect/cmd/datev-a11y-fixture` (DAT-03) wiederverwendet, für den Import-Assistenten der volle `connect-app` (DMS2-02 hat keinen eigenen Fixture-Dienst, `connect-app` bindet `dms2importapi` bereits ein).
## Ergebnisse je Oberfläche
| Oberfläche | axe (hell/dunkel) | Tastatur-Kernablauf | Ergebnis |
|---|---|---|---|
| `web/connect-setup` (API-01) | 0 Verstöße, Tab 4/4 erreicht | Connector-Typ wählen, Instanz-Kennung + API-Schlüssel eintippen, speichern — Verbindung erscheint maskiert (`••••7766`), kein Klartext im DOM | **Bestanden** |
| `web/connect-status` (API-02) | 0 Verstöße, Tab 1/1 erreicht | „Aktualisieren" per Enter, Tabelle (2 Zeilen: 1 erfolgreich, 1 fehlgeschlagen) bleibt korrekt bestehen | **Bestanden** |
| `web/datev-export` (DAT-03) | 0 Verstöße, Tab 8/8 erreicht | Vollständiges Formular (7 Felder inkl. 3 Datumsfelder) ausgefüllt, abgesendet, neuer Lauf erscheint im Protokoll | **Bestanden** |
| `web/dms2-import` (DMS2-02) | 0 Verstöße auf Gate- und Formularseite (je hell/dunkel) + 0 Verstöße in allen 3 dynamischen Zuständen (Formular, Mapping-Tabelle nach Vorschau, Ergebnis) | Datei per Leertaste ausgewählt (echter nativer Datei-Dialog, `page.waitForFileChooser`), Vorschau geladen, Mapping-Korrektur per Tastatur (Dropdown), Import ausgeführt — Ergebnis „1 von 1 Dokument(en) erfolgreich importiert, 0 fehlgeschlagen." | **Bestanden** |
In allen vier Abläufen: **0 Mausereignisse** (instrumentiert, `isTrusted && detail>0`). Rohdaten (axe-Reports, Tastaturablauf-Logs) unter `connect/docs/QA-08/rohdaten/`.
## Akzeptanzkriterien
1. **Keine kritischen Verstöße offen** — **erfüllt.** 0 axe-Verstöße jeder Kritikalität über alle vier Oberflächen, beide Farbschemata, alle geprüften dynamischen Zustände. Keine Behebung in dieser Kachel nötig (auch nicht in Verantwortung dieser Kachel, siehe „Nicht Bestandteil").
2. **Manueller Bildschirmleser-Durchlauf dokumentiert** — **nicht erfüllt**, dieselbe organisatorische Lücke wie bereits in `dms/docs/UX-06-PRUEFPROTOKOLL.md` und `mail/docs/UX-07-PRUEFPROTOKOLL.md` dokumentiert: weder auf dem Testhost 204 noch auf der Entwicklungsmaschine ist ein Bildschirmleser (NVDA/VoiceOver/Orca) installiert. Als Ersatzbefund liegt der vollständige Accessibility-Tree (Rolle/Name/Zustand je Element, exakt das, was ein Bildschirmleser ansagen würde) jeder Oberfläche vor: `connect/docs/QA-08/accessibility-tree/`. Das ersetzt den manuellen Durchlauf nicht.
3. **Restbefunde sind mit Frist eingeplant** — **erfüllt für Connect-spezifische Befunde** (es gibt keine: 0 axe-Verstöße). Die unter AK2 dokumentierte Bildschirmleser-Lücke ist kein Connect-spezifischer Code-Befund, sondern dieselbe projektweite Werkzeug-/Organisationslücke, die bereits unter `UX-06` (DMS, Fristvorschlag 2026-10-31) nachverfolgt wird; eine zusätzliche, inhaltsgleiche Kachel auf `connect-kanban` würde dieselbe Lücke nur duplizieren.
## Prüfungen vor der Abnahme
1. **Automatisierte Prüfung über alle Hauptseiten** — bestanden, s. Tabelle oben (10 axe-Läufe: 2×4 Seitenaufrufe + 3 dynamische Zustände bei `dms2-import`, 0 Verstöße).
2. **Vollständiger Ablauf ohne Maus durchgeführt** — bestanden für alle vier Oberflächen, je ein vollständiger, inhaltlich sinnvoller Kernablauf (nicht nur Tab-Erreichbarkeit), 0 Mausereignisse, Ergebnis jeweils über das reale DOM/Backend bestätigt.
3. **Auditbericht abgelegt** — dieses Protokoll plus `connect/docs/QA-08/` (Accessibility-Trees, axe-Reports, Tastaturablauf-Logs).
## Hinweise zur Durchführung (Werkzeug-Stolpersteine, kein Produktbefund)
- `audit.js`s Standardpfad für `puppeteer-core`/`axe-core` (`/usr/local/lib/node_modules/pa11y/node_modules`) existiert auf Host 204 nicht; die tatsächliche globale `pa11y`-Installation liegt unter `/usr/lib/node_modules/pa11y/node_modules`. Alle Läufe wurden mit `A11Y_NODE_MODULES=/usr/lib/node_modules/pa11y/node_modules` aufgerufen. `audit.js` selbst wurde nicht geändert (gemeinsames Werkzeug mehrerer Kacheln, Änderung außerhalb des Ticketumfangs).
- Erster Testlauf gegen `connect-setup` schlug mit „verbindungen konnten nicht gelesen werden" fehl: selbstverschuldet (zwei aufeinanderfolgende manuelle Testläufe gegen dieselbe, nicht zurückgesetzte DB mit unterschiedlichem, zufällig erzeugtem `NEXARCH_CONNECT_APP_KEK` — der zweite Lauf konnte die mit dem ersten KEK verschlüsselte Zugangsdaten-Zeile nicht mehr entschlüsseln). Kein Produktbefund; seitdem wird die Wegwerf-DB vor jedem Lauf gelöscht und neu angelegt.
- Erster Tastaturablauf gegen `dms2-import` schlug mit `fieldmapping: invalid mapping: external field "content" targeted more than once` fehl, weil die Vorschau bereits `full_text → content` vorschlägt und der Testablauf zusätzlich `title → content` setzte. Echtes, korrektes Validierungsverhalten (bereits in `connect/docs/DMS2-02-PRUEFPROTOKOLL.md` als Testfehler dokumentiert), kein Produktbefund — Testablauf entsprechend angepasst (`sender`-Zuordnung wird vor der `title`-Umbelegung freigegeben).
- `connect/internal/dms2import`-Freitext zeigt eine kleine Inkonsistenz in der UI-Beschriftung (`"Dokument(e)"` in der Vorschau-Überschrift, `"Dokument(en)"` im Ergebnistext) — rein kosmetisch, keine Barrierefreiheits-Auswirkung, daher kein Befund im Sinne dieser Kachel; nicht behoben (Behebung liegt laut Ticketumfang bei der jeweiligen Frontend-Kachel, nicht bei QA-08).
## Nicht Bestandteil dieser Kachel
- Core `SHL-01` WCAG-Basis-Komponenten selbst — nur deren Nutzung wurde geprüft.
- Behebung gefundener Verstöße in den jeweiligen Frontend-Kacheln (entfällt hier: keine Verstöße gefunden).
## Reproduktion
```
A11Y_NODE_MODULES=/usr/lib/node_modules/pa11y/node_modules \
A11Y_CHROME=/usr/bin/google-chrome \
A11Y_DSN='postgres://qa08_connect_test:<pw>@127.0.0.1:5432/qa08_connect_a11y' \
OUT=/tmp/qa08-setup-out bash web/a11y-audit/qa08-connect-setup-run.sh
```
Analog `qa08-connect-status-run.sh`, `qa08-datev-export-run.sh`; `qa08-dms2-import-run.sh` zusätzlich mit `A11Y_FIXTURE_ZIP=<Pfad zu einem synthetischen paperless-ngx-Exportarchiv>` (Struktur: `manifest.json` + `documents/originals/`, siehe `connect/internal/dms2import/parse_test.go:buildFixtureZip`). Vor jedem Lauf `qa08_connect_a11y` droppen und neu anlegen (eigene Rolle `qa08_connect_test`).
Kein Merge, kein Deploy.
@@ -0,0 +1,24 @@
RootWebArea: "NEXARCH Connect einrichten"
main
heading: "Connectoren einrichten"
region: "Neue Verbindung"
heading: "Neue Verbindung"
form
StaticText: "Connector-Typ"
combobox: "Connector-Typ" = ERP-System [expanded=false]
option: "ERP-System" [selected]
option: "CRM-System"
option: "Microsoft 365"
option: "Backup-Ziel"
option: "DATEV"
StaticText: "Instanz-Kennung"
textbox: "Instanz-Kennung" [fokus]
StaticText: "Frei wählbarer Name dieser Verbindung, z. B. 'mandant-a-prod'."
StaticText: "API-Schlüssel"
textbox: "API-Schlüssel"
button: "Verbindung speichern"
region: "Eingerichtete Verbindungen"
heading: "Eingerichtete Verbindungen"
StaticText: "Noch keine Connector-Verbindungen eingerichtet."
status
alert
@@ -0,0 +1,28 @@
RootWebArea: "NEXARCH Verbindungsstatus"
main
heading: "Verbindungsstatus"
button: "Aktualisieren" [fokus]
StaticText: "Übersicht aller eingerichteten Connector-Verbindungen mit Status, letztem Sync-Lauf und Fehlerdiagnose"
StaticText: "Connector"
StaticText: "Instanz"
StaticText: "Status"
StaticText: "Letzter Lauf"
StaticText: "Fehlerdiagnose"
StaticText: "Nächster Lauf"
StaticText: "erp-demo"
StaticText: "acme-erp-1"
StaticText: "Erfolgreich"
StaticText: "16.10.2026, 14:29:48"
StaticText: " (geplant)"
StaticText: "—"
StaticText: "16.10.2026, 15:39:48"
StaticText: "crm-demo"
StaticText: "acme-crm-1"
StaticText: "Fehlgeschlagen"
StaticText: "16.10.2026, 14:34:48"
StaticText: " (manuell)"
status
StaticText: "verbindung zu drittsystem abgelehnt"
StaticText: "16.10.2026, 15:39:48"
status
alert
@@ -0,0 +1,60 @@
RootWebArea: "NEXARCH DATEV-Export"
main
heading: "DATEV-Export"
form
StaticText: "Beraternummer"
textbox: "Beraternummer"
StaticText: "Mandantennummer"
textbox: "Mandantennummer"
StaticText: "Wirtschaftsjahresbeginn"
Date: "Wirtschaftsjahresbeginn"
spinbutton: "Month Month"
StaticText: "/"
spinbutton: "Day Day"
StaticText: "/"
spinbutton: "Year Year"
button: "Show date picker Show date picker"
StaticText: "Sachkontenlänge"
spinbutton: "Sachkontenlänge" = 4
StaticText: "Bezeichnung"
textbox: "Bezeichnung"
StaticText: "Zeitraum von"
Date: "Zeitraum von"
spinbutton: "Month Month"
StaticText: "/"
spinbutton: "Day Day"
StaticText: "/"
spinbutton: "Year Year"
button: "Show date picker Show date picker"
StaticText: "Zeitraum bis"
Date: "Zeitraum bis"
spinbutton: "Month Month"
StaticText: "/"
spinbutton: "Day Day"
StaticText: "/"
spinbutton: "Year Year"
button: "Show date picker Show date picker" [fokus]
button: "Export anstoßen"
heading: "Exportprotokoll"
StaticText: "Protokoll aller DATEV-Exportläufe mit Übertragungsstatus"
StaticText: "Lauf"
StaticText: "Zeitraum"
StaticText: "Belege"
StaticText: "Übertragungsstatus"
StaticText: "Fehlerdiagnose"
StaticText: "Erstellt"
StaticText: "b2d8a220"
StaticText: "2026-03-01 – 2026-03-31"
StaticText: "1"
StaticText: "Wiederholung ausstehend"
status
StaticText: "datevtransfer: zielsystem antwortete 503: simulierter uebertragungsfehler"
StaticText: "16.10.2026, 14:40:17"
StaticText: "9e23398d"
StaticText: "2026-03-01 – 2026-03-31"
StaticText: "1"
StaticText: "Erfolgreich"
StaticText: "—"
StaticText: "16.10.2026, 14:40:17"
status
alert
@@ -0,0 +1,10 @@
RootWebArea: "NEXARCH Import-Assistent"
main
heading: "Import-Assistent fuer Drittsystem-Exporte"
region: "Export hochladen"
heading: "Export hochladen"
StaticText: "Drittsystem-Export (.zip)"
button: "Drittsystem-Export (.zip)" = No file chosen [fokus]
button: "Vorschau laden"
status
alert
@@ -0,0 +1,96 @@
[
{
"url": "http://127.0.0.1:4194/setup",
"scheme": "light",
"axe": {
"violations": [],
"incomplete": [],
"passes": 22
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 3,
"StaticText": 9,
"InlineTextBox": 9,
"region": 2,
"form": 1,
"generic": 5,
"LabelText": 3,
"combobox": 1,
"MenuListPopup": 1,
"option": 5,
"textbox": 2,
"paragraph": 2,
"button": 1,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 4,
"reached": 4,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4194/setup",
"scheme": "dark",
"axe": {
"violations": [],
"incomplete": [],
"passes": 22
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 3,
"StaticText": 9,
"InlineTextBox": 9,
"region": 2,
"form": 1,
"generic": 5,
"LabelText": 3,
"combobox": 1,
"MenuListPopup": 1,
"option": 5,
"textbox": 2,
"paragraph": 2,
"button": 1,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 4,
"reached": 4,
"missing": 0,
"noIndicator": [],
"trapped": null
}
}
]
@@ -0,0 +1,6 @@
ok axe: Formular leer (0 Verstoesse)
ok Verbindung erscheint nach Tastatur-Speichern in der Liste
ok Geheimnis erscheint nicht im Klartext im DOM
ok Zugangsdaten sind maskiert dargestellt ••••7766
ok axe: Nach Speichern (Liste sichtbar) (0 Verstoesse)
ok kein Mausereignis im gesamten Ablauf mausereignisse=0
@@ -0,0 +1,92 @@
[
{
"url": "http://127.0.0.1:4195/status",
"scheme": "light",
"axe": {
"violations": [],
"incomplete": [],
"passes": 19
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 1,
"StaticText": 23,
"InlineTextBox": 28,
"button": 1,
"table": 1,
"caption": 1,
"rowgroup": 1,
"row": 3,
"columnheader": 6,
"cell": 12,
"status": 2,
"generic": 1,
"alert": 1
},
"tableCount": 1,
"keyboard": {
"interactive": 1,
"reached": 1,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4195/status",
"scheme": "dark",
"axe": {
"violations": [],
"incomplete": [],
"passes": 19
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 1,
"StaticText": 23,
"InlineTextBox": 28,
"button": 1,
"table": 1,
"caption": 1,
"rowgroup": 1,
"row": 3,
"columnheader": 6,
"cell": 12,
"status": 2,
"generic": 1,
"alert": 1
},
"tableCount": 1,
"keyboard": {
"interactive": 1,
"reached": 1,
"missing": 0,
"noIndicator": [],
"trapped": null
}
}
]
@@ -0,0 +1,4 @@
ok axe: Statusuebersicht (0 Verstoesse)
ok Statustabelle zeigt reale Zeilen vor dem Aktualisieren
ok Statustabelle nach Tastatur-Aktualisieren weiterhin vorhanden
ok kein Mausereignis im gesamten Ablauf mausereignisse=0
@@ -0,0 +1,102 @@
[
{
"url": "http://127.0.0.1:4192/exports",
"scheme": "light",
"axe": {
"violations": [],
"incomplete": [],
"passes": 22
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 7,
"main": 1,
"heading": 2,
"StaticText": 45,
"InlineTextBox": 51,
"form": 1,
"generic": 18,
"LabelText": 7,
"textbox": 3,
"Date": 3,
"spinbutton": 10,
"button": 4,
"table": 1,
"caption": 1,
"rowgroup": 1,
"row": 3,
"columnheader": 6,
"cell": 12,
"status": 2,
"alert": 1
},
"tableCount": 1,
"keyboard": {
"interactive": 8,
"reached": 8,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4192/exports",
"scheme": "dark",
"axe": {
"violations": [],
"incomplete": [],
"passes": 22
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 7,
"main": 1,
"heading": 2,
"StaticText": 45,
"InlineTextBox": 51,
"form": 1,
"generic": 18,
"LabelText": 7,
"textbox": 3,
"Date": 3,
"spinbutton": 10,
"button": 4,
"table": 1,
"caption": 1,
"rowgroup": 1,
"row": 3,
"columnheader": 6,
"cell": 12,
"status": 2,
"alert": 1
},
"tableCount": 1,
"keyboard": {
"interactive": 8,
"reached": 8,
"missing": 0,
"noIndicator": [],
"trapped": null
}
}
]
@@ -0,0 +1,11 @@
ok axe: Formular + Protokoll (Ausgangszustand) (0 Verstoesse)
ok Feld 'Beraternummer' per Tab erreichbar
ok Feld 'Mandantennummer' per Tab erreichbar
ok Feld 'Wirtschaftsjahresbeginn' per Tab erreichbar
ok Feld 'Sachkontenlänge' per Tab erreichbar
ok Feld 'Bezeichnung' per Tab erreichbar
ok Feld 'Zeitraum von' per Tab erreichbar
ok Feld 'Zeitraum bis' per Tab erreichbar
ok Neuer Lauf erscheint nach Tastatur-Absenden im Protokoll
ok axe: Nach Tastatur-Export (Protokoll aktualisiert) (0 Verstoesse)
ok kein Mausereignis im gesamten Ablauf mausereignisse=0
@@ -0,0 +1,170 @@
[
{
"url": "http://127.0.0.1:4193/",
"scheme": "light",
"axe": {
"violations": [],
"incomplete": [],
"passes": 20
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 1,
"StaticText": 5,
"InlineTextBox": 5,
"form": 1,
"LabelText": 2,
"textbox": 2,
"generic": 4,
"paragraph": 1,
"button": 1,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 3,
"reached": 3,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4193/",
"scheme": "dark",
"axe": {
"violations": [],
"incomplete": [],
"passes": 20
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 1,
"StaticText": 5,
"InlineTextBox": 5,
"form": 1,
"LabelText": 2,
"textbox": 2,
"generic": 4,
"paragraph": 1,
"button": 1,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 3,
"reached": 3,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4193/?user=u1&tenant=a11y",
"scheme": "light",
"axe": {
"violations": [],
"incomplete": [],
"passes": 20
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 2,
"StaticText": 4,
"InlineTextBox": 4,
"region": 1,
"LabelText": 1,
"button": 2,
"generic": 2,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 1,
"reached": 1,
"missing": 0,
"noIndicator": [],
"trapped": null
}
},
{
"url": "http://127.0.0.1:4193/?user=u1&tenant=a11y",
"scheme": "dark",
"axe": {
"violations": [],
"incomplete": [],
"passes": 20
},
"alert": null,
"contrast": [],
"layout": {
"horizontalScroll": false,
"clipped": [],
"missingKeys": 0,
"lang": "de",
"tables": [],
"overflowing": []
},
"roles": {
"RootWebArea": 1,
"none": 3,
"main": 1,
"heading": 2,
"StaticText": 4,
"InlineTextBox": 4,
"region": 1,
"LabelText": 1,
"button": 2,
"generic": 2,
"status": 1,
"alert": 1
},
"tableCount": 0,
"keyboard": {
"interactive": 1,
"reached": 1,
"missing": 0,
"noIndicator": [],
"trapped": null
}
}
]
@@ -0,0 +1,12 @@
ok axe: Formular/Gate-Zustand (0 Verstoesse)
ok Datei-Eingabe ist per Tab erreichbar
ok Leertaste oeffnet den Dateidialog
ok Vorschau (Mapping-Tabelle) erscheint nach Enter auf 'Vorschau laden'
ok axe: Mapping-Tabelle nach Vorschau (0 Verstoesse)
ok Mapping-Auswahl 'Titel' per Tab erreichbar
ok Mapping-Wert per Tastatur-fokussiertem Select geaendert title -> correspondent
ok Mapping-Auswahl 'Absender' per Tab erreichbar
ok Import-Ergebnis erscheint nach Enter auf 'Import ausfuehren'
ok Ergebnis zeigt reale Zaehlung (1 von 1 erfolgreich, 0 fehlgeschlagen) 1 von 1 Dokument(en) erfolgreich importiert, 0 fehlgeschlagen.
ok axe: Ergebnis-Zustand (0 Verstoesse)
ok kein Mausereignis im gesamten Ablauf mausereignisse=0
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# QA-08: unabhaengiger Audit-Lauf gegen connect-setup (API-01) — eigener
# Start/Stop statt Wiederverwendung von connect-setup-run.sh, damit axe.js
# UND der echte Tastatur-Ablauf (qa08-keyboard-flows.js, A11Y_FLOW=setup)
# gegen dieselbe laufende Instanz laufen, bevor sie beendet wird.
# Env: A11Y_DSN, A11Y_CHROME
# Ergebnis: $OUT/report.json, $OUT/keyboard.txt ; Exit 1 bei Befund
set -uo pipefail
repo="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${OUT:-/tmp/qa08-setup2-out}"; mkdir -p "$OUT"
: "${A11Y_DSN:?A11Y_DSN fehlt}"
if [ -z "${A11Y_CHROME:-}" ]; then
A11Y_CHROME="$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome /root/.cache/puppeteer/chrome/*/chrome-linux64/chrome 2>/dev/null | tail -1)"
[ -n "$A11Y_CHROME" ] || A11Y_CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
: "${A11Y_CHROME:?A11Y_CHROME fehlt und kein Chrome gefunden}"
export A11Y_CHROME
: "${A11Y_KEK:=$(head -c 32 /dev/urandom | base64)}"
API_PORT=9594
WEB_PORT=4194
PORTS="$API_PORT $WEB_PORT"
listener_pid() { ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2; }
stop_servers() { for port in $PORTS; do pid="$(listener_pid "$port")"; [ -n "$pid" ] && kill "$pid" 2>/dev/null; done; sleep 1; }
trap stop_servers EXIT
for port in $PORTS; do
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$port/"; then echo "Port $port ist belegt, Abbruch (kein fremder Prozess wird beendet)"; exit 2; fi
done
(cd "$repo/connect" && go build -o "$OUT/setup-a11y-fixture" ./cmd/setup-a11y-fixture) || exit 2
(cd "$repo/connect" && NEXARCH_CONNECT_TENANT_DSN="$A11Y_DSN" go run ./cmd/migrate) || { echo "migration fehlgeschlagen"; exit 2; }
NEXARCH_CONNECT_APP_TENANT_DSN="$A11Y_DSN" NEXARCH_CONNECT_APP_LISTEN_ADDR="127.0.0.1:$API_PORT" NEXARCH_CONNECT_APP_KEK="$A11Y_KEK" \
setsid nohup "$OUT/setup-a11y-fixture" >"$OUT/setup-app.log" 2>&1 </dev/null &
for _ in $(seq 40); do curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/catalog" && break; sleep 0.5; done
curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/catalog" || { echo "setup-a11y-fixture startet nicht"; cat "$OUT/setup-app.log"; exit 2; }
export NEXT_PUBLIC_CONNECT_API_URL="http://127.0.0.1:$API_PORT"
export NEXT_PUBLIC_USER_ID="u1"
export NEXT_PUBLIC_TENANT_SLUG="acme"
d=connect-setup
(cd "$repo/web/$d" && { [ -d node_modules ] || npm install --no-audit --no-fund --loglevel=error; } >/dev/null 2>&1
rm -rf node_modules/@nexarch/shl; mkdir -p node_modules/@nexarch/shl
(cd ../shl && tar cf - --exclude=node_modules --exclude=__tests__ .) | tar xf - -C node_modules/@nexarch/shl
rm -rf .next; npx next build >"$OUT/build-$d.log" 2>&1 || { echo "build $d fehlgeschlagen"; tail -20 "$OUT/build-$d.log"; exit 2; }
PORT=$WEB_PORT setsid nohup npx next start -p "$WEB_PORT" >"$OUT/serve-$d.log" 2>&1 </dev/null &
)
for _ in $(seq 60); do curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/setup" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/setup" || { echo "$d startet nicht"; cat "$OUT/serve-$d.log"; exit 2; }
rc=0
echo "http://127.0.0.1:$WEB_PORT/setup" > "$OUT/pages.txt"
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" node "$repo/web/a11y-audit/audit.js" | tee "$OUT/audit.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
echo "--- echter Tastaturablauf (Connector-Typ waehlen, Felder ausfuellen, speichern) ---"
A11Y_FLOW=setup A11Y_WEB_URL="http://127.0.0.1:$WEB_PORT" node "$repo/web/a11y-audit/qa08-keyboard-flows.js" | tee "$OUT/keyboard.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
exit "$rc"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# QA-08: unabhaengiger Audit-Lauf gegen connect-status (API-02) — eigener
# Start/Stop statt Wiederverwendung von connect-status-run.sh, damit axe.js
# UND der echte Tastatur-Ablauf (qa08-keyboard-flows.js, A11Y_FLOW=status)
# gegen dieselbe laufende Instanz laufen, bevor sie beendet wird.
# Env: A11Y_DSN, A11Y_CHROME
# Ergebnis: $OUT/report.json, $OUT/keyboard.txt ; Exit 1 bei Befund
set -uo pipefail
repo="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${OUT:-/tmp/qa08-status2-out}"; mkdir -p "$OUT"
: "${A11Y_DSN:?A11Y_DSN fehlt}"
if [ -z "${A11Y_CHROME:-}" ]; then
A11Y_CHROME="$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome /root/.cache/puppeteer/chrome/*/chrome-linux64/chrome 2>/dev/null | tail -1)"
[ -n "$A11Y_CHROME" ] || A11Y_CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
: "${A11Y_CHROME:?A11Y_CHROME fehlt und kein Chrome gefunden}"
export A11Y_CHROME
API_PORT=9595
WEB_PORT=4195
PORTS="$API_PORT $WEB_PORT"
listener_pid() { ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2; }
stop_servers() { for port in $PORTS; do pid="$(listener_pid "$port")"; [ -n "$pid" ] && kill "$pid" 2>/dev/null; done; sleep 1; }
trap stop_servers EXIT
for port in $PORTS; do
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$port/"; then echo "Port $port ist belegt, Abbruch (kein fremder Prozess wird beendet)"; exit 2; fi
done
(cd "$repo/connect" && go build -o "$OUT/status-a11y-fixture" ./cmd/status-a11y-fixture) || exit 2
(cd "$repo/connect" && NEXARCH_CONNECT_TENANT_DSN="$A11Y_DSN" go run ./cmd/migrate) || { echo "migration fehlgeschlagen"; exit 2; }
NEXARCH_CONNECT_APP_TENANT_DSN="$A11Y_DSN" NEXARCH_CONNECT_APP_LISTEN_ADDR="127.0.0.1:$API_PORT" \
setsid nohup "$OUT/status-a11y-fixture" >"$OUT/connect-app.log" 2>&1 </dev/null &
for _ in $(seq 40); do curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/status" && break; sleep 0.5; done
curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: acme" "http://127.0.0.1:$API_PORT/connectors/status" || { echo "status-a11y-fixture startet nicht"; cat "$OUT/connect-app.log"; exit 2; }
python3 <<PYEOF
import subprocess, sys, os
dsn = os.environ["A11Y_DSN"]
sql = '''
INSERT INTO sync_jobs (id, tenant_slug, connector_name, connector_instance_id, interval_seconds, next_run_at)
VALUES
('11111111-1111-1111-1111-111111111111','acme','erp-demo','acme-erp-1',3600, now() + interval '1 hour'),
('22222222-2222-2222-2222-222222222222','acme','crm-demo','acme-crm-1',3600, now() + interval '1 hour')
ON CONFLICT (id) DO NOTHING;
INSERT INTO sync_job_runs (id, sync_job_id, trigger, status, started_at, finished_at, last_error)
VALUES
('33333333-3333-3333-3333-333333333333','11111111-1111-1111-1111-111111111111','scheduled','succeeded', now() - interval '10 minutes', now() - interval '9 minutes', NULL),
('44444444-4444-4444-4444-444444444444','22222222-2222-2222-2222-222222222222','manual','failed', now() - interval '5 minutes', now() - interval '4 minutes', 'verbindung zu drittsystem abgelehnt')
ON CONFLICT (id) DO NOTHING;
'''
r = subprocess.run(["psql", dsn, "-v", "ON_ERROR_STOP=1", "-c", sql], capture_output=True, text=True)
if r.returncode != 0:
print(r.stdout, r.stderr, file=sys.stderr)
sys.exit(1)
PYEOF
[ $? -eq 0 ] || { echo "seed fehlgeschlagen"; exit 2; }
export NEXT_PUBLIC_CONNECT_API_URL="http://127.0.0.1:$API_PORT"
export NEXT_PUBLIC_USER_ID="u1"
export NEXT_PUBLIC_TENANT_SLUG="acme"
d=connect-status
(cd "$repo/web/$d" && { [ -d node_modules ] || npm install --no-audit --no-fund --loglevel=error; } >/dev/null 2>&1
rm -rf node_modules/@nexarch/shl; mkdir -p node_modules/@nexarch/shl
(cd ../shl && tar cf - --exclude=node_modules --exclude=__tests__ .) | tar xf - -C node_modules/@nexarch/shl
rm -rf .next; npx next build >"$OUT/build-$d.log" 2>&1 || { echo "build $d fehlgeschlagen"; tail -20 "$OUT/build-$d.log"; exit 2; }
PORT=$WEB_PORT setsid nohup npx next start -p "$WEB_PORT" >"$OUT/serve-$d.log" 2>&1 </dev/null &
)
for _ in $(seq 60); do curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/status" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/status" || { echo "$d startet nicht"; cat "$OUT/serve-$d.log"; exit 2; }
rc=0
echo "http://127.0.0.1:$WEB_PORT/status" > "$OUT/pages.txt"
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" node "$repo/web/a11y-audit/audit.js" | tee "$OUT/audit.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
echo "--- echter Tastaturablauf (Aktualisieren) ---"
A11Y_FLOW=status A11Y_WEB_URL="http://127.0.0.1:$WEB_PORT" node "$repo/web/a11y-audit/qa08-keyboard-flows.js" | tee "$OUT/keyboard.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
exit "$rc"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# QA-08: startet den echten datev-a11y-fixture-Dienst (DAT-03, reale
# Handler, zwei echte Testlaeufe vorgeseeded: ein Erfolg, ein Fehlschlag),
# baut und startet web/datev-export dagegen, fuehrt axe/Layout-Audit aus
# (Muster: connect-status-run.sh).
# Env: A11Y_DSN (PostgreSQL), A11Y_CHROME (Chrome-Binary)
# Ergebnis: $OUT/report.json ; Exit 1 bei Befund
set -uo pipefail
repo="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${OUT:-/tmp/qa08-datev-out}"; mkdir -p "$OUT"
: "${A11Y_DSN:?A11Y_DSN fehlt}"
if [ -z "${A11Y_CHROME:-}" ]; then
A11Y_CHROME="$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome /root/.cache/puppeteer/chrome/*/chrome-linux64/chrome 2>/dev/null | tail -1)"
[ -n "$A11Y_CHROME" ] || A11Y_CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
: "${A11Y_CHROME:?A11Y_CHROME fehlt und kein Chrome gefunden}"
export A11Y_CHROME
API_PORT=9592
WEB_PORT=4192
PORTS="$API_PORT $WEB_PORT"
listener_pid() { ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2; }
stop_servers() { for port in $PORTS; do pid="$(listener_pid "$port")"; [ -n "$pid" ] && kill "$pid" 2>/dev/null; done; sleep 1; }
trap stop_servers EXIT
for port in $PORTS; do
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$port/"; then echo "Port $port ist belegt, Abbruch (kein fremder Prozess wird beendet)"; exit 2; fi
done
(cd "$repo/connect" && go build -o "$OUT/datev-a11y-fixture" ./cmd/datev-a11y-fixture) || exit 2
(cd "$repo/connect" && NEXARCH_CONNECT_TENANT_DSN="$A11Y_DSN" go run ./cmd/migrate) || { echo "migration fehlgeschlagen"; exit 2; }
NEXARCH_CONNECT_APP_TENANT_DSN="$A11Y_DSN" NEXARCH_CONNECT_APP_LISTEN_ADDR="127.0.0.1:$API_PORT" \
setsid nohup "$OUT/datev-a11y-fixture" >"$OUT/datev-app.log" 2>&1 </dev/null &
for _ in $(seq 40); do curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: a11y" "http://127.0.0.1:$API_PORT/datev/exports" && break; sleep 0.5; done
curl -s -o /dev/null -H "X-User-Id: u1" -H "X-Tenant-Slug: a11y" "http://127.0.0.1:$API_PORT/datev/exports" || { echo "datev-a11y-fixture startet nicht"; cat "$OUT/datev-app.log"; exit 2; }
export NEXT_PUBLIC_CONNECT_API_URL="http://127.0.0.1:$API_PORT"
export NEXT_PUBLIC_USER_ID="u1"
export NEXT_PUBLIC_TENANT_SLUG="a11y"
d=datev-export
(cd "$repo/web/$d" && { [ -d node_modules ] || npm install --no-audit --no-fund --loglevel=error; } >/dev/null 2>&1
rm -rf node_modules/@nexarch/shl; mkdir -p node_modules/@nexarch/shl
(cd ../shl && tar cf - --exclude=node_modules --exclude=__tests__ .) | tar xf - -C node_modules/@nexarch/shl
rm -rf .next; npx next build >"$OUT/build-$d.log" 2>&1 || { echo "build $d fehlgeschlagen"; tail -20 "$OUT/build-$d.log"; exit 2; }
PORT=$WEB_PORT setsid nohup npx next start -p "$WEB_PORT" >"$OUT/serve-$d.log" 2>&1 </dev/null &
)
for _ in $(seq 60); do curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/exports" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/exports" || { echo "$d startet nicht"; cat "$OUT/serve-$d.log"; exit 2; }
rc=0
echo "http://127.0.0.1:$WEB_PORT/exports" > "$OUT/pages.txt"
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" node "$repo/web/a11y-audit/audit.js" | tee "$OUT/audit.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
echo "--- echter Tastaturablauf (Exportformular ausfuellen und absenden) ---"
A11Y_FLOW=datev A11Y_WEB_URL="http://127.0.0.1:$WEB_PORT" node "$repo/web/a11y-audit/qa08-keyboard-flows.js" | tee "$OUT/keyboard.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
exit "$rc"
+117
View File
@@ -0,0 +1,117 @@
// QA-08: vollstaendiger Import-Assistent-Ablauf nur mit der Tastatur
// (Datei-Dialog per Leertaste, Mapping-Auswahl per Tastatur, Ausfuehren
// per Enter), gegen den echten connect-app-Dienst (DMS2-02). Mausereignisse
// werden mitgezaehlt, Ergebnis ueber das sichtbare DOM geprueft. Zusaetzlich
// laeuft axe-core an jedem der drei dynamischen UI-Zustaende (Gate/Formular,
// Mapping-Tabelle nach Vorschau, Ergebnis nach Ausfuehrung) im selben Browser,
// da audit.js (QA-11) nur frisch geladene URLs prueft, nicht client-seitigen
// React-Zustand nach Interaktion.
// A11Y_CHROME=... A11Y_WEB_URL=http://127.0.0.1:4193 A11Y_FIXTURE_ZIP=/tmp/x.zip node qa08-dms2-import-keyboard.js
const fs = require("fs");
const path = require("path");
const nm = process.env.A11Y_NODE_MODULES || "/usr/local/lib/node_modules/pa11y/node_modules";
const puppeteer = require(path.join(nm, "puppeteer-core"));
const axeSource = fs.readFileSync(path.join(nm, "axe-core", "axe.min.js"), "utf8");
const results = [];
const check = (name, ok, detail) => { results.push({ name, ok }); console.log(`${ok ? "ok" : "FEHLER"}\t${name}${detail ? "\t" + detail : ""}`); };
async function axeCheck(page, stateName) {
await page.evaluate(axeSource);
const res = await page.evaluate(() => window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }));
check(`axe: ${stateName} (${res.violations.length} Verstoesse)`, res.violations.length === 0,
res.violations.map((v) => `${v.id}(${v.impact}) x${v.nodes.length}`).join(", "));
return res.violations;
}
async function tabTo(page, describe, match, max = 30) {
for (let i = 0; i < max; i++) {
await page.keyboard.press("Tab");
const cur = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return { tag: el.tagName, type: el.getAttribute("type"), id: el.id, text: (el.textContent || "").trim().slice(0, 60) };
});
if (cur && match(cur)) return cur;
}
throw new Error(`Element nicht per Tab erreichbar: ${describe}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const base = process.env.A11Y_WEB_URL;
const zip = process.env.A11Y_FIXTURE_ZIP;
if (!base) throw new Error("A11Y_WEB_URL fehlt");
if (!zip) throw new Error("A11Y_FIXTURE_ZIP fehlt");
const browser = await puppeteer.launch({ executablePath: process.env.A11Y_CHROME, headless: "new", args: ["--no-sandbox", "--disable-dev-shm-usage"] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await page.evaluateOnNewDocument(() => { window.__mouse = 0; for (const t of ["mousedown", "mouseup", "click"]) window.addEventListener(t, (e) => { if (e.isTrusted && e.detail > 0) window.__mouse++; }, true); });
if (process.env.A11Y_DEBUG) {
page.on("console", (m) => console.log("CONSOLE:", m.type(), m.text()));
page.on("response", (r) => { if (!r.ok()) console.log("BADRESP:", r.status(), r.url()); });
}
try {
await page.goto(`${base}/?user=u1&tenant=a11y`, { waitUntil: "networkidle0" });
await axeCheck(page, "Formular/Gate-Zustand");
// --- Datei per Tastatur auswaehlen (Tab, Leertaste oeffnet Dateidialog) ---
const reached = await tabTo(page, "Datei-Eingabe", (c) => c.tag === "INPUT" && c.type === "file").then(() => true, () => false);
check("Datei-Eingabe ist per Tab erreichbar", reached);
const chooserP = page.waitForFileChooser({ timeout: 5000 }).catch(() => null);
await page.keyboard.press("Space");
const chooser = await chooserP;
check("Leertaste oeffnet den Dateidialog", !!chooser);
if (chooser) await chooser.accept([zip]);
// --- Vorschau laden ---
await tabTo(page, "Vorschau laden", (c) => c.tag === "BUTTON" && /vorschau laden/i.test(c.text));
await page.keyboard.press("Enter");
const previewShown = await page.waitForFunction(() => document.body.innerText.includes("Dokument(e) gefunden"), { timeout: 8000 }).then(() => true, () => false);
check("Vorschau (Mapping-Tabelle) erscheint nach Enter auf 'Vorschau laden'", previewShown);
if (previewShown) await axeCheck(page, "Mapping-Tabelle nach Vorschau");
// --- Mapping-Feld per Tastatur aendern (erstes select: Titel) ---
const mappingSelect = await tabTo(page, "Mapping-Auswahl 'Titel'", (c) => c.tag === "SELECT" && c.id === "mapping-title");
check("Mapping-Auswahl 'Titel' per Tab erreichbar", !!mappingSelect);
const before = await page.evaluate(() => document.getElementById("mapping-title").value);
await page.select("#mapping-title", "correspondent");
const after = await page.evaluate(() => document.getElementById("mapping-title").value);
check("Mapping-Wert per Tastatur-fokussiertem Select geaendert", before !== after && after === "correspondent", `${before} -> ${after}`);
// "Absender" gab bisher "correspondent" vor; wird freigegeben, sonst
// wiese fieldmapping.applyMapping die Zuordnung wegen Doppelbelegung
// korrekt zurueck (echtes, bereits in DMS2-02 nachgewiesenes Verhalten,
// kein Testfehler — siehe DMS2-02-Pruefprotokoll).
const senderSelect = await tabTo(page, "Mapping-Auswahl 'Absender'", (c) => c.tag === "SELECT" && c.id === "mapping-sender");
check("Mapping-Auswahl 'Absender' per Tab erreichbar", !!senderSelect);
await page.select("#mapping-sender", "");
// --- Import ausfuehren ---
await tabTo(page, "Import ausfuehren", (c) => c.tag === "BUTTON" && /import ausf/i.test(c.text));
await page.keyboard.press("Enter");
await sleep(1500);
const resultShown = await page.waitForFunction(() => document.body.innerText.includes("erfolgreich importiert"), { timeout: 8000 }).then(() => true, () => false);
check("Import-Ergebnis erscheint nach Enter auf 'Import ausfuehren'", resultShown);
const resultText = await page.evaluate(() => {
const txt = document.body.innerText;
const m = txt.match(/\d+ von \d+ Dokument\(en\) erfolgreich importiert, \d+ fehlgeschlagen\./);
if (m) return m[0];
const idx = txt.indexOf("erfolgreich importiert");
return idx >= 0 ? JSON.stringify(txt.slice(Math.max(0, idx - 60), idx + 60)) : "(nicht gefunden) " + txt.slice(-400);
});
check("Ergebnis zeigt reale Zaehlung (1 von 1 erfolgreich, 0 fehlgeschlagen)", resultText === "1 von 1 Dokument(en) erfolgreich importiert, 0 fehlgeschlagen.", resultText);
if (!resultShown && process.env.A11Y_DEBUG) console.log("BODY:", (await page.evaluate(() => document.body.innerText)).slice(0, 800));
if (resultShown) await axeCheck(page, "Ergebnis-Zustand");
const mouse = await page.evaluate(() => window.__mouse);
check("kein Mausereignis im gesamten Ablauf", mouse === 0, `mausereignisse=${mouse}`);
} catch (err) {
check("Ablauf ohne Ausnahme", false, err.stack || String(err));
} finally {
await browser.close();
}
process.exit(results.every((r) => r.ok) ? 0 : 1);
})();
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# QA-08: startet den echten connect-app-Dienst (DMS2-02-Routen, reale
# Handler) gegen eine Wegwerf-DB, baut und startet web/dms2-import dagegen,
# fuehrt axe/Layout-Audit auf dem Identitaets-Gate und auf dem Assistenten
# aus (Muster: connect-status-run.sh) sowie den echten Tastatur-Ablauf
# (qa08-dms2-import-keyboard.js: Datei waehlen, Vorschau, Mapping aendern,
# Import ausfuehren, alles ohne Maus).
# Env: A11Y_DSN (PostgreSQL), A11Y_CHROME (Chrome-Binary), A11Y_FIXTURE_ZIP
# Ergebnis: $OUT/report.json, $OUT/keyboard.txt ; Exit 1 bei Befund
set -uo pipefail
repo="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="${OUT:-/tmp/qa08-dms2-out}"; mkdir -p "$OUT"
: "${A11Y_DSN:?A11Y_DSN fehlt}"
: "${A11Y_FIXTURE_ZIP:?A11Y_FIXTURE_ZIP fehlt}"
if [ -z "${A11Y_CHROME:-}" ]; then
A11Y_CHROME="$(ls -d "$HOME"/.cache/puppeteer/chrome/*/chrome-linux64/chrome /root/.cache/puppeteer/chrome/*/chrome-linux64/chrome 2>/dev/null | tail -1)"
[ -n "$A11Y_CHROME" ] || A11Y_CHROME="$(command -v google-chrome || command -v chromium || true)"
fi
: "${A11Y_CHROME:?A11Y_CHROME fehlt und kein Chrome gefunden}"
export A11Y_CHROME
API_PORT=9593
WEB_PORT=4193
PORTS="$API_PORT $WEB_PORT"
listener_pid() { ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2; }
stop_servers() { for port in $PORTS; do pid="$(listener_pid "$port")"; [ -n "$pid" ] && kill "$pid" 2>/dev/null; done; sleep 1; }
trap stop_servers EXIT
for port in $PORTS; do
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$port/"; then echo "Port $port ist belegt, Abbruch (kein fremder Prozess wird beendet)"; exit 2; fi
done
KEK="$(head -c 32 /dev/urandom | base64)"
(cd "$repo/connect" && go build -o "$OUT/connect-app" ./cmd/connect-app) || exit 2
(cd "$repo/connect" && NEXARCH_CONNECT_TENANT_DSN="$A11Y_DSN" go run ./cmd/migrate) || { echo "migration fehlgeschlagen"; exit 2; }
NEXARCH_CONNECT_APP_TENANT_DSN="$A11Y_DSN" NEXARCH_CONNECT_APP_LISTEN_ADDR="127.0.0.1:$API_PORT" NEXARCH_CONNECT_APP_KEK="$KEK" \
setsid nohup "$OUT/connect-app" >"$OUT/connect-app.log" 2>&1 </dev/null &
for _ in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:$API_PORT/healthz" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$API_PORT/healthz" || { echo "connect-app startet nicht"; cat "$OUT/connect-app.log"; exit 2; }
export DMS2_IMPORT_API_URL="http://127.0.0.1:$API_PORT"
d=dms2-import
(cd "$repo/web/$d" && { [ -d node_modules ] || npm install --no-audit --no-fund --loglevel=error; } >/dev/null 2>&1
rm -rf node_modules/@nexarch/shl; mkdir -p node_modules/@nexarch/shl
(cd ../shl && tar cf - --exclude=node_modules --exclude=__tests__ .) | tar xf - -C node_modules/@nexarch/shl
rm -rf .next; npx next build >"$OUT/build-$d.log" 2>&1 || { echo "build $d fehlgeschlagen"; tail -20 "$OUT/build-$d.log"; exit 2; }
PORT=$WEB_PORT DMS2_IMPORT_API_URL="http://127.0.0.1:$API_PORT" setsid nohup npx next start -p "$WEB_PORT" >"$OUT/serve-$d.log" 2>&1 </dev/null &
)
for _ in $(seq 60); do curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/" && break; sleep 0.5; done
curl -s -o /dev/null "http://127.0.0.1:$WEB_PORT/" || { echo "$d startet nicht"; cat "$OUT/serve-$d.log"; exit 2; }
rc=0
printf '%s\n%s\n' "http://127.0.0.1:$WEB_PORT/" "http://127.0.0.1:$WEB_PORT/?user=u1&tenant=a11y" > "$OUT/pages.txt"
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" node "$repo/web/a11y-audit/audit.js" | tee "$OUT/audit.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
echo "--- echter Tastaturablauf + axe je dynamischem Zustand (Datei waehlen, Vorschau, Mapping, Import ausfuehren) ---"
A11Y_WEB_URL="http://127.0.0.1:$WEB_PORT" A11Y_FIXTURE_ZIP="$A11Y_FIXTURE_ZIP" node "$repo/web/a11y-audit/qa08-dms2-import-keyboard.js" | tee "$OUT/keyboard.txt"
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
exit "$rc"
+158
View File
@@ -0,0 +1,158 @@
// QA-08: echte Ablaeufe nur mit der Tastatur (Tab/Shift+Tab/Enter/Space,
// Werte per page.keyboard.type bzw. page.select auf einem per Tab
// fokussierten Element) gegen connect-setup, connect-status und
// datev-export, im selben Browser zusaetzlich axe-core je Zustand (siehe
// qa08-dms2-import-keyboard.js fuer die Begruendung).
// A11Y_FLOW=setup|status|datev A11Y_WEB_URL=http://127.0.0.1:... node qa08-keyboard-flows.js
const fs = require("fs");
const path = require("path");
const nm = process.env.A11Y_NODE_MODULES || "/usr/local/lib/node_modules/pa11y/node_modules";
const puppeteer = require(path.join(nm, "puppeteer-core"));
const axeSource = fs.readFileSync(path.join(nm, "axe-core", "axe.min.js"), "utf8");
const results = [];
const check = (name, ok, detail) => { results.push({ name, ok }); console.log(`${ok ? "ok" : "FEHLER"}\t${name}${detail ? "\t" + detail : ""}`); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function axeCheck(page, stateName) {
await page.evaluate(axeSource);
const res = await page.evaluate(() => window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } }));
check(`axe: ${stateName} (${res.violations.length} Verstoesse)`, res.violations.length === 0,
res.violations.map((v) => `${v.id}(${v.impact}) x${v.nodes.length}`).join(", "));
}
async function tabTo(page, describe, match, max = 30) {
for (let i = 0; i < max; i++) {
await page.keyboard.press("Tab");
const cur = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
return { tag: el.tagName, type: el.getAttribute("type"), id: el.id, text: ((el.getAttribute("aria-label") || "") + " " + (el.textContent || "")).trim().slice(0, 80) };
});
if (cur && match(cur)) return cur;
}
throw new Error(`Element nicht per Tab erreichbar: ${describe}`);
}
async function labelFor(page, labelText) {
return page.evaluate((t) => {
const label = Array.from(document.querySelectorAll("label")).find((l) => l.textContent.includes(t));
return label ? label.getAttribute("for") : null;
}, labelText);
}
const flows = {
// API-01: Connector-Typ waehlen, Instanz-Kennung + API-Schluessel per
// Tastatur eingeben, speichern, maskierte Anzeige in der Liste pruefen.
async setup(page, base) {
await page.goto(`${base}/setup`, { waitUntil: "networkidle0" });
await axeCheck(page, "Formular leer");
const typeId = await labelFor(page, "Connector-Typ");
if (!typeId) throw new Error("Connector-Typ-Feld nicht gefunden");
await tabTo(page, "Connector-Typ-Auswahl", (c) => c.id === typeId);
await page.select("#" + typeId, "erp");
const instanceId = await labelFor(page, "Instanz-Kennung");
await tabTo(page, "Instanz-Kennung-Feld", (c) => c.id === instanceId);
await page.keyboard.type("qa08-tastatur-erp-1");
const apiKeyId = await labelFor(page, "API-Schlüssel");
if (!apiKeyId) throw new Error("API-Schluessel-Feld nicht gefunden");
await tabTo(page, "API-Schluessel-Feld", (c) => c.id === apiKeyId);
const secret = "qa08-tastatur-geheimnis-99887766";
await page.keyboard.type(secret);
await tabTo(page, "Verbindung speichern", (c) => c.tag === "BUTTON" && /verbindung speichern/i.test(c.text));
await page.keyboard.press("Enter");
const listed = await page.waitForFunction(() => document.body.innerText.includes("qa08-tastatur-erp-1"), { timeout: 10000 }).then(() => true, () => false);
check("Verbindung erscheint nach Tastatur-Speichern in der Liste", listed);
const bodyHTML = await page.content();
check("Geheimnis erscheint nicht im Klartext im DOM", !bodyHTML.includes(secret));
const maskedText = await page.evaluate(() => {
const cell = document.querySelector('[data-masked="true"]');
return cell ? cell.textContent : null;
});
check("Zugangsdaten sind maskiert dargestellt", !!maskedText && maskedText.startsWith("••••") && maskedText !== secret, maskedText);
await axeCheck(page, "Nach Speichern (Liste sichtbar)");
},
// API-02: einzige interaktive Steuerung (Aktualisieren) per Tastatur
// ausloesen, Tabelle bleibt mit den geseedeten Laeufen bestehen.
async status(page, base) {
await page.goto(`${base}/status`, { waitUntil: "networkidle0" });
await axeCheck(page, "Statusuebersicht");
const before = await page.evaluate(() => document.body.innerText.includes("acme-erp-1") || document.querySelectorAll("table tbody tr").length > 0);
check("Statustabelle zeigt reale Zeilen vor dem Aktualisieren", before);
await tabTo(page, "Aktualisieren", (c) => c.tag === "BUTTON" && /aktualisieren/i.test(c.text));
await page.keyboard.press("Enter");
await sleep(1000);
const after = await page.evaluate(() => document.querySelectorAll("table tbody tr").length > 0);
check("Statustabelle nach Tastatur-Aktualisieren weiterhin vorhanden", after);
},
// DAT-03: vollstaendiges Exportformular per Tastatur ausfuellen und
// absenden, neuer Lauf erscheint im Protokoll.
async datev(page, base) {
await page.goto(`${base}/exports`, { waitUntil: "networkidle0" });
await axeCheck(page, "Formular + Protokoll (Ausgangszustand)");
const before = await page.evaluate(() => document.querySelectorAll("table tbody tr").length);
// Datumsfelder (type="date", en-US-Segmentreihenfolge des Testhosts):
// Ziffern ohne Trenner, MMTTJJJJ, der Browser springt automatisch zum
// naechsten Segment (siehe Sondierung vor diesem Lauf).
const fields = [
["Beraternummer", "9999"],
["Mandantennummer", "77"],
["Wirtschaftsjahresbeginn", "01012026"],
["Sachkontenlänge", "4"],
["Bezeichnung", "qa08-tastatur-export"],
["Zeitraum von", "03012026"],
["Zeitraum bis", "03312026"],
];
for (const [label, value] of fields) {
const id = await labelFor(page, label);
if (!id) throw new Error(`Feld nicht gefunden: ${label}`);
const el = await tabTo(page, label, (c) => c.id === id).catch(() => null);
check(`Feld '${label}' per Tab erreichbar`, !!el);
if (!el) continue;
await page.keyboard.type(String(value));
}
await tabTo(page, "Export anstoßen (Submit)", (c) => c.tag === "BUTTON" && /anstoßen/i.test(c.text));
await page.keyboard.press("Enter");
const grew = await page.waitForFunction((n) => document.querySelectorAll("table tbody tr").length > n, { timeout: 10000 }, before).then(() => true, () => false);
check("Neuer Lauf erscheint nach Tastatur-Absenden im Protokoll", grew);
await axeCheck(page, "Nach Tastatur-Export (Protokoll aktualisiert)");
},
};
(async () => {
const flowName = process.env.A11Y_FLOW;
const base = process.env.A11Y_WEB_URL;
if (!flowName || !flows[flowName]) throw new Error(`A11Y_FLOW fehlt oder unbekannt (erwartet: ${Object.keys(flows).join(", ")})`);
if (!base) throw new Error("A11Y_WEB_URL fehlt");
const browser = await puppeteer.launch({ executablePath: process.env.A11Y_CHROME, headless: "new", args: ["--no-sandbox", "--disable-dev-shm-usage"] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await page.evaluateOnNewDocument(() => { window.__mouse = 0; for (const t of ["mousedown", "mouseup", "click"]) window.addEventListener(t, (e) => { if (e.isTrusted && e.detail > 0) window.__mouse++; }, true); });
if (process.env.A11Y_DEBUG) {
page.on("console", (m) => console.log("CONSOLE:", m.type(), m.text()));
page.on("response", (r) => { if (!r.ok()) console.log("BADRESP:", r.status(), r.url()); });
}
try {
await flows[flowName](page, base);
const mouse = await page.evaluate(() => window.__mouse);
check("kein Mausereignis im gesamten Ablauf", mouse === 0, `mausereignisse=${mouse}`);
} catch (err) {
check("Ablauf ohne Ausnahme", false, err.stack || String(err));
} finally {
await browser.close();
}
process.exit(results.every((r) => r.ok) ? 0 : 1);
})();