xref: /aosp_15_r20/external/openscreen/cast/standalone_e2e.py (revision 3f982cf4871df8771c9d4abe6e9a6f8d829b2736)
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