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). * * Dreizehn 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.`. * * Test 12/13 (quick-260918-gza): die vier Herkunftsfelder in der Nutzlast, * abhaengig vom Cookie `tessera_desktop_client` — jsdom hat ohne gesetztes * Cookie keinen Desktop-Client, darum bleibt Test 2 beim Browser-Fall. */ 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); }); /** * Groesse von `document.body` vortaeuschen. WICHTIG: `Object.defineProperty` * legt eine eigene Eigenschaft auf dem Element an, die den Getter von * `Element.prototype` verdeckt — `document.body` ueberlebt `cleanup()` und * damit die ganze Datei. Ohne Ruecknahme saehen alle folgenden Tests 3200x1000. * Deshalb merken und in `afterEach` wieder loeschen. */ let bodyGroesseVorgetaeuscht = false; function stubBodyGroesse(scrollWidth: number, scrollHeight: number) { Object.defineProperty(document.body, 'scrollWidth', { value: scrollWidth, configurable: true }); Object.defineProperty(document.body, 'scrollHeight', { value: scrollHeight, configurable: true }); bodyGroesseVorgetaeuscht = true; } function restoreBodyGroesse() { if (!bodyGroesseVorgetaeuscht) return; Reflect.deleteProperty(document.body, 'scrollWidth'); Reflect.deleteProperty(document.body, 'scrollHeight'); bodyGroesseVorgetaeuscht = false; } function clearDesktopCookies() { document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; document.cookie = 'tessera_desktop_client=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; } afterEach(() => { cleanup(); clearErrorBuffer(); clearDesktopCookies(); restoreBodyGroesse(); 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 () => { stubBodyGroesse(3200, 1000); // Kein `expect` INNERHALB der Attrappe: ein Fehler daraus landet mitten im // `await` der Komponente und wird von `captureScreenshot` verschluckt // (`catch` liefert `null`) — der Test waere dann an ganz anderer Stelle // mit irrefuehrender Meldung durchgefallen. Also nur festhalten, unten // pruefen (quick-260921-ldf). let dialogWaehrendDerAufnahme: unknown = 'toPng wurde nicht aufgerufen'; mockToPng.mockImplementation(async () => { dialogWaehrendDerAufnahme = screen.queryByRole('dialog'); return DATA_URL; }); const user = userEvent.setup(); await renderButton(); const dialog = await openDialog(user); expect(mockToPng).toHaveBeenCalledTimes(1); expect(dialogWaehrendDerAufnahme).toBeNull(); 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.get('clientKind')).toBe('browser'); expect(body.get('clientOs')).toBe(''); expect(body.get('clientVersion')).toBe(''); expect(body.get('clientCommit')).toBe(''); 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(); } }); it('Test 12 (quick-260918-gza): Desktop-Cookies gesetzt -> clientKind desktop mit Os/Version/Commit aus dem Cookie', async () => { document.cookie = 'tessera_desktop=1; path=/'; document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/'; mockFetch.mockResolvedValue(jsonResponse(200, { sent: true })); const user = userEvent.setup(); await renderButton(); await openDialog(user); 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.get('clientKind')).toBe('desktop'); expect(body.get('clientOs')).toBe('windows'); expect(body.get('clientVersion')).toBe('1.2.0'); expect(body.get('clientCommit')).toBe('a6d1a64'); }); it('Test 13 (quick-260918-gza, alter Client): nur tessera_desktop ohne tessera_desktop_client -> desktop ohne Details', async () => { document.cookie = 'tessera_desktop=1; path=/'; mockFetch.mockResolvedValue(jsonResponse(200, { sent: true })); const user = userEvent.setup(); await renderButton(); await openDialog(user); 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.get('clientKind')).toBe('desktop'); expect(body.get('clientOs')).toBe(''); expect(body.get('clientVersion')).toBe(''); expect(body.get('clientCommit')).toBe(''); }); /** * Test 14/15 (quick-260921-ldf) sind der Nachweis fuer die Ursache des * Wackeltests aus CI-Lauf 395. Der Dialog zog seinen Anfangszustand frueher * per `useEffect` nach; React schrieb deshalb bei JEDEM Oeffnen zuerst einen * falschen DOM-Zustand fest und korrigierte ihn erst einen Commit spaeter. * Gemessen hielt der falsche Zustand zwei volle Makrotask-Runden — lange * genug, dass der Browser ihn zeichnen konnte und der Test ihn gelegentlich * sah. Die beiden Tests beobachten jeden Commit und dulden keinen einzigen * falschen. */ function protokolliereCommits(): { commits: string[]; stop: () => void } { const commits: string[] = []; const obs = new MutationObserver(() => { const dialog = document.querySelector('[role="dialog"]'); if (!dialog) return; const bild = dialog.querySelector('img'); const box = document.getElementById('bug-report-attach') as HTMLInputElement | null; const feld = document.getElementById('bug-report-description') as HTMLTextAreaElement | null; const danke = dialog.textContent?.includes(T.sent) ?? false; commits.push( `bild=${bild ? 'ja' : 'nein'} haekchen=${box ? (box.checked ? 'an' : 'aus') : '-'} ` + `gesperrt=${box ? box.disabled : '-'} text="${feld?.value ?? ''}" danke=${danke}`, ); }); obs.observe(document.body, { childList: true, subtree: true, attributes: true }); return { commits, stop: () => obs.disconnect() }; } it('Test 14 (quick-260921-ldf): kein falscher Zwischenzustand — in KEINEM Commit steht das Vorschaubild mit ausgeschaltetem Haekchen', async () => { const { commits, stop } = protokolliereCommits(); try { const user = userEvent.setup(); await renderButton(); await openDialog(user); } finally { stop(); } expect(commits.length).toBeGreaterThan(0); const falsche = commits.filter((c) => c.includes('bild=ja') && c.includes('haekchen=aus') && c.includes('gesperrt=false')); expect(falsche).toEqual([]); // Und der allererste Commit mit Dialog ist schon der richtige. expect(commits[0]).toContain('bild=ja'); expect(commits[0]).toContain('haekchen=an'); }); it('Test 15 (quick-260921-ldf): erneutes Oeffnen startet frisch — kein alter Dankestext, kein alter Text, Haekchen wieder an', async () => { mockFetch.mockResolvedValue(jsonResponse(200, { sent: true })); const user = userEvent.setup(); await renderButton(); // Erstes Oeffnen: Haekchen abwaehlen, Text tippen, senden, schliessen. await openDialog(user); await user.click(screen.getByRole('checkbox', { name: T.attachScreenshot })); await user.type(screen.getByLabelText(T.descriptionLabel), 'alter Text'); await user.click(screen.getByRole('button', { name: T.send })); await screen.findByText(T.sent); await user.click(screen.getByRole('button', { name: T.close })); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); // Zweites Oeffnen: jeder Commit muss frisch sein. const { commits, stop } = protokolliereCommits(); try { await openDialog(user); } finally { stop(); } expect(commits.length).toBeGreaterThan(0); expect(commits.filter((c) => c.includes('danke=true'))).toEqual([]); expect(commits.filter((c) => c.includes('text="alter Text"'))).toEqual([]); expect(commits.filter((c) => c.includes('bild=ja') && c.includes('haekchen=aus') && c.includes('gesperrt=false'))).toEqual([]); expect(screen.getByRole('checkbox', { name: T.attachScreenshot })).toBeChecked(); expect(screen.getByLabelText(T.descriptionLabel)).toHaveValue(''); }); }); 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 }); }); });