From bf4384ad739b1311454169f904919c6f24a636af Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 23 Sep 2026 15:52:02 +0200 Subject: [PATCH] fix(bug-report): Bildschirmfoto scheitert nicht mehr an einem fremden Bild html-to-image laedt jedes per fetch nach; ein einziges Bild ohne CORS (z. B. ein direkt von der Website geholtes Favoriten-Symbol) liess die ganze Aufnahme scheitern, im Dialog blieb das Haekchen "Bildschirmfoto beifuegen" gesperrt. Nicht ladbare Bilder werden jetzt zum transparenten Pixel; scheitert die Aufnahme trotzdem, folgt ein zweiter Versuch ohne Bilder und Rahmen. Im Linux-Client 1.3.1 nachgestellt und nach dem Fix gegengeprueft. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/lib/bug-report-api.test.ts | 44 ++++++++++++++++++++++++- apps/web/src/lib/bug-report-api.ts | 33 +++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/bug-report-api.test.ts b/apps/web/src/lib/bug-report-api.test.ts index 99eb4bb..b229d61 100644 --- a/apps/web/src/lib/bug-report-api.test.ts +++ b/apps/web/src/lib/bug-report-api.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { sendBugReport } from './bug-report-api'; +import { captureScreenshot, sendBugReport } from './bug-report-api'; + +const { mockToPng } = vi.hoisted(() => ({ mockToPng: vi.fn() })); +vi.mock('html-to-image', () => ({ toPng: mockToPng })); /** * bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der @@ -85,3 +88,42 @@ describe('sendBugReport (quick-260918-gza)', () => { ).resolves.toEqual({ ok: false, status: 429 }); }); }); + +describe('captureScreenshot (Bild von fremder Adresse)', () => { + beforeEach(() => { + mockToPng.mockReset(); + }); + + it('ersetzt nicht ladbare Bilder statt die Aufnahme abzubrechen', async () => { + mockToPng.mockResolvedValue('data:image/png;base64,AAA'); + + await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,AAA'); + + const opts = mockToPng.mock.calls[0][1]; + expect(opts.imagePlaceholder).toMatch(/^data:image\/png;base64,/); + expect(opts.onImageErrorHandler()).toBeUndefined(); + }); + + it('versucht es ein zweites Mal ohne Bilder und Rahmen, wenn der erste Versuch scheitert', async () => { + mockToPng + .mockRejectedValueOnce(new Error('tainted')) + .mockResolvedValueOnce('data:image/png;base64,BBB'); + + await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,BBB'); + + expect(mockToPng).toHaveBeenCalledTimes(2); + const filter = mockToPng.mock.calls[1][1].filter as (n: Node) => boolean; + expect(filter(document.createElement('img'))).toBe(false); + expect(filter(document.createElement('iframe'))).toBe(false); + expect(filter(document.createElement('div'))).toBe(true); + const ignored = document.createElement('div'); + ignored.dataset.bugReportIgnore = 'true'; + expect(filter(ignored)).toBe(false); + }); + + it('liefert null, wenn auch der zweite Versuch scheitert', async () => { + mockToPng.mockRejectedValue(new Error('kaputt')); + + await expect(captureScreenshot()).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/lib/bug-report-api.ts b/apps/web/src/lib/bug-report-api.ts index 27fd81e..7c135ec 100644 --- a/apps/web/src/lib/bug-report-api.ts +++ b/apps/web/src/lib/bug-report-api.ts @@ -42,23 +42,50 @@ export function computeCaptureSize( }; } +/** 1x1 transparentes PNG — Ersatz fuer Bilder, die sich nicht laden lassen. */ +const TRANSPARENT_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +function isIgnoredNode(n: Node): boolean { + return n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'; +} + /** * Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu * werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne. + * + * `html-to-image` laedt jedes `` per `fetch` nach. Ein einziges Bild + * von fremder Adresse ohne CORS (z. B. ein direkt von der Website geholtes + * Favoriten-Symbol) liess bisher die GANZE Aufnahme scheitern — im Dialog + * blieb das Haekchen „Bildschirmfoto beifuegen“ dann gesperrt. Deshalb: + * nicht ladbare Bilder werden zum transparenten Pixel, und scheitert die + * Aufnahme trotzdem, folgt ein zweiter Versuch ganz ohne Bilder und Rahmen. */ export async function captureScreenshot(): Promise { try { const { toPng } = await import('html-to-image'); const node = document.body; const size = computeCaptureSize(node.scrollWidth, node.scrollHeight); - return await toPng(node, { + const base = { pixelRatio: 1, skipFonts: true, cacheBust: true, canvasWidth: size.width, canvasHeight: size.height, - filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'), - }); + imagePlaceholder: TRANSPARENT_PIXEL, + onImageErrorHandler: () => undefined, + }; + try { + return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) }); + } catch { + return await toPng(node, { + ...base, + filter: (n: Node) => + !isIgnoredNode(n) && + !(n instanceof HTMLImageElement) && + !(n instanceof HTMLIFrameElement), + }); + } } catch { return null; }