1#!/usr/bin/env python3 2# Copyright 2021 The Chromium Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5""" 6This script is intended to cover end to end testing for the standalone sender 7and receiver executables in cast. This ensures that the basic functionality of 8these executables is not impaired, such as the TLS/UDP connections and encoding 9and decoding video. 10""" 11 12import argparse 13import os 14import pathlib 15import logging 16import subprocess 17import sys 18import time 19import unittest 20import ssl 21from collections import namedtuple 22 23from enum import IntEnum, IntFlag 24from urllib import request 25 26# Environment variables that can be overridden to set test properties. 27ROOT_ENVVAR = 'OPENSCREEN_ROOT_DIR' 28BUILD_ENVVAR = 'OPENSCREEN_BUILD_DIR' 29LIBAOM_ENVVAR = 'OPENSCREEN_HAVE_LIBAOM' 30 31TEST_VIDEO_NAME = 'Contador_Glam.mp4' 32# NOTE: we use the HTTP protocol instead of HTTPS due to certificate issues 33# in the legacy urllib.request API. 34TEST_VIDEO_URL = ('https://storage.googleapis.com/openscreen_standalone/' + 35 TEST_VIDEO_NAME) 36 37PROCESS_TIMEOUT = 15 # seconds 38 39# Open Screen test certificates expire after 3 days. We crop this slightly (by 40# 8 hours) to account for potential errors in time calculations. 41CERT_EXPIRY_AGE = (3 * 24 - 8) * 60 * 60 42 43# These properties are based on compiled settings in Open Screen, and should 44# not change without updating this file. 45TEST_CERT_NAME = 'generated_root_cast_receiver.crt' 46TEST_KEY_NAME = 'generated_root_cast_receiver.key' 47SENDER_BINARY_NAME = 'cast_sender' 48RECEIVER_BINARY_NAME = 'cast_receiver' 49 50EXPECTED_RECEIVER_MESSAGES = [ 51 "CastService is running.", "Found codec: opus (known to FFMPEG as opus)", 52 "Successfully negotiated a session, creating SDL players.", 53 "Receivers are currently destroying, resetting SDL players." 54] 55 56class VideoCodec(IntEnum): 57 """There are different messages printed by the receiver depending on the codec 58 chosen. """ 59 Vp8 = 0 60 Vp9 = 1 61 Av1 = 2 62 63VIDEO_CODEC_SPECIFIC_RECEIVER_MESSAGES = [ 64 "Found codec: vp8 (known to FFMPEG as vp8)", 65 "Found codec: vp9 (known to FFMPEG as vp9)", 66 "Found codec: libaom-av1 (known to FFMPEG as av1)" 67] 68 69EXPECTED_SENDER_MESSAGES = [ 70 "Launching Mirroring App on the Cast Receiver", 71 "Max allowed media bitrate (audio + video) will be", 72 "Contador_Glam.mp4 (starts in one second)...", 73 "The video capturer has reached the end of the media stream.", 74 "The audio capturer has reached the end of the media stream.", 75 "Video complete. Exiting...", "Shutting down..." 76] 77 78MISSING_LOG_MESSAGE = """Missing an expected message from either the sender 79or receiver. This either means that one of the binaries misbehaved, or you 80changed or deleted one of the log messages used for validation. Please ensure 81that the necessary log messages are left unchanged, or update this 82test suite's expectations.""" 83 84DESCRIPTION = """Runs end to end tests for the standalone Cast Streaming sender 85and receiver. By default, this script assumes it is being ran from a current 86working directory inside Open Screen's source directory, and uses 87<root_dir>/out/Default as the build directory. To override these, set the 88OPENSCREEN_ROOT_DIR and OPENSCREEN_BUILD_DIR environment variables. If the root 89directory is set and the build directory is not, 90<OPENSCREEN_ROOT_DIR>/out/Default will be used. In addition, if LibAOM is 91installed, one can choose to run AV1 tests by defining the 92OPENSCREEN_HAVE_LIBAOM environment variable. 93 94See below for the the help output generated by the `unittest` package.""" 95 96 97def _set_log_level(is_verbose): 98 """Sets the logging level, either DEBUG or ERROR as appropriate.""" 99 level = logging.DEBUG if is_verbose else logging.INFO 100 logging.basicConfig(stream=sys.stdout, level=level) 101 102 103def _get_loopback_adapter_name(): 104 """Retrieves the name of the loopback adapter (lo on Linux/lo0 on Mac).""" 105 if sys.platform == 'linux' or sys.platform == 'linux2': 106 return 'lo' 107 if sys.platform == 'darwin': 108 return 'lo0' 109 return None 110 111 112def _get_file_age_in_seconds(path): 113 """Get the age of a given file in seconds""" 114 # Time is stored in seconds since epoch 115 file_last_modified = 0 116 if path.exists(): 117 file_last_modified = path.stat().st_mtime 118 return time.time() - file_last_modified 119 120 121def _get_build_paths(): 122 """Gets the root and build paths (either default or from the environment 123 variables), and sets related paths to binaries and files.""" 124 root_path = pathlib.Path( 125 os.environ[ROOT_ENVVAR] if os.getenv(ROOT_ENVVAR) else subprocess. 126 getoutput('git rev-parse --show-toplevel')) 127 assert root_path.exists(), 'Could not find openscreen root!' 128 129 build_path = pathlib.Path(os.environ[BUILD_ENVVAR]) if os.getenv( 130 BUILD_ENVVAR) else root_path.joinpath('out', 131 'Default').resolve() 132 assert build_path.exists(), 'Could not find openscreen build!' 133 134 BuildPaths = namedtuple("BuildPaths", 135 "root build test_video cast_receiver cast_sender") 136 return BuildPaths(root = root_path, 137 build = build_path, 138 test_video = build_path.joinpath(TEST_VIDEO_NAME).resolve(), 139 cast_receiver = build_path.joinpath(RECEIVER_BINARY_NAME).resolve(), 140 cast_sender = build_path.joinpath(SENDER_BINARY_NAME).resolve() 141 ) 142 143 144class TestFlags(IntFlag): 145 """ 146 Test flags, primarily used to control sender and receiver configuration 147 to test different features of the standalone libraries. 148 """ 149 UseRemoting = 1 150 UseAndroidHack = 2 151 152 153class StandaloneCastTest(unittest.TestCase): 154 """ 155 Test class for setting up and running end to end tests on the 156 standalone sender and receiver binaries. This class uses the unittest 157 package, so methods that are executed as tests all have named prefixed 158 with "test_". 159 160 This suite sets the current working directory to the root of the Open 161 Screen repository, and references all files from the root directory. 162 Generated certificates should always be in |cls.build_paths.root|. 163 """ 164 165 @classmethod 166 def setUpClass(cls): 167 """Shared setup method for all tests, handles one-time updates.""" 168 cls.build_paths = _get_build_paths() 169 os.chdir(cls.build_paths.root) 170 cls.download_video() 171 cls.generate_certificates() 172 173 @classmethod 174 def download_video(cls): 175 """Downloads the test video from Google storage.""" 176 if os.path.exists(cls.build_paths.test_video): 177 logging.debug('Video already exists, skipping download...') 178 return 179 180 logging.debug('Downloading video from %s', TEST_VIDEO_URL) 181 with request.urlopen(TEST_VIDEO_URL, context=ssl.SSLContext()) as url: 182 with open(cls.build_paths.test_video, 'wb') as file: 183 file.write(url.read()) 184 185 @classmethod 186 def generate_certificates(cls): 187 """Generates test certificates using the cast receiver.""" 188 cert_age = _get_file_age_in_seconds(pathlib.Path(TEST_CERT_NAME)) 189 key_age = _get_file_age_in_seconds(pathlib.Path(TEST_KEY_NAME)) 190 if cert_age < CERT_EXPIRY_AGE and key_age < CERT_EXPIRY_AGE: 191 logging.debug('Credentials are up to date...') 192 return 193 194 logging.debug('Credentials out of date, generating new ones...') 195 try: 196 subprocess.check_output( 197 [ 198 cls.build_paths.cast_receiver, 199 '-g', # Generate certificate and private key. 200 '-v' # Enable verbose logging. 201 ], 202 stderr=subprocess.STDOUT) 203 except subprocess.CalledProcessError as e: 204 print('Generation failed with output: ', e.output.decode()) 205 raise 206 207 def launch_receiver(self): 208 """Launches the receiver process with discovery disabled.""" 209 logging.debug('Launching the receiver application...') 210 loopback = _get_loopback_adapter_name() 211 self.assertTrue(loopback) 212 213 #pylint: disable = consider-using-with 214 return subprocess.Popen( 215 [ 216 self.build_paths.cast_receiver, 217 '-d', 218 TEST_CERT_NAME, 219 '-p', 220 TEST_KEY_NAME, 221 '-x', # Skip discovery, only necessary on Mac OS X. 222 '-v', # Enable verbose logging. 223 loopback 224 ], 225 stdout=subprocess.PIPE, 226 stderr=subprocess.PIPE) 227 228 def launch_sender(self, flags, codec=None): 229 """Launches the sender process, running the test video file once.""" 230 logging.debug('Launching the sender application...') 231 command = [ 232 self.build_paths.cast_sender, 233 '127.0.0.1:8010', 234 self.build_paths.test_video, 235 '-d', 236 TEST_CERT_NAME, 237 '-n' # Only play the video once, and then exit. 238 ] 239 if TestFlags.UseAndroidHack in flags: 240 command.append('-a') 241 if TestFlags.UseRemoting in flags: 242 command.append('-r') 243 244 # The standalone sender sends VP8 if no codec command line argument is 245 # passed. 246 if codec: 247 command.append('-c') 248 if codec == VideoCodec.Vp8: 249 command.append('vp8') 250 elif codec == VideoCodec.Vp9: 251 command.append('vp9') 252 else: 253 self.assertTrue(codec == VideoCodec.Av1) 254 command.append('av1') 255 256 #pylint: disable = consider-using-with 257 return subprocess.Popen(command, 258 stdout=subprocess.PIPE, 259 stderr=subprocess.PIPE) 260 261 def check_logs(self, logs, codec=None): 262 """Checks that the outputted logs contain expected behavior.""" 263 264 # If a codec was not provided, we should make sure that the standalone 265 # sender sent VP8. 266 if codec == None: 267 codec = VideoCodec.Vp8 268 269 for message in (EXPECTED_RECEIVER_MESSAGES + 270 [VIDEO_CODEC_SPECIFIC_RECEIVER_MESSAGES[codec]]): 271 self.assertTrue( 272 message in logs[0], 273 'Missing log message: {}.\n{}'.format(message, 274 MISSING_LOG_MESSAGE)) 275 for message in EXPECTED_SENDER_MESSAGES: 276 self.assertTrue( 277 message in logs[1], 278 'Missing log message: {}.\n{}'.format(message, 279 MISSING_LOG_MESSAGE)) 280 for log, prefix in logs, ["[ERROR:", "[FATAL:"]: 281 self.assertTrue(prefix not in log, "Logs contained an error") 282 logging.debug('Finished validating log output') 283 284 def get_output(self, flags, codec=None): 285 """Launches the sender and receiver, and handles exit output.""" 286 receiver_process = self.launch_receiver() 287 logging.debug('Letting the receiver start up...') 288 time.sleep(3) 289 sender_process = self.launch_sender(flags, codec) 290 291 logging.debug('Launched sender PID %i and receiver PID %i...', 292 sender_process.pid, receiver_process.pid) 293 logging.debug('collating output...') 294 output = (receiver_process.communicate( 295 timeout=PROCESS_TIMEOUT)[1].decode('utf-8'), 296 sender_process.communicate( 297 timeout=PROCESS_TIMEOUT)[1].decode('utf-8')) 298 299 # TODO(issuetracker.google.com/194292855): standalones should exit zero. 300 # Remoting causes the sender to exit with code -4. 301 if not TestFlags.UseRemoting in flags: 302 self.assertEqual(sender_process.returncode, 0, 303 'sender had non-zero exit code') 304 return output 305 306 def test_golden_case(self): 307 """Tests that when settings are normal, things work end to end.""" 308 output = self.get_output([]) 309 self.check_logs(output) 310 311 def test_remoting(self): 312 """Tests that basic remoting works.""" 313 output = self.get_output(TestFlags.UseRemoting) 314 self.check_logs(output) 315 316 def test_with_android_hack(self): 317 """Tests that things work when the Android RTP hack is enabled.""" 318 output = self.get_output(TestFlags.UseAndroidHack) 319 self.check_logs(output) 320 321 def test_vp8_flag(self): 322 """Tests that the VP8 flag works with standard settings.""" 323 output = self.get_output([], VideoCodec.Vp8) 324 self.check_logs(output, VideoCodec.Vp8) 325 326 def test_vp9_flag(self): 327 """Tests that the VP9 flag works with standard settings.""" 328 output = self.get_output([], VideoCodec.Vp9) 329 self.check_logs(output, VideoCodec.Vp9) 330 331 @unittest.skipUnless(os.getenv(LIBAOM_ENVVAR), 332 'Skipping AV1 test since LibAOM not installed.') 333 def test_av1_flag(self): 334 """Tests that the AV1 flag works with standard settings.""" 335 output = self.get_output([], VideoCodec.Av1) 336 self.check_logs(output, VideoCodec.Av1) 337 338 339def parse_args(): 340 """Parses the command line arguments and sets up the logging module.""" 341 # NOTE for future developers: the `unittest` module will complain if it is 342 # passed any args that it doesn't understand. If any Open Screen-specific 343 # command line arguments are added in the future, they should be cropped 344 # from sys.argv before |unittest.main()| is called. 345 parser = argparse.ArgumentParser(description=DESCRIPTION) 346 parser.add_argument('-v', 347 '--verbose', 348 help='enable debug logging', 349 action='store_true') 350 351 parsed_args = parser.parse_args(sys.argv[1:]) 352 _set_log_level(parsed_args.verbose) 353 354 355if __name__ == '__main__': 356 parse_args() 357 unittest.main() 358