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;