diff --git a/README.md b/README.md index 3ed0368..e5f2b13 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue > **Security note:** BugDrop is not a spam or malware filtering service. Treat feedback and screenshots as unauthenticated user-generated content. Exclude `bugdrop-screenshots` from CI/deploy workflows, and self-host behind your own WAF/CAPTCHA/content controls for stricter environments. +## Features + +- 🔒 **Privacy masking** — tag sensitive elements with `data-bugdrop-mask` and BugDrop covers them in the screenshot before it's submitted. Passwords and credit-card inputs are masked automatically. + ## Widget Options | Attribute | Values | Default | diff --git a/docs/superpowers/plans/2026-05-10-screenshot-privacy-masking.md b/docs/superpowers/plans/2026-05-10-screenshot-privacy-masking.md new file mode 100644 index 0000000..21b3998 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-screenshot-privacy-masking.md @@ -0,0 +1,1735 @@ +# Screenshot Privacy Masking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a developer-configured screenshot masking primitive that paints opaque rectangles over `data-bugdrop-mask` elements (and password/credit-card inputs by default) on the captured PNG before the annotator opens. + +**Architecture:** A new `src/widget/mask.ts` module exposes `collectMaskRects(root)` (DOM walk → document-coordinate rects) and `applyMaskToImage(dataUrl, rects, pixelRatio, originOffset?)` (canvas blit). `captureScreenshot` chains these between `html-to-image` and the existing return path. No DOM mutation, no annotator changes, no new public API. + +**Tech Stack:** TypeScript, esbuild widget bundle, `html-to-image` (already bundled), Vitest with jsdom for unit tests, Playwright for E2E. + +**Spec:** `docs/superpowers/specs/2026-05-10-screenshot-privacy-masking-design.md` + +--- + +## File Structure + +| Path | Action | Responsibility | +|---|---|---| +| `src/widget/mask.ts` | Create | Pure DOM-walk + canvas-blit logic. ~120 lines. | +| `src/widget/screenshot.ts` | Modify | Wire mask collection + application into `captureScreenshot`. | +| `test/mask.test.ts` | Create | Unit tests for `collectMaskRects` + `translateMaskRect` math. | +| `public/test/masking-basic.html` | Create | E2E fixture: single masked div + password input. | +| `public/test/masking-nested.html` | Create | E2E fixture: nested masks + mixed siblings. | +| `e2e/widget.spec.ts` | Modify | New `Screenshot Masking` describe block. | +| `docs/website/security.mdx` | Modify | "Screenshot masking" subsection under Privacy. | +| `docs/website/installation.mdx` | Modify | Brief "Protecting sensitive data" section. | +| `README.md` | Modify | One-paragraph mention with example. | + +`src/widget/index.ts` is intentionally unchanged — masks are baked into the PNG by `captureScreenshot`, so the existing annotator and area-crop flows pick them up for free. + +--- + +## Conventions Worth Knowing + +- **Vitest env:** default is `node`. Tests using DOM start with `// @vitest-environment jsdom` (see `test/cropScreenshot.test.ts:1`). +- **Test seam:** `window.__bugdropMockToPng` is read in `src/widget/screenshot.ts:33` and lets tests stub the underlying `html-to-image.toPng`. Same pattern works for masking E2E. +- **E2E fixture pages:** lives in `public/test/`, served by `wrangler dev` at `http://localhost:8787/test/.html`. Existing examples: `public/test/annotation-preview-size.html`. +- **Build before E2E:** widget changes require `npm run build:widget` to regenerate `public/widget.js` (gitignored). +- **Commit messages:** conventional commits enforced by commitlint. `feat:` for new behavior, `test:` for tests-only, `docs:` for docs-only. +- **DOM cleanup in unit tests:** prefer `document.body.replaceChildren()` over `innerHTML = ''` — the project's pre-write security hook flags any `innerHTML` assignment, even empty-string. + +--- + +## Task 1: Create `mask.ts` skeleton with types and signatures + +**Files:** +- Create: `src/widget/mask.ts` +- Create: `test/mask.test.ts` + +- [ ] **Step 1: Write a smoke test that mask.ts exports the expected names** + +Create `test/mask.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { collectMaskRects, applyMaskToImage } from '../src/widget/mask'; + +describe('mask module exports', () => { + it('exports collectMaskRects', () => { + expect(typeof collectMaskRects).toBe('function'); + }); + + it('exports applyMaskToImage', () => { + expect(typeof applyMaskToImage).toBe('function'); + }); +}); +``` + +- [ ] **Step 2: Run the test to see the import fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — `Cannot find module '../src/widget/mask'`. + +- [ ] **Step 3: Create the mask.ts skeleton** + +Create `src/widget/mask.ts`: + +```ts +export interface MaskRect { + x: number; + y: number; + w: number; + h: number; +} + +export function collectMaskRects(_root: Element): MaskRect[] { + return []; +} + +export async function applyMaskToImage( + dataUrl: string, + _rects: MaskRect[], + _pixelRatio: number, + _originOffset?: { x: number; y: number } +): Promise { + return dataUrl; +} +``` + +- [ ] **Step 4: Run the test to see it pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — both export checks succeed. + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: scaffold screenshot mask module" +``` + +--- + +## Task 2: Implement `collectMaskRects` for explicit `data-bugdrop-mask` + +**Files:** +- Modify: `src/widget/mask.ts` +- Modify: `test/mask.test.ts` + +- [ ] **Step 1: Add a test helper and tests for the basic explicit-attribute case** + +Update the imports at the top of `test/mask.test.ts` to include `beforeEach`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +``` + +Append to `test/mask.test.ts`: + +```ts +function withRect(el: HTMLElement, x: number, y: number, w: number, h: number): HTMLElement { + el.getBoundingClientRect = () => + ({ + x, + y, + width: w, + height: h, + top: y, + left: x, + bottom: y + h, + right: x + w, + toJSON() { + return {}; + }, + }) as DOMRect; + return el; +} + +describe('collectMaskRects — explicit attribute', () => { + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('returns empty array for clean DOM', () => { + expect(collectMaskRects(document.body)).toEqual([]); + }); + + it('returns rect for a single masked div', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); + + it('returns rects for multiple sibling masked elements', () => { + const a = withRect(document.createElement('div'), 0, 0, 50, 50); + a.setAttribute('data-bugdrop-mask', ''); + const b = withRect(document.createElement('div'), 100, 0, 50, 50); + b.setAttribute('data-bugdrop-mask', ''); + document.body.append(a, b); + + expect(collectMaskRects(document.body)).toEqual([ + { x: 0, y: 0, w: 50, h: 50 }, + { x: 100, y: 0, w: 50, h: 50 }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to see them fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — the two attribute tests get `[]` but expect rects. + +- [ ] **Step 3: Implement explicit-attribute collection** + +Replace the body of `collectMaskRects` in `src/widget/mask.ts`: + +```ts +const MASK_SELECTOR = '[data-bugdrop-mask]'; + +export function collectMaskRects(root: Element): MaskRect[] { + const rects: MaskRect[] = []; + const matches = root.querySelectorAll(MASK_SELECTOR); + for (const el of matches) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + rects.push({ x: rect.left, y: rect.top, w: rect.width, h: rect.height }); + } + return rects; +} +``` + +- [ ] **Step 4: Run the tests to see them pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — 5 tests (2 existing + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: collect mask rects for [data-bugdrop-mask] elements" +``` + +--- + +## Task 3: Add password and credit-card autocomplete defaults + +**Files:** +- Modify: `src/widget/mask.ts` +- Modify: `test/mask.test.ts` + +- [ ] **Step 1: Add tests for the built-in defaults** + +Append to `test/mask.test.ts`: + +```ts +describe('collectMaskRects — built-in defaults', () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + it('masks input[type="password"] without explicit attribute', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + document.body.appendChild(input); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); + + it('masks credit-card autocomplete inputs', () => { + const ccNumber = withRect(document.createElement('input'), 0, 0, 200, 30); + ccNumber.setAttribute('autocomplete', 'cc-number'); + const ccCsc = withRect(document.createElement('input'), 0, 40, 80, 30); + ccCsc.setAttribute('autocomplete', 'cc-csc'); + const ccExp = withRect(document.createElement('input'), 0, 80, 80, 30); + ccExp.setAttribute('autocomplete', 'cc-exp'); + document.body.append(ccNumber, ccCsc, ccExp); + + const rects = collectMaskRects(document.body); + expect(rects).toHaveLength(3); + expect(rects).toContainEqual({ x: 0, y: 0, w: 200, h: 30 }); + expect(rects).toContainEqual({ x: 0, y: 40, w: 80, h: 30 }); + expect(rects).toContainEqual({ x: 0, y: 80, w: 80, h: 30 }); + }); + + it('does not double-count an element matching multiple criteria', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + input.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(input); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to see them fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — defaults are not yet collected. + +- [ ] **Step 3: Add defaults to the selector with deduplication** + +Replace the implementation in `src/widget/mask.ts`: + +```ts +const EXPLICIT_SELECTOR = '[data-bugdrop-mask]'; +const DEFAULT_SELECTOR = + 'input[type="password"], input[autocomplete*="cc-number"], input[autocomplete*="cc-csc"], input[autocomplete*="cc-exp"]'; +const COMBINED_SELECTOR = `${EXPLICIT_SELECTOR}, ${DEFAULT_SELECTOR}`; + +export function collectMaskRects(root: Element): MaskRect[] { + const seen = new Set(); + const rects: MaskRect[] = []; + const matches = root.querySelectorAll(COMBINED_SELECTOR); + for (const el of matches) { + if (seen.has(el)) continue; + seen.add(el); + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + rects.push({ x: rect.left, y: rect.top, w: rect.width, h: rect.height }); + } + return rects; +} +``` + +The `seen` set guards against an element matching both selectors. `querySelectorAll` only returns each element once for a comma-separated selector, but the set future-proofs this once the top-most-ancestor logic in Task 4 starts adding manual collection paths. + +- [ ] **Step 4: Run the tests to see them pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: add default mask coverage for password and cc-* inputs" +``` + +--- + +## Task 4: Top-most ancestor rule, root inclusion, scoped collection + +**Files:** +- Modify: `src/widget/mask.ts` +- Modify: `test/mask.test.ts` + +- [ ] **Step 1: Add tests for nesting, scoping, and root inclusion** + +Append to `test/mask.test.ts`: + +```ts +describe('collectMaskRects — nesting and scoping', () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + it('returns parent-only rect when a masked element is inside another masked element', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + parent.setAttribute('data-bugdrop-mask', ''); + const child = withRect(document.createElement('div'), 10, 10, 50, 30); + child.setAttribute('data-bugdrop-mask', ''); + parent.appendChild(child); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('does not separately mask password input nested inside masked ancestor', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + parent.setAttribute('data-bugdrop-mask', ''); + const password = withRect(document.createElement('input'), 10, 10, 100, 20); + password.type = 'password'; + parent.appendChild(password); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('returns rects for descendant masks of an unmasked parent', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + const a = withRect(document.createElement('span'), 10, 10, 50, 20); + a.setAttribute('data-bugdrop-mask', ''); + const b = withRect(document.createElement('span'), 100, 10, 50, 20); + b.setAttribute('data-bugdrop-mask', ''); + parent.append(a, b); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([ + { x: 10, y: 10, w: 50, h: 20 }, + { x: 100, y: 10, w: 50, h: 20 }, + ]); + }); + + it('scoped collection ignores siblings outside the root', () => { + const target = withRect(document.createElement('div'), 0, 0, 200, 100); + const inside = withRect(document.createElement('span'), 10, 10, 50, 20); + inside.setAttribute('data-bugdrop-mask', ''); + target.appendChild(inside); + const outside = withRect(document.createElement('span'), 300, 0, 50, 20); + outside.setAttribute('data-bugdrop-mask', ''); + document.body.append(target, outside); + + expect(collectMaskRects(target)).toEqual([{ x: 10, y: 10, w: 50, h: 20 }]); + }); + + it('root inclusion: returns a rect when root itself is masked', () => { + const root = withRect(document.createElement('div'), 0, 0, 200, 100); + root.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(root); + + expect(collectMaskRects(root)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('root inclusion: returns a rect when root is a built-in default (password input)', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + document.body.appendChild(input); + + expect(collectMaskRects(input)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to see them fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — current implementation walks all descendants flatly and ignores `root` itself. The nested case will return both parent and child rects (and the password one); root inclusion returns nothing. + +- [ ] **Step 3: Rewrite `collectMaskRects` with proper traversal** + +Replace the body of `src/widget/mask.ts` (keep the `MaskRect` interface and selectors): + +```ts +const EXPLICIT_SELECTOR = '[data-bugdrop-mask]'; +const DEFAULT_SELECTOR = + 'input[type="password"], input[autocomplete*="cc-number"], input[autocomplete*="cc-csc"], input[autocomplete*="cc-exp"]'; + +function shouldMask(el: Element): boolean { + return el.matches(EXPLICIT_SELECTOR) || el.matches(DEFAULT_SELECTOR); +} + +function pushRect(el: Element, rects: MaskRect[]): void { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + rects.push({ x: rect.left, y: rect.top, w: rect.width, h: rect.height }); +} + +export function collectMaskRects(root: Element): MaskRect[] { + const rects: MaskRect[] = []; + + if (shouldMask(root)) { + pushRect(root, rects); + return rects; + } + + walk(root, rects); + return rects; +} + +function walk(node: Element, rects: MaskRect[]): void { + for (const child of Array.from(node.children)) { + if (shouldMask(child)) { + pushRect(child, rects); + // Top-most-ancestor rule: do not descend into masked subtrees. + continue; + } + walk(child, rects); + } +} +``` + +- [ ] **Step 4: Run the tests to see them pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — 14 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: top-most-ancestor rule and root inclusion for mask collection" +``` + +--- + +## Task 5: Document coordinates and visibility/opacity edge cases + +**Files:** +- Modify: `src/widget/mask.ts` +- Modify: `test/mask.test.ts` + +- [ ] **Step 1: Add tests for scrolled coordinates, visibility:hidden, opacity:0, and zero-size** + +Append to `test/mask.test.ts`: + +```ts +describe('collectMaskRects — coordinates and visibility', () => { + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('returns document coordinates by adding window.scrollX / scrollY', () => { + Object.defineProperty(window, 'scrollX', { value: 50, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 200, configurable: true }); + + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 60, y: 220, w: 100, h: 50 }]); + }); + + it('skips elements with zero getBoundingClientRect()', () => { + const div = withRect(document.createElement('div'), 0, 0, 0, 0); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([]); + }); + + it('includes visibility:hidden elements (defense in depth)', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + div.style.visibility = 'hidden'; + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); + + it('includes opacity:0 elements (defense in depth)', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + div.style.opacity = '0'; + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to see them fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — the scrolled-coordinates test gets `{x:10, y:20}` but expects `{x:60, y:220}`. Visibility/opacity/zero-size tests already pass with the current implementation, which is fine. + +- [ ] **Step 3: Update `pushRect` to add scroll offset** + +Replace the `pushRect` helper in `src/widget/mask.ts`: + +```ts +function pushRect(el: Element, rects: MaskRect[]): void { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return; + rects.push({ + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + w: rect.width, + h: rect.height, + }); +} +``` + +- [ ] **Step 4: Run the tests to see them pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — 18 tests. Existing non-scrolled tests still pass because `scrollX` and `scrollY` are reset to 0 in their `beforeEach`. + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: emit document-coordinate mask rects (scroll-aware)" +``` + +--- + +## Task 6: Implement `applyMaskToImage` with extracted `translateMaskRect` math + +**Files:** +- Modify: `src/widget/mask.ts` +- Modify: `test/mask.test.ts` + +The pure math is unit-tested; the canvas orchestration is exercised end-to-end in Tasks 9–12 (jsdom canvas pixel verification is unreliable, mirroring the existing approach in `test/cropScreenshot.test.ts`). + +- [ ] **Step 1: Add tests for the pure `translateMaskRect` math** + +Update the import at the top of `test/mask.test.ts`: + +```ts +import { collectMaskRects, applyMaskToImage, translateMaskRect } from '../src/widget/mask'; +``` + +Append to `test/mask.test.ts`: + +```ts +describe('translateMaskRect', () => { + it('scales a rect by pixelRatio with no origin offset', () => { + expect( + translateMaskRect({ x: 10, y: 20, w: 100, h: 50 }, 2, { x: 0, y: 0 }, 1000, 1000) + ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + }); + + it('subtracts originOffset before scaling', () => { + expect( + translateMaskRect({ x: 110, y: 220, w: 100, h: 50 }, 2, { x: 100, y: 200 }, 1000, 1000) + ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + }); + + it('clips a rect that overflows the canvas on the right and bottom', () => { + expect( + translateMaskRect({ x: 90, y: 90, w: 30, h: 30 }, 1, { x: 0, y: 0 }, 100, 100) + ).toEqual({ x: 90, y: 90, w: 10, h: 10 }); + }); + + it('clips a rect that starts to the left and above the canvas', () => { + expect( + translateMaskRect({ x: -10, y: -20, w: 30, h: 50 }, 1, { x: 0, y: 0 }, 100, 100) + ).toEqual({ x: 0, y: 0, w: 20, h: 30 }); + }); + + it('returns a non-positive size when fully outside the canvas', () => { + const out = translateMaskRect({ x: 1000, y: 1000, w: 50, h: 50 }, 1, { x: 0, y: 0 }, 100, 100); + expect(out.w).toBeLessThanOrEqual(0); + expect(out.h).toBeLessThanOrEqual(0); + }); +}); +``` + +- [ ] **Step 2: Run the new tests to see them fail** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: FAIL — `translateMaskRect` is not exported. + +- [ ] **Step 3: Implement `translateMaskRect` and the full `applyMaskToImage`** + +Append the following to `src/widget/mask.ts`: + +```ts +export function translateMaskRect( + rect: MaskRect, + pixelRatio: number, + originOffset: { x: number; y: number }, + canvasWidth: number, + canvasHeight: number +): MaskRect { + const rawX = (rect.x - originOffset.x) * pixelRatio; + const rawY = (rect.y - originOffset.y) * pixelRatio; + const rawW = rect.w * pixelRatio; + const rawH = rect.h * pixelRatio; + + const x = Math.max(0, rawX); + const y = Math.max(0, rawY); + const right = Math.min(canvasWidth, rawX + rawW); + const bottom = Math.min(canvasHeight, rawY + rawH); + + return { + x, + y, + w: right - x, + h: bottom - y, + }; +} +``` + +Replace the stub `applyMaskToImage` body with: + +```ts +export async function applyMaskToImage( + dataUrl: string, + rects: MaskRect[], + pixelRatio: number, + originOffset: { x: number; y: number } = { x: 0, y: 0 } +): Promise { + if (rects.length === 0) return dataUrl; + + const img = await loadImage(dataUrl); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + + ctx.drawImage(img, 0, 0); + ctx.fillStyle = '#000'; + for (const rect of rects) { + const t = translateMaskRect(rect, pixelRatio, originOffset, canvas.width, canvas.height); + if (t.w <= 0 || t.h <= 0) continue; + ctx.fillRect(t.x, t.y, t.w, t.h); + } + + return canvas.toDataURL('image/png'); +} + +function loadImage(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to apply privacy masks')); + img.src = dataUrl; + }); +} +``` + +- [ ] **Step 4: Run the tests to see them pass** + +```bash +npm test -- test/mask.test.ts +``` + +Expected: PASS — 23 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/mask.ts test/mask.test.ts +git commit -m "feat: implement applyMaskToImage with translateMaskRect helper" +``` + +--- + +## Task 7: Wire mask pipeline into `captureScreenshot` + +**Files:** +- Modify: `src/widget/screenshot.ts` +- Modify: `test/cropScreenshot.test.ts` + +The integration is small: `captureScreenshot` calls `collectMaskRects` and computes `originOffset` BEFORE invoking `toPng`, then `applyMaskToImage` on the result. Computing rects before the await ensures the document state is captured at the same instant capture begins. + +- [ ] **Step 1: Add an integration test verifying `captureScreenshot` short-circuits with no masks** + +Append to `test/cropScreenshot.test.ts`: + +```ts +import { captureScreenshot } from '../src/widget/screenshot'; + +describe('captureScreenshot integrates with mask pipeline', () => { + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + Object.defineProperty(window, 'devicePixelRatio', { value: 1, configurable: true }); + }); + + afterEach(() => { + delete (window as unknown as { __bugdropMockToPng?: unknown }).__bugdropMockToPng; + }); + + it('returns the toPng output unchanged when no masked elements exist', async () => { + const STUB = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + (window as unknown as { __bugdropMockToPng: () => Promise }).__bugdropMockToPng = + () => Promise.resolve(STUB); + + const result = await captureScreenshot(); + + // No masks → applyMaskToImage short-circuits and returns the input unchanged. + expect(result).toBe(STUB); + }); + + it('completes element-scoped capture when the picked element has a masked descendant', async () => { + const target = document.createElement('section'); + target.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 200, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 200, + toJSON() { + return {}; + }, + }) as DOMRect; + const masked = document.createElement('div'); + masked.setAttribute('data-bugdrop-mask', ''); + masked.getBoundingClientRect = () => + ({ + x: 10, + y: 10, + width: 50, + height: 30, + top: 10, + left: 10, + bottom: 40, + right: 60, + toJSON() { + return {}; + }, + }) as DOMRect; + target.appendChild(masked); + document.body.appendChild(target); + + const STUB = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + (window as unknown as { __bugdropMockToPng: () => Promise }).__bugdropMockToPng = + () => Promise.resolve(STUB); + + // Should not throw — exercises the element-scoped wiring. + await expect(captureScreenshot(target)).resolves.toBeDefined(); + }); +}); +``` + +The existing import line in `test/cropScreenshot.test.ts` already includes `beforeEach` and `afterEach`. Confirm before editing: + +```bash +grep -n "import.*beforeEach.*afterEach" test/cropScreenshot.test.ts +``` + +If they are missing, update the import to include them. + +- [ ] **Step 2: Run the integration test against the current (un-wired) implementation** + +```bash +npm test -- test/cropScreenshot.test.ts +``` + +Expected: PASS — both tests will pass even before the wiring change, because they exercise the no-mask paths. Treat this as a baseline check; the meaningful verification is that they continue to pass AFTER the wiring change in Step 3. + +- [ ] **Step 3: Wire mask collection and application into `captureScreenshot`** + +Open `src/widget/screenshot.ts`. Add the import at the top: + +```ts +import { collectMaskRects, applyMaskToImage } from './mask'; +``` + +Replace the body of `captureScreenshot` (currently `screenshot.ts:23-57`) with: + +```ts +export async function captureScreenshot( + element?: Element, + screenshotScale?: number +): Promise { + const target = element || document.body; + const isFullPage = !element; + + const pixelRatio = getPixelRatio(isFullPage, screenshotScale); + + const toPng = + (window as unknown as { __bugdropMockToPng?: typeof htmlToImage.toPng }).__bugdropMockToPng ?? + htmlToImage.toPng; + + const opts = { + cacheBust: true, + pixelRatio, + filter: (node: HTMLElement) => node.id !== 'bugdrop-host', + }; + + const rects = collectMaskRects(target); + const originOffset = element + ? (() => { + const r = element.getBoundingClientRect(); + return { x: r.left + window.scrollX, y: r.top + window.scrollY }; + })() + : { x: 0, y: 0 }; + + const capturePromise = toPng(target as HTMLElement, opts); + + let timer: ReturnType; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Screenshot capture timed out — the page may be too complex')), + CAPTURE_TIMEOUT_MS + ); + }); + + try { + const dataUrl = await Promise.race([capturePromise, timeoutPromise]); + return await applyMaskToImage(dataUrl, rects, pixelRatio, originOffset); + } finally { + clearTimeout(timer!); + } +} +``` + +- [ ] **Step 4: Run the unit + integration tests to confirm they still pass** + +```bash +npm test +``` + +Expected: PASS — all existing tests plus the two new wiring checks. Total ≈ 141 (from 116 + new mask tests + new wiring tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/widget/screenshot.ts test/cropScreenshot.test.ts +git commit -m "feat: apply mask layer in captureScreenshot pipeline" +``` + +--- + +## Task 8: Create E2E fixture HTMLs and rebuild widget + +**Files:** +- Create: `public/test/masking-basic.html` +- Create: `public/test/masking-nested.html` + +These pages load the widget the same way as `public/test/index.html` and contain elements the masking E2E tests reference by selector. + +- [ ] **Step 1: Inspect the existing test page conventions** + +```bash +ls public/test/ +head -40 public/test/index.html +``` + +Note the ` + + +``` + +- [ ] **Step 3: Create `public/test/masking-nested.html`** + +```html + + + + + BugDrop — Masking Nested + + + +

