feat(quick-260914-m97): Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto vor dem Dialog (html-to-image 1.11.13), Fehlerpuffer, Dialog mit Vorschau, i18n bugReport

- Knopf (Kaefer-Symbol) unmittelbar vor dem Erscheinungsbild-Schalter; captureScreenshot() laeuft VOR dem Oeffnen, Test pinnt es im toPng-Mock
- computeCaptureSize (laengste Kante 1600 px, reine Funktion, getestet), dataUrlToBlob, sendBugReport als Multipart mit credentials und ohne Content-Type-Header
- error-buffer: Ringpuffer 20, window error/unhandledrejection, console.error (Original bleibt), fetch-Wrapper nur bei !ok ohne Anfrage-Rumpf/Suchteil/Kopfzeilen (T-M97-02), idempotent, SSR-sicher; installiert in app-shell
- Dialog mit Vorschau, Haekchen, Beschreibung, Meldungen je Status 409/413/429/502/allgemein, Admin-Link auf /admin/smtp
- i18n bugReport de/en, Allowlist um "passiert"; html-to-image exakt 1.11.13, Lockfile aktualisiert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:50:01 +02:00
parent 54121c1721
commit 60b0ee8409
13 changed files with 968 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
/**
* bug-report-api — Bildaufnahme und Versand des Fehler-melden-Knopfs
* (quick-260914-m97).
*
* `captureScreenshot` rastert `document.body` ueber `html-to-image`
* (SVG `foreignObject`, der Browser zeichnet selbst — deshalb stimmen die
* OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert). Die
* Bibliothek wird erst beim Klick dynamisch geladen. Die laengste Kante
* ist auf 1600 px begrenzt (`computeCaptureSize`), `pixelRatio: 1`, keine
* Schrift-Einbettung (`skipFonts` — das Projekt hat keinen Webfont).
*
* `sendBugReport` schickt `multipart/form-data` an `POST /bug-reports`
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
* Multipart-Grenze setzt der Browser selbst.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Zielmass fuer die Aufnahme: laengste Kante hoechstens `maxEdge`,
* Seitenverhaeltnis bleibt, nie kleiner als 1x1 (kein 0-Canvas).
* Reine Funktion, direkt getestet.
*/
export function computeCaptureSize(
width: number,
height: number,
maxEdge = 1600,
): { width: number; height: number } {
const w = Math.max(1, Math.round(width || 0));
const h = Math.max(1, Math.round(height || 0));
const scale = Math.min(1, maxEdge / Math.max(w, h));
return {
width: Math.max(1, Math.round(w * scale)),
height: Math.max(1, Math.round(h * scale)),
};
}
/**
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
*/
export async function captureScreenshot(): Promise<string | null> {
try {
const { toPng } = await import('html-to-image');
const node = document.body;
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
return await toPng(node, {
pixelRatio: 1,
skipFonts: true,
cacheBust: true,
canvasWidth: size.width,
canvasHeight: size.height,
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
});
} catch {
return null;
}
}
/** Base64-Teil einer Data-URL -> PNG-Blob (per `atob`, kein fetch). */
export function dataUrlToBlob(dataUrl: string): Blob {
const comma = dataUrl.indexOf(',');
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: 'image/png' });
}
export interface BugReportPayload {
description: string;
page: string;
webVersion: string;
webChannel: string;
webCommit: string;
userAgent: string;
viewport: string;
clientTime: string;
errors: string[];
screenshot: Blob | null;
}
export type BugReportResult = { ok: true } | { ok: false; status: number };
export async function sendBugReport(p: BugReportPayload): Promise<BugReportResult> {
const body = new FormData();
body.append('description', p.description);
body.append('page', p.page);
body.append('webVersion', p.webVersion);
body.append('webChannel', p.webChannel);
body.append('webCommit', p.webCommit);
body.append('userAgent', p.userAgent);
body.append('viewport', p.viewport);
body.append('clientTime', p.clientTime);
for (const line of p.errors) body.append('errors', line);
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
try {
const res = await fetch(`${API_URL}/bug-reports`, {
method: 'POST',
credentials: 'include',
body,
});
return res.ok ? { ok: true } : { ok: false, status: res.status };
} catch {
return { ok: false, status: 0 };
}
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
clearErrorBuffer,
formatErrorsForReport,
getRecentErrors,
installErrorBuffer,
recordError,
uninstallErrorBuffer,
} from './error-buffer';
/**
* error-buffer.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Vier Tests fuer den Browser-Fehlerpuffer, darunter die Sicherheitsregel
* T-M97-02: der fetch-Wrapper notiert NUR fehlgeschlagene Antworten mit
* Methode, Pfad OHNE Suchteil, Status und 200 Zeichen des ANTWORT-Rumpfs —
* nie den Anfrage-Rumpf (Kennwoerter), nie Kopfzeilen, nie den Suchteil
* (Tokens). Test 2 pinnt `geheim` und `password` als NICHT im Puffer.
*/
afterEach(() => {
uninstallErrorBuffer();
clearErrorBuffer();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('error-buffer (quick-260914-m97)', () => {
it('Test 1: Ringpuffer haelt 20 Eintraege — 25 Meldungen -> die ersten fuenf fallen raus', () => {
for (let i = 0; i < 25; i++) recordError('error', `m${i}`);
const entries = getRecentErrors();
expect(entries).toHaveLength(20);
expect(entries[0].message).toBe('m5');
expect(entries[19].message).toBe('m24');
for (const e of entries) {
expect(e.kind).toBe('error');
expect(typeof e.message).toBe('string');
expect(() => new Date(e.at).toISOString()).not.toThrow();
expect(e.at).toBe(new Date(e.at).toISOString());
}
});
it('Test 2 (T-M97-02): fetch-Wrapper notiert nur !ok — Methode, Pfad ohne Suchteil, Status, Antwort-Auszug; nie Anfrage-Rumpf; Antwort bleibt lesbar; ok-Antworten werden nicht notiert', async () => {
let status = 500;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response('{"statusCode":500,"message":"kaputt"}', { status }),
);
vi.stubGlobal('fetch', fetchMock);
installErrorBuffer();
const res = await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' });
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe('fetch');
expect(entries[0].message.startsWith('POST /api/x -> 500')).toBe(true);
expect(entries[0].message).toContain('kaputt');
expect(entries[0].message).not.toContain('geheim');
expect(entries[0].message).not.toContain('password');
expect(entries[0].message).not.toContain('token=');
await expect(res.json()).resolves.toEqual({ statusCode: 500, message: 'kaputt' });
status = 200;
await fetch('/api/y');
expect(getRecentErrors()).toHaveLength(1);
});
it('Test 3: console.error ruft weiterhin das Original und notiert die Argumente als Text', () => {
const orig = vi.spyOn(console, 'error').mockImplementation(() => {});
installErrorBuffer();
console.error('boom', { a: 1 });
expect(orig).toHaveBeenCalledTimes(1);
expect(orig).toHaveBeenCalledWith('boom', { a: 1 });
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
expect(entries[0].kind).toBe('console.error');
expect(entries[0].message).toContain('boom');
});
it('Test 4: Installation ist idempotent (kein doppeltes Wrapping); formatErrorsForReport liefert "[<ISO>] fetch: ..."', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('nein', { status: 503 })));
installErrorBuffer();
installErrorBuffer();
await fetch('/api/z');
const entries = getRecentErrors();
expect(entries).toHaveLength(1);
const lines = formatErrorsForReport();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] fetch: GET \/api\/z -> 503/);
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* error-buffer — Ringpuffer der letzten Fehlermeldungen im Browser
* (quick-260914-m97, Fehler-melden-Knopf).
*
* Zweck: wenn ein Anwender „Fehler melden“ klickt, kommen die letzten 20
* Fehler mit, die der Browser im Hintergrund gesehen hat — unbehandelte
* Ausnahmen, abgelehnte Promises, `console.error`-Aufrufe und fehlgeschlagene
* API-Antworten. Der Administrator sieht damit im Bericht, was VOR dem
* Klick schiefging, ohne Rueckfragen stellen zu muessen.
*
* Grenzen: 20 Eintraege, je hoechstens 1000 Zeichen, Antwort-Auszug 200
* Zeichen. Einmal je Seitenladung installiert (`installErrorBuffer`,
* idempotent ueber ein Guard-Symbol auf `window`), SSR-sicher (ohne
* `window` passiert nichts).
*
* Sicherheitsregel (T-M97-02): der fetch-Wrapper notiert NUR bei
* `!response.ok` — Methode, Pfad OHNE Suchteil, Status und die ersten 200
* Zeichen des ANTWORT-Rumpfs (aus einem `clone()`, die Antwort bleibt fuer
* den Aufrufer lesbar). Nie der Anfrage-Rumpf (Kennwoerter, Formulare),
* nie Kopfzeilen, nie Cookies (das Sitzungs-Cookie ist httpOnly und fuer
* JavaScript ohnehin unsichtbar), nie der Suchteil (Tokens in URLs).
*/
export type BufferedErrorKind = 'error' | 'unhandledrejection' | 'console.error' | 'fetch';
export interface BufferedError {
at: string;
kind: BufferedErrorKind;
message: string;
}
const MAX_ENTRIES = 20;
const MAX_MESSAGE = 1000;
const BODY_EXCERPT = 200;
const GUARD = '__tesseraErrorBufferInstalled';
const buffer: BufferedError[] = [];
let originalFetch: typeof fetch | null = null;
let originalConsoleError: typeof console.error | null = null;
let listeners: { error: (e: ErrorEvent) => void; rejection: (e: PromiseRejectionEvent) => void } | null = null;
export function recordError(kind: BufferedErrorKind, message: string): void {
const text = String(message ?? '');
buffer.push({
at: new Date().toISOString(),
kind,
message: text.length > MAX_MESSAGE ? text.slice(0, MAX_MESSAGE) : text,
});
while (buffer.length > MAX_ENTRIES) buffer.shift();
}
export function getRecentErrors(): BufferedError[] {
return buffer.map((e) => ({ ...e }));
}
export function clearErrorBuffer(): void {
buffer.length = 0;
}
/** Zeilen der Form `[<ISO>] <Art>: <Meldung>` fuer das Feld `errors` des Berichts. */
export function formatErrorsForReport(): string[] {
return buffer.map((e) => `[${e.at}] ${e.kind}: ${e.message}`);
}
function argToText(arg: unknown): string {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return arg.message;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
function pathOf(input: RequestInfo | URL): string {
try {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return new URL(String(raw), window.location.href).pathname;
} catch {
return '?';
}
}
export function installErrorBuffer(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[GUARD]) return;
w[GUARD] = true;
const onError = (e: ErrorEvent) => {
recordError('error', `${e.message} @ ${e.filename}:${e.lineno}`);
};
const onRejection = (e: PromiseRejectionEvent) => {
const reason = e.reason as { message?: unknown } | undefined;
recordError('unhandledrejection', String(reason?.message ?? e.reason));
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onRejection);
listeners = { error: onError, rejection: onRejection };
// console.error: ZUERST das Original mit denselben Argumenten, dann notieren.
const origConsoleError = console.error;
originalConsoleError = origConsoleError;
console.error = (...args: unknown[]) => {
origConsoleError.apply(console, args);
recordError('console.error', args.map(argToText).join(' '));
};
// fetch: nur fehlgeschlagene Antworten, nie der Anfrage-Rumpf (T-M97-02).
const origFetch = window.fetch;
originalFetch = origFetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = String(init?.method ?? 'GET').toUpperCase();
let response: Response;
try {
response = await origFetch(input, init);
} catch (err) {
recordError('fetch', `${method} ${pathOf(input)} -> Netzwerkfehler`);
throw err;
}
if (!response.ok) {
let excerpt = '';
try {
excerpt = (await response.clone().text()).slice(0, BODY_EXCERPT);
} catch {
excerpt = '';
}
recordError('fetch', `${method} ${pathOf(input)} -> ${response.status}${excerpt ? ` ${excerpt}` : ''}`);
}
return response;
};
}
/** Nur fuer Tests: Original-fetch/console.error zurueck, Guard loeschen. Kein Aufrufer im Produktionscode. */
export function uninstallErrorBuffer(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (!w[GUARD]) return;
if (originalFetch) window.fetch = originalFetch;
if (originalConsoleError) console.error = originalConsoleError;
if (listeners) {
window.removeEventListener('error', listeners.error);
window.removeEventListener('unhandledrejection', listeners.rejection);
}
originalFetch = null;
originalConsoleError = null;
listeners = null;
delete w[GUARD];
}