diff options
Diffstat (limited to '')
-rw-r--r-- | browser/base/content/test/webrtc/head.js | 1338 |
1 files changed, 1338 insertions, 0 deletions
diff --git a/browser/base/content/test/webrtc/head.js b/browser/base/content/test/webrtc/head.js new file mode 100644 index 0000000000..13526e91b6 --- /dev/null +++ b/browser/base/content/test/webrtc/head.js @@ -0,0 +1,1338 @@ +var { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const PREF_PERMISSION_FAKE = "media.navigator.permission.fake"; +const PREF_AUDIO_LOOPBACK = "media.audio_loopback_dev"; +const PREF_VIDEO_LOOPBACK = "media.video_loopback_dev"; +const PREF_FAKE_STREAMS = "media.navigator.streams.fake"; +const PREF_FOCUS_SOURCE = "media.getusermedia.window.focus_source.enabled"; + +const STATE_CAPTURE_ENABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED; +const STATE_CAPTURE_DISABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED; + +const USING_LEGACY_INDICATOR = Services.prefs.getBoolPref( + "privacy.webrtc.legacyGlobalIndicator", + false +); + +const ALLOW_SILENCING_NOTIFICATIONS = Services.prefs.getBoolPref( + "privacy.webrtc.allowSilencingNotifications", + false +); + +const SHOW_GLOBAL_MUTE_TOGGLES = Services.prefs.getBoolPref( + "privacy.webrtc.globalMuteToggles", + false +); + +const INDICATOR_PATH = USING_LEGACY_INDICATOR + ? "chrome://browser/content/webrtcLegacyIndicator.xhtml" + : "chrome://browser/content/webrtcIndicator.xhtml"; + +const IS_MAC = AppConstants.platform == "macosx"; + +const SHARE_SCREEN = 1; +const SHARE_WINDOW = 2; + +let observerTopics = [ + "getUserMedia:response:allow", + "getUserMedia:revoke", + "getUserMedia:response:deny", + "getUserMedia:request", + "recording-device-events", + "recording-window-ended", +]; + +// Structured hierarchy of subframes. Keys are frame id:s, The children member +// contains nested sub frames if any. The noTest member make a frame be ignored +// for testing if true. +let gObserveSubFrames = {}; +// Object of subframes to test. Each element contains the members bc and id, for +// the frames BrowsingContext and id, respectively. +let gSubFramesToTest = []; +let gBrowserContextsToObserve = []; + +function whenDelayedStartupFinished(aWindow) { + return TestUtils.topicObserved( + "browser-delayed-startup-finished", + subject => subject == aWindow + ); +} + +function promiseIndicatorWindow() { + let startTime = performance.now(); + + // We don't show the legacy indicator window on Mac. + if (USING_LEGACY_INDICATOR && IS_MAC) { + return Promise.resolve(); + } + + return new Promise(resolve => { + Services.obs.addObserver(function obs(win) { + win.addEventListener( + "load", + function () { + if (win.location.href !== INDICATOR_PATH) { + info("ignoring a window with this url: " + win.location.href); + return; + } + + Services.obs.removeObserver(obs, "domwindowopened"); + executeSoon(() => { + ChromeUtils.addProfilerMarker("promiseIndicatorWindow", { + startTime, + category: "Test", + }); + resolve(win); + }); + }, + { once: true } + ); + }, "domwindowopened"); + }); +} + +async function assertWebRTCIndicatorStatus(expected) { + let ui = ChromeUtils.import("resource:///modules/webrtcUI.jsm").webrtcUI; + let expectedState = expected ? "visible" : "hidden"; + let msg = "WebRTC indicator " + expectedState; + if (!expected && ui.showGlobalIndicator) { + // It seems the global indicator is not always removed synchronously + // in some cases. + await TestUtils.waitForCondition( + () => !ui.showGlobalIndicator, + "waiting for the global indicator to be hidden" + ); + } + is(ui.showGlobalIndicator, !!expected, msg); + + let expectVideo = false, + expectAudio = false, + expectScreen = ""; + if (expected) { + if (expected.video) { + expectVideo = true; + } + if (expected.audio) { + expectAudio = true; + } + if (expected.screen) { + expectScreen = expected.screen; + } + } + is( + Boolean(ui.showCameraIndicator), + expectVideo, + "camera global indicator as expected" + ); + is( + Boolean(ui.showMicrophoneIndicator), + expectAudio, + "microphone global indicator as expected" + ); + is( + ui.showScreenSharingIndicator, + expectScreen, + "screen global indicator as expected" + ); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + let menu = win.document.getElementById("tabSharingMenu"); + is( + !!menu && !menu.hidden, + !!expected, + "WebRTC menu should be " + expectedState + ); + } + + if (USING_LEGACY_INDICATOR && IS_MAC) { + return; + } + + if (!expected) { + let win = Services.wm.getMostRecentWindow("Browser:WebRTCGlobalIndicator"); + if (win) { + await new Promise((resolve, reject) => { + win.addEventListener("unload", function listener(e) { + if (e.target == win.document) { + win.removeEventListener("unload", listener); + executeSoon(resolve); + } + }); + }); + } + } + + let indicator = Services.wm.getEnumerator("Browser:WebRTCGlobalIndicator"); + let hasWindow = indicator.hasMoreElements(); + is(hasWindow, !!expected, "popup " + msg); + if (hasWindow) { + let document = indicator.getNext().document; + let docElt = document.documentElement; + + if (document.readyState != "complete") { + info("Waiting for the sharing indicator's document to load"); + await new Promise(resolve => { + document.addEventListener( + "readystatechange", + function onReadyStateChange() { + if (document.readyState != "complete") { + return; + } + document.removeEventListener( + "readystatechange", + onReadyStateChange + ); + executeSoon(resolve); + } + ); + }); + } + + if ( + !USING_LEGACY_INDICATOR && + expected.screen && + expected.screen.startsWith("Window") + ) { + // These tests were originally written to express window sharing by + // having expected.screen start with "Window". This meant that the + // legacy indicator is expected to have the "sharingscreen" attribute + // set to true when sharing a window. + // + // The new indicator, however, differentiates between screen, window + // and browser window sharing. If we're using the new indicator, we + // update the expectations accordingly. This can be removed once we + // are able to remove the tests for the legacy indicator. + expected.screen = null; + expected.window = true; + } + + if (!USING_LEGACY_INDICATOR && !SHOW_GLOBAL_MUTE_TOGGLES) { + expected.video = false; + expected.audio = false; + + let visible = docElt.getAttribute("visible") == "true"; + + if (!expected.screen && !expected.window && !expected.browserwindow) { + ok(!visible, "Indicator should not be visible in this configuation."); + } else { + ok(visible, "Indicator should be visible."); + } + } + + for (let item of ["video", "audio", "screen", "window", "browserwindow"]) { + let expectedValue; + + if (USING_LEGACY_INDICATOR) { + expectedValue = expected && expected[item] ? "true" : ""; + } else { + expectedValue = expected && expected[item] ? "true" : null; + } + + is( + docElt.getAttribute("sharing" + item), + expectedValue, + item + " global indicator attribute as expected" + ); + } + + ok(!indicator.hasMoreElements(), "only one global indicator window"); + } +} + +function promiseNotificationShown(notification) { + let win = notification.browser.ownerGlobal; + if (win.PopupNotifications.panel.state == "open") { + return Promise.resolve(); + } + let panelPromise = BrowserTestUtils.waitForPopupEvent( + win.PopupNotifications.panel, + "shown" + ); + notification.reshow(); + return panelPromise; +} + +function ignoreEvent(aSubject, aTopic, aData) { + // With e10s disabled, our content script receives notifications for the + // preview displayed in our screen sharing permission prompt; ignore them. + const kBrowserURL = AppConstants.BROWSER_CHROME_URL; + const nsIPropertyBag = Ci.nsIPropertyBag; + if ( + aTopic == "recording-device-events" && + aSubject.QueryInterface(nsIPropertyBag).getProperty("requestURL") == + kBrowserURL + ) { + return true; + } + if (aTopic == "recording-window-ended") { + let win = Services.wm.getOuterWindowWithId(aData).top; + if (win.document.documentURI == kBrowserURL) { + return true; + } + } + return false; +} + +function expectObserverCalledInProcess(aTopic, aCount = 1) { + let promises = []; + for (let count = aCount; count > 0; count--) { + promises.push(TestUtils.topicObserved(aTopic, ignoreEvent)); + } + return promises; +} + +function expectObserverCalled( + aTopic, + aCount = 1, + browser = gBrowser.selectedBrowser +) { + if (!gMultiProcessBrowser) { + return expectObserverCalledInProcess(aTopic, aCount); + } + + let browsingContext = Element.isInstance(browser) + ? browser.browsingContext + : browser; + + return BrowserTestUtils.contentTopicObserved(browsingContext, aTopic, aCount); +} + +// This is a special version of expectObserverCalled that should only +// be used when expecting a notification upon closing a window. It uses +// the per-process message manager instead of actors to send the +// notifications. +function expectObserverCalledOnClose( + aTopic, + aCount = 1, + browser = gBrowser.selectedBrowser +) { + if (!gMultiProcessBrowser) { + return expectObserverCalledInProcess(aTopic, aCount); + } + + let browsingContext = Element.isInstance(browser) + ? browser.browsingContext + : browser; + + return new Promise(resolve => { + BrowserTestUtils.sendAsyncMessage( + browsingContext, + "BrowserTestUtils:ObserveTopic", + { + topic: aTopic, + count: 1, + filterFunctionSource: ((subject, topic, data) => { + Services.cpmm.sendAsyncMessage("WebRTCTest:ObserverCalled", { + topic, + }); + return true; + }).toSource(), + } + ); + + function observerCalled(message) { + if (message.data.topic == aTopic) { + Services.ppmm.removeMessageListener( + "WebRTCTest:ObserverCalled", + observerCalled + ); + resolve(); + } + } + Services.ppmm.addMessageListener( + "WebRTCTest:ObserverCalled", + observerCalled + ); + }); +} + +function promiseMessage( + aMessage, + aAction, + aCount = 1, + browser = gBrowser.selectedBrowser +) { + let startTime = performance.now(); + let promise = ContentTask.spawn( + browser, + [aMessage, aCount], + async function ([expectedMessage, expectedCount]) { + return new Promise(resolve => { + function listenForMessage({ data }) { + if ( + (!expectedMessage || data == expectedMessage) && + --expectedCount == 0 + ) { + content.removeEventListener("message", listenForMessage); + resolve(data); + } + } + content.addEventListener("message", listenForMessage); + }); + } + ); + if (aAction) { + aAction(); + } + return promise.then(data => { + ChromeUtils.addProfilerMarker( + "promiseMessage", + { startTime, category: "Test" }, + data + ); + return data; + }); +} + +function promisePopupNotificationShown(aName, aAction, aWindow = window) { + let startTime = performance.now(); + return new Promise(resolve => { + aWindow.PopupNotifications.panel.addEventListener( + "popupshown", + function () { + ok( + !!aWindow.PopupNotifications.getNotification(aName), + aName + " notification shown" + ); + ok(aWindow.PopupNotifications.isPanelOpen, "notification panel open"); + ok( + !!aWindow.PopupNotifications.panel.firstElementChild, + "notification panel populated" + ); + + executeSoon(() => { + ChromeUtils.addProfilerMarker( + "promisePopupNotificationShown", + { startTime, category: "Test" }, + aName + ); + resolve(); + }); + }, + { once: true } + ); + + if (aAction) { + aAction(); + } + }); +} + +async function promisePopupNotification(aName) { + return TestUtils.waitForCondition( + () => PopupNotifications.getNotification(aName), + aName + " notification appeared" + ); +} + +async function promiseNoPopupNotification(aName) { + return TestUtils.waitForCondition( + () => !PopupNotifications.getNotification(aName), + aName + " notification removed" + ); +} + +const kActionAlways = 1; +const kActionDeny = 2; +const kActionNever = 3; + +async function activateSecondaryAction(aAction) { + let notification = PopupNotifications.panel.firstElementChild; + switch (aAction) { + case kActionNever: + if (notification.notification.secondaryActions.length > 1) { + // "Always Block" is the first (and only) item in the menupopup. + await Promise.all([ + BrowserTestUtils.waitForEvent(notification.menupopup, "popupshown"), + notification.menubutton.click(), + ]); + notification.menupopup.querySelector("menuitem").click(); + return; + } + if (!notification.checkbox.checked) { + notification.checkbox.click(); + } + // fallthrough + case kActionDeny: + notification.secondaryButton.click(); + break; + case kActionAlways: + if (!notification.checkbox.checked) { + notification.checkbox.click(); + } + notification.button.click(); + break; + } +} + +async function getMediaCaptureState() { + let startTime = performance.now(); + + function gatherBrowsingContexts(aBrowsingContext) { + let list = [aBrowsingContext]; + + let children = aBrowsingContext.children; + for (let child of children) { + list.push(...gatherBrowsingContexts(child)); + } + + return list; + } + + function combine(x, y) { + if ( + x == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED || + y == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED + ) { + return Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED; + } + if ( + x == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED || + y == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED + ) { + return Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED; + } + return Ci.nsIMediaManagerService.STATE_NOCAPTURE; + } + + let video = Ci.nsIMediaManagerService.STATE_NOCAPTURE; + let audio = Ci.nsIMediaManagerService.STATE_NOCAPTURE; + let screen = Ci.nsIMediaManagerService.STATE_NOCAPTURE; + let window = Ci.nsIMediaManagerService.STATE_NOCAPTURE; + let browser = Ci.nsIMediaManagerService.STATE_NOCAPTURE; + + for (let bc of gatherBrowsingContexts( + gBrowser.selectedBrowser.browsingContext + )) { + let state = await SpecialPowers.spawn(bc, [], async function () { + let mediaManagerService = Cc[ + "@mozilla.org/mediaManagerService;1" + ].getService(Ci.nsIMediaManagerService); + + let hasCamera = {}; + let hasMicrophone = {}; + let hasScreenShare = {}; + let hasWindowShare = {}; + let hasBrowserShare = {}; + let devices = {}; + mediaManagerService.mediaCaptureWindowState( + content, + hasCamera, + hasMicrophone, + hasScreenShare, + hasWindowShare, + hasBrowserShare, + devices, + false + ); + + return { + video: hasCamera.value, + audio: hasMicrophone.value, + screen: hasScreenShare.value, + window: hasWindowShare.value, + browser: hasBrowserShare.value, + }; + }); + + video = combine(state.video, video); + audio = combine(state.audio, audio); + screen = combine(state.screen, screen); + window = combine(state.window, window); + browser = combine(state.browser, browser); + } + + let result = {}; + + if (video != Ci.nsIMediaManagerService.STATE_NOCAPTURE) { + result.video = true; + } + if (audio != Ci.nsIMediaManagerService.STATE_NOCAPTURE) { + result.audio = true; + } + + if (screen != Ci.nsIMediaManagerService.STATE_NOCAPTURE) { + result.screen = "Screen"; + } else if (window != Ci.nsIMediaManagerService.STATE_NOCAPTURE) { + result.screen = "Window"; + } else if (browser != Ci.nsIMediaManagerService.STATE_NOCAPTURE) { + result.screen = "Browser"; + } + + ChromeUtils.addProfilerMarker("getMediaCaptureState", { + startTime, + category: "Test", + }); + return result; +} + +async function stopSharing( + aType = "camera", + aShouldKeepSharing = false, + aFrameBC, + aWindow = window +) { + let promiseRecordingEvent = expectObserverCalled( + "recording-device-events", + 1, + aFrameBC + ); + let observerPromise1 = expectObserverCalled( + "getUserMedia:revoke", + 1, + aFrameBC + ); + + // If we are stopping screen sharing and expect to still have another stream, + // "recording-window-ended" won't be fired. + let observerPromise2 = null; + if (!aShouldKeepSharing) { + observerPromise2 = expectObserverCalled( + "recording-window-ended", + 1, + aFrameBC + ); + } + + await revokePermission(aType, aShouldKeepSharing, aFrameBC, aWindow); + await promiseRecordingEvent; + await observerPromise1; + await observerPromise2; + + if (!aShouldKeepSharing) { + await checkNotSharing(); + } +} + +async function revokePermission( + aType = "camera", + aShouldKeepSharing = false, + aFrameBC, + aWindow = window +) { + aWindow.gPermissionPanel._identityPermissionBox.click(); + let popup = aWindow.gPermissionPanel._permissionPopup; + // If the popup gets hidden before being shown, by stray focus/activate + // events, don't bother failing the test. It's enough to know that we + // started showing the popup. + let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown"); + await Promise.race([hiddenEvent, shownEvent]); + let doc = aWindow.document; + let permissions = doc.getElementById("permission-popup-permission-list"); + let cancelButton = permissions.querySelector( + ".permission-popup-permission-icon." + + aType + + "-icon ~ " + + ".permission-popup-permission-remove-button" + ); + + cancelButton.click(); + popup.hidePopup(); + + if (!aShouldKeepSharing) { + await checkNotSharing(); + } +} + +function getBrowsingContextForFrame(aBrowsingContext, aFrameId) { + if (!aFrameId) { + return aBrowsingContext; + } + + return SpecialPowers.spawn(aBrowsingContext, [aFrameId], frameId => { + return content.document.getElementById(frameId).browsingContext; + }); +} + +async function getBrowsingContextsAndFrameIdsForSubFrames( + aBrowsingContext, + aSubFrames +) { + let pendingBrowserSubFrames = [ + { bc: aBrowsingContext, subFrames: aSubFrames }, + ]; + let browsingContextsAndFrames = []; + while (pendingBrowserSubFrames.length) { + let { bc, subFrames } = pendingBrowserSubFrames.shift(); + for (let id of Object.keys(subFrames)) { + let subBc = await getBrowsingContextForFrame(bc, id); + if (subFrames[id].children) { + pendingBrowserSubFrames.push({ + bc: subBc, + subFrames: subFrames[id].children, + }); + } + if (subFrames[id].noTest) { + continue; + } + let observeBC = subFrames[id].observe ? subBc : undefined; + browsingContextsAndFrames.push({ bc: subBc, id, observeBC }); + } + } + return browsingContextsAndFrames; +} + +async function promiseRequestDevice( + aRequestAudio, + aRequestVideo, + aFrameId, + aType, + aBrowsingContext, + aBadDevice = false +) { + info("requesting devices"); + let bc = + aBrowsingContext ?? + (await getBrowsingContextForFrame(gBrowser.selectedBrowser, aFrameId)); + return SpecialPowers.spawn( + bc, + [{ aRequestAudio, aRequestVideo, aType, aBadDevice }], + async function (args) { + let global = content.wrappedJSObject; + global.requestDevice( + args.aRequestAudio, + args.aRequestVideo, + args.aType, + args.aBadDevice + ); + } + ); +} + +async function promiseRequestAudioOutput(options) { + info("requesting audio output"); + const bc = gBrowser.selectedBrowser; + return SpecialPowers.spawn(bc, [options], async function (opts) { + const global = content.wrappedJSObject; + global.requestAudioOutput(Cu.cloneInto(opts, content)); + }); +} + +async function stopTracks( + aKind, + aAlreadyStopped, + aLastTracks, + aFrameId, + aBrowsingContext, + aBrowsingContextToObserve +) { + // If the observers are listening to other frames, listen for a notification + // on the right subframe. + let frameBC = + aBrowsingContext ?? + (await getBrowsingContextForFrame( + gBrowser.selectedBrowser.browsingContext, + aFrameId + )); + + let observerPromises = []; + if (!aAlreadyStopped) { + observerPromises.push( + expectObserverCalled( + "recording-device-events", + 1, + aBrowsingContextToObserve + ) + ); + } + if (aLastTracks) { + observerPromises.push( + expectObserverCalled( + "recording-window-ended", + 1, + aBrowsingContextToObserve + ) + ); + } + + info(`Stopping all ${aKind} tracks`); + await SpecialPowers.spawn(frameBC, [aKind], async function (kind) { + content.wrappedJSObject.stopTracks(kind); + }); + + await Promise.all(observerPromises); +} + +async function closeStream( + aAlreadyClosed, + aFrameId, + aDontFlushObserverVerification, + aBrowsingContext, + aBrowsingContextToObserve +) { + // Check that spurious notifications that occur while closing the + // stream are handled separately. Tests that use skipObserverVerification + // should pass true for aDontFlushObserverVerification. + if (!aDontFlushObserverVerification) { + await disableObserverVerification(); + await enableObserverVerification(); + } + + // If the observers are listening to other frames, listen for a notification + // on the right subframe. + let frameBC = + aBrowsingContext ?? + (await getBrowsingContextForFrame( + gBrowser.selectedBrowser.browsingContext, + aFrameId + )); + + let observerPromises = []; + if (!aAlreadyClosed) { + observerPromises.push( + expectObserverCalled( + "recording-device-events", + 1, + aBrowsingContextToObserve + ) + ); + observerPromises.push( + expectObserverCalled( + "recording-window-ended", + 1, + aBrowsingContextToObserve + ) + ); + } + + info("closing the stream"); + await SpecialPowers.spawn(frameBC, [], async function () { + content.wrappedJSObject.closeStream(); + }); + + await Promise.all(observerPromises); + + await assertWebRTCIndicatorStatus(null); +} + +async function reloadAsUser() { + info("reloading as a user"); + + const reloadButton = document.getElementById("reload-button"); + await TestUtils.waitForCondition(() => !reloadButton.disabled); + // Disable observers as the page is being reloaded which can destroy + // the actors listening to the notifications. + await disableObserverVerification(); + + let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + reloadButton.click(); + await loadedPromise; + + await enableObserverVerification(); +} + +async function reloadFromContent() { + info("reloading from content"); + + // Disable observers as the page is being reloaded which can destroy + // the actors listening to the notifications. + await disableObserverVerification(); + + let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await ContentTask.spawn(gBrowser.selectedBrowser, null, () => + content.location.reload() + ); + + await loadedPromise; + + await enableObserverVerification(); +} + +async function reloadAndAssertClosedStreams() { + await reloadFromContent(); + await checkNotSharing(); +} + +/** + * @param {("microphone"|"camera"|"screen")[]} aExpectedTypes + * @param {Window} [aWindow] + */ +function checkDeviceSelectors(aExpectedTypes, aWindow = window) { + for (const type of aExpectedTypes) { + if (!["microphone", "camera", "screen", "speaker"].includes(type)) { + throw new Error(`Bad device type name ${type}`); + } + } + let document = aWindow.document; + + let expectedDescribedBy = "webRTC-shareDevices-notification-description"; + for (let type of ["Camera", "Microphone", "Speaker"]) { + let selector = document.getElementById(`webRTC-select${type}`); + if (!aExpectedTypes.includes(type.toLowerCase())) { + ok(selector.hidden, `${type} selector hidden`); + continue; + } + ok(!selector.hidden, `${type} selector visible`); + let selectorList = document.getElementById(`webRTC-select${type}-menulist`); + let label = document.getElementById( + `webRTC-select${type}-single-device-label` + ); + // If there's only 1 device listed, then we should show the label + // instead of the menulist. + if (selectorList.itemCount == 1) { + ok(selectorList.hidden, `${type} selector list should be hidden.`); + ok(!label.hidden, `${type} selector label should not be hidden.`); + is( + label.value, + selectorList.selectedItem.getAttribute("label"), + `${type} label should be showing the lone device label.` + ); + expectedDescribedBy += ` webRTC-select${type}-icon webRTC-select${type}-single-device-label`; + } else { + ok(!selectorList.hidden, `${type} selector list should not be hidden.`); + ok(label.hidden, `${type} selector label should be hidden.`); + } + } + let ariaDescribedby = + aWindow.PopupNotifications.panel.getAttribute("aria-describedby"); + is(ariaDescribedby, expectedDescribedBy, "aria-describedby"); + + let screenSelector = document.getElementById("webRTC-selectWindowOrScreen"); + if (aExpectedTypes.includes("screen")) { + ok(!screenSelector.hidden, "screen selector visible"); + } else { + ok(screenSelector.hidden, "screen selector hidden"); + } +} + +/** + * Tests the siteIdentity icons, the permission panel and the global indicator + * UI state. + * @param {Object} aExpected - Expected state for the current tab. + * @param {window} [aWin] - Top level chrome window to test state of. + * @param {Object} [aExpectedGlobal] - Expected state for all tabs. + * @param {Object} [aExpectedPerm] - Expected permission states keyed by device + * type. + */ +async function checkSharingUI( + aExpected, + aWin = window, + aExpectedGlobal = null, + aExpectedPerm = null +) { + function isPaused(streamState) { + if (typeof streamState == "string") { + return streamState.includes("Paused"); + } + return streamState == STATE_CAPTURE_DISABLED; + } + + let doc = aWin.document; + // First check the icon above the control center (i) icon. + let permissionBox = doc.getElementById("identity-permission-box"); + let webrtcSharingIcon = doc.getElementById("webrtc-sharing-icon"); + ok(webrtcSharingIcon.hasAttribute("sharing"), "sharing attribute is set"); + let sharing = webrtcSharingIcon.getAttribute("sharing"); + if (aExpected.screen) { + is(sharing, "screen", "showing screen icon in the identity block"); + } else if (aExpected.video == STATE_CAPTURE_ENABLED) { + is(sharing, "camera", "showing camera icon in the identity block"); + } else if (aExpected.audio == STATE_CAPTURE_ENABLED) { + is(sharing, "microphone", "showing mic icon in the identity block"); + } else if (aExpected.video) { + is(sharing, "camera", "showing camera icon in the identity block"); + } else if (aExpected.audio) { + is(sharing, "microphone", "showing mic icon in the identity block"); + } + + let allStreamsPaused = Object.values(aExpected).every(isPaused); + is( + webrtcSharingIcon.hasAttribute("paused"), + allStreamsPaused, + "sharing icon(s) should be in paused state when paused" + ); + + // Then check the sharing indicators inside the permission popup. + permissionBox.click(); + let popup = aWin.gPermissionPanel._permissionPopup; + // If the popup gets hidden before being shown, by stray focus/activate + // events, don't bother failing the test. It's enough to know that we + // started showing the popup. + let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown"); + await Promise.race([hiddenEvent, shownEvent]); + let permissions = doc.getElementById("permission-popup-permission-list"); + for (let id of ["microphone", "camera", "screen"]) { + let convertId = idToConvert => { + if (idToConvert == "camera") { + return "video"; + } + if (idToConvert == "microphone") { + return "audio"; + } + return idToConvert; + }; + let expected = aExpected[convertId(id)]; + + // Extract the expected permission for the device type. + // Defaults to temporary allow. + let { state, scope } = aExpectedPerm?.[convertId(id)] || {}; + if (state == null) { + state = SitePermissions.ALLOW; + } + if (scope == null) { + scope = SitePermissions.SCOPE_TEMPORARY; + } + + is( + !!aWin.gPermissionPanel._sharingState.webRTC[id], + !!expected, + "sharing state for " + id + " as expected" + ); + let item = permissions.querySelectorAll( + ".permission-popup-permission-item-" + id + ); + let stateLabel = item?.[0]?.querySelector( + ".permission-popup-permission-state-label" + ); + let icon = permissions.querySelectorAll( + ".permission-popup-permission-icon." + id + "-icon" + ); + if (expected) { + is(item.length, 1, "should show " + id + " item in permission panel"); + is( + stateLabel?.textContent, + SitePermissions.getCurrentStateLabel(state, id, scope), + "should show correct item label for " + id + ); + is(icon.length, 1, "should show " + id + " icon in permission panel"); + is( + icon[0].classList.contains("in-use"), + expected && !isPaused(expected), + "icon should have the in-use class, unless paused" + ); + } else if (!icon.length && !item.length && !stateLabel) { + ok(true, "should not show " + id + " item in the permission panel"); + ok(true, "should not show " + id + " icon in the permission panel"); + ok( + true, + "should not show " + id + " state label in the permission panel" + ); + } else { + // This will happen if there are persistent permissions set. + ok( + !icon[0].classList.contains("in-use"), + "if shown, the " + id + " icon should not have the in-use class" + ); + is(item.length, 1, "should not show more than 1 " + id + " item"); + is(icon.length, 1, "should not show more than 1 " + id + " icon"); + } + } + aWin.gPermissionPanel._permissionPopup.hidePopup(); + await TestUtils.waitForCondition( + () => permissionPopupHidden(aWin), + "identity popup should be hidden" + ); + + // Check the global indicators. + await assertWebRTCIndicatorStatus(aExpectedGlobal || aExpected); +} + +async function checkNotSharing() { + Assert.deepEqual( + await getMediaCaptureState(), + {}, + "expected nothing to be shared" + ); + + ok( + !document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"), + "no sharing indicator on the control center icon" + ); + + await assertWebRTCIndicatorStatus(null); +} + +async function checkNotSharingWithinGracePeriod() { + Assert.deepEqual( + await getMediaCaptureState(), + {}, + "expected nothing to be shared" + ); + + ok( + document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"), + "has sharing indicator on the control center icon" + ); + ok( + document.getElementById("webrtc-sharing-icon").hasAttribute("paused"), + "sharing indicator is paused" + ); + + await assertWebRTCIndicatorStatus(null); +} + +async function promiseReloadFrame(aFrameId, aBrowsingContext) { + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true, + arg => { + return true; + } + ); + let bc = + aBrowsingContext ?? + (await getBrowsingContextForFrame( + gBrowser.selectedBrowser.browsingContext, + aFrameId + )); + await SpecialPowers.spawn(bc, [], async function () { + content.location.reload(); + }); + return loadedPromise; +} + +function promiseChangeLocationFrame(aFrameId, aNewLocation) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser.browsingContext, + [{ aFrameId, aNewLocation }], + async function (args) { + let frame = content.wrappedJSObject.document.getElementById( + args.aFrameId + ); + return new Promise(resolve => { + function listener() { + frame.removeEventListener("load", listener, true); + resolve(); + } + frame.addEventListener("load", listener, true); + + content.wrappedJSObject.document.getElementById( + args.aFrameId + ).contentWindow.location = args.aNewLocation; + }); + } + ); +} + +async function openNewTestTab(leaf = "get_user_media.html") { + let rootDir = getRootDirectory(gTestPath); + rootDir = rootDir.replace( + "chrome://mochitests/content/", + "https://example.com/" + ); + let absoluteURI = rootDir + leaf; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, absoluteURI); + return tab.linkedBrowser; +} + +// Enabling observer verification adds listeners for all of the webrtc +// observer topics. If any notifications occur for those topics that +// were not explicitly requested, a failure will occur. +async function enableObserverVerification(browser = gBrowser.selectedBrowser) { + // Skip these checks in single process mode as it isn't worth implementing it. + if (!gMultiProcessBrowser) { + return; + } + + gBrowserContextsToObserve = [browser.browsingContext]; + + // A list of subframe indicies to also add observers to. This only + // supports one nested level. + if (gObserveSubFrames) { + let bcsAndFrameIds = await getBrowsingContextsAndFrameIdsForSubFrames( + browser, + gObserveSubFrames + ); + for (let { observeBC } of bcsAndFrameIds) { + if (observeBC) { + gBrowserContextsToObserve.push(observeBC); + } + } + } + + for (let bc of gBrowserContextsToObserve) { + await BrowserTestUtils.startObservingTopics(bc, observerTopics); + } +} + +async function disableObserverVerification() { + if (!gMultiProcessBrowser) { + return; + } + + for (let bc of gBrowserContextsToObserve) { + await BrowserTestUtils.stopObservingTopics(bc, observerTopics).catch( + reason => { + ok(false, "Failed " + reason); + } + ); + } +} + +function permissionPopupHidden(win = window) { + let popup = win.gPermissionPanel._permissionPopup; + return !popup || popup.state == "closed"; +} + +async function runTests(tests, options = {}) { + let browser = await openNewTestTab(options.relativeURI); + + is( + PopupNotifications._currentNotifications.length, + 0, + "should start the test without any prior popup notification" + ); + ok( + permissionPopupHidden(), + "should start the test with the permission panel hidden" + ); + + // Set prefs so that permissions prompts are shown and loopback devices + // are not used. To test the chrome we want prompts to be shown, and + // these tests are flakey when using loopback devices (though it would + // be desirable to make them work with loopback in future). See bug 1643711. + let prefs = [ + [PREF_PERMISSION_FAKE, true], + [PREF_AUDIO_LOOPBACK, ""], + [PREF_VIDEO_LOOPBACK, ""], + [PREF_FAKE_STREAMS, true], + [PREF_FOCUS_SOURCE, false], + ]; + await SpecialPowers.pushPrefEnv({ set: prefs }); + + // When the frames are in different processes, add observers to each frame, + // to ensure that the notifications don't get sent in the wrong process. + gObserveSubFrames = SpecialPowers.useRemoteSubframes ? options.subFrames : {}; + + for (let testCase of tests) { + let startTime = performance.now(); + info(testCase.desc); + if ( + !testCase.skipObserverVerification && + !options.skipObserverVerification + ) { + await enableObserverVerification(); + } + await testCase.run(browser, options.subFrames); + if ( + !testCase.skipObserverVerification && + !options.skipObserverVerification + ) { + await disableObserverVerification(); + } + if (options.cleanup) { + await options.cleanup(); + } + ChromeUtils.addProfilerMarker( + "browser-test", + { startTime, category: "Test" }, + testCase.desc + ); + } + + // Some tests destroy the original tab and leave a new one in its place. + BrowserTestUtils.removeTab(gBrowser.selectedTab); +} + +/** + * Given a browser from a tab in this window, chooses to share + * some combination of camera, mic or screen. + * + * @param {<xul:browser} browser - The browser to share devices with. + * @param {boolean} camera - True to share a camera device. + * @param {boolean} mic - True to share a microphone device. + * @param {Number} [screenOrWin] - One of either SHARE_WINDOW or SHARE_SCREEN + * to share a window or screen. Defaults to neither. + * @param {boolean} remember - True to persist the permission to the + * SitePermissions database as SitePermissions.SCOPE_PERSISTENT. Note that + * callers are responsible for clearing this persistent permission. + * @return {Promise} + * @resolves {undefined} - Once the sharing is complete. + */ +async function shareDevices( + browser, + camera, + mic, + screenOrWin = 0, + remember = false +) { + if (camera || mic) { + let promise = promisePopupNotificationShown( + "webRTC-shareDevices", + null, + window + ); + + await promiseRequestDevice(mic, camera, null, null, browser); + await promise; + + const expectedDeviceSelectorTypes = [ + camera && "camera", + mic && "microphone", + ].filter(x => x); + checkDeviceSelectors(expectedDeviceSelectorTypes); + let observerPromise1 = expectObserverCalled("getUserMedia:response:allow"); + let observerPromise2 = expectObserverCalled("recording-device-events"); + + let rememberCheck = PopupNotifications.panel.querySelector( + ".popup-notification-checkbox" + ); + rememberCheck.checked = remember; + + promise = promiseMessage("ok", () => { + PopupNotifications.panel.firstElementChild.button.click(); + }); + + await observerPromise1; + await observerPromise2; + await promise; + } + + if (screenOrWin) { + let promise = promisePopupNotificationShown( + "webRTC-shareDevices", + null, + window + ); + + await promiseRequestDevice(false, true, null, "screen", browser); + await promise; + + checkDeviceSelectors(["screen"], window); + + let document = window.document; + + let menulist = document.getElementById("webRTC-selectWindow-menulist"); + let displayMediaSource; + + if (screenOrWin == SHARE_SCREEN) { + displayMediaSource = "screen"; + } else if (screenOrWin == SHARE_WINDOW) { + displayMediaSource = "window"; + } else { + throw new Error("Got an invalid argument to shareDevices."); + } + + let menuitem = null; + for (let i = 0; i < menulist.itemCount; ++i) { + let current = menulist.getItemAtIndex(i); + if (current.mediaSource == displayMediaSource) { + menuitem = current; + break; + } + } + + Assert.ok(menuitem, "Should have found an appropriate display menuitem"); + menuitem.doCommand(); + + let notification = window.PopupNotifications.panel.firstElementChild; + + let observerPromise1 = expectObserverCalled("getUserMedia:response:allow"); + let observerPromise2 = expectObserverCalled("recording-device-events"); + await promiseMessage( + "ok", + () => { + notification.button.click(); + }, + 1, + browser + ); + await observerPromise1; + await observerPromise2; + } +} |