Merge PR #304: QA-08: Barrierefreiheits-Audit (Mail, stackt auf PR #298 UX-01)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Konflikt in MessageList.test.tsx: beide Seiten fuegten Tests am Dateiende an (UX-04 onSelect/Klick/Tastatur-Navigation, QA-08 tabIndex-Fix fuer den scrollbaren Listbox-Container), additiv zusammengefuehrt. npx vitest run: 59/59 Tests gruen (web/mail-postfach), auf Testhost 131 verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
479c99cbf1
8 files changed
+572
-6
No files matched your search
@@ -0,0 +1,242 @@
|
||||
// QA-08: startet den ECHTEN mailapi.Server (reale Routen, reale
|
||||
// moduleauth-Tokenprüfung) mit synthetischen Postfachdaten für den
|
||||
// Barrierefreiheits-Audit von web/mail-postfach. Eigenständig von
|
||||
// mailpostfach-fixture (UX-01, Mandantentrennungs-Fokus), da der
|
||||
// Browser-Audit zusätzlich CORS-Header und einen Platzhalter für den
|
||||
// Core-Modul-Token-Endpunkt braucht (kein echter NEXARCH-Core auf dem
|
||||
// Testhost verfügbar) — kein Produktivpfad.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
|
||||
)
|
||||
|
||||
const tenant = "a11y-tenant"
|
||||
|
||||
type memorySearch struct {
|
||||
docs map[string]search.Document
|
||||
}
|
||||
|
||||
func newMemorySearch() *memorySearch {
|
||||
return &memorySearch{docs: map[string]search.Document{}}
|
||||
}
|
||||
|
||||
func (m *memorySearch) put(doc search.Document) {
|
||||
m.docs[doc.MessageID] = doc
|
||||
}
|
||||
|
||||
func (m *memorySearch) Search(_ context.Context, tenantSlug, queryText string) ([]search.Result, error) {
|
||||
if tenantSlug != tenant {
|
||||
return nil, nil
|
||||
}
|
||||
var results []search.Result
|
||||
for _, doc := range m.docs {
|
||||
results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch, Sender: doc.Sender, Mailbox: doc.Mailbox})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *memorySearch) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []search.FacetFilter) ([]search.Result, error) {
|
||||
results, err := m.Search(ctx, tenantSlug, queryText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make([]search.Result, 0, len(results))
|
||||
for _, res := range results {
|
||||
match := true
|
||||
for _, f := range filters {
|
||||
if f.Field == search.FacetFieldMailbox && res.Mailbox != f.Value {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
if match {
|
||||
filtered = append(filtered, res)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *memorySearch) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
|
||||
if tenantSlug != tenant {
|
||||
return search.Document{}, false, nil
|
||||
}
|
||||
doc, ok := m.docs[messageID]
|
||||
return doc, ok, nil
|
||||
}
|
||||
|
||||
func (m *memorySearch) ListByMailbox(_ context.Context, tenantSlug, mailbox string) ([]search.Document, error) {
|
||||
if tenantSlug != tenant {
|
||||
return nil, nil
|
||||
}
|
||||
var docs []search.Document
|
||||
for _, doc := range m.docs {
|
||||
if mailbox != "" && doc.Mailbox != mailbox {
|
||||
continue
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func (m *memorySearch) Facets(_ context.Context, tenantSlug, _ string, _ []search.FacetFilter) (search.FacetResult, error) {
|
||||
if tenantSlug != tenant {
|
||||
return search.FacetResult{}, nil
|
||||
}
|
||||
counts := map[string]int64{}
|
||||
for _, doc := range m.docs {
|
||||
if doc.Mailbox != "" {
|
||||
counts[doc.Mailbox]++
|
||||
}
|
||||
}
|
||||
values := make([]search.FacetValue, 0, len(counts))
|
||||
for mailbox, count := range counts {
|
||||
values = append(values, search.FacetValue{Value: mailbox, Count: count})
|
||||
}
|
||||
return search.FacetResult{Values: map[string][]search.FacetValue{string(search.FacetFieldMailbox): values}}, nil
|
||||
}
|
||||
|
||||
type noopUsageReporter struct{}
|
||||
|
||||
func (noopUsageReporter) Report(context.Context, string, string, int64) error { return nil }
|
||||
|
||||
type fixedStorageProvider struct {
|
||||
service *storage.Service
|
||||
}
|
||||
|
||||
func (p fixedStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) {
|
||||
if tenantSlug != tenant {
|
||||
return nil, fmt.Errorf("a11y-fixture: unbekannter mandant %q", tenantSlug)
|
||||
}
|
||||
return p.service, nil
|
||||
}
|
||||
|
||||
// seed deckt die Fälle ab, die die Postfach-Oberfläche real zeigen muss:
|
||||
// einen verschachtelten Ordner (Ordnerbaum-Auf-/Zuklappen), einen leeren
|
||||
// Ordner (Leerzustand) und genug Nachrichten, um Sortierung sichtbar zu
|
||||
// machen.
|
||||
func seed(sc *memorySearch) {
|
||||
sc.put(search.Document{MessageID: "msg-rechnung-1", Subject: "Rechnung September 2026", Sender: "buchhaltung@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_300})
|
||||
sc.put(search.Document{MessageID: "msg-rechnung-2", Subject: "Rechnung Oktober 2026", Sender: "zentrale@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_400})
|
||||
sc.put(search.Document{MessageID: "msg-vertrag-1", Subject: "Vertragsunterlagen zur Unterschrift", Sender: "vertrieb@beispiel.test", Mailbox: "archiv", SentAtUnixEpoch: 1_700_000_200})
|
||||
sc.put(search.Document{MessageID: "msg-projekt-1", Subject: "Projektplan Alpha", Sender: "pm@beispiel.test", Mailbox: "projekte/alpha", SentAtUnixEpoch: 1_700_000_100})
|
||||
sc.put(search.Document{MessageID: "msg-projekt-2", Subject: "Statusbericht Beta", Sender: "pm@beispiel.test", Mailbox: "projekte/beta", SentAtUnixEpoch: 1_700_000_150})
|
||||
|
||||
// Genug Nachrichten, damit die Nachrichtenliste tatsaechlich ueberlaeuft
|
||||
// (Grundlage fuer die Tastatur-Scroll-Pruefung, nicht nur Tab-Erreichbarkeit).
|
||||
for i := 0; i < 20; i++ {
|
||||
sc.put(search.Document{
|
||||
MessageID: fmt.Sprintf("msg-liste-%d", i),
|
||||
Subject: fmt.Sprintf("Nachricht %d", i),
|
||||
Sender: fmt.Sprintf("absender%d@beispiel.test", i%5),
|
||||
Mailbox: "posteingang",
|
||||
SentAtUnixEpoch: 1_700_001_000 + int64(i),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newStorageService() (*storage.Service, error) {
|
||||
dir, err := os.MkdirTemp("", "qa08-a11y-fixture-storage-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.NewService(storage.NewLocalDriver(dir), noopUsageReporter{}, tenant), nil
|
||||
}
|
||||
|
||||
// corsForAudit reflektiert den anfragenden Origin mit Zugangsdaten-Flag,
|
||||
// da der Browser-Audit das Frontend und diese Fixture-API auf
|
||||
// unterschiedlichen Ports (= unterschiedlichen Origins) betreibt. Nur für
|
||||
// diesen Testaufbau nötig, der echte NEXARCH-Core läuft same-origin
|
||||
// hinter einem Reverse-Proxy.
|
||||
func corsForAudit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := newMemorySearch()
|
||||
seed(sc)
|
||||
|
||||
svc, err := newStorageService()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "a11y-fixture: storage:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sp := fixedStorageProvider{service: svc}
|
||||
|
||||
srv := mailapi.NewServer(sc, sp)
|
||||
mux := http.NewServeMux()
|
||||
verifier, issuer := moduleauth.NewTestVerifierAndIssuer()
|
||||
srv.RegisterRoutes(mux, verifier)
|
||||
|
||||
token := issuer.Token("a11y-user", tenant, "user", "mail", "")
|
||||
|
||||
// Platzhalter für den Core-Modul-Token-Endpunkt: web/mail-postfach holt
|
||||
// sich darüber ein Bearer-Token (lib/http.ts, @nexarch/shl createSession).
|
||||
// Kein echter NEXARCH-Core im Testaufbau, daher liefert dieser Stub das
|
||||
// bereits ausgestellte, echt signierte Token direkt.
|
||||
mux.HandleFunc("/auth/module-token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"token": token,
|
||||
"expires_in": 3600,
|
||||
"tenant": tenant,
|
||||
"role": "user",
|
||||
})
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "a11y-fixture: listener:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
fmt.Println(mustJSON(map[string]any{
|
||||
"port": port,
|
||||
"tenant": tenant,
|
||||
}))
|
||||
|
||||
httpSrv := &http.Server{Handler: corsForAudit(mux)}
|
||||
go func() {
|
||||
if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Fprintln(os.Stderr, "a11y-fixture: serve:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
_ = httpSrv.Close()
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# QA-08 – Prüfprotokoll: Barrierefreiheits-Audit der Postfach-Oberfläche
|
||||
|
||||
Stand: 2026-10-16. Entwicklungshost 192.168.1.204, Google Chrome (Puppeteer), axe-core aus dem bestehenden pa11y-Bestand. Branch `feature/qa-08-barrierefreiheits-audit`.
|
||||
|
||||
## Basis dieses Audits
|
||||
|
||||
`UX-01` (Postfach-/Ordneransicht, `web/mail-postfach`) ist zum Zeitpunkt dieses Audits **nicht** auf `main` gemergt, sondern liegt nur auf dem offenen, ungemergten Gitea-Remote-Branch `feature/ux-01-postfach-ordneransicht`. Da dieses Ticket explizit "der Postfach-Oberfläche" gilt, wurde direkt auf diesem Branch aufgebaut: `git fetch origin feature/ux-01-postfach-ordneransicht` + `git checkout -b feature/qa-08-barrierefreiheits-audit origin/feature/ux-01-postfach-ordneransicht`. Alle hier beschriebenen Funde und Fixes gelten für den UX-01-Code-Stand dieses Branches; sie müssen beim Mergen von UX-01 nach `main` mitgenommen werden (siehe "Offene Punkte" unten).
|
||||
|
||||
Laut `mail/docs/UX-01-PRUEFPROTOKOLL.md` war Barrierefreiheit in UX-01 bewusst nur als Grundstruktur vorbereitet ("ARIA-Rollen (`tree`, `treeitem`, `listbox`, `option`) vorbereitet, kein vollständiger Audit") — dieses Ticket liefert den fehlenden vollständigen Audit nach.
|
||||
|
||||
## Prüfaufbau (wiederverwendet aus `web/mail-search`, UX-07/UX-08)
|
||||
|
||||
Gleiches Werkzeug wie bei allen anderen Modulen dieser Session: `web/a11y-audit/audit.js` (axe WCAG 2.0/2.1 A+AA, Tab-Erreichbarkeit, Fokusindikator-Prüfung, Layout/Kontrast), keine neue Prüflogik. Neu nur die modulspezifische Startinfrastruktur, nach dem Muster von `web/a11y-audit/mail-search-run.sh`:
|
||||
|
||||
- `mail/cmd/mailpostfach-a11y-fixture` (neu, reine Testinfrastruktur, kein Produktivpfad): startet den echten `mailapi.Server` mit echter `moduleauth`-Signaturprüfung auf einem echten TCP-Port, synthetische Postfachdaten eines Mandanten (verschachtelter Ordner `projekte/alpha`/`projekte/beta` für den Ordnerbaum, 25 Nachrichten in `posteingang` damit die Nachrichtenliste tatsächlich überläuft — sonst wäre der unten gefundene Scroll-Bug nicht reproduzierbar). Zusätzlich zu den echten `mailapi`-Routen registriert die Fixture zwei testaufbau-spezifische Hilfen, die ein echter NEXARCH-Core auf dem Testhost ersetzen würde: einen Platzhalter-Endpunkt `/auth/module-token` (liefert das bereits echt signierte Testtoken, das `@nexarch/shl` `createSession` sonst vom Core holen würde — kein echter Core auf dem Testhost verfügbar) und CORS-Header (Frontend und Fixture-API laufen auf unterschiedlichen Ports/Origins, ein echtes Deployment hätte beides hinter demselben Reverse-Proxy).
|
||||
- `web/a11y-audit/mail-postfach-run.sh` (neu, nach dem Muster von `mail-search-run.sh`/`connect-status-run.sh`): baut die Fixture, baut und startet `web/mail-postfach` dagegen, führt `audit.js` und den Tastaturfluss aus.
|
||||
- `web/mail-postfach/keyboard-flow-check.mjs` (neu, nach dem Muster von `web/mail-search/keyboard-flow-check.mjs`, UX-08): echter Browserlauf der Tastaturbedienung. Anders als bei `mail-search` muss hier kein `fetch` gemockt werden — die Fixture liefert bereits echte Daten über eine echte, signierte Autorisierung.
|
||||
|
||||
## Ergebnis auf einen Blick
|
||||
|
||||
| Akzeptanzkriterium | Ergebnis |
|
||||
|---|---|
|
||||
| AK1: automatisierter Audit-Lauf zeigt keine kritischen Verstöße mehr | erfüllt (1 echter Fund, behoben, siehe unten) |
|
||||
| AK2: manuelle Stichprobe bestätigt sinnvolle Tastatur- und Screenreader-Bedienung | Tastatur: erfüllt (echter Browserlauf, 9/9 bestanden). Screenreader: nicht möglich, siehe Einschränkung unten |
|
||||
| AK3: Ergebnis dokumentiert und mit Verantwortlichen für Nachbesserung rückgekoppelt | erfüllt (dieses Protokoll + priorisierte offene Punkte unten) |
|
||||
|
||||
## Vorher/Nachher-Vergleich: automatisierter axe-Lauf
|
||||
|
||||
### Vorher (`OUT=/tmp/qa08-out-before web/a11y-audit/mail-postfach-run.sh`)
|
||||
|
||||
| Schema | axe-Befund |
|
||||
|---|---|
|
||||
| hell | `scrollable-region-focusable` (serious, 1 Knoten) |
|
||||
| dunkel | `scrollable-region-focusable` (serious, 1 Knoten) |
|
||||
|
||||
Exit-Code 1. Betroffener Knoten: `<div role="listbox" aria-label="Nachrichtenliste" style="height: 480px; overflow-y: auto; position: relative;">` — die scrollbare Nachrichtenliste selbst war nicht per Tastatur fokussierbar (kein `tabIndex`), nur ihre virtualisiert gerenderten Einträge. Bei einem Ordner mit mehr Nachrichten, als in den sichtbaren Bereich passen, konnte ein reiner Tastaturnutzer den Inhalt nicht scrollen — weder per Tab (die Einträge selbst sind per Design nicht fokussierbar, siehe `role=option` ohne eigene Aktion) noch per Pfeiltaste (Container nicht fokussierbar). WCAG 2.1.1 (Tastatur), axe-Regel `scrollable-region-focusable` (`wcag2a`).
|
||||
|
||||
### Fix
|
||||
|
||||
`web/mail-postfach/components/MessageList.tsx`: `tabIndex={0}` auf dem Listbox-Container ergänzt. Damit ist der scrollbare Bereich selbst per Tab erreichbar, und Chrome scrollt einen fokussierten, scrollbaren Container nativ per Pfeiltaste/Bild-ab — keine eigene Scroll-Logik nötig.
|
||||
|
||||
### Nachher (`OUT=/tmp/qa08-out-after web/a11y-audit/mail-postfach-run.sh`)
|
||||
|
||||
| Schema | axe-Befund |
|
||||
|---|---|
|
||||
| hell | keine |
|
||||
| dunkel | keine |
|
||||
|
||||
Exit-Code 0. Tab-Erreichbarkeit: 9/9 interaktive Elemente erreicht (vorher 8/8 — der jetzt fokussierbare Listbox-Container zählt als zusätzliches Element), kein fehlender Fokusindikator, keine Tastaturfalle.
|
||||
|
||||
## Zweiter echter Fund (nicht von axe erkannt, über den Tastaturfluss gefunden, behoben)
|
||||
|
||||
`web/mail-postfach/components/FolderTree.tsx`: die Ordnerbaum-Einträge (`role="treeitem"`, `tabIndex={0}`) hatten ausschließlich einen `onClick`-Handler. Ein `<div>` löst bei Tastaturaktivierung (Enter/Leertaste) anders als `<button>`/`<a>` **kein** `click`-Ereignis aus — der Ordnerbaum war also per Tab erreichbar und zeigte einen sichtbaren Fokusring, aber per Tastatur nicht auswählbar. Das ist kein axe-Befund (axe prüft nicht, ob ein Klick-Handler per Tastatur erreichbar ist), sondern wurde erst durch den echten Tastaturfluss (`keyboard-flow-check.mjs`, Prüfung 2 unten) aufgedeckt.
|
||||
|
||||
**Fix:** `onKeyDown`-Handler ergänzt, der bei `Enter` und `" "` dieselbe Aktion wie `onClick` auslöst (`activateNode`/`onSelect`), inkl. `preventDefault()` (sonst scrollt die Leertaste die Seite). Für beide Treeitem-Varianten (Wurzel "Postfach" und Ordnerknoten).
|
||||
|
||||
## Durchgeführte Prüfungen
|
||||
|
||||
### 1. Automatisierter axe-/Layout-/Tab-Audit (`web/a11y-audit/mail-postfach-run.sh`)
|
||||
|
||||
Siehe Vorher/Nachher oben. Geprüft: Postfach-Hauptansicht (einzige Kernansicht dieses Moduls — es gibt noch keine Detail-/Threadansicht, UX-02 ist nicht Teil von UX-01) in hellem und dunklem Farbschema.
|
||||
|
||||
### 2. Echter Tastaturfluss im Browser (`web/mail-postfach/keyboard-flow-check.mjs`)
|
||||
|
||||
Ersatz für Prüfung 2 des Tickets ("Stichprobentest mit Screenreader durch zweite Person") — siehe Einschränkung unten. 9/9 Prüfungen bestanden:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
|---|---|
|
||||
| Ordner "archiv" per Tab erreichbar | OK |
|
||||
| Enter auf Ordner "archiv" wählt ihn aus (Nachrichtenliste filtert auf "Vertragsunterlagen zur Unterschrift") | OK |
|
||||
| `aria-selected` auf "archiv" ist `"true"` nach Enter | OK |
|
||||
| Fokussierter Ordner hat sichtbaren Fokusring (`outlineWidth`) | OK (`3px`) |
|
||||
| Ordner "posteingang" per Tab erreichbar | OK |
|
||||
| Leertaste auf Ordner "posteingang" wählt ihn aus (`aria-selected`) | OK |
|
||||
| Nachrichtenliste (`role=listbox`) per Tab erreichbar | OK |
|
||||
| Pfeiltaste scrollt die fokussierte Nachrichtenliste | OK (`scrollTop` 0 → 30) |
|
||||
| Tab nach der Listbox bewegt den Fokus weiter (keine Tastaturfalle) | OK |
|
||||
|
||||
### 3. Vitest, tsc, go build/vet
|
||||
|
||||
- `web/mail-postfach`: `npx vitest run` — **38/38 Tests bestanden** (vorher 35/35, 3 neue: 2× Tastaturaktivierung `FolderTree.test.tsx`, 1× `tabIndex` auf der Listbox `MessageList.test.tsx`). `npx tsc --noEmit` fehlerfrei.
|
||||
- `mail`-Modul: `go build ./...` und `go vet ./...` fehlerfrei (neue Fixture `mailpostfach-a11y-fixture` baut mit, keine Regression in anderen Paketen).
|
||||
|
||||
## Einschränkung: kein echter Screenreader-Test (Prüfung 2 des Tickets)
|
||||
|
||||
Wie bereits in `dms/docs/UX-06-PRUEFPROTOKOLL.md` und `mail/docs/UX-07-PRUEFPROTOKOLL.md` dokumentiert: auf dem Testhost (192.168.1.204) und der Entwicklungsmaschine ist weder NVDA noch VoiceOver verfügbar, und es gibt keine zweite Person für einen manuellen Stichprobentest. Als bestmögliche reale Annäherung (laut Ticket ausdrücklich vorgesehen) wurde ein echter automatisierter Tastaturfluss im Browser durchgeführt (siehe Prüfung 2 oben) — das deckt die tatsächliche Erreichbarkeit/Aktivierbarkeit der eigenständig gebauten ARIA-Widgets (Ordnerbaum, Nachrichtenliste) ab, nicht aber die konkrete Sprachausgabe eines echten Screenreaders. Die axe-Prüfung deckt die strukturellen Screenreader-Voraussetzungen ab (Rollen, Labels, `aria-selected`/`aria-expanded`-Zustände, Landmarks) — dieselbe organisatorische Lücke wie bei den anderen Modulen dieser Session.
|
||||
|
||||
## Offene Punkte (priorisiert)
|
||||
|
||||
1. **Hoch — Merge-Reihenfolge beachten:** Die beiden Fixes (`FolderTree.tsx`, `MessageList.tsx`) liegen auf einem Branch, der von `feature/ux-01-postfach-ordneransicht` abzweigt, nicht von `main`. Wer UX-01 nach `main` mergt, muss diese Fixes mit übernehmen (z. B. durch Mergen dieses Branches zuerst, oder Cherry-Pick der beiden Commits in den UX-01-Branch) — sonst geht der Audit-Nachweis mit einem Code-Stand ohne die Fixes verloren.
|
||||
2. **Mittel — echter Screenreader-Stichprobentest fehlt weiterhin:** siehe Einschränkung oben. Gehört organisatorisch gelöst (Testhost mit NVDA/VoiceOver oder zweite Person), nicht technisch durch dieses Ticket.
|
||||
3. **Niedrig — kein eigener Tastatur-Navigationscode wie im DMS-Pendant (`web/dms-browser`):** Pfeiltasten zwischen Ordnerknoten (klassisches WAI-ARIA-Treeview-Rovingfokus-Muster) sind nicht umgesetzt — Tab-basierte lineare Navigation ist laut Audit vollständig bedienbar (kein WCAG-Verstoß), eine Rovingfokus-Umsetzung wäre reiner Komfortgewinn und war kein tatsächlicher Fund dieses Audits. Nicht umgesetzt, da außerhalb des Tickets ("kein Umbau angrenzender Bereiche über die tatsächlich gefundenen a11y-Fixes hinaus").
|
||||
4. **Niedrig — UX-02/UX-04/UX-05/UX-06 weiterhin nicht Teil dieses Moduls:** Detail-/Threadansicht, responsives Layout, Dark-Mode-Feinschliff über die vorhandene globale Regel hinaus, Mehrsprachigkeit über Deutsch hinaus — wie in `UX-01-PRUEFPROTOKOLL.md` dokumentiert, nicht Gegenstand dieses Audits.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
- Jegliche UX-01-Funktionsänderung über die beiden a11y-Fixes hinaus.
|
||||
- Eigene Treeview-Rovingfokus-Navigation (siehe offene Punkte, Punkt 3).
|
||||
- Echter Screenreader-Test durch eine zweite Person (siehe Einschränkung oben, organisatorische Lücke, nicht durch dieses Ticket lösbar).
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# QA-08: axe-/Tastatur-Audit von web/mail-postfach gegen den echten
|
||||
# mailapi.Server (mail/cmd/mailpostfach-a11y-fixture, synthetische Daten,
|
||||
# kein Produktivpfad). Nutzt das vorhandene audit.js (axe + Tab-
|
||||
# Erreichbarkeit + Fokusindikator + Layout/Kontrast), keine Neuentwicklung
|
||||
# der Pruefwerkzeuge.
|
||||
set -uo pipefail
|
||||
repo="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUT="${OUT:-/tmp/qa08-out}"; mkdir -p "$OUT"
|
||||
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
|
||||
if [ -z "${A11Y_NODE_MODULES:-}" ]; then
|
||||
A11Y_NODE_MODULES="$(ls -d /usr/local/lib/node_modules/pa11y/node_modules /usr/lib/node_modules/pa11y/node_modules 2>/dev/null | head -1)"
|
||||
[ -n "$A11Y_NODE_MODULES" ] && export A11Y_NODE_MODULES
|
||||
fi
|
||||
|
||||
API_PORT=4310
|
||||
WEB_PORT=4311
|
||||
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/mail" && go build -o "$OUT/mailpostfach-a11y-fixture" ./cmd/mailpostfach-a11y-fixture) || exit 2
|
||||
"$OUT/mailpostfach-a11y-fixture" >"$OUT/fixture-info.json" 2>"$OUT/fixture.log" &
|
||||
for _ in $(seq 20); do [ -s "$OUT/fixture-info.json" ] && break; sleep 0.3; done
|
||||
[ -s "$OUT/fixture-info.json" ] || { echo "fixture startet nicht"; cat "$OUT/fixture.log"; exit 2; }
|
||||
API_PORT="$(python3 -c "import json;print(json.load(open('$OUT/fixture-info.json'))['port'])")"
|
||||
curl -s -o /dev/null "http://127.0.0.1:$API_PORT/auth/module-token" -X POST || { echo "fixture-api antwortet nicht"; cat "$OUT/fixture.log"; exit 2; }
|
||||
|
||||
cd "$repo/web/mail-postfach"
|
||||
[ -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
|
||||
|
||||
export NEXT_PUBLIC_MAIL_API_URL="http://127.0.0.1:$API_PORT/api/v1/mail"
|
||||
export NEXT_PUBLIC_CORE_API_BASE="http://127.0.0.1:$API_PORT"
|
||||
rm -rf .next
|
||||
npx next build >"$OUT/build.log" 2>&1 || { echo "build fehlgeschlagen"; tail -40 "$OUT/build.log"; exit 2; }
|
||||
setsid nohup npx next start -p $WEB_PORT >"$OUT/server.log" 2>&1 </dev/null &
|
||||
for _ in $(seq 30); 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 "server startet nicht"; cat "$OUT/server.log"; exit 2; }
|
||||
|
||||
echo "http://127.0.0.1:$WEB_PORT/" > "$OUT/pages.txt"
|
||||
|
||||
rc=0
|
||||
cd "$repo/web/a11y-audit"
|
||||
A11Y_PAGES="$OUT/pages.txt" A11Y_OUT="$OUT/report.json" A11Y_SCHEMES=light,dark node audit.js | tee "$OUT/audit.txt"
|
||||
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
|
||||
echo "audit report: $OUT/report.json"
|
||||
|
||||
# Pruefung 2 (Ticket): echter Tastaturfluss im Browser, da weder
|
||||
# Screenreader-Software noch eine zweite Person auf dem Testhost verfuegbar
|
||||
# sind (siehe Pruefprotokoll) -- bestmoegliche reale Annaeherung, UX-08-Muster.
|
||||
PUPPETEER_MODULE_DIR="${A11Y_NODE_MODULES%/}/puppeteer" KEYBOARD_CHECK_BASE_URL="http://127.0.0.1:$WEB_PORT" node "$repo/web/mail-postfach/keyboard-flow-check.mjs" | tee "$OUT/keyboard.txt"
|
||||
[ "${PIPESTATUS[0]}" -eq 0 ] || rc=1
|
||||
|
||||
exit $rc
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { I18nProvider } from "@nexarch/shl";
|
||||
import { FolderTree } from "./FolderTree";
|
||||
|
||||
@@ -33,4 +33,20 @@ describe("FolderTree (Akzeptanzkriterium 1: Ordnerbaum)", () => {
|
||||
const item = screen.getByText("archiv (1)");
|
||||
expect(item.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
// QA-08: div[role=treeitem] loest Enter/Space nicht wie ein nativer Button aus -- ohne
|
||||
// eigenen Tastatur-Handler war der Ordnerbaum per Tab erreichbar, aber nicht aktivierbar.
|
||||
it("aktiviert die Ordnerauswahl über die Enter-Taste (nicht nur per Klick)", () => {
|
||||
const onSelect = vi.fn();
|
||||
ui([{ name: "archiv", messageCount: 1 }], null, onSelect);
|
||||
fireEvent.keyDown(screen.getByText("archiv (1)"), { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith("archiv");
|
||||
});
|
||||
|
||||
it("aktiviert die Ordnerauswahl über die Leertaste", () => {
|
||||
const onSelect = vi.fn();
|
||||
ui([{ name: "archiv", messageCount: 1 }], null, onSelect);
|
||||
fireEvent.keyDown(screen.getByText("archiv (1)"), { key: " " });
|
||||
expect(onSelect).toHaveBeenCalledWith("archiv");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
// angemeldeten Mandanten. Die komplette (mandantengebundene) Liste kommt
|
||||
// in einem Aufruf (keine Lazy-Load-Ebenen wie im DMS-Pendant nötig — die
|
||||
// Mail-API liefert ohnehin die gesamte, bereits kleine Facettenliste).
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { useI18n } from "@nexarch/shl";
|
||||
import "../lib/messages";
|
||||
import type { MailboxDTO } from "../lib/api";
|
||||
@@ -31,6 +31,21 @@ export function FolderTree({ mailboxes, selected, onSelect }: FolderTreeProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function activateNode(node: FolderNode) {
|
||||
if (node.children.length > 0) toggle(node);
|
||||
if (node.messageCount !== null) onSelect(node.name);
|
||||
}
|
||||
|
||||
// div[role=treeitem] loest Enter/Space nicht wie ein nativer Button aus.
|
||||
function onActivateKey(handler: () => void) {
|
||||
return (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="tree" aria-label={t("mail.postfach.folderTreeLabel", "Ordnerbaum")}>
|
||||
<div
|
||||
@@ -39,6 +54,7 @@ export function FolderTree({ mailboxes, selected, onSelect }: FolderTreeProps) {
|
||||
tabIndex={0}
|
||||
style={{ fontWeight: selected === null ? 600 : 400, cursor: "pointer", padding: "4px 0" }}
|
||||
onClick={() => onSelect(null)}
|
||||
onKeyDown={onActivateKey(() => onSelect(null))}
|
||||
>
|
||||
{t("mail.postfach.title", "Postfach")}
|
||||
</div>
|
||||
@@ -55,10 +71,8 @@ export function FolderTree({ mailboxes, selected, onSelect }: FolderTreeProps) {
|
||||
cursor: node.messageCount !== null ? "pointer" : "default",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (node.children.length > 0) toggle(node);
|
||||
if (node.messageCount !== null) onSelect(node.name);
|
||||
}}
|
||||
onClick={() => activateNode(node)}
|
||||
onKeyDown={onActivateKey(() => activateNode(node))}
|
||||
>
|
||||
{node.children.length > 0 ? (expanded.has(node.name) ? "▾ " : "▸ ") : ""}
|
||||
{node.label}
|
||||
|
||||
@@ -81,4 +81,11 @@ describe("MessageList (Akzeptanzkriterium 2: Sortierung, Akzeptanzkriterium 3: P
|
||||
expect(selected.getAttribute("aria-selected")).toBe("true");
|
||||
expect(unselected.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
// QA-08: ohne tabIndex war der scrollbare Listbox-Container selbst nicht per
|
||||
// Tastatur erreichbar (axe: scrollable-region-focusable, serious).
|
||||
it("macht den scrollbaren Listenbereich per Tastatur fokussierbar", () => {
|
||||
ui(messages);
|
||||
expect(screen.getByRole("listbox").getAttribute("tabIndex")).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ export function MessageList({ messages, sortColumn, sortOrder, onSortChange, onS
|
||||
ref={containerRef}
|
||||
role="listbox"
|
||||
aria-label={t("mail.postfach.messageListLabel", "Nachrichtenliste")}
|
||||
tabIndex={0}
|
||||
style={{ height: CONTAINER_HEIGHT, overflowY: "auto", position: "relative" }}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// QA-08: echter Browserlauf der Tastatur-Bedienung gegen die echte
|
||||
// mailpostfach-a11y-fixture (reale mailapi/moduleauth, synthetische
|
||||
// Daten) — kein gemocktes Backend nötig, da die Fixture bereits real
|
||||
// läuft. Gleiches Muster wie web/mail-search/keyboard-flow-check.mjs
|
||||
// (UX-08): direkter Import aus dem bereits verifizierten pa11y-Bestand,
|
||||
// da NODE_PATH bei ESM-Imports nicht wirkt.
|
||||
const puppeteerModuleDir = process.env.PUPPETEER_MODULE_DIR ?? "/usr/lib/node_modules/pa11y/node_modules/puppeteer";
|
||||
const { default: puppeteer } = await import(`${puppeteerModuleDir}/lib/puppeteer/puppeteer.js`);
|
||||
|
||||
const baseUrl = process.env.KEYBOARD_CHECK_BASE_URL ?? "http://127.0.0.1:4311";
|
||||
|
||||
async function main() {
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: process.env.A11Y_CHROME,
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
const results = [];
|
||||
function check(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? "OK " : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
}
|
||||
|
||||
await page.goto(baseUrl, { waitUntil: "networkidle0" });
|
||||
await page.waitForSelector("[role='treeitem']");
|
||||
await page.waitForFunction(() => document.querySelectorAll("[role='treeitem']").length > 1);
|
||||
|
||||
async function tabTo(matcher, maxSteps = 20) {
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const matched = await page.evaluate((m) => {
|
||||
const el = document.activeElement;
|
||||
if (!el || el.getAttribute("role") !== m.role) return false;
|
||||
const text = el.textContent ?? "";
|
||||
if (m.includes && !text.includes(m.includes)) return false;
|
||||
if (m.startsWith && !text.startsWith(m.startsWith)) return false;
|
||||
return true;
|
||||
}, matcher);
|
||||
if (matched) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
|
||||
// 1. Der Ordnerbaum ist per Tab erreichbar und per Enter aktivierbar
|
||||
// (vorher: role=treeitem mit tabIndex=0, aber ohne Tastatur-Handler —
|
||||
// per Klick erreichbar, per Tastatur nicht aktivierbar).
|
||||
const reachedArchiv = await tabTo({ role: "treeitem", includes: "archiv" });
|
||||
check("Ordner 'archiv' per Tab erreichbar", reachedArchiv);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(
|
||||
() => document.body.textContent?.includes("Vertragsunterlagen zur Unterschrift"),
|
||||
{ timeout: 3000 }
|
||||
).then(() => check("Enter auf Ordner 'archiv' wählt ihn aus (Nachrichtenliste filtert)", true))
|
||||
.catch(() => check("Enter auf Ordner 'archiv' wählt ihn aus (Nachrichtenliste filtert)", false));
|
||||
|
||||
const archivSelected = await page.evaluate(() => {
|
||||
const items = Array.from(document.querySelectorAll("[role='treeitem']"));
|
||||
const archiv = items.find((el) => el.textContent?.includes("archiv"));
|
||||
return archiv?.getAttribute("aria-selected");
|
||||
});
|
||||
check("aria-selected auf 'archiv' ist 'true' nach Enter", archivSelected === "true", archivSelected);
|
||||
|
||||
// 2. Sichtbarer Fokus auf dem aktivierten Ordner.
|
||||
const outlineWidth = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el ? getComputedStyle(el).outlineWidth : null;
|
||||
});
|
||||
check("fokussierter Ordner hat sichtbaren Fokusring", outlineWidth !== "0px" && outlineWidth !== null, outlineWidth);
|
||||
|
||||
// 3. Die Leertaste aktiviert einen anderen Ordner ebenso (nicht nur Enter).
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const reachedPosteingang = await tabTo({ role: "treeitem", startsWith: "posteingang" });
|
||||
check("Ordner 'posteingang' per Tab erreichbar", reachedPosteingang);
|
||||
await page.keyboard.press(" ");
|
||||
const posteingangSelected = await page.evaluate(() => {
|
||||
const items = Array.from(document.querySelectorAll("[role='treeitem']"));
|
||||
const el = items.find((n) => n.textContent?.startsWith("posteingang"));
|
||||
return el?.getAttribute("aria-selected");
|
||||
});
|
||||
check("Leertaste auf Ordner 'posteingang' wählt ihn aus (aria-selected)", posteingangSelected === "true", posteingangSelected);
|
||||
|
||||
// 4. Der scrollbare Nachrichtenbereich ist selbst per Tastatur
|
||||
// erreichbar und per Pfeiltaste bedienbar (vorher: axe-Befund
|
||||
// scrollable-region-focusable, da kein tabIndex auf dem Container).
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const reachedListbox = await tabTo({ role: "listbox" });
|
||||
check("Nachrichtenliste (role=listbox) per Tab erreichbar", reachedListbox);
|
||||
|
||||
const scrollBefore = await page.evaluate(() => document.activeElement?.scrollTop ?? null);
|
||||
for (let i = 0; i < 10; i++) await page.keyboard.press("ArrowDown");
|
||||
const scrollAfter = await page.evaluate(() => document.activeElement?.scrollTop ?? null);
|
||||
check(
|
||||
"Pfeiltaste scrollt die fokussierte Nachrichtenliste",
|
||||
scrollBefore !== null && scrollAfter !== null && scrollAfter > scrollBefore,
|
||||
`${scrollBefore} -> ${scrollAfter}`
|
||||
);
|
||||
|
||||
// 5. Kein Tastaturfang: nach allen Schritten ist weiterhin ein Tab
|
||||
// möglich und bewegt den Fokus.
|
||||
const beforeTrapCheck = await page.evaluate(() => document.activeElement?.tagName);
|
||||
await page.keyboard.press("Tab");
|
||||
const afterTrapCheck = await page.evaluate(() => document.activeElement?.tagName);
|
||||
check("Tab nach der Listbox bewegt den Fokus weiter (keine Tastaturfalle)", !!afterTrapCheck, `${beforeTrapCheck} -> ${afterTrapCheck}`);
|
||||
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} Prüfungen bestanden`);
|
||||
if (failed.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user