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();
+ });
+});