1 /* Any copyright is dedicated to the Public Domain.
2 * http://creativecommons.org/publicdomain/zero/1.0/ */
4 const EXAMPLE_COM_URL =
5 "https://example.com/document-builder.sjs?html=<h1>Test midi permission with synthetic site permission addon</h1>";
6 const PAGE_WITH_IFRAMES_URL = `https://example.org/document-builder.sjs?html=
7 <h1>Test midi permission with synthetic site permission addon in iframes</h1>
8 <iframe id=sameOrigin src="${encodeURIComponent(
9 'https://example.org/document-builder.sjs?html=SameOrigin"'
11 <iframe id=crossOrigin src="${encodeURIComponent(
12 'https://example.net/document-builder.sjs?html=CrossOrigin"'
15 const l10n = new Localization(
17 "browser/addonNotifications.ftl",
18 "toolkit/global/extensions.ftl",
19 "toolkit/global/extensionPermissions.ftl",
25 const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
26 ChromeUtils.defineESModuleGetters(this, {
27 AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
30 add_setup(async function () {
31 await SpecialPowers.pushPrefEnv({
32 set: [["midi.prompt.testing", false]],
35 AddonTestUtils.initMochitest(this);
36 AddonTestUtils.hookAMTelemetryEvents();
38 // Once the addon is installed, a dialog is displayed as a confirmation.
39 // This could interfere with tests running after this one, so we set up a listener
40 // that will always accept post install dialogs so we don't have to deal with them in
42 alwaysAcceptAddonPostInstallDialogs();
44 registerCleanupFunction(async () => {
45 // Remove the permission.
46 await SpecialPowers.removePermission("midi-sysex", {
49 await SpecialPowers.removePermission("midi-sysex", {
50 url: PAGE_WITH_IFRAMES_URL,
52 await SpecialPowers.removePermission("midi", {
55 await SpecialPowers.removePermission("midi", {
56 url: PAGE_WITH_IFRAMES_URL,
58 await SpecialPowers.removePermission("install", {
62 while (gBrowser.tabs.length > 1) {
63 BrowserTestUtils.removeTab(gBrowser.selectedTab);
68 add_task(async function testRequestMIDIAccess() {
69 gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, EXAMPLE_COM_URL);
70 await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
71 const testPageHost = gBrowser.selectedTab.linkedBrowser.documentURI.host;
73 info("Check that midi-sysex isn't set");
75 await SpecialPowers.testPermission(
77 SpecialPowers.Services.perms.UNKNOWN_ACTION,
78 { url: EXAMPLE_COM_URL }
80 "midi-sysex value should have UNKNOWN permission"
83 info("Request midi-sysex access");
84 let onAddonInstallBlockedNotification = waitForNotification(
85 "addon-install-blocked"
87 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
88 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
93 info("Deny site permission addon install in first popup");
94 let addonInstallPanel = await onAddonInstallBlockedNotification;
95 const [installPopupHeader, installPopupMessage] =
96 addonInstallPanel.querySelectorAll(
97 "description.popup-notification-description"
100 installPopupHeader.textContent,
101 l10n.formatValueSync("site-permission-install-first-prompt-midi-header"),
102 "First popup has expected header text"
105 installPopupMessage.textContent,
106 l10n.formatValueSync("site-permission-install-first-prompt-midi-message"),
107 "First popup has expected message"
110 let notification = addonInstallPanel.childNodes[0];
111 // secondaryButton is the "Don't allow" button
112 notification.secondaryButton.click();
114 let rejectionMessage = await SpecialPowers.spawn(
115 gBrowser.selectedBrowser,
120 await content.midiAccessRequestPromise;
122 errorMessage = `${e.name}: ${e.message}`;
125 delete content.midiAccessRequestPromise;
131 "SecurityError: WebMIDI requires a site permission add-on to activate"
134 assertSitePermissionInstallTelemetryEvents(["site_warning", "cancelled"]);
136 info("Deny site permission addon install in second popup");
137 onAddonInstallBlockedNotification = waitForNotification(
138 "addon-install-blocked"
140 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
141 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
145 addonInstallPanel = await onAddonInstallBlockedNotification;
146 notification = addonInstallPanel.childNodes[0];
147 let dialogPromise = waitForInstallDialog();
148 notification.button.click();
149 let installDialog = await dialogPromise;
151 installDialog.querySelector(".popup-notification-description").textContent,
152 l10n.formatValueSync(
153 "webext-site-perms-header-with-gated-perms-midi-sysex",
154 { hostname: testPageHost }
156 "Install dialog has expected header text"
159 installDialog.querySelector("popupnotificationcontent description")
161 l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"),
162 "Install dialog has expected description"
165 // secondaryButton is the "Cancel" button
166 installDialog.secondaryButton.click();
168 rejectionMessage = await SpecialPowers.spawn(
169 gBrowser.selectedBrowser,
174 await content.midiAccessRequestPromise;
176 errorMessage = `${e.name}: ${e.message}`;
179 delete content.midiAccessRequestPromise;
185 "SecurityError: WebMIDI requires a site permission add-on to activate"
188 assertSitePermissionInstallTelemetryEvents([
190 "permissions_prompt",
194 info("Request midi-sysex access again");
195 onAddonInstallBlockedNotification = waitForNotification(
196 "addon-install-blocked"
198 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
199 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
204 info("Accept site permission addon install");
205 addonInstallPanel = await onAddonInstallBlockedNotification;
206 notification = addonInstallPanel.childNodes[0];
207 dialogPromise = waitForInstallDialog();
208 notification.button.click();
209 installDialog = await dialogPromise;
210 installDialog.button.click();
212 info("Wait for the midi-sysex access request promise to resolve");
213 let accessGranted = await SpecialPowers.spawn(
214 gBrowser.selectedBrowser,
218 await content.midiAccessRequestPromise;
222 delete content.midiAccessRequestPromise;
226 ok(accessGranted, "requestMIDIAccess resolved");
228 info("Check that midi-sysex is now set");
230 await SpecialPowers.testPermission(
232 SpecialPowers.Services.perms.ALLOW_ACTION,
233 { url: EXAMPLE_COM_URL }
235 "midi-sysex value should have ALLOW permission"
238 await SpecialPowers.testPermission(
240 SpecialPowers.Services.perms.UNKNOWN_ACTION,
241 { url: EXAMPLE_COM_URL }
243 "but midi should have UNKNOWN permission"
246 info("Check that we don't prompt user again once they installed the addon");
247 const accessPromiseState = await SpecialPowers.spawn(
248 gBrowser.selectedBrowser,
251 return content.navigator
252 .requestMIDIAccess({ sysex: true })
253 .then(() => "resolved");
259 "requestMIDIAccess resolved without user prompt"
262 assertSitePermissionInstallTelemetryEvents([
264 "permissions_prompt",
268 info("Request midi access without sysex");
269 onAddonInstallBlockedNotification = waitForNotification(
270 "addon-install-blocked"
272 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
273 content.midiNoSysexAccessRequestPromise =
274 content.navigator.requestMIDIAccess();
277 info("Accept site permission addon install");
278 addonInstallPanel = await onAddonInstallBlockedNotification;
279 notification = addonInstallPanel.childNodes[0];
283 .querySelector("#addon-install-blocked-info")
284 .getAttribute("href"),
285 Services.urlFormatter.formatURLPref("app.support.baseURL") +
286 "site-permission-addons",
287 "Got the expected SUMO page as a learn more link in the addon-install-blocked panel"
290 dialogPromise = waitForInstallDialog();
291 notification.button.click();
292 installDialog = await dialogPromise;
295 installDialog.querySelector(".popup-notification-description").textContent,
296 l10n.formatValueSync("webext-site-perms-header-with-gated-perms-midi", {
297 hostname: testPageHost,
299 "Install dialog has expected header text"
302 installDialog.querySelector("popupnotificationcontent description")
304 l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"),
305 "Install dialog has expected description"
308 installDialog.button.click();
310 info("Wait for the midi access request promise to resolve");
311 accessGranted = await SpecialPowers.spawn(
312 gBrowser.selectedBrowser,
316 await content.midiNoSysexAccessRequestPromise;
320 delete content.midiNoSysexAccessRequestPromise;
324 ok(accessGranted, "requestMIDIAccess resolved");
326 info("Check that both midi-sysex and midi are now set");
328 await SpecialPowers.testPermission(
330 SpecialPowers.Services.perms.ALLOW_ACTION,
331 { url: EXAMPLE_COM_URL }
333 "midi-sysex value should have ALLOW permission"
336 await SpecialPowers.testPermission(
338 SpecialPowers.Services.perms.ALLOW_ACTION,
339 { url: EXAMPLE_COM_URL }
341 "and midi value should also have ALLOW permission"
344 assertSitePermissionInstallTelemetryEvents([
346 "permissions_prompt",
350 info("Check that we don't prompt user again when they perm denied");
351 // remove permission to have a clean state
352 await SpecialPowers.removePermission("midi-sysex", {
353 url: EXAMPLE_COM_URL,
356 onAddonInstallBlockedNotification = waitForNotification(
357 "addon-install-blocked"
359 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
360 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
365 info("Perm-deny site permission addon install");
366 addonInstallPanel = await onAddonInstallBlockedNotification;
367 // Click the "Report Suspicious Site" menuitem, which has the same effect as
368 // "Never Allow" and also submits a telemetry event (which we check below).
369 notification.menupopup.querySelectorAll("menuitem")[1].click();
371 rejectionMessage = await SpecialPowers.spawn(
372 gBrowser.selectedBrowser,
377 await content.midiAccessRequestPromise;
379 errorMessage = e.name;
382 delete content.midiAccessRequestPromise;
386 is(rejectionMessage, "SecurityError", "requestMIDIAccess was rejected");
388 info("Request midi-sysex access again");
389 let denyIntervalStart = performance.now();
390 rejectionMessage = await SpecialPowers.spawn(
391 gBrowser.selectedBrowser,
396 await content.navigator.requestMIDIAccess({
400 errorMessage = e.name;
408 "requestMIDIAccess was rejected without user prompt"
410 let denyIntervalElapsed = performance.now() - denyIntervalStart;
412 denyIntervalElapsed >= 3000,
413 `Rejection should be delayed by a randomized interval no less than 3 seconds (got ${
414 denyIntervalElapsed / 1000
418 // Invoking getAMTelemetryEvents resets the mocked event array, and we want
419 // to test two different things here, so we cache it.
420 let events = AddonTestUtils.getAMTelemetryEvents();
422 events.filter(evt => evt.method == "reportSuspiciousSite")[0],
424 method: "reportSuspiciousSite",
425 object: "suspiciousSite",
426 value: "example.com",
430 assertSitePermissionInstallTelemetryEvents(
431 ["site_warning", "cancelled"],
436 add_task(async function testIframeRequestMIDIAccess() {
437 gBrowser.selectedTab = BrowserTestUtils.addTab(
439 PAGE_WITH_IFRAMES_URL
441 await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
443 info("Check that midi-sysex isn't set");
445 await SpecialPowers.testPermission(
447 SpecialPowers.Services.perms.UNKNOWN_ACTION,
448 { url: PAGE_WITH_IFRAMES_URL }
450 "midi-sysex value should have UNKNOWN permission"
453 info("Request midi-sysex access from the same-origin iframe");
454 const sameOriginIframeBrowsingContext = await SpecialPowers.spawn(
455 gBrowser.selectedBrowser,
458 return content.document.getElementById("sameOrigin").browsingContext;
462 let onAddonInstallBlockedNotification = waitForNotification(
463 "addon-install-blocked"
465 await SpecialPowers.spawn(sameOriginIframeBrowsingContext, [], () => {
466 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
471 info("Accept site permission addon install");
472 const addonInstallPanel = await onAddonInstallBlockedNotification;
473 const notification = addonInstallPanel.childNodes[0];
474 const dialogPromise = waitForInstallDialog();
475 notification.button.click();
476 let installDialog = await dialogPromise;
477 installDialog.button.click();
479 info("Wait for the midi-sysex access request promise to resolve");
480 const accessGranted = await SpecialPowers.spawn(
481 sameOriginIframeBrowsingContext,
485 await content.midiAccessRequestPromise;
489 delete content.midiAccessRequestPromise;
493 ok(accessGranted, "requestMIDIAccess resolved");
495 info("Check that midi-sysex is now set");
497 await SpecialPowers.testPermission(
499 SpecialPowers.Services.perms.ALLOW_ACTION,
500 { url: PAGE_WITH_IFRAMES_URL }
502 "midi-sysex value should have ALLOW permission"
506 "Check that we don't prompt user again once they installed the addon from the same-origin iframe"
508 const accessPromiseState = await SpecialPowers.spawn(
509 gBrowser.selectedBrowser,
512 return content.navigator
513 .requestMIDIAccess({ sysex: true })
514 .then(() => "resolved");
520 "requestMIDIAccess resolved without user prompt"
523 assertSitePermissionInstallTelemetryEvents([
525 "permissions_prompt",
529 info("Check that request is rejected when done from a cross-origin iframe");
530 const crossOriginIframeBrowsingContext = await SpecialPowers.spawn(
531 gBrowser.selectedBrowser,
534 return content.document.getElementById("crossOrigin").browsingContext;
538 const onConsoleErrorMessage = new Promise(resolve => {
539 const errorListener = {
541 if (error.message.includes("WebMIDI access request was denied")) {
543 Services.console.unregisterListener(errorListener);
547 Services.console.registerListener(errorListener);
550 const rejectionMessage = await SpecialPowers.spawn(
551 crossOriginIframeBrowsingContext,
556 await content.navigator.requestMIDIAccess({
569 "requestMIDIAccess from the remote iframe was rejected"
572 const consoleErrorMessage = await onConsoleErrorMessage;
574 consoleErrorMessage.message.includes(
575 `WebMIDI access request was denied: ❝SitePermsAddons can't be installed from cross origin subframes❞`,
576 "an error message is sent to the console"
579 assertSitePermissionInstallTelemetryEvents([]);
582 add_task(async function testRequestMIDIAccessLocalhost() {
583 const httpServer = new HttpServer();
584 httpServer.start(-1);
585 httpServer.registerPathHandler(`/test`, function (request, response) {
586 response.setStatusLine(request.httpVersion, 200, "OK");
590 <h1>Test requestMIDIAccess on lcoalhost</h1>`);
592 const localHostTestUrl = `http://localhost:${httpServer.identity.primaryPort}/test`;
594 registerCleanupFunction(async function cleanup() {
595 await new Promise(resolve => httpServer.stop(resolve));
598 gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, localHostTestUrl);
599 await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
601 info("Check that midi-sysex isn't set");
603 await SpecialPowers.testPermission(
605 SpecialPowers.Services.perms.UNKNOWN_ACTION,
606 { url: localHostTestUrl }
608 "midi-sysex value should have UNKNOWN permission"
612 "Request midi-sysex access should not prompt for addon install on locahost, but for permission"
614 let popupShown = BrowserTestUtils.waitForEvent(
615 PopupNotifications.panel,
618 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
619 content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
625 PopupNotifications.panel.querySelector("popupnotification").id,
627 "midi notification was displayed"
630 info("Accept permission");
631 PopupNotifications.panel
632 .querySelector(".popup-notification-primary-button")
635 info("Wait for the midi-sysex access request promise to resolve");
636 const accessGranted = await SpecialPowers.spawn(
637 gBrowser.selectedBrowser,
641 await content.midiAccessRequestPromise;
645 delete content.midiAccessRequestPromise;
649 ok(accessGranted, "requestMIDIAccess resolved");
651 info("Check that we prompt user again even if they accepted before");
652 popupShown = BrowserTestUtils.waitForEvent(
653 PopupNotifications.panel,
656 await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
657 content.navigator.requestMIDIAccess({ sysex: true });
661 PopupNotifications.panel.querySelector("popupnotification").id,
663 "midi notification was displayed again"
666 assertSitePermissionInstallTelemetryEvents([]);
669 add_task(async function testDisabledRequestMIDIAccessFile() {
670 let dir = getChromeDir(getResolvedURI(gTestPath));
671 dir.append("blank.html");
672 const fileSchemeTestUri = Services.io.newFileURI(dir).spec;
674 gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, fileSchemeTestUri);
675 await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
677 info("Check that requestMIDIAccess isn't set on navigator on file scheme");
678 const isRequestMIDIAccessDefined = await SpecialPowers.spawn(
679 gBrowser.selectedBrowser,
682 return "requestMIDIAccess" in content.wrappedJSObject.navigator;
686 isRequestMIDIAccessDefined,
688 "navigator.requestMIDIAccess is not defined on file scheme"
692 // Ignore any additional telemetry events collected in this file.
693 // Unfortunately it doesn't work to have this in a cleanup function.
694 // Keep this as the last task done.
695 add_task(function teardown_telemetry_events() {
696 AddonTestUtils.getAMTelemetryEvents();
700 * Check that the expected sitepermission install events are recorded.
702 * @param {Array<String>} expectedSteps: An array of the expected extra.step values recorded.
704 function assertSitePermissionInstallTelemetryEvents(
708 let amInstallEvents = (events ?? AddonTestUtils.getAMTelemetryEvents())
709 .filter(evt => evt.method === "install" && evt.object === "sitepermission")
710 .map(evt => evt.extra.step);
712 Assert.deepEqual(amInstallEvents, expectedSteps);
715 async function waitForInstallDialog(id = "addon-webext-permissions") {
716 let panel = await waitForNotification(id);
717 return panel.childNodes[0];
721 * Adds an event listener that will listen for post-install dialog event and automatically
724 function alwaysAcceptAddonPostInstallDialogs() {
725 // Once the addon is installed, a dialog is displayed as a confirmation.
726 // This could interfere with tests running after this one, so we set up a listener
727 // that will always accept post install dialogs so we don't have to deal with them in
729 const abortController = new AbortController();
731 const { AppMenuNotifications } = ChromeUtils.importESModule(
732 "resource://gre/modules/AppMenuNotifications.sys.mjs"
734 info("Start listening and accept addon post-install notifications");
735 PanelUI.notificationPanel.addEventListener(
737 async function popupshown() {
738 let notification = AppMenuNotifications.activeNotification;
739 if (!notification || notification.id !== "addon-installed") {
743 let popupnotificationID = PanelUI._getPopupId(notification);
744 if (popupnotificationID) {
745 info("Accept post-install dialog");
746 let popupnotification = document.getElementById(popupnotificationID);
747 popupnotification?.button.click();
751 signal: abortController.signal,
755 registerCleanupFunction(async () => {
756 // Clear the listener at the end of the test file, to prevent it to stay
757 // around when the same browser instance may be running other unrelated
759 abortController.abort();
763 const PROGRESS_NOTIFICATION = "addon-progress";
764 async function waitForNotification(notificationId) {
765 info(`Waiting for ${notificationId} notification`);
767 let topic = getObserverTopic(notificationId);
770 if (notificationId !== "addon-webext-permissions") {
771 observerPromise = new Promise(resolve => {
772 Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
773 // Ignore the progress notification unless that is the notification we want
775 notificationId != PROGRESS_NOTIFICATION &&
776 aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
780 Services.obs.removeObserver(observer, topic);
786 let panelEventPromise = new Promise(resolve => {
787 window.PopupNotifications.panel.addEventListener(
789 function eventListener(e) {
790 // Skip notifications that are not the one that we are supposed to be looking for
791 if (!e.detail.includes(notificationId)) {
794 window.PopupNotifications.panel.removeEventListener(
803 await observerPromise;
804 await panelEventPromise;
807 info(`Saw a ${notificationId} notification`);
808 await SimpleTest.promiseFocus(window.PopupNotifications.window);
809 return window.PopupNotifications.panel;
812 // This function is similar to the one in
813 // toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js,
814 // please keep both in sync!
815 function getObserverTopic(aNotificationId) {
816 let topic = aNotificationId;
817 if (topic == "xpinstall-disabled") {
818 topic = "addon-install-disabled";
819 } else if (topic == "addon-progress") {
820 topic = "addon-install-started";
821 } else if (topic == "addon-installed") {
822 topic = "webextension-install-notify";
827 function waitForTick() {
828 return new Promise(resolve => executeSoon(resolve));