Masking — Nested Fixture

+ +
+

Outer container is masked — every descendant should be covered.

+
Inner element also marked (parent rect wins).
+

Sibling text inside the masked outer container.

+
+ +
+

This parent is NOT masked.

+
+ But this child IS masked — should appear black. +
+

This sibling text should appear in the screenshot.

+
+ + + + +``` + +- [ ] **Step 4: Rebuild the widget bundle** + +```bash +npm run build:widget +``` + +Expected: build completes without errors and writes `public/widget.js`. Confirms the new `mask.ts` module compiles into the bundle. + +- [ ] **Step 5: Commit** + +```bash +git add public/test/masking-basic.html public/test/masking-nested.html +git commit -m "test: add E2E fixtures for screenshot privacy masking" +``` + +--- + +## Task 9: E2E — default password mask + explicit element mask + no-op control + +**Files:** +- Modify: `e2e/widget.spec.ts` + +These tests use the real `html-to-image` capture (no `__bugdropMockToPng`) so we get genuine pixel output and can verify the mask landed where we expect. Each test reads the submitted screenshot's pixels via `page.evaluate`. + +- [ ] **Step 1: Verify `Page` is imported in the spec** + +```bash +grep -n "import.*Page" e2e/widget.spec.ts | head -3 +``` + +If `Page` is not imported, edit the import line near the top of `e2e/widget.spec.ts`: + +```ts +import { test, expect, type Page } from '@playwright/test'; +``` + +- [ ] **Step 2: Append a new `Screenshot Masking` describe block to the bottom of `e2e/widget.spec.ts`** + +```ts +test.describe('Screenshot Masking', () => { + // Sample a single pixel from a base64 PNG payload via a page-side canvas. + async function pixelAt( + page: Page, + dataUrl: string, + x: number, + y: number + ): Promise<[number, number, number, number]> { + return page.evaluate( + ({ dataUrl, x, y }) => + new Promise<[number, number, number, number]>((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d'); + if (!ctx) { + reject(new Error('no ctx')); + return; + } + ctx.drawImage(img, 0, 0); + const px = ctx.getImageData(x, y, 1, 1).data; + resolve([px[0], px[1], px[2], px[3]]); + }; + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + { dataUrl, x, y } + ); + } + + // Read an element's bounding rect in document coordinates from the live page. + async function docRectOf(page: Page, selector: string) { + return page.evaluate(sel => { + const el = document.querySelector(sel); + if (!el) throw new Error(`no element matches ${sel}`); + const r = el.getBoundingClientRect(); + return { + x: r.left + window.scrollX, + y: r.top + window.scrollY, + w: r.width, + h: r.height, + }; + }, selector); + } + + // Walk the standard feedback flow up to a captured screenshot, returning the submitted payload. + async function submitFeedbackWithFullPageCapture( + page: Page, + fixturePath: string + ): Promise<{ screenshot: string; pixelRatio: number }> { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto(fixturePath); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').waitFor(); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + + await host.locator('css=#title').fill('Mask test'); + await host.locator('css=#submit-btn').click(); + + // Choose Full Page capture. + await host.locator('css=[data-action="capture"]').click(); + + // Wait for annotation step (proves capture+mask completed). + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + + // Submit annotated screenshot. + await host.locator('css=[data-action="annotate-done"]').click(); + + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload captured'); + const pr = await page.evaluate(() => window.devicePixelRatio || 1); + return { screenshot: payload.screenshot as string, pixelRatio: pr }; + } + + test('masks input[type=password] by default', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + const rect = await docRectOf(page, '#password'); + const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio); + const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio); + + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('masks elements tagged with data-bugdrop-mask', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + const rect = await docRectOf(page, '#customer-panel'); + const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio); + const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio); + + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('does not mask unrelated elements', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + const rect = await docRectOf(page, '#public-note'); + const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio); + const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio); + + // Background of #public-note is light yellow; assert it's NOT solid black. + expect(await pixelAt(page, screenshot, cx, cy)).not.toEqual([0, 0, 0, 255]); + }); +}); +``` + +- [ ] **Step 3: Run the masking E2E tests** + +```bash +npm run test:e2e -- --grep "Screenshot Masking" +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/widget.spec.ts +git commit -m "test: e2e coverage for default and explicit screenshot masks" +``` + +--- + +## Task 10: E2E — inheritance and scrolled capture + +**Files:** +- Modify: `e2e/widget.spec.ts` + +- [ ] **Step 1: Append inheritance and scrolled tests INSIDE the existing `Screenshot Masking` describe block** + +After the three tests from Task 9 (still inside the describe block), append: + +```ts +test('parent mask covers all descendants (inheritance)', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-nested.html' + ); + + // Sample inside the deeply-nested .inner-masked element — the OUTER mask + // should already cover it, so this pixel must be opaque black. + const innerRect = await docRectOf(page, '.inner-masked'); + const ix = Math.floor((innerRect.x + innerRect.w / 2) * pixelRatio); + const iy = Math.floor((innerRect.y + innerRect.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, ix, iy)).toEqual([0, 0, 0, 255]); + + // Sibling area inside the masked outer container should also be covered. + // Sample 5px below the outer top edge — still inside the mask but outside + // any nested element. + const outerRect = await docRectOf(page, '#outer-masked'); + const ox = Math.floor((outerRect.x + 10) * pixelRatio); + const oy = Math.floor((outerRect.y + 5) * pixelRatio); + expect(await pixelAt(page, screenshot, ox, oy)).toEqual([0, 0, 0, 255]); +}); + +test('masked child of unmasked parent is masked; siblings are not', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-nested.html' + ); + + const child = await docRectOf(page, '#masked-child'); + const cx = Math.floor((child.x + child.w / 2) * pixelRatio); + const cy = Math.floor((child.y + child.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + + const sibling = await docRectOf(page, '#visible-sibling'); + const sx = Math.floor((sibling.x + sibling.w / 2) * pixelRatio); + const sy = Math.floor((sibling.y + sibling.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, sx, sy)).not.toEqual([0, 0, 0, 255]); +}); + +test('scrolled full-page capture masks an element below the initial viewport', async ({ + page, +}) => { + // A scrolled variant of the helper — same flow, but scrolls AFTER goto so the page is + // captured while the user is offset from the top. + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + // Inject a tall spacer + a masked target below the fold AT page load. + await page.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + const spacer = document.createElement('div'); + spacer.style.height = '2000px'; + spacer.id = 'spacer'; + const target = document.createElement('div'); + target.id = 'below-fold-mask'; + target.setAttribute('data-bugdrop-mask', ''); + target.style.cssText = 'width: 200px; height: 100px; background: #ccc;'; + target.textContent = 'sensitive'; + document.body.append(spacer, target); + }); + }); + + await page.goto('/test/masking-basic.html'); + await page.evaluate(() => window.scrollTo(0, 1500)); + + const host = page.locator('#bugdrop-host'); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Scroll mask'); + await host.locator('css=#submit-btn').click(); + await host.locator('css=[data-action="capture"]').click(); + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="annotate-done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload'); + const pr = await page.evaluate(() => window.devicePixelRatio || 1); + const screenshot = payload.screenshot as string; + + const rect = await docRectOf(page, '#below-fold-mask'); + const cx = Math.floor((rect.x + rect.w / 2) * pr); + const cy = Math.floor((rect.y + rect.h / 2) * pr); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); +}); +``` + +- [ ] **Step 2: Run the new tests** + +```bash +npm run test:e2e -- --grep "Screenshot Masking" +``` + +Expected: 6 tests pass (3 from Task 9 + 3 here). + +- [ ] **Step 3: Commit** + +```bash +git add e2e/widget.spec.ts +git commit -m "test: e2e coverage for mask inheritance and scrolled capture" +``` + +--- + +## Task 11: E2E — element-scoped capture (3 cases) + +**Files:** +- Modify: `e2e/widget.spec.ts` + +Element-scoped capture goes through the element-picker flow. Three sub-cases: (a) descendant of picked element is masked; (b) picked element itself has `data-bugdrop-mask`; (c) picked element is a password input. + +- [ ] **Step 1: Append an element-scoped helper and three tests inside the same describe block** + +After the tests from Task 10 (still inside the describe block), append: + +```ts +async function submitFeedbackWithElementCapture( + page: Page, + fixturePath: string, + selector: string +): Promise<{ screenshot: string; pixelRatio: number }> { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto(fixturePath); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').waitFor(); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Element scope test'); + await host.locator('css=#submit-btn').click(); + + // Choose "Select Element". + await host.locator('css=[data-action="element"]').click(); + + // Click the target element on the page (NOT inside the shadow root). + await page.locator(selector).click(); + + // Wait for annotation step. + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="annotate-done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload captured'); + const pr = await page.evaluate(() => window.devicePixelRatio || 1); + return { screenshot: payload.screenshot as string, pixelRatio: pr }; +} + +test('element-scoped capture masks descendant inside picked element', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithElementCapture( + page, + '/test/masking-nested.html', + '#unmasked-parent' + ); + + // Cropped image is the picked element only — coordinates are local to its bounds. + const childLocal = await page.evaluate(() => { + const parent = document.querySelector('#unmasked-parent') as HTMLElement; + const child = document.querySelector('#masked-child') as HTMLElement; + const p = parent.getBoundingClientRect(); + const c = child.getBoundingClientRect(); + return { x: c.left - p.left, y: c.top - p.top, w: c.width, h: c.height }; + }); + + const cx = Math.floor((childLocal.x + childLocal.w / 2) * pixelRatio); + const cy = Math.floor((childLocal.y + childLocal.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); +}); + +test('element-scoped capture masks the picked element itself', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithElementCapture( + page, + '/test/masking-nested.html', + '#outer-masked' + ); + + const size = await page.evaluate(() => { + const el = document.querySelector('#outer-masked') as HTMLElement; + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + const cx = Math.floor((size.w / 2) * pixelRatio); + const cy = Math.floor((size.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); +}); + +test('element-scoped capture masks a picked password input', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithElementCapture( + page, + '/test/masking-basic.html', + '#password' + ); + + const size = await page.evaluate(() => { + const el = document.querySelector('#password') as HTMLElement; + const r = el.getBoundingClientRect(); + return { w: r.width, h: r.height }; + }); + const cx = Math.floor((size.w / 2) * pixelRatio); + const cy = Math.floor((size.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); +}); +``` + +- [ ] **Step 2: Run the new tests** + +```bash +npm run test:e2e -- --grep "Screenshot Masking" +``` + +Expected: 9 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/widget.spec.ts +git commit -m "test: e2e coverage for element-scoped masking captures" +``` + +--- + +## Task 12: E2E — area-cropped capture and clean-baseline regression guard + +**Files:** +- Modify: `e2e/widget.spec.ts` + +- [ ] **Step 1: Append area-cropped and no-op tests inside the same describe block** + +After the tests from Task 11, append: + +```ts +test('area-cropped capture preserves masks inside the selected region', async ({ page }) => { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto('/test/masking-basic.html'); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Area test'); + await host.locator('css=#submit-btn').click(); + await host.locator('css=[data-action="area"]').click(); + + // Drag a rectangle around the customer panel. + const rect = await docRectOf(page, '#customer-panel'); + const startX = rect.x - 10; + const startY = rect.y - 10; + const endX = rect.x + rect.w + 10; + const endY = rect.y + rect.h + 10; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 5 }); + await page.mouse.up(); + + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="annotate-done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload'); + const pr = await page.evaluate(() => window.devicePixelRatio || 1); + + // The cropped image's geometric center should land inside the masked panel. + const cropW = endX - startX; + const cropH = endY - startY; + const cx = Math.floor((cropW / 2) * pr); + const cy = Math.floor((cropH / 2) * pr); + expect(await pixelAt(page, payload.screenshot as string, cx, cy)).toEqual([0, 0, 0, 255]); +}); + +test('clean baseline: page with no masked elements has no opaque-black sample at unrelated points', async ({ + page, +}) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(page, '/test/'); + + // Sample a handful of points; none should be exactly [0,0,0,255]. The standard fixture + // contains no masking attributes, so any solid-black 1px sample at these coordinates + // would be a regression. + const samplePoints: Array<[number, number]> = [ + [10, 10], + [50, 50], + [200, 100], + ]; + + for (const [x, y] of samplePoints) { + const px = await pixelAt( + page, + screenshot, + Math.floor(x * pixelRatio), + Math.floor(y * pixelRatio) + ); + expect(px).not.toEqual([0, 0, 0, 255]); + } +}); +``` + +- [ ] **Step 2: Run the new tests** + +```bash +npm run test:e2e -- --grep "Screenshot Masking" +``` + +Expected: 11 tests pass total. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/widget.spec.ts +git commit -m "test: e2e coverage for area-cropped masking and clean-baseline regression" +``` + +--- + +## Task 13: Documentation — security.mdx, installation.mdx, README.md + +**Files:** +- Modify: `docs/website/security.mdx` +- Modify: `docs/website/installation.mdx` +- Modify: `README.md` + +- [ ] **Step 1: Add a "Screenshot masking" subsection to `docs/website/security.mdx`** + +Insert the following block AFTER the existing `## Privacy` bullet list (immediately before the line that begins `The only network requests BugDrop makes are:`): + +````mdx +### Screenshot masking + +You can mark sensitive elements so they never appear in submitted screenshots. Add the +`data-bugdrop-mask` attribute to any element you want covered: + +```html + + +
+ Customer name + customer@example.com +
+``` + +When a user submits feedback, BugDrop paints an opaque rectangle over each tagged +element's bounding box on the captured PNG before showing the user the annotator +preview. The user sees what is masked and can audit it before submitting. + +**Inheritance.** When an ancestor has `data-bugdrop-mask`, the entire ancestor box is +masked as a single rectangle. Descendants do not get individual rectangles — this +prevents gaps from CSS `gap` or non-masked siblings inside a masked container. + +**Built-in defaults.** These are always masked, with or without an explicit attribute: + +- `input[type="password"]` +- Any input with `autocomplete="cc-number"`, `cc-csc`, or `cc-exp"` + +**Known limitations:** + +- Elements inside Shadow DOM and cross-origin iframes are not traversed in this + iteration. +- Mask rectangles are collected at the start of capture. If the page reflows or reveals + sensitive elements between collection and the moment `html-to-image` finishes + rendering, the mask may not cover the final pixels. Keep masked content stable during + the brief capture window. +```` + +- [ ] **Step 2: Add a "Protecting sensitive data" section to `docs/website/installation.mdx`** + +Insert AFTER the script-tag attribute documentation (find the section listing +`data-theme`, `data-position`, etc.) and BEFORE the next major heading: + +````mdx +## Protecting sensitive data + +If your page renders customer data, billing details, or any other content you do not +want to appear in submitted screenshots, mark those elements with `data-bugdrop-mask`: + +```html +
+ Jane Doe — jane@acme.com +
+``` + +BugDrop covers each marked element with an opaque rectangle on the captured screenshot. +Password inputs and credit-card autocomplete fields are masked automatically. See +[Screenshot masking](/security#screenshot-masking) on the Security page for details. +```` + +- [ ] **Step 3: Add a one-paragraph mention to `README.md`** + +Find the existing feature bullet list near the top of `README.md`. Add this bullet: + +```md +- 🔒 **Privacy masking** — tag sensitive elements with `data-bugdrop-mask` and BugDrop covers them in the screenshot before it's submitted. Passwords and credit-card inputs are masked automatically. +``` + +- [ ] **Step 4: Verify the build still passes** + +```bash +npm run build +``` + +Expected: TypeScript build succeeds. (Website MDX is rendered separately and not part of the TS build.) + +- [ ] **Step 5: Commit** + +```bash +git add docs/website/security.mdx docs/website/installation.mdx README.md +git commit -m "docs: document screenshot privacy masking feature" +``` + +--- + +## Final Verification + +After all tasks complete, run the full test suite: + +```bash +npm test # Unit + integration: ~141 tests (was 116) +npm run build:widget +npm run test:e2e # E2E: existing tests + 11 new Screenshot Masking tests +npm run lint # ESLint +``` + +All green → push branch and open PR per `CLAUDE.md` PR review gate (run the 6 pr-review-toolkit agents in parallel before creating the PR). + +--- + +## Self-Review Checklist + +- **Spec coverage:** + - Per-element `data-bugdrop-mask` attribute → Tasks 2, 4 + - Built-in defaults (password + cc-*) → Task 3 + - Inheritance (top-most ancestor rule) → Task 4 + - Solid black fill → Task 6 + - Document coordinates / scroll handling → Task 5, Task 10 + - All three capture modes (full / element / area) → Tasks 9, 11, 12 + - Fail-closed error handling → Task 6 (image-load reject), Task 7 (timeout finally clears) + - No new public API or script-tag attribute → Task 7 leaves index.ts untouched + - Docs (security/install/README) → Task 13 +- **Type consistency:** `MaskRect` defined in Task 1, used unchanged in Tasks 4, 5, 6, 7. Function signatures (`collectMaskRects`, `applyMaskToImage`, `translateMaskRect`) match between definition and call sites. +- **No placeholders:** every step contains the full code or command to run. diff --git a/docs/superpowers/specs/2026-05-10-screenshot-privacy-masking-design.md b/docs/superpowers/specs/2026-05-10-screenshot-privacy-masking-design.md new file mode 100644 index 0000000..c8be3fe --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-screenshot-privacy-masking-design.md @@ -0,0 +1,321 @@ +# Screenshot Privacy Masking + +**Date:** 2026-05-10 + +## Problem + +Screenshots captured by BugDrop currently include whatever is rendered on the page — +passwords, customer emails, billing details, and any other sensitive content. Site owners +have no way to mark elements as confidential before the screenshot is generated, which +makes BugDrop unsuitable for SaaS apps where screenshots will routinely capture +end-customer data. + +This is a privacy primitive: the goal is for site owners to declare sensitive regions +once in their markup and trust that those regions will never appear in a captured +screenshot, regardless of which user submits feedback or how the screenshot is taken +(full page, picked element, or area crop). + +## Scope + +**In scope (this spec):** +- Developer-configured masking via per-element HTML attribute +- Built-in defaults for unambiguously sensitive inputs (`type="password"`, credit-card + autocomplete fields) +- Solid-block visual style applied client-side before the annotator opens +- Coverage of all three capture modes: full page, element-scoped, area-cropped + +**Out of scope (deliberately deferred):** +- End-user redaction tool inside the annotator (planned as a follow-up layer) +- Global selector list via script-tag attribute (`data-mask-selectors=`) +- Aggressive auto-detection (regex-based PII matching in textContent) +- Shadow DOM and iframe content (documented as known limitations) + +## Approach + +Capture proceeds normally; masking is applied as a post-processing canvas pass on the +resulting PNG. This avoids any DOM mutation (no flicker, no failure-mode where capture +throws and the page is left visibly broken) and reuses the same canvas-blit pattern +already established by `cropScreenshot` in `src/widget/screenshot.ts`. + +The pipeline for every capture mode is: + +1. Walk the DOM rooted at the capture target, collect document-coordinate rectangles + for top-most masked ancestors and built-in defaults. +2. Run `html-to-image` unchanged. +3. Load the resulting PNG into an offscreen canvas, paint opaque rectangles over the + collected coordinates (translated to image space by `pixelRatio` and any document + origin offset), export as PNG. +4. Pass the masked PNG into the existing annotator unchanged. + +For the area-cropped mode, masks are baked into the full-page PNG before the existing +crop step runs, so the cropped output inherits the masks with no additional logic. + +## Public API + +### HTML Attribute + +```html + + +
+ Jane Doe + jane@acme.com +
+``` + +### Inheritance Rule + +When an ancestor has `data-bugdrop-mask`, a single rectangle is collected for the +ancestor's bounding box. The walker does not descend further into masked subtrees. +This avoids gaps from CSS `gap`, `margin`, or non-masked siblings inside a masked +container — the kind of leakage that would defeat the purpose of a privacy primitive. + +Selector for collection: top-most masked ancestors plus built-in defaults that are not +themselves inside a masked ancestor. + +### Built-in Defaults + +These are always masked, with or without an explicit attribute: + +- `input[type="password"]` +- `input[autocomplete*="cc-number"]` +- `input[autocomplete*="cc-csc"]` +- `input[autocomplete*="cc-exp"]` + +Defaults are folded into the same DOM walk as the explicit-attribute logic — there is +no separate code path. + +### Visual Style + +Masked regions are rendered as opaque black rectangles (`#000`, alpha 1.0) at the +element's bounding rectangle. No label, no blur, no transparency — chosen for +unambiguity and to eliminate any risk of partial leakage from low-radius blur. + +### No New JS API or Script-Tag Attribute + +The widget's `window.BugDrop` interface is unchanged. No new `data-*` attribute is +added to the loader script. Privacy is automatic when the markup is tagged. + +## New Module: `src/widget/mask.ts` + +Exports two pure functions: + +```ts +export interface MaskRect { + x: number; + y: number; + w: number; + h: number; +} + +export function collectMaskRects(root: Element): MaskRect[]; + +export function applyMaskToImage( + dataUrl: string, + rects: MaskRect[], + pixelRatio: number, + originOffset?: { x: number; y: number } +): Promise; +``` + +`collectMaskRects` returns document-coordinate rectangles for: +- `root` itself if it matches `[data-bugdrop-mask]`, `input[type="password"]`, or a + credit-card autocomplete default +- Top-most descendants of `root` matching `[data-bugdrop-mask]` +- `input[type="password"]` and credit-card autocomplete inputs not inside a masked + ancestor + +It skips elements with zero `getBoundingClientRect()` (not rendered, detached). It +does *not* skip elements with `visibility: hidden` or `opacity: 0`: both still occupy +layout, both could become visible mid-capture, and painting an extra black rectangle +over empty space costs nothing. This errs on the side of fail-closed. + +Document coordinates are derived from `getBoundingClientRect()` plus `window.scrollX` / +`window.scrollY`. This keeps full-page and area-crop math aligned with the existing +area picker, which returns document coordinates for the selected rectangle. + +`applyMaskToImage`: +- Creates an offscreen `` matching the image's natural dimensions +- Draws the source image +- For each rect, paints `(rectX − originOffset.x) * pixelRatio, + (rectY − originOffset.y) * pixelRatio, rectW * pixelRatio, rectH * pixelRatio` + in opaque black +- Clips rects that fall partly outside the canvas to its bounds +- Exports as PNG via `canvas.toDataURL('image/png')` +- Empty rects array short-circuits and returns the input data URL unchanged + +## Modified Module: `src/widget/screenshot.ts` + +`captureScreenshot(element?, screenshotScale?)` retains its existing signature. Inside, +after `toPng` resolves, the function: + +1. Calls `collectMaskRects(element ?? document.body)` to gather rects scoped to the + capture target +2. Computes `originOffset`: `{x: 0, y: 0}` for full-page, or the picked element's + document-coordinate origin for element-scoped captures: + `{ x: rect.left + window.scrollX, y: rect.top + window.scrollY }` +3. Calls `applyMaskToImage(pngDataUrl, rects, pixelRatio, originOffset)` and returns + its result + +`getPixelRatio` and `cropScreenshot` are unchanged. + +## Modified Module: `src/widget/index.ts` + +Only the area-crop branch needs verification — the masks are baked in by +`captureScreenshot` before the crop happens, so existing logic at lines 717–734 works +unchanged. No reordering required. + +## Coordinate Translation + +All collected rectangles are document coordinates: + +``` +docX = rect.left + window.scrollX +docY = rect.top + window.scrollY +imageX = (docX − originOffset.x) * pixelRatio +imageY = (docY − originOffset.y) * pixelRatio +imageW = rect.width * pixelRatio +imageH = rect.height * pixelRatio +``` + +For full-page captures, `originOffset` is `{ x: 0, y: 0 }`, so masks line up with the +same document-space coordinates used by `cropScreenshot` for area selection. For +element-scoped captures, `originOffset` is the selected element's document-space +origin so descendant masks are translated into the captured element image. + +Viewport-only coordinates are not sufficient: the user may be scrolled when capture +starts, and `src/widget/area-picker.ts` already returns document coordinates by adding +`window.scrollX` / `window.scrollY`. + +## Error Handling + +The guiding principle is **fail closed**: a screenshot that should have been masked +but was not is a privacy incident; a screenshot that did not get taken is a UX +annoyance. We always pick the second. + +| Failure | Behavior | +|---|---| +| `collectMaskRects` throws | Propagate; existing capture error UI shows "screenshot failed" with retry | +| Masked element has zero `getBoundingClientRect()` (`display:none`, detached) | Skip silently (no rect emitted) | +| Masked rect overflows captured element bounds | Clip to canvas bounds (no throw) | +| `applyMaskToImage` canvas context is `null` | Throw; caught by existing capture error path | +| `applyMaskToImage` source image fails to load | Throw with message "Failed to apply privacy masks" | + +There is no graceful-degradation mode that ships an unmasked screenshot when masking +fails. Developers who want that escape hatch can build it on top; the library does not +make that call for them. + +## Known Limitations + +- **Shadow DOM:** masked attributes inside shadow roots are not discovered by the + document walk in this iteration. +- **Iframes:** iframe contents are not traversed, including same-origin iframes. Hosts + should apply BugDrop masking inside those documents only after a future iframe-aware + implementation exists. +- **DOM changes during capture:** rectangles are collected before `html-to-image` + renders the PNG. If the page moves, resizes, expands, or reveals sensitive elements + between rect collection and rendering, a stale mask rectangle may not cover the final + rendered pixels. The implementation should keep the collect-to-render interval as + small as possible, but this spec does not attempt clone-time mask discovery. + +## Documentation Changes + +- `docs/website/security.mdx` — new "Screenshot masking" subsection under Privacy, + showing the attribute, inheritance rule, built-in defaults, and known limitations + (Shadow DOM, iframes, DOM changes during capture) +- `docs/website/installation.mdx` — short "Protecting sensitive data" section with + one code example +- `README.md` — one-paragraph mention with code example + +## Testing + +### Unit (Vitest, `test/mask.test.ts`) + +`collectMaskRects`: +- Returns rect for `
` +- Returns parent-only rect for nested `data-bugdrop-mask` (top-most ancestor rule) +- Returns rects for descendant `data-bugdrop-mask` of an unmasked parent +- Returns rect for `` without explicit attribute +- Returns rects for `autocomplete="cc-number"`, `cc-csc`, `cc-exp"` inputs +- Skips elements with zero `getBoundingClientRect()` +- Includes `visibility: hidden` and `opacity: 0` elements (defense in depth) +- Returns empty array for clean DOM +- Scoped collection: `collectMaskRects(element)` returns only `element` itself and its + descendants, never siblings or ancestors outside the capture target +- Root inclusion: `collectMaskRects(element)` returns a rect when `element` itself is + masked or is a built-in default such as a password input +- Scrolled page coordinates: collected rects include `window.scrollX` / `window.scrollY` + so offscreen masked elements use document coordinates + +`applyMaskToImage`: +- 100×100 white PNG + rect `{x:10, y:10, w:20, h:20}` + `pixelRatio:2` produces black + pixels in `(20,20)–(60,60)` and white pixels elsewhere (sample 4 corner pixels of + each region) +- `originOffset` correctly subtracts before scaling +- Empty rects array returns image unchanged (sample-pixel equality) +- Out-of-bounds rect is clipped without throwing + +### Integration (Vitest, extending existing patterns) + +- The `__bugdropMockToPng` window hook (`screenshot.ts:33`) is used to verify + `captureScreenshot` invokes `applyMaskToImage` after `toPng` resolves +- Verify rect collection happens on the right root for each capture mode (full body + vs picked element) + +### E2E (Playwright, `e2e/widget.spec.ts`) + +A new "Screenshot Masking" describe block: + +- **Default mask**: page with `` → trigger + feedback → submit → fetched PNG has opaque pixels at the input's bounding box + (read rect via `page.evaluate`, then sample-pixel check) +- **Explicit mask**: page with `
SECRET
` → opaque region + over the div +- **Inheritance**: `
nested
` → opaque region + covers the parent's full rect including margin/padding +- **Element-scoped capture with masked descendant**: pick an outer element, verify + the descendant's mask still appears in the cropped image at correct (translated) + coords +- **Element-scoped capture where selected element is masked**: pick an element that + itself has `data-bugdrop-mask`, verify the entire selected element image is masked +- **Element-scoped capture where selected element is a password input**: pick the + password input directly, verify the captured element image is masked +- **Area-cropped capture**: select an area overlapping a masked element, verify the + cropped image still has the mask applied +- **Scrolled full-page capture**: scroll down before capture, mask an element below the + initial viewport, and verify the mask appears at the element's document-coordinate + location in the full screenshot +- **Scrolled area-cropped capture**: scroll before area selection, select an area + overlapping a masked element, and verify the cropped image contains the translated + mask at the expected crop-local coordinates +- **No-op case**: page with no masked elements → screenshot pixel-identical to + baseline (regression guard against accidental masking) + +### Test Fixtures + +- `public/test/masking-basic.html` — single masked div, password input +- `public/test/masking-nested.html` — nested masked elements, mixed siblings + +These follow the existing `public/test/annotation-*.html` convention. + +### Out of Test Scope + +- Byte-exact pixel comparison of the PNG (use sample-region checks; `html-to-image` + output is not byte-stable across runs) +- Shadow DOM, iframe, and mid-capture DOM-change behavior (documented as known + limitations) +- Performance benchmarks (one extra canvas pass; add benchmarks only if it becomes + a measurable bottleneck) + +## Files Changed + +- **Create:** `src/widget/mask.ts` — rect collection and image-masking logic +- **Modify:** `src/widget/screenshot.ts` — invoke mask collection + application inside + `captureScreenshot` +- **Modify:** `docs/website/security.mdx` — Privacy subsection +- **Modify:** `docs/website/installation.mdx` — short config example +- **Modify:** `README.md` — one-paragraph mention with example +- **Create:** `test/mask.test.ts` — unit tests for both exports +- **Create:** `public/test/masking-basic.html` — E2E fixture +- **Create:** `public/test/masking-nested.html` — E2E fixture +- **Modify:** `e2e/widget.spec.ts` — Screenshot Masking describe block diff --git a/docs/website/installation.mdx b/docs/website/installation.mdx index 73215d2..3cefc41 100644 --- a/docs/website/installation.mdx +++ b/docs/website/installation.mdx @@ -71,6 +71,21 @@ You can add data attributes to customize the widget's appearance and behavior: See the [Configuration](/docs/configuration) and [Styling](/docs/styling) docs for all available attributes. +## Protecting sensitive data + +If your page renders customer data, billing details, or any other content you do not +want to appear in submitted screenshots, mark those elements with `data-bugdrop-mask`: + +```html +
+ Jane Doe — jane@acme.com +
+``` + +BugDrop covers each marked element with an opaque rectangle on the captured screenshot. +Password inputs and credit-card autocomplete fields are masked automatically. See +[Screenshot masking](/security#screenshot-masking) on the Security page for details. + ## Step 3: You Are Done That is it. Load your page and you will see the BugDrop button in the corner of your site. Click it to open the feedback form, fill it out, and submit -- a GitHub Issue will be created in your repository automatically. diff --git a/docs/website/security.mdx b/docs/website/security.mdx index 46e571f..1bb1bae 100644 --- a/docs/website/security.mdx +++ b/docs/website/security.mdx @@ -73,6 +73,48 @@ BugDrop is built with a privacy-first approach: - **Client-side screenshots** -- Screenshots are rendered in the user's browser, not captured server-side - **Open source** -- The entire codebase is open source (MIT licensed) and auditable +### Screenshot masking + +You can mark sensitive elements so they never appear in submitted screenshots. Add the +`data-bugdrop-mask` attribute to any element you want covered: + +```html + + +
+ Customer name + customer@example.com +
+``` + +When a user submits feedback, BugDrop paints an opaque rectangle over each tagged +element's bounding box on the captured PNG before showing the user the annotator +preview. The user sees what is masked and can audit it before submitting. + +**Inheritance.** When an ancestor has `data-bugdrop-mask`, the entire ancestor box is +masked as a single rectangle. Descendants do not get individual rectangles — this +prevents gaps from CSS `gap` or non-masked siblings inside a masked container. + +**Built-in defaults.** These are always masked, with or without an explicit attribute: + +- `input[type="password"]` +- Any input with `autocomplete="cc-number"`, `cc-csc`, or `cc-exp` + +**Known limitations:** + +- Elements inside Shadow DOM and cross-origin iframes are not traversed in this + iteration. +- Mask rectangles are collected at the start of capture. If the page reflows or reveals + sensitive elements between collection and the moment `html-to-image` finishes + rendering, the mask may not cover the final pixels. Keep masked content stable during + the brief capture window. +- **Viewport capture fallback (very complex pages):** When the page exceeds the + full-page DOM-complexity threshold, BugDrop falls back to a screen-recording + capture (via the browser's `getDisplayMedia` API). That path captures the + visible viewport directly and does not apply element masks. If you rely on + masking, ensure the user is not capturing through this fallback when sensitive + elements are visible. + The only network requests BugDrop makes are: 1. Loading the widget script from Cloudflare Workers diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts index 8ac4794..28e1828 100644 --- a/e2e/widget.spec.ts +++ b/e2e/widget.spec.ts @@ -3656,3 +3656,638 @@ test.describe('Screenshot Mode Configuration', () => { expect(getPayload()?.screenshot).toEqual(expect.stringMatching(/^data:image\/png;base64,/)); }); }); + +test.describe('Screenshot Masking', () => { + // Sample a single pixel from a base64 PNG payload via a page-side canvas. + async function pixelAt( + page: Page, + dataUrl: string, + x: number, + y: number + ): Promise<[number, number, number, number]> { + return page.evaluate( + ({ dataUrl, x, y }) => + new Promise<[number, number, number, number]>((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d'); + if (!ctx) { + reject(new Error('no ctx')); + return; + } + ctx.drawImage(img, 0, 0); + const px = ctx.getImageData(x, y, 1, 1).data; + resolve([px[0], px[1], px[2], px[3]]); + }; + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + { dataUrl, x, y } + ); + } + + // Read an element's bounding rect in document coordinates from the live page. + async function docRectOf(page: Page, selector: string) { + return page.evaluate(sel => { + const el = document.querySelector(sel); + if (!el) throw new Error(`no element matches ${sel}`); + const r = el.getBoundingClientRect(); + return { + x: r.left + window.scrollX, + y: r.top + window.scrollY, + w: r.width, + h: r.height, + }; + }, selector); + } + + // Walk the standard feedback flow up to a captured screenshot, returning the submitted payload. + async function submitFeedbackWithFullPageCapture( + page: Page, + fixturePath: string + ): Promise<{ screenshot: string; pixelRatio: number }> { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto(fixturePath); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').waitFor(); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + + await host.locator('css=#title').fill('Mask test'); + + // Opt in to screenshot capture. + await host.locator('css=#include-screenshot').check(); + await host.locator('css=#submit-btn').click(); + + // Choose Full Page capture. + await expect(host.locator('css=[data-action="capture"]')).toBeVisible({ timeout: 5000 }); + await host.locator('css=[data-action="capture"]').click(); + + // Wait for annotation step (proves capture+mask completed). + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + + // Submit annotated screenshot. + await host.locator('css=[data-action="done"]').click(); + + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload captured'); + + // Derive the actual pixel ratio from the image dimensions rather than + // window.devicePixelRatio, because the widget's getPixelRatio() enforces a + // minimum scale of 2 regardless of the browser's DPR. + const screenshotDataUrl = payload.screenshot as string; + const pr = await page.evaluate( + ({ dataUrl }) => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const vw = window.innerWidth; + resolve(vw > 0 ? img.naturalWidth / vw : 1); + }; + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + { dataUrl: screenshotDataUrl } + ); + return { screenshot: screenshotDataUrl, pixelRatio: pr }; + } + + test('masks input[type=password] by default', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + const rect = await docRectOf(page, '#password'); + const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio); + const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio); + + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('masks elements tagged with data-bugdrop-mask', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + const rect = await docRectOf(page, '#customer-panel'); + const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio); + const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio); + + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('does not mask unrelated elements', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-basic.html' + ); + + // Sample inside the panel's padding (top-left, ~3-4px in) where the yellow + // background is guaranteed to be rendered without overlapping text. Sampling + // the geometric center can land on anti-aliased glyph pixels in headless CI + // and produce a spurious [0,0,0,255] match. + const rect = await docRectOf(page, '#public-note'); + const sx = Math.floor((rect.x + 4) * pixelRatio); + const sy = Math.floor((rect.y + 4) * pixelRatio); + expect(await pixelAt(page, screenshot, sx, sy)).not.toEqual([0, 0, 0, 255]); + }); + + test('parent mask covers all descendants (inheritance)', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-nested.html' + ); + + // Sample inside the deeply-nested .inner-masked element — the OUTER mask + // should already cover it, so this pixel must be opaque black. + const innerRect = await docRectOf(page, '.inner-masked'); + const ix = Math.floor((innerRect.x + innerRect.w / 2) * pixelRatio); + const iy = Math.floor((innerRect.y + innerRect.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, ix, iy)).toEqual([0, 0, 0, 255]); + + // Sibling area inside the masked outer container should also be covered. + // Sample 5px below the outer top edge — still inside the mask but outside + // any nested element. + const outerRect = await docRectOf(page, '#outer-masked'); + const ox = Math.floor((outerRect.x + 10) * pixelRatio); + const oy = Math.floor((outerRect.y + 5) * pixelRatio); + expect(await pixelAt(page, screenshot, ox, oy)).toEqual([0, 0, 0, 255]); + }); + + test('masked child of unmasked parent is masked; siblings are not', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-nested.html' + ); + + const child = await docRectOf(page, '#masked-child'); + const cx = Math.floor((child.x + child.w / 2) * pixelRatio); + const cy = Math.floor((child.y + child.h / 2) * pixelRatio); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + + // The sibling is a

with no padding, so any pixel inside its rect could + // overlap rendered glyphs and produce a spurious solid-black sample on + // anti-aliased headless rendering. A real mask would make EVERY pixel inside + // the rect solid black; sampling four corners and asserting at least one is + // non-black is sufficient to disprove masking and is robust to text + // rendering differences across environments. + const sibling = await docRectOf(page, '#visible-sibling'); + const corners: Array<[number, number]> = [ + [sibling.x + 1, sibling.y + 1], + [sibling.x + sibling.w - 2, sibling.y + 1], + [sibling.x + 1, sibling.y + sibling.h - 2], + [sibling.x + sibling.w - 2, sibling.y + sibling.h - 2], + ]; + const samples = await Promise.all( + corners.map(([x, y]) => + pixelAt(page, screenshot, Math.floor(x * pixelRatio), Math.floor(y * pixelRatio)) + ) + ); + const anyNonBlack = samples.some(px => !(px[0] === 0 && px[1] === 0 && px[2] === 0)); + expect(anyNonBlack).toBe(true); + }); + + test('scrolled full-page capture masks an element below the initial viewport', async ({ + page, + }) => { + // A scrolled variant of the helper — same flow, but scrolls AFTER goto so the page is + // captured while the user is offset from the top. + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + // Inject a tall spacer + a masked target below the fold AT page load. + await page.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + const spacer = document.createElement('div'); + spacer.style.height = '2000px'; + spacer.id = 'spacer'; + const target = document.createElement('div'); + target.id = 'below-fold-mask'; + target.setAttribute('data-bugdrop-mask', ''); + target.style.cssText = 'width: 200px; height: 100px; background: #ccc;'; + target.textContent = 'sensitive'; + document.body.append(spacer, target); + }); + }); + + await page.goto('/test/masking-basic.html'); + await page.evaluate(() => window.scrollTo(0, 1500)); + + const host = page.locator('#bugdrop-host'); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Scroll mask'); + // Note: in optional mode, the include-screenshot checkbox must be checked. + await host.locator('css=#include-screenshot').check(); + await host.locator('css=#submit-btn').click(); + await host.locator('css=[data-action="capture"]').click(); + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload'); + + const screenshot = payload.screenshot as string; + // Infer pixelRatio the same way the existing helper does (full-page capture): + // naturalWidth / window.innerWidth. + const pr = await page.evaluate( + dataUrl => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img.naturalWidth / window.innerWidth); + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + screenshot + ); + + const rect = await docRectOf(page, '#below-fold-mask'); + const cx = Math.floor((rect.x + rect.w / 2) * pr); + const cy = Math.floor((rect.y + rect.h / 2) * pr); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + // Walk the element-picker flow and capture the chosen element. + // + // `selector` identifies the element to click in the picker. Because the picker + // resolves the DEEPEST element at the click point (via elementsFromPoint), pass + // `clickOffset` to land on the element's own padding rather than a child. + // Defaults to the element's center. + // + // Returns the screenshot data URL and the image's natural pixel dimensions. + // Dimensions are read from the image itself so they are always consistent with + // the actual pixels — html-to-image uses clientWidth/clientHeight which can + // differ from offsetWidth/offsetHeight when layout changes during capture. + async function submitFeedbackWithElementCapture( + page: Page, + fixturePath: string, + selector: string, + clickOffset?: { x: number; y: number } + ): Promise<{ screenshot: string; imageSize: { w: number; h: number } }> { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto(fixturePath); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').waitFor(); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Element scope test'); + await host.locator('css=#include-screenshot').check(); + await host.locator('css=#submit-btn').click(); + + // Choose "Select Element". + await host.locator('css=[data-action="element"]').click(); + + // Wait for the element picker tooltip to confirm picker mode is active. + await expect(page.locator('#bugdrop-element-picker-tooltip')).toBeVisible({ timeout: 5000 }); + + // Click the target element using mouse coordinates. The picker intercepts + // pointer events at document level via elementsFromPoint, which returns the + // DEEPEST element at the cursor — use clickOffset to land on the element's + // own padding when you need to select the element rather than a child. + const target = page.locator(selector); + await expect(target).toBeVisible({ timeout: 5000 }); + const targetBox = await target.boundingBox(); + if (!targetBox) throw new Error(`element not found or has no bounding box: ${selector}`); + const clickX = targetBox.x + (clickOffset?.x ?? targetBox.width / 2); + const clickY = targetBox.y + (clickOffset?.y ?? targetBox.height / 2); + await page.mouse.move(clickX, clickY); + await page.mouse.click(clickX, clickY); + + // Wait for annotation step. + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload captured'); + + const screenshot = payload.screenshot as string; + + // Read the image's natural dimensions directly — these are ground truth for + // any coordinate computation. + const imageSize = await page.evaluate( + dataUrl => + new Promise<{ w: number; h: number }>((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight }); + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + screenshot + ); + + return { screenshot, imageSize }; + } + + test('element-scoped capture masks descendant inside picked element', async ({ page }) => { + // Click inside the top padding of #unmasked-parent (above the first

child) + // so the picker's elementsFromPoint resolves the parent, not a child element. + // The 16px top padding gives ~8px of safe click area before the first child. + const { screenshot, imageSize } = await submitFeedbackWithElementCapture( + page, + '/test/masking-nested.html', + '#unmasked-parent', + { x: 40, y: 8 } // 8px from top = within the 16px top padding + ); + + // Measure child geometry relative to the parent using the image's own scale. + // The image width / parent clientWidth gives the pixelRatio used by html-to-image. + const geometry = await page.evaluate(() => { + const parent = document.querySelector('#unmasked-parent') as HTMLElement; + const child = document.querySelector('#masked-child') as HTMLElement; + const p = parent.getBoundingClientRect(); + const c = child.getBoundingClientRect(); + return { + parentClientW: parent.clientWidth, + childRelX: c.left - p.left, + childRelY: c.top - p.top, + childW: c.width, + childH: c.height, + }; + }); + + const pr = imageSize.w / geometry.parentClientW; + const cx = Math.floor((geometry.childRelX + geometry.childW / 2) * pr); + const cy = Math.floor((geometry.childRelY + geometry.childH / 2) * pr); + + // Sanity check: the child must fall within the captured image height. + expect(cy).toBeLessThan(imageSize.h); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('element-scoped capture masks the picked element itself', async ({ page }) => { + const { screenshot, imageSize } = await submitFeedbackWithElementCapture( + page, + '/test/masking-nested.html', + '#outer-masked' + ); + + // The mask covers the entire captured image (root element is masked). + // Use the image center — guaranteed in-bounds regardless of pixelRatio. + const cx = Math.floor(imageSize.w / 2); + const cy = Math.floor(imageSize.h / 2); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('element-scoped capture masks a picked password input', async ({ page }) => { + const { screenshot, imageSize } = await submitFeedbackWithElementCapture( + page, + '/test/masking-basic.html', + '#password' + ); + + // The mask covers the entire captured image (password input is masked at root). + const cx = Math.floor(imageSize.w / 2); + const cy = Math.floor(imageSize.h / 2); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('area-cropped capture preserves masks inside the selected region', async ({ page }) => { + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto('/test/masking-basic.html'); + const host = page.locator('#bugdrop-host'); + + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Area test'); + await host.locator('css=#include-screenshot').check(); + await host.locator('css=#submit-btn').click(); + + // Read the customer-panel's viewport (client) rect BEFORE clicking "Select Area", + // because the area picker overlay needs client coordinates (clientX/clientY). + const clientRect = await page.evaluate(() => { + const el = document.querySelector('#customer-panel'); + if (!el) throw new Error('no #customer-panel'); + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.top, w: r.width, h: r.height }; + }); + const startX = clientRect.x - 10; + const startY = clientRect.y - 10; + const endX = clientRect.x + clientRect.w + 10; + const endY = clientRect.y + clientRect.h + 10; + const cropW = endX - startX; + const cropH = endY - startY; + + await host.locator('css=[data-action="area"]').click(); + + // Wait for the area picker overlay to appear (createAreaPicker has a 50ms delay). + await expect(page.locator('#bugdrop-area-picker-overlay')).toBeVisible({ timeout: 5000 }); + + // Drag a rectangle around the customer panel on the overlay. + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.waitForTimeout(50); + await page.mouse.move(endX, endY, { steps: 5 }); + await page.mouse.up(); + + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload'); + + // Infer pixelRatio from the cropped image. Cropped image width = cropW * pixelRatio. + const screenshot = payload.screenshot as string; + const pr = await page.evaluate( + ({ dataUrl, w }) => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img.naturalWidth / w); + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + { dataUrl: screenshot, w: cropW } + ); + + // The cropped image's geometric center should land inside the masked panel. + const cx = Math.floor((cropW / 2) * pr); + const cy = Math.floor((cropH / 2) * pr); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('scrolled area-cropped capture preserves masks at translated crop-local coordinates', async ({ + page, + }) => { + // Inject a tall spacer + a masked target below the fold. + await page.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + const spacer = document.createElement('div'); + spacer.style.height = '2000px'; + const target = document.createElement('div'); + target.id = 'scrolled-mask'; + target.setAttribute('data-bugdrop-mask', ''); + target.style.cssText = 'width: 200px; height: 100px; background: #ccc;'; + target.textContent = 'sensitive'; + document.body.append(spacer, target); + }); + }); + + let payload: Record | null = null; + await page.route('**/api/check/**', async route => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true }), + }) + ); + await page.route('**/feedback', async route => { + payload = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, issueNumber: 1, issueUrl: '#', isPublic: false }), + }); + }); + + await page.goto('/test/masking-basic.html'); + await page.evaluate(() => window.scrollTo(0, 1900)); + + const host = page.locator('#bugdrop-host'); + await host.locator('css=.bd-trigger').click(); + await host.locator('css=[data-action="continue"]').click(); + await host.locator('css=#title').fill('Scrolled area test'); + await host.locator('css=#include-screenshot').check(); + await host.locator('css=#submit-btn').click(); + await host.locator('css=[data-action="area"]').click(); + + // Wait for the area picker overlay (50ms initialization delay). + await expect(page.locator('#bugdrop-area-picker-overlay')).toBeVisible({ timeout: 5000 }); + + // Get viewport-coordinate rect of the masked element (it's now in the scrolled viewport). + const targetClient = await page.evaluate(() => { + const el = document.querySelector('#scrolled-mask') as HTMLElement; + const r = el.getBoundingClientRect(); + return { x: r.left, y: r.top, w: r.width, h: r.height }; + }); + + // Drag a rectangle around the masked target. Area picker uses CLIENT (viewport) coordinates. + const startX = targetClient.x - 10; + const startY = targetClient.y - 10; + const endX = targetClient.x + targetClient.w + 10; + const endY = targetClient.y + targetClient.h + 10; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.waitForTimeout(50); + await page.mouse.move(endX, endY, { steps: 5 }); + await page.mouse.up(); + + await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 30000 }); + await host.locator('css=[data-action="done"]').click(); + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 10000 }); + + if (!payload) throw new Error('no payload'); + const screenshot = payload.screenshot as string; + + // Cropped image's geometric center should be inside the masked target. + const cropW = endX - startX; + const cropH = endY - startY; + const pr = await page.evaluate( + ({ dataUrl, w }) => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img.naturalWidth / w); + img.onerror = () => reject(new Error('image load failed')); + img.src = dataUrl; + }), + { dataUrl: screenshot, w: cropW } + ); + const cx = Math.floor((cropW / 2) * pr); + const cy = Math.floor((cropH / 2) * pr); + expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); + }); + + test('clean baseline: page with no masked elements has no opaque-black sample at unrelated points', async ({ + page, + }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(page, '/test/'); + + // Sample a handful of points; none should be exactly [0,0,0,255]. The standard fixture + // contains no masking attributes, so any solid-black 1px sample at these coordinates + // would be a regression. + const samplePoints: Array<[number, number]> = [ + [10, 10], + [50, 50], + [200, 100], + ]; + + for (const [x, y] of samplePoints) { + const px = await pixelAt( + page, + screenshot, + Math.floor(x * pixelRatio), + Math.floor(y * pixelRatio) + ); + expect(px).not.toEqual([0, 0, 0, 255]); + } + }); +}); diff --git a/public/test/masking-basic.html b/public/test/masking-basic.html new file mode 100644 index 0000000..d5ca016 --- /dev/null +++ b/public/test/masking-basic.html @@ -0,0 +1,63 @@ + + + + + BugDrop — Masking Basic + + + +

Masking — Basic Fixture

+ +
+ + +
+ +
+ + +
+ +
+ +
+ Jane Doe — jane@acme.com — 555-0100 +
+
+ +
+ +
This text should appear in the screenshot.
+
+ + + + diff --git a/public/test/masking-nested.html b/public/test/masking-nested.html new file mode 100644 index 0000000..1859892 --- /dev/null +++ b/public/test/masking-nested.html @@ -0,0 +1,52 @@ + + + + + BugDrop — Masking Nested + + + +

Masking — Nested Fixture

+ +
+

Outer container is masked — every descendant should be covered.

+
Inner element also marked (parent rect wins).
+

Sibling text inside the masked outer container.

+
+ +
+

This parent is NOT masked.

+
+ But this child IS masked — should appear black. +
+

This sibling text should appear in the screenshot.

+
+ + + + diff --git a/src/widget/index.ts b/src/widget/index.ts index 647b688..56dc378 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -881,7 +881,8 @@ async function capturePromiseWithLoading( const screenshot = await capturePromise; loadingModal?.remove(); return screenshot; - } catch (_error) { + } catch (error) { + console.warn('[BugDrop] Screenshot capture failed:', error); loadingModal?.remove(); const allowSkip = opts?.allowSkip !== false; diff --git a/src/widget/mask.ts b/src/widget/mask.ts new file mode 100644 index 0000000..c6d22f0 --- /dev/null +++ b/src/widget/mask.ts @@ -0,0 +1,109 @@ +interface MaskRect { + x: number; + y: number; + w: number; + h: number; +} + +const EXPLICIT_SELECTOR = '[data-bugdrop-mask]'; +const DEFAULT_SELECTOR = + 'input[type="password"], input[autocomplete*="cc-number"], input[autocomplete*="cc-csc"], input[autocomplete*="cc-exp"]'; + +function shouldMask(el: Element): boolean { + return el.matches(EXPLICIT_SELECTOR) || el.matches(DEFAULT_SELECTOR); +} + +function pushRect(el: Element, rects: MaskRect[]): void { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + rects.push({ + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + w: rect.width, + h: rect.height, + }); +} + +export function collectMaskRects(root: Element): MaskRect[] { + const rects: MaskRect[] = []; + + if (shouldMask(root)) { + pushRect(root, rects); + return rects; + } + + walk(root, rects); + return rects; +} + +function walk(node: Element, rects: MaskRect[]): void { + for (const child of Array.from(node.children)) { + if (shouldMask(child)) { + pushRect(child, rects); + // Top-most-ancestor rule: do not descend into masked subtrees. + continue; + } + walk(child, rects); + } +} + +export function translateMaskRect( + rect: MaskRect, + pixelRatio: number, + originOffset: { x: number; y: number }, + canvasWidth: number, + canvasHeight: number +): MaskRect { + const rawX = (rect.x - originOffset.x) * pixelRatio; + const rawY = (rect.y - originOffset.y) * pixelRatio; + const rawW = rect.w * pixelRatio; + const rawH = rect.h * pixelRatio; + + const x = Math.max(0, rawX); + const y = Math.max(0, rawY); + const right = Math.min(canvasWidth, rawX + rawW); + const bottom = Math.min(canvasHeight, rawY + rawH); + + return { + x, + y, + w: right - x, + h: bottom - y, + }; +} + +export async function applyMaskToImage( + dataUrl: string, + rects: MaskRect[], + pixelRatio: number, + originOffset: { x: number; y: number } = { x: 0, y: 0 } +): Promise { + if (rects.length === 0) return dataUrl; + + const img = await loadImage(dataUrl); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + + ctx.drawImage(img, 0, 0); + ctx.fillStyle = '#000'; + for (const rect of rects) { + const t = translateMaskRect(rect, pixelRatio, originOffset, canvas.width, canvas.height); + if (!(t.w > 0 && t.h > 0)) continue; + ctx.fillRect(t.x, t.y, t.w, t.h); + } + + return canvas.toDataURL('image/png'); +} + +function loadImage(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to apply privacy masks')); + img.src = dataUrl; + }); +} diff --git a/src/widget/screenshot.ts b/src/widget/screenshot.ts index b37cef9..da672ec 100644 --- a/src/widget/screenshot.ts +++ b/src/widget/screenshot.ts @@ -1,4 +1,5 @@ import * as htmlToImage from 'html-to-image'; +import { collectMaskRects, applyMaskToImage } from './mask'; const CAPTURE_TIMEOUT_MS = 15_000; const DOM_COMPLEXITY_THRESHOLD = 3_000; @@ -197,9 +198,16 @@ export async function captureScreenshot( filter: (node: HTMLElement) => node.id !== 'bugdrop-host', }; - const capturePromise = toPng(target as HTMLElement, opts); + const rects = collectMaskRects(target); + let originOffset = { x: 0, y: 0 }; + if (element) { + const r = element.getBoundingClientRect(); + originOffset = { x: r.left + window.scrollX, y: r.top + window.scrollY }; + } - return withCaptureTimeout(capturePromise); + const capturePromise = toPng(target as HTMLElement, opts); + const dataUrl = await withCaptureTimeout(capturePromise); + return applyMaskToImage(dataUrl, rects, pixelRatio, originOffset); } export async function cropScreenshot( diff --git a/test/cropScreenshot.test.ts b/test/cropScreenshot.test.ts index 8b8a411..5471465 100644 --- a/test/cropScreenshot.test.ts +++ b/test/cropScreenshot.test.ts @@ -8,6 +8,7 @@ import { cropScreenshot, canCaptureViewportNatively, beginViewportCapture, + captureScreenshot, } from '../src/widget/screenshot'; describe('getPixelRatio', () => { @@ -164,3 +165,101 @@ describe('native viewport capture', () => { expect(window.__bugdropMockViewportCapture).toHaveBeenCalledOnce(); }); }); + +describe('captureScreenshot integrates with mask pipeline', () => { + let OriginalImage: typeof Image; + + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + Object.defineProperty(window, 'devicePixelRatio', { value: 1, configurable: true }); + + // jsdom does not fire Image onload for data URLs; replace with a stub that does. + OriginalImage = window.Image; + (window as unknown as { Image: unknown }).Image = class FakeImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 1; + naturalHeight = 1; + width = 1; + height = 1; + set src(_: string) { + // Fire onload asynchronously so callers can set it first. + Promise.resolve().then(() => this.onload?.()); + } + }; + + // jsdom does not implement canvas 2D context; stub it to avoid "Failed to get canvas context". + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage: vi.fn(), + fillRect: vi.fn(), + fillStyle: '', + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( + 'data:image/png;base64,masked' + ); + }); + + afterEach(() => { + (window as unknown as { Image: unknown }).Image = OriginalImage; + delete (window as unknown as { __bugdropMockToPng?: unknown }).__bugdropMockToPng; + vi.restoreAllMocks(); + }); + + it('returns the toPng output unchanged when no masked elements exist', async () => { + const STUB = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + (window as unknown as { __bugdropMockToPng: () => Promise }).__bugdropMockToPng = () => + Promise.resolve(STUB); + + const result = await captureScreenshot(); + + // No masks → applyMaskToImage short-circuits and returns the input unchanged. + expect(result).toBe(STUB); + }); + + it('completes element-scoped capture when the picked element has a masked descendant', async () => { + const target = document.createElement('section'); + target.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 200, + height: 200, + top: 0, + left: 0, + bottom: 200, + right: 200, + toJSON() { + return {}; + }, + }) as DOMRect; + const masked = document.createElement('div'); + masked.setAttribute('data-bugdrop-mask', ''); + masked.getBoundingClientRect = () => + ({ + x: 10, + y: 10, + width: 50, + height: 30, + top: 10, + left: 10, + bottom: 40, + right: 60, + toJSON() { + return {}; + }, + }) as DOMRect; + target.appendChild(masked); + document.body.appendChild(target); + + const STUB = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + (window as unknown as { __bugdropMockToPng: () => Promise }).__bugdropMockToPng = () => + Promise.resolve(STUB); + + // applyMaskToImage must have run: only it produces the masked sentinel. + await expect(captureScreenshot(target)).resolves.toBe('data:image/png;base64,masked'); + }); +}); diff --git a/test/mask.test.ts b/test/mask.test.ts new file mode 100644 index 0000000..b96b0a1 --- /dev/null +++ b/test/mask.test.ts @@ -0,0 +1,364 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { collectMaskRects, applyMaskToImage, translateMaskRect } from '../src/widget/mask'; + +describe('mask module exports', () => { + it('exports collectMaskRects', () => { + expect(typeof collectMaskRects).toBe('function'); + }); + + it('exports applyMaskToImage', () => { + expect(typeof applyMaskToImage).toBe('function'); + }); +}); + +function withRect(el: HTMLElement, x: number, y: number, w: number, h: number): HTMLElement { + el.getBoundingClientRect = () => + ({ + x, + y, + width: w, + height: h, + top: y, + left: x, + bottom: y + h, + right: x + w, + toJSON() { + return {}; + }, + }) as DOMRect; + return el; +} + +describe('collectMaskRects — explicit attribute', () => { + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('returns empty array for clean DOM', () => { + expect(collectMaskRects(document.body)).toEqual([]); + }); + + it('returns rect for a single masked div', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); + + it('returns rects for multiple sibling masked elements', () => { + const a = withRect(document.createElement('div'), 0, 0, 50, 50); + a.setAttribute('data-bugdrop-mask', ''); + const b = withRect(document.createElement('div'), 100, 0, 50, 50); + b.setAttribute('data-bugdrop-mask', ''); + document.body.append(a, b); + + expect(collectMaskRects(document.body)).toEqual([ + { x: 0, y: 0, w: 50, h: 50 }, + { x: 100, y: 0, w: 50, h: 50 }, + ]); + }); +}); + +describe('collectMaskRects — built-in defaults', () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + it('masks input[type="password"] without explicit attribute', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + document.body.appendChild(input); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); + + it('masks credit-card autocomplete inputs', () => { + const ccNumber = withRect(document.createElement('input'), 0, 0, 200, 30); + ccNumber.setAttribute('autocomplete', 'cc-number'); + const ccCsc = withRect(document.createElement('input'), 0, 40, 80, 30); + ccCsc.setAttribute('autocomplete', 'cc-csc'); + const ccExp = withRect(document.createElement('input'), 0, 80, 80, 30); + ccExp.setAttribute('autocomplete', 'cc-exp'); + document.body.append(ccNumber, ccCsc, ccExp); + + const rects = collectMaskRects(document.body); + expect(rects).toHaveLength(3); + expect(rects).toContainEqual({ x: 0, y: 0, w: 200, h: 30 }); + expect(rects).toContainEqual({ x: 0, y: 40, w: 80, h: 30 }); + expect(rects).toContainEqual({ x: 0, y: 80, w: 80, h: 30 }); + }); + + it('does not double-count an element matching multiple criteria', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + input.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(input); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); +}); + +describe('collectMaskRects — nesting and scoping', () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + it('returns parent-only rect when a masked element is inside another masked element', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + parent.setAttribute('data-bugdrop-mask', ''); + const child = withRect(document.createElement('div'), 10, 10, 50, 30); + child.setAttribute('data-bugdrop-mask', ''); + parent.appendChild(child); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('does not separately mask password input nested inside masked ancestor', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + parent.setAttribute('data-bugdrop-mask', ''); + const password = withRect(document.createElement('input'), 10, 10, 100, 20); + password.type = 'password'; + parent.appendChild(password); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('returns rects for descendant masks of an unmasked parent', () => { + const parent = withRect(document.createElement('div'), 0, 0, 200, 100); + const a = withRect(document.createElement('span'), 10, 10, 50, 20); + a.setAttribute('data-bugdrop-mask', ''); + const b = withRect(document.createElement('span'), 100, 10, 50, 20); + b.setAttribute('data-bugdrop-mask', ''); + parent.append(a, b); + document.body.appendChild(parent); + + expect(collectMaskRects(document.body)).toEqual([ + { x: 10, y: 10, w: 50, h: 20 }, + { x: 100, y: 10, w: 50, h: 20 }, + ]); + }); + + it('scoped collection ignores siblings outside the root', () => { + const target = withRect(document.createElement('div'), 0, 0, 200, 100); + const inside = withRect(document.createElement('span'), 10, 10, 50, 20); + inside.setAttribute('data-bugdrop-mask', ''); + target.appendChild(inside); + const outside = withRect(document.createElement('span'), 300, 0, 50, 20); + outside.setAttribute('data-bugdrop-mask', ''); + document.body.append(target, outside); + + expect(collectMaskRects(target)).toEqual([{ x: 10, y: 10, w: 50, h: 20 }]); + }); + + it('root inclusion: returns a rect when root itself is masked', () => { + const root = withRect(document.createElement('div'), 0, 0, 200, 100); + root.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(root); + + expect(collectMaskRects(root)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('root inclusion: returns a rect when root is a built-in default (password input)', () => { + const input = withRect(document.createElement('input'), 0, 0, 200, 30); + input.type = 'password'; + document.body.appendChild(input); + + expect(collectMaskRects(input)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + }); +}); + +describe('collectMaskRects — coordinates and visibility', () => { + beforeEach(() => { + document.body.replaceChildren(); + Object.defineProperty(window, 'scrollX', { value: 0, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }); + }); + + it('returns document coordinates by adding window.scrollX / scrollY', () => { + Object.defineProperty(window, 'scrollX', { value: 50, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 200, configurable: true }); + + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 60, y: 220, w: 100, h: 50 }]); + }); + + it('skips elements with zero getBoundingClientRect()', () => { + const div = withRect(document.createElement('div'), 0, 0, 0, 0); + div.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([]); + }); + + it('includes visibility:hidden elements (defense in depth)', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + div.style.visibility = 'hidden'; + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); + + it('includes opacity:0 elements (defense in depth)', () => { + const div = withRect(document.createElement('div'), 10, 20, 100, 50); + div.setAttribute('data-bugdrop-mask', ''); + div.style.opacity = '0'; + document.body.appendChild(div); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + }); +}); + +describe('translateMaskRect', () => { + it('scales a rect by pixelRatio with no origin offset', () => { + expect( + translateMaskRect({ x: 10, y: 20, w: 100, h: 50 }, 2, { x: 0, y: 0 }, 1000, 1000) + ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + }); + + it('subtracts originOffset before scaling', () => { + expect( + translateMaskRect({ x: 110, y: 220, w: 100, h: 50 }, 2, { x: 100, y: 200 }, 1000, 1000) + ).toEqual({ x: 20, y: 40, w: 200, h: 100 }); + }); + + it('clips a rect that overflows the canvas on the right and bottom', () => { + expect(translateMaskRect({ x: 90, y: 90, w: 30, h: 30 }, 1, { x: 0, y: 0 }, 100, 100)).toEqual({ + x: 90, + y: 90, + w: 10, + h: 10, + }); + }); + + it('clips a rect that starts to the left and above the canvas', () => { + expect( + translateMaskRect({ x: -10, y: -20, w: 30, h: 50 }, 1, { x: 0, y: 0 }, 100, 100) + ).toEqual({ x: 0, y: 0, w: 20, h: 30 }); + }); + + it('returns a non-positive size when fully outside the canvas', () => { + const out = translateMaskRect({ x: 1000, y: 1000, w: 50, h: 50 }, 1, { x: 0, y: 0 }, 100, 100); + expect(out.w).toBeLessThanOrEqual(0); + expect(out.h).toBeLessThanOrEqual(0); + }); +}); + +describe('applyMaskToImage', () => { + let OriginalImage: typeof Image; + let ctx: { + drawImage: ReturnType; + fillRect: ReturnType; + fillStyle: string; + }; + + beforeEach(() => { + // Replace Image with a stub that fires onload synchronously (jsdom doesn't for data URLs). + OriginalImage = window.Image; + (window as unknown as { Image: unknown }).Image = class FakeImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 200; + naturalHeight = 100; + width = 200; + height = 100; + set src(_: string) { + Promise.resolve().then(() => this.onload?.()); + } + }; + + ctx = { + drawImage: vi.fn(), + fillRect: vi.fn(), + fillStyle: '', + }; + + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D + ); + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( + 'data:image/png;base64,result' + ); + }); + + afterEach(() => { + (window as unknown as { Image: unknown }).Image = OriginalImage; + vi.restoreAllMocks(); + }); + + it('paints rects at translated coords (scales by pixelRatio)', async () => { + // FakeImage: naturalWidth=200, naturalHeight=100 → canvas is 200×100 + // rect {x:5,y:5,w:20,h:10}, pixelRatio=2, originOffset={x:0,y:0} + // translateMaskRect: rawX=10, rawY=10, rawW=40, rawH=20 + // right=min(200,10+40)=50, bottom=min(100,10+20)=30 → no clipping → fillRect(10,10,40,20) + await applyMaskToImage('data:image/png;base64,test', [{ x: 5, y: 5, w: 20, h: 10 }], 2, { + x: 0, + y: 0, + }); + expect(ctx.fillRect).toHaveBeenCalledWith(10, 10, 40, 20); + }); + + it('returns the input dataUrl unchanged when rects is empty', async () => { + const input = 'data:image/png;base64,original'; + const result = await applyMaskToImage(input, [], 2, { x: 0, y: 0 }); + expect(result).toBe(input); + // No canvas operations needed — short-circuit before any image load. + expect(ctx.fillRect).not.toHaveBeenCalled(); + }); + + it('subtracts originOffset before scaling', async () => { + // FakeImage: naturalWidth=200, naturalHeight=100 → canvas is 200×100 + // rect {x:105,y:205,w:20,h:10}, pixelRatio=2, originOffset={x:100,y:200} + // translateMaskRect: rawX=(105-100)*2=10, rawY=(205-200)*2=10, rawW=40, rawH=20 + // right=min(200,10+40)=50, bottom=min(100,10+20)=30 → no clipping → fillRect(10,10,40,20) + await applyMaskToImage('data:image/png;base64,test', [{ x: 105, y: 205, w: 20, h: 10 }], 2, { + x: 100, + y: 200, + }); + expect(ctx.fillRect).toHaveBeenCalledWith(10, 10, 40, 20); + }); + + it('skips a rect that translates to non-positive dimensions', async () => { + // A rect fully outside the canvas → translateMaskRect returns w≤0 or h≤0 → skipped. + // naturalWidth=200, so a rect at x=1000 with w=50 is entirely off-canvas. + await applyMaskToImage('data:image/png;base64,test', [{ x: 1000, y: 1000, w: 50, h: 50 }], 1, { + x: 0, + y: 0, + }); + expect(ctx.fillRect).not.toHaveBeenCalled(); + }); + + it('throws when canvas context is null', async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + await expect( + applyMaskToImage('data:image/png;base64,test', [{ x: 0, y: 0, w: 10, h: 10 }], 1) + ).rejects.toThrow('Failed to get canvas context'); + }); + + it('throws when image fails to load', async () => { + // Override Image so it fires onerror instead of onload. + (window as unknown as { Image: unknown }).Image = class ErrorImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 0; + naturalHeight = 0; + width = 0; + height = 0; + set src(_: string) { + Promise.resolve().then(() => this.onerror?.()); + } + }; + await expect( + applyMaskToImage('data:image/png;base64,bad', [{ x: 0, y: 0, w: 10, h: 10 }], 1) + ).rejects.toThrow('Failed to apply privacy masks'); + }); +});