bug 815002 - allow using loopback devices in WebRTC mochitests on Linux when available. r=jsmith,jmaher
authorTed Mielczarek <ted@mielczarek.org>
Thu, 01 May 2014 07:18:00 -0400
changeset 194700 575f54b18f5d970372e5c0fb2f20271b05f00c22
parent 194699 c589ff189068bc330da3cdcd7bef158a1586f97e
child 194701 fa947e53a79013b942741c4c3c6bca8dcdead9ba
push idunknown
push userunknown
push dateunknown
reviewersjsmith, jmaher
bugs815002
milestone32.0a1
bug 815002 - allow using loopback devices in WebRTC mochitests on Linux when available. r=jsmith,jmaher
dom/media/tests/mochitest/head.js
testing/mochitest/mach_commands.py
testing/mochitest/mochitest_options.py
testing/mochitest/runtests.py
--- a/dom/media/tests/mochitest/head.js
+++ b/dom/media/tests/mochitest/head.js
@@ -3,16 +3,26 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 var Cc = SpecialPowers.Cc;
 var Ci = SpecialPowers.Ci;
 var Cr = SpecialPowers.Cr;
 
 // Specifies whether we are using fake streams to run this automation
 var FAKE_ENABLED = true;
+try {
+  var audioDevice = SpecialPowers.getCharPref('media.audio_loopback_dev');
+  var videoDevice = SpecialPowers.getCharPref('media.video_loopback_dev');
+  dump('TEST DEVICES: Using media devices:\n');
+  dump('audio: ' + audioDevice + '\nvideo: ' + videoDevice + '\n');
+  FAKE_ENABLED = false;
+} catch (e) {
+  dump('TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n');
+  FAKE_ENABLED = true;
+}
 
 
 /**
  * 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
@@ -92,17 +102,17 @@ function createMediaElement(type, label)
  * @param {Dictionary} constraints
  *        The constraints for this mozGetUserMedia callback
  * @param {Function} onSuccess
  *        The success callback if the stream is successfully retrieved
  * @param {Function} onError
  *        The error callback if the stream fails to be retrieved
  */
 function getUserMedia(constraints, onSuccess, onError) {
-  if (!("fake" in constraints)) {
+  if (!("fake" in constraints) && FAKE_ENABLED) {
     constraints["fake"] = FAKE_ENABLED;
   }
 
   info("Call getUserMedia for " + JSON.stringify(constraints));
   navigator.mozGetUserMedia(constraints, onSuccess, onError);
 }
 
 
--- a/testing/mochitest/mach_commands.py
+++ b/testing/mochitest/mach_commands.py
@@ -185,17 +185,18 @@ class MochitestRunner(MozbuildObject):
 
     def run_desktop_test(self, context, suite=None, test_paths=None, debugger=None,
         debugger_args=None, slowscript=False, screenshot_on_fail = False, shuffle=False, keep_open=False,
         rerun_failures=False, no_autorun=False, repeat=0, run_until_failure=False,
         slow=False, chunk_by_dir=0, total_chunks=None, this_chunk=None,
         jsdebugger=False, debug_on_failure=False, start_at=None, end_at=None,
         e10s=False, dmd=False, dump_output_directory=None,
         dump_about_memory_after_test=False, dump_dmd_after_test=False,
-        install_extension=None, quiet=False, environment=[], app_override=None, **kwargs):
+        install_extension=None, quiet=False, environment=[], app_override=None,
+        useTestMediaDevices=False, **kwargs):
         """Runs a mochitest.
 
         test_paths are path to tests. They can be a relative path from the
         top source directory, an absolute filename, or a directory containing
         test files.
 
         suite is the type of mochitest to run. It can be one of ('plain',
         'chrome', 'browser', 'metro', 'a11y').
@@ -310,16 +311,17 @@ class MochitestRunner(MozbuildObject):
         options.startAt = start_at
         options.endAt = end_at
         options.e10s = e10s
         options.dumpAboutMemoryAfterTest = dump_about_memory_after_test
         options.dumpDMDAfterTest = dump_dmd_after_test
         options.dumpOutputDirectory = dump_output_directory
         options.quiet = quiet
         options.environment = environment
+        options.useTestMediaDevices = useTestMediaDevices
 
         options.failureFile = failure_file_path
         if install_extension != None:
             options.extensionsToInstall = [os.path.join(self.topsrcdir,install_extension)]
 
         for k, v in kwargs.iteritems():
             setattr(options, k, v)
 
@@ -521,16 +523,22 @@ def MochitestCommand(func):
         help='Do not print test log lines unless a failure occurs.')
     func = quiet(func)
 
     setenv = CommandArgument('--setenv', default=[], action='append',
                              metavar='NAME=VALUE', dest='environment',
                              help="Sets the given variable in the application's environment")
     func = setenv(func)
 
+    test_media = CommandArgument('--use-test-media-devices', default=False,
+                                 action='store_true',
+                                 dest='useTestMediaDevices',
+        help='Use test media device drivers for media testing.')
+    func = test_media(func)
+
     app_override = CommandArgument('--app-override', default=None, action='store',
         help="Override the default binary used to run tests with the path you provide, e.g. " \
             " --app-override /usr/bin/firefox . " \
             "If you have run ./mach package beforehand, you can specify 'dist' to " \
             "run tests against the distribution bundle's binary.");
     func = app_override(func)
 
     return func
--- a/testing/mochitest/mochitest_options.py
+++ b/testing/mochitest/mochitest_options.py
@@ -411,16 +411,22 @@ class MochitestOptions(optparse.OptionPa
          }],
         [["--pidfile"],
         { "action": "store",
           "type": "string",
           "dest": "pidFile",
           "help": "name of the pidfile to generate",
           "default": "",
         }],
+        [["--use-test-media-devices"],
+        { "action": "store_true",
+          "default": False,
+          "dest": "useTestMediaDevices",
+          "help": "Use test media device drivers for media testing.",
+        }],
     ]
 
     def __init__(self, **kwargs):
 
         optparse.OptionParser.__init__(self, **kwargs)
         for option, value in self.mochitest_options:
             self.add_option(*option, **value)
         addCommonOptions(self)
@@ -569,16 +575,23 @@ class MochitestOptions(optparse.OptionPa
         if options.dumpOutputDirectory is None:
             options.dumpOutputDirectory = tempfile.gettempdir()
 
         if options.dumpAboutMemoryAfterTest or options.dumpDMDAfterTest:
             if not os.path.isdir(options.dumpOutputDirectory):
                 self.error('--dump-output-directory not a directory: %s' %
                            options.dumpOutputDirectory)
 
+        if options.useTestMediaDevices:
+            if not mozinfo.isLinux:
+                self.error('--use-test-media-devices is only supported on Linux currently')
+            for f in ['/usr/bin/gst-launch-0.10', '/usr/bin/pactl']:
+                if not os.path.isfile(f):
+                    self.error('Missing binary %s required for --use-test-media-devices')
+
         return options
 
 
 class B2GOptions(MochitestOptions):
     b2g_options = [
         [["--b2gpath"],
         { "action": "store",
           "type": "string",
--- a/testing/mochitest/runtests.py
+++ b/testing/mochitest/runtests.py
@@ -7,16 +7,17 @@ Runs the Mochitest test harness.
 """
 
 from __future__ import with_statement
 import os
 import sys
 SCRIPT_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__)))
 sys.path.insert(0, SCRIPT_DIR);
 
