diff options
Diffstat (limited to '')
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> |