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
@@ -0,0 +1,284 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import { clearErrorBuffer, recordError } from '@/lib/error-buffer';
import { computeCaptureSize } from '@/lib/bug-report-api';
/**
* bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Elf Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem Dialog
* aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs, dass noch
* KEIN `role="dialog"` im DOM steht. `html-to-image` selbst laeuft in jsdom
* nicht (gemessen zur Planungszeit: `HTMLVideoElement is not defined`, kein
* Canvas-Backend) — deshalb der Mock; der Bildbeweis kommt aus dem Browser.
*
* Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster
* `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
*/
const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({
mockToPng: vi.fn(),
mockFetch: vi.fn(),
mockUser: { id: 'u1', username: 'anna', displayName: 'Anna', role: 'USER', tenantId: 't1' } as {
id: string;
username: string;
displayName: string | null;
role: string;
tenantId: string;
},
}));
vi.mock('html-to-image', () => ({
toPng: (...args: unknown[]) => mockToPng(...args),
}));
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
};
});
vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (sel?: (s: { user: typeof mockUser }) => unknown) =>
sel ? sel({ user: mockUser }) : { user: mockUser },
}));
vi.mock('@/lib/app-version', () => ({
appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' },
}));
const T = de.bugReport;
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
async function renderButton() {
const { BugReportButton } = await import('./bug-report-button');
render(<BugReportButton />);
}
async function openDialog(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: T.button }));
return screen.findByRole('dialog');
}
function jsonResponse(status: number, body: unknown = { statusCode: status }) {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
beforeEach(() => {
mockUser.role = 'USER';
mockToPng.mockReset();
mockToPng.mockResolvedValue(DATA_URL);
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
clearErrorBuffer();
vi.unstubAllGlobals();
});
describe('BugReportButton (quick-260914-m97)', () => {
it('Test 1: Bild VOR dem Dialog — toPng laeuft ohne offenen Dialog, mit document.body, pixelRatio 1, skipFonts, 1600-px-Kante; danach Dialog mit Vorschau, Haekchen an, Textfeld leer', async () => {
Object.defineProperty(document.body, 'scrollWidth', { value: 3200, configurable: true });
Object.defineProperty(document.body, 'scrollHeight', { value: 1000, configurable: true });
mockToPng.mockImplementation(async () => {
expect(screen.queryByRole('dialog')).toBeNull();
return DATA_URL;
});
const user = userEvent.setup();
await renderButton();
const dialog = await openDialog(user);
expect(mockToPng).toHaveBeenCalledTimes(1);
const [node, opts] = mockToPng.mock.calls[0] as [unknown, Record<string, unknown>];
expect(node).toBe(document.body);
expect(opts).toMatchObject({ pixelRatio: 1, skipFonts: true, canvasWidth: 1600, canvasHeight: 500 });
expect(dialog).toBeInTheDocument();
const img = screen.getByAltText(T.screenshotAlt) as HTMLImageElement;
expect(img.getAttribute('src')).toBe(DATA_URL);
expect(screen.getByRole('checkbox', { name: T.attachScreenshot })).toBeChecked();
expect(screen.getByLabelText(T.descriptionLabel)).toHaveValue('');
});
it('Test 2: Senden mit Bild — multipart POST /bug-reports mit credentials, allen Feldern, Fehlerliste, PNG-Blob, ohne Content-Type-Header; danach Dankestext und Schliessen-Knopf', async () => {
recordError('fetch', 'GET /modules -> 500');
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.type(screen.getByLabelText(T.descriptionLabel), 'Knopf tut nichts');
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(String(url).endsWith('/bug-reports')).toBe(true);
expect(init.method).toBe('POST');
expect(init.credentials).toBe('include');
expect(init.body).toBeInstanceOf(FormData);
const body = init.body as FormData;
expect(body.get('description')).toBe('Knopf tut nichts');
expect(body.get('webVersion')).toBe('v1.2.3');
expect(body.get('webChannel')).toBe('beta');
expect(body.get('webCommit')).toBe('abc1234');
expect(String(body.get('page')).startsWith('/')).toBe(true);
expect(body.get('userAgent')).toBeTruthy();
expect(body.get('viewport')).toMatch(/^\d+x\d+$/);
expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true);
const shot = body.get('screenshot');
expect(shot).toBeInstanceOf(Blob);
expect((shot as Blob).type).toBe('image/png');
expect((shot as Blob).size).toBeGreaterThan(0);
const headers = (init.headers ?? {}) as Record<string, string>;
expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain('content-type');
expect(screen.getByRole('button', { name: T.close })).toBeInTheDocument();
});
it('Test 3: Haekchen aus -> kein Feld screenshot im Rumpf', async () => {
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('checkbox', { name: T.attachScreenshot }));
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.has('screenshot')).toBe(false);
});
it('Test 4: 409 — Meldung "kein Postfach"; als ADMIN zusaetzlich Hinweis mit Link auf /admin/smtp, als USER kein Link', async () => {
mockUser.role = 'ADMIN';
mockFetch.mockResolvedValue(jsonResponse(409, { statusCode: 409, message: 'kein Postfach' }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorNotConfigured);
expect(screen.getByText(T.errorNotConfiguredAdminHint)).toBeInTheDocument();
const link = screen.getByRole('link', { name: T.errorNotConfiguredAdminLink });
expect(link.getAttribute('href')).toBe('/admin/smtp');
cleanup();
mockUser.role = 'USER';
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorNotConfigured);
expect(screen.queryByRole('link', { name: T.errorNotConfiguredAdminLink })).toBeNull();
expect(screen.queryByText(T.errorNotConfiguredAdminHint)).toBeNull();
});
it('Test 5: Escape schliesst den Dialog', async () => {
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('Test 6: Aufnahme scheitert -> Dialog oeffnet trotzdem mit Hinweis, Haekchen abgeschaltet und aus, Senden ohne screenshot', async () => {
mockToPng.mockRejectedValue(new Error('kein Canvas'));
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
expect(screen.getByText(T.screenshotUnavailable)).toBeInTheDocument();
const box = screen.getByRole('checkbox', { name: T.attachScreenshot });
expect(box).toBeDisabled();
expect(box).not.toBeChecked();
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.sent);
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
expect(body.has('screenshot')).toBe(false);
});
it('Test 7: 413 -> errorTooLarge; Knoepfe bleiben, erneutes Senden ruft fetch ein zweites Mal', async () => {
mockFetch.mockResolvedValue(jsonResponse(413));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorTooLarge);
await user.click(screen.getByRole('button', { name: T.send }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
});
it('Test 8: 429 -> errorTooMany, nicht errorTooLarge', async () => {
mockFetch.mockResolvedValue(jsonResponse(429));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorTooMany);
expect(screen.queryByText(T.errorTooLarge)).toBeNull();
});
it('Test 9: 502 -> errorSendFailed', async () => {
mockFetch.mockResolvedValue(jsonResponse(502));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorSendFailed);
});
it('Test 10: 500 und Netzwerkfehler -> errorGeneric, keine der vier spezifischen Meldungen', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(500));
const user = userEvent.setup();
await renderButton();
await openDialog(user);
await user.click(screen.getByRole('button', { name: T.send }));
await screen.findByText(T.errorGeneric);
mockFetch.mockRejectedValueOnce(new Error('netz'));
await user.click(screen.getByRole('button', { name: T.send }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
await screen.findByText(T.errorGeneric);
for (const specific of [T.errorNotConfigured, T.errorTooMany, T.errorTooLarge, T.errorSendFailed]) {
expect(screen.queryByText(specific)).toBeNull();
}
});
});
describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => {
it('Test 11: laengste Kante hoechstens 1600 px, Seitenverhaeltnis bleibt, Mindestmass 1x1, maxEdge einstellbar', () => {
expect(computeCaptureSize(3200, 1000)).toEqual({ width: 1600, height: 500 });
expect(computeCaptureSize(800, 600)).toEqual({ width: 800, height: 600 });
expect(computeCaptureSize(1000, 4000)).toEqual({ width: 400, height: 1600 });
expect(computeCaptureSize(0, 0)).toEqual({ width: 1, height: 1 });
expect(computeCaptureSize(3200, 1000, 800)).toEqual({ width: 800, height: 250 });
});
});
@@ -0,0 +1,72 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { captureScreenshot } from '@/lib/bug-report-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { BugReportDialog } from './bug-report-dialog';
/**
* Fehler-melden-Knopf in der Kopfzeile (quick-260914-m97), Stil wie
* `ThemeToggle`. Kernanforderung: das Bild der Seite wird aufgenommen,
* BEVOR der Dialog erscheint — sonst waere der Dialog im Bild.
*/
export function BugReportButton() {
const t = useTranslations('bugReport');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [capturing, setCapturing] = useState(false);
const [open, setOpen] = useState(false);
const [screenshot, setScreenshot] = useState<string | null>(null);
const handleClick = async () => {
if (capturing) return;
setCapturing(true);
// Reihenfolge ist die Kernanforderung: erst aufnehmen, dann oeffnen.
const shot = await captureScreenshot();
setScreenshot(shot);
setOpen(true);
setCapturing(false);
};
return (
<>
<button
type="button"
onClick={handleClick}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
aria-label={t('button')}
title={t('button')}
disabled={capturing}
data-bug-report-ignore="true"
>
{/* Kaefer-Symbol nach dem lucide-Symbol "bug" */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
</button>
<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />
</>
);
}
@@ -0,0 +1,196 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { appVersion } from '@/lib/app-version';
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
import { formatErrorsForReport } from '@/lib/error-buffer';
/**
* Dialog des Fehler-melden-Knopfs (quick-260914-m97). Muster:
* `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`,
* Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL —
* die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint.
*/
interface BugReportDialogProps {
open: boolean;
screenshot: string | null;
isAdmin: boolean;
onClose: () => void;
}
type Status = 'ready' | 'sending' | 'sent' | 'failed';
export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugReportDialogProps) {
const t = useTranslations('bugReport');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<Status>('ready');
const [failedStatus, setFailedStatus] = useState(0);
const [description, setDescription] = useState('');
const [attach, setAttach] = useState(screenshot !== null);
// Bei jedem Oeffnen frisch beginnen.
useEffect(() => {
if (open) {
setStatus('ready');
setFailedStatus(0);
setDescription('');
setAttach(screenshot !== null);
textareaRef.current?.focus();
}
}, [open, screenshot]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && status !== 'sending') onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, status, onClose]);
if (!open) return null;
const handleSend = async () => {
setStatus('sending');
const result = await sendBugReport({
description: description.trim(),
page: window.location.pathname + window.location.search,
webVersion: appVersion.version,
webChannel: appVersion.channel,
webCommit: appVersion.commit,
userAgent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
clientTime: new Date().toISOString(),
errors: formatErrorsForReport(),
screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null,
});
if (result.ok) {
setStatus('sent');
} else {
setFailedStatus(result.status);
setStatus('failed');
}
};
const errorKey =
failedStatus === 409
? 'errorNotConfigured'
: failedStatus === 429
? 'errorTooMany'
: failedStatus === 413
? 'errorTooLarge'
: failedStatus === 502
? 'errorSendFailed'
: 'errorGeneric';
const busy = status === 'sending';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
aria-labelledby="bug-report-title"
data-bug-report-ignore="true"
>
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
{t('title')}
</h2>
{status === 'sent' ? (
<>
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('close')}
</button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">{t('intro')}</p>
{screenshot ? (
<img
src={screenshot}
alt={t('screenshotAlt')}
className="max-h-48 w-auto rounded border border-border mb-3"
/>
) : (
<p className="text-sm text-muted-foreground mb-3">{t('screenshotUnavailable')}</p>
)}
<div className="flex items-center gap-2 mb-3">
<input
id="bug-report-attach"
type="checkbox"
className="h-4 w-4"
checked={attach}
disabled={screenshot === null || busy}
onChange={(e) => setAttach(e.target.checked)}
/>
<label htmlFor="bug-report-attach" className="text-sm text-foreground">
{t('attachScreenshot')}
</label>
</div>
<label htmlFor="bug-report-description" className="mb-1 block text-sm text-foreground">
{t('descriptionLabel')}
</label>
<textarea
id="bug-report-description"
ref={textareaRef}
rows={4}
maxLength={4000}
placeholder={t('descriptionPlaceholder')}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground"
value={description}
disabled={busy}
onChange={(e) => setDescription(e.target.value)}
/>
{status === 'failed' && (
<div className="mt-3 text-sm text-destructive">
<p>{t(errorKey)}</p>
{failedStatus === 409 && isAdmin && (
<p className="mt-1">
{t('errorNotConfiguredAdminHint')}{' '}
<Link href="/admin/smtp" className="underline">
{t('errorNotConfiguredAdminLink')}
</Link>
</p>
)}
</div>
)}
<div className="mt-4 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
>
{t('cancel')}
</button>
<button
type="button"
onClick={handleSend}
disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{busy ? t('sending') : t('send')}
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
@@ -13,6 +14,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
setMounted(true);
}, []);
// Fehlerpuffer fuer den Fehler-melden-Knopf (quick-260914-m97): einmal je
// Seitenladung, SSR-sicher, idempotent (StrictMode-Doppeleffekt unschaedlich).
useEffect(() => {
installErrorBuffer();
}, []);
// Desktop: sidebar pushes content via margin-left
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
const sidebarWidth = isCollapsed
@@ -8,6 +8,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
import { fetchCurrentUser, logout } from '@/lib/auth-actions';
import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { usePathname } from 'next/navigation';
export function Header() {
@@ -109,6 +110,7 @@ export function Header() {
{/* Right: Actions */}
<div className="flex items-center gap-2">
<BugReportButton />
<ThemeToggle />
{/* User avatar dropdown */}