From c4bb5457b728de56f6997fbda84bc9adfc38ebd0 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 16 Oct 2026 07:17:56 +0000 Subject: [PATCH 1/3] QA-08: barrierefreiheits-audit Zwei echte a11y-Befunde aus dem Audit von web/mail-postfach behoben: Ordnerbaum-Treeitems waren per Tastatur erreichbar, aber ohne onKeyDown-Handler nicht per Enter/Leertaste aktivierbar; die scrollbare Nachrichtenliste hatte keinen tabIndex (axe: scrollable-region-focusable, serious). Co-Authored-By: Claude Sonnet 5 --- .../components/FolderTree.test.tsx | 18 +++++++++++++- web/mail-postfach/components/FolderTree.tsx | 24 +++++++++++++++---- .../components/MessageList.test.tsx | 7 ++++++ web/mail-postfach/components/MessageList.tsx | 1 + 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/web/mail-postfach/components/FolderTree.test.tsx b/web/mail-postfach/components/FolderTree.test.tsx index eb8aeb78..8bffb5f3 100644 --- a/web/mail-postfach/components/FolderTree.test.tsx +++ b/web/mail-postfach/components/FolderTree.test.tsx @@ -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"); + }); }); diff --git a/web/mail-postfach/components/FolderTree.tsx b/web/mail-postfach/components/FolderTree.tsx index 4015434f..363e5aaf 100644 --- a/web/mail-postfach/components/FolderTree.tsx +++ b/web/mail-postfach/components/FolderTree.tsx @@ -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 (
onSelect(null)} + onKeyDown={onActivateKey(() => onSelect(null))} > {t("mail.postfach.title", "Postfach")}
@@ -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} diff --git a/web/mail-postfach/components/MessageList.test.tsx b/web/mail-postfach/components/MessageList.test.tsx index 895fe095..1dfdb438 100644 --- a/web/mail-postfach/components/MessageList.test.tsx +++ b/web/mail-postfach/components/MessageList.test.tsx @@ -51,4 +51,11 @@ describe("MessageList (Akzeptanzkriterium 2: Sortierung, Akzeptanzkriterium 3: P const options = screen.getAllByRole("option"); expect(options.length).toBeLessThan(50); }); + + // 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"); + }); }); diff --git a/web/mail-postfach/components/MessageList.tsx b/web/mail-postfach/components/MessageList.tsx index 683fe527..f26749a1 100644 --- a/web/mail-postfach/components/MessageList.tsx +++ b/web/mail-postfach/components/MessageList.tsx @@ -53,6 +53,7 @@ export function MessageList({ messages, sortColumn, sortOrder, onSortChange }: M 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)} > From b1809a35205d425c5b2fc4c3e10a32ecd95325de Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 16 Oct 2026 07:18:05 +0000 Subject: [PATCH 2/3] QA-08: barrierefreiheits-audit Audit-Infrastruktur fuer web/mail-postfach nach dem Muster von web/a11y-audit (mail-search-run.sh/connect-status-run.sh): neue Testfixture mit echtem mailapi.Server + Platzhalter fuer den Core-Modul-Token-Endpunkt (kein echter NEXARCH-Core auf dem Testhost), Runner-Skript, echter Tastaturfluss im Browser nach dem keyboard-flow-check.mjs-Muster aus UX-08. Co-Authored-By: Claude Sonnet 5 --- mail/cmd/mailpostfach-a11y-fixture/main.go | 242 +++++++++++++++++++++ web/a11y-audit/mail-postfach-run.sh | 65 ++++++ web/mail-postfach/keyboard-flow-check.mjs | 122 +++++++++++ 3 files changed, 429 insertions(+) create mode 100644 mail/cmd/mailpostfach-a11y-fixture/main.go create mode 100755 web/a11y-audit/mail-postfach-run.sh create mode 100644 web/mail-postfach/keyboard-flow-check.mjs diff --git a/mail/cmd/mailpostfach-a11y-fixture/main.go b/mail/cmd/mailpostfach-a11y-fixture/main.go new file mode 100644 index 00000000..67fd7628 --- /dev/null +++ b/mail/cmd/mailpostfach-a11y-fixture/main.go @@ -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) +} diff --git a/web/a11y-audit/mail-postfach-run.sh b/web/a11y-audit/mail-postfach-run.sh new file mode 100755 index 00000000..93496a5e --- /dev/null +++ b/web/a11y-audit/mail-postfach-run.sh @@ -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 "$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 diff --git a/web/mail-postfach/keyboard-flow-check.mjs b/web/mail-postfach/keyboard-flow-check.mjs new file mode 100644 index 00000000..f44154c7 --- /dev/null +++ b/web/mail-postfach/keyboard-flow-check.mjs @@ -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); +}); From af33c5337bd11e6f7c3e61e6db92ae39272101c3 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 16 Oct 2026 07:18:10 +0000 Subject: [PATCH 3/3] QA-08: barrierefreiheits-audit Pruefprotokoll mit Vorher/Nachher-Vergleich, Basis-Entscheidung (Branch feature/ux-01-postfach-ordneransicht statt main), und priorisierten offenen Punkten. Co-Authored-By: Claude Sonnet 5 --- mail/docs/QA-08-PRUEFPROTOKOLL.md | 99 +++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 mail/docs/QA-08-PRUEFPROTOKOLL.md diff --git a/mail/docs/QA-08-PRUEFPROTOKOLL.md b/mail/docs/QA-08-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b2337dcd --- /dev/null +++ b/mail/docs/QA-08-PRUEFPROTOKOLL.md @@ -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 modul­spezifische 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: `
` — 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 `
` löst bei Tastaturaktivierung (Enter/Leertaste) anders als `