Skip to content

Commit 79d5337

Browse files
committed
feat: improve redaction screenshot ux
1 parent 033107e commit 79d5337

9 files changed

Lines changed: 377 additions & 28 deletions

File tree

docs/website/configuration.mdx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,27 @@ Control how screenshots are collected during the feedback flow.
218218
- **`auto`** -- Automatically captures a full-page screenshot after the form is submitted, without showing the manual screenshot picker or redaction step.
219219
- **`required`** -- Requires a screenshot before submission. Users can choose full page, element, or area, then annotate or redact before submitting.
220220

221-
Manual redaction is controlled by the person submitting feedback. Use developer-configured masking for fields that should never appear in screenshots, especially with automatic screenshots.
221+
Manual redaction is controlled by the person submitting feedback. Use developer-configured masking for fields that should never appear in screenshots, especially with automatic screenshots. Auto mode warns users that a full-page screenshot will be attached, but it does not show the manual picker or annotation review step.
222+
223+
### Developer-configured screenshot masking
224+
225+
BugDrop can visually mask fields that your app marks as private before a screenshot is uploaded:
226+
227+
```html
228+
<input data-bugdrop-redact value="sk_live_..." />
229+
<section data-bugdrop-mask>Private account details</section>
230+
```
231+
232+
Supported attributes:
233+
234+
- `data-bugdrop-redact`
235+
- `data-bd-redact`
236+
- `data-bugdrop-redacted`
237+
- `data-bugdrop-mask`
238+
239+
This is best-effort visual masking, not a data-loss-prevention or security boundary. BugDrop can only mask content it can measure in the page DOM. Unmarked sensitive information can still appear, and browser rendering limits can apply to canvas, image, video, iframe, SVG, shadow DOM, pseudo-element, and highly custom control content.
240+
241+
Selected-area screenshots are rendered to the selected dimensions and masks are translated into that crop, but the capture still runs client-side against the page DOM. Avoid screenshots entirely for pages where client-side screenshot rendering is unacceptable.
222242

223243
```html
224244
<!-- Automatically attach a full-page screenshot -->

e2e/widget.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,6 +2202,20 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
22022202
}`;
22032203
}
22042204

