From 5c1d8393e846aec2459b425f338b6e900bb73e4d Mon Sep 17 00:00:00 2001 From: Abhinav Gorrepati Date: Sun, 12 Jul 2026 12:50:23 -0700 Subject: [PATCH] feat(feedback): add screenshot upload fallback Co-Authored-By: OpenAI Codex --- .../feedback/screenshotUploadFallback/init.js | 9 ++ .../screenshotUploadFallback/template.html | 7 ++ .../feedback/screenshotUploadFallback/test.ts | 56 +++++++++ .../components/ScreenshotEditor.tsx | 30 ++++- .../components/ScreenshotFallback.tsx | 28 +++++ .../components/ScreenshotInput.css.ts | 11 ++ .../feedback/src/screenshot/integration.ts | 46 ++++++++ .../test/screenshot/integration.test.ts | 107 ++++++++++++++++++ 8 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/init.js create mode 100644 dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/template.html create mode 100644 dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/test.ts create mode 100644 packages/feedback/src/screenshot/components/ScreenshotFallback.tsx create mode 100644 packages/feedback/test/screenshot/integration.test.ts diff --git a/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/init.js b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/init.js new file mode 100644 index 000000000000..b98f0b854c6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; +import { feedbackIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [feedbackIntegration()], +}); diff --git a/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/template.html b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/template.html new file mode 100644 index 000000000000..7c26f485c41b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/test.ts b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/test.ts new file mode 100644 index 000000000000..47e4ef15adac --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/feedback/screenshotUploadFallback/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import { parseEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipFeedbackTest } from '../../../utils/helpers'; + +sentryTest('uploads an image after display capture is rejected', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeedbackTest()) { + sentryTest.skip(); + } + + await page.addInitScript(() => { + Object.defineProperty(globalThis, 'isSecureContext', { + configurable: true, + value: true, + }); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getDisplayMedia: () => Promise.reject(new DOMException('Blocked', 'NotAllowedError')), + }, + }); + }); + + const feedbackRequestPromise = page.waitForResponse(response => { + return response.url().includes('/envelope/'); + }); + const url = await getLocalTestUrl({ testDir: __dirname, handleLazyLoadedFeedback: true }); + + await page.goto(url); + await page.getByText('Report a Bug').click(); + await page.getByRole('button', { name: 'Add a screenshot' }).click(); + + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeVisible(); + await fileInput.setInputFiles({ + name: 'bug.png', + mimeType: 'image/png', + buffer: Buffer.from('manual screenshot'), + }); + await page.locator('[name="name"]').fill('Jane Doe'); + await page.locator('[name="email"]').fill('janedoe@example.org'); + await page.locator('[name="message"]').fill('Screenshot capture is blocked'); + await page.locator('[data-sentry-feedback] .btn--primary').click(); + + const request = (await feedbackRequestPromise).request(); + const items = parseEnvelope(request.postDataBuffer()!)[1]; + const attachment = items.find(([header]) => header.type === 'attachment'); + + expect(attachment?.[0]).toEqual({ + type: 'attachment', + length: 17, + filename: 'bug.png', + content_type: 'image/png', + }); + expect(new TextDecoder().decode(attachment?.[1] as Uint8Array)).toBe('manual screenshot'); +}); diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 8995566fa8c2..e70c7640cf21 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -1,3 +1,4 @@ +// oxlint-disable max-lines import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/core'; import type { ComponentType, h as hType, VNode } from 'preact'; import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars @@ -5,6 +6,7 @@ import type * as Hooks from 'preact/hooks'; import { DOCUMENT, WINDOW } from '../../constants'; import IconCloseFactory from './IconClose'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; +import { ScreenshotFallback } from './ScreenshotFallback'; import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; @@ -29,6 +31,11 @@ interface FactoryParams { * Needed to set nonce and id values for editor specific styles */ options: FeedbackInternalOptions; + + onScreenshotStart: () => void; + onScreenshotCaptured: () => void; + onFileSelected: (file: File) => void; + onReset: () => void; } interface Props { @@ -117,6 +124,10 @@ export function ScreenshotEditorFactory({ outputBuffer, dialog, options, + onScreenshotStart, + onScreenshotCaptured, + onFileSelected, + onReset, }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); @@ -328,11 +339,19 @@ export function ScreenshotEditorFactory({ ); }; - return function Wrapper({ onError }: Props): VNode { + return function Wrapper(_props: Props): VNode { const [screenshot, setScreenshot] = hooks.useState(); + const [captureFailed, setCaptureFailed] = hooks.useState(false); + + hooks.useEffect(() => { + onReset(); + return onReset; + }, []); useTakeScreenshot({ onBeforeScreenshot: hooks.useCallback(() => { + onScreenshotStart(); + setCaptureFailed(false); dialogStyle.display = 'none'; }, []), onScreenshot: hooks.useCallback((screenshotVideo: HTMLVideoElement, dpi: number) => { @@ -344,6 +363,7 @@ export function ScreenshotEditorFactory({ ctx.drawImage(screenshotVideo, 0, 0, canvas.width, canvas.height); setScreenshot({ canvas, dpi }); + onScreenshotCaptured(); }); // The output buffer, we only need to set the width/height on this once, it stays the same forever @@ -353,9 +373,9 @@ export function ScreenshotEditorFactory({ onAfterScreenshot: hooks.useCallback(() => { dialogStyle.display = 'block'; }, []), - onError: hooks.useCallback(error => { + onError: hooks.useCallback(_error => { dialogStyle.display = 'block'; - onError(error); + setCaptureFailed(true); }, []), }); @@ -363,6 +383,10 @@ export function ScreenshotEditorFactory({ return ; } + if (captureFailed) { + return ; + } + return
; }; } diff --git a/packages/feedback/src/screenshot/components/ScreenshotFallback.tsx b/packages/feedback/src/screenshot/components/ScreenshotFallback.tsx new file mode 100644 index 000000000000..b4894d558e5d --- /dev/null +++ b/packages/feedback/src/screenshot/components/ScreenshotFallback.tsx @@ -0,0 +1,28 @@ +import type { FeedbackInternalOptions } from '@sentry/core'; +import type { VNode } from 'preact'; +import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars + +interface Props { + options: FeedbackInternalOptions; + onFileSelected: (file: File) => void; +} + +export function ScreenshotFallback({ options, onFileSelected }: Props): VNode { + return ( + + ); +} diff --git a/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts b/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts index cdfda0ddc3f9..a80b13a0fc40 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts +++ b/packages/feedback/src/screenshot/components/ScreenshotInput.css.ts @@ -101,6 +101,17 @@ export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleEleme .editor__rect:hover button { opacity: 1; } + +.screenshot-fallback { + display: flex; + flex-direction: column; + gap: 8px; + padding: 15px; +} + +.screenshot-fallback__input { + color: var(--foreground); +} `; if (styleNonce) { diff --git a/packages/feedback/src/screenshot/integration.ts b/packages/feedback/src/screenshot/integration.ts index 38c6349cd1a6..5857a1844a53 100644 --- a/packages/feedback/src/screenshot/integration.ts +++ b/packages/feedback/src/screenshot/integration.ts @@ -4,12 +4,36 @@ import type * as Hooks from 'preact/hooks'; import { DOCUMENT } from '../constants'; import { ScreenshotEditorFactory } from './components/ScreenshotEditor'; +function readFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + resolve(reader.result); + } else { + reject(new Error('Unable to read screenshot file')); + } + }; + reader.onerror = () => reject(reader.error ?? new Error('Unable to read screenshot file')); + reader.readAsArrayBuffer(file); + }); +} + export const feedbackScreenshotIntegration = ((): FeedbackScreenshotIntegration => { return { name: 'FeedbackScreenshot' as const, setupOnce() {}, createInput: ({ h, hooks, dialog, options }) => { const outputBuffer = DOCUMENT.createElement('canvas'); + let selectedFile: File | undefined; + let hasCapturedScreenshot = false; + + const reset = (): void => { + selectedFile = undefined; + hasCapturedScreenshot = false; + outputBuffer.width = 0; + outputBuffer.height = 0; + }; return { input: ScreenshotEditorFactory({ @@ -18,9 +42,31 @@ export const feedbackScreenshotIntegration = ((): FeedbackScreenshotIntegration outputBuffer, dialog, options, + onScreenshotStart: reset, + onScreenshotCaptured: () => { + selectedFile = undefined; + hasCapturedScreenshot = true; + }, + onFileSelected: file => { + selectedFile = file; + hasCapturedScreenshot = false; + }, + onReset: reset, }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any value: async () => { + if (selectedFile) { + return { + data: new Uint8Array(await readFile(selectedFile)), + filename: selectedFile.name, + contentType: selectedFile.type || 'application/octet-stream', + }; + } + + if (!hasCapturedScreenshot) { + return undefined; + } + const blob = await new Promise[0]>(resolve => { outputBuffer.toBlob(resolve, 'image/png'); }); diff --git a/packages/feedback/test/screenshot/integration.test.ts b/packages/feedback/test/screenshot/integration.test.ts new file mode 100644 index 000000000000..a0ec5bdc7098 --- /dev/null +++ b/packages/feedback/test/screenshot/integration.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment jsdom + */ +import type { FeedbackInternalOptions, FeedbackScreenshotIntegration } from '@sentry/core'; +import { h, render } from 'preact'; +import * as hooks from 'preact/hooks'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { feedbackScreenshotIntegration } from '../../src/screenshot/integration'; + +const options = { + addScreenshotButtonLabel: 'Add a screenshot', + id: 'sentry-feedback', + removeHighlightText: 'Remove', + styleNonce: undefined, +} as FeedbackInternalOptions; + +function createInput(): ReturnType { + return feedbackScreenshotIntegration().createInput({ + h, + hooks, + dialog: { el: document.createElement('div') } as unknown as Parameters< + FeedbackScreenshotIntegration['createInput'] + >[0]['dialog'], + options, + }); +} + +describe('feedback screenshot upload fallback', () => { + beforeEach(() => { + document.body.innerHTML = ''; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + }); + + it('keeps the input visible when display capture is rejected', async () => { + const onError = vi.fn(); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getDisplayMedia: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }, + }); + const input = createInput(); + const host = document.createElement('div'); + + render(h(input.input, { onError }), host); + + await vi.waitFor(() => expect(host.querySelector('input[type="file"]')).not.toBeNull()); + expect(onError).not.toHaveBeenCalled(); + expect(await input.value()).toBeUndefined(); + }); + + it('returns the selected image as an attachment', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getDisplayMedia: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }, + }); + const input = createInput(); + const host = document.createElement('div'); + + render(h(input.input, { onError: vi.fn() }), host); + const fileInput = await vi.waitFor(() => { + const element = host.querySelector('input[type="file"]'); + expect(element).not.toBeNull(); + return element as HTMLInputElement; + }); + const file = new File([new Uint8Array([137, 80, 78, 71])], 'bug.png', { type: 'image/png' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [file] }); + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(async () => { + const attachment = await input.value(); + expect(attachment).toEqual({ + data: new Uint8Array([137, 80, 78, 71]), + filename: 'bug.png', + contentType: 'image/png', + }); + }); + }); + + it('clears a selected file when the screenshot input is removed', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getDisplayMedia: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }, + }); + const input = createInput(); + const host = document.createElement('div'); + + render(h(input.input, { onError: vi.fn() }), host); + const fileInput = await vi.waitFor(() => { + const element = host.querySelector('input[type="file"]'); + expect(element).not.toBeNull(); + return element as HTMLInputElement; + }); + const file = new File(['old screenshot'], 'old.png', { type: 'image/png' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [file] }); + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + await vi.waitFor(() => expect(input.value()).resolves.toBeDefined()); + + render(null, host); + + await expect(input.value()).resolves.toBeUndefined(); + }); +});