diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c539699..c8e2530 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,24 @@ jobs: echo "Preview worker not ready after 300s" exit 1 + - name: Verify preview sandbox assets + env: + WORKER_ORIGIN: https://bugdrop-preview.neonwatty.workers.dev + run: | + check() { + local path="$1" expected="$2" + echo "Checking ${WORKER_ORIGIN}${path} for '${expected}'..." + body=$(curl -sSf "${WORKER_ORIGIN}${path}") || { echo "Fetch failed: $path"; exit 1; } + grep -q -- "$expected" <<<"$body" || { echo "Wrong content for $path (expected: $expected)"; exit 1; } + } + check /sandbox/ 'id="sandbox-form"' + check /sandbox/preview 'BugDrop Sandbox Preview' + check /sandbox/sandbox.css 'sandbox-shell' + check /sandbox/sandbox.js 'generateScriptTag' + check /sandbox/attribute-map.js 'data-repo' + check /sandbox/sanitizers.js 'sanitizeConfig' + check /widget.js 'bd-trigger' + - name: Verify test venue is reachable run: | VENUE_URL="${PLAYWRIGHT_BASE_URL}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index efa1fe0..da4a226 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -64,3 +64,21 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Verify production sandbox assets + env: + WORKER_ORIGIN: https://bugdrop.neonwatty.workers.dev + run: | + check() { + local path="$1" expected="$2" + echo "Checking ${WORKER_ORIGIN}${path} for '${expected}'..." + body=$(curl -sSf "${WORKER_ORIGIN}${path}") || { echo "Fetch failed: $path"; exit 1; } + grep -q -- "$expected" <<<"$body" || { echo "Wrong content for $path (expected: $expected)"; exit 1; } + } + check /sandbox/ 'id="sandbox-form"' + check /sandbox/preview 'BugDrop Sandbox Preview' + check /sandbox/sandbox.css 'sandbox-shell' + check /sandbox/sandbox.js 'generateScriptTag' + check /sandbox/attribute-map.js 'data-repo' + check /sandbox/sanitizers.js 'sanitizeConfig' + check /widget.js 'bd-trigger' diff --git a/README.md b/README.md index d7775d4..d517474 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Version](https://img.shields.io/badge/version-1.11.0-14b8a6)](./CHANGELOG.md) [![Security Policy](https://img.shields.io/badge/Security-Policy-blue)](./SECURITY.md) [![Live Demo](https://img.shields.io/badge/Demo-Try_It_Live-ff9e64)](https://bugdrop-widget-test.vercel.app) +[![Sandbox](https://img.shields.io/badge/Sandbox-Configure_Widget-7c3aed)](https://bugdrop.neonwatty.workers.dev/sandbox/) [![GitHub Marketplace](https://img.shields.io/badge/GitHub%20Marketplace-Install-2ea44f?logo=github)](https://github.com/marketplace/bugdrop-in-app-feedback-to-github-issues) [![Product Hunt](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1141615&theme=light&t=1778415221018)](https://www.producthunt.com/products/bugdrop-2?utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-bugdrop-2) @@ -55,6 +56,8 @@ That's it! Users can now click the bug button to submit feedback as GitHub Issue See [full documentation](https://bugdrop.dev/docs/configuration) for all options including styling, submitter info, and dismissible button. +Use the [BugDrop Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to preview settings, check GitHub App installation, test screenshot masking, and generate a script tag. + ## Documentation - [Full Documentation](https://bugdrop.dev/docs) diff --git a/docs/website/ci-testing.mdx b/docs/website/ci-testing.mdx index b1c8370..99a95b6 100644 --- a/docs/website/ci-testing.mdx +++ b/docs/website/ci-testing.mdx @@ -45,7 +45,7 @@ const EXPECTED = { requireEmail: false, buttonDismissible: false, showRestore: true, - welcomeMessage: "Report a bug or request a feature", + welcome: "once", }; const URL = "https://your-site.com"; // Replace with your site URL @@ -114,17 +114,21 @@ test.describe("BugDrop Widget", () => { expect(bgColor).toBeTruthy(); }); - test("displays correct welcome message", async ({ page }) => { + test("uses expected welcome behavior", async ({ page }) => { const btn = await shadowEl(page, "bug-drop-widget", ".floating-btn"); await (btn as any).click(); - const welcomeText = await page.evaluate(() => { + // The welcome modal carries the `.bd-welcome` class; the feedback form does not. + // For `welcome: "once"`, the welcome modal only appears on the first open per repo; + // the "seen" flag is stored under localStorage keys prefixed with `bugdrop_welcomed_`, + // so for a deterministic test add `await page.evaluate(() => localStorage.clear())` + // (or `localStorage.removeItem('bugdrop_welcomed_/')`) in beforeEach. + const welcomeVisible = await page.evaluate(() => { const host = document.querySelector("bug-drop-widget"); - const welcome = host?.shadowRoot?.querySelector(".welcome-message, h2"); - return welcome?.textContent?.trim(); + return Boolean(host?.shadowRoot?.querySelector(".bd-welcome")); }); - expect(welcomeText).toContain(EXPECTED.welcomeMessage); + expect(welcomeVisible).toBe(EXPECTED.welcome !== "never"); }); test("shows/hides name field based on config", async ({ page }) => { @@ -249,7 +253,7 @@ The test suite covers three areas: - **Opens and closes the form** -- Clicks the button, verifies the form appears, then closes it - **Correct position** -- Validates the button is in the expected corner (bottom-right or bottom-left) - **Correct accent color** -- Checks the button's background color -- **Welcome message** -- Opens the form and verifies the correct welcome text is displayed +- **Welcome screen** -- Opens the form and verifies the expected welcome behavior - **Name/email field visibility** -- Confirms fields show or hide based on your configuration ### Accessibility Tests (WCAG AA) @@ -272,7 +276,7 @@ const EXPECTED = { requireEmail: false, // data-require-email buttonDismissible: false, // data-button-dismissible showRestore: true, // data-show-restore - welcomeMessage: "Report a bug or request a feature", // data-welcome + welcome: "once", // data-welcome }; ``` diff --git a/docs/website/configuration.mdx b/docs/website/configuration.mdx index 7c8244b..a6673ca 100644 --- a/docs/website/configuration.mdx +++ b/docs/website/configuration.mdx @@ -9,14 +9,14 @@ These attributes control the fundamental behavior of the widget. | Attribute | Default | Description | |-----------|---------|-------------| | `data-repo` | **(required)** | GitHub repository in `owner/repo` format | -| `data-theme` | `"light"` | Widget theme: `"light"` or `"dark"` | +| `data-theme` | `"auto"` | Widget theme: `"auto"`, `"light"`, or `"dark"` | | `data-position` | `"bottom-right"` | Button position: `"bottom-right"` or `"bottom-left"` | | `data-color` | `"#7c3aed"` | Primary accent color (any CSS color value) | | `data-icon` | *(default bug icon)* | Custom icon: URL to an image, `"none"` to hide, or omit for default | | `data-label` | `""` | Text label displayed next to the button icon | | `data-category-labels` | built-in labels | Self-hosted JSON mapping from built-in categories to GitHub labels | | `data-button` | `"true"` | Show the floating button: `"true"` or `"false"` | -| `data-welcome` | `"Report a bug or request a feature"` | Welcome message displayed at the top of the form | +| `data-welcome` | `"once"` | Welcome screen behavior: `"once"`, `"always"`, `"never"`, or `"false"` | ### Basic Example @@ -27,7 +27,7 @@ These attributes control the fundamental behavior of the widget. data-theme="dark" data-position="bottom-left" data-color="#ef4444" - data-welcome="Found a bug? Let us know!" + data-welcome="always" > ``` @@ -35,11 +35,16 @@ These attributes control the fundamental behavior of the widget. The `data-theme` attribute controls the overall color scheme of the widget: -- **`light`** (default) -- White background with dark text. Suitable for most sites. +- **`auto`** (default) -- Follows the user's system color scheme. +- **`light`** -- White background with dark text. Suitable for most sites. - **`dark`** -- Dark background with light text. Ideal for sites with dark color schemes. ```html - + + + + @@ -55,6 +60,24 @@ The `data-position` attribute controls where the floating button appears: - **`bottom-right`** (default) -- Fixed to the bottom-right corner - **`bottom-left`** -- Fixed to the bottom-left corner +### Welcome Screen Options + +The `data-welcome` attribute controls when the built-in welcome screen appears: + +- **`once`** (default) -- Show the welcome screen once per repository, then skip it on future opens in the same browser. +- **`always`** -- Show the welcome screen every time the user opens the widget. +- **`never`** or **`false`** -- Skip the welcome screen and open the feedback form directly. + +```html + + + + + +``` + ## Submitter Information Control whether the feedback form collects the submitter's name and email address. @@ -307,7 +330,7 @@ Here is a script tag using many configuration options together: data-theme="dark" data-position="bottom-left" data-color="#6366f1" - data-welcome="We'd love to hear from you!" + data-welcome="always" data-show-name="true" data-show-email="true" data-require-email="true" @@ -318,7 +341,7 @@ Here is a script tag using many configuration options together: > ``` -This configuration creates a dark-themed widget in the bottom-left corner with an indigo accent color, collects the submitter's name and email (email required), and shows a "Feedback" label on the button. The button is dismissible for 2 hours with a restore link. +This configuration creates a dark-themed widget in the bottom-left corner with an indigo accent color, always shows the built-in welcome screen, collects the submitter's name and email (email required), and shows a "Feedback" label on the button. The button is dismissible for 2 hours with a restore link. ## Next Steps diff --git a/docs/website/getting-started.mdx b/docs/website/getting-started.mdx index 0dd330b..bd2a065 100644 --- a/docs/website/getting-started.mdx +++ b/docs/website/getting-started.mdx @@ -50,6 +50,7 @@ That is it. No servers to run, no databases to manage, no user accounts required Ready to get started? Here is where to go next: - **[Installation](/docs/installation)** -- Step-by-step setup guide to add BugDrop to your site +- **[Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/)** -- Preview settings, check installation, and generate your script tag - **[Configuration](/docs/configuration)** -- All widget attributes for customizing behavior - **[Styling](/docs/styling)** -- Theme your widget with colors, fonts, borders, and shadows - **[JavaScript API](/docs/javascript-api)** -- Programmatic control with `window.BugDrop` diff --git a/docs/website/installation.mdx b/docs/website/installation.mdx index 3cefc41..4c863d7 100644 --- a/docs/website/installation.mdx +++ b/docs/website/installation.mdx @@ -65,12 +65,16 @@ You can add data attributes to customize the widget's appearance and behavior: data-theme="dark" data-position="bottom-left" data-color="#6366f1" - data-welcome="We'd love your feedback!" + data-welcome="always" > ``` See the [Configuration](/docs/configuration) and [Styling](/docs/styling) docs for all available attributes. +### Try it in the Sandbox + +Use the [BugDrop Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to preview widget behavior, check GitHub App installation, test screenshot masking, and generate a script tag before adding BugDrop to your app. + ## Protecting sensitive data If your page renders customer data, billing details, or any other content you do not @@ -84,7 +88,7 @@ want to appear in submitted screenshots, mark those elements with `data-bugdrop- BugDrop covers each marked element with an opaque rectangle on the captured screenshot. Password inputs and credit-card autocomplete fields are masked automatically. See -[Screenshot masking](/security#screenshot-masking) on the Security page for details. +[Screenshot masking](/docs/security#screenshot-masking) on the Security page for details. ## Step 3: You Are Done @@ -174,6 +178,7 @@ You can also test by clicking the bug button and submitting a test report. Check ## Next Steps +- [Try the Sandbox](https://bugdrop.neonwatty.workers.dev/sandbox/) to generate and test your script tag - [Configure the widget](/docs/configuration) with custom attributes - [Style the widget](/docs/styling) to match your site's design - [Use the JavaScript API](/docs/javascript-api) for advanced integrations diff --git a/e2e/sandbox.spec.ts b/e2e/sandbox.spec.ts new file mode 100644 index 0000000..bb6a802 --- /dev/null +++ b/e2e/sandbox.spec.ts @@ -0,0 +1,267 @@ +import { test, expect } from '@playwright/test'; + +test.describe('BugDrop Sandbox', () => { + test('serves sandbox route assets from the Worker', async ({ request }) => { + const cases: Array<{ path: string; contentType: RegExp }> = [ + { path: '/sandbox/', contentType: /html/ }, + { path: '/sandbox/preview', contentType: /html/ }, + { path: '/sandbox/attribute-map.js', contentType: /javascript/ }, + { path: '/sandbox/sanitizers.js', contentType: /javascript/ }, + { path: '/sandbox/sandbox.css', contentType: /css/ }, + { path: '/sandbox/sandbox.js', contentType: /javascript/ }, + { path: '/widget.js', contentType: /javascript/ }, + ]; + for (const { path, contentType } of cases) { + const response = await request.get(path); + expect(response.ok(), `${path} should resolve`).toBe(true); + expect(response.headers()['content-type'], `${path} content-type`).toMatch(contentType); + } + }); + + test('generates a script tag and loads the configured preview widget', async ({ page }) => { + const widgetRequests: string[] = []; + page.on('request', request => { + if (request.url().endsWith('/widget.js')) widgetRequests.push(request.url()); + }); + + await page.route('**/api/check/**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'mean-weasel/bugdrop' }), + }); + }); + + await page.goto('/sandbox/'); + + await expect(page.getByRole('heading', { name: 'BugDrop Sandbox' })).toBeVisible(); + await expect(page.locator('#sandbox-preview')).toHaveAttribute('sandbox', /allow-scripts/); + await expect(page.locator('#script-code')).toContainText('data-repo="mean-weasel/bugdrop"'); + + await page.locator('#repo').fill('acme/app'); + await page.locator('#theme').selectOption('dark'); + await page.locator('#position').selectOption('bottom-left'); + await page.locator('#screenshot').selectOption('required'); + await page.locator('#label').fill('Send Feedback'); + await page.locator('#icon').fill('none'); + await page.locator('#screenshotScale').fill('3'); + await page.locator('#radius').fill('10px'); + await page.locator('#categoryLabels').fill('{"bug":["defect"],"feature":"idea"}'); + + const scriptCode = page.locator('#script-code'); + await expect(scriptCode).toContainText('data-repo="acme/app"'); + await expect(scriptCode).toContainText('data-theme="dark"'); + await expect(scriptCode).toContainText('data-position="bottom-left"'); + await expect(scriptCode).toContainText('data-screenshot="required"'); + await expect(scriptCode).toContainText('data-label="Send Feedback"'); + await expect(scriptCode).toContainText('data-icon="none"'); + await expect(scriptCode).toContainText('data-screenshot-scale="3"'); + await expect(scriptCode).toContainText('data-radius="10px"'); + await expect(scriptCode).toContainText( + 'data-category-labels="{"bug":["defect"],"feature":"idea"}"' + ); + await expect(scriptCode).not.toContainText('async'); + await expect(scriptCode).not.toContainText('defer'); + + const frame = page.frameLocator('#sandbox-preview'); + await expect(frame.locator('#bugdrop-host').locator('css=.bd-trigger')).toBeVisible({ + timeout: 10_000, + }); + await expect(frame.locator('[data-bugdrop-mask]')).toHaveCount(3); + expect(widgetRequests.some(url => url === `${new URL(page.url()).origin}/widget.js`)).toBe( + true + ); + }); + + test('validates and encodes repo paths before checking installation', async ({ page }) => { + const checkRequests: string[] = []; + await page.route('**/api/check/**', async route => { + checkRequests.push(route.request().url()); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: false, repo: 'acme/app' }), + }); + }); + + await page.goto('/sandbox/'); + + await page.locator('#repo').fill('not-a-repo'); + await expect(page.locator('#repo-feedback')).toHaveText( + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.' + ); + + await page.locator('#repo').fill('acme/app?x=1'); + await expect(page.locator('#repo-feedback')).toHaveText( + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.' + ); + + await page.locator('#repo').fill('acme/app'); + await expect(page.locator('#repo-feedback')).toHaveText('Ready to check installation.'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText( + 'BugDrop is not installed on acme/app.' + ); + expect(checkRequests).toContain(`${new URL(page.url()).origin}/api/check/acme/app`); + }); + + test('ignores stale installation checks when repo changes', async ({ page }) => { + let releaseSlow: (() => void) | undefined; + const slowReleased = new Promise(resolve => { + releaseSlow = resolve; + }); + await page.route('**/api/check/slow/repo', async route => { + await slowReleased; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'slow/repo' }), + }); + }); + await page.route('**/api/check/fast/repo', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: false, repo: 'fast/repo' }), + }); + }); + + await page.goto('/sandbox/'); + await page.locator('#repo').fill('slow/repo'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText('Checking GitHub App installation...'); + await page.locator('#repo').fill('fast/repo'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText( + 'BugDrop is not installed on fast/repo.' + ); + + // Now release the slow request; its callback must not clobber the fast result. + releaseSlow?.(); + // Drive the deterministic guarantee: poll for stability rather than wall-clock wait. + await expect + .poll(() => page.locator('#repo-feedback').textContent(), { timeout: 2000 }) + .toBe('BugDrop is not installed on fast/repo.'); + }); + + test('discards stale check when repo input changes mid-flight (no second click)', async ({ + page, + }) => { + let releaseSlow: (() => void) | undefined; + const slowReleased = new Promise(resolve => { + releaseSlow = resolve; + }); + await page.route('**/api/check/slow/repo', async route => { + await slowReleased; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ installed: true, repo: 'slow/repo' }), + }); + }); + + await page.goto('/sandbox/'); + await page.locator('#repo').fill('slow/repo'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toHaveText('Checking GitHub App installation...'); + + // User edits the repo without clicking check again, then the slow response returns. + await page.locator('#repo').fill('other/repo'); + const slowResponse = page.waitForResponse(r => r.url().includes('/api/check/slow/repo')); + releaseSlow?.(); + // Wait for the slow handler to actually fulfill so the stale-discard guard has + // run; without this the negative-match could pass trivially before the handler + // had any chance to write to repo-feedback. + await slowResponse; + + // Validation feedback (from input handler) is fine; what must NOT happen is the + // stale "installed on slow/repo" text overwriting it. + await expect(page.locator('#repo-feedback')).not.toHaveText(/installed on slow\/repo/); + }); + + test('surfaces HTTP detail when installation check returns 500', async ({ page }) => { + await page.route('**/api/check/**', async route => { + await route.fulfill({ status: 500, contentType: 'text/plain', body: 'boom' }); + }); + await page.goto('/sandbox/'); + await page.locator('#repo').fill('acme/app'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toContainText( + /Installation check failed \(HTTP 500/ + ); + await expect(page.locator('#repo-feedback')).toHaveClass(/error/); + }); + + test('surfaces HTTP detail when API returns malformed JSON', async ({ page }) => { + await page.route('**/api/check/**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: 'not json', + }); + }); + await page.goto('/sandbox/'); + await page.locator('#repo').fill('acme/app'); + await page.locator('#check-installation').click(); + await expect(page.locator('#repo-feedback')).toContainText( + /Installation check failed \(HTTP 200: invalid JSON response/ + ); + }); + + test('preview shows error banner when widget.js fails to load', async ({ page }) => { + await page.route('**/widget.js', route => route.fulfill({ status: 404, body: '' })); + await page.goto('/sandbox/'); + const frame = page.frameLocator('#sandbox-preview'); + await expect(frame.locator('#bd-preview-error')).toBeVisible({ timeout: 10_000 }); + await expect(frame.locator('#bd-preview-error')).toContainText( + 'failed to load from /widget.js' + ); + }); + + test('required contact fields imply visible contact fields in generated script', async ({ + page, + }) => { + await page.goto('/sandbox/'); + + await page.locator('#requireEmail').check(); + await page.locator('#requireName').check(); + + const scriptCode = page.locator('#script-code'); + await expect(scriptCode).toContainText('data-show-email="true"'); + await expect(scriptCode).toContainText('data-require-email="true"'); + await expect(scriptCode).toContainText('data-show-name="true"'); + await expect(scriptCode).toContainText('data-require-name="true"'); + }); + + test('surfaces an "ignored invalid values" notice when sanitizers reject input', async ({ + page, + }) => { + await page.goto('/sandbox/'); + const notice = page.locator('#sanitize-feedback'); + await expect(notice).toBeHidden(); + + // Inject a value the CSS-token sanitizer rejects. + await page.locator('#color').fill('red"; onerror="alert(1)'); + + await expect(notice).toBeVisible(); + await expect(notice).toContainText(/Ignored invalid values for: .*Accent color/); + await expect(page.locator('#script-code')).not.toContainText('data-color'); + + // Restoring a valid value clears the notice. + await page.locator('#color').fill('#7c3aed'); + await expect(notice).toBeHidden(); + }); + + test('reports clipboard failures without throwing', async ({ page }) => { + await page.goto('/sandbox/'); + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + }); + + await page.locator('#copy-script').click(); + await expect(page.locator('#copy-script')).toHaveText('Copy failed'); + }); +}); diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts index 6a61b1d..439f681 100644 --- a/e2e/widget.spec.ts +++ b/e2e/widget.spec.ts @@ -233,6 +233,9 @@ test.describe('Widget Interaction', () => { await button.click(); const getStartedBtn = page.locator('#bugdrop-host').locator('css=[data-action="continue"]'); await expect(getStartedBtn).toBeVisible({ timeout: 5000 }); + // The welcome modal carries the `bd-welcome` class — the docs example test in + // docs/website/ci-testing.mdx targets this selector, so lock it in here. + await expect(page.locator('#bugdrop-host').locator('css=.bd-welcome')).toBeVisible(); await getStartedBtn.click(); // Form should appear diff --git a/public/sandbox/attribute-map.js b/public/sandbox/attribute-map.js new file mode 100644 index 0000000..ca4b6b7 --- /dev/null +++ b/public/sandbox/attribute-map.js @@ -0,0 +1,30 @@ +// Single source of truth for the widget's camelCase → data-* attribute mapping. +// Imported by sandbox.js (config UI) and preview.html (iframe). Keep in sync +// with the consumers in src/widget/index.ts (script.dataset.* reads). +export const ATTRIBUTE_MAP = Object.freeze({ + repo: 'data-repo', + theme: 'data-theme', + position: 'data-position', + color: 'data-color', + label: 'data-label', + icon: 'data-icon', + screenshot: 'data-screenshot', + welcome: 'data-welcome', + showName: 'data-show-name', + requireName: 'data-require-name', + showEmail: 'data-show-email', + requireEmail: 'data-require-email', + buttonDismissible: 'data-button-dismissible', + dismissDuration: 'data-dismiss-duration', + showRestore: 'data-show-restore', + showButton: 'data-button', + screenshotScale: 'data-screenshot-scale', + font: 'data-font', + radius: 'data-radius', + bg: 'data-bg', + text: 'data-text', + borderWidth: 'data-border-width', + borderColor: 'data-border-color', + shadow: 'data-shadow', + categoryLabels: 'data-category-labels', +}); diff --git a/public/sandbox/index.html b/public/sandbox/index.html new file mode 100644 index 0000000..0f7f6bb --- /dev/null +++ b/public/sandbox/index.html @@ -0,0 +1,178 @@ + + + + + + BugDrop Sandbox + + + +
+
+
+
+

BugDrop Sandbox

+

Configure the widget, preview it on a realistic page, and generate the script tag.

+
+ Installation docs +
+ +
+ + +
+
+
+

Preview

+

Reloads with the selected script attributes.

+
+ +
+ + + + +
+
+
+

Script Tag

+

Paste this before the closing body tag in your app.

+
+ +
+
+
+ +
+

Screenshot Masking

+

The preview includes fields marked with data-bugdrop-mask. Masking also covers a small set of built-in defaults (input[type="password"] and credit-card autocomplete fields). It is best-effort visual masking, not automatic secret detection.

+
+
+
+
+
+ + + diff --git a/public/sandbox/preview.html b/public/sandbox/preview.html new file mode 100644 index 0000000..398c8b1 --- /dev/null +++ b/public/sandbox/preview.html @@ -0,0 +1,515 @@ + + + + + + BugDrop Sandbox Preview + + + +
+
+
Acme Console
+ +
+ +
+
+

Ship feedback from the page where users notice it.

+

This preview page gives the widget real layout, forms, private fields, scroll depth, and UI density for setup testing.

+
+ + +
+
+ +
+ +
+
+
+

Recent Feedback

+ Live queue +
+
+
+
+
Checkout total changes after coupon edit
+
Reported from /billing/checkout
+
+ Bug + High +
+
+
+
Add export option for filtered events
+
Reported from /events
+
+ Feature + Medium +
+
+
+
Clarify which workspace owns audit logs
+
Reported from /settings/security
+
+ Question + Low +
+
+
+
Keyboard focus skips the invite role selector
+
Reported from /team/invites
+
+ Bug + High +
+
+
+ + +
+
+ + + + diff --git a/public/sandbox/sandbox.css b/public/sandbox/sandbox.css new file mode 100644 index 0000000..a25a366 --- /dev/null +++ b/public/sandbox/sandbox.css @@ -0,0 +1,322 @@ +:root { + color-scheme: light dark; + --bg: #f7f8fb; + --panel: #ffffff; + --panel-subtle: #f1f4f8; + --text: #172033; + --muted: #617089; + --border: #d9e0ea; + --strong-border: #b7c2d1; + --accent: #7c3aed; + --accent-strong: #5b21b6; + --success: #0f766e; + --warning: #9a3412; + --danger: #b91c1c; + --shadow: 0 18px 44px rgba(23, 32, 51, 0.11); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--text); + background: + linear-gradient(180deg, rgba(124, 58, 237, 0.08), transparent 360px), + var(--bg); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +.docs-link { + min-height: 40px; + border: 1px solid transparent; + border-radius: 8px; + padding: 0 14px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 650; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +button { + color: #fff; + background: var(--accent); +} + +button:hover { + background: var(--accent-strong); +} + +.secondary-button, +.docs-link { + color: var(--text); + background: var(--panel); + border-color: var(--border); +} + +.secondary-button:hover, +.docs-link:hover { + background: var(--panel-subtle); +} + +.sandbox-shell { + min-height: 100vh; + padding: 28px; +} + +.workspace { + width: min(1480px, 100%); + margin: 0 auto; +} + +.workspace-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(2rem, 4vw, 3.75rem); + line-height: 1; + font-weight: 760; +} + +.workspace-header p, +.preview-toolbar p, +.script-header p, +.safety-note p { + color: var(--muted); + margin-top: 8px; + line-height: 1.5; +} + +.layout-grid { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.controls-panel, +.script-output, +.safety-note { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.controls-panel { + position: sticky; + top: 20px; + max-height: calc(100vh - 40px); + overflow: auto; +} + +.control-section { + padding: 18px; + border-bottom: 1px solid var(--border); +} + +.control-section:last-child { + border-bottom: 0; +} + +h2 { + color: var(--text); + font-size: 0.78rem; + font-weight: 780; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.field { + display: grid; + gap: 7px; + margin-top: 14px; +} + +.field span { + color: var(--muted); + font-size: 0.84rem; + font-weight: 650; +} + +input, +textarea, +select { + width: 100%; + color: var(--text); + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0 11px; + outline: none; +} + +input, +select { + min-height: 40px; +} + +textarea { + min-height: 88px; + padding-top: 10px; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.14); +} + +.repo-feedback { + margin: 10px 0 12px; + min-height: 20px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.4; +} + +.repo-feedback.ok { + color: var(--success); +} + +.repo-feedback.warn { + color: var(--warning); +} + +.repo-feedback.error { + color: var(--danger); +} + +.sanitize-feedback { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid rgba(180, 83, 9, 0.32); + border-radius: 8px; + background: rgba(180, 83, 9, 0.08); + color: var(--warning); + font-size: 0.84rem; + line-height: 1.4; +} + +.checkbox-grid { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.checkbox-grid label { + display: flex; + align-items: center; + gap: 9px; + color: var(--text); + font-size: 0.9rem; + font-weight: 560; +} + +.checkbox-grid input { + width: 16px; + min-height: 16px; + accent-color: var(--accent); +} + +.preview-column { + display: grid; + gap: 18px; +} + +.preview-toolbar, +.script-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +#sandbox-preview { + width: 100%; + min-height: 680px; + border: 1px solid var(--strong-border); + border-radius: 8px; + background: #fff; + box-shadow: var(--shadow); +} + +.script-output, +.safety-note { + padding: 18px; +} + +pre { + margin: 16px 0 0; + padding: 16px; + overflow: auto; + color: #e5e7eb; + background: #111827; + border-radius: 8px; + font-size: 0.84rem; + line-height: 1.55; +} + +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.safety-note code { + color: var(--accent-strong); + background: rgba(124, 58, 237, 0.08); + border-radius: 4px; + padding: 2px 4px; +} + +@media (max-width: 980px) { + .sandbox-shell { + padding: 18px; + } + + .workspace-header, + .preview-toolbar, + .script-header { + align-items: flex-start; + flex-direction: column; + } + + .layout-grid { + grid-template-columns: 1fr; + } + + .controls-panel { + position: static; + max-height: none; + } + + #sandbox-preview { + min-height: 620px; + } +} diff --git a/public/sandbox/sandbox.js b/public/sandbox/sandbox.js new file mode 100644 index 0000000..3b07de1 --- /dev/null +++ b/public/sandbox/sandbox.js @@ -0,0 +1,247 @@ +import { ATTRIBUTE_MAP } from './attribute-map.js'; +import { + escapeAttribute, + isValidRepo, + getRepoPath, + normalizeConfig, + sanitizeConfig, +} from './sanitizers.js'; + +const form = document.querySelector('#sandbox-form'); +const preview = document.querySelector('#sandbox-preview'); +const scriptCode = document.querySelector('#script-code'); +const repoFeedback = document.querySelector('#repo-feedback'); +const sanitizeFeedback = document.querySelector('#sanitize-feedback'); +const copyButton = document.querySelector('#copy-script'); +const checkButton = document.querySelector('#check-installation'); +const refreshButton = document.querySelector('#refresh-preview'); + +// Monotonic id; in-flight installation checks are discarded when this advances +// or when the repo input changes mid-flight. +let installationCheckId = 0; + +const BOOLEAN_FIELDS = new Set([ + 'showName', + 'requireName', + 'showEmail', + 'requireEmail', + 'buttonDismissible', + 'showRestore', + 'showButton', +]); + +// User-facing labels for fields whose sanitizer can reject input (coerce non-empty +// input to ''). Used by describeRejectedFields to build the sanitize-feedback notice. +// Enum-fallback fields (theme/position/screenshot/welcome) are intentionally absent +// since they always return a non-empty default. Boolean fields are also absent. +// IMPORTANT: when adding a new sanitizer in sanitizers.js whose output can be '', +// add the matching key here so its rejection is surfaced to the user. +const FIELD_LABELS = { + repo: 'GitHub repository', + color: 'Accent color', + label: 'Button label', + icon: 'Icon', + dismissDuration: 'Dismiss duration', + screenshotScale: 'Screenshot scale', + font: 'Font', + radius: 'Radius', + bg: 'Background color', + text: 'Text color', + borderWidth: 'Border width', + borderColor: 'Border color', + shadow: 'Shadow', + categoryLabels: 'Category labels', +}; + +function readConfig() { + const config = {}; + for (const key of Object.keys(ATTRIBUTE_MAP)) { + const field = form[key]; + if (!field) { + // ATTRIBUTE_MAP drift: a config key has no matching form input. Surface + // this loudly so a regression is visible rather than silently sending '' . + console.warn(`[BugDrop sandbox] no form field for config key "${key}"`); + config[key] = ''; + continue; + } + if (BOOLEAN_FIELDS.has(key)) { + config[key] = field.checked; + } else { + const value = field.value; + config[key] = typeof value === 'string' ? value.trim() : value; + } + } + return config; +} + +function getWidgetSrc() { + return `${window.location.origin}/widget.js`; +} + +function getScriptAttributes(config) { + const attrs = { + repo: config.repo, + theme: config.theme, + position: config.position, + color: config.color, + label: config.label, + icon: config.icon, + screenshot: config.screenshot, + welcome: config.welcome === 'once' ? '' : config.welcome, + showName: config.showName ? 'true' : '', + requireName: config.requireName ? 'true' : '', + showEmail: config.showEmail ? 'true' : '', + requireEmail: config.requireEmail ? 'true' : '', + buttonDismissible: config.buttonDismissible ? 'true' : '', + dismissDuration: config.dismissDuration, + showRestore: config.showRestore ? '' : 'false', + showButton: config.showButton ? '' : 'false', + screenshotScale: config.screenshotScale === '2' ? '' : config.screenshotScale, + font: config.font, + radius: config.radius, + bg: config.bg, + text: config.text, + borderWidth: config.borderWidth, + borderColor: config.borderColor, + shadow: config.shadow, + categoryLabels: config.categoryLabels, + }; + + return Object.entries(attrs).filter(([, value]) => value !== ''); +} + +function generateScriptTag(config) { + const lines = [``; + return lines.join('\n'); +} + +function updateRequiredImplications() { + if (form.requireName.checked) form.showName.checked = true; + if (form.requireEmail.checked) form.showEmail.checked = true; +} + +function describeRejectedFields(raw, sanitized) { + const rejected = []; + for (const [key, label] of Object.entries(FIELD_LABELS)) { + const before = typeof raw[key] === 'string' ? raw[key] : ''; + if (before && !sanitized[key]) rejected.push(label); + } + return rejected; +} + +function renderSanitizeFeedback(rejected) { + if (!sanitizeFeedback) return; + if (rejected.length === 0) { + sanitizeFeedback.textContent = ''; + sanitizeFeedback.hidden = true; + return; + } + sanitizeFeedback.textContent = `Ignored invalid values for: ${rejected.join(', ')}.`; + sanitizeFeedback.hidden = false; +} + +function updatePreview() { + updateRequiredImplications(); + const rawConfig = normalizeConfig(readConfig()); + const config = sanitizeConfig(rawConfig); + const params = new URLSearchParams(); + + for (const [key, value] of getScriptAttributes(config)) { + params.set(key, value); + } + + scriptCode.textContent = generateScriptTag(config); + preview.src = `./preview?${params.toString()}&v=${Date.now()}`; + renderSanitizeFeedback(describeRejectedFields(rawConfig, config)); + validateRepo(rawConfig.repo, false); +} + +function validateRepo(repo, announceSuccess) { + repoFeedback.className = 'repo-feedback'; + + if (!repo) { + repoFeedback.classList.add('error'); + repoFeedback.textContent = 'Enter a repository in owner/repo format.'; + return false; + } + + if (!isValidRepo(repo)) { + repoFeedback.classList.add('error'); + repoFeedback.textContent = + 'Repository must use GitHub owner/repo format with letters, numbers, dots, underscores, or hyphens.'; + return false; + } + + repoFeedback.classList.add('ok'); + repoFeedback.textContent = announceSuccess + ? 'Repository format looks valid.' + : 'Ready to check installation.'; + + return true; +} + +async function checkInstallation() { + const { repo } = readConfig(); + if (!validateRepo(repo, false)) return; + + const requestId = ++installationCheckId; + repoFeedback.className = 'repo-feedback'; + repoFeedback.textContent = 'Checking GitHub App installation...'; + + try { + const response = await fetch(`/api/check/${getRepoPath(repo)}`); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 80)}` : ''}`); + } + let result; + try { + result = await response.json(); + } catch (parseErr) { + throw new Error(`HTTP ${response.status}: invalid JSON response`, { cause: parseErr }); + } + if (requestId !== installationCheckId || readConfig().repo !== repo) return; + + repoFeedback.className = `repo-feedback ${result.installed ? 'ok' : 'warn'}`; + repoFeedback.textContent = result.installed + ? `BugDrop is installed on ${result.repo}.` + : `BugDrop is not installed on ${result.repo}.`; + } catch (err) { + if (requestId !== installationCheckId || readConfig().repo !== repo) return; + console.warn('[BugDrop sandbox] installation check failed:', err); + const message = err instanceof Error ? err.message : ''; + repoFeedback.className = 'repo-feedback error'; + repoFeedback.textContent = message.startsWith('HTTP') + ? `Installation check failed (${message}). Try again or open an issue.` + : 'Unable to reach the BugDrop API from this page.'; + } +} + +async function copyScript() { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(scriptCode.textContent); + copyButton.textContent = 'Copied'; + } catch (err) { + console.warn('[BugDrop sandbox] clipboard write failed:', err); + copyButton.textContent = 'Copy failed'; + } + + window.setTimeout(() => { + copyButton.textContent = 'Copy'; + }, 1400); +} + +form.addEventListener('input', updatePreview); +form.addEventListener('change', updatePreview); +checkButton.addEventListener('click', checkInstallation); +refreshButton.addEventListener('click', updatePreview); +copyButton.addEventListener('click', copyScript); + +updatePreview(); diff --git a/public/sandbox/sanitizers.js b/public/sandbox/sanitizers.js new file mode 100644 index 0000000..71b8fe9 --- /dev/null +++ b/public/sandbox/sanitizers.js @@ -0,0 +1,116 @@ +// Pure sanitization/validation helpers for the sandbox config UI. +// All inputs are user-typed; outputs are safe to embed in script tag attributes +// and URL parameters. Invalid inputs coerce to '' (the generator omits them). + +const GITHUB_REPO_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/; +const SAFE_TEXT_PATTERN = /^[\w .,:;!?@#%&()[\]/'"_+=-]{0,120}$/; +const CSS_TOKEN_REJECT = /[<>"`{}\r\n\t]/; + +export function escapeAttribute(value) { + return String(value).replaceAll('&', '&').replaceAll('"', '"'); +} + +export function isValidRepo(repo) { + return GITHUB_REPO_PATTERN.test(repo) && !repo.endsWith('.git'); +} + +export function getRepoPath(repo) { + const [owner, name] = repo.split('/'); + return `${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; +} + +export function sanitizePlainText(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return SAFE_TEXT_PATTERN.test(trimmed) ? trimmed : ''; +} + +export function sanitizeCssToken(value, maxLength) { + const trimmed = value.trim().slice(0, maxLength); + return !CSS_TOKEN_REJECT.test(trimmed) ? trimmed : ''; +} + +export function sanitizeIcon(value) { + const trimmed = value.trim(); + if (!trimmed || trimmed === 'none') return trimmed; + try { + const url = new URL(trimmed); + if (!['https:', 'http:'].includes(url.protocol)) return ''; + // Reject embedded credentials (e.g. https://user:pass@host) — they would + // leak into the generated script's data-icon attribute. + if (url.username || url.password) return ''; + return url.toString(); + } catch { + return ''; + } +} + +export function sanitizeDismissDuration(value) { + if (!value || value === 'session') return value; + return /^\d{1,5}$/.test(value) ? value : ''; +} + +export function sanitizeScreenshotScale(value) { + if (!value) return ''; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 4 ? String(parsed) : ''; +} + +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +export function sanitizeCategoryLabels(value) { + if (!value) return ''; + if (value.length > 1000) return ''; + let parsed; + try { + parsed = JSON.parse(value); + } catch { + return ''; + } + // Widget expects a non-null, non-array object. Other JSON shapes are rejected. + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return ''; + } + // Reject prototype-pollution-style keys; downstream consumers may still + // process this JSON, so don't let those keys survive validation. + if (Object.keys(parsed).some(key => FORBIDDEN_KEYS.has(key))) return ''; + return value; +} + +export function normalizeConfig(config) { + return { + ...config, + showName: config.requireName || config.showName, + showEmail: config.requireEmail || config.showEmail, + }; +} + +const THEMES = ['auto', 'light', 'dark']; +const POSITIONS = ['bottom-right', 'bottom-left']; +const SCREENSHOTS = ['optional', 'required', 'auto']; +const WELCOMES = ['once', 'always', 'never']; + +export function sanitizeConfig(config) { + const normalized = normalizeConfig(config); + return { + ...normalized, + repo: isValidRepo(normalized.repo) ? normalized.repo : '', + theme: THEMES.includes(normalized.theme) ? normalized.theme : 'auto', + position: POSITIONS.includes(normalized.position) ? normalized.position : 'bottom-right', + screenshot: SCREENSHOTS.includes(normalized.screenshot) ? normalized.screenshot : 'optional', + welcome: WELCOMES.includes(normalized.welcome) ? normalized.welcome : 'once', + color: sanitizeCssToken(normalized.color, 80), + label: sanitizePlainText(normalized.label, 80), + icon: sanitizeIcon(normalized.icon), + dismissDuration: sanitizeDismissDuration(normalized.dismissDuration), + screenshotScale: sanitizeScreenshotScale(normalized.screenshotScale), + font: sanitizeCssToken(normalized.font, 120), + radius: sanitizeCssToken(normalized.radius, 40), + bg: sanitizeCssToken(normalized.bg, 80), + text: sanitizeCssToken(normalized.text, 80), + borderWidth: sanitizeCssToken(normalized.borderWidth, 40), + borderColor: sanitizeCssToken(normalized.borderColor, 80), + shadow: sanitizeCssToken(normalized.shadow, 120), + categoryLabels: sanitizeCategoryLabels(normalized.categoryLabels), + }; +} diff --git a/src/index.ts b/src/index.ts index d229ec4..f8fc5aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,10 @@ app.use('*', logger()); // Mount API routes app.route('/api', api); +// /sandbox needs a trailing-slash redirect; everything under /sandbox/* is served +// directly by Wrangler Assets (see [assets] in wrangler.toml) without invoking the Worker. +app.get('/sandbox', c => c.redirect('/sandbox/', 301)); + // Redirect to landing page on Vercel app.get('/', c => { return c.redirect('https://bugdrop.dev', 301); diff --git a/src/widget/index.ts b/src/widget/index.ts index 9fb4f64..7242360 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -1057,7 +1057,8 @@ function showWelcomeScreen(root: HTMLElement): Promise { `, - true + true, + 'bd-welcome' ); const closeBtn = modal.querySelector('.bd-close') as HTMLElement; diff --git a/test/sandboxSanitizers.test.ts b/test/sandboxSanitizers.test.ts new file mode 100644 index 0000000..7051999 --- /dev/null +++ b/test/sandboxSanitizers.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect } from 'vitest'; +import { + escapeAttribute, + isValidRepo, + getRepoPath, + sanitizePlainText, + sanitizeCssToken, + sanitizeIcon, + sanitizeDismissDuration, + sanitizeScreenshotScale, + sanitizeCategoryLabels, + normalizeConfig, + sanitizeConfig, + // @ts-expect-error — plain JS module, no type declarations +} from '../public/sandbox/sanitizers.js'; + +describe('escapeAttribute', () => { + it('escapes & before " so order is correct', () => { + expect(escapeAttribute('&"')).toBe('&"'); + }); + + it('replaces all occurrences, not just the first', () => { + expect(escapeAttribute('a&b&c"d"e')).toBe('a&b&c"d"e'); + }); + + it('coerces non-strings via String()', () => { + expect(escapeAttribute(42)).toBe('42'); + expect(escapeAttribute(true)).toBe('true'); + }); +}); + +describe('isValidRepo', () => { + it('accepts valid owner/repo names', () => { + expect(isValidRepo('mean-weasel/bugdrop')).toBe(true); + expect(isValidRepo('acme/app.io')).toBe(true); + expect(isValidRepo('Acme/my_repo')).toBe(true); + expect(isValidRepo('a/b')).toBe(true); + }); + + it('rejects names ending with .git', () => { + expect(isValidRepo('acme/app.git')).toBe(false); + }); + + it('rejects formats without exactly one slash', () => { + expect(isValidRepo('justone')).toBe(false); + expect(isValidRepo('one/two/three')).toBe(false); + expect(isValidRepo('')).toBe(false); + }); + + it('rejects unsafe characters', () => { + expect(isValidRepo('acme/app?x=1')).toBe(false); + expect(isValidRepo('acme/app#frag')).toBe(false); + expect(isValidRepo('acme/