2205+
function redactionAwarePng() {
2206+
return `function(el, opts) {
2207+
window.__captureOpts = opts;
2208+
var canvas = document.createElement('canvas');
2209+
var pixelRatio = opts && opts.pixelRatio ? opts.pixelRatio : 1;
2210+
canvas.width = Math.max(1, Math.ceil((opts && opts.width ? opts.width : document.documentElement.scrollWidth) * pixelRatio));
2211+
canvas.height = Math.max(1, Math.ceil((opts && opts.height ? opts.height : document.documentElement.scrollHeight) * pixelRatio));
2212+
var ctx = canvas.getContext('2d');
2213+
ctx.fillStyle = '#ffffff';
2214+
ctx.fillRect(0, 0, canvas.width, canvas.height);
2215+
return Promise.resolve(canvas.toDataURL('image/png'));
2216+
}`;
2217+
}
2218+
22052219
function mockGetDisplayMedia(page: Page, body: string) {
22062220
return page.addInitScript(`
22072221
Object.defineProperty(navigator, 'mediaDevices', {
@@ -3262,6 +3276,58 @@ test.describe('Screenshot Crash Prevention (#67)', () => {
32623276
await expect(host.locator('css=p >> text=too complex')).not.toBeAttached();
32633277
});
32643278

3279+
test('communicates developer redactions for full-page screenshots', async ({ page }) => {
3280+
await mockHtmlToImage(page, redactionAwarePng());
3281+
await page.goto('/test/redaction.html');
3282+
3283+
const host = await navigateToScreenshotOptions(page);
3284+
await expect(host.locator('css=.bd-redaction-note')).toContainText(
3285+
'marked some fields for redaction'
3286+
);
3287+
3288+
await host.locator('css=[data-action="capture"]').click();
3289+
3290+
await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 10000 });
3291+
await expect(host.locator('css=.bd-redaction-note')).toContainText(
3292+
'1 private item was marked for redaction'
3293+
);
3294+
await expect(page.locator('#redacted-test-input')).toHaveValue('sk_live_local_test_secret');
3295+
});
3296+
3297+
test('communicates developer redactions for selected-area screenshots', async ({ page }) => {
3298+
await mockHtmlToImage(page, redactionAwarePng());
3299+
await page.goto('/test/redaction.html');
3300+
await page.locator('#redacted-test-input').scrollIntoViewIfNeeded();
3301+
3302+
const host = await navigateToScreenshotOptions(page);
3303+
await host.locator('css=[data-action="area"]').click();
3304+
await expect(page.locator('#bugdrop-area-picker-overlay')).toBeVisible({ timeout: 5000 });
3305+
await expect(page.locator('#bugdrop-area-picker-tooltip')).toContainText(
3306+
'Marked private fields may be masked if included'
3307+
);
3308+
3309+
const box = await page.locator('#redacted-test-input').boundingBox();
3310+
expect(box).toBeTruthy();
3311+
3312+
await page.mouse.move(box!.x - 8, box!.y - 8);
3313+
await page.mouse.down();
3314+
await page.mouse.move(box!.x + box!.width + 8, box!.y + box!.height + 8);
3315+
await page.mouse.up();
3316+
3317+
await expect(host.locator('css=#annotation-canvas')).toBeVisible({ timeout: 10000 });
3318+
await expect(host.locator('css=.bd-redaction-note')).toContainText(
3319+
'1 private item was marked for redaction'
3320+
);
3321+
3322+
const captureOpts = await page.evaluate(
3323+
() =>
3324+
(window as Window & { __captureOpts?: { width?: number; height?: number } }).__captureOpts
3325+
);
3326+
expect(captureOpts?.width).toBeGreaterThan(100);
3327+
expect(captureOpts?.height).toBeGreaterThan(40);
3328+
await expect(page.locator('#redacted-test-input')).toHaveValue('sk_live_local_test_secret');
3329+
});
3330+
32653331
test('annotation modal gives issue #117 style previews enough readable width', async ({
32663332
page,
32673333
}) => {
@@ -3833,6 +3899,22 @@ test.describe('Screenshot Mode Configuration', () => {
38333899
).toBe(1);
38343900
});
38353901

3902+
test('auto mode warns users about automatic full-page screenshots', async ({ page }) => {
3903+
await setupInstalledApp(page);
3904+
await mockSuccessfulCapture(page);
3905+
await setupSuccessfulSubmit(page);
3906+
3907+
await page.goto('/test/redaction.html?screenshot=auto');
3908+
const host = await openForm(page);
3909+
3910+
await expect(host.locator('css=form')).toContainText(
3911+
'This site will attach a full-page screenshot when you submit'
3912+
);
3913+
await expect(host.locator('css=form')).toContainText(
3914+
'unmarked sensitive information can still be included'
3915+
);
3916+
});
3917+
38363918
test('auto mode skips full-page capture on very complex pages', async ({ page }) => {
38373919
await setupInstalledApp(page);
38383920
await mockSuccessfulCapture(page);
@@ -4059,6 +4141,19 @@ test.describe('Screenshot Masking', () => {
40594141
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
40604142
});
40614143

4144+
test('masks elements tagged with data-bugdrop-redact', async ({ page }) => {
4145+
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
4146+
page,
4147+
'/test/redaction.html'
4148+
);
4149+
4150+
const rect = await docRectOf(page, '#redacted-test-input');
4151+
const cx = Math.floor((rect.x + rect.w / 2) * pixelRatio);
4152+
const cy = Math.floor((rect.y + rect.h / 2) * pixelRatio);
4153+
4154+
expect(await pixelAt(page, screenshot, cx, cy)).toEqual([0, 0, 0, 255]);
4155+
});
4156+
40624157
test('does not mask unrelated elements', async ({ page }) => {
40634158
const { screenshot, pixelRatio } = await submitFeedbackWithFullPageCapture(
40644159
page,

playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default defineConfig({
3232
webServer: process.env.LIVE_TARGET
3333
? undefined
3434
: {
35-
command: 'npm run dev',
35+
command: 'BUGDROP_TEST_HOOKS=1 npm run build:widget && npm run dev',
3636
url: 'http://localhost:8787',
3737
reuseExistingServer: !process.env.CI,
3838
timeout: 120 * 1000,

public/test/redaction.html

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>BugDrop Redaction Fixture</title>
7+
<style>
8+
* {
9+
box-sizing: border-box;
10+
}
11+
12+
body {
13+
margin: 0;
14+
min-height: 100vh;
15+
padding: 96px;
16+
background: #101828;
17+
color: #e5e7eb;
18+
font-family: Arial, sans-serif;
19+
}
20+
21+
main {
22+
max-width: 860px;
23+
margin: 0 auto;
24+
}
25+
26+
h1 {
27+
margin: 0 0 12px;
28+
font-size: 32px;
29+
}
30+
31+
p {
32+
margin: 0 0 32px;
33+
color: #aeb7c8;
34+
}
35+
36+
.fixture-panel {
37+
padding: 28px;
38+
background: #182235;
39+
border: 1px solid #31405c;
40+
border-radius: 12px;
41+
}
42+
43+
label {
44+
display: block;
45+
margin-bottom: 8px;
46+
color: #cbd5e1;
47+
font-size: 14px;
48+
font-weight: 700;
49+
}
50+
51+
.redacted-field {
52+
width: 420px;
53+
height: 72px;
54+
padding: 0 20px;
55+
background: #dc2626;
56+
border: 2px solid #fecaca;
57+
border-radius: 12px;
58+
color: #ffffff;
59+
font-size: 24px;
60+
font-weight: 700;
61+
}
62+
63+
.plain-field {
64+
width: 420px;
65+
height: 52px;
66+
margin-top: 24px;
67+
padding: 0 16px;
68+
background: #0f172a;
69+
border: 1px solid #475569;
70+
border-radius: 8px;
71+
color: #e5e7eb;
72+
font-size: 18px;
73+
}
74+
</style>
75+
</head>
76+
<body>
77+
<main>
78+
<h1>Redaction Fixture</h1>
79+
<p>Fixture used by Playwright to verify BugDrop screenshot redaction behavior.</p>
80+
81+
<section class="fixture-panel">
82+
<label for="redacted-test-input">Marked private</label>
83+
<input
84+
id="redacted-test-input"
85+
class="redacted-field"
86+
type="text"
87+
data-bugdrop-redact
88+
value="sk_live_local_test_secret"
89+
>
90+
91+
<label for="plain-test-input" style="margin-top: 24px;">Unmarked field</label>
92+
<input
93+
id="plain-test-input"
94+
class="plain-field"
95+
type="text"
96+
value="ordinary visible value"
97+
>
98+
</section>
99+
</main>
100+
101+
<script id="bugdrop-script" defer src="/widget.js" data-repo="mean-weasel/bugdrop-widget-test" data-theme="dark" data-color="#ff9e64"></script>
102+
<script>
103+
(function () {
104+
try {
105+
var script = document.getElementById('bugdrop-script');
106+
if (!script) return;
107+
var params = new URLSearchParams(location.search);
108+
var mapping = { screenshot: 'data-screenshot' };
109+
Object.keys(mapping).forEach(function (key) {
110+
var v = params.get(key);
111+
if (v != null) script.setAttribute(mapping[key], v);
112+
});
113+
} catch (e) {
114+
// fixture must never break the page
115+
}
116+
})();
117+
</script>
118+
</body>
119+
</html>

scripts/build-widget.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,24 @@ const [major, minor, patch] = version.split('.');
3232

3333
console.log(`Building widget version ${version}...`);
3434

35+
const enableTestHooks = process.env.BUGDROP_TEST_HOOKS === '1';
36+
3537
// Build the main widget bundle
3638
execSync(
37-
`npx esbuild src/widget/index.ts --bundle --minify --format=iife --define:__BUGDROP_VERSION__='"${version}"' --outfile=public/widget.js`,
39+
`npx esbuild src/widget/index.ts --bundle --minify --format=iife --define:__BUGDROP_VERSION__='"${version}"' --define:__BUGDROP_ENABLE_TEST_HOOKS__=${enableTestHooks ? 'true' : 'false'} --outfile=public/widget.js`,
3840
{ cwd: rootDir, stdio: 'inherit' }
3941
);
4042

4143
// Create versioned copies
4244
const widgetPath = join(publicDir, 'widget.js');
4345

46+
if (!enableTestHooks) {
47+
const widget = readFileSync(widgetPath, 'utf-8');
48+
if (widget.includes('__bugdropMockToPng')) {
49+
throw new Error('Production widget build unexpectedly contains test screenshot hook');
50+
}
51+
}
52+
4453
// widget.v{major}.js (e.g., widget.v1.js)
4554
const majorVersionPath = join(publicDir, `widget.v${major}.js`);
4655
copyFileSync(widgetPath, majorVersionPath);

src/widget/area-picker.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@ import { resolvePickerStyle } from './picker';
33

44
const MIN_SELECTION_SIZE = 10;
55
const AREA_PICKER_INSTRUCTION = 'Draw a selection around the area to capture (ESC to cancel)';
6+
const AREA_PICKER_REDACTION_INSTRUCTION =
7+
'Draw a selection around the area to capture. Marked private fields may be masked if included. (ESC to cancel)';
68

7-
export function createAreaPicker(style?: PickerStyle): Promise<DOMRect | null> {
9+
export function createAreaPicker(
10+
style?: PickerStyle,
11+
opts?: { redactionsAvailable?: boolean }
12+
): Promise<DOMRect | null> {
813
return new Promise(resolve => {
914
setTimeout(() => {
10-
startAreaPicker(resolve, style);
15+
startAreaPicker(resolve, style, opts);
1116
}, 50);
1217
});
1318
}
1419

15-
function startAreaPicker(resolve: (rect: DOMRect | null) => void, style?: PickerStyle): void {
20+
function startAreaPicker(
21+
resolve: (rect: DOMRect | null) => void,
22+
style?: PickerStyle,
23+
opts?: { redactionsAvailable?: boolean }
24+
): void {
1625
const { accent, fontFamily, radius, bw, tooltipBg, tooltipText, tooltipBorder } =
1726
resolvePickerStyle(style);
1827

@@ -65,7 +74,9 @@ function startAreaPicker(resolve: (rect: DOMRect | null) => void, style?: Picker
6574
border: ${bw}px solid ${tooltipBorder};
6675
pointer-events: none;
6776
`;
68-
tooltip.textContent = AREA_PICKER_INSTRUCTION;
77+
tooltip.textContent = opts?.redactionsAvailable
78+
? AREA_PICKER_REDACTION_INSTRUCTION
79+
: AREA_PICKER_INSTRUCTION;
6980
document.body.appendChild(tooltip);
7081

7182
let startX = 0;

0 commit comments

Comments
 (0)