1471 lines
44 KiB
JavaScript
1471 lines
44 KiB
JavaScript
/* 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();
|
|
|
|
/**
|
|
* 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 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 * 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 getUserMedia callback
|
|
*/
|
|
function getUserMedia(constraints) {
|
|
if (!constraints.fake && constraints.audio) {
|
|
// 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
|
|
);
|
|
}
|
|
info("Call getUserMedia for " + JSON.stringify(constraints));
|
|
return navigator.mediaDevices.getUserMedia(constraints).then(stream => {
|
|
checkMediaStreamTracks(constraints, stream);
|
|
return 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.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.audio.capture.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", ""],
|
|
["media.peerconnection.treat_warnings_as_errors", true],
|
|
],
|
|
};
|
|
|
|
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.getIntPref("media.webrtc.encoder_creation_strategy") == 1;
|
|
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() {
|
|
// Has hardware H.264 encoder support
|
|
const hardware =
|
|
SpecialPowers.getIntPref("media.webrtc.encoder_creation_strategy") == 1 &&
|
|
(navigator.userAgent.includes("Android") ||
|
|
navigator.userAgent.includes("Mac OS X"));
|
|
|
|
await pushPrefs(
|
|
["media.webrtc.encoder_creation_strategy", hardware ? 1 : 0],
|
|
["media.navigator.mediadatadecoder_h264_enabled", hardware]
|
|
);
|
|
|
|
const software = !navigator.userAgent.includes("Android");
|
|
return {
|
|
hardware,
|
|
software,
|
|
any: hardware || software,
|
|
};
|
|
}
|
|
|
|
async function matchPlatformAV1CodecPrefs() {
|
|
// Has hardware AV1 encoder support
|
|
const hardware = false;
|
|
const software = !navigator.userAgent.includes("Android");
|
|
await pushPrefs(["media.webrtc.codec.video.av1.enabled", software]);
|
|
return {
|
|
hardware,
|
|
software,
|
|
any: hardware || software,
|
|
};
|
|
}
|
|
|
|
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);
|
|
return 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 yo dawg I heard you like Proxies
|
|
// Example: let value = await GleanTest.category.metric.testGetValue();
|
|
// For labeled metrics:
|
|
// let value = await GleanTest.category.metric["label"].testGetValue();
|
|
// Please don't try to use the string "testGetValue" as a label.
|
|
const GleanTest = new Proxy(
|
|
{},
|
|
{
|
|
get(target, categoryName, receiver) {
|
|
return new Proxy(
|
|
{},
|
|
{
|
|
get(target, metricName, receiver) {
|
|
return new Proxy(
|
|
{
|
|
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();
|
|
}
|
|
);
|
|
},
|
|
},
|
|
{
|
|
get(target, prop, receiver) {
|
|
// The only prop that will be there is testGetValue, but we
|
|
// might add more later.
|
|
if (prop in target) {
|
|
return target[prop];
|
|
}
|
|
|
|
// |prop| must be a label?
|
|
const label = prop;
|
|
return {
|
|
async testGetValue() {
|
|
return SpecialPowers.spawnChrome(
|
|
[categoryName, metricName, label],
|
|
async (categoryName, metricName, label) => {
|
|
await Services.fog.testFlushAllChildren();
|
|
const window = this.browsingContext.topChromeWindow;
|
|
return window.Glean[categoryName][metricName][
|
|
label
|
|
].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 AudioStreamFlowingHelper() {
|
|
this._context = new AudioContext();
|
|
// Tests may have changed the values of prefs, so recheck
|
|
updateConfigFromFakeAndLoopbackPrefs();
|
|
if (!WANT_FAKE_AUDIO) {
|
|
// Loopback device is configured, start the default loopback tone
|
|
if (!DefaultLoopbackTone) {
|
|
DefaultLoopbackTone = new LoopbackTone(this._context, TEST_AUDIO_FREQ);
|
|
DefaultLoopbackTone.start();
|
|
}
|
|
}
|
|
}
|
|
|
|
AudioStreamFlowingHelper.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);
|
|
},
|
|
|
|
// Use checkAudioNotFlowing() only after checkAudioFlowing() or similar to
|
|
// know that audio had previously been flowing on the same stream, as
|
|
// checkAudioNotFlowing() does not wait for the loopback device to return
|
|
// any audio that it receives.
|
|
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, { fillEntireFrame = false } = {}) {
|
|
if (!width) {
|
|
width = 64;
|
|
}
|
|
if (!height) {
|
|
height = width;
|
|
}
|
|
this._helper = new CaptureStreamTestHelper2D(width, height);
|
|
this._fillEntireFrame = fillEntireFrame;
|
|
this._canvas = this._helper.createAndAppendElement(
|
|
"canvas",
|
|
"source_canvas"
|
|
);
|
|
this._canvas.width = width;
|
|
this._canvas.height = height;
|
|
this._drawColorOptions = {
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
width: fillEntireFrame ? width : width / 2,
|
|
height: fillEntireFrame ? height : height / 2,
|
|
};
|
|
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._drawColorOptions);
|
|
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,
|
|
this._drawColorOptions
|
|
);
|
|
} catch (e) {
|
|
// ignore; stream might have shut down
|
|
}
|
|
}
|
|
|
|
size(width, height) {
|
|
this._canvas.width = width;
|
|
this._canvas.height = height;
|
|
this._drawColorOptions.width = this._fillEntireFrame ? width : width / 2;
|
|
this._drawColorOptions.height = this._fillEntireFrame ? height : height / 2;
|
|
}
|
|
|
|
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,
|
|
this._drawColorOptions
|
|
);
|
|
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);
|
|
})();
|