From 278f2f3d34487f432c5a01d23bc12ab01070b351 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 16:26:08 +0200 Subject: [PATCH] fix(widget): framework attributes no longer blank the screenshot The page clone is rendered as SVG and must be valid XML. Attributes such as Alpine's :class or x-on:click are not, and a single one made the browser render the whole capture as an empty image (gray in annotation mode, white when sent). Strip such attributes from the clone only. Co-Authored-By: Claude Opus 5.5 --- widget/src/capture/snapshot.ts | 16 ++++++++++++ widget/tests/e2e/screenshot.spec.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/widget/src/capture/snapshot.ts b/widget/src/capture/snapshot.ts index 83c3741..b3cd8e7 100644 --- a/widget/src/capture/snapshot.ts +++ b/widget/src/capture/snapshot.ts @@ -28,6 +28,21 @@ export class CaptureError extends Error { const MAX_BYTES = 3 * 1024 * 1024; +/** XML-safe attribute name without a namespace prefix. */ +const XML_NAME = /^[A-Za-z_][\w.-]*$/; + +/** + * The clone is rendered as SVG, so it has to be valid XML. Framework attributes such as + * Alpine's `:class`, `x-on:click` or Vue's `v-bind:href` are not, and a single one makes the + * browser render the whole page as an empty image. They carry no styling, so drop them. + */ +export function stripInvalidAttributes(node: Node): void { + if (!(node instanceof Element)) return; + for (const attr of Array.from(node.attributes)) { + if (attr.namespaceURI === null && !XML_NAME.test(attr.name)) node.removeAttribute(attr.name); + } +} + export function toJpeg(canvas: HTMLCanvasElement, quality: number): Promise { return new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new CaptureError('canvas_limit'))), 'image/jpeg', quality); @@ -68,6 +83,7 @@ export async function capturePage(options: CaptureOptions): Promise !(node instanceof Element && (options.exclude.includes(node) || (ignore !== '' && node.matches(ignore)))), + onCloneEachNode: stripInvalidAttributes, timeout: options.timeoutMs ?? 15_000, }), timeout, diff --git a/widget/tests/e2e/screenshot.spec.ts b/widget/tests/e2e/screenshot.spec.ts index 0fe9891..f1fa6d7 100644 --- a/widget/tests/e2e/screenshot.spec.ts +++ b/widget/tests/e2e/screenshot.spec.ts @@ -188,6 +188,46 @@ test('sends the full page with masks and burnt-in marks', async ({ page }, info) expect(annotations[0]).toEqual({ type: 'rect', x: 100, y: 650, w: 200, h: 110 }); }); +test('framework attributes like :class or x-on:click do not blank the screenshot', async ({ page }, info) => { + const id = sid(info); + const x = w(page); + await page.evaluate(() => { + const el = document.createElement('div'); + el.id = 'alpine'; + el.style.cssText = 'width:120px;height:120px;background:rgb(0,0,255)'; + for (const name of [':class', 'x-on:click', 'x-bind:title', '@click']) el.setAttribute(name, 'x'); + const tpl = document.createElement('template'); + tpl.innerHTML = '
'; + document.getElementById('intro')!.append(el, tpl); + }); + await fill(page, 'a@b.ch', 'Alpine Seite'); + await takeShot(page); + await x.done.click(); + await x.submit.click(); + await expect(x.success).toBeVisible(); + + const point = await page.evaluate(() => { + const r = document.getElementById('alpine')!.getBoundingClientRect(); + return { x: r.left + scrollX + 60, y: r.top + scrollY + 60 }; + }); + const pixel = await page.evaluate( + async ({ url, point }) => { + const img = new Image(); + img.src = url; + await img.decode(); + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + return Array.from(ctx.getImageData(point.x, point.y, 1, 1).data.slice(0, 3)); + }, + { url: `/s/${id}/__shot/0`, point }, + ); + const [r, g, b] = pixel as [number, number, number]; + expect(r < 80 && g < 80 && b > 140, `pixel ${pixel}`).toBe(true); +}); + test('a failed screenshot keeps the camera usable and sends without image', async ({ page }, info) => { const id = sid(info); const x = w(page);