Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions widget/src/capture/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new CaptureError('canvas_limit'))), 'image/jpeg', quality);
Expand Down Expand Up @@ -68,6 +83,7 @@ export async function capturePage(options: CaptureOptions): Promise<CaptureResul
scale,
backgroundColor: background && background !== 'rgba(0, 0, 0, 0)' ? background : '#FFFFFF',
filter: (node) => !(node instanceof Element && (options.exclude.includes(node) || (ignore !== '' && node.matches(ignore)))),
onCloneEachNode: stripInvalidAttributes,
timeout: options.timeoutMs ?? 15_000,
}),
timeout,
Expand Down
40 changes: 40 additions & 0 deletions widget/tests/e2e/screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,46 @@
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 = '<div :role="a" x-on:toast-close="b"></div>';
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);

Check failure on line 228 in widget/tests/e2e/screenshot.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (Chromium, Firefox, WebKit, Mobile) + full stack

[firefox] › tests/e2e/screenshot.spec.ts:191:1 › framework attributes like :class or x-on:click do not blank the screenshot

1) [firefox] › tests/e2e/screenshot.spec.ts:191:1 › framework attributes like :class or x-on:click do not blank the screenshot Error: pixel 244,244,255 expect(received).toBe(expected) // Object.is equality Expected: true Received: false 226 | ); 227 | const [r, g, b] = pixel as [number, number, number]; > 228 | expect(r < 80 && g < 80 && b > 140, `pixel ${pixel}`).toBe(true); | ^ 229 | }); 230 | 231 | test('a failed screenshot keeps the camera usable and sends without image', async ({ page }, info) => { at /home/runner/work/feedback.codebar.ch/feedback.codebar.ch/widget/tests/e2e/screenshot.spec.ts:228:57
});

test('a failed screenshot keeps the camera usable and sends without image', async ({ page }, info) => {
const id = sid(info);
const x = w(page);
Expand Down
Loading