diff options
Diffstat (limited to 'dom/media/webrtc/tests/mochitests/head.js')
-rw-r--r-- | dom/media/webrtc/tests/mochitests/head.js | 1414 |
1 files changed, 1414 insertions, 0 deletions
diff --git a/dom/media/webrtc/tests/mochitests/head.js b/dom/media/webrtc/tests/mochitests/head.js new file mode 100644 index 0000000000..79f12e403d --- /dev/null +++ b/dom/media/webrtc/tests/mochitests/head.js @@ -0,0 +1,1414 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var Cc = SpecialPowers.Cc; +var Ci = SpecialPowers.Ci; + +// Specifies if we want fake audio streams for this run +let WANT_FAKE_AUDIO = true; +// Specifies if we want fake video streams for this run +let WANT_FAKE_VIDEO = true; +let TEST_AUDIO_FREQ = 1000; + +/** + * Reads the current values of preferences affecting fake and loopback devices + * and sets the WANT_FAKE_AUDIO and WANT_FAKE_VIDEO gloabals appropriately. + */ +function updateConfigFromFakeAndLoopbackPrefs() { + let audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev", ""); + if (audioDevice) { + WANT_FAKE_AUDIO = false; + dump("TEST DEVICES: Got loopback audio: " + audioDevice + "\n"); + } else { + WANT_FAKE_AUDIO = true; + dump( + "TEST DEVICES: No test device found in media.audio_loopback_dev, using fake audio streams.\n" + ); + } + let videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev", ""); + if (videoDevice) { + WANT_FAKE_VIDEO = false; + dump("TEST DEVICES: Got loopback video: " + videoDevice + "\n"); + } else { + WANT_FAKE_VIDEO = true; + dump( + "TEST DEVICES: No test device found in media.video_loopback_dev, using fake video streams.\n" + ); + } +} + +updateConfigFromFakeAndLoopbackPrefs(); + +/** + * Global flag to skip LoopbackTone + */ +let DISABLE_LOOPBACK_TONE = false; +/** + * Helper class to setup a sine tone of a given frequency. + */ +class LoopbackTone { + constructor(audioContext, frequency) { + if (!audioContext) { + throw new Error("You must provide a valid AudioContext"); + } + this.oscNode = audioContext.createOscillator(); + var gainNode = audioContext.createGain(); + gainNode.gain.value = 0.5; + this.oscNode.connect(gainNode); + gainNode.connect(audioContext.destination); + this.changeFrequency(frequency); + } + + // Method should be used when WANT_FAKE_AUDIO is false. + start() { + if (!this.oscNode) { + throw new Error("Attempt to start a stopped LoopbackTone"); + } + info(`Start loopback tone at ${this.oscNode.frequency.value}`); + this.oscNode.start(); + } + + // Change the frequency of the tone. It can be used after start. + // Frequency will change on the fly. No need to stop and create a new instance. + changeFrequency(frequency) { + if (!this.oscNode) { + throw new Error("Attempt to change frequency on a stopped LoopbackTone"); + } + this.oscNode.frequency.value = frequency; + } + + stop() { + if (!this.oscNode) { + throw new Error("Attempt to stop a stopped LoopbackTone"); + } + this.oscNode.stop(); + this.oscNode = null; + } +} +// Object that holds the default loopback tone. +var DefaultLoopbackTone = null; + +/** + * This class provides helpers around analysing the audio content in a stream + * using WebAudio AnalyserNodes. + * + * @constructor + * @param {object} stream + * A MediaStream object whose audio track we shall analyse. + */ +function AudioStreamAnalyser(ac, stream) { + this.audioContext = ac; + this.stream = stream; + this.sourceNodes = []; + this.analyser = this.audioContext.createAnalyser(); + // Setting values lower than default for speedier testing on emulators + this.analyser.smoothingTimeConstant = 0.2; + this.analyser.fftSize = 1024; + this.connectTrack = t => { + let source = this.audioContext.createMediaStreamSource( + new MediaStream([t]) + ); + this.sourceNodes.push(source); + source.connect(this.analyser); + }; + this.stream.getAudioTracks().forEach(t => this.connectTrack(t)); + this.onaddtrack = ev => this.connectTrack(ev.track); + this.stream.addEventListener("addtrack", this.onaddtrack); + this.data = new Uint8Array(this.analyser.frequencyBinCount); +} + +AudioStreamAnalyser.prototype = { + /** + * Get an array of frequency domain data for our stream's audio track. + * + * @returns {array} A Uint8Array containing the frequency domain data. + */ + getByteFrequencyData() { + this.analyser.getByteFrequencyData(this.data); + return this.data; + }, + + /** + * Append a canvas to the DOM where the frequency data are drawn. + * Useful to debug tests. + */ + enableDebugCanvas() { + var cvs = (this.debugCanvas = document.createElement("canvas")); + const content = document.getElementById("content"); + content.insertBefore(cvs, content.children[0]); + + // Easy: 1px per bin + cvs.width = this.analyser.frequencyBinCount; + cvs.height = 128; + cvs.style.border = "1px solid red"; + + var c = cvs.getContext("2d"); + c.fillStyle = "black"; + + var self = this; + function render() { + c.clearRect(0, 0, cvs.width, cvs.height); + var array = self.getByteFrequencyData(); + for (var i = 0; i < array.length; i++) { + c.fillRect(i, cvs.height - array[i] / 2, 1, cvs.height); + } + if (!cvs.stopDrawing) { + requestAnimationFrame(render); + } + } + requestAnimationFrame(render); + }, + + /** + * Stop drawing of and remove the debug canvas from the DOM if it was + * previously added. + */ + disableDebugCanvas() { + if (!this.debugCanvas || !this.debugCanvas.parentElement) { + return; + } + + this.debugCanvas.stopDrawing = true; + this.debugCanvas.parentElement.removeChild(this.debugCanvas); + }, + + /** + * Disconnects the input stream from our internal analyser node. + * Call this to reduce main thread processing, mostly necessary on slow + * devices. + */ + disconnect() { + this.disableDebugCanvas(); + this.sourceNodes.forEach(n => n.disconnect()); + this.sourceNodes = []; + this.stream.removeEventListener("addtrack", this.onaddtrack); + }, + + /** + * Return a Promise, that will be resolved when the function passed as + * argument, when called, returns true (meaning the analysis was a + * success). The promise is rejected if the cancel promise resolves first. + * + * @param {function} analysisFunction + * A function that performs an analysis, and resolves with true if the + * analysis was a success (i.e. it found what it was looking for) + * @param {promise} cancel + * A promise that on resolving will reject the promise we returned. + */ + async waitForAnalysisSuccess( + analysisFunction, + cancel = wait(60000, new Error("Audio analysis timed out")) + ) { + let aborted = false; + cancel.then(() => (aborted = true)); + + // We need to give the Analyser some time to start gathering data. + await wait(200); + + do { + await new Promise(resolve => requestAnimationFrame(resolve)); + if (aborted) { + throw await cancel; + } + } while (!analysisFunction(this.getByteFrequencyData())); + }, + + /** + * Return the FFT bin index for a given frequency. + * + * @param {double} frequency + * The frequency for whicht to return the bin number. + * @returns {integer} the index of the bin in the FFT array. + */ + binIndexForFrequency(frequency) { + return ( + 1 + + Math.round( + (frequency * this.analyser.fftSize) / this.audioContext.sampleRate + ) + ); + }, + + /** + * Reverse operation, get the frequency for a bin index. + * + * @param {integer} index an index in an FFT array + * @returns {double} the frequency for this bin + */ + frequencyForBinIndex(index) { + return ((index - 1) * this.audioContext.sampleRate) / this.analyser.fftSize; + }, +}; + +/** + * Creates a MediaStream with an audio track containing a sine tone at the + * given frequency. + * + * @param {AudioContext} ac + * AudioContext in which to create the OscillatorNode backing the stream + * @param {double} frequency + * The frequency in Hz of the generated sine tone + * @returns {MediaStream} the MediaStream containing sine tone audio track + */ +function createOscillatorStream(ac, frequency) { + var osc = ac.createOscillator(); + osc.frequency.value = frequency; + + var oscDest = ac.createMediaStreamDestination(); + osc.connect(oscDest); + osc.start(); + return oscDest.stream; +} + +/** + * Create the necessary HTML elements for head and body as used by Mochitests + * + * @param {object} meta + * Meta information of the test + * @param {string} meta.title + * Description of the test + * @param {string} [meta.bug] + * Bug the test was created for + * @param {boolean} [meta.visible=false] + * Visibility of the media elements + */ +function realCreateHTML(meta) { + var test = document.getElementById("test"); + + // Create the head content + var elem = document.createElement("meta"); + elem.setAttribute("charset", "utf-8"); + document.head.appendChild(elem); + + var title = document.createElement("title"); + title.textContent = meta.title; + document.head.appendChild(title); + + // Create the body content + var anchor = document.createElement("a"); + anchor.textContent = meta.title; + if (meta.bug) { + anchor.setAttribute( + "href", + "https://bugzilla.mozilla.org/show_bug.cgi?id=" + meta.bug + ); + } else { + anchor.setAttribute("target", "_blank"); + } + + document.body.insertBefore(anchor, test); + + var display = document.createElement("p"); + display.setAttribute("id", "display"); + document.body.insertBefore(display, test); + + var content = document.createElement("div"); + content.setAttribute("id", "content"); + content.style.display = meta.visible ? "block" : "none"; + document.body.appendChild(content); +} + +/** + * Creates an element of the given type, assigns the given id, sets the controls + * and autoplay attributes and adds it to the content node. + * + * @param {string} type + * Defining if we should create an "audio" or "video" element + * @param {string} id + * A string to use as the element id. + */ +function createMediaElement(type, id) { + const element = document.createElement(type); + element.setAttribute("id", id); + element.setAttribute("height", 100); + element.setAttribute("width", 150); + element.setAttribute("controls", "controls"); + element.setAttribute("autoplay", "autoplay"); + element.setAttribute("muted", "muted"); + element.muted = true; + document.getElementById("content").appendChild(element); + + return element; +} + +/** + * Returns an existing element for the given track with the given idPrefix, + * as it was added by createMediaElementForTrack(). + * + * @param {MediaStreamTrack} track + * Track used as the element's source. + * @param {string} idPrefix + * A string to use as the element id. The track id will also be appended. + */ +function getMediaElementForTrack(track, idPrefix) { + return document.getElementById(idPrefix + "_" + track.id); +} + +/** + * Create a media element with a track as source and attach it to the content + * node. + * + * @param {MediaStreamTrack} track + * Track for use as source. + * @param {string} idPrefix + * A string to use as the element id. The track id will also be appended. + * @return {HTMLMediaElement} The created HTML media element + */ +function createMediaElementForTrack(track, idPrefix) { + const id = idPrefix + "_" + track.id; + const element = createMediaElement(track.kind, id); + element.srcObject = new MediaStream([track]); + + return element; +} + +/** + * Wrapper function for mediaDevices.getUserMedia used by some tests. Whether + * to use fake devices or not is now determined in pref further below instead. + * + * @param {Dictionary} constraints + * The constraints for this mozGetUserMedia callback + */ +function getUserMedia(constraints) { + // Tests may have changed the values of prefs, so recheck + updateConfigFromFakeAndLoopbackPrefs(); + if ( + !WANT_FAKE_AUDIO && + !constraints.fake && + constraints.audio && + !DISABLE_LOOPBACK_TONE + ) { + // Loopback device is configured, start the default loopback tone + if (!DefaultLoopbackTone) { + TEST_AUDIO_FREQ = 440; + DefaultLoopbackTone = new LoopbackTone( + new AudioContext(), + TEST_AUDIO_FREQ + ); + DefaultLoopbackTone.start(); + } + // Disable input processing mode when it's not explicity enabled. + // This is to avoid distortion of the loopback tone + constraints.audio = Object.assign( + {}, + { autoGainControl: false }, + { echoCancellation: false }, + { noiseSuppression: false }, + constraints.audio + ); + } else { + // Fake device configured, ensure our test freq is correct. + TEST_AUDIO_FREQ = 1000; + } + info("Call getUserMedia for " + JSON.stringify(constraints)); + return navigator.mediaDevices + .getUserMedia(constraints) + .then(stream => (checkMediaStreamTracks(constraints, stream), stream)); +} + +// These are the promises we use to track that the prerequisites for the test +// are in place before running it. +var setTestOptions; +var testConfigured = new Promise(r => (setTestOptions = r)); + +function pushPrefs(...p) { + return SpecialPowers.pushPrefEnv({ set: p }); +} + +async function withPrefs(prefs, func) { + await SpecialPowers.pushPrefEnv({ set: prefs }); + try { + return await func(); + } finally { + await SpecialPowers.popPrefEnv(); + } +} + +function setupEnvironment() { + var defaultMochitestPrefs = { + set: [ + ["media.peerconnection.enabled", true], + ["media.peerconnection.identity.enabled", true], + ["media.peerconnection.identity.timeout", 120000], + ["media.peerconnection.ice.stun_client_maximum_transmits", 14], + ["media.peerconnection.ice.trickle_grace_period", 30000], + ["media.navigator.permission.disabled", true], + // If either fake audio or video is desired we enable fake streams. + // If loopback devices are set they will be chosen instead of fakes in gecko. + ["media.navigator.streams.fake", WANT_FAKE_AUDIO || WANT_FAKE_VIDEO], + ["media.getusermedia.audiocapture.enabled", true], + ["media.getusermedia.screensharing.enabled", true], + ["media.getusermedia.window.focus_source.enabled", false], + ["media.recorder.audio_node.enabled", true], + ["media.peerconnection.ice.obfuscate_host_addresses", false], + ["media.peerconnection.nat_simulator.filtering_type", ""], + ["media.peerconnection.nat_simulator.mapping_type", ""], + ["media.peerconnection.nat_simulator.block_tcp", false], + ["media.peerconnection.nat_simulator.block_udp", false], + ["media.peerconnection.nat_simulator.redirect_address", ""], + ["media.peerconnection.nat_simulator.redirect_targets", ""], + ], + }; + + if (navigator.userAgent.includes("Android")) { + defaultMochitestPrefs.set.push( + ["media.navigator.video.default_width", 320], + ["media.navigator.video.default_height", 240], + ["media.navigator.video.max_fr", 10], + ["media.autoplay.default", Ci.nsIAutoplay.ALLOWED] + ); + } + + // Platform codec prefs should be matched because fake H.264 GMP codec doesn't + // produce/consume real bitstreams. [TODO] remove after bug 1509012 is fixed. + const platformEncoderEnabled = SpecialPowers.getBoolPref( + "media.webrtc.platformencoder" + ); + defaultMochitestPrefs.set.push([ + "media.navigator.mediadatadecoder_h264_enabled", + platformEncoderEnabled, + ]); + + // Running as a Mochitest. + SimpleTest.requestFlakyTimeout("WebRTC inherently depends on timeouts"); + window.finish = () => SimpleTest.finish(); + SpecialPowers.pushPrefEnv(defaultMochitestPrefs, setTestOptions); + + // We don't care about waiting for this to complete, we just want to ensure + // that we don't build up a huge backlog of GC work. + SpecialPowers.exactGC(); +} + +// [TODO] remove after bug 1509012 is fixed. +async function matchPlatformH264CodecPrefs() { + const hasHW264 = + SpecialPowers.getBoolPref("media.webrtc.platformencoder") && + (navigator.userAgent.includes("Android") || + navigator.userAgent.includes("Mac OS X")); + + await pushPrefs( + ["media.webrtc.platformencoder", hasHW264], + ["media.navigator.mediadatadecoder_h264_enabled", hasHW264] + ); +} + +async function runTestWhenReady(testFunc) { + setupEnvironment(); + const options = await testConfigured; + try { + await testFunc(options); + } catch (e) { + ok( + false, + `Error executing test: ${e} +${e.stack ? e.stack : ""}` + ); + } finally { + SimpleTest.finish(); + } +} + +/** + * Checks that the media stream tracks have the expected amount of tracks + * with the correct attributes based on the type and constraints given. + * + * @param {Object} constraints specifies whether the stream should have + * audio, video, or both + * @param {String} type the type of media stream tracks being checked + * @param {sequence<MediaStreamTrack>} mediaStreamTracks the media stream + * tracks being checked + */ +function checkMediaStreamTracksByType(constraints, type, mediaStreamTracks) { + if (constraints[type]) { + is(mediaStreamTracks.length, 1, "One " + type + " track shall be present"); + + if (mediaStreamTracks.length) { + is(mediaStreamTracks[0].kind, type, "Track kind should be " + type); + ok(mediaStreamTracks[0].id, "Track id should be defined"); + ok(!mediaStreamTracks[0].muted, "Track should not be muted"); + } + } else { + is(mediaStreamTracks.length, 0, "No " + type + " tracks shall be present"); + } +} + +/** + * Check that the given media stream contains the expected media stream + * tracks given the associated audio & video constraints provided. + * + * @param {Object} constraints specifies whether the stream should have + * audio, video, or both + * @param {MediaStream} mediaStream the media stream being checked + */ +function checkMediaStreamTracks(constraints, mediaStream) { + checkMediaStreamTracksByType( + constraints, + "audio", + mediaStream.getAudioTracks() + ); + checkMediaStreamTracksByType( + constraints, + "video", + mediaStream.getVideoTracks() + ); +} + +/** + * Check that a media stream contains exactly a set of media stream tracks. + * + * @param {MediaStream} mediaStream the media stream being checked + * @param {Array} tracks the tracks that should exist in mediaStream + * @param {String} [message] an optional message to pass to asserts + */ +function checkMediaStreamContains(mediaStream, tracks, message) { + message = message ? message + ": " : ""; + tracks.forEach(t => + ok( + mediaStream.getTrackById(t.id), + message + "MediaStream " + mediaStream.id + " contains track " + t.id + ) + ); + is( + mediaStream.getTracks().length, + tracks.length, + message + "MediaStream " + mediaStream.id + " contains no extra tracks" + ); +} + +function checkMediaStreamCloneAgainstOriginal(clone, original) { + isnot(clone.id.length, 0, "Stream clone should have an id string"); + isnot(clone, original, "Stream clone should be different from the original"); + isnot( + clone.id, + original.id, + "Stream clone's id should be different from the original's" + ); + is( + clone.getAudioTracks().length, + original.getAudioTracks().length, + "All audio tracks should get cloned" + ); + is( + clone.getVideoTracks().length, + original.getVideoTracks().length, + "All video tracks should get cloned" + ); + is(clone.active, original.active, "Active state should be preserved"); + original + .getTracks() + .forEach(t => + ok(!clone.getTrackById(t.id), "The clone's tracks should be originals") + ); +} + +function checkMediaStreamTrackCloneAgainstOriginal(clone, original) { + isnot(clone.id.length, 0, "Track clone should have an id string"); + isnot(clone, original, "Track clone should be different from the original"); + isnot( + clone.id, + original.id, + "Track clone's id should be different from the original's" + ); + is( + clone.kind, + original.kind, + "Track clone's kind should be same as the original's" + ); + is( + clone.enabled, + original.enabled, + "Track clone's kind should be same as the original's" + ); + is( + clone.readyState, + original.readyState, + "Track clone's readyState should be same as the original's" + ); + is( + clone.muted, + original.muted, + "Track clone's muted state should be same as the original's" + ); +} + +/*** Utility methods */ + +/** The dreadful setTimeout, use sparingly */ +function wait(time, message) { + return new Promise(r => setTimeout(() => r(message), time)); +} + +/** The even more dreadful setInterval, use even more sparingly */ +function waitUntil(func, time) { + return new Promise(resolve => { + var interval = setInterval(() => { + if (func()) { + clearInterval(interval); + resolve(); + } + }, time || 200); + }); +} + +/** Time out while waiting for a promise to get resolved or rejected. */ +var timeout = (promise, time, msg) => + Promise.race([ + promise, + wait(time).then(() => Promise.reject(new Error(msg))), + ]); + +/** Adds a |finally| function to a promise whose argument is invoked whether the + * promise is resolved or rejected, and that does not interfere with chaining.*/ +var addFinallyToPromise = promise => { + promise.finally = func => { + return promise.then( + result => { + func(); + return Promise.resolve(result); + }, + error => { + func(); + return Promise.reject(error); + } + ); + }; + return promise; +}; + +/** Use event listener to call passed-in function on fire until it returns true */ +var listenUntil = (target, eventName, onFire) => { + return new Promise(resolve => + target.addEventListener(eventName, function callback(event) { + var result = onFire(event); + if (result) { + target.removeEventListener(eventName, callback); + resolve(result); + } + }) + ); +}; + +/* Test that a function throws the right error */ +function mustThrowWith(msg, reason, f) { + try { + f(); + ok(false, msg + " must throw"); + } catch (e) { + is(e.name, reason, msg + " must throw: " + e.message); + } +} + +/* Get a dummy audio track */ +function getSilentTrack() { + let ctx = new AudioContext(), + oscillator = ctx.createOscillator(); + let dst = oscillator.connect(ctx.createMediaStreamDestination()); + oscillator.start(); + return Object.assign(dst.stream.getAudioTracks()[0], { enabled: false }); +} + +function getBlackTrack({ width = 640, height = 480 } = {}) { + let canvas = Object.assign(document.createElement("canvas"), { + width, + height, + }); + canvas.getContext("2d").fillRect(0, 0, width, height); + let stream = canvas.captureStream(); + return Object.assign(stream.getVideoTracks()[0], { enabled: false }); +} + +/*** Test control flow methods */ + +/** + * Generates a callback function fired only under unexpected circumstances + * while running the tests. The generated function kills off the test as well + * gracefully. + * + * @param {String} [message] + * An optional message to show if no object gets passed into the + * generated callback method. + */ +function generateErrorCallback(message) { + var stack = new Error().stack.split("\n"); + stack.shift(); // Don't include this instantiation frame + + /** + * @param {object} aObj + * The object fired back from the callback + */ + return aObj => { + if (aObj) { + if (aObj.name && aObj.message) { + ok( + false, + "Unexpected callback for '" + + aObj.name + + "' with message = '" + + aObj.message + + "' at " + + JSON.stringify(stack) + ); + } else { + ok( + false, + "Unexpected callback with = '" + + aObj + + "' at: " + + JSON.stringify(stack) + ); + } + } else { + ok( + false, + "Unexpected callback with message = '" + + message + + "' at: " + + JSON.stringify(stack) + ); + } + throw new Error("Unexpected callback"); + }; +} + +var unexpectedEventArrived; +var rejectOnUnexpectedEvent = new Promise((x, reject) => { + unexpectedEventArrived = reject; +}); + +/** + * Generates a callback function fired only for unexpected events happening. + * + * @param {String} description + Description of the object for which the event has been fired + * @param {String} eventName + Name of the unexpected event + */ +function unexpectedEvent(message, eventName) { + var stack = new Error().stack.split("\n"); + stack.shift(); // Don't include this instantiation frame + + return e => { + var details = + "Unexpected event '" + + eventName + + "' fired with message = '" + + message + + "' at: " + + JSON.stringify(stack); + ok(false, details); + unexpectedEventArrived(new Error(details)); + }; +} + +/** + * Implements the one-shot event pattern used throughout. Each of the 'onxxx' + * attributes on the wrappers can be set with a custom handler. Prior to the + * handler being set, if the event fires, it causes the test execution to halt. + * That handler is used exactly once, after which the original, error-generating + * handler is re-installed. Thus, each event handler is used at most once. + * + * @param {object} wrapper + * The wrapper on which the psuedo-handler is installed + * @param {object} obj + * The real source of events + * @param {string} event + * The name of the event + */ +function createOneShotEventWrapper(wrapper, obj, event) { + var onx = "on" + event; + var unexpected = unexpectedEvent(wrapper, event); + wrapper[onx] = unexpected; + obj[onx] = e => { + info(wrapper + ': "on' + event + '" event fired'); + e.wrapper = wrapper; + wrapper[onx](e); + wrapper[onx] = unexpected; + }; +} + +/** + * Returns a promise that resolves when `target` has raised an event with the + * given name the given number of times. Cancel the returned promise by passing + * in a `cancel` promise and resolving it. + * + * @param {object} target + * The target on which the event should occur. + * @param {string} name + * The name of the event that should occur. + * @param {integer} count + * Optional number of times the event should be raised before resolving. + * @param {promise} cancel + * Optional promise that on resolving rejects the returned promise, + * so we can avoid logging results after a test has finished. + * @returns {promise} A promise that resolves to the last of the seen events. + */ +function haveEvents(target, name, count, cancel) { + var listener; + var counter = count || 1; + return Promise.race([ + (cancel || new Promise(() => {})).then(e => Promise.reject(e)), + new Promise(resolve => + target.addEventListener( + name, + (listener = e => --counter < 1 && resolve(e)) + ) + ), + ]).then(e => (target.removeEventListener(name, listener), e)); +} + +/** + * Returns a promise that resolves when `target` has raised an event with the + * given name. Cancel the returned promise by passing in a `cancel` promise and + * resolving it. + * + * @param {object} target + * The target on which the event should occur. + * @param {string} name + * The name of the event that should occur. + * @param {promise} cancel + * Optional promise that on resolving rejects the returned promise, + * so we can avoid logging results after a test has finished. + * @returns {promise} A promise that resolves to the seen event. + */ +function haveEvent(target, name, cancel) { + return haveEvents(target, name, 1, cancel); +} + +/** + * Returns a promise that resolves if the target has not seen the given event + * after one crank (or until the given timeoutPromise resolves) of the event + * loop. + * + * @param {object} target + * The target on which the event should not occur. + * @param {string} name + * The name of the event that should not occur. + * @param {promise} timeoutPromise + * Optional promise defining how long we should wait before resolving. + * @returns {promise} A promise that is rejected if we see the given event, or + * resolves after a timeout otherwise. + */ +function haveNoEvent(target, name, timeoutPromise) { + return haveEvent(target, name, timeoutPromise || wait(0)).then( + () => Promise.reject(new Error("Too many " + name + " events")), + () => {} + ); +} + +/** + * Returns a promise that resolves after the target has seen the given number + * of events but no such event in a following crank of the event loop. + * + * @param {object} target + * The target on which the events should occur. + * @param {string} name + * The name of the event that should occur. + * @param {integer} count + * Optional number of times the event should be raised before resolving. + * @param {promise} cancel + * Optional promise that on resolving rejects the returned promise, + * so we can avoid logging results after a test has finished. + * @returns {promise} A promise that resolves to the last of the seen events. + */ +function haveEventsButNoMore(target, name, count, cancel) { + return haveEvents(target, name, count, cancel).then(e => + haveNoEvent(target, name).then(() => e) + ); +} + +/* + * Resolves the returned promise with an object with usage and reportCount + * properties. `usage` is in the same units as reported by the reporter for + * `path`. + */ +const collectMemoryUsage = async path => { + const MemoryReporterManager = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + + let usage = 0; + let reportCount = 0; + await new Promise(resolve => + MemoryReporterManager.getReports( + (aProcess, aPath, aKind, aUnits, aAmount, aDesc) => { + if (aPath != path) { + return; + } + ++reportCount; + usage += aAmount; + }, + null, + resolve, + null, + /* anonymized = */ false + ) + ); + return { usage, reportCount }; +}; + +// Some DNS helper functions +const dnsLookup = async hostname => { + // Convenience API for various networking related stuff. _Almost_ convenient + // enough. + const neckoDashboard = SpecialPowers.Cc[ + "@mozilla.org/network/dashboard;1" + ].getService(Ci.nsIDashboard); + + const results = await new Promise(r => { + neckoDashboard.requestDNSLookup(hostname, results => { + r(SpecialPowers.wrap(results)); + }); + }); + + // |address| is an array-like dictionary (ie; keys are all integers). + // We convert to an array to make it less unwieldy. + const addresses = [...results.address]; + info(`DNS results for ${hostname}: ${JSON.stringify(addresses)}`); + return addresses; +}; + +const dnsLookupV4 = async hostname => { + const addresses = await dnsLookup(hostname); + return addresses.filter(address => !address.includes(":")); +}; + +const dnsLookupV6 = async hostname => { + const addresses = await dnsLookup(hostname); + return addresses.filter(address => address.includes(":")); +}; + +const getTurnHostname = turnUrl => { + const urlNoParams = turnUrl.split("?")[0]; + // Strip off scheme + const hostAndMaybePort = urlNoParams.split(":", 2)[1]; + if (hostAndMaybePort[0] == "[") { + // IPV6 literal, strip out '[', and split at closing ']' + return hostAndMaybePort.substring(1).split("]")[0]; + } + return hostAndMaybePort.split(":")[0]; +}; + +// Yo dawg I heard you like Proxies +// Example: let value = await GleanTest.category.metric.testGetValue(); +const GleanTest = new Proxy( + {}, + { + get(target, categoryName, receiver) { + return new Proxy( + {}, + { + get(target, metricName, receiver) { + return { + // The only API we actually implement right now. + async testGetValue() { + return SpecialPowers.spawnChrome( + [categoryName, metricName], + async (categoryName, metricName) => { + await Services.fog.testFlushAllChildren(); + const window = this.browsingContext.topChromeWindow; + return window.Glean[categoryName][ + metricName + ].testGetValue(); + } + ); + }, + }; + }, + } + ); + }, + } +); + +/** + * This class executes a series of functions in a continuous sequence. + * Promise-bearing functions are executed after the previous promise completes. + * + * @constructor + * @param {object} framework + * A back reference to the framework which makes use of the class. It is + * passed to each command callback. + * @param {function[]} commandList + * Commands to set during initialization + */ +function CommandChain(framework, commandList) { + this._framework = framework; + this.commands = commandList || []; +} + +CommandChain.prototype = { + /** + * Start the command chain. This returns a promise that always resolves + * cleanly (this catches errors and fails the test case). + */ + execute() { + return this.commands + .reduce((prev, next, i) => { + if (typeof next !== "function" || !next.name) { + throw new Error("registered non-function" + next); + } + + return prev.then(() => { + info("Run step " + (i + 1) + ": " + next.name); + return Promise.race([next(this._framework), rejectOnUnexpectedEvent]); + }); + }, Promise.resolve()) + .catch(e => + ok( + false, + "Error in test execution: " + + e + + (typeof e.stack === "string" + ? " " + e.stack.split("\n").join(" ... ") + : "") + ) + ); + }, + + /** + * Add new commands to the end of the chain + */ + append(commands) { + this.commands = this.commands.concat(commands); + }, + + /** + * Returns the index of the specified command in the chain. + * @param {occurrence} Optional param specifying which occurrence to match, + * with 0 representing the first occurrence. + */ + indexOf(functionOrName, occurrence) { + occurrence = occurrence || 0; + return this.commands.findIndex(func => { + if (typeof functionOrName === "string") { + if (func.name !== functionOrName) { + return false; + } + } else if (func !== functionOrName) { + return false; + } + if (occurrence) { + --occurrence; + return false; + } + return true; + }); + }, + + mustHaveIndexOf(functionOrName, occurrence) { + var index = this.indexOf(functionOrName, occurrence); + if (index == -1) { + throw new Error("Unknown test: " + functionOrName); + } + return index; + }, + + /** + * Inserts the new commands after the specified command. + */ + insertAfter(functionOrName, commands, all, occurrence) { + this._insertHelper(functionOrName, commands, 1, all, occurrence); + }, + + /** + * Inserts the new commands after every occurrence of the specified command + */ + insertAfterEach(functionOrName, commands) { + this._insertHelper(functionOrName, commands, 1, true); + }, + + /** + * Inserts the new commands before the specified command. + */ + insertBefore(functionOrName, commands, all, occurrence) { + this._insertHelper(functionOrName, commands, 0, all, occurrence); + }, + + _insertHelper(functionOrName, commands, delta, all, occurrence) { + occurrence = occurrence || 0; + for ( + var index = this.mustHaveIndexOf(functionOrName, occurrence); + index !== -1; + index = this.indexOf(functionOrName, ++occurrence) + ) { + this.commands = [].concat( + this.commands.slice(0, index + delta), + commands, + this.commands.slice(index + delta) + ); + if (!all) { + break; + } + } + }, + + /** + * Removes the specified command, returns what was removed. + */ + remove(functionOrName, occurrence) { + return this.commands.splice( + this.mustHaveIndexOf(functionOrName, occurrence), + 1 + ); + }, + + /** + * Removes all commands after the specified one, returns what was removed. + */ + removeAfter(functionOrName, occurrence) { + return this.commands.splice( + this.mustHaveIndexOf(functionOrName, occurrence) + 1 + ); + }, + + /** + * Removes all commands before the specified one, returns what was removed. + */ + removeBefore(functionOrName, occurrence) { + return this.commands.splice( + 0, + this.mustHaveIndexOf(functionOrName, occurrence) + ); + }, + + /** + * Replaces a single command, returns what was removed. + */ + replace(functionOrName, commands) { + this.insertBefore(functionOrName, commands); + return this.remove(functionOrName); + }, + + /** + * Replaces all commands after the specified one, returns what was removed. + */ + replaceAfter(functionOrName, commands, occurrence) { + var oldCommands = this.removeAfter(functionOrName, occurrence); + this.append(commands); + return oldCommands; + }, + + /** + * Replaces all commands before the specified one, returns what was removed. + */ + replaceBefore(functionOrName, commands) { + var oldCommands = this.removeBefore(functionOrName); + this.insertBefore(functionOrName, commands); + return oldCommands; + }, + + /** + * Remove all commands whose name match the specified regex. + */ + filterOut(id_match) { + this.commands = this.commands.filter(c => !id_match.test(c.name)); + }, +}; + +function AudioStreamHelper() { + this._context = new AudioContext(); +} + +AudioStreamHelper.prototype = { + checkAudio(stream, analyser, fun) { + /* + analyser.enableDebugCanvas(); + return analyser.waitForAnalysisSuccess(fun) + .then(() => analyser.disableDebugCanvas()); + */ + return analyser.waitForAnalysisSuccess(fun); + }, + + checkAudioFlowing(stream) { + var analyser = new AudioStreamAnalyser(this._context, stream); + var freq = analyser.binIndexForFrequency(TEST_AUDIO_FREQ); + return this.checkAudio(stream, analyser, array => array[freq] > 200); + }, + + checkAudioNotFlowing(stream) { + var analyser = new AudioStreamAnalyser(this._context, stream); + var freq = analyser.binIndexForFrequency(TEST_AUDIO_FREQ); + return this.checkAudio(stream, analyser, array => array[freq] < 50); + }, +}; + +class VideoFrameEmitter { + constructor(color1, color2, width, height) { + if (!width) { + width = 50; + } + if (!height) { + height = width; + } + this._helper = new CaptureStreamTestHelper2D(width, height); + this._canvas = this._helper.createAndAppendElement( + "canvas", + "source_canvas" + ); + this._canvas.width = width; + this._canvas.height = height; + this._color1 = color1 ? color1 : this._helper.green; + this._color2 = color2 ? color2 : this._helper.red; + // Make sure this is initted + this._helper.drawColor(this._canvas, this._color1); + this._stream = this._canvas.captureStream(); + this._started = false; + } + + stream() { + return this._stream; + } + + helper() { + return this._helper; + } + + colors(color1, color2) { + this._color1 = color1 ? color1 : this._helper.green; + this._color2 = color2 ? color2 : this._helper.red; + try { + this._helper.drawColor(this._canvas, this._color1); + } catch (e) { + // ignore; stream might have shut down + } + } + + size(width, height) { + this._canvas.width = width; + this._canvas.height = height; + } + + start() { + if (this._started) { + info("*** emitter already started"); + return; + } + + let i = 0; + this._started = true; + this._intervalId = setInterval(() => { + try { + this._helper.drawColor(this._canvas, i ? this._color1 : this._color2); + i = 1 - i; + } catch (e) { + // ignore; stream might have shut down, and we don't bother clearing + // the setInterval. + } + }, 500); + } + + stop() { + if (this._started) { + clearInterval(this._intervalId); + this._started = false; + } + } +} + +class VideoStreamHelper { + constructor() { + this._helper = new CaptureStreamTestHelper2D(50, 50); + } + + async checkHasFrame(video, { offsetX, offsetY, threshold } = {}) { + const h = this._helper; + await h.waitForPixel( + video, + px => { + let result = h.isOpaquePixelNot(px, h.black, threshold); + info( + "Checking that we have a frame, got [" + + Array.from(px) + + "]. Ref=[" + + Array.from(h.black.data) + + "]. Threshold=" + + threshold + + ". Pass=" + + result + ); + return result; + }, + { offsetX, offsetY } + ); + } + + async checkVideoPlaying( + video, + { offsetX = 10, offsetY = 10, threshold = 16 } = {} + ) { + const h = this._helper; + await this.checkHasFrame(video, { offsetX, offsetY, threshold }); + let startPixel = { + data: h.getPixel(video, offsetX, offsetY), + name: "startcolor", + }; + await h.waitForPixel( + video, + px => { + let result = h.isPixelNot(px, startPixel, threshold); + info( + "Checking playing, [" + + Array.from(px) + + "] vs [" + + Array.from(startPixel.data) + + "]. Threshold=" + + threshold + + " Pass=" + + result + ); + return result; + }, + { offsetX, offsetY } + ); + } + + async checkVideoPaused( + video, + { offsetX = 10, offsetY = 10, threshold = 16, time = 5000 } = {} + ) { + const h = this._helper; + await this.checkHasFrame(video, { offsetX, offsetY, threshold }); + let startPixel = { + data: h.getPixel(video, offsetX, offsetY), + name: "startcolor", + }; + try { + await h.waitForPixel( + video, + px => { + let result = h.isOpaquePixelNot(px, startPixel, threshold); + info( + "Checking paused, [" + + Array.from(px) + + "] vs [" + + Array.from(startPixel.data) + + "]. Threshold=" + + threshold + + " Pass=" + + result + ); + return result; + }, + { offsetX, offsetY, cancel: wait(time, "timeout") } + ); + ok(false, "Frame changed within " + time / 1000 + " seconds"); + } catch (e) { + is( + e, + "timeout", + "Frame shouldn't change for " + time / 1000 + " seconds" + ); + } + } +} + +(function() { + var el = document.createElement("link"); + el.rel = "stylesheet"; + el.type = "text/css"; + el.href = "/tests/SimpleTest/test.css"; + document.head.appendChild(el); +})(); |