+import ctypes
 import glob
 import json
 import mozcrash
 import mozinfo
 import mozprocess
 import mozrunner
 import optparse
 import re
@@ -749,21 +750,133 @@ class SSLTunnel:
 
   def stop(self):
     """ Stops the SSL Tunnel and cleans up """
     if self.process is not None:
       self.process.kill()
     if os.path.exists(self.configFile):
       os.remove(self.configFile)
 
+def checkAndConfigureV4l2loopback(device):
+  '''
+  Determine if a given device path is a v4l2loopback device, and if so
+  toggle a few settings on it via fcntl. Very linux-specific.
+
+  Returns (status, device name) where status is a boolean.
+  '''
+  if not mozinfo.isLinux:
+    return False, ''
+
+  libc = ctypes.cdll.LoadLibrary('libc.so.6')
+  O_RDWR = 2
+  # These are from linux/videodev2.h
+  class v4l2_capability(ctypes.Structure):
+    _fields_ = [
+      ('driver', ctypes.c_char * 16),
+      ('card', ctypes.c_char * 32),
+      ('bus_info', ctypes.c_char * 32),
+      ('version', ctypes.c_uint32),
+      ('capabilities', ctypes.c_uint32),
+      ('device_caps', ctypes.c_uint32),
+      ('reserved', ctypes.c_uint32 * 3)
+      ]
+  VIDIOC_QUERYCAP = 0x80685600
+
+  fd = libc.open(device, O_RDWR)
+  if fd < 0:
+    return False, ''
+
+  vcap = v4l2_capability()
+  if libc.ioctl(fd, VIDIOC_QUERYCAP, ctypes.byref(vcap)) != 0:
+    return False, ''
+
+  if vcap.driver != 'v4l2 loopback':
+    return False, ''
+
+  class v4l2_control(ctypes.Structure):
+    _fields_ = [
+      ('id', ctypes.c_uint32),
+      ('value', ctypes.c_int32)
+    ]
+
+  # These are private v4l2 control IDs, see:
+  # https://github.com/umlaeute/v4l2loopback/blob/fd822cf0faaccdf5f548cddd9a5a3dcebb6d584d/v4l2loopback.c#L131
+  KEEP_FORMAT = 0x8000000
+  SUSTAIN_FRAMERATE = 0x8000001
+  VIDIOC_S_CTRL = 0xc008561c
+
+  control = v4l2_control()
+  control.id = KEEP_FORMAT
+  control.value = 1
+  libc.ioctl(fd, VIDIOC_S_CTRL, ctypes.byref(control))
+
+  control.id = SUSTAIN_FRAMERATE
+  control.value = 1
+  libc.ioctl(fd, VIDIOC_S_CTRL, ctypes.byref(control))
+  libc.close(fd)
+
+  return True, vcap.card
+
+def findTestMediaDevices():
+  '''
+  Find the test media devices configured on this system, and return a dict
+  containing information about them. The dict will have keys for 'audio'
+  and 'video', each containing the name of the media device to use.
+
+  If audio and video devices could not be found, return None.
+
+  This method is only currently implemented for Linux.
+  '''
+  if not mozinfo.isLinux:
+    return None
+
+  info = {}
+  # Look for a v4l2loopback device.
+  name = None
+  device = None
+  for dev in sorted(glob.glob('/dev/video*')):
+    result, name_ = checkAndConfigureV4l2loopback(dev)
+    if result:
+      name = name_
+      device = dev
+      break
+
+  if not (name and device):
+    log.error('Couldn\'t find a v4l2loopback video device')
+    return None
+
+  # Feed it a frame of output so it has something to display
+  subprocess.check_call(['/usr/bin/gst-launch-0.10', 'videotestsrc',
+                         'pattern=green', 'num-buffers=1', '!',
+                         'v4l2sink', 'device=%s' % device])
+  info['video'] = name
+
+  # Use pactl to see if the PulseAudio module-sine-source module is loaded.
+  def sine_source_loaded():
+    o = subprocess.check_output(['/usr/bin/pactl', 'list', 'short', 'modules'])
+    return filter(lambda x: 'module-sine-source' in x, o.splitlines())
+
+  if not sine_source_loaded():
+    # Load module-sine-source
+    subprocess.check_call(['/usr/bin/pactl', 'load-module',
+                           'module-sine-source'])
+  if not sine_source_loaded():
+    log.error('Couldn\'t load module-sine-source')
+    return None
+
+  # Hardcode the name since it's always the same.
+  info['audio'] = 'Sine source at 440 Hz'
+  return info
+
 class Mochitest(MochitestUtilsMixin):
   certdbNew = False
   sslTunnel = None
   vmwareHelper = None
   DEFAULT_TIMEOUT = 60.0
+  mediaDevices = None
 
   # XXX use automation.py for test name to avoid breaking legacy
   # TODO: replace this with 'runtests.py' or 'mochitest' or the like
   test_name = 'automation.py'
 
   def __init__(self):
     super(Mochitest, self).__init__()
 
@@ -872,16 +985,21 @@ class Mochitest(MochitestUtilsMixin):
     # use SSL port for legacy compatibility; see
     # - https://bugzilla.mozilla.org/show_bug.cgi?id=688667#c66
     # - https://bugzilla.mozilla.org/show_bug.cgi?id=899221
     # - https://github.com/mozilla/mozbase/commit/43f9510e3d58bfed32790c82a57edac5f928474d
     #             'ws': str(self.webSocketPort)
              'ws': options.sslPort
              }
 
+    # See if we should use fake media devices.
+    if options.useTestMediaDevices:
+      prefs['media.audio_loopback_dev'] = self.mediaDevices['audio']
+      prefs['media.video_loopback_dev'] = self.mediaDevices['video']
+
 
     # create a profile
     self.profile = Profile(profile=options.profilePath,
                            addons=extensions,
                            locations=self.locations,
                            preferences=prefs,
                            apps=apps,
                            proxy=proxy
@@ -1220,16 +1338,23 @@ class Mochitest(MochitestUtilsMixin):
     #  'args': arguments to the debugger (list)
     # TODO: use mozrunner.local.debugger_arguments:
     # https://github.com/mozilla/mozbase/blob/master/mozrunner/mozrunner/local.py#L42
     debuggerInfo = getDebuggerInfo(self.oldcwd,
                                    options.debugger,
                                    options.debuggerArgs,
                                    options.debuggerInteractive)
 
+    if options.useTestMediaDevices:
+      devices = findTestMediaDevices()
+      if not devices:
+        log.error("Could not find test media devices to use")
+        return 1
+      self.mediaDevices = devices
+
     self.leak_report_file = os.path.join(options.profilePath, "runtests_leaks.log")
 
     browserEnv = self.buildBrowserEnv(options, debuggerInfo is not None)
     if browserEnv is None:
       return 1
 
     # buildProfile sets self.profile .
     # This relies on sideeffects and isn't very stateful: