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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user