diff --git a/docs/website/security.mdx b/docs/website/security.mdx index 6a37ca2..c6c202f 100644 --- a/docs/website/security.mdx +++ b/docs/website/security.mdx @@ -78,10 +78,10 @@ BugDrop is built with a privacy-first approach: ### Screenshot masking -You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add the `data-bugdrop-mask` attribute to any element you want covered: +You can mark sensitive elements so BugDrop visually covers them in supported screenshot modes. Add `data-bugdrop-redact` or `data-bugdrop-mask` to any element you want covered: ```html - +
Customer name @@ -89,9 +89,15 @@ You can mark sensitive elements so BugDrop visually covers them in supported scr
``` -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. +Supported explicit attributes are `data-bugdrop-redact`, `data-bd-redact`, +`data-bugdrop-redacted`, and `data-bugdrop-mask`. + +When a user submits feedback, BugDrop plans redactions from matching DOM +elements, then paints an opaque rectangle over each target's measured bounding +box on supported captured PNGs. In manual screenshot flows, the masked image is +shown in the annotator preview so the user can audit it before submitting. In +automatic screenshot mode, BugDrop applies supported masks but submits without +showing the preview step. Masking is best-effort visual coverage, not a data-loss-prevention or security boundary. Users should still review screenshots before submitting when the manual @@ -108,8 +114,14 @@ prevents gaps from CSS `gap` or non-masked siblings inside a masked container. **Known limitations:** -- Elements inside Shadow DOM and cross-origin iframes are not traversed in this - iteration. +- Elements inside open Shadow DOM are traversed when the browser exposes the + shadow root. Closed Shadow DOM cannot be traversed; mark the host element if + the whole custom control should be covered. +- Iframe contents are not traversed. Mark the iframe element itself if the whole + embedded frame should be visually covered. +- BugDrop does not inspect pixels or text inside canvas, image, video, plugin, + or iframe content. Mark the containing DOM element if that entire region should + be visually covered. - 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 diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts index 92c8ee2..54826fd 100644 --- a/e2e/widget.spec.ts +++ b/e2e/widget.spec.ts @@ -2855,6 +2855,39 @@ test.describe('Screenshot Crash Prevention (#67)', () => { await expect(errorText).not.toBeVisible({ timeout: 3000 }); }); + test('privacy masking failure shows a dedicated modal and submits without a screenshot', async ({ + page, + }) => { + const payloads = await trackFeedbackPayloads(page); + // toPng resolves with a data URL that fails Image.onload, so applyMaskToImage + // rejects with MaskApplicationError. Use /test/redaction.html so the page has + // a [data-bugdrop-redact] element — otherwise applyMaskToImage early-returns + // when rects.length === 0 and the failure path never fires. + await mockHtmlToImage( + page, + "function() { return Promise.resolve('data:image/png;base64,not-a-real-image'); }" + ); + await page.goto('/test/redaction.html'); + await navigateToFullPageCapture(page); + + const host = page.locator('#bugdrop-host'); + const modalTitle = host.locator('css=.bd-title'); + await expect(modalTitle).toHaveText('Privacy masking failed', { timeout: 5000 }); + + await expect(host.locator('css=.bd-error-message__text')).toContainText( + 'Automatic redaction of private fields could not be applied' + ); + // Retry must NOT be offered for masking failures — retrying would fail the + // same way and a user might be tempted to send unredacted output. + await expect(host.locator('css=[data-action="retry"]')).not.toBeAttached(); + + await host.locator('css=[data-action="skip"]').click(); + + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 }); + expect(payloads).toHaveLength(1); + expect(payloads[0].screenshot).toBeNull(); + }); + test('retry button on error modal re-attempts capture', async ({ page }) => { // First call fails, second call succeeds await mockHtmlToImage( @@ -3252,7 +3285,7 @@ test.describe('Screenshot Crash Prevention (#67)', () => { }); test('remembers complex-page skip after element capture failure skip', async ({ page }) => { - await trackFeedbackSubmissions(page); + const payloads = await trackFeedbackPayloads(page); await mockHtmlToImage(page, "function() { return Promise.reject(new Error('mock failure')); }"); await page.goto('/test/complex-dom.html?nodes=12000'); @@ -3271,6 +3304,8 @@ test.describe('Screenshot Crash Prevention (#67)', () => { await expect(host.locator('css=.bd-error-message__text')).toBeVisible({ timeout: 5000 }); await host.locator('css=[data-action="skip"]').click(); await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 }); + expect(payloads).toHaveLength(1); + expect((payloads[0].metadata as { elementSelector?: string }).elementSelector).toContain('h1'); await host.locator('css=.bd-close').click(); await host.locator('css=.bd-trigger').click(); @@ -3278,6 +3313,27 @@ test.describe('Screenshot Crash Prevention (#67)', () => { await expect(host.locator('css=#include-screenshot')).not.toBeChecked(); }); + test('does not remember complex-page skip after element picker cancellation', async ({ + page, + }) => { + const payloads = await trackFeedbackPayloads(page); + await page.goto('/test/complex-dom.html?nodes=12000'); + + const host = await navigateToScreenshotOptions(page); + await host.locator('css=[data-action="element"]').click(); + await expect(page.locator('#bugdrop-element-picker-tooltip')).toBeVisible({ timeout: 5000 }); + await page.keyboard.press('Escape'); + + await expect(host.locator('css=.bd-success-icon')).toBeVisible({ timeout: 5000 }); + expect(payloads).toHaveLength(1); + expect(payloads[0].screenshot).toBeNull(); + + await host.locator('css=.bd-close').click(); + await host.locator('css=.bd-trigger').click(); + await expect(host.locator('css=#title')).toBeVisible({ timeout: 5000 }); + await expect(host.locator('css=#include-screenshot')).toBeChecked(); + }); + test('shows all buttons on pages below 10k nodes', async ({ page }) => { await mockHtmlToImage(page, spyToPng()); await page.goto('/test/complex-dom.html?nodes=4000'); @@ -4318,6 +4374,57 @@ test.describe('Screenshot Masking', () => { expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]); }); + test('full-page capture masks transformed redaction targets', async ({ page }) => { + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-layout-edge.html' + ); + + const rect = await docRectOf(page, '#transformed-mask'); + 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('full-page capture masks sticky redaction targets after scrolling', async ({ page }) => { + await page.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + requestAnimationFrame(() => window.scrollTo(0, 420)); + }); + }); + + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-layout-edge.html' + ); + + const rect = await docRectOf(page, '#sticky-mask'); + 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('full-page capture masks fixed redaction targets after scrolling', async ({ page }) => { + await page.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + requestAnimationFrame(() => window.scrollTo(0, 420)); + }); + }); + + const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture( + page, + '/test/masking-layout-edge.html' + ); + + const rect = await docRectOf(page, '#fixed-mask'); + 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]); + }); + // Walk the element-picker flow and capture the chosen element. // // `selector` identifies the element to click in the picker. Because the picker diff --git a/public/test/masking-layout-edge.html b/public/test/masking-layout-edge.html new file mode 100644 index 0000000..21af5a3 --- /dev/null +++ b/public/test/masking-layout-edge.html @@ -0,0 +1,68 @@ + + + + + BugDrop - Masking Layout Edge Cases + + + +

Masking Layout Edge Cases

+

Fixture used by Playwright for transformed and sticky redaction geometry.

+ +
+ transformed private account id +
+ +
+ +
sticky private session id
+ +
+ +
fixed private token
+ + + + diff --git a/src/widget/annotation-flow.ts b/src/widget/annotation-flow.ts new file mode 100644 index 0000000..e521627 --- /dev/null +++ b/src/widget/annotation-flow.ts @@ -0,0 +1,89 @@ +import { createAnnotator, type Tool } from './annotator'; +import { createModal, redactionNoteHtml } from './ui'; + +export function showAnnotationStep( + root: HTMLElement, + screenshot: string, + redactionCount = 0, + opts?: { redactionUnavailable?: boolean } +): Promise { + return new Promise(resolve => { + let redactionNote = ''; + if (opts?.redactionUnavailable) { + redactionNote = redactionNoteHtml( + 'This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.' + ); + } else if (redactionCount > 0) { + redactionNote = redactionNoteHtml( + `${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.` + ); + } + const modal = createModal( + root, + 'Review Screenshot', + ` + ${redactionNote} +

+ Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image. +

+
+ + + + + +
+
+
+ + +
+ `, + false, + 'bd-modal--annotator' + ); + + const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement; + const annotator = createAnnotator(canvasContainer, screenshot); + + const toolButtons = modal.querySelectorAll('[data-tool]'); + toolButtons.forEach(btn => { + btn.addEventListener('click', e => { + const target = e.currentTarget as HTMLElement; + const tool = target.dataset.tool; + + if (tool) { + toolButtons.forEach(b => b.classList.remove('active')); + target.classList.add('active'); + annotator.setTool(tool as Tool); + } + }); + }); + + const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null; + undoBtn?.addEventListener('click', () => annotator.undo()); + + const closeBtn = modal.querySelector('.bd-close') as HTMLElement; + const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement; + const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement; + + closeBtn?.addEventListener('click', () => { + annotator.destroy(); + modal.remove(); + resolve('cancel'); + }); + + retakeBtn?.addEventListener('click', () => { + annotator.destroy(); + modal.remove(); + resolve('retake'); + }); + + doneBtn?.addEventListener('click', () => { + const annotated = annotator.getImageData(); + annotator.destroy(); + modal.remove(); + resolve(annotated); + }); + }); +} diff --git a/src/widget/capture-flow.ts b/src/widget/capture-flow.ts new file mode 100644 index 0000000..a659d45 --- /dev/null +++ b/src/widget/capture-flow.ts @@ -0,0 +1,314 @@ +import { createAreaPicker } from './area-picker'; +import { showAnnotationStep } from './annotation-flow'; +import { + captureAreaWithLoading, + capturePromiseWithLoading, + captureWithLoading, +} from './capture-loading'; +import { createElementPicker } from './picker'; +import { beginViewportCapture, getRedactionCount, isFullPageDisabled } from './screenshot'; +import { showScreenshotOptions, type ScreenshotChoice } from './screenshot-options'; + +export interface CaptureFlowConfig { + screenshotMode: 'optional' | 'auto' | 'required'; + screenshotScale?: number; + accentColor?: string; + font?: string; + radius?: string; + borderWidth?: string; + bgColor?: string; + textColor?: string; + borderColor?: string; + theme: 'light' | 'dark' | 'auto'; +} + +export interface CaptureFlowResult { + screenshot: string | null; + elementSelector: string | null; + returnToForm: boolean; +} + +export type EmptyCaptureReason = + | 'none' + | 'explicit-skip' + | 'capture-failure-skip' + | 'selection-cancelled'; + +type ChosenCaptureResult = + | { + kind: 'captured'; + screenshot: string; + elementSelector: string | null; + redactionCount: number; + redactionUnavailable: boolean; + } + | { kind: 'returnToForm' } + | { kind: 'empty'; reason: EmptyCaptureReason; elementSelector: string | null }; + +export async function runScreenshotCaptureFlow( + root: HTMLElement, + config: CaptureFlowConfig, + includeScreenshot: boolean, + onComplexScreenshotSkipped: () => void +): Promise { + if (config.screenshotMode === 'auto') { + return captureAutomaticScreenshot(root, config); + } + + if (!includeScreenshot) { + return emptyCaptureResult(); + } + + const screenshotRequired = config.screenshotMode === 'required'; + while (true) { + const result = await captureChosenScreenshot(root, config, screenshotRequired); + if (result.kind === 'returnToForm') { + return { ...emptyCaptureResult(), returnToForm: true }; + } + + if (result.kind === 'empty') { + if (!screenshotRequired && shouldRememberComplexScreenshotSkip(result.reason)) { + onComplexScreenshotSkipped(); + } + if (screenshotRequired) continue; + return { + screenshot: null, + elementSelector: result.elementSelector, + returnToForm: false, + }; + } + + const annotatedScreenshot = await showAnnotationStep( + root, + result.screenshot, + result.redactionCount, + { + redactionUnavailable: result.redactionUnavailable, + } + ); + + if (annotatedScreenshot === 'retake') continue; + if (annotatedScreenshot === 'cancel') { + return { ...emptyCaptureResult(), returnToForm: true }; + } + + return { + screenshot: annotatedScreenshot, + elementSelector: result.elementSelector, + returnToForm: false, + }; + } +} + +async function captureAutomaticScreenshot( + root: HTMLElement, + config: CaptureFlowConfig +): Promise { + if (isFullPageDisabled()) { + return emptyCaptureResult(); + } + + const result = await captureWithLoading(root, undefined, config.screenshotScale); + if (result.kind === 'cancelled') { + return { ...emptyCaptureResult(), returnToForm: true }; + } + + return { + screenshot: result.kind === 'ok' ? result.dataUrl : null, + elementSelector: null, + returnToForm: false, + }; +} + +export function shouldRememberComplexScreenshotSkip(reason: EmptyCaptureReason): boolean { + return reason === 'explicit-skip' || reason === 'capture-failure-skip'; +} + +async function captureChosenScreenshot( + root: HTMLElement, + config: CaptureFlowConfig, + screenshotRequired: boolean +): Promise { + const screenshotChoice = await showScreenshotOptions(root, { + allowSkip: !screenshotRequired, + }); + + switch (screenshotChoice.kind) { + case 'cancel': + return { kind: 'returnToForm' }; + case 'skip': + return { kind: 'empty', reason: 'explicit-skip', elementSelector: null }; + case 'viewport': + return captureFromViewportChoice(root, screenshotChoice, screenshotRequired); + case 'capture': + return captureFromFullPageChoice(root, config, screenshotRequired); + case 'element': + return captureFromElementChoice(root, config, screenshotRequired); + case 'area': + return captureFromAreaChoice(root, config, screenshotRequired); + default: + return assertNever(screenshotChoice); + } +} + +async function captureFromViewportChoice( + root: HTMLElement, + choice: Extract, + screenshotRequired: boolean +): Promise { + const result = await capturePromiseWithLoading( + root, + choice.capture, + () => beginViewportCapture(), + { + allowSkip: !screenshotRequired, + showLoading: false, + } + ); + if (result.kind === 'cancelled') return { kind: 'returnToForm' }; + if (result.kind === 'skipped') { + return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null }; + } + return { + kind: 'captured', + screenshot: result.dataUrl, + elementSelector: null, + redactionCount: 0, + redactionUnavailable: true, + }; +} + +async function captureFromFullPageChoice( + root: HTMLElement, + config: CaptureFlowConfig, + screenshotRequired: boolean +): Promise { + const result = await captureWithLoading(root, undefined, config.screenshotScale, { + allowSkip: !screenshotRequired, + }); + if (result.kind === 'cancelled') return { kind: 'returnToForm' }; + if (result.kind === 'skipped') { + return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null }; + } + return { + kind: 'captured', + screenshot: result.dataUrl, + elementSelector: null, + redactionCount: getRedactionCount(), + redactionUnavailable: false, + }; +} + +async function captureFromElementChoice( + root: HTMLElement, + config: CaptureFlowConfig, + screenshotRequired: boolean +): Promise { + const element = await createElementPicker(getPickerStyle(config)); + if (!element) { + return { kind: 'empty', reason: 'selection-cancelled', elementSelector: null }; + } + + const elementSelector = getElementSelector(element); + const result = await captureWithLoading(root, element, config.screenshotScale, { + allowSkip: !screenshotRequired, + }); + if (result.kind === 'cancelled') return { kind: 'returnToForm' }; + if (result.kind === 'skipped') { + return { kind: 'empty', reason: 'capture-failure-skip', elementSelector }; + } + return { + kind: 'captured', + screenshot: result.dataUrl, + elementSelector, + redactionCount: getRedactionCount(element), + redactionUnavailable: false, + }; +} + +async function captureFromAreaChoice( + root: HTMLElement, + config: CaptureFlowConfig, + screenshotRequired: boolean +): Promise { + const rect = await createAreaPicker(getPickerStyle(config), { + redactionsAvailable: getRedactionCount() > 0, + }); + if (!rect) { + return { kind: 'empty', reason: 'selection-cancelled', elementSelector: null }; + } + + const result = await captureAreaWithLoading(root, rect, config.screenshotScale, { + allowSkip: !screenshotRequired, + }); + if (result.kind === 'cancelled') return { kind: 'returnToForm' }; + if (result.kind === 'skipped') { + return { kind: 'empty', reason: 'capture-failure-skip', elementSelector: null }; + } + return { + kind: 'captured', + screenshot: result.dataUrl, + elementSelector: null, + redactionCount: getRedactionCount(undefined, rect), + redactionUnavailable: false, + }; +} + +function getPickerStyle(config: CaptureFlowConfig) { + return { + accentColor: config.accentColor, + font: config.font, + radius: config.radius, + borderWidth: config.borderWidth, + bgColor: config.bgColor, + textColor: config.textColor, + borderColor: config.borderColor, + theme: config.theme, + }; +} + +function emptyCaptureResult(): CaptureFlowResult { + return { + screenshot: null, + elementSelector: null, + returnToForm: false, + }; +} + +function assertNever(value: never): never { + throw new Error(`Unhandled screenshot choice: ${JSON.stringify(value)}`); +} + +function getElementSelector(element: Element): string { + const path: string[] = []; + let current: Element | null = element; + + while (current && current !== document.body) { + let selector = current.tagName.toLowerCase(); + + if (current.id) { + selector = `#${current.id}`; + path.unshift(selector); + break; + } + + if (current.className) { + const classNameStr = + typeof current.className === 'string' + ? current.className + : (current.className as SVGAnimatedString).baseVal || ''; + const classes = classNameStr + .split(' ') + .filter(c => c) + .slice(0, 2); + if (classes.length) { + selector += `.${classes.join('.')}`; + } + } + + path.unshift(selector); + current = current.parentElement; + } + + return path.join(' > '); +} diff --git a/src/widget/capture-loading.ts b/src/widget/capture-loading.ts new file mode 100644 index 0000000..9b47403 --- /dev/null +++ b/src/widget/capture-loading.ts @@ -0,0 +1,145 @@ +import { MaskApplicationError } from './mask'; +import { captureAreaScreenshot, captureScreenshot } from './screenshot'; +import { createModal } from './ui'; + +export type CaptureWithLoadingResult = + | { kind: 'ok'; dataUrl: string } + | { kind: 'skipped' } + | { kind: 'cancelled' }; + +export async function captureWithLoading( + root: HTMLElement, + element?: Element, + screenshotScale?: number, + opts?: { allowSkip?: boolean } +): Promise { + return capturePromiseWithLoading( + root, + captureScreenshot(element, screenshotScale), + () => captureScreenshot(element, screenshotScale), + opts + ); +} + +export async function captureAreaWithLoading( + root: HTMLElement, + rect: DOMRect, + screenshotScale?: number, + opts?: { allowSkip?: boolean } +): Promise { + return capturePromiseWithLoading( + root, + captureAreaScreenshot(rect, screenshotScale), + () => captureAreaScreenshot(rect, screenshotScale), + opts + ); +} + +export async function capturePromiseWithLoading( + root: HTMLElement, + capturePromise: Promise, + retryCapture: () => Promise, + opts?: { allowSkip?: boolean; showLoading?: boolean } +): Promise { + const loadingModal = + opts?.showLoading === false + ? null + : createModal( + root, + 'Capturing...', + ` +
+
+

Capturing screenshot...

+
+ ` + ); + + try { + const screenshot = await capturePromise; + loadingModal?.remove(); + return { kind: 'ok', dataUrl: screenshot }; + } catch (error) { + console.warn('[BugDrop] Screenshot capture failed:', error); + loadingModal?.remove(); + const allowSkip = opts?.allowSkip !== false; + + if (error instanceof MaskApplicationError) { + return showMaskFailureModal(root); + } + + return new Promise(resolve => { + const errorModal = createModal( + root, + 'Capture Failed', + ` +
+ + + + Failed to capture screenshot. The page may be too complex or browser restrictions may apply. +
+
+ + +
+ `, + true + ); + + const closeBtn = errorModal.querySelector('.bd-close') as HTMLElement; + const skipBtn = errorModal.querySelector('[data-action="skip"]') as HTMLElement; + const retryBtn = errorModal.querySelector('[data-action="retry"]') as HTMLElement; + + closeBtn?.addEventListener('click', () => { + errorModal.remove(); + resolve({ kind: 'cancelled' }); + }); + + skipBtn?.addEventListener('click', () => { + errorModal.remove(); + resolve({ kind: 'skipped' }); + }); + + retryBtn?.addEventListener('click', async () => { + errorModal.remove(); + const result = await capturePromiseWithLoading(root, retryCapture(), retryCapture, opts); + resolve(result); + }); + }); + } +} + +function showMaskFailureModal(root: HTMLElement): Promise { + return new Promise(resolve => { + const modal = createModal( + root, + 'Privacy masking failed', + ` +
+ + + + Automatic redaction of private fields could not be applied. To protect your data, this screenshot was discarded. You can still submit feedback without one. +
+
+ +
+ `, + true + ); + + const closeBtn = modal.querySelector('.bd-close') as HTMLElement; + const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement; + + closeBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'cancelled' }); + }); + + skipBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'skipped' }); + }); + }); +} diff --git a/src/widget/index.ts b/src/widget/index.ts index b8a336d..ded6327 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -1,16 +1,10 @@ import { - beginViewportCapture, - canCaptureViewportNatively, - captureAreaScreenshot, - captureScreenshot, FULL_PAGE_DISABLE_THRESHOLD, getDomNodeCount, getRedactionCount, isFullPageDisabled, } from './screenshot'; -import { createElementPicker } from './picker'; -import { createAreaPicker } from './area-picker'; -import { createAnnotator, type Tool } from './annotator'; +import { runScreenshotCaptureFlow } from './capture-flow'; import { injectStyles, createModal, showSuccessModal } from './ui'; import { resolveTheme, @@ -92,14 +86,6 @@ interface FeedbackData { email?: string; } -type ScreenshotChoice = - | 'skip' - | 'capture' - | 'element' - | 'area' - | 'cancel' - | { type: 'viewport'; capture: Promise }; - // localStorage key for dismissed state const BUGDROP_DISMISSED_KEY = 'bugdrop_dismissed'; const BUGDROP_WELCOMED_PREFIX = 'bugdrop_welcomed_'; @@ -760,134 +746,15 @@ async function openFeedbackFlow( return; } - let screenshot: string | null = null; - let elementSelector: string | null = null; - let redactionCount = 0; - let redactionUnavailable = false; - let returnToForm = false; - - // Step 3: Screenshot flow (if configured/user opted in) - if (config.screenshotMode === 'auto') { - if (!isFullPageDisabled()) { - const result = await captureWithLoading(root, undefined, config.screenshotScale); - if (result === 'cancel') { - returnToForm = true; - } else { - screenshot = result; - redactionCount = screenshot ? getRedactionCount() : 0; - } - } - } else if (formResult.includeScreenshot) { - const screenshotRequired = config.screenshotMode === 'required'; - while (true) { - screenshot = null; - elementSelector = null; - redactionCount = 0; - redactionUnavailable = false; - - const screenshotChoice = await showScreenshotOptions(root, { - allowSkip: !screenshotRequired, - }); - if (screenshotChoice === 'cancel') { - returnToForm = true; - break; - } - if (screenshotChoice === 'skip') { - rememberComplexScreenshotSkip(config, formResult); - break; - } - - const pickerStyle = { - accentColor: config.accentColor, - font: config.font, - radius: config.radius, - borderWidth: config.borderWidth, - bgColor: config.bgColor, - textColor: config.textColor, - borderColor: config.borderColor, - theme: config.theme, - }; - - if (typeof screenshotChoice === 'object' && screenshotChoice.type === 'viewport') { - const result = await capturePromiseWithLoading( - root, - screenshotChoice.capture, - () => beginViewportCapture(), - { - allowSkip: !screenshotRequired, - showLoading: false, - } - ); - if (result === 'cancel') { - returnToForm = true; - break; - } - if (!result && !screenshotRequired) rememberComplexScreenshotSkip(config, formResult); - screenshot = result; - redactionUnavailable = Boolean(screenshot); - redactionCount = 0; - } else if (screenshotChoice === 'capture') { - const result = await captureWithLoading(root, undefined, config.screenshotScale, { - allowSkip: !screenshotRequired, - }); - if (result === 'cancel') { - returnToForm = true; - break; - } - if (!result) rememberComplexScreenshotSkip(config, formResult); - screenshot = result; - redactionCount = screenshot ? getRedactionCount() : 0; - } else if (screenshotChoice === 'element') { - const element = await createElementPicker(pickerStyle); - if (element) { - const result = await captureWithLoading(root, element, config.screenshotScale, { - allowSkip: !screenshotRequired, - }); - if (result === 'cancel') { - returnToForm = true; - break; - } - if (!result) rememberComplexScreenshotSkip(config, formResult); - screenshot = result; - redactionCount = screenshot ? getRedactionCount(element) : 0; - elementSelector = getElementSelector(element); - } - } else if (screenshotChoice === 'area') { - const rect = await createAreaPicker(pickerStyle, { - redactionsAvailable: getRedactionCount() > 0, - }); - if (rect) { - const result = await captureAreaWithLoading(root, rect, config.screenshotScale, { - allowSkip: !screenshotRequired, - }); - if (result === 'cancel') { - returnToForm = true; - break; - } - if (!result) rememberComplexScreenshotSkip(config, formResult); - screenshot = result; - redactionCount = screenshot ? getRedactionCount(undefined, rect) : 0; - } - } - - // Step 4: Annotate (if screenshot exists) - if (screenshot) { - const result = await showAnnotationStep(root, screenshot, redactionCount, { - redactionUnavailable, - }); - if (result === 'retake') continue; - if (result === 'cancel') { - returnToForm = true; - break; - } - screenshot = result; - } - if (screenshotRequired && !screenshot) continue; - break; - } - } + const submittedFormResult = formResult; + const screenshotResult = await runScreenshotCaptureFlow( + root, + config, + submittedFormResult.includeScreenshot, + () => rememberComplexScreenshotSkip(config, submittedFormResult) + ); - if (returnToForm) continue; + if (screenshotResult.returnToForm) continue; // Submit await submitFeedback(root, config, { @@ -896,8 +763,8 @@ async function openFeedbackFlow( category: formResult.category, name: formResult.name, email: formResult.email, - screenshot, - elementSelector, + screenshot: screenshotResult.screenshot, + elementSelector: screenshotResult.elementSelector, }); break; } @@ -906,107 +773,6 @@ async function openFeedbackFlow( _isModalOpen = false; } -async function captureWithLoading( - root: HTMLElement, - element?: Element, - screenshotScale?: number, - opts?: { allowSkip?: boolean } -): Promise { - return capturePromiseWithLoading( - root, - captureScreenshot(element, screenshotScale), - () => captureScreenshot(element, screenshotScale), - opts - ); -} - -async function captureAreaWithLoading( - root: HTMLElement, - rect: DOMRect, - screenshotScale?: number, - opts?: { allowSkip?: boolean } -): Promise { - return capturePromiseWithLoading( - root, - captureAreaScreenshot(rect, screenshotScale), - () => captureAreaScreenshot(rect, screenshotScale), - opts - ); -} - -async function capturePromiseWithLoading( - root: HTMLElement, - capturePromise: Promise, - retryCapture: () => Promise, - opts?: { allowSkip?: boolean; showLoading?: boolean } -): Promise { - // Show a temporary loading indicator - const loadingModal = - opts?.showLoading === false - ? null - : createModal( - root, - 'Capturing...', - ` -
-
-

Capturing screenshot...

-
- ` - ); - - try { - const screenshot = await capturePromise; - loadingModal?.remove(); - return screenshot; - } catch (error) { - console.warn('[BugDrop] Screenshot capture failed:', error); - loadingModal?.remove(); - const allowSkip = opts?.allowSkip !== false; - - // Show error with retry option - return new Promise(resolve => { - const errorModal = createModal( - root, - 'Capture Failed', - ` -
- - - - Failed to capture screenshot. The page may be too complex or browser restrictions may apply. -
-
- - -
- `, - true - ); - - const closeBtn = errorModal.querySelector('.bd-close') as HTMLElement; - const skipBtn = errorModal.querySelector('[data-action="skip"]') as HTMLElement; - const retryBtn = errorModal.querySelector('[data-action="retry"]') as HTMLElement; - - closeBtn?.addEventListener('click', () => { - errorModal.remove(); - resolve('cancel'); - }); - - skipBtn?.addEventListener('click', () => { - errorModal.remove(); - resolve(null); - }); - - retryBtn?.addEventListener('click', async () => { - errorModal.remove(); - const result = await capturePromiseWithLoading(root, retryCapture(), retryCapture, opts); - resolve(result); - }); - }); - } -} - async function checkInstallation( config: WidgetConfig ): Promise<{ status: 'installed' | 'not_installed' | 'unreachable'; appName?: string }> { @@ -1303,170 +1069,6 @@ function escapeHtml(value: string): string { .replace(/'/g, '''); } -function showScreenshotOptions( - root: HTMLElement, - opts?: { allowSkip?: boolean } -): Promise { - const fullPageDisabled = isFullPageDisabled(); - const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively(); - const allowSkip = opts?.allowSkip !== false; - const redactionNote = - nativeViewportAvailable && fullPageDisabled - ? '

Browser viewport capture cannot apply automatic private-field masks. Select Element to preserve automatic masking, or review and cover sensitive areas before sending.

' - : getRedactionCount() > 0 - ? '

This site marked some fields for redaction. Review the screenshot before sending.

' - : ''; - - return new Promise(resolve => { - const complexNote = fullPageDisabled - ? `

${nativeViewportAvailable ? 'This page is too complex for full-page or area capture. Capture the visible viewport or select a specific element instead.' : 'This page is too complex for full-page or area capture. Select a specific element instead.'}

` - : ''; - const primaryCaptureButton = fullPageDisabled - ? nativeViewportAvailable - ? '' - : '' - : ''; - - const modal = createModal( - root, - 'Capture Screenshot', - ` -

Choose what to capture:

- ${complexNote} - ${redactionNote} -
- ${primaryCaptureButton} - ${fullPageDisabled ? '' : ''} - - ${allowSkip ? '' : ''} -
- ` - ); - - const closeBtn = modal.querySelector('.bd-close') as HTMLElement; - const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement | null; - const elementBtn = modal.querySelector('[data-action="element"]') as HTMLElement; - const areaBtn = modal.querySelector('[data-action="area"]') as HTMLElement; - const captureBtn = modal.querySelector('[data-action="capture"]') as HTMLElement; - const viewportBtn = modal.querySelector('[data-action="viewport"]') as HTMLElement; - - closeBtn?.addEventListener('click', () => { - modal.remove(); - resolve('cancel'); - }); - - skipBtn?.addEventListener('click', () => { - modal.remove(); - resolve('skip'); - }); - - elementBtn?.addEventListener('click', () => { - modal.remove(); - resolve('element'); - }); - - areaBtn?.addEventListener('click', () => { - modal.remove(); - resolve('area'); - }); - - captureBtn?.addEventListener('click', () => { - modal.remove(); - resolve('capture'); - }); - - viewportBtn?.addEventListener('click', () => { - modal.remove(); - const capture = beginViewportCapture(); - resolve({ type: 'viewport', capture }); - }); - }); -} - -function showAnnotationStep( - root: HTMLElement, - screenshot: string, - redactionCount = 0, - opts?: { redactionUnavailable?: boolean } -): Promise { - return new Promise(resolve => { - const redactionNote = opts?.redactionUnavailable - ? `

This browser viewport capture could not apply automatic private-field masks. Review and cover any sensitive areas before sending.

` - : redactionCount > 0 - ? `

${redactionCount} private ${redactionCount === 1 ? 'item was' : 'items were'} marked for redaction in this screenshot. Review before sending.

` - : ''; - const modal = createModal( - root, - 'Review Screenshot', - ` - ${redactionNote} -

- Check that no sensitive information is visible before sending. Cover sensitive areas before submitting. Redactions are baked into the uploaded image. -

-
- - - - - -
-
-
- - -
- `, - false, - 'bd-modal--annotator' - ); - - const canvasContainer = modal.querySelector('#annotation-canvas') as HTMLElement; - const annotator = createAnnotator(canvasContainer, screenshot); - - // Tool buttons - const toolButtons = modal.querySelectorAll('[data-tool]'); - toolButtons.forEach(btn => { - btn.addEventListener('click', e => { - const target = e.currentTarget as HTMLElement; - const tool = target.dataset.tool; - - if (tool) { - toolButtons.forEach(b => b.classList.remove('active')); - target.classList.add('active'); - annotator.setTool(tool as Tool); - } - }); - }); - - const undoBtn = modal.querySelector('[data-action="undo"]') as HTMLElement | null; - undoBtn?.addEventListener('click', () => annotator.undo()); - - // Action buttons - const closeBtn = modal.querySelector('.bd-close') as HTMLElement; - const retakeBtn = modal.querySelector('[data-action="retake"]') as HTMLElement; - const doneBtn = modal.querySelector('[data-action="done"]') as HTMLElement; - - closeBtn?.addEventListener('click', () => { - annotator.destroy(); - modal.remove(); - resolve('cancel'); - }); - - retakeBtn?.addEventListener('click', () => { - annotator.destroy(); - modal.remove(); - resolve('retake'); - }); - - doneBtn?.addEventListener('click', () => { - const annotated = annotator.getImageData(); - annotator.destroy(); - modal.remove(); - resolve(annotated); - }); - }); -} - async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) { // Show submitting modal with loading state const modal = createModal( @@ -1582,38 +1184,3 @@ function showSubmitError( await submitFeedback(root, config, data); }); } - -function getElementSelector(element: Element): string { - const path: string[] = []; - let current: Element | null = element; - - while (current && current !== document.body) { - let selector = current.tagName.toLowerCase(); - - if (current.id) { - selector = `#${current.id}`; - path.unshift(selector); - break; - } - - if (current.className) { - // Handle SVG elements where className is SVGAnimatedString, not a string - const classNameStr = - typeof current.className === 'string' - ? current.className - : (current.className as SVGAnimatedString).baseVal || ''; - const classes = classNameStr - .split(' ') - .filter(c => c) - .slice(0, 2); - if (classes.length) { - selector += `.${classes.join('.')}`; - } - } - - path.unshift(selector); - current = current.parentElement; - } - - return path.join(' > '); -} diff --git a/src/widget/mask.ts b/src/widget/mask.ts index d74b4c6..66fe39c 100644 --- a/src/widget/mask.ts +++ b/src/widget/mask.ts @@ -5,40 +5,74 @@ interface MaskRect { h: number; } +type RedactionReason = 'developer-marked' | 'sensitive-input'; +type RedactionStrategy = 'canvas-mask'; + +interface RedactionTarget { + element: Element; + rect: MaskRect; + reason: RedactionReason; + strategy: RedactionStrategy; +} + +interface RedactionPlan { + targets: RedactionTarget[]; +} + +export class MaskApplicationError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'MaskApplicationError'; + } +} + const EXPLICIT_SELECTOR = '[data-bugdrop-mask], [data-bugdrop-redact], [data-bd-redact], [data-bugdrop-redacted]'; 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 getRedactionReason(el: Element): RedactionReason | null { + if (el.matches(EXPLICIT_SELECTOR)) return 'developer-marked'; + if (el.matches(DEFAULT_SELECTOR)) return 'sensitive-input'; + return null; } -function pushRect(el: Element, rects: MaskRect[]): void { +function createTarget(el: Element, reason: RedactionReason): RedactionTarget | null { 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, - }); + if (rect.width === 0 || rect.height === 0) return null; + return { + element: el, + rect: { + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + w: rect.width, + h: rect.height, + }, + reason, + strategy: 'canvas-mask', + }; } -export function collectMaskRects(root: Element): MaskRect[] { - const rects: MaskRect[] = []; +export function createRedactionPlan(root: Element): RedactionPlan { + const targets: RedactionTarget[] = []; + const rootReason = getRedactionReason(root); - if (shouldMask(root)) { - pushRect(root, rects); - return rects; + if (rootReason) { + const rootTarget = createTarget(root, rootReason); + return { targets: rootTarget ? [rootTarget] : [] }; } - walk(root, rects); - return rects; + walk(root, targets); + walkOpenShadowRoot(root, targets); + return { targets }; +} + +export function collectMaskRects(root: Element): MaskRect[] { + return createRedactionPlan(root).targets.map(target => target.rect); } export function countMaskRects(root: Element = document.body, area?: DOMRect): number { - const rects = collectMaskRects(root); + const rects = createRedactionPlan(root).targets.map(target => target.rect); if (!area) return rects.length; return rects.filter(rect => intersects(rect, area)).length; } @@ -52,14 +86,33 @@ function intersects(rect: MaskRect, area: DOMRect): boolean { ); } -function walk(node: Element, rects: MaskRect[]): void { +function walk(node: Element, targets: RedactionTarget[]): void { for (const child of Array.from(node.children)) { - if (shouldMask(child)) { - pushRect(child, rects); + const reason = getRedactionReason(child); + if (reason) { + const target = createTarget(child, reason); + if (target) targets.push(target); // Top-most-ancestor rule: do not descend into masked subtrees. continue; } - walk(child, rects); + walk(child, targets); + walkOpenShadowRoot(child, targets); + } +} + +function walkOpenShadowRoot(node: Element, targets: RedactionTarget[]): void { + const shadowRoot = node.shadowRoot; + if (!shadowRoot) return; + + for (const child of Array.from(shadowRoot.children)) { + const reason = getRedactionReason(child); + if (reason) { + const target = createTarget(child, reason); + if (target) targets.push(target); + continue; + } + walk(child, targets); + walkOpenShadowRoot(child, targets); } } @@ -102,7 +155,7 @@ export async function applyMaskToImage( canvas.height = img.naturalHeight || img.height; const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); + if (!ctx) throw new MaskApplicationError('Failed to get canvas context for privacy masking'); ctx.drawImage(img, 0, 0); ctx.fillStyle = '#000'; @@ -119,7 +172,8 @@ 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.onerror = () => + reject(new MaskApplicationError('Failed to load image for privacy masking')); img.src = dataUrl; }); } diff --git a/src/widget/screenshot-options.ts b/src/widget/screenshot-options.ts new file mode 100644 index 0000000..928affb --- /dev/null +++ b/src/widget/screenshot-options.ts @@ -0,0 +1,112 @@ +import { + beginViewportCapture, + canCaptureViewportNatively, + getRedactionCount, + isFullPageDisabled, +} from './screenshot'; +import { createModal, redactionNoteHtml } from './ui'; + +export type ScreenshotChoice = + | { kind: 'skip' } + | { kind: 'capture' } + | { kind: 'element' } + | { kind: 'area' } + | { kind: 'cancel' } + | { kind: 'viewport'; capture: Promise }; + +export function showScreenshotOptions( + root: HTMLElement, + opts?: { allowSkip?: boolean } +): Promise { + const fullPageDisabled = isFullPageDisabled(); + const nativeViewportAvailable = fullPageDisabled && canCaptureViewportNatively(); + const allowSkip = opts?.allowSkip !== false; + + let redactionNote = ''; + if (nativeViewportAvailable) { + redactionNote = redactionNoteHtml( + 'Browser viewport capture cannot apply automatic private-field masks. Select Element to preserve automatic masking, or review and cover sensitive areas before sending.' + ); + } else if (getRedactionCount() > 0) { + redactionNote = redactionNoteHtml( + 'This site marked some fields for redaction. Review the screenshot before sending.' + ); + } + + return new Promise(resolve => { + const complexNote = fullPageDisabled + ? `

${nativeViewportAvailable ? 'This page is too complex for full-page or area capture. Capture the visible viewport or select a specific element instead.' : 'This page is too complex for full-page or area capture. Select a specific element instead.'}

` + : ''; + + let primaryCaptureButton = ''; + if (!fullPageDisabled) { + primaryCaptureButton = + ''; + } else if (nativeViewportAvailable) { + primaryCaptureButton = + ''; + } + + const modal = createModal( + root, + 'Capture Screenshot', + ` +

Choose what to capture:

+ ${complexNote} + ${redactionNote} +
+ ${primaryCaptureButton} + ${fullPageDisabled ? '' : ''} + + ${allowSkip ? '' : ''} +
+ ` + ); + + const closeBtn = modal.querySelector('.bd-close') as HTMLElement; + const skipBtn = modal.querySelector('[data-action="skip"]') as HTMLElement | null; + const elementBtn = modal.querySelector('[data-action="element"]') as HTMLElement; + const areaBtn = modal.querySelector('[data-action="area"]') as HTMLElement; + const captureBtn = modal.querySelector('[data-action="capture"]') as HTMLElement; + const viewportBtn = modal.querySelector('[data-action="viewport"]') as HTMLElement; + + closeBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'cancel' }); + }); + + skipBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'skip' }); + }); + + elementBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'element' }); + }); + + areaBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'area' }); + }); + + captureBtn?.addEventListener('click', () => { + modal.remove(); + resolve({ kind: 'capture' }); + }); + + viewportBtn?.addEventListener('click', () => { + modal.remove(); + // beginViewportCapture must run synchronously inside the click handler to + // preserve the user gesture required by getDisplayMedia. The in-flight + // promise is then handed to capture-flow for awaiting. + const capture = beginViewportCapture(); + capture.catch(() => { + // Attach a handler immediately so an early rejection does not surface + // as 'Unhandled promise rejection' in the brief window before the + // caller awaits this promise. The real error is still raised on await. + }); + resolve({ kind: 'viewport', capture }); + }); + }); +} diff --git a/src/widget/screenshot.ts b/src/widget/screenshot.ts index 83c67d2..ea9ff30 100644 --- a/src/widget/screenshot.ts +++ b/src/widget/screenshot.ts @@ -1,6 +1,6 @@ import * as htmlToImage from 'html-to-image'; import type { Options as HtmlToImageOptions } from 'html-to-image/lib/types'; -import { applyMaskToImage, collectMaskRects, countMaskRects } from './mask'; +import { applyMaskToImage, countMaskRects, createRedactionPlan } from './mask'; declare const __BUGDROP_ENABLE_TEST_HOOKS__: boolean; @@ -202,7 +202,7 @@ export async function captureScreenshot( filter: (node: HTMLElement) => node.id !== 'bugdrop-host', }; - const rects = collectMaskRects(target); + const redactionPlan = createRedactionPlan(target); let originOffset = { x: 0, y: 0 }; if (element) { const r = element.getBoundingClientRect(); @@ -211,7 +211,12 @@ export async function captureScreenshot( const capturePromise = toPng(target as HTMLElement, opts); const dataUrl = await withCaptureTimeout(capturePromise); - return applyMaskToImage(dataUrl, rects, pixelRatio, originOffset); + return applyMaskToImage( + dataUrl, + redactionPlan.targets.map(target => target.rect), + pixelRatio, + originOffset + ); } export async function captureAreaScreenshot( @@ -236,10 +241,16 @@ export async function captureAreaScreenshot( }; const dataUrl = await withCaptureTimeout(toPng(document.body, opts)); - return applyMaskToImage(dataUrl, collectMaskRects(document.body), pixelRatio, { - x: rect.x, - y: rect.y, - }); + const redactionPlan = createRedactionPlan(document.body); + return applyMaskToImage( + dataUrl, + redactionPlan.targets.map(target => target.rect), + pixelRatio, + { + x: rect.x, + y: rect.y, + } + ); } export function getRedactionCount(element?: Element, rect?: DOMRect): number { diff --git a/src/widget/ui.ts b/src/widget/ui.ts index 942700d..9aa4336 100644 --- a/src/widget/ui.ts +++ b/src/widget/ui.ts @@ -1063,6 +1063,10 @@ export function injectStyles(shadow: ShadowRoot, config: WidgetConfig) { return root; } +export function redactionNoteHtml(message: string): string { + return `

${message}

`; +} + export function createModal( container: HTMLElement, title: string, diff --git a/test/captureFlow.test.ts b/test/captureFlow.test.ts new file mode 100644 index 0000000..38a2e31 --- /dev/null +++ b/test/captureFlow.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { EmptyCaptureReason } from '../src/widget/capture-flow'; +import type { ScreenshotChoice } from '../src/widget/screenshot-options'; +import type { CaptureWithLoadingResult } from '../src/widget/capture-loading'; + +const baseConfig = { + screenshotMode: 'optional' as const, + theme: 'light' as const, +}; + +async function loadCaptureFlowWithMocks(opts: { + screenshotChoice: ScreenshotChoice; + pickedElement?: Element | null; + captureResult?: CaptureWithLoadingResult; + annotationResult?: string | 'retake' | 'cancel'; +}) { + vi.resetModules(); + vi.doMock('../src/widget/screenshot-options', () => ({ + showScreenshotOptions: vi.fn().mockResolvedValue(opts.screenshotChoice), + })); + vi.doMock('../src/widget/picker', () => ({ + createElementPicker: vi.fn().mockResolvedValue(opts.pickedElement ?? null), + })); + vi.doMock('../src/widget/area-picker', () => ({ + createAreaPicker: vi.fn(), + })); + vi.doMock('../src/widget/capture-loading', () => ({ + captureWithLoading: vi.fn().mockResolvedValue(opts.captureResult ?? { kind: 'skipped' }), + captureAreaWithLoading: vi.fn(), + capturePromiseWithLoading: vi.fn().mockResolvedValue(opts.captureResult ?? { kind: 'skipped' }), + })); + vi.doMock('../src/widget/annotation-flow', () => ({ + showAnnotationStep: vi.fn().mockResolvedValue(opts.annotationResult ?? 'annotated-image'), + })); + vi.doMock('../src/widget/screenshot', () => ({ + beginViewportCapture: vi.fn(), + getRedactionCount: vi.fn().mockReturnValue(0), + isFullPageDisabled: vi.fn().mockReturnValue(false), + })); + + return import('../src/widget/capture-flow'); +} + +afterEach(() => { + vi.doUnmock('../src/widget/screenshot-options'); + vi.doUnmock('../src/widget/picker'); + vi.doUnmock('../src/widget/area-picker'); + vi.doUnmock('../src/widget/capture-loading'); + vi.doUnmock('../src/widget/annotation-flow'); + vi.doUnmock('../src/widget/screenshot'); + vi.resetModules(); +}); + +describe('capture flow state decisions', () => { + it.each([ + ['explicit-skip', true], + ['capture-failure-skip', true], + ['selection-cancelled', false], + ['none', false], + ] satisfies Array<[EmptyCaptureReason, boolean]>)( + 'complex screenshot skip persistence for %s is %s', + async (reason, expected) => { + const { shouldRememberComplexScreenshotSkip } = await import('../src/widget/capture-flow'); + expect(shouldRememberComplexScreenshotSkip(reason)).toBe(expected); + } + ); + + it('remembers complex screenshot skip for an explicit optional skip', async () => { + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'skip' }, + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: false }); + expect(onComplexScreenshotSkipped).toHaveBeenCalledTimes(1); + }); + + it('does not remember complex screenshot skip when element selection is cancelled', async () => { + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'element' }, + pickedElement: null, + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: false }); + expect(onComplexScreenshotSkipped).not.toHaveBeenCalled(); + }); + + it('keeps selected element metadata when element capture is skipped after failure', async () => { + const element = document.createElement('button'); + element.id = 'target-button'; + document.body.appendChild(element); + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'element' }, + pickedElement: element, + captureResult: { kind: 'skipped' }, + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ + screenshot: null, + elementSelector: '#target-button', + returnToForm: false, + }); + expect(onComplexScreenshotSkipped).toHaveBeenCalledTimes(1); + }); + + it('returns to form when the user dismisses the screenshot options modal', async () => { + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'cancel' }, + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true }); + expect(onComplexScreenshotSkipped).not.toHaveBeenCalled(); + }); + + it('returns to form when the capture-failure modal is cancelled mid-capture', async () => { + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'capture' }, + captureResult: { kind: 'cancelled' }, + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true }); + expect(onComplexScreenshotSkipped).not.toHaveBeenCalled(); + }); + + it('returns to form when the annotation step is cancelled', async () => { + const { runScreenshotCaptureFlow } = await loadCaptureFlowWithMocks({ + screenshotChoice: { kind: 'capture' }, + captureResult: { kind: 'ok', dataUrl: 'data:image/png;base64,AAAA' }, + annotationResult: 'cancel', + }); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result).toEqual({ screenshot: null, elementSelector: null, returnToForm: true }); + expect(onComplexScreenshotSkipped).not.toHaveBeenCalled(); + }); + + it('flags redactionUnavailable on the annotation step for native viewport captures', async () => { + const annotationMock = vi.fn().mockResolvedValue('annotated-image'); + vi.resetModules(); + vi.doMock('../src/widget/screenshot-options', () => ({ + showScreenshotOptions: vi.fn().mockResolvedValue({ + kind: 'viewport', + capture: Promise.resolve('data:image/png;base64,VVVV'), + } satisfies ScreenshotChoice), + })); + vi.doMock('../src/widget/picker', () => ({ createElementPicker: vi.fn() })); + vi.doMock('../src/widget/area-picker', () => ({ createAreaPicker: vi.fn() })); + vi.doMock('../src/widget/capture-loading', () => ({ + captureWithLoading: vi.fn(), + captureAreaWithLoading: vi.fn(), + capturePromiseWithLoading: vi + .fn() + .mockResolvedValue({ kind: 'ok', dataUrl: 'data:image/png;base64,VVVV' }), + })); + vi.doMock('../src/widget/annotation-flow', () => ({ showAnnotationStep: annotationMock })); + vi.doMock('../src/widget/screenshot', () => ({ + beginViewportCapture: vi.fn(), + getRedactionCount: vi.fn().mockReturnValue(0), + isFullPageDisabled: vi.fn().mockReturnValue(true), + })); + + const { runScreenshotCaptureFlow } = await import('../src/widget/capture-flow'); + const onComplexScreenshotSkipped = vi.fn(); + + const result = await runScreenshotCaptureFlow( + document.createElement('div'), + baseConfig, + true, + onComplexScreenshotSkipped + ); + + expect(result.screenshot).toBe('annotated-image'); + expect(annotationMock).toHaveBeenCalledWith( + expect.any(HTMLElement), + 'data:image/png;base64,VVVV', + 0, + { redactionUnavailable: true } + ); + }); +}); diff --git a/test/mask.test.ts b/test/mask.test.ts index 9803f5e..242926f 100644 --- a/test/mask.test.ts +++ b/test/mask.test.ts @@ -1,12 +1,22 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { collectMaskRects, applyMaskToImage, translateMaskRect } from '../src/widget/mask'; +import { + applyMaskToImage, + collectMaskRects, + createRedactionPlan, + MaskApplicationError, + translateMaskRect, +} from '../src/widget/mask'; describe('mask module exports', () => { it('exports collectMaskRects', () => { expect(typeof collectMaskRects).toBe('function'); }); + it('exports createRedactionPlan', () => { + expect(typeof createRedactionPlan).toBe('function'); + }); + it('exports applyMaskToImage', () => { expect(typeof applyMaskToImage).toBe('function'); }); @@ -47,6 +57,14 @@ describe('collectMaskRects — explicit attribute', () => { document.body.appendChild(div); expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 20, w: 100, h: 50 }]); + expect(createRedactionPlan(document.body).targets).toMatchObject([ + { + element: div, + rect: { x: 10, y: 20, w: 100, h: 50 }, + reason: 'developer-marked', + strategy: 'canvas-mask', + }, + ]); }); it('returns rects for multiple sibling masked elements', () => { @@ -74,6 +92,14 @@ describe('collectMaskRects — built-in defaults', () => { document.body.appendChild(input); expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); + expect(createRedactionPlan(document.body).targets).toMatchObject([ + { + element: input, + rect: { x: 0, y: 0, w: 200, h: 30 }, + reason: 'sensitive-input', + strategy: 'canvas-mask', + }, + ]); }); it('masks credit-card autocomplete inputs', () => { @@ -171,6 +197,78 @@ describe('collectMaskRects — nesting and scoping', () => { expect(collectMaskRects(input)).toEqual([{ x: 0, y: 0, w: 200, h: 30 }]); }); + + it('traverses open shadow DOM redaction targets from a host element', () => { + const host = withRect(document.createElement('div'), 0, 0, 200, 100); + const shadow = host.attachShadow({ mode: 'open' }); + const privateField = withRect(document.createElement('div'), 10, 10, 50, 20); + privateField.setAttribute('data-bugdrop-mask', ''); + shadow.appendChild(privateField); + document.body.appendChild(host); + + expect(collectMaskRects(document.body)).toEqual([{ x: 10, y: 10, w: 50, h: 20 }]); + expect(createRedactionPlan(document.body).targets).toMatchObject([ + { + element: privateField, + rect: { x: 10, y: 10, w: 50, h: 20 }, + reason: 'developer-marked', + strategy: 'canvas-mask', + }, + ]); + }); + + it('uses a marked shadow host as the top-most mask', () => { + const host = withRect(document.createElement('div'), 0, 0, 200, 100); + host.setAttribute('data-bugdrop-mask', ''); + const shadow = host.attachShadow({ mode: 'open' }); + const privateField = withRect(document.createElement('div'), 10, 10, 50, 20); + privateField.setAttribute('data-bugdrop-mask', ''); + shadow.appendChild(privateField); + document.body.appendChild(host); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it('does not traverse iframe document redaction targets', () => { + const iframe = withRect(document.createElement('iframe'), 0, 0, 200, 100); + const iframeBody = document.createElement('body'); + const privateField = withRect(document.createElement('div'), 10, 10, 50, 20); + privateField.setAttribute('data-bugdrop-mask', ''); + iframeBody.appendChild(privateField); + Object.defineProperty(iframe, 'contentDocument', { + value: { body: iframeBody }, + configurable: true, + }); + document.body.appendChild(iframe); + + expect(createRedactionPlan(document.body).targets).toEqual([]); + }); + + it('masks a marked iframe container without inspecting iframe contents', () => { + const iframe = withRect(document.createElement('iframe'), 0, 0, 200, 100); + iframe.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(iframe); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it.each(['canvas', 'img', 'video'] as const)('masks a marked %s container', tagName => { + const media = withRect(document.createElement(tagName), 0, 0, 200, 100); + media.setAttribute('data-bugdrop-mask', ''); + document.body.appendChild(media); + + expect(collectMaskRects(document.body)).toEqual([{ x: 0, y: 0, w: 200, h: 100 }]); + }); + + it.each(['canvas', 'img', 'video'] as const)( + 'does not treat unmarked %s pixels as redaction targets', + tagName => { + const media = withRect(document.createElement(tagName), 0, 0, 200, 100); + document.body.appendChild(media); + + expect(collectMaskRects(document.body)).toEqual([]); + } + ); }); describe('collectMaskRects — coordinates and visibility', () => { @@ -357,6 +455,6 @@ describe('applyMaskToImage', () => { }; await expect( applyMaskToImage('data:image/png;base64,bad', [{ x: 0, y: 0, w: 10, h: 10 }], 1) - ).rejects.toThrow('Failed to apply privacy masks'); + ).rejects.toThrow(MaskApplicationError); }); });