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;
}