diff --git a/apps/web/package.json b/apps/web/package.json index d3ef571..061093e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@uiw/react-md-editor": "4.1.1", "fflate": "^0.8.3", + "html-to-image": "1.11.13", "jose": "^6.2.3", "next": "^15.3.0", "next-intl": "^4.13.0", diff --git a/apps/web/src/components/bug-report/bug-report-button.test.tsx b/apps/web/src/components/bug-report/bug-report-button.test.tsx new file mode 100644 index 0000000..487e12f --- /dev/null +++ b/apps/web/src/components/bug-report/bug-report-button.test.tsx @@ -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.`. + */ +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; + const lookup = (path: string): string | undefined => + path.split('.').reduce((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 }) => ( + + {children} + + ), +})); + +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(); +} + +async function openDialog(user: ReturnType) { + 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]; + 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; + 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 }); + }); +}); diff --git a/apps/web/src/components/bug-report/bug-report-button.tsx b/apps/web/src/components/bug-report/bug-report-button.tsx new file mode 100644 index 0000000..6e466cf --- /dev/null +++ b/apps/web/src/components/bug-report/bug-report-button.tsx @@ -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(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 ( + <> + + setOpen(false)} /> + + ); +} diff --git a/apps/web/src/components/bug-report/bug-report-dialog.tsx b/apps/web/src/components/bug-report/bug-report-dialog.tsx new file mode 100644 index 0000000..624bb40 --- /dev/null +++ b/apps/web/src/components/bug-report/bug-report-dialog.tsx @@ -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(null); + const [status, setStatus] = useState('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 ( +
+
+

+ {t('title')} +

+ + {status === 'sent' ? ( + <> +

{t('sent')}

+
+ +
+ + ) : ( + <> +

{t('intro')}

+ + {screenshot ? ( + {t('screenshotAlt')} + ) : ( +

{t('screenshotUnavailable')}

+ )} + +
+ setAttach(e.target.checked)} + /> + +
+ + +