summaryrefslogtreecommitdiffstats
path: root/dom/midi/tests
diff options
context:
space:
mode:
Diffstat (limited to 'dom/midi/tests')
-rw-r--r--dom/midi/tests/MIDITestUtils.js94
-rw-r--r--dom/midi/tests/blank.html5
-rw-r--r--dom/midi/tests/browser.ini24
-rw-r--r--dom/midi/tests/browser_midi_permission_gated.js829
-rw-r--r--dom/midi/tests/browser_refresh_port_list.js71
-rw-r--r--dom/midi/tests/browser_stable_midi_port_ids.js52
-rw-r--r--dom/midi/tests/file_midi_permission_gated.html15
-rw-r--r--dom/midi/tests/mochitest.ini25
-rw-r--r--dom/midi/tests/port_ids_page_1.html17
-rw-r--r--dom/midi/tests/port_ids_page_2.html17
-rw-r--r--dom/midi/tests/refresh_port_list.html49
-rw-r--r--dom/midi/tests/test_midi_device_connect_disconnect.html54
-rw-r--r--dom/midi/tests/test_midi_device_enumeration.html46
-rw-r--r--dom/midi/tests/test_midi_device_explicit_open_close.html94
-rw-r--r--dom/midi/tests/test_midi_device_implicit_open_close.html54
-rw-r--r--dom/midi/tests/test_midi_device_pending.html118
-rw-r--r--dom/midi/tests/test_midi_device_sysex.html57
-rw-r--r--dom/midi/tests/test_midi_device_system_rt.html39
-rw-r--r--dom/midi/tests/test_midi_message_event.html45
-rw-r--r--dom/midi/tests/test_midi_packet_timing_sorting.html47
-rw-r--r--dom/midi/tests/test_midi_permission_allow.html26
-rw-r--r--dom/midi/tests/test_midi_permission_deny.html26
-rw-r--r--dom/midi/tests/test_midi_permission_gated.html181
-rw-r--r--dom/midi/tests/test_midi_permission_prompt.html24
-rw-r--r--dom/midi/tests/test_midi_send_messages.html112
25 files changed, 2121 insertions, 0 deletions
diff --git a/dom/midi/tests/MIDITestUtils.js b/dom/midi/tests/MIDITestUtils.js
new file mode 100644
index 0000000000..779a961991
--- /dev/null
+++ b/dom/midi/tests/MIDITestUtils.js
@@ -0,0 +1,94 @@
+var MIDITestUtils = {
+ permissionSetup: allow => {
+ let permPromiseRes;
+ let permPromise = new Promise((res, rej) => {
+ permPromiseRes = res;
+ });
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["dom.webmidi.enabled", true],
+ ["midi.testing", true],
+ ["midi.prompt.testing", true],
+ ["media.navigator.permission.disabled", allow],
+ ],
+ },
+ () => {
+ permPromiseRes();
+ }
+ );
+ return permPromise;
+ },
+ // This list needs to stay synced with the ports in
+ // dom/midi/TestMIDIPlatformService.
+ inputInfo: {
+ get id() {
+ return MIDITestUtils.stableId(this);
+ },
+ name: "Test Control MIDI Device Input Port",
+ manufacturer: "Test Manufacturer",
+ version: "1.0.0",
+ },
+ outputInfo: {
+ get id() {
+ return MIDITestUtils.stableId(this);
+ },
+ name: "Test Control MIDI Device Output Port",
+ manufacturer: "Test Manufacturer",
+ version: "1.0.0",
+ },
+ stateTestInputInfo: {
+ get id() {
+ return MIDITestUtils.stableId(this);
+ },
+ name: "Test State MIDI Device Input Port",
+ manufacturer: "Test Manufacturer",
+ version: "1.0.0",
+ },
+ stateTestOutputInfo: {
+ get id() {
+ return MIDITestUtils.stableId(this);
+ },
+ name: "Test State MIDI Device Output Port",
+ manufacturer: "Test Manufacturer",
+ version: "1.0.0",
+ },
+ alwaysClosedTestOutputInfo: {
+ get id() {
+ return MIDITestUtils.stableId(this);
+ },
+ name: "Always Closed MIDI Device Output Port",
+ manufacturer: "Test Manufacturer",
+ version: "1.0.0",
+ },
+ checkPacket: (expected, actual) => {
+ if (expected.length != actual.length) {
+ ok(false, "Packet " + expected + " length not same as packet " + actual);
+ }
+ for (var i = 0; i < expected.length; ++i) {
+ is(expected[i], actual[i], "Packet value " + expected[i] + " matches.");
+ }
+ },
+ stableId: async info => {
+ // This computes the stable ID of a MIDI port according to the logic we
+ // use in the Web MIDI implementation. See MIDIPortChild::GenerateStableId()
+ // and nsContentUtils::AnonymizeId().
+ const id = info.name + info.manufacturer + info.version;
+ const encoder = new TextEncoder();
+ const data = encoder.encode(id);
+ const keyBytes = encoder.encode(self.origin);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ keyBytes,
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const result = new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
+ let resultString = "";
+ for (let i = 0; i < result.length; i++) {
+ resultString += String.fromCharCode(result[i]);
+ }
+ return btoa(resultString);
+ },
+};
diff --git a/dom/midi/tests/blank.html b/dom/midi/tests/blank.html
new file mode 100644
index 0000000000..7fdd0621f6
--- /dev/null
+++ b/dom/midi/tests/blank.html
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<meta charset="utf8">
+<html>
+<body></body>
+</html>
diff --git a/dom/midi/tests/browser.ini b/dom/midi/tests/browser.ini
new file mode 100644
index 0000000000..f971c4fec6
--- /dev/null
+++ b/dom/midi/tests/browser.ini
@@ -0,0 +1,24 @@
+[DEFAULT]
+prefs =
+ dom.webmidi.enabled=true
+ midi.testing=true
+ midi.prompt.testing=true
+ media.navigator.permission.disabled=true
+ dom.sitepermsaddon-provider.enabled=true
+
+[browser_midi_permission_gated.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+support-files =
+ blank.html
+
+[browser_stable_midi_port_ids.js]
+run-if = (os != 'android')
+support-files =
+ port_ids_page_1.html
+ port_ids_page_2.html
+
+[browser_refresh_port_list.js]
+run-if = (os != 'android')
+support-files =
+ refresh_port_list.html
diff --git a/dom/midi/tests/browser_midi_permission_gated.js b/dom/midi/tests/browser_midi_permission_gated.js
new file mode 100644
index 0000000000..2367e8ec90
--- /dev/null
+++ b/dom/midi/tests/browser_midi_permission_gated.js
@@ -0,0 +1,829 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const EXAMPLE_COM_URL =
+ "https://example.com/document-builder.sjs?html=<h1>Test midi permission with synthetic site permission addon</h1>";
+const PAGE_WITH_IFRAMES_URL = `https://example.org/document-builder.sjs?html=
+ <h1>Test midi permission with synthetic site permission addon in iframes</h1>
+ <iframe id=sameOrigin src="${encodeURIComponent(
+ 'https://example.org/document-builder.sjs?html=SameOrigin"'
+ )}"></iframe>
+ <iframe id=crossOrigin src="${encodeURIComponent(
+ 'https://example.net/document-builder.sjs?html=CrossOrigin"'
+ )}"></iframe>`;
+
+const l10n = new Localization(
+ [
+ "browser/addonNotifications.ftl",
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "branding/brand.ftl",
+ ],
+ true
+);
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+ChromeUtils.defineESModuleGetters(this, {
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["midi.prompt.testing", false]],
+ });
+
+ AddonTestUtils.initMochitest(this);
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ // Once the addon is installed, a dialog is displayed as a confirmation.
+ // This could interfere with tests running after this one, so we set up a listener
+ // that will always accept post install dialogs so we don't have to deal with them in
+ // the test.
+ alwaysAcceptAddonPostInstallDialogs();
+
+ registerCleanupFunction(async () => {
+ // Remove the permission.
+ await SpecialPowers.removePermission("midi-sysex", {
+ url: EXAMPLE_COM_URL,
+ });
+ await SpecialPowers.removePermission("midi-sysex", {
+ url: PAGE_WITH_IFRAMES_URL,
+ });
+ await SpecialPowers.removePermission("midi", {
+ url: EXAMPLE_COM_URL,
+ });
+ await SpecialPowers.removePermission("midi", {
+ url: PAGE_WITH_IFRAMES_URL,
+ });
+ await SpecialPowers.removePermission("install", {
+ url: EXAMPLE_COM_URL,
+ });
+
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ });
+});
+
+add_task(async function testRequestMIDIAccess() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, EXAMPLE_COM_URL);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ const testPageHost = gBrowser.selectedTab.linkedBrowser.documentURI.host;
+
+ info("Check that midi-sysex isn't set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_COM_URL }
+ ),
+ "midi-sysex value should have UNKNOWN permission"
+ );
+
+ info("Request midi-sysex access");
+ let onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+
+ info("Deny site permission addon install in first popup");
+ let addonInstallPanel = await onAddonInstallBlockedNotification;
+ const [installPopupHeader, installPopupMessage] =
+ addonInstallPanel.querySelectorAll(
+ "description.popup-notification-description"
+ );
+ is(
+ installPopupHeader.textContent,
+ l10n.formatValueSync("site-permission-install-first-prompt-midi-header"),
+ "First popup has expected header text"
+ );
+ is(
+ installPopupMessage.textContent,
+ l10n.formatValueSync("site-permission-install-first-prompt-midi-message"),
+ "First popup has expected message"
+ );
+
+ let notification = addonInstallPanel.childNodes[0];
+ // secondaryButton is the "Don't allow" button
+ notification.secondaryButton.click();
+
+ let rejectionMessage = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ let errorMessage;
+ try {
+ await content.midiAccessRequestPromise;
+ } catch (e) {
+ errorMessage = `${e.name}: ${e.message}`;
+ }
+
+ delete content.midiAccessRequestPromise;
+ return errorMessage;
+ }
+ );
+ is(
+ rejectionMessage,
+ "SecurityError: WebMIDI requires a site permission add-on to activate"
+ );
+
+ assertSitePermissionInstallTelemetryEvents(["site_warning", "cancelled"]);
+
+ info("Deny site permission addon install in second popup");
+ onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+ addonInstallPanel = await onAddonInstallBlockedNotification;
+ notification = addonInstallPanel.childNodes[0];
+ let dialogPromise = waitForInstallDialog();
+ notification.button.click();
+ let installDialog = await dialogPromise;
+ is(
+ installDialog.querySelector(".popup-notification-description").textContent,
+ l10n.formatValueSync(
+ "webext-site-perms-header-with-gated-perms-midi-sysex",
+ { hostname: testPageHost }
+ ),
+ "Install dialog has expected header text"
+ );
+ is(
+ installDialog.querySelector("popupnotificationcontent description")
+ .textContent,
+ l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"),
+ "Install dialog has expected description"
+ );
+
+ // secondaryButton is the "Cancel" button
+ installDialog.secondaryButton.click();
+
+ rejectionMessage = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ let errorMessage;
+ try {
+ await content.midiAccessRequestPromise;
+ } catch (e) {
+ errorMessage = `${e.name}: ${e.message}`;
+ }
+
+ delete content.midiAccessRequestPromise;
+ return errorMessage;
+ }
+ );
+ is(
+ rejectionMessage,
+ "SecurityError: WebMIDI requires a site permission add-on to activate"
+ );
+
+ assertSitePermissionInstallTelemetryEvents([
+ "site_warning",
+ "permissions_prompt",
+ "cancelled",
+ ]);
+
+ info("Request midi-sysex access again");
+ onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+
+ info("Accept site permission addon install");
+ addonInstallPanel = await onAddonInstallBlockedNotification;
+ notification = addonInstallPanel.childNodes[0];
+ dialogPromise = waitForInstallDialog();
+ notification.button.click();
+ installDialog = await dialogPromise;
+ installDialog.button.click();
+
+ info("Wait for the midi-sysex access request promise to resolve");
+ let accessGranted = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ try {
+ await content.midiAccessRequestPromise;
+ return true;
+ } catch (e) {}
+
+ delete content.midiAccessRequestPromise;
+ return false;
+ }
+ );
+ ok(accessGranted, "requestMIDIAccess resolved");
+
+ info("Check that midi-sysex is now set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: EXAMPLE_COM_URL }
+ ),
+ "midi-sysex value should have ALLOW permission"
+ );
+ ok(
+ await SpecialPowers.testPermission(
+ "midi",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_COM_URL }
+ ),
+ "but midi should have UNKNOWN permission"
+ );
+
+ info("Check that we don't prompt user again once they installed the addon");
+ const accessPromiseState = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ return content.navigator
+ .requestMIDIAccess({ sysex: true })
+ .then(() => "resolved");
+ }
+ );
+ is(
+ accessPromiseState,
+ "resolved",
+ "requestMIDIAccess resolved without user prompt"
+ );
+
+ assertSitePermissionInstallTelemetryEvents([
+ "site_warning",
+ "permissions_prompt",
+ "completed",
+ ]);
+
+ info("Request midi access without sysex");
+ onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiNoSysexAccessRequestPromise =
+ content.navigator.requestMIDIAccess();
+ });
+
+ info("Accept site permission addon install");
+ addonInstallPanel = await onAddonInstallBlockedNotification;
+ notification = addonInstallPanel.childNodes[0];
+
+ is(
+ notification
+ .querySelector("#addon-install-blocked-info")
+ .getAttribute("href"),
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "site-permission-addons",
+ "Got the expected SUMO page as a learn more link in the addon-install-blocked panel"
+ );
+
+ dialogPromise = waitForInstallDialog();
+ notification.button.click();
+ installDialog = await dialogPromise;
+
+ is(
+ installDialog.querySelector(".popup-notification-description").textContent,
+ l10n.formatValueSync("webext-site-perms-header-with-gated-perms-midi", {
+ hostname: testPageHost,
+ }),
+ "Install dialog has expected header text"
+ );
+ is(
+ installDialog.querySelector("popupnotificationcontent description")
+ .textContent,
+ l10n.formatValueSync("webext-site-perms-description-gated-perms-midi"),
+ "Install dialog has expected description"
+ );
+
+ installDialog.button.click();
+
+ info("Wait for the midi access request promise to resolve");
+ accessGranted = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ try {
+ await content.midiNoSysexAccessRequestPromise;
+ return true;
+ } catch (e) {}
+
+ delete content.midiNoSysexAccessRequestPromise;
+ return false;
+ }
+ );
+ ok(accessGranted, "requestMIDIAccess resolved");
+
+ info("Check that both midi-sysex and midi are now set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: EXAMPLE_COM_URL }
+ ),
+ "midi-sysex value should have ALLOW permission"
+ );
+ ok(
+ await SpecialPowers.testPermission(
+ "midi",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: EXAMPLE_COM_URL }
+ ),
+ "and midi value should also have ALLOW permission"
+ );
+
+ assertSitePermissionInstallTelemetryEvents([
+ "site_warning",
+ "permissions_prompt",
+ "completed",
+ ]);
+
+ info("Check that we don't prompt user again when they perm denied");
+ // remove permission to have a clean state
+ await SpecialPowers.removePermission("midi-sysex", {
+ url: EXAMPLE_COM_URL,
+ });
+
+ onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+
+ info("Perm-deny site permission addon install");
+ addonInstallPanel = await onAddonInstallBlockedNotification;
+ // Click the "Report Suspicious Site" menuitem, which has the same effect as
+ // "Never Allow" and also submits a telemetry event (which we check below).
+ notification.menupopup.querySelectorAll("menuitem")[1].click();
+
+ rejectionMessage = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ let errorMessage;
+ try {
+ await content.midiAccessRequestPromise;
+ } catch (e) {
+ errorMessage = e.name;
+ }
+
+ delete content.midiAccessRequestPromise;
+ return errorMessage;
+ }
+ );
+ is(rejectionMessage, "SecurityError", "requestMIDIAccess was rejected");
+
+ info("Request midi-sysex access again");
+ let denyIntervalStart = performance.now();
+ rejectionMessage = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ let errorMessage;
+ try {
+ await content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ } catch (e) {
+ errorMessage = e.name;
+ }
+ return errorMessage;
+ }
+ );
+ is(
+ rejectionMessage,
+ "SecurityError",
+ "requestMIDIAccess was rejected without user prompt"
+ );
+ let denyIntervalElapsed = performance.now() - denyIntervalStart;
+ ok(
+ denyIntervalElapsed >= 3000,
+ `Rejection should be delayed by a randomized interval no less than 3 seconds (got ${
+ denyIntervalElapsed / 1000
+ } seconds)`
+ );
+
+ // Invoking getAMTelemetryEvents resets the mocked event array, and we want
+ // to test two different things here, so we cache it.
+ let events = AddonTestUtils.getAMTelemetryEvents();
+ Assert.deepEqual(
+ events.filter(evt => evt.method == "reportSuspiciousSite")[0],
+ {
+ method: "reportSuspiciousSite",
+ object: "suspiciousSite",
+ value: "example.com",
+ extra: undefined,
+ }
+ );
+ assertSitePermissionInstallTelemetryEvents(
+ ["site_warning", "cancelled"],
+ events
+ );
+});
+
+add_task(async function testIframeRequestMIDIAccess() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ PAGE_WITH_IFRAMES_URL
+ );
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info("Check that midi-sysex isn't set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: PAGE_WITH_IFRAMES_URL }
+ ),
+ "midi-sysex value should have UNKNOWN permission"
+ );
+
+ info("Request midi-sysex access from the same-origin iframe");
+ const sameOriginIframeBrowsingContext = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ return content.document.getElementById("sameOrigin").browsingContext;
+ }
+ );
+
+ let onAddonInstallBlockedNotification = waitForNotification(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(sameOriginIframeBrowsingContext, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+
+ info("Accept site permission addon install");
+ const addonInstallPanel = await onAddonInstallBlockedNotification;
+ const notification = addonInstallPanel.childNodes[0];
+ const dialogPromise = waitForInstallDialog();
+ notification.button.click();
+ let installDialog = await dialogPromise;
+ installDialog.button.click();
+
+ info("Wait for the midi-sysex access request promise to resolve");
+ const accessGranted = await SpecialPowers.spawn(
+ sameOriginIframeBrowsingContext,
+ [],
+ async () => {
+ try {
+ await content.midiAccessRequestPromise;
+ return true;
+ } catch (e) {}
+
+ delete content.midiAccessRequestPromise;
+ return false;
+ }
+ );
+ ok(accessGranted, "requestMIDIAccess resolved");
+
+ info("Check that midi-sysex is now set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: PAGE_WITH_IFRAMES_URL }
+ ),
+ "midi-sysex value should have ALLOW permission"
+ );
+
+ info(
+ "Check that we don't prompt user again once they installed the addon from the same-origin iframe"
+ );
+ const accessPromiseState = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ return content.navigator
+ .requestMIDIAccess({ sysex: true })
+ .then(() => "resolved");
+ }
+ );
+ is(
+ accessPromiseState,
+ "resolved",
+ "requestMIDIAccess resolved without user prompt"
+ );
+
+ assertSitePermissionInstallTelemetryEvents([
+ "site_warning",
+ "permissions_prompt",
+ "completed",
+ ]);
+
+ info("Check that request is rejected when done from a cross-origin iframe");
+ const crossOriginIframeBrowsingContext = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ return content.document.getElementById("crossOrigin").browsingContext;
+ }
+ );
+
+ const onConsoleErrorMessage = new Promise(resolve => {
+ const errorListener = {
+ observe(error) {
+ if (error.message.includes("WebMIDI access request was denied")) {
+ resolve(error);
+ Services.console.unregisterListener(errorListener);
+ }
+ },
+ };
+ Services.console.registerListener(errorListener);
+ });
+
+ const rejectionMessage = await SpecialPowers.spawn(
+ crossOriginIframeBrowsingContext,
+ [],
+ async () => {
+ let errorName;
+ try {
+ await content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ } catch (e) {
+ errorName = e.name;
+ }
+ return errorName;
+ }
+ );
+
+ is(
+ rejectionMessage,
+ "SecurityError",
+ "requestMIDIAccess from the remote iframe was rejected"
+ );
+
+ const consoleErrorMessage = await onConsoleErrorMessage;
+ ok(
+ consoleErrorMessage.message.includes(
+ `WebMIDI access request was denied: ❝SitePermsAddons can't be installed from cross origin subframes❞`,
+ "an error message is sent to the console"
+ )
+ );
+ assertSitePermissionInstallTelemetryEvents([]);
+});
+
+add_task(async function testRequestMIDIAccessLocalhost() {
+ const httpServer = new HttpServer();
+ httpServer.start(-1);
+ httpServer.registerPathHandler(`/test`, function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(`
+ <!DOCTYPE html>
+ <meta charset=utf8>
+ <h1>Test requestMIDIAccess on lcoalhost</h1>`);
+ });
+ const localHostTestUrl = `http://localhost:${httpServer.identity.primaryPort}/test`;
+
+ registerCleanupFunction(async function cleanup() {
+ await new Promise(resolve => httpServer.stop(resolve));
+ });
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, localHostTestUrl);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info("Check that midi-sysex isn't set");
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: localHostTestUrl }
+ ),
+ "midi-sysex value should have UNKNOWN permission"
+ );
+
+ info(
+ "Request midi-sysex access should not prompt for addon install on locahost, but for permission"
+ );
+ let popupShown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.midiAccessRequestPromise = content.navigator.requestMIDIAccess({
+ sysex: true,
+ });
+ });
+ await popupShown;
+ is(
+ PopupNotifications.panel.querySelector("popupnotification").id,
+ "midi-notification",
+ "midi notification was displayed"
+ );
+
+ info("Accept permission");
+ PopupNotifications.panel
+ .querySelector(".popup-notification-primary-button")
+ .click();
+
+ info("Wait for the midi-sysex access request promise to resolve");
+ const accessGranted = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ try {
+ await content.midiAccessRequestPromise;
+ return true;
+ } catch (e) {}
+
+ delete content.midiAccessRequestPromise;
+ return false;
+ }
+ );
+ ok(accessGranted, "requestMIDIAccess resolved");
+
+ info("Check that we prompt user again even if they accepted before");
+ popupShown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ content.navigator.requestMIDIAccess({ sysex: true });
+ });
+ await popupShown;
+ is(
+ PopupNotifications.panel.querySelector("popupnotification").id,
+ "midi-notification",
+ "midi notification was displayed again"
+ );
+
+ assertSitePermissionInstallTelemetryEvents([]);
+});
+
+add_task(async function testDisabledRequestMIDIAccessFile() {
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append("blank.html");
+ const fileSchemeTestUri = Services.io.newFileURI(dir).spec;
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, fileSchemeTestUri);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info("Check that requestMIDIAccess isn't set on navigator on file scheme");
+ const isRequestMIDIAccessDefined = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return "requestMIDIAccess" in content.wrappedJSObject.navigator;
+ }
+ );
+ is(
+ isRequestMIDIAccessDefined,
+ false,
+ "navigator.requestMIDIAccess is not defined on file scheme"
+ );
+});
+
+// Ignore any additional telemetry events collected in this file.
+// Unfortunately it doesn't work to have this in a cleanup function.
+// Keep this as the last task done.
+add_task(function teardown_telemetry_events() {
+ AddonTestUtils.getAMTelemetryEvents();
+});
+
+/**
+ * Check that the expected sitepermission install events are recorded.
+ *
+ * @param {Array<String>} expectedSteps: An array of the expected extra.step values recorded.
+ */
+function assertSitePermissionInstallTelemetryEvents(
+ expectedSteps,
+ events = null
+) {
+ let amInstallEvents = (events ?? AddonTestUtils.getAMTelemetryEvents())
+ .filter(evt => evt.method === "install" && evt.object === "sitepermission")
+ .map(evt => evt.extra.step);
+
+ Assert.deepEqual(amInstallEvents, expectedSteps);
+}
+
+async function waitForInstallDialog(id = "addon-webext-permissions") {
+ let panel = await waitForNotification(id);
+ return panel.childNodes[0];
+}
+
+/**
+ * Adds an event listener that will listen for post-install dialog event and automatically
+ * close the dialogs.
+ */
+function alwaysAcceptAddonPostInstallDialogs() {
+ // Once the addon is installed, a dialog is displayed as a confirmation.
+ // This could interfere with tests running after this one, so we set up a listener
+ // that will always accept post install dialogs so we don't have to deal with them in
+ // the test.
+ const abortController = new AbortController();
+
+ const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ info("Start listening and accept addon post-install notifications");
+ PanelUI.notificationPanel.addEventListener(
+ "popupshown",
+ async function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification || notification.id !== "addon-installed") {
+ return;
+ }
+
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ if (popupnotificationID) {
+ info("Accept post-install dialog");
+ let popupnotification = document.getElementById(popupnotificationID);
+ popupnotification?.button.click();
+ }
+ },
+ {
+ signal: abortController.signal,
+ }
+ );
+
+ registerCleanupFunction(async () => {
+ // Clear the listener at the end of the test file, to prevent it to stay
+ // around when the same browser instance may be running other unrelated
+ // test files.
+ abortController.abort();
+ });
+}
+
+const PROGRESS_NOTIFICATION = "addon-progress";
+async function waitForNotification(notificationId) {
+ info(`Waiting for ${notificationId} notification`);
+
+ let topic = getObserverTopic(notificationId);
+
+ let observerPromise;
+ if (notificationId !== "addon-webext-permissions") {
+ observerPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ // Ignore the progress notification unless that is the notification we want
+ if (
+ notificationId != PROGRESS_NOTIFICATION &&
+ aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
+ ) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }, topic);
+ });
+ }
+
+ let panelEventPromise = new Promise(resolve => {
+ window.PopupNotifications.panel.addEventListener(
+ "PanelUpdated",
+ function eventListener(e) {
+ // Skip notifications that are not the one that we are supposed to be looking for
+ if (!e.detail.includes(notificationId)) {
+ return;
+ }
+ window.PopupNotifications.panel.removeEventListener(
+ "PanelUpdated",
+ eventListener
+ );
+ resolve();
+ }
+ );
+ });
+
+ await observerPromise;
+ await panelEventPromise;
+ await waitForTick();
+
+ info(`Saw a ${notificationId} notification`);
+ await SimpleTest.promiseFocus(window.PopupNotifications.window);
+ return window.PopupNotifications.panel;
+}
+
+// This function is similar to the one in
+// toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js,
+// please keep both in sync!
+function getObserverTopic(aNotificationId) {
+ let topic = aNotificationId;
+ if (topic == "xpinstall-disabled") {
+ topic = "addon-install-disabled";
+ } else if (topic == "addon-progress") {
+ topic = "addon-install-started";
+ } else if (topic == "addon-installed") {
+ topic = "webextension-install-notify";
+ }
+ return topic;
+}
+
+function waitForTick() {
+ return new Promise(resolve => executeSoon(resolve));
+}
diff --git a/dom/midi/tests/browser_refresh_port_list.js b/dom/midi/tests/browser_refresh_port_list.js
new file mode 100644
index 0000000000..152b067254
--- /dev/null
+++ b/dom/midi/tests/browser_refresh_port_list.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const EXAMPLE_ORG_URL = "https://example.org/browser/dom/midi/tests/";
+const PAGE = "refresh_port_list.html";
+
+async function get_access(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.get_access();
+ });
+}
+
+async function reset_access(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.reset_access();
+ });
+}
+
+async function get_num_ports(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.get_num_ports();
+ });
+}
+
+async function add_port(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ return content.wrappedJSObject.add_port();
+ });
+}
+
+async function remove_port(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ return content.wrappedJSObject.remove_port();
+ });
+}
+
+async function force_refresh(browser) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ return content.wrappedJSObject.force_refresh();
+ });
+}
+
+add_task(async function () {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ EXAMPLE_ORG_URL + PAGE
+ );
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await get_access(gBrowser.selectedBrowser);
+ let ports_num = await get_num_ports(gBrowser.selectedBrowser);
+ Assert.equal(ports_num, 4, "We start with four ports");
+ await add_port(gBrowser.selectedBrowser);
+ ports_num = await get_num_ports(gBrowser.selectedBrowser);
+ Assert.equal(ports_num, 5, "One port is added manually");
+ // This causes the test service to refresh the ports the next time a refresh
+ // is requested, it will happen after we reload the tab later on and will add
+ // back the port that we're removing on the next line.
+ await force_refresh(gBrowser.selectedBrowser);
+ await remove_port(gBrowser.selectedBrowser);
+ ports_num = await get_num_ports(gBrowser.selectedBrowser);
+ Assert.equal(ports_num, 4, "One port is removed manually");
+
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+
+ await get_access(gBrowser.selectedBrowser);
+ let refreshed_ports_num = await get_num_ports(gBrowser.selectedBrowser);
+ Assert.equal(refreshed_ports_num, 5, "One port is added by the refresh");
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/dom/midi/tests/browser_stable_midi_port_ids.js b/dom/midi/tests/browser_stable_midi_port_ids.js
new file mode 100644
index 0000000000..e7d3056160
--- /dev/null
+++ b/dom/midi/tests/browser_stable_midi_port_ids.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const EXAMPLE_COM_URL = "https://example.com/browser/dom/midi/tests/";
+const EXAMPLE_ORG_URL = "https://example.org/browser/dom/midi/tests/";
+const PAGE1 = "port_ids_page_1.html";
+const PAGE2 = "port_ids_page_2.html";
+
+// Return the MIDI port id of the first input port for the given URL and page
+function id_for_tab(url, page) {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: url + page,
+ waitForLoad: true,
+ },
+ async function (browser) {
+ return SpecialPowers.spawn(browser, [""], function () {
+ return content.wrappedJSObject.get_first_input_id();
+ });
+ }
+ );
+}
+
+add_task(async function () {
+ let com_page1;
+ let com_page1_reload;
+ let org_page1;
+ let org_page2;
+
+ [com_page1, com_page1_reload, org_page1, org_page2] = await Promise.all([
+ id_for_tab(EXAMPLE_COM_URL, PAGE1),
+ id_for_tab(EXAMPLE_COM_URL, PAGE1),
+ id_for_tab(EXAMPLE_ORG_URL, PAGE1),
+ id_for_tab(EXAMPLE_ORG_URL, PAGE2),
+ ]);
+ Assert.equal(
+ com_page1,
+ com_page1_reload,
+ "MIDI port ids should be the same when reloading the same page"
+ );
+ Assert.notEqual(
+ com_page1,
+ org_page1,
+ "MIDI port ids should be different in different origins"
+ );
+ Assert.equal(
+ org_page1,
+ org_page2,
+ "MIDI port ids should be the same in the same origin"
+ );
+});
diff --git a/dom/midi/tests/file_midi_permission_gated.html b/dom/midi/tests/file_midi_permission_gated.html
new file mode 100644
index 0000000000..8e3ed4d625
--- /dev/null
+++ b/dom/midi/tests/file_midi_permission_gated.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script>
+ window.addEventListener("message", async (evt) => {
+ try {
+ await navigator.requestMIDIAccess({sysex: evt.data});
+ parent.postMessage("succeeded", "*");
+ } catch (ex) {
+ parent.postMessage("failed", "*");
+ }
+ });
+</script>
+<body></body>
+</html>
diff --git a/dom/midi/tests/mochitest.ini b/dom/midi/tests/mochitest.ini
new file mode 100644
index 0000000000..273d1e8fc3
--- /dev/null
+++ b/dom/midi/tests/mochitest.ini
@@ -0,0 +1,25 @@
+[DEFAULT]
+support-files =
+ MIDITestUtils.js
+ file_midi_permission_gated.html
+scheme = https
+
+[test_midi_permission_prompt.html]
+[test_midi_permission_allow.html]
+[test_midi_permission_deny.html]
+[test_midi_permission_gated.html]
+skip-if =
+ os == 'android' #Bug 1747637
+ http3
+[test_midi_device_enumeration.html]
+[test_midi_device_implicit_open_close.html]
+[test_midi_device_explicit_open_close.html]
+[test_midi_device_sysex.html]
+[test_midi_device_system_rt.html]
+[test_midi_packet_timing_sorting.html]
+[test_midi_device_connect_disconnect.html]
+disabled = Bug 1437204
+[test_midi_device_pending.html]
+disabled = Bug 1437204
+[test_midi_send_messages.html]
+[test_midi_message_event.html]
diff --git a/dom/midi/tests/port_ids_page_1.html b/dom/midi/tests/port_ids_page_1.html
new file mode 100644
index 0000000000..31dadad4a5
--- /dev/null
+++ b/dom/midi/tests/port_ids_page_1.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Stable MIDI port id test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<script>
+ async function get_first_input_id() {
+ let access = await navigator.requestMIDIAccess({ sysex: false });
+ const inputs = access.inputs.values();
+ const input = inputs.next();
+ return input.value.id;
+ }
+</script>
+</body>
+</html>
diff --git a/dom/midi/tests/port_ids_page_2.html b/dom/midi/tests/port_ids_page_2.html
new file mode 100644
index 0000000000..8c313b04da
--- /dev/null
+++ b/dom/midi/tests/port_ids_page_2.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Stable MIDI port id test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<script>
+ async function get_first_input_id() {
+ let access = await navigator.requestMIDIAccess({ sysex: false });
+ const inputs = access.inputs.values();
+ const input = inputs.next();
+ return input.value.id;
+}
+</script>
+</body>
+</html>
diff --git a/dom/midi/tests/refresh_port_list.html b/dom/midi/tests/refresh_port_list.html
new file mode 100644
index 0000000000..96e4a7a309
--- /dev/null
+++ b/dom/midi/tests/refresh_port_list.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>Refresh MIDI port list test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<script>
+ var access = null;
+ async function get_access() {
+ access = await navigator.requestMIDIAccess({ sysex: true });
+ }
+
+ async function reset_access() {
+ access = null;
+ }
+
+ async function get_num_ports() {
+ return access.inputs.size + access.outputs.size;
+ }
+
+ async function add_port() {
+ let addPortPromise = new Promise(resolve => {
+ access.addEventListener("statechange", (event) => { dump("***** 1 event.port.name = " + event.port.name + "event.connection = " + event.port.connection + "\n"); if (event.port.connection != "open") { resolve(); } });
+ });
+ const outputs = access.outputs.values();
+ const output = outputs.next().value;
+ output.send([0x90, 0x01, 0x00]);
+ await addPortPromise;
+ }
+
+ async function remove_port() {
+ let removePortPromise = new Promise(resolve => {
+ access.addEventListener("statechange", (event) => { dump("***** 2 event.port.name = " + event.port.name + "event.connection = " + event.port.connection + "\n"); if (event.port.connection != "open") { resolve(); } });
+ });
+ const outputs = access.outputs.values();
+ const output = outputs.next().value;
+ output.send([0x90, 0x02, 0x00]);
+ await removePortPromise;
+ }
+
+ async function force_refresh() {
+ const outputs = access.outputs.values();
+ const output = outputs.next().value;
+ output.send([0x90, 0x04, 0x00]);
+ }
+</script>
+</body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_connect_disconnect.html b/dom/midi/tests/test_midi_device_connect_disconnect.html
new file mode 100644
index 0000000000..338d1de55d
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_connect_disconnect.html
@@ -0,0 +1,54 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ let output;
+
+ let midi_access;
+ try {
+ midi_access = await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "MIDI Access Request successful");
+ } catch (e) {
+ ok(false, "MIDI Access Request failed!");
+ SimpleTest.finish();
+ return;
+ }
+ is(midi_access.sysexEnabled, false, "Sysex should be false");
+ output = midi_access.outputs.get(await MIDITestUtils.outputInfo.id);
+ let statePromiseRes;
+ let statePromise = new Promise((res) => { statePromiseRes = res; });
+ await output.open();
+ let stateChangeHandler = (event) => {
+ if (event.port == output) {
+ return;
+ }
+ statePromiseRes(event.port);
+ };
+ midi_access.addEventListener("statechange", stateChangeHandler);
+ // Send command to connect new port.
+ output.send([0x90, 0x01, 0x00]);
+ let p = await statePromise;
+ is(p.state, "connected", "Device " + p.name + " connected");
+
+ // Rebuild our promise, we'll need to await another one.
+ statePromise = new Promise((res) => { statePromiseRes = res; });
+ output.send([0x90, 0x02, 0x00]);
+ p = await statePromise;
+ is(p.state, "disconnected", "Device " + p.name + " disconnected");
+ midi_access.removeEventListener("statechange", stateChangeHandler);
+ SimpleTest.finish();
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_enumeration.html b/dom/midi/tests/test_midi_device_enumeration.html
new file mode 100644
index 0000000000..1dab1c8cf7
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_enumeration.html
@@ -0,0 +1,46 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ let objectCompare = async (type, props, obj) => {
+ for (var prop in props) {
+ is(await props[prop], obj[prop], type + " property value " + prop + " is " + props[prop]);
+ }
+ };
+ let failOnCall = (event) => {
+ ok(false, "No connect/state events should be received on startup!");
+ };
+ async function runTests () {
+ await MIDITestUtils.permissionSetup(true);
+ // Request access without sysex.
+ let access = await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "MIDI Access Request successful");
+ is(access.sysexEnabled, false, "Sysex should be false");
+ access.addEventListener("statechange", failOnCall);
+ var input_id = await MIDITestUtils.inputInfo.id;
+ var output_id = await MIDITestUtils.outputInfo.id;
+ var inputs = access.inputs;
+ var outputs = access.outputs;
+ is(inputs.size, 1, "Should have one input");
+ is(outputs.size, 3, "Should have three outputs");
+ ok(inputs.has(input_id), "input list should contain input id");
+ ok(outputs.has(output_id), "output list should contain output id");
+ var input = access.inputs.get(input_id);
+ var output = access.outputs.get(output_id);
+ await objectCompare("input", MIDITestUtils.inputInfo, input);
+ await objectCompare("output", MIDITestUtils.outputInfo, output);
+ access.removeEventListener("statechange", failOnCall);
+ SimpleTest.finish();
+ };
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_explicit_open_close.html b/dom/midi/tests/test_midi_device_explicit_open_close.html
new file mode 100644
index 0000000000..d3ed910a55
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_explicit_open_close.html
@@ -0,0 +1,94 @@
+<html>
+ <head>
+ <title>WebMIDI Device Open/Close Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+
+ let access;
+ try {
+ access = await navigator.requestMIDIAccess({ "sysex": false })
+ } catch (e) {
+ ok(false, "MIDI Access Request Failed!");
+ SimpleTest.finish();
+ }
+
+ ok(true, "MIDI Access Request successful");
+ let input = access.inputs.get(await MIDITestUtils.inputInfo.id);
+ let portEventRes;
+ let accessEventRes;
+ let portEventPromise = new Promise((resolve, reject) => { portEventRes = resolve; });
+ let accessEventPromise = new Promise((resolve, reject) => { accessEventRes = resolve; });
+ let shouldClose = false;
+ let checkPort = (event) => {
+ ok(input === event.port, "input port object and event port object are same object");
+ ok(true, "port connection event fired");
+ ok(event.port.connection === (!shouldClose ? "open" : "closed"), "connection registered correctly");
+ };
+ let inputEventHandler = (event) => {
+ checkPort(event);
+ portEventRes();
+ };
+ let accessEventHandler = (event) => {
+ checkPort(event);
+ accessEventRes();
+ };
+ input.addEventListener("statechange", inputEventHandler);
+ access.addEventListener("statechange", accessEventHandler);
+ await input.open();
+ ok(true, "connection successful");
+ ok(input.connection === "open", "connection registered as open");
+ await Promise.all([portEventPromise, accessEventPromise]);
+ input.removeEventListener("statechange", inputEventHandler);
+ access.removeEventListener("statechange", accessEventHandler);
+ ok(true, "MIDI Port Open Test finished.");
+ ok(true, "Testing open failure");
+ let out_access;
+ try {
+ out_access = await navigator.requestMIDIAccess({ "sysex": false });
+ } catch (e) {
+ ok(false, "MIDI Access Request Failed!");
+ SimpleTest.finish();
+ }
+ let outputEventHandler = (event) => {
+ ok(output_opened === event.port, "output port object and event port object are same object");
+ ok(true, "access connection event fired");
+ ok(event.port.connection === "closed", "connection registered as closed");
+ };
+ out_access.addEventListener("statechange", outputEventHandler);
+ let output_opened = out_access.outputs.get(await MIDITestUtils.alwaysClosedTestOutputInfo.id);
+ try {
+ await output_opened.open();
+ ok(false, "Should've failed to open port!");
+ } catch(err) {
+ is(err.name, "InvalidAccessError", "error name " + err.name + " should be InvalidAccessError");
+ ok(output_opened.connection == "closed", "connection registered as closed");
+ ok(true, "Port not opened, test succeeded");
+ } finally {
+ out_access.removeEventListener("statechange", outputEventHandler);
+ }
+ ok(true, "Starting MIDI port closing test");
+ portEventPromise = new Promise((resolve, reject) => { portEventRes = resolve; });
+ accessEventPromise = new Promise((resolve, reject) => { accessEventRes = resolve; });
+ input.addEventListener("statechange", inputEventHandler);
+ access.addEventListener("statechange", accessEventHandler);
+ shouldClose = true;
+ await input.close();
+ ok(input.connection === "closed", "connection registered as closed");
+ await Promise.all([portEventPromise, accessEventPromise]);
+ input.removeEventListener("statechange", inputEventHandler);
+ access.removeEventListener("statechange", accessEventHandler);
+ SimpleTest.finish();
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_implicit_open_close.html b/dom/midi/tests/test_midi_device_implicit_open_close.html
new file mode 100644
index 0000000000..cddbaf26c2
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_implicit_open_close.html
@@ -0,0 +1,54 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ let access = await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "MIDI Access Request successful");
+ is(access.sysexEnabled, false, "Sysex should be false");
+
+ var checkCount = 0;
+ var input;
+ var output;
+ function checkCallbacks(port) {
+ if (checkCount < 2) {
+ ok(port.connection === "open", "Got port " + port.connection + " for " + port.name);
+ } else {
+ ok(port.connection === "closed", "Got port " + port.connection + " for " + port.name);
+ }
+
+ checkCount++;
+ if (checkCount == 4) {
+ input.onstatechange = undefined;
+ output.onstatechange = undefined;
+ SimpleTest.finish();
+ }
+ }
+ function checkReturn(event) {
+ ok(true, "Got echo message back");
+ MIDITestUtils.checkPacket(event.data, [0x90, 0x00, 0x7f]);
+ input.close();
+ output.close();
+ }
+
+ input = access.inputs.get(await MIDITestUtils.inputInfo.id);
+ output = access.outputs.get(await MIDITestUtils.outputInfo.id);
+ input.onstatechange = (event) => { checkCallbacks(event.port); };
+ output.onstatechange = (event) => { checkCallbacks(event.port); };
+ // Ports are closed. Fire rest of tests.
+ input.onmidimessage = checkReturn;
+ output.send([0x90, 0x00, 0x7F]);
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_pending.html b/dom/midi/tests/test_midi_device_pending.html
new file mode 100644
index 0000000000..2e6bd08420
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_pending.html
@@ -0,0 +1,118 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+
+
+ var output;
+ var test_ports = [];
+ let access;
+
+ let accessRes;
+ let accessPromise;
+ let portRes;
+ let portPromise;
+
+ function resetPromises() {
+ accessPromise = new Promise((res, rej) => { accessRes = res; });
+ portPromise = new Promise((res, rej) => { portRes = res; });
+ }
+
+ function accessStateChangeHandler(event) {
+ var p = event.port;
+ // We'll get an open event for the output control port. Ignore it.
+ if (p.name == MIDITestUtils.outputInfo.name) {
+ return;
+ }
+ accessRes(event);
+ }
+
+ function portStateChangeHandler(event) {
+ var p = event.port;
+ // We'll get an open event for the output control port. Ignore it.
+ if (p.name == MIDITestUtils.outputInfo.name) {
+ return;
+ }
+ portRes(event);
+ }
+
+ // Part 1: Create MIDIAccess object, attach state change listener to list for new connections
+ access = await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "MIDI Access Request successful");
+ is(access.sysexEnabled, false, "Sysex should be false");
+ access.addEventListener("statechange", accessStateChangeHandler);
+
+ // Part 2: open test device, make sure it connects, attach event handler to device object
+ output = access.outputs.get(await MIDITestUtils.outputInfo.id);
+ resetPromises();
+ output.send([0x90, 0x01, 0x00]);
+ let accessEvent = await accessPromise;
+ let testPort = accessEvent.port;
+ test_ports.push(testPort);
+ testPort.addEventListener("statechange", portStateChangeHandler);
+ is(testPort.state, "connected", "Device " + testPort.name + " connected");
+
+ // Part 3: Listen for port status change on open as both an access event
+ // and a port event.
+ resetPromises();
+ testPort.open();
+ accessEvent = await accessPromise;
+ is(testPort.connection, "open", "Connection " + testPort.name + " opened");
+ let portEvent = await portPromise;
+ is(testPort.connection, "open", "Connection " + testPort.name + " opened");
+
+ // Part 4: Disconnect port but don't close, check status to make sure we're pending.
+ resetPromises();
+ output.send([0x90, 0x02, 0x00]);
+ accessEvent = await accessPromise;
+ is(testPort.connection, "pending", "Connection " + testPort.name + " pending");
+ is(access.inputs.has(testPort.id), false, "port removed from input map while pending");
+ portEvent = await portPromise;
+ is(testPort.connection, "pending", "Connection " + testPort.name + " pending");
+
+ // Part 5: Connect ports again, make sure we return to the right status. The events will
+ // fire because the device has been readded to the device maps in the access object.
+ resetPromises();
+ output.send([0x90, 0x01, 0x00]);
+ accessEvent = await accessPromise;
+ var port = access.inputs.get(testPort.id);
+ is(port, accessEvent.port, "port in map and port in event should be the same");
+ is(testPort.connection, "pending", "Connection " + testPort.name + " pending");
+ portEvent = await portPromise;
+ is(testPort.connection, "pending", "Connection " + testPort.name + " pending");
+
+ // Part 6: Close out everything and clean up.
+ resetPromises();
+ accessEvent = await accessPromise;
+ is(accessEvent.port.connection, "open", "Connection " + testPort.name + " opened");
+ portEvent = await portPromise;
+ is(portEvent.port.connection, "open", "Connection " + testPort.name + " opened");
+
+ /* for (let port of test_ports) {
+ * port.removeEventListener("statechange", checkDevices);
+ * }
+ * access.removeEventListener("statechange", checkDevices);*/
+ output.send([0x90, 0x02, 0x00]);
+ testPort.removeEventListener("statechange", portStateChangeHandler);
+ access.removeEventListener("statechange", accessStateChangeHandler);
+ access = undefined;
+ output = undefined;
+ testPort = undefined;
+ accessEvent = undefined;
+ portEvent = undefined;
+ SimpleTest.finish();
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_sysex.html b/dom/midi/tests/test_midi_device_sysex.html
new file mode 100644
index 0000000000..618f54ac8a
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_sysex.html
@@ -0,0 +1,57 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ var sysexCheckCount = 0;
+ var checkCount = 0;
+ var input;
+ var output;
+ function checkSysexReceive(event) {
+ checkCount++;
+ sysexCheckCount++;
+ if (sysexCheckCount == 1) {
+ is(event.data[0], 0xF0, "Echoed sysex message via sysex port");
+ } else {
+ is(event.data[0], 0x90, "Echoed regular message via sysex port");
+ }
+ if (checkCount == 5) {
+ SimpleTest.finish();
+ }
+ }
+
+ function checkNoSysexReceive(event) {
+ checkCount++;
+ is(event.data[0], 0x90, "Echoed regular message via non-sysex port");
+ if (checkCount == 5) {
+ SimpleTest.finish()
+ }
+ }
+
+ // Request access without sysex.
+ let access_regular = await navigator.requestMIDIAccess({ "sysex": false });
+ let access_sysex = await navigator.requestMIDIAccess({ "sysex": true });
+ ok(true, "MIDI Access Request successful");
+ ok(true, "Check for sysex message drop");
+ input = access_regular.inputs.get(await MIDITestUtils.inputInfo.id);
+ output = access_sysex.outputs.get(await MIDITestUtils.outputInfo.id);
+ let input_sysex = access_sysex.inputs.get(await MIDITestUtils.inputInfo.id);
+ input_sysex.onmidimessage = checkSysexReceive;
+ input.onmidimessage = checkNoSysexReceive;
+ output.send([0xF0, 0x00, 0xF7]);
+ output.send([0x90, 0x00, 0x01]);
+ output.send([0x90, 0x00, 0x01]);
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_device_system_rt.html b/dom/midi/tests/test_midi_device_system_rt.html
new file mode 100644
index 0000000000..81de0c3a94
--- /dev/null
+++ b/dom/midi/tests/test_midi_device_system_rt.html
@@ -0,0 +1,39 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ var checkCount = 0;
+
+ function checkReturn(msg) {
+ checkCount++;
+ if (checkCount == 1) {
+ MIDITestUtils.checkPacket(msg.data, [0xFA]);
+ } else if (checkCount == 2) {
+ MIDITestUtils.checkPacket(msg.data, [0xF8]);
+ } else if (checkCount == 3) {
+ MIDITestUtils.checkPacket(msg.data, [0xF0, 0x01, 0x02, 0x03, 0x04, 0x05, 0xF7]);
+ SimpleTest.finish();
+ }
+ }
+
+ // Request access without sysex.
+ let access_sysex = await navigator.requestMIDIAccess({ "sysex": true });
+ let input_sysex = access_sysex.inputs.get(await MIDITestUtils.inputInfo.id);
+ input_sysex.onmidimessage = checkReturn;
+ let output_sysex = access_sysex.outputs.get(await MIDITestUtils.outputInfo.id);
+ output_sysex.send([0xF0, 0x01, 0xF7]);
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_message_event.html b/dom/midi/tests/test_midi_message_event.html
new file mode 100644
index 0000000000..098b033008
--- /dev/null
+++ b/dom/midi/tests/test_midi_message_event.html
@@ -0,0 +1,45 @@
+<html>
+
+<head>
+ <title>WebMIDI MIDIMessageEvent Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+</head>
+
+<body>
+ <script class="testbody" type="application/javascript">
+ add_task(async () => {
+ await MIDITestUtils.permissionSetup(true);
+
+ is(new MIDIMessageEvent('eventType').bubbles, false, "bubbles field is false by default");
+ is(new MIDIMessageEvent('eventType').cancelable, false, "cancelable field is false by default");
+ isDeeply(new MIDIMessageEvent('eventType').data, [], "The default message is empty");
+
+ is(new MIDIMessageEvent('eventType', { bubbles: false }).bubbles, false, "bubbles is passed");
+ is(new MIDIMessageEvent('eventType', { bubbles: true }).bubbles, true, "bubbles is passed");
+
+ is(new MIDIMessageEvent('eventType', { cancelable: false }).cancelable, false, "cancelable is passed");
+ is(new MIDIMessageEvent('eventType', { cancelable: true }).cancelable, true, "cancelable is passed");
+
+ var data = new Uint8Array(16);
+ isDeeply(new MIDIMessageEvent('eventType', { data }).data, data, "data is passed");
+
+ // All initializers are passed.
+ data = new Uint8Array(3);
+ is(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).bubbles, true, "all initializers are passed");
+ is(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).cancelable, true, "all initializers are passed");
+ isDeeply(new MIDIMessageEvent('eventType', { bubbles: true, cancelable: true, data }).data, data, "all initializers are passed");
+
+ if (window.SharedArrayBuffer) {
+ data = new Uint8Array(new SharedArrayBuffer(3));
+ SimpleTest.doesThrow(() => { new MIDIMessageEvent('eventType', { data }); }, "shared array buffers are rejected");
+ } else {
+ todo(false, 'SharedArrayBuffer is unavailable.');
+ }
+ });
+ </script>
+</body>
+
+</html>
diff --git a/dom/midi/tests/test_midi_packet_timing_sorting.html b/dom/midi/tests/test_midi_packet_timing_sorting.html
new file mode 100644
index 0000000000..3c344066ac
--- /dev/null
+++ b/dom/midi/tests/test_midi_packet_timing_sorting.html
@@ -0,0 +1,47 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ await SpecialPowers.pushPrefEnv({"set": [["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false]]});
+ var checkCount = 0;
+ var lastTime = 0;
+ var input;
+ var output;
+ function checkReturn(event) {
+ ok(event.timeStamp > lastTime, "Received timestamp " + event.timeStamp + " should be greater than " + lastTime);
+ lastTime = event.timeStamp;
+ checkCount++;
+
+ if (checkCount == 6) {
+ input.close();
+ output.close();
+ SimpleTest.finish();
+ }
+ }
+ ok("Testing MIDI packet reordering based on timestamps");
+ // Request access without sysex.
+ let access = await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "MIDI Access Request successful");
+ is(access.sysexEnabled, false, "Sysex should be false");
+
+ input = access.inputs.get(await MIDITestUtils.inputInfo.id);
+ output = access.outputs.get(await MIDITestUtils.outputInfo.id);
+ input.onmidimessage = checkReturn;
+ // trigger the packet timing sorting tests
+ output.send([0x90, 0x03, 0x00], 0);
+ ok(true, "Waiting on packets");
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_permission_allow.html b/dom/midi/tests/test_midi_permission_allow.html
new file mode 100644
index 0000000000..84578cfeae
--- /dev/null
+++ b/dom/midi/tests/test_midi_permission_allow.html
@@ -0,0 +1,26 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ // Request access without sysex.
+ try {
+ await navigator.requestMIDIAccess({ "sysex": false })
+ ok(true, "MIDI Access Request successful");
+ SimpleTest.finish();
+ } catch (ex) {
+ ok(false, "MIDI Access Request Failed!");
+ SimpleTest.finish();
+ }
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_permission_deny.html b/dom/midi/tests/test_midi_permission_deny.html
new file mode 100644
index 0000000000..8e3043a49a
--- /dev/null
+++ b/dom/midi/tests/test_midi_permission_deny.html
@@ -0,0 +1,26 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(false);
+ // Request access without sysex.
+ try {
+ await navigator.requestMIDIAccess({ "sysex": false });
+ ok(false, "MIDI Access Request Deny failed");
+ SimpleTest.finish();
+ } catch (ex) {
+ ok(true, "MIDI Access Request Deny successful!");
+ SimpleTest.finish();
+ }
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_permission_gated.html b/dom/midi/tests/test_midi_permission_gated.html
new file mode 100644
index 0000000000..0e85e99e9c
--- /dev/null
+++ b/dom/midi/tests/test_midi_permission_gated.html
@@ -0,0 +1,181 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <iframe id="subdomain"></iframe>
+ <iframe id="localhost"></iframe>
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+ const filePath = "/tests/dom/midi/tests/file_midi_permission_gated.html";
+ // Generally this runs on example.com but with --enable-xorigin-tests it runs
+ // on example.org.
+ let subdomainURL = "https://test1." + location.host + filePath;
+ $("subdomain").src = subdomainURL;
+ // For some reason the mochitest server returns "Bad request" with localhost,
+ // but permits the loopback address. That's good enough for testing purposes.
+ $("localhost").src = "http://127.0.0.1:8888" + filePath;
+
+ function waitForMessage() {
+ return new Promise((resolve) => {
+ window.addEventListener("message", (e) => resolve(e.data), {once: true});
+ });
+ }
+
+ async function runTests() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.webmidi.enabled", true],
+ ["midi.testing", true],
+ ],
+ });
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ document
+ ),
+ "midi-sysex value should have UNKNOWN permission"
+ );
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ subdomainURL
+ ),
+ "permission should also not be set for subdomain"
+ );
+
+ let onChangeCalled = 0;
+ let onChangeCalledWithSysex = 0;
+ // We expect the same states with and without sysex support.
+ const expectedChangedStates = ["denied", "granted", "prompt"];
+
+ const results = [];
+ for (let sysex of [false, true]) {
+ let result = await navigator.permissions.query({ name: "midi", sysex });
+ is(result?.state, "prompt", "expected 'prompt' permission status");
+ // Register two unique listeners that should be invoked every time we
+ // change permissions in the rest of this test case: one with sysex
+ // support, and the other one without.
+ if (sysex) {
+ result.onchange = () => {
+ is(
+ result.state,
+ expectedChangedStates[onChangeCalledWithSysex++],
+ "expected change event with sysex support"
+ );
+ };
+ results.push(result);
+ } else {
+ result.onchange = () => {
+ is(
+ result.state,
+ expectedChangedStates[onChangeCalled++],
+ "expected change event"
+ );
+ };
+ results.push(result);
+ }
+ }
+
+ // Explicitly set the permission as blocked, and expect the
+ // `requestMIDIAccess` call to be automatically rejected (not having any
+ // permission set would trigger the synthetic addon install provided by
+ // AddonManager and SitePermsAddonProvider).
+ await SpecialPowers.addPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.DENY_ACTION,
+ document
+ );
+ await SpecialPowers.addPermission(
+ "midi",
+ SpecialPowers.Services.perms.DENY_ACTION,
+ document
+ );
+ for (let sysex of [false, true]) {
+ try {
+ await navigator.requestMIDIAccess({ sysex });
+ ok(false, "MIDI Access Request gate allowed but expected to be denied");
+ } catch (ex) {
+ ok(true, "MIDI Access Request denied by default");
+ }
+
+ let result = await navigator.permissions.query({ name: "midi", sysex });
+ // We expect "denied" because that's what has been set above (with
+ // `SpecialPowers.addPermission()`). In practice, this state should
+ // never be returned since explicit rejection is handled at the add-on
+ // installation level.
+ is(result?.state, "denied", "expected 'denied' permission status");
+ }
+
+ // Gated permission should prompt for localhost.
+ //
+ // Note: We don't appear to have good test machinery anymore for
+ // navigating prompts from a plain mochitest. If you uncomment the lines
+ // below and run the test interactively, it should pass. Given that this
+ // is a niche feature that's unlikely to break, it doesn't seem worth
+ // investing in complicated test infrastructure to check it in automation.
+ // for (let sysex of [false, true]) {
+ // $("localhost").contentWindow.postMessage(sysex, "*");
+ // let response = await waitForMessage();
+ // is(response, "succeeded", "MIDI Access Request allowed for localhost");
+ // }
+
+ // When an addon is installed, the permission is inserted. Test
+ // that the request succeeds after we insert the permission.
+ await SpecialPowers.addPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ document
+ );
+ await SpecialPowers.addPermission(
+ "midi",
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ document
+ );
+ // Gated permission should allow access after addon inserted permission.
+ for (let sysex of [false, true]) {
+ try {
+ await navigator.requestMIDIAccess({ sysex });
+ ok(true, "MIDI Access Request allowed");
+ } catch (ex) {
+ ok(false, "MIDI Access Request failed");
+ }
+
+ let result = await navigator.permissions.query({ name: "midi", sysex });
+ is(result?.state, "granted", "expected 'granted' permission status");
+ }
+
+ // Gated permission should also apply to subdomains.
+ for (let sysex of [false, true]) {
+ $("subdomain").contentWindow.postMessage(sysex, "*");
+ let response = await waitForMessage();
+ is(response, "succeeded", "MIDI Access Request allowed for subdomain");
+ }
+
+ is(
+ onChangeCalled,
+ expectedChangedStates.length - 1,
+ `expected onchange listener to have been called ${expectedChangedStates.length - 1} times`
+ );
+ is(
+ onChangeCalledWithSysex,
+ expectedChangedStates.length - 1,
+ `expected onchange listener to have been called ${expectedChangedStates.length - 1} times (sysex)`
+ );
+
+ // Remove the permission.
+ await SpecialPowers.removePermission("midi-sysex", document);
+ await SpecialPowers.removePermission("midi", document);
+
+ results.forEach(result => result.onchange = null);
+
+ SimpleTest.finish();
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_permission_prompt.html b/dom/midi/tests/test_midi_permission_prompt.html
new file mode 100644
index 0000000000..26a6b3d789
--- /dev/null
+++ b/dom/midi/tests/test_midi_permission_prompt.html
@@ -0,0 +1,24 @@
+<html>
+ <head>
+ <title>WebMIDI Listener Test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+ </head>
+
+ <body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ try {
+ await navigator.requestMIDIAccess({ "sysex": false });
+ ok(true, "Prompting for permissions succeeded!");
+ } catch (e) {
+ ok(false, "Prompting for permissions failed!");
+ }
+ SimpleTest.finish();
+ }
+ </script>
+ </body>
+</html>
diff --git a/dom/midi/tests/test_midi_send_messages.html b/dom/midi/tests/test_midi_send_messages.html
new file mode 100644
index 0000000000..1d78709877
--- /dev/null
+++ b/dom/midi/tests/test_midi_send_messages.html
@@ -0,0 +1,112 @@
+<html>
+
+<head>
+ <title>WebMIDI Send Test</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript" src="MIDITestUtils.js"></script>
+</head>
+
+<body onload="runTests()">
+ <script class="testbody" type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ async function runTests() {
+ await MIDITestUtils.permissionSetup(true);
+ const access = await navigator.requestMIDIAccess({ sysex: true });
+ const output = access.outputs.get(await MIDITestUtils.stateTestOutputInfo.id);
+
+
+ // Note on(off).
+ output.send([0xff, 0x90, 0x00, 0x00, 0x90, 0x07, 0x00]);
+
+ try {
+ output.send([0x00, 0x01])
+ } catch (ex) {
+ ok(true, "Caught exception");
+ }
+
+ // Running status is not allowed in Web MIDI API.
+ SimpleTest.doesThrow(() => output.send([0x00, 0x01]), "Running status is not allowed in Web MIDI API.");
+
+ // Unexpected End of Sysex.
+ SimpleTest.doesThrow(() => output.send([0xf7]), "Unexpected End of Sysex.");
+
+ // Unexpected reserved status bytes.
+ SimpleTest.doesThrow(() => output.send([0xf4]), "Unexpected reserved status byte 0xf4.");
+ SimpleTest.doesThrow(() => output.send([0xf5]), "Unexpected reserved status byte 0xf5.");
+ SimpleTest.doesThrow(() => output.send([0xf9]), "Unexpected reserved status byte 0xf9.");
+ SimpleTest.doesThrow(() => output.send([0xfd]), "Unexpected reserved status byte 0xfd.");
+
+ // Incomplete channel messages.
+ SimpleTest.doesThrow(() => output.send([0x80]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0x80, 0x00]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0x90]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0x90, 0x00]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xa0]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xa0, 0x00]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xb0]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xb0, 0x00]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xc0]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xd0]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xe0]), "Incomplete channel message.");
+ SimpleTest.doesThrow(() => output.send([0xe0, 0x00]), "Incomplete channel message.");
+
+ // Incomplete system messages.
+ SimpleTest.doesThrow(() => output.send([0xf1]), "Incomplete system message.");
+ SimpleTest.doesThrow(() => output.send([0xf2]), "Incomplete system message.");
+ SimpleTest.doesThrow(() => output.send([0xf2, 0x00]), "Incomplete system message.");
+ SimpleTest.doesThrow(() => output.send([0xf3]), "Incomplete system message.");
+
+ // Invalid data bytes.
+ SimpleTest.doesThrow(() => output.send([0x80, 0x80, 0x00]), "Incomplete system message.");
+ SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0x80]), "Incomplete system message.");
+
+ // Complete messages.
+ output.send([0x80, 0x00, 0x00]);
+ output.send([0x90, 0x00, 0x00]);
+ output.send([0xa0, 0x00, 0x00]);
+ output.send([0xb0, 0x00, 0x00]);
+ output.send([0xc0, 0x00]);
+ output.send([0xd0, 0x00]);
+ output.send([0xe0, 0x00, 0x00]);
+
+ // Real-Time messages.
+ output.send([0xf8]);
+ output.send([0xfa]);
+ output.send([0xfb]);
+ output.send([0xfc]);
+ output.send([0xfe]);
+ output.send([0xff]);
+
+ // Valid messages with Real-Time messages.
+ output.send([0x90, 0xff, 0xff, 0x00, 0xff, 0x01, 0xff, 0x80, 0xff, 0x00,
+ 0xff, 0xff, 0x00, 0xff, 0xff]);
+
+ // Sysex messages.
+ output.send([0xf0, 0x00, 0x01, 0x02, 0x03, 0xf7]);
+ output.send([0xf0, 0xf8, 0xf7, 0xff]);
+ SimpleTest.doesThrow(() => output.send([0xf0, 0x80, 0xf7]), "Invalid sysex message.");
+ SimpleTest.doesThrow(() => output.send([0xf0, 0xf0, 0xf7]), "Double begin sysex message.");
+ SimpleTest.doesThrow(() => output.send([0xf0, 0xff, 0xf7, 0xf7]), "Double end sysex message.");
+
+ // Reserved status bytes.
+ SimpleTest.doesThrow(() => output.send([0xf4, 0x80, 0x00, 0x00]), "Reserved status byte.");
+ SimpleTest.doesThrow(() => output.send([0x80, 0xf4, 0x00, 0x00]), "Reserved status byte.");
+ SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0xf4, 0x00]), "Reserved status byte.");
+ SimpleTest.doesThrow(() => output.send([0x80, 0x00, 0x00, 0xf4]), "Reserved status byte.");
+ SimpleTest.doesThrow(() => output.send([0xf0, 0xff, 0xf4, 0xf7]), "Reserved status byte.");
+
+ // Invalid timestamps.
+ SimpleTest.doesThrow(() => output.send([], NaN), "NaN timestamp.");
+ SimpleTest.doesThrow(() => output.send([], Infinity), "Infinity timestamp.");
+ SimpleTest.doesThrow(() => output.send(new Uint8Array(), NaN), "NaN timestamp.");
+ SimpleTest.doesThrow(() => output.send(new Uint8Array(), Infinity), "Infinity timestamp.");
+
+ SimpleTest.finish();
+ }
+ </script>
+</body>
+
+</html>