From 2cba31bad22e52530b6a4cffadd6684bfa9f15e1 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Wed, 11 Nov 2026 20:06:40 +0100 Subject: [PATCH] Feedback forms: tab capture for the marked screenshot and the recording (Gleap #228450) The form's capture step now uses the capture requests' method: - "Mark the bug": Next takes one frame of the current tab, drawings included (desktop Chromium, one click in the browser's share dialog). The image is uploaded and sent as screenshotUrl. Where the tab can't be captured (other browsers, a frame, webScreenshotMethod "dom-only"), or it is declined or fails, the report renders the DOM on the server as before. - "Record screen": GleapScreenRecorder runs on DisplayRecording and requestDisplayStream (current tab first on Chromium, MP4/WebM with a seekable duration) instead of its own MediaRecorder code. Reports sent without marking, and silent reports, keep the DOM render: they have no click on the page that could ask for the tab. Co-Authored-By: Claude Fable 5.2 --- src/Gleap.js | 2 + src/GleapCaptureManager.js | 20 +- src/GleapCaptureScreenshot.js | 63 ++++-- src/GleapCaptureSettings.js | 16 ++ src/GleapEventManager.js | 1 + src/GleapFeedback.js | 79 ++++--- src/GleapFeedback.test.js | 87 +++++++- src/GleapMarkerManager.js | 59 +++++- src/GleapMarkerManager.test.js | 137 +++++++++++++ src/GleapScreenRecorder.js | 352 ++++++++------------------------ src/GleapScreenRecorder.test.js | 132 ++++++++++++ src/UI.js | 8 + 12 files changed, 620 insertions(+), 336 deletions(-) create mode 100644 src/GleapMarkerManager.test.js create mode 100644 src/GleapScreenRecorder.test.js diff --git a/src/Gleap.js b/src/Gleap.js index bb80aae2..41aaf8ae 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -78,6 +78,8 @@ class Gleap { // Global data globalData = { screenRecordingData: null, + // The feedback form's marked screenshot when it was taken from the tab (GleapMarkerManager). + screenshotImage: null, webReplay: null, snapshotPosition: { x: 0, diff --git a/src/GleapCaptureManager.js b/src/GleapCaptureManager.js index 7eaf888e..d50cd0cf 100644 --- a/src/GleapCaptureManager.js +++ b/src/GleapCaptureManager.js @@ -22,10 +22,10 @@ import { CAPTURE_MAX_RECORDING_SEC, CAPTURE_PLATFORM, CAPTURE_SDK_TYPE, + canCaptureTab, getCaptureConfig, getRecordingMethod, getSdkVersion, - hasDisplayMedia, hasUserMedia, isCaptureSupported, isDesktopChromium, @@ -83,14 +83,6 @@ const HIDDEN_GLEAP_UI_SELECTOR = [ 'gleap-checklist', ].join(', '); -const isTopWindow = () => { - try { - return window.top === window.self; - } catch (exp) { - return false; - } -}; - const readStorage = (key) => { try { return window.sessionStorage.getItem(key); @@ -680,10 +672,8 @@ export default class GleapCaptureManager { if (session.kind !== 'screenshot' || session.phase !== 'bar') { return; } - const config = getCaptureConfig(); - // Called inside the Capture click: the tab capture needs that user activation. Not from inside a - // frame: the tab shows the whole page, the masks only know this frame. - const useTab = config.webScreenshotMethod !== 'dom-only' && isDesktopChromium() && hasDisplayMedia() && isTopWindow(); + // Called inside the Capture click: the tab capture needs that user activation. + const useTab = canCaptureTab(); const streamPromise = useTab ? requestTabStream() : null; session.phase = 'capturing'; @@ -999,9 +989,7 @@ export default class GleapCaptureManager { session.recording = null; session.result = result; // When it was paused: the logs leave those stretches out (Pause is for logging in or paying). - session.pauses = session.timeline - ? session.timeline.pauses.map((pause) => [pause[0], pause[1] || result.endedAt]) - : []; + session.pauses = session.timeline ? session.timeline.pauses.map((pause) => [pause[0], pause[1] || result.endedAt]) : []; session.timelineEntries = session.timeline ? session.timeline.stop(result.endedAt) : []; const interactions = session.timeline ? session.timeline.interactions : 0; session.timeline = null; diff --git a/src/GleapCaptureScreenshot.js b/src/GleapCaptureScreenshot.js index 2bc511a0..a360424f 100644 --- a/src/GleapCaptureScreenshot.js +++ b/src/GleapCaptureScreenshot.js @@ -1554,14 +1554,9 @@ export const renderDom = ({ privacyOptions, maskSelectors, isActive }) => .catch((error) => finish(error)); }); -/** - * Takes the screenshot: the shared tab when `streamPromise` (from requestTabStream) delivers one, - * the DOM render otherwise (declined, cancelled, another surface or any error) — without asking again. - * @returns {Promise<{dataUrl: string, width: number, height: number, method: 'tab'|'dom'}>} - */ -export const captureScreenshot = ({ streamPromise, setUiHidden, privacyOptions, maskSelectors, isActive }) => { - const active = () => !isActive || isActive(); - const fromTab = streamPromise +// One frame of the shared tab, or null when there is no stream, it was declined or it failed. +const captureTab = ({ streamPromise, setUiHidden, privacyOptions, maskSelectors, active }) => + streamPromise ? streamPromise.then( (stream) => { if (!active()) { @@ -1573,15 +1568,51 @@ export const captureScreenshot = ({ streamPromise, setUiHidden, privacyOptions, ); }, () => { - // Declined or failed: the bar (hidden for the share dialog) shows its busy state again - // while the page is rendered instead. + // Declined or failed: the UI (hidden for the share dialog) shows again. setUiHidden(false); return null; } ) : Promise.resolve(null); - return fromTab +const encodeAndRelease = (capture) => { + try { + const encoded = encodeCapture(capture); + encoded.method = capture.method; + return encoded; + } finally { + releaseCanvas(capture.canvas); + } +}; + +/** + * Takes the screenshot from the shared tab only (`streamPromise` from requestTabStream). Resolves + * with null when the tab was declined, cancelled, another surface or failed: the caller has its own + * fallback (the feedback form renders the DOM on the server). + * @returns {Promise<{dataUrl: string, width: number, height: number, method: 'tab'}|null>} + */ +export const captureTabScreenshot = ({ streamPromise, setUiHidden, privacyOptions, maskSelectors, isActive }) => { + const active = () => !isActive || isActive(); + return captureTab({ streamPromise, setUiHidden, privacyOptions, maskSelectors, active }).then((capture) => { + if (!capture) { + return null; + } + if (!active()) { + releaseCanvas(capture.canvas); + return null; + } + return encodeAndRelease(capture); + }); +}; + +/** + * Takes the screenshot: the shared tab when `streamPromise` (from requestTabStream) delivers one, + * the DOM render otherwise (declined, cancelled, another surface or any error) — without asking again. + * @returns {Promise<{dataUrl: string, width: number, height: number, method: 'tab'|'dom'}>} + */ +export const captureScreenshot = ({ streamPromise, setUiHidden, privacyOptions, maskSelectors, isActive }) => { + const active = () => !isActive || isActive(); + return captureTab({ streamPromise, setUiHidden, privacyOptions, maskSelectors, active }) .then((capture) => { if (!active()) { if (capture) { @@ -1591,13 +1622,5 @@ export const captureScreenshot = ({ streamPromise, setUiHidden, privacyOptions, } return capture || renderDom({ privacyOptions, maskSelectors, isActive: active }); }) - .then((capture) => { - try { - const encoded = encodeCapture(capture); - encoded.method = capture.method; - return encoded; - } finally { - releaseCanvas(capture.canvas); - } - }); + .then(encodeAndRelease); }; diff --git a/src/GleapCaptureSettings.js b/src/GleapCaptureSettings.js index 8b2b0006..35a98c92 100644 --- a/src/GleapCaptureSettings.js +++ b/src/GleapCaptureSettings.js @@ -124,6 +124,22 @@ export const isDesktopChromium = () => { } }; +const isTopWindow = () => { + try { + return window.top === window.self; + } catch (exp) { + return false; + } +}; + +/** + * Whether a screenshot can be one frame of the current tab (desktop Chromium, unless the project + * renders the DOM only). Not from inside a frame: the tab shows the whole page, the masks only know + * this frame. The request itself needs a user activation (requestTabStream inside the click). + */ +export const canCaptureTab = () => + getCaptureConfig().webScreenshotMethod !== 'dom-only' && isDesktopChromium() && hasDisplayMedia() && isTopWindow(); + /** * 'display' (getDisplayMedia + MediaRecorder, desktop browsers) or 'rrweb' (a page recording, * phones and tablets). diff --git a/src/GleapEventManager.js b/src/GleapEventManager.js index 2a3f4cba..1bfd8f19 100644 --- a/src/GleapEventManager.js +++ b/src/GleapEventManager.js @@ -20,6 +20,7 @@ export default class GleapEventManager { const gleapInstance = Gleap.getInstance(); gleapInstance.setGlobalDataItem('webReplay', null); gleapInstance.setGlobalDataItem('screenRecordingData', null); + gleapInstance.setGlobalDataItem('screenshotImage', null); gleapInstance.takeCurrentReplay(); } diff --git a/src/GleapFeedback.js b/src/GleapFeedback.js index 6d714e76..750ac92c 100644 --- a/src/GleapFeedback.js +++ b/src/GleapFeedback.js @@ -17,6 +17,17 @@ import Gleap, { // spinner that never ends. Generous on purpose — a 20k-element page captures in ~1.5s. const SCREENSHOT_CAPTURE_TIMEOUT_MS = 15000; +const dataUrlToFile = (dataUrl, fileName) => { + const [header, base64] = dataUrl.split(','); + const type = (/^data:([^;]+)/.exec(header) || [])[1] || 'image/jpeg'; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new File([bytes], fileName, { type }); +}; + // Resolves early if the wrapped promise overruns. Rejection is passed through untouched, so a // genuinely failed capture still surfaces as a failed send exactly as before. const resolveAfterTimeout = (promise, timeoutMs, label) => @@ -71,9 +82,12 @@ export default class GleapFeedback { isSilent = false; outboundId = undefined; screenshotData = undefined; + // The marked screenshot as an uploaded image (see forgetSentCaptures); screenshotData otherwise. + screenshotUrl = undefined; + screenshotImage = undefined; webReplay = undefined; screenRecordingUrl = undefined; - // The form's recording this report uploads (see forgetSentScreenRecording). + // The form's recording this report uploads (see forgetSentCaptures). screenRecordingData = undefined; spamToken = undefined; @@ -109,7 +123,7 @@ export default class GleapFeedback { var screenRecordingData = this.isSilent ? null : gleapInstance.getGlobalDataItem('screenRecordingData'); if (screenRecordingData != null) { this.screenRecordingData = screenRecordingData; - var recordingUrlPromise = GleapScreenRecorder.uploadScreenRecording(screenRecordingData).then((recordingUrl) => { + var recordingUrlPromise = GleapScreenRecorder.uploadFile(screenRecordingData).then((recordingUrl) => { if (recordingUrl) { this.screenRecordingUrl = recordingUrl; } @@ -119,21 +133,35 @@ export default class GleapFeedback { // Prepare screenshot if (!(this.excludeData && this.excludeData.screenshot)) { - // The replay options go along so form fields are masked the same way as in replays. - var screenshotDataPromise = startScreenCapture( - gleapInstance.isLiveMode(), - GleapReplayRecorder.getInstance().customOptions - ).then((screenshotData) => { - if (screenshotData) { - const snapshotPosition = gleapInstance.getGlobalDataItem('snapshotPosition'); - screenshotData['x'] = snapshotPosition.x; - screenshotData['y'] = snapshotPosition.y; - this.screenshotData = screenshotData; - } - }); - dataPromises.push( - resolveAfterTimeout(screenshotDataPromise, SCREENSHOT_CAPTURE_TIMEOUT_MS, 'screenshot capture') - ); + // The DOM, rendered on the server. The replay options go along so form fields are masked the + // same way as in replays. + const captureDom = () => + startScreenCapture(gleapInstance.isLiveMode(), GleapReplayRecorder.getInstance().customOptions).then( + (screenshotData) => { + if (screenshotData) { + const snapshotPosition = gleapInstance.getGlobalDataItem('snapshotPosition'); + screenshotData['x'] = snapshotPosition.x; + screenshotData['y'] = snapshotPosition.y; + this.screenshotData = screenshotData; + } + } + ); + // The screenshot the user marked, when the form took it from the tab (GleapMarkerManager): it + // goes out as an image. Without one (no tab capture in this browser, declined, a report sent + // without marking, a silent report) or when its upload fails, the DOM is rendered instead. + const screenshotImage = this.isSilent ? null : gleapInstance.getGlobalDataItem('screenshotImage'); + const screenshotPromise = screenshotImage + ? GleapScreenRecorder.uploadFile(dataUrlToFile(screenshotImage.dataUrl, 'screenshot.jpg')) + .then((screenshotUrl) => { + if (!screenshotUrl) { + throw new Error('no-screenshot-url'); + } + this.screenshotImage = screenshotImage; + this.screenshotUrl = screenshotUrl; + }) + .catch(captureDom) + : captureDom(); + dataPromises.push(resolveAfterTimeout(screenshotPromise, SCREENSHOT_CAPTURE_TIMEOUT_MS, 'screenshot capture')); } return Promise.all(dataPromises); @@ -156,6 +184,7 @@ export default class GleapFeedback { isSilent: this.isSilent, outbound: this.outboundId, screenshotData: this.screenshotData, + screenshotUrl: this.screenshotUrl, webReplay: this.webReplay, screenRecordingUrl: this.screenRecordingUrl, spamToken: this.spamToken, @@ -177,6 +206,7 @@ export default class GleapFeedback { if (keyToExclude === 'screenshot') { delete feedbackData.screenshotData; + delete feedbackData.screenshotUrl; } if (keyToExclude === 'replays') { @@ -203,13 +233,16 @@ export default class GleapFeedback { }); } - // The recording went out with this report: a later report must not upload it again. - forgetSentScreenRecording() { + // The recording and the marked screenshot went out with this report: a later report must not + // upload them again. + forgetSentCaptures() { try { const gleapInstance = Gleap.getInstance(); - if (this.screenRecordingData && gleapInstance.getGlobalDataItem('screenRecordingData') === this.screenRecordingData) { - gleapInstance.setGlobalDataItem('screenRecordingData', null); - } + ['screenRecordingData', 'screenshotImage'].forEach((key) => { + if (this[key] && gleapInstance.getGlobalDataItem(key) === this[key]) { + gleapInstance.setGlobalDataItem(key, null); + } + }); } catch (exp) {} } @@ -231,7 +264,7 @@ export default class GleapFeedback { http.onreadystatechange = function (e) { if (http.readyState === 4) { if (http.status === 200 || http.status === 201) { - self.forgetSentScreenRecording(); + self.forgetSentCaptures(); try { const feedback = JSON.parse(http.responseText); resolve(feedback); diff --git a/src/GleapFeedback.test.js b/src/GleapFeedback.test.js index e890bf0c..01dfb330 100644 --- a/src/GleapFeedback.test.js +++ b/src/GleapFeedback.test.js @@ -1,22 +1,25 @@ import GleapFeedback from './GleapFeedback'; import { startScreenCapture } from './ScreenCapture'; +import { GleapScreenRecorder } from './GleapScreenRecorder'; const WEB_REPLAY = { startDate: 1, events: ['packed-event'], packed: true, type: 'rrweb' }; const REPLAY_OPTIONS = { maskAllInputs: true }; // sendFeedback only reads from the sibling managers; stub the barrel so it loads // without a DOM or the SDK_VERSION Webpack global. +// The SDK's global data; tests add to it (reset in beforeEach). +const mockGlobalData = {}; + jest.mock('./Gleap', () => { const manager = (methods) => ({ getInstance: jest.fn(() => methods) }); return { __esModule: true, default: { getInstance: jest.fn(() => ({ - getGlobalDataItem: (key) => - ({ - webReplay: { startDate: 1, events: ['packed-event'], packed: true, type: 'rrweb' }, - snapshotPosition: { x: 0, y: 0 }, - })[key] ?? null, + getGlobalDataItem: (key) => mockGlobalData[key] ?? null, + setGlobalDataItem: (key, value) => { + mockGlobalData[key] = value; + }, isLiveMode: () => false, })), }, @@ -35,7 +38,7 @@ jest.mock('./ScreenCapture', () => ({ startScreenCapture: jest.fn(() => Promise.resolve({ html: '' })), })); -jest.mock('./GleapScreenRecorder', () => ({ GleapScreenRecorder: { uploadScreenRecording: jest.fn() } })); +jest.mock('./GleapScreenRecorder', () => ({ GleapScreenRecorder: { uploadFile: jest.fn() } })); // Minimal XHR double: tests drive responses explicitly via respond(). class MockXhr { @@ -67,13 +70,13 @@ class MockXhr { // takeSnapshot resolves through a few promise hops before the request goes out. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); -const send = async () => { +const send = async ({ isSilent = false, excludeData = {} } = {}) => { const result = new GleapFeedback( 'BUG', 'MEDIUM', { description: 'It broke' }, - false, - {}, + isSilent, + excludeData, undefined, undefined ).sendFeedback(); @@ -89,6 +92,10 @@ const send = async () => { beforeEach(() => { MockXhr.instances = []; global.XMLHttpRequest = MockXhr; + Object.keys(mockGlobalData).forEach((key) => delete mockGlobalData[key]); + Object.assign(mockGlobalData, { webReplay: WEB_REPLAY, snapshotPosition: { x: 0, y: 0 } }); + startScreenCapture.mockClear(); + GleapScreenRecorder.uploadFile.mockReset(); }); describe('sendFeedback when the report is too large (#147738)', () => { @@ -153,10 +160,68 @@ describe('sendFeedback when the report is too large (#147738)', () => { describe('screenshot capture', () => { test('gets the replay options, so form fields are masked the same way as in replays', async () => { - startScreenCapture.mockClear(); - await send(); expect(startScreenCapture).toHaveBeenCalledWith(false, REPLAY_OPTIONS); }); }); + +// The form's marked screenshot, taken from the tab on "Next" (GleapMarkerManager), #228450. +describe('tab screenshot of the feedback form', () => { + // A 1x1 JPEG header is enough: only the bytes and the type are passed on. + const IMAGE = { dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', width: 1, height: 1, method: 'tab' }; + + test('goes out as an uploaded image instead of the DOM, and is not sent twice', async () => { + mockGlobalData.screenshotImage = IMAGE; + GleapScreenRecorder.uploadFile.mockResolvedValue('https://files.gleap.io/screenshot.jpg'); + + await send(); + const file = GleapScreenRecorder.uploadFile.mock.calls[0][0]; + expect(file.name).toBe('screenshot.jpg'); + expect(file.type).toBe('image/jpeg'); + expect(file.size).toBe(6); + expect(MockXhr.instances[0].body.screenshotUrl).toBe('https://files.gleap.io/screenshot.jpg'); + expect(MockXhr.instances[0].body.screenshotData).toBeUndefined(); + expect(startScreenCapture).not.toHaveBeenCalled(); + + MockXhr.instances[0].respond(201, '{"id":"ticket-1"}'); + expect(mockGlobalData.screenshotImage).toBeNull(); + }); + + test('a failed upload falls back to the DOM render', async () => { + mockGlobalData.screenshotImage = IMAGE; + GleapScreenRecorder.uploadFile.mockRejectedValue(new Error('Upload failed with status 500')); + + await send(); + + expect(MockXhr.instances[0].body.screenshotUrl).toBeUndefined(); + expect(MockXhr.instances[0].body.screenshotData).toEqual({ html: '', x: 0, y: 0 }); + }); + + test('no tab screenshot (not offered, declined, or not marked) -> the DOM render', async () => { + await send(); + + expect(GleapScreenRecorder.uploadFile).not.toHaveBeenCalled(); + expect(MockXhr.instances[0].body.screenshotData).toEqual({ html: '', x: 0, y: 0 }); + }); + + test('a silent report never takes the form screenshot along', async () => { + mockGlobalData.screenshotImage = IMAGE; + + await send({ isSilent: true }); + + expect(GleapScreenRecorder.uploadFile).not.toHaveBeenCalled(); + expect(MockXhr.instances[0].body.screenshotUrl).toBeUndefined(); + expect(mockGlobalData.screenshotImage).toBe(IMAGE); + }); + + test('a form that excludes screenshots sends none', async () => { + mockGlobalData.screenshotImage = IMAGE; + + await send({ excludeData: { screenshot: true } }); + + expect(GleapScreenRecorder.uploadFile).not.toHaveBeenCalled(); + expect(MockXhr.instances[0].body.screenshotUrl).toBeUndefined(); + expect(MockXhr.instances[0].body.screenshotData).toBeUndefined(); + }); +}); diff --git a/src/GleapMarkerManager.js b/src/GleapMarkerManager.js index f283706c..774bd236 100644 --- a/src/GleapMarkerManager.js +++ b/src/GleapMarkerManager.js @@ -1,7 +1,9 @@ -import Gleap, { GleapTranslationManager, GleapFrameManager } from './Gleap'; +import Gleap, { GleapTranslationManager, GleapFrameManager, GleapCaptureManager } from './Gleap'; import { ScreenDrawer } from './ScreenDrawer'; import { GleapScrollStopper } from './GleapScrollStopper'; import { GleapScreenRecorder } from './GleapScreenRecorder'; +import { captureTabScreenshot, requestTabStream } from './GleapCaptureScreenshot'; +import { canCaptureTab } from './GleapCaptureSettings'; import { loadIcon } from './UI'; export default class GleapMarkerManager { @@ -12,6 +14,8 @@ export default class GleapMarkerManager { screenDrawer = null; escListener = null; pageLeaveListener = null; + // True while the marked page is captured from the tab (the Next click until the frame is there). + capturingTab = false; overrideLanguage = Gleap.getInstance().overrideLanguage; constructor(type) { @@ -100,6 +104,9 @@ export default class GleapMarkerManager { } clear() { + this.capturingTab = false; + Gleap.getInstance().setGlobalDataItem('screenshotImage', null); + if (this.screenDrawer) { this.screenDrawer.destroyScrollTracker(); } @@ -248,6 +255,53 @@ export default class GleapMarkerManager { } showNextStep = function () { + if (this.capturingTab) { + return; + } + if (this.type === 'screenshot' && canCaptureTab()) { + this.captureTabScreenshot(); + return; + } + this.finishNextStep(); + }; + + // The marked page as the browser shows it: one frame of this tab, drawings included. Runs inside + // the Next click (the browser's share dialog needs that user activation). Declined, cancelled or + // failed: nothing is kept and the report renders the DOM as before (GleapFeedback). + captureTabScreenshot() { + this.capturingTab = true; + const streamPromise = requestTabStream(); + const captureEditor = document.querySelector('.bb-capture-editor'); + const setUiHidden = (hidden) => { + if (captureEditor) { + captureEditor.classList.toggle('bb-capture-editor--capturing', hidden); + } + }; + // Hidden before the share dialog shows: a tab stream can still hand out frames painted before a + // later hide. The drawings stay. + setUiHidden(true); + + const captureManager = GleapCaptureManager.getInstance(); + captureTabScreenshot({ + streamPromise, + setUiHidden, + privacyOptions: captureManager.privacyOptions(), + maskSelectors: captureManager.maskSelectors(), + isActive: () => this.capturingTab, + }) + .catch(() => null) + .then((image) => { + // Dismissed meanwhile. + if (!this.capturingTab) { + return; + } + this.capturingTab = false; + Gleap.getInstance().setGlobalDataItem('screenshotImage', image); + this.finishNextStep(); + }); + } + + finishNextStep() { // Adapt the UI this.showWidgetUI(); @@ -264,7 +318,7 @@ export default class GleapMarkerManager { }); this.callback(true); } - }; + } unregisterListeners() { if (this.escListener) { @@ -376,6 +430,7 @@ export default class GleapMarkerManager { } dismiss() { + this.capturingTab = false; this.showWidgetUI(); if (this.callback) { diff --git a/src/GleapMarkerManager.test.js b/src/GleapMarkerManager.test.js new file mode 100644 index 00000000..0db57d50 --- /dev/null +++ b/src/GleapMarkerManager.test.js @@ -0,0 +1,137 @@ +/** + * @jest-environment jsdom + */ +import GleapMarkerManager from './GleapMarkerManager'; +import { captureTabScreenshot, requestTabStream } from './GleapCaptureScreenshot'; +import { canCaptureTab } from './GleapCaptureSettings'; + +// The feedback form's "Mark the bug" step takes its screenshot from the tab on Next (#228450). +const mockGlobalData = {}; +const mockSendMessage = jest.fn(); + +jest.mock('./Gleap', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + overrideLanguage: '', + setGlobalDataItem: (key, value) => { + mockGlobalData[key] = value; + }, + }), + }, + GleapTranslationManager: { translateText: (key) => key, getInstance: () => ({ isRTLLayout: false }) }, + GleapFrameManager: { getInstance: () => ({ sendMessage: mockSendMessage }) }, + GleapCaptureManager: { + getInstance: () => ({ privacyOptions: () => ({ maskAllInputs: true }), maskSelectors: () => ['.secret'] }), + }, +})); +jest.mock('./ScreenDrawer', () => ({ ScreenDrawer: jest.fn() })); +jest.mock('./GleapScrollStopper', () => ({ GleapScrollStopper: { enableScroll: jest.fn(), disableScroll: jest.fn() } })); +jest.mock('./GleapScreenRecorder', () => ({ GleapScreenRecorder: jest.fn() })); +jest.mock('./UI', () => ({ loadIcon: () => '' })); +jest.mock('./GleapCaptureSettings', () => ({ canCaptureTab: jest.fn() })); +jest.mock('./GleapCaptureScreenshot', () => ({ captureTabScreenshot: jest.fn(), requestTabStream: jest.fn() })); + +const IMAGE = { dataUrl: 'data:image/jpeg;base64,/9j/4AAQ', width: 1280, height: 720, method: 'tab' }; +const STREAM_PROMISE = Promise.resolve({ id: 'tab-stream' }); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +let manager; +let callback; +let resolveCapture; +const editor = () => document.querySelector('.bb-capture-editor'); + +beforeEach(() => { + Object.keys(mockGlobalData).forEach((key) => delete mockGlobalData[key]); + mockSendMessage.mockClear(); + canCaptureTab.mockReset().mockReturnValue(true); + requestTabStream.mockReset().mockReturnValue(STREAM_PROMISE); + captureTabScreenshot.mockReset().mockImplementation( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }) + ); + document.body.innerHTML = ''; + manager = new GleapMarkerManager('screenshot'); + manager.createEditorUI(); + callback = jest.fn(); + manager.callback = callback; +}); + +test('Next asks for the tab inside the click, hides the editor (not the drawings) and keeps the image', async () => { + manager.showNextStep(); + + // Synchronously: the share dialog needs the click's user activation. + expect(requestTabStream).toHaveBeenCalledTimes(1); + expect(editor().classList.contains('bb-capture-editor--capturing')).toBe(true); + expect(document.querySelector('.bb-capture-svg')).not.toBeNull(); + const options = captureTabScreenshot.mock.calls[0][0]; + expect(options.streamPromise).toBe(STREAM_PROMISE); + expect(options.privacyOptions).toEqual({ maskAllInputs: true }); + expect(options.maskSelectors).toEqual(['.secret']); + expect(callback).not.toHaveBeenCalled(); + + // A second Next (Enter) while the share dialog is open asks nothing again. + manager.showNextStep(); + expect(requestTabStream).toHaveBeenCalledTimes(1); + + resolveCapture(IMAGE); + await flush(); + + expect(mockGlobalData.screenshotImage).toBe(IMAGE); + expect(callback).toHaveBeenCalledWith(true); + expect(mockSendMessage).toHaveBeenCalledWith({ + name: 'set-form-data', + data: { formKey: 'capture', data: { value: 'screenshot', dirty: true } }, + }); +}); + +test('declined or failed: the form goes on without an image (the report renders the DOM)', async () => { + captureTabScreenshot.mockReturnValue(Promise.reject(new Error('tab-timeout'))); + manager.showNextStep(); + await flush(); + + expect(mockGlobalData.screenshotImage).toBeNull(); + expect(callback).toHaveBeenCalledWith(true); +}); + +test('no tab capture in this browser: Next goes on right away, as before', () => { + canCaptureTab.mockReturnValue(false); + manager.showNextStep(); + + expect(requestTabStream).not.toHaveBeenCalled(); + expect(mockGlobalData.screenshotImage).toBeUndefined(); + expect(callback).toHaveBeenCalledWith(true); +}); + +test('dismissed while the share dialog is open: the capture stops and its image is dropped', async () => { + manager.showNextStep(); + manager.dismiss(); + + expect(captureTabScreenshot.mock.calls[0][0].isActive()).toBe(false); + expect(callback).toHaveBeenCalledWith(false); + + resolveCapture(IMAGE); + await flush(); + expect(mockGlobalData.screenshotImage).toBeUndefined(); + expect(callback).toHaveBeenCalledTimes(1); +}); + +test('removing the capture drops the image', async () => { + manager.showNextStep(); + resolveCapture(IMAGE); + await flush(); + + manager.clear(); + expect(mockGlobalData.screenshotImage).toBeNull(); + expect(editor()).toBeNull(); +}); + +test('a screen recording never asks for a tab screenshot', () => { + manager.type = 'capture'; + manager.showNextStep(); + + expect(requestTabStream).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(true); +}); diff --git a/src/GleapScreenRecorder.js b/src/GleapScreenRecorder.js index d331966a..fe6fb900 100644 --- a/src/GleapScreenRecorder.js +++ b/src/GleapScreenRecorder.js @@ -1,250 +1,152 @@ import GleapSession from './GleapSession'; +import { DisplayRecording, requestDisplayStream } from './GleapCaptureRecorder'; +import { stopStream } from './GleapCaptureScreenshot'; +import { hasDisplayMedia, isDesktopChromium } from './GleapCaptureSettings'; +// The feedback form's screen recording ("Record screen" in the form's capture step). It records the +// same way capture requests do (GleapCaptureRecorder): Chromium offers the current tab with one +// click, other browsers show their own picker. export class GleapScreenRecorder { - // Constants static MAX_RECORD_TIME = 120; // 2 minutes - static MAX_WIDTH = 3072; - static MAX_HEIGHT = 1728; - static IDEAL_FRAME_RATE = 10; - static MAX_FRAME_RATE = 24; - static CHUNK_INTERVAL = 200; // milliseconds - static TIMER_UPDATE_INTERVAL = 1000; // milliseconds static RERENDER_DELAY = 100; // milliseconds - // MIME types in order of preference - static MIME_TYPES = [ - 'video/webm;codecs=vp9,opus', - 'video/webm;codecs=vp8,opus', - 'video/webm;codecs=vp9', - 'video/webm;codecs=vp8', - 'video/webm', - 'video/mp4', - ]; - // CSS selectors static SELECTORS = { PREVIEW_VIDEO: '.bb-capture-preview video', TIMER_LABEL: '.bb-capture-toolbar-item-timer', + TOOLBAR: '.bb-capture-toolbar', }; // Instance properties rerender; - stream; - mediaRecorder; + // The running DisplayRecording, from the Start click until it stopped. + recording = null; audioMuted = false; audioAvailable = true; available = true; isRecording = false; file = null; - maxRecordTime = GleapScreenRecorder.MAX_RECORD_TIME; - recordTime = 0; - recordingTimer = null; permissionErrorText = ''; - // Internals for composed capture - _displayVideoTrack = null; - _displayStream = null; - _micStream = null; - constructor(rerender, permissionErrorText) { this.rerender = rerender; this.permissionErrorText = permissionErrorText; - if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { - this.available = false; - } + this.available = hasDisplayMedia(); setTimeout(() => { this.rerender(); }, GleapScreenRecorder.RERENDER_DELAY); } - getSupportedMimeType() { - // Prefer explicit audio+video codecs to ensure audio gets muxed. - for (const type of GleapScreenRecorder.MIME_TYPES) { - if (window.MediaRecorder && MediaRecorder.isTypeSupported(type)) { - return type; - } - } - - return 'video/webm'; - } - formatTime(s) { return (s - (s %= 60)) / 60 + (9 < s ? ':' : ':0') + s; } - stopStreamTracks(stream) { - if (!stream) return; - try { - stream.getTracks().forEach((track) => track.stop()); - } catch (_) {} - } - - createMediaStream(tracks) { - const validTracks = tracks.filter((track) => track !== null); - return new MediaStream(validTracks); - } - - getFirstVideoTrack(stream) { - if (!stream) return null; - const videoTracks = stream.getVideoTracks(); - return videoTracks.length > 0 ? videoTracks[0] : null; + // The editor is gone once the form shows again: nothing to render then. + rerenderEditor() { + if (document.querySelector(GleapScreenRecorder.SELECTORS.TOOLBAR)) { + this.rerender(); + } } - getFirstAudioTrack(stream) { - if (!stream) return null; - const audioTracks = stream.getAudioTracks(); - return audioTracks.length > 0 ? audioTracks[0] : null; + setTimerLabel(remainingSec) { + const timerLabel = document.querySelector(GleapScreenRecorder.SELECTORS.TIMER_LABEL); + if (timerLabel) { + timerLabel.innerHTML = this.formatTime(Math.max(0, remainingSec)); + } } - async startScreenRecording() { - if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia || this.isRecording) { + // Called inside the Start click: the browser's share dialog needs that user activation. + startScreenRecording() { + if (!hasDisplayMedia()) { this.available = false; this.rerender(); return; } - - try { - const displayStream = await navigator.mediaDevices.getDisplayMedia({ - video: { - width: { - ideal: Math.min(window.screen.width, GleapScreenRecorder.MAX_WIDTH), - }, - height: { - ideal: Math.min(window.screen.height, GleapScreenRecorder.MAX_HEIGHT), - }, - frameRate: { - ideal: GleapScreenRecorder.IDEAL_FRAME_RATE, - max: GleapScreenRecorder.MAX_FRAME_RATE, - }, - displaySurface: 'monitor', - }, - audio: false, - selfBrowserSurface: 'include', - }); - - // Keep references to both the original stream and video track - this._displayStream = displayStream; - this._displayVideoTrack = this.getFirstVideoTrack(displayStream); - - // If user wants audio and permissions allow it, get mic, else continue with video-only - if (!this.audioMuted) { - await this.startAudioRecording(); - } else { - this.audioAvailable = false; - const composed = this.createMediaStream([this._displayVideoTrack]); - this.stream = composed; - this.handleRecord({ stream: composed }); - } - - this.rerender(); - } catch (error) { - window.alert(this.permissionErrorText); - this.rerender(); - } - } - - stopScreenRecording() { - if (!this.mediaRecorder || !this.stream || !this.isRecording) { + if (this.recording) { return; } - if (this.recordingTimer) { - clearInterval(this.recordingTimer); - this.recordingTimer = null; - } - - try { - this.mediaRecorder.stop(); - } catch (_) {} - - this.releaseCapture(); - - this.rerender(); + const streamPromise = requestDisplayStream(isDesktopChromium()); + const recording = new DisplayRecording({ + maxDurationSec: GleapScreenRecorder.MAX_RECORD_TIME, + onTick: (elapsedSec) => this.setTimerLabel(GleapScreenRecorder.MAX_RECORD_TIME - elapsedSec), + onStop: (result) => this.onRecordingEnded(recording, result), + onError: () => this.onRecordingEnded(recording, null), + }); + this.recording = recording; + + streamPromise + .then((stream) => { + // Stopped or dismissed while the share dialog was open. + if (this.recording !== recording) { + stopStream(stream); + return null; + } + return recording.start(stream, !this.audioMuted).then(({ hasMicrophone }) => { + this.audioAvailable = hasMicrophone; + this.isRecording = true; + this.setTimerLabel(GleapScreenRecorder.MAX_RECORD_TIME); + this.rerenderEditor(); + }); + }) + .catch(() => { + if (this.recording !== recording) { + return; + } + recording.cancel(); + this.recording = null; + this.isRecording = false; + window.alert(this.permissionErrorText); + this.rerenderEditor(); + }); } - // Stops the timer and every captured track: the screen (what Chrome uses for the recording - // indicator), the composed stream and the microphone. - releaseCapture() { - if (this.recordingTimer) { - clearInterval(this.recordingTimer); - this.recordingTimer = null; - } - - // Stop composed stream tracks - this.stopStreamTracks(this.stream); - - // Stop original display stream tracks (this is what Chrome uses for the recording indicator) - if (this._displayStream) { - this.stopStreamTracks(this._displayStream); - this._displayStream = null; + // The Stop button, the time limit and the browser's "Stop sharing" all end here (DisplayRecording + // releases the screen and the microphone on every path). `result` is null when nothing was recorded. + onRecordingEnded(recording, result) { + if (this.recording !== recording) { + return; } + this.recording = null; + this.isRecording = false; + this.setTimerLabel(GleapScreenRecorder.MAX_RECORD_TIME); - // Also stop original mic stream tracks if present - if (this._micStream) { - this.stopStreamTracks(this._micStream); - this._micStream = null; + if (result) { + this.file = new File([result.blob], result.fileName, { type: result.type }); + const previewVideoElement = document.querySelector(GleapScreenRecorder.SELECTORS.PREVIEW_VIDEO); + if (previewVideoElement) { + previewVideoElement.src = URL.createObjectURL(result.blob); + } } - - this._displayVideoTrack = null; + this.rerenderEditor(); } - async startAudioRecording() { - if (!this._displayVideoTrack) { + stopScreenRecording() { + if (!this.recording) { return; } - - try { - const voiceStream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - video: false, - }); - - this._micStream = voiceStream; - const micTrack = this.getFirstAudioTrack(voiceStream); - - this.audioMuted = false; - this.audioAvailable = !!micTrack; - - // Compose a NEW stream with exactly one video track + (optional) one mic track - const composed = this.createMediaStream([this._displayVideoTrack, micTrack]); - - this.stream = composed; - this.handleRecord({ stream: composed }); - this.rerender(); - } catch (error) { - this.audioAvailable = false; - - // Fallback to video-only if mic fails - const composed = this.createMediaStream([this._displayVideoTrack]); - - this.stream = composed; - this.handleRecord({ stream: composed }); - this.rerender(); + if (!this.isRecording) { + // The share dialog is still open: the stream is stopped when it arrives. + this.recording = null; + return; } + this.recording.stop(); } toggleAudio() { this.audioMuted = !this.audioMuted; - this.rerender(); - - if (!this.stream) { - return; - } - - const audioTracks = this.stream.getAudioTracks(); - for (let i = 0; i < audioTracks.length; i++) { - const audioTrack = audioTracks[i]; - audioTrack.enabled = !this.audioMuted; + if (this.recording) { + this.recording.setMicrophoneEnabled(!this.audioMuted); } + this.rerender(); } - static async uploadScreenRecording(screenRecordingData) { - if (screenRecordingData == null) { + /** + * Uploads a file of a report (the screen recording, the tab screenshot); resolves with its URL. + */ + static async uploadFile(file) { + if (file == null) { return null; } @@ -254,7 +156,7 @@ export class GleapScreenRecorder { GleapSession.getInstance().injectSession(xhr); const formData = new FormData(); - formData.append('file', screenRecordingData); + formData.append('file', file); xhr.onerror = function () { reject(new Error('Network error during file upload')); @@ -280,82 +182,4 @@ export class GleapScreenRecorder { this.file = null; this.rerender(); } - - handleRecord({ stream }) { - const self = this; - - const recordedChunks = []; - this.mediaRecorder = new MediaRecorder(stream, { - mimeType: this.getSupportedMimeType(), - }); - this.isRecording = true; - this.recordTime = 0; - - // Set timer (guard if label isn't present) - const timerLabel = document.querySelector(GleapScreenRecorder.SELECTORS.TIMER_LABEL); - if (this.recordingTimer) { - clearInterval(this.recordingTimer); - this.recordingTimer = null; - } - this.recordingTimer = setInterval(() => { - self.recordTime++; - const remainingTime = self.maxRecordTime - self.recordTime; - if (timerLabel) { - if (remainingTime > 0) { - timerLabel.innerHTML = self.formatTime(remainingTime); - } else { - timerLabel.innerHTML = '2:00'; - self.stopScreenRecording(); - } - } else if (remainingTime <= 0) { - self.stopScreenRecording(); - } - }, GleapScreenRecorder.TIMER_UPDATE_INTERVAL); - - this.mediaRecorder.ondataavailable = function (e) { - if (e.data && e.data.size > 0) { - recordedChunks.push(e.data); - } - }; - - const videoTrack = this.getFirstVideoTrack(stream); - if (videoTrack) { - // The browser's "Stop sharing" ends the screen track: stop like the Stop button does, so the - // timer and the microphone stop too (the recorder's onstop prepares the recording). - videoTrack.onended = function () { - if (self.isRecording) { - self.stopScreenRecording(); - } else { - self.releaseCapture(); - } - }; - } - - this.mediaRecorder.onstop = function () { - self.prepareRecording(recordedChunks); - }; - - // Start recording with configured chunk interval - this.mediaRecorder.start(GleapScreenRecorder.CHUNK_INTERVAL); - - self.rerender(); - } - - prepareRecording(recordedChunks) { - const mimeType = this.getSupportedMimeType(); - const completeBlob = new Blob(recordedChunks, { type: mimeType }); - - const extension = mimeType.includes('mp4') ? 'mp4' : 'webm'; - this.file = new File([completeBlob], `screen-recording.${extension}`, { - type: mimeType, - }); - - const previewVideoElement = document.querySelector(GleapScreenRecorder.SELECTORS.PREVIEW_VIDEO); - if (previewVideoElement) { - previewVideoElement.src = URL.createObjectURL(completeBlob); - this.audioAvailable = this.stream ? this.stream.getAudioTracks().length > 0 : this.audioAvailable; - this.isRecording = false; - this.rerender(); - } - } } diff --git a/src/GleapScreenRecorder.test.js b/src/GleapScreenRecorder.test.js new file mode 100644 index 00000000..4b4b6b7e --- /dev/null +++ b/src/GleapScreenRecorder.test.js @@ -0,0 +1,132 @@ +/** + * @jest-environment jsdom + */ +import { GleapScreenRecorder } from './GleapScreenRecorder'; +import { requestDisplayStream } from './GleapCaptureRecorder'; +import { hasDisplayMedia, isDesktopChromium } from './GleapCaptureSettings'; + +// The feedback form's recorder runs on the capture requests' recording engine (#228450). +const mockRecordings = []; + +jest.mock('./GleapSession', () => ({ __esModule: true, default: { getInstance: () => ({}) } })); +jest.mock('./GleapCaptureScreenshot', () => ({ stopStream: jest.fn() })); +jest.mock('./GleapCaptureSettings', () => ({ + hasDisplayMedia: jest.fn(() => true), + isDesktopChromium: jest.fn(() => true), +})); +jest.mock('./GleapCaptureRecorder', () => ({ + requestDisplayStream: jest.fn(), + DisplayRecording: class { + constructor(options) { + this.options = options; + this.start = jest.fn(() => Promise.resolve({ hasMicrophone: true })); + this.stop = jest.fn(); + this.cancel = jest.fn(); + this.setMicrophoneEnabled = jest.fn(); + mockRecordings.push(this); + } + }, +})); + +const { stopStream } = jest.requireMock('./GleapCaptureScreenshot'); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +const STREAM = { id: 'tab-stream' }; + +let rerender; +const createRecorder = () => new GleapScreenRecorder(rerender, 'No access to the screen.'); + +beforeEach(() => { + mockRecordings.length = 0; + rerender = jest.fn(); + hasDisplayMedia.mockReturnValue(true); + isDesktopChromium.mockReturnValue(true); + requestDisplayStream.mockReset().mockReturnValue(Promise.resolve(STREAM)); + stopStream.mockClear(); + window.alert = jest.fn(); + URL.createObjectURL = jest.fn(() => 'blob:preview'); + document.body.innerHTML = + '
' + + '
'; +}); + +test('Start asks for the current tab (Chromium) inside the click and records with the microphone', async () => { + const recorder = createRecorder(); + recorder.startScreenRecording(); + + // Synchronously: the share dialog needs the click's user activation. + expect(requestDisplayStream).toHaveBeenCalledWith(true); + await flush(); + + expect(mockRecordings[0].options.maxDurationSec).toBe(120); + expect(mockRecordings[0].start).toHaveBeenCalledWith(STREAM, true); + expect(recorder.isRecording).toBe(true); + expect(recorder.audioAvailable).toBe(true); +}); + +test('other browsers get their own picker; a muted start records without the microphone', async () => { + isDesktopChromium.mockReturnValue(false); + const recorder = createRecorder(); + recorder.toggleAudio(); + recorder.startScreenRecording(); + await flush(); + + expect(requestDisplayStream).toHaveBeenCalledWith(false); + expect(mockRecordings[0].start).toHaveBeenCalledWith(STREAM, false); +}); + +test('the timer counts down and the stopped recording becomes the file and the preview', async () => { + const recorder = createRecorder(); + recorder.startScreenRecording(); + await flush(); + + mockRecordings[0].options.onTick(15); + expect(document.querySelector('.bb-capture-toolbar-item-timer').innerHTML).toBe('1:45'); + + recorder.stopScreenRecording(); + expect(mockRecordings[0].stop).toHaveBeenCalled(); + mockRecordings[0].options.onStop({ + blob: new Blob(['video']), + type: 'video/mp4', + fileName: 'screen-recording.mp4', + }); + + expect(recorder.isRecording).toBe(false); + expect(recorder.file.name).toBe('screen-recording.mp4'); + expect(recorder.file.type).toBe('video/mp4'); + expect(document.querySelector('.bb-capture-preview video').getAttribute('src')).toBe('blob:preview'); +}); + +test('a declined share dialog tells the user and leaves the recorder ready for another try', async () => { + requestDisplayStream.mockReturnValue(Promise.reject(new Error('NotAllowedError'))); + const recorder = createRecorder(); + recorder.startScreenRecording(); + await flush(); + + expect(window.alert).toHaveBeenCalledWith('No access to the screen.'); + expect(recorder.isRecording).toBe(false); + + requestDisplayStream.mockReturnValue(Promise.resolve(STREAM)); + recorder.startScreenRecording(); + await flush(); + expect(recorder.isRecording).toBe(true); +}); + +test('dismissed while the share dialog is open: the stream is stopped, nothing records', async () => { + const recorder = createRecorder(); + recorder.startScreenRecording(); + recorder.stopScreenRecording(); + await flush(); + + expect(stopStream).toHaveBeenCalledWith(STREAM); + expect(mockRecordings[0].start).not.toHaveBeenCalled(); + expect(recorder.isRecording).toBe(false); +}); + +test('without getDisplayMedia the recorder is unavailable', () => { + hasDisplayMedia.mockReturnValue(false); + const recorder = createRecorder(); + recorder.startScreenRecording(); + + expect(recorder.available).toBe(false); + expect(requestDisplayStream).not.toHaveBeenCalled(); +}); diff --git a/src/UI.js b/src/UI.js index 24aa3e83..2c5dbead 100644 --- a/src/UI.js +++ b/src/UI.js @@ -1968,6 +1968,14 @@ export const injectStyledCSS = ( .bb-capture-editor-recording .bb-capture-dismiss { display: none; } + + .bb-capture-editor--capturing .bb-capture-toolbar, + .bb-capture-editor--capturing .bb-capture-toolbar-item-colorpicker, + .bb-capture-editor--capturing .bb-capture-dismiss, + .bb-capture-editor--capturing .bb-capture-editor-drag-info, + .bb-capture-editor--capturing .bb-capture-editor-borderlayer { + display: none !important; + } .bb-capture-editor-item-inactive { opacity: 0.3;