summaryrefslogtreecommitdiffstats
path: root/dom/media/webrtc/tests/mochitests/head.js
diff options
context:
space:
mode:
Diffstat (limited to 'dom/media/webrtc/tests/mochitests/head.js')
-rw-r--r--dom/media/webrtc/tests/mochitests/head.js1414
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);
+})();