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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions e2e/widget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
});
});

test.describe('Widget Interaction', () => {

Check warning on line 44 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Arrow function has too many lines (344). Maximum allowed is 150
test('clicking feedback button triggers modal', async ({ page }) => {
await page.goto('/test/');

Expand Down Expand Up @@ -325,11 +325,11 @@
await page.goto('/test/');

// Wait for BugDrop API to be available, then open programmatically
await page.waitForFunction(() => typeof (window as any).BugDrop !== 'undefined', {

Check warning on line 328 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
timeout: 5000,
});
await page.evaluate(() => {
(window as any).BugDrop?.open();

Check warning on line 332 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
});

// Form should appear directly (no welcome screen)
Expand Down Expand Up @@ -458,7 +458,7 @@
await page.route('**/api/check/**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',

Check warning on line 461 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

File has too many lines (3546). Maximum allowed is 300
body: JSON.stringify({ installed: true }),
});
});
Expand Down Expand Up @@ -615,7 +615,7 @@
});
});

test.describe('Dismissible Button', () => {

Check warning on line 618 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Arrow function has too many lines (328). Maximum allowed is 150
test.beforeEach(async ({ page }) => {
// Clear localStorage before each test
await page.goto('/test/dismissible.html');
Expand Down Expand Up @@ -1240,10 +1240,10 @@

// Track host-page keydown events that fire while BugDrop is open
await page.evaluate(() => {
(window as any).__hostKeystrokeCount = 0;

Check warning on line 1243 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
document.addEventListener('keydown', () => {
if (document.getElementById('bugdrop-host')) {
(window as any).__hostKeystrokeCount++;

Check warning on line 1246 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
}
});
});
Expand Down Expand Up @@ -1279,7 +1279,7 @@
await expect(descInput).toHaveValue('Also testing textarea');

// Host page should NOT have received any keystrokes
const leakedCount = await page.evaluate(() => (window as any).__hostKeystrokeCount);

Check warning on line 1282 in e2e/widget.spec.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, Knip, Audit

Unexpected any. Specify a different type
expect(leakedCount).toBe(0);
});
});
Expand Down Expand Up @@ -2136,9 +2136,41 @@
const img = page.locator('#bugdrop-host').locator('css=.bd-trigger-icon img');
await expect(img).toBeVisible();

// Img src should contain the data URI
// Img src should contain the same-origin icon URL
const src = await img.getAttribute('src');
expect(src).toContain('data:image/png;base64,');
expect(src).toContain('/test/bugdrop-icon.svg');
});

test('hostile label and icon config cannot inject markup', async ({ page }) => {
await page.goto('/test/index.html');
await page.locator('#bugdrop-host').locator('css=.bd-trigger').waitFor();

await page.evaluate(() => {
document.getElementById('bugdrop-host')?.remove();

const script = document.createElement('script');
script.src = '/widget.js';
script.dataset.repo = 'mean-weasel/bugdrop-widget-test';
script.dataset.label = '<img src=x onerror="window.__bugdropLabelXss = true">';
script.dataset.icon = 'javascript:window.__bugdropIconXss = true';
document.body.appendChild(script);
});

const host = page.locator('#bugdrop-host');
const trigger = host.locator('css=.bd-trigger');
await expect(trigger).toBeVisible({ timeout: 5000 });

await expect(host.locator('css=.bd-trigger-label img')).not.toBeAttached();
await expect(host.locator('css=.bd-trigger-icon img')).not.toBeAttached();
await expect(host.locator('css=.bd-trigger-label')).toHaveText(
'<img src=x onerror="window.__bugdropLabelXss = true">'
);

const executed = await page.evaluate(() => ({
label: Boolean((window as typeof window & { __bugdropLabelXss?: boolean }).__bugdropLabelXss),
icon: Boolean((window as typeof window & { __bugdropIconXss?: boolean }).__bugdropIconXss),
}));
expect(executed).toEqual({ label: false, icon: false });
});

test('broken icon URL falls back to default emoji', async ({ page }) => {
Expand Down
4 changes: 4 additions & 0 deletions public/test/bugdrop-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions public/test/icon-custom.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
<body>
<h1>Custom Icon Test</h1>
<div class="info">
<p><strong>Test:</strong> This page loads BugDrop with <code>data-icon</code> set to a data URI image.</p>
<p><strong>Test:</strong> This page loads BugDrop with <code>data-icon</code> set to a same-origin SVG image.</p>
<p>The feedback button should show a custom image instead of the default 🐛 emoji.</p>
</div>

<!-- Load BugDrop WITH custom icon (1x1 green PNG as data URI) -->
<script src="/widget.js" data-repo="mean-weasel/bugdrop-widget-test" data-theme="dark" data-icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="></script>
<!-- Load BugDrop WITH custom icon -->
<script src="/widget.js" data-repo="mean-weasel/bugdrop-widget-test" data-theme="dark" data-icon="/test/bugdrop-icon.svg"></script>
</body>
</html>
138 changes: 90 additions & 48 deletions src/widget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import {
isValidTheme,
type ThemeMode,
} from './theme';
import {
escapeHtml,
sanitizeCssColor,
sanitizeCssFontFamily,
sanitizeNonNegativeNumber,
sanitizeNonNegativePixelValue,
sanitizePositiveInteger,
sanitizeShadowPreset,
sanitizeUrl,
} from './sanitize';

type FeedbackCategory = 'bug' | 'feature' | 'question';
type CategoryLabelConfig = Partial<Record<FeedbackCategory, string | string[]>>;
Expand Down Expand Up @@ -323,42 +333,68 @@ if (!document.currentScript) {
'[BugDrop] document.currentScript is null — do not use async or defer on the BugDrop script tag.'
);
}
const rawTheme = script?.dataset.theme as WidgetConfig['theme'] | undefined;
const rawTheme = script?.dataset.theme;
if (rawTheme && !isValidTheme(rawTheme)) {
console.warn(`[BugDrop] Invalid data-theme "${rawTheme}". Expected "light", "dark", or "auto".`);
}
const requireName = script?.dataset.requireName === 'true';
const requireEmail = script?.dataset.requireEmail === 'true';
const rawPosition = script?.dataset.position;
if (rawPosition && rawPosition !== 'bottom-right' && rawPosition !== 'bottom-left') {
console.warn(
`[BugDrop] Invalid data-position "${rawPosition}". Expected "bottom-right" or "bottom-left".`
);
}
const rawDismissDuration = script?.dataset.dismissDuration;
const dismissDuration = sanitizePositiveInteger(rawDismissDuration);
if (rawDismissDuration && dismissDuration === undefined) {
console.warn(
'[BugDrop] Invalid data-dismiss-duration. Expected a positive whole number of days.'
);
}
const rawScreenshotScale = script?.dataset.screenshotScale;
const screenshotScale = sanitizeNonNegativeNumber(rawScreenshotScale);
if (rawScreenshotScale && screenshotScale === undefined) {
console.warn('[BugDrop] Invalid data-screenshot-scale. Expected a non-negative number.');
}
const rawShadow = script?.dataset.shadow;
const shadow = sanitizeShadowPreset(rawShadow);
if (rawShadow && !shadow) {
console.warn('[BugDrop] Invalid data-shadow. Expected "soft", "hard", or "none".');
}
const config: WidgetConfig = {
repo: script?.dataset.repo || '',
apiUrl: script?.src.replace(/\/widget(?:\.v[\d.]+)?\.js$/, '/api') || '',
position: (script?.dataset.position as WidgetConfig['position']) || 'bottom-right',
theme: rawTheme || 'auto', // Default to auto-detection
position: rawPosition === 'bottom-left' ? 'bottom-left' : 'bottom-right',
theme: isValidTheme(rawTheme) ? rawTheme : 'auto', // Default to auto-detection
// Name/email field configuration (all default to false for backwards compatibility)
showName: script?.dataset.showName === 'true',
requireName: script?.dataset.requireName === 'true',
showEmail: script?.dataset.showEmail === 'true',
requireEmail: script?.dataset.requireEmail === 'true',
showName: script?.dataset.showName === 'true' || requireName,
requireName,
showEmail: script?.dataset.showEmail === 'true' || requireEmail,
requireEmail,
// Dismissible button configuration
buttonDismissible: script?.dataset.buttonDismissible === 'true',
dismissDuration: script?.dataset.dismissDuration
? parseInt(script.dataset.dismissDuration, 10)
: undefined,
dismissDuration,
// Show restore pill after dismissing (default true when dismissible, unless explicitly false)
showRestore: script?.dataset.showRestore !== 'false',
// Button visibility (default true, set to false for API-only mode)
showButton: script?.dataset.button !== 'false',
// Custom accent color (e.g., "#FF6B35")
accentColor: script?.dataset.color || undefined,
accentColor: sanitizeCssColor(script?.dataset.color),
// Custom icon URL (or 'none' to hide)
iconUrl: script?.dataset.icon || undefined,
iconUrl: sanitizeUrl(script?.dataset.icon),
// Custom trigger label
label: script?.dataset.label || undefined,
categoryLabels: parseCategoryLabels(script?.dataset.categoryLabels),
// Tier 1 styling customization
font: script?.dataset.font || undefined,
radius: script?.dataset.radius || undefined,
bgColor: script?.dataset.bg || undefined,
textColor: script?.dataset.text || undefined,
font: sanitizeCssFontFamily(script?.dataset.font),
radius: sanitizeNonNegativePixelValue(script?.dataset.radius)?.toString(),
bgColor: sanitizeCssColor(script?.dataset.bg),
textColor: sanitizeCssColor(script?.dataset.text),
// Tier 2 styling customization
borderWidth: script?.dataset.borderWidth || undefined,
borderColor: script?.dataset.borderColor || undefined,
shadow: script?.dataset.shadow || undefined,
borderWidth: sanitizeNonNegativePixelValue(script?.dataset.borderWidth)?.toString(),
borderColor: sanitizeCssColor(script?.dataset.borderColor),
shadow,
// Welcome screen behavior (default: 'once')
welcome: (() => {
const val = script?.dataset.welcome;
Expand All @@ -377,9 +413,7 @@ const config: WidgetConfig = {
}
return 'optional' as const;
})(),
screenshotScale: script?.dataset.screenshotScale
? parseFloat(script.dataset.screenshotScale)
: undefined,
screenshotScale,
};

// Validate config
Expand All @@ -393,22 +427,41 @@ if (!config.repo) {
initWidget(config);
}

// Build the trigger button icon HTML - custom image with emoji fallback, 'none' to hide, or default emoji
function getTriggerIconHtml(config: WidgetConfig): string {
if (config.iconUrl === 'none') {
return '';
}
if (config.iconUrl) {
return `<img src="${config.iconUrl}" alt="" onerror="this.style.display='none';this.nextSibling.style.display=''"><span style="display:none">🐛</span>`;
}
return '🐛';
}

// Build the trigger button label text
function getTriggerLabel(config: WidgetConfig): string {
return config.label !== undefined ? config.label : 'Feedback';
}

function appendTriggerContent(trigger: HTMLElement, config: WidgetConfig): void {
if (config.iconUrl !== 'none') {
const icon = document.createElement('span');
icon.className = 'bd-trigger-icon';

if (config.iconUrl) {
const image = document.createElement('img');
image.src = config.iconUrl;
image.alt = '';
const fallback = document.createElement('span');
fallback.textContent = '🐛';
fallback.style.display = 'none';
image.addEventListener('error', () => {
image.style.display = 'none';
fallback.style.display = '';
});
icon.append(image, fallback);
} else {
icon.textContent = '🐛';
}

trigger.appendChild(icon);
}

const label = document.createElement('span');
label.className = 'bd-trigger-label';
label.textContent = getTriggerLabel(config);
trigger.appendChild(label);
}

// Create the pull tab shown after dismissing the button
function createPullTab(root: HTMLElement, config: WidgetConfig): HTMLElement {
const tab = document.createElement('div');
Expand Down Expand Up @@ -490,15 +543,14 @@ function initWidget(config: WidgetConfig) {
if (shouldShowButton) {
const trigger = document.createElement('button');
trigger.className = 'bd-trigger';
const iconHtml = getTriggerIconHtml(config);
trigger.innerHTML = `${iconHtml ? `<span class="bd-trigger-icon">${iconHtml}</span>` : ''}<span class="bd-trigger-label">${getTriggerLabel(config)}</span>`;
appendTriggerContent(trigger, config);
trigger.setAttribute('aria-label', 'Report a bug or send feedback');

// Add close button if dismissible
if (config.buttonDismissible) {
const closeBtn = document.createElement('button');
closeBtn.className = 'bd-trigger-close';
closeBtn.innerHTML = '×';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
trigger.appendChild(closeBtn);

Expand Down Expand Up @@ -651,14 +703,13 @@ function exposeBugDropAPI(root: HTMLElement, config: WidgetConfig) {
function createTriggerButton(root: HTMLElement, config: WidgetConfig, isRestoring = false) {
const trigger = document.createElement('button');
trigger.className = isRestoring ? 'bd-trigger bd-trigger--restoring' : 'bd-trigger';
const iconHtml = getTriggerIconHtml(config);
trigger.innerHTML = `${iconHtml ? `<span class="bd-trigger-icon">${iconHtml}</span>` : ''}<span class="bd-trigger-label">${getTriggerLabel(config)}</span>`;
appendTriggerContent(trigger, config);
trigger.setAttribute('aria-label', 'Report a bug or send feedback');

if (config.buttonDismissible) {
const closeBtn = document.createElement('button');
closeBtn.className = 'bd-trigger-close';
closeBtn.innerHTML = '×';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Dismiss feedback button');
trigger.appendChild(closeBtn);

Expand Down Expand Up @@ -1060,15 +1111,6 @@ function getCategoryChecked(
return (initialValues?.category || 'bug') === category ? 'checked' : '';
}

function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

async function submitFeedback(root: HTMLElement, config: WidgetConfig, data: FeedbackData) {
// Show submitting modal with loading state
const modal = createModal(
Expand Down
20 changes: 13 additions & 7 deletions src/widget/picker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { sanitizeCssColor, sanitizeCssFontFamily, sanitizeNonNegativePixelValue } from './sanitize';

export interface PickerStyle {
accentColor?: string;
font?: string;
Expand All @@ -21,17 +23,21 @@ interface ResolvedPickerStyle {

export function resolvePickerStyle(style?: PickerStyle): ResolvedPickerStyle {
const isDark = style?.theme === 'dark';
const radius = sanitizeNonNegativePixelValue(style?.radius);
const borderWidth = sanitizeNonNegativePixelValue(style?.borderWidth);
const fontFamily = sanitizeCssFontFamily(style?.font);

return {
accent: style?.accentColor || '#14b8a6',
accent: sanitizeCssColor(style?.accentColor) || '#14b8a6',
fontFamily:
style?.font === 'inherit'
? 'system-ui, sans-serif'
: style?.font || "'Space Grotesk', system-ui, sans-serif",
radius: style?.radius !== undefined ? `${style.radius}px` : '6px',
bw: style?.borderWidth || '3',
tooltipBg: style?.bgColor || (isDark ? '#0f172a' : '#1a1a1a'),
tooltipText: style?.textColor || '#f1f5f9',
tooltipBorder: style?.borderColor || (isDark ? '#334155' : '#333'),
: fontFamily || "'Space Grotesk', system-ui, sans-serif",
radius: radius !== undefined ? `${radius}px` : '6px',
bw: borderWidth !== undefined ? String(borderWidth) : '3',
tooltipBg: sanitizeCssColor(style?.bgColor) || (isDark ? '#0f172a' : '#1a1a1a'),
tooltipText: sanitizeCssColor(style?.textColor) || '#f1f5f9',
tooltipBorder: sanitizeCssColor(style?.borderColor) || (isDark ? '#334155' : '#333'),
};
}

Expand Down
Loading
Loading