| #!/usr/bin/python |
| # |
| """This script uses the UWP Device Portal API [1] to run unit tests on the Xbox. |
| |
| This module is designed to be integrated with the abstract_launcher framework. |
| It does the following when it is asked to run an app: |
| 1. Uninstall existing appx. |
| 2. Upload and install new application. |
| 3. Wait for the install to finish |
| 4. Run the application |
| 5. Wait for the application to finish running |
| 6. Fetch the content and write it to the appropriate place. |
| |
| When combined with starboard/tools/example/app_launcher_client.py, this module |
| enables changing target names (e.g. nplb), the Xbox used for testing, and |
| |
| Examples: |
| python starboard/tools/example/app_launcher_client.py --p xb1 -t nplb |
| -c debug -d 172.31.164.179 --target_params="--gtest_filter=*SbSocket*ait*" |
| |
| python starboard/tools/example/app_launcher_client.py --p xb1 -t nplb |
| -c debug -d 172.31.164.179 |
| --target_params="--gtest_filter=*Sb*Win*" |
| |
| Caveats: |
| Since we cannot launch write to a location any of the user directories like |
| LocalCache before the application is run for the first time, we have to bake |
| in the commandline arguments at install time. Thus, we have to reinstall |
| the package each time we want to run the binary with different arguments. |
| |
| Sample output (truncated for brevity): |
| Uninstalling App (if it exists). |
| Uninstall successfully started |
| Installing on NAVREETXB1 |
| Uploading 6 files in appx/...Done. |
| . |
| . |
| Uploading 80 files in appx/content/data/icu/icudt56l/zone...Done. |
| Skipping appx/microsoft.system.package.metadata since it has 0 files. |
| Done uploading. |
| Uploading args. [ --gtest_filter=*Sb*Win*; ] |
| Uploading 1 files in appx/content/data/arguments...Done. |
| Done uploading. |
| Done registering. |
| Installation is running.. |
| Installation complete. |
| Starting GoogleInc.YouTube::nplb |
| App started successfully. |
| nplb is running. |
| nplb is running. |
| Fetching log.. |
| Found 12 installed apps. |
| [1800:61781033300:INFO:application_uwp.cc(465)] Starting nplb.exe |
| Note: Google Test filter = *Sb*Win* |
| [==========] Running 7 tests from 4 test cases. |
| [----------] Global test environment set-up. |
| [----------] 2 tests from SbWindowCreateTest |
| [ RUN ] SbWindowCreateTest.SunnyDayDefault |
| [ OK ] SbWindowCreateTest.SunnyDayDefault (1 ms) |
| . |
| . |
| . |
| [----------] Global test environment tear-down |
| [==========] 7 tests from 4 test cases ran. (5644 ms total) |
| [ PASSED ] 7 tests. |
| Killing Process (if running). |
| Found 12 installed apps. |
| Log file found. Deleting. |
| |
| [1] |
| https://docs.microsoft.com/en-us/windows/uwp/debug-test-perf/device-portal-api-core |
| """ |
| |
| from __future__ import print_function |
| |
| import os |
| import subprocess |
| import sys |
| import time |
| |
| from starboard.shared.win32 import mini_dump_printer |
| from starboard.tools import abstract_launcher |
| from starboard.tools.abstract_launcher import TargetStatus |
| from starboard.tools import net_args |
| from starboard.tools import net_log |
| from starboard.xb1.tools import packager |
| from starboard.xb1.tools import xb1_network_api |
| |
| _ARGS_DIRECTORY = 'content/data/arguments' |
| _STARBOARD_ARGUMENTS_FILE = 'starboard_arguments.txt' |
| _DEFAULT_PACKAGE_NAME = 'GoogleInc.YouTube' |
| _STUB_PACKAGE_NAME = 'Microsoft.Title.StubApp' |
| _DEBUG_VC_LIBS_PACKAGE_NAME = 'Microsoft.VCLibs.140.00.Debug' |
| _DEFAULT_APPX_NAME = 'cobalt.appx' |
| _DEFAULT_STAGING_APP_NAME = 'appx' |
| _EXTENSION_SDK_DIR = os.path.realpath( |
| os.path.expandvars('%ProgramFiles(x86)%\\Microsoft SDKs' |
| '\\Windows Kits\\10\\ExtensionSDKs')) |
| _DEBUG_VC_LIBS_PATH = os.path.join(_EXTENSION_SDK_DIR, 'Microsoft.VCLibs', |
| '14.0', 'Appx', 'Debug', 'x64', |
| 'Microsoft.VCLibs.x64.Debug.14.00.appx') |
| _XB1_LOG_FILE_PARAM = 'xb1_log_file' |
| _XB1_PORT = 11443 |
| _XB1_NET_LOG_PORT = 49353 |
| _XB1_NET_ARG_PORT = 49355 |
| |
| # Number of times a test will try or retry. |
| _TEST_MAX_TRIES = 4 |
| # Seconds to wait between retries (scales with backoff factor). |
| _TEST_RETRY_WAIT = 8 |
| # Amount to multiply retry time with each failed attempt (i.e. 2 doubles the |
| # amount of time to wait between retries). |
| _TEST_RETRY_BACKOFF_FACTOR = 2 |
| |
| _PROCESS_TIMEOUT = 60 * 5.0 |
| _PROCESS_KILL_TIMEOUT_SECONDS = 5.0 |
| |
| _RESTART_TIMEOUT = 180 |
| |
| |
| def ToAppxFriendlyName(app_name): |
| if app_name == 'cobalt': |
| app_name = 'App' |
| else: |
| app_name = app_name.replace('_', '') + 'App' |
| return app_name |
| |
| |
| def GlobalVars(): |
| return GlobalVars.__dict__ |
| |
| |
| # First call returns True, otherwise return false. |
| def FirstRun(): |
| v = GlobalVars() |
| if 'first_run' in v: |
| return False |
| v['first_run'] = False |
| return True |
| |
| |
| def GetXb1NetworkApiFor(device_id, port): |
| v = GlobalVars() |
| network_key = str(device_id) + str(port) |
| if network_key not in v: |
| v[network_key] = xb1_network_api.Xb1NetworkApi(device_id, port) |
| return v[network_key] |
| |
| |
| def TryGetAppxDirectory(target_dir): |
| appx_path = os.path.join(target_dir, _DEFAULT_STAGING_APP_NAME) |
| if os.path.exists(os.path.join(appx_path, 'AppxManifest.xml')): |
| return appx_path |
| return None |
| |
| |
| def TryGetTargetPath(appx_path, target_name): |
| if not appx_path: |
| return None |
| target_path = os.path.join(appx_path, target_name + '.exe') |
| if os.path.exists(target_path): |
| return target_path |
| return None |
| |
| |
| def TryGetPackagedBinary(target_path): |
| path = os.path.join(target_path, 'package', _DEFAULT_APPX_NAME) |
| if os.path.exists(path): |
| return path |
| return None |
| |
| |
| def CheckEnvironment(): |
| v = GlobalVars() |
| if 'os_checked' not in v: |
| v['os_checked'] = True |
| path = os.path.abspath(__file__) |
| if path.startswith('/cygdrive'): |
| # os.path.abspath() does not work in cygwin, maybe other |
| # path operations don't work either. |
| raise OSError('\n **** cygwin not supported ****') |
| |
| |
| # Input: "--url=https://www.youtube.com/tv?v=1FmJdQiKy_w,--install=True" |
| # Output: {'url': 'https://www.youtube.com/tv?v=1FmJdQiKy_w', 'install': 'True'} |
| def CommandArgsToDict(s): |
| if not s: |
| return {} |
| |
| array = s.split(',') |
| out = {} |
| for v in array: |
| elems = v.split('=', 1) |
| if len(elems) < 1: |
| continue |
| key = elems[0] |
| |
| if key.startswith('--'): |
| key = key[2:] |
| val = None |
| if len(elems) > 1: |
| val = elems[1] |
| out[key] = val |
| return out |
| |
| |
| def Str2Bool(s): |
| if not s: |
| return False |
| s = s.lower() |
| return s in ('true', '1') |
| |
| |
| class Launcher(abstract_launcher.AbstractLauncher): |
| """Class for launching Cobalt/tools on Xbox.""" |
| |
| def __init__(self, platform, target_name, config, device_id, **kwargs): |
| """Launcher constructor. |
| |
| Args: |
| platform: Platform name (e.g. xb1, or xb1-future) |
| target_name: Target name (e.g. "nplb") |
| config: Config name (e.g. debug, devel, qa, gold) |
| device_id: IP address of the device (e.g. 172.2.3.4) |
| """ |
| super().__init__(platform, target_name, config, device_id, **kwargs) |
| |
| CheckEnvironment() |
| self._network_api = GetXb1NetworkApiFor(device_id, _XB1_PORT) |
| self._network_api.SetLoggingFunction(self._Log) |
| self._logfile_name = target_name + '_stdout.txt' |
| # Global argument will be visible to every launched application. |
| # --net_args_wait_for_connection will instruct the app to wait |
| # until net_args can connect via a socket and receive the command |
| # arguments. |
| # --net_log_wait_for_connection will instruct the app to wait until |
| # the netlog attaches. |
| self._global_args = (';--net_args_wait_for_connection' |
| ';--net_log_wait_for_connection') |
| # Once net_args is connected, the following _target_args will be sent |
| # over the wire. |
| self._target_args = ['--' + _XB1_LOG_FILE_PARAM + '=' + self._logfile_name] |
| self._do_deploy = True |
| self._do_restart = True |
| self._do_run = True |
| self._device_id = device_id |
| self._platform = platform |
| self._config = config |
| |
| # Note that target_params is an array of strings, not a string. |
| user_params = kwargs.get('target_params', '') |
| if user_params and len(user_params) > 0: |
| self.InitTargetParams(','.join(user_params)) |
| |
| self._package_root_dir = os.path.split(self.GetTargetPath())[0] |
| |
| # Ensure that target is built and ready to be packaged. |
| self._source_appx_path = TryGetAppxDirectory(self._package_root_dir) |
| self._source_exe_path = TryGetTargetPath(self._source_appx_path, |
| target_name) |
| if not self._source_exe_path: |
| raise IOError(f'Target {target_name} not found in appx directory.') |
| |
| def InitTargetParams(self, user_params_str): |
| args_dict = CommandArgsToDict(user_params_str) |
| # If --restart is present, consume the field |
| self._do_restart = Str2Bool(args_dict.pop('restart', 'True')) |
| |
| # If --deploy is present, then consume the value and |
| # remove from the rest of the arguments. |
| if 'deploy' in args_dict: |
| val = args_dict['deploy'] |
| del args_dict['deploy'] |
| if val is not None: |
| self._do_deploy = Str2Bool(val) |
| |
| # If --run is present, then consume the value and |
| # remove from the rest of the arguments. |
| if 'run' in args_dict: |
| val = args_dict['run'] |
| del args_dict['run'] |
| if val is not None: |
| self._do_run = Str2Bool(val) |
| # If there are still arguments then add them to the target |
| # args. |
| if len(args_dict) > 0: |
| args = [] |
| for key, val in args_dict.items(): |
| entry = '--' + key |
| if val is not None: |
| entry += '=' + val |
| args.append(entry) |
| self._target_args.extend(args) |
| |
| def _Log(self, s): |
| try: |
| self.output_file.write(s) |
| except UnicodeEncodeError: |
| s = s.encode('ascii', 'replace').decode() |
| self.output_file.write(s) |
| self.output_file.flush() |
| |
| def _LogLn(self, s): |
| try: |
| # Attempt to convert to a string if the type is bytes. |
| s = s.decode() |
| except (UnicodeDecodeError, AttributeError): |
| pass |
| self._Log(s + '\n') |
| |
| def InstallStarboardArgument(self, global_args): |
| args_path = [self._source_appx_path] |
| args_path.extend(_ARGS_DIRECTORY.split('/')) |
| args_path.append(_STARBOARD_ARGUMENTS_FILE) |
| args_path = os.path.join(*args_path) # splat args_path into flat list. |
| |
| args_dir_path = os.path.dirname(args_path) |
| if not os.path.exists(args_dir_path): |
| os.makedirs(args_dir_path) |
| |
| if not global_args: |
| if os.path.exists(args_path): |
| os.remove(args_path) |
| self._LogLn('Removed global command argument file: ' + args_path) |
| else: |
| with open(args_path, 'w', encoding='utf-8') as fd: |
| fd.write(global_args) |
| self._LogLn('Installed global command arguments: \"' + global_args + |
| '\" to:\n' + os.path.realpath(args_path)) |
| |
| def GetSerial(self): |
| try: |
| poll = self._network_api.GetDeviceInfo() |
| return poll['SerialNumber'] |
| except IOError: |
| return '' |
| |
| def RestartDevkit(self): |
| dev_info = self._network_api.GetDeviceInfo() |
| serial = dev_info['SerialNumber'] |
| console_type = dev_info['ConsoleType'] |
| self._Log('Connected to: ') |
| self._LogLn(self._network_api.ComputerName() + ' Type:' + console_type + |
| ' Serial:' + serial + ', triggering restart') |
| start = time.time() |
| self._network_api.Restart() |
| |
| while self.GetSerial(): # Wait for at least one failed poll |
| time.sleep(1) |
| |
| while self.GetSerial() != serial: |
| elapsed = int(time.time() - start) |
| if elapsed > _RESTART_TIMEOUT: |
| raise RuntimeError('Timed out waiting for restart, ' |
| f'timeout:{_RESTART_TIMEOUT}') |
| self._LogLn(f'Waiting for restart, elapsed seconds:{elapsed} ' |
| f'( timeout: {_RESTART_TIMEOUT} )') |
| time.sleep(1) |
| |
| time.sleep(10) |
| self._Log('Restart complete: ') |
| self._LogLn(self._network_api.ComputerName()) |
| |
| def SignIn(self): |
| self._Log('Connected to: ') |
| self._LogLn(self._network_api.ComputerName()) |
| |
| users = self._network_api.GetXboxLiveUserInfos().get('Users', []) |
| |
| if len(users) == 0: |
| raise IOError('Please add at least one user to the test accounts on\n' + |
| 'the Xbox developer homescreen.') |
| |
| # If we find an existing user that is signed in, then use that. |
| for user in users: |
| if user.get('SignedIn', False): |
| return |
| |
| # Else, sign in the first user. |
| self._network_api.SetXboxLiveSignedInUserState(users[0]['EmailAddress'], |
| True) |
| |
| def WinAppDeployCmd(self, command: str): |
| try: |
| exe_path = os.path.join(packager.GetWinToolsPath(), 'WinAppDeployCmd.exe') |
| command_str = f'{exe_path} {command} -ip {self.GetDeviceIp()}' |
| self._LogLn('Running: ' + command_str) |
| out = subprocess.check_output(command_str).decode() |
| except subprocess.CalledProcessError as e: |
| self._LogLn(e.output) |
| raise e |
| |
| return out |
| |
| def PackageCobalt(self): |
| # Package all targets into an appx. |
| package_parameters = { |
| 'source_dir': self.out_directory, |
| 'output_dir': os.path.join(self.out_directory, 'package'), |
| 'publisher': None, |
| 'product': 'youtube', |
| } |
| if not os.path.exists(package_parameters['output_dir']): |
| os.makedirs(package_parameters['output_dir']) |
| packager.Package(**package_parameters) |
| |
| def UninstallSubPackages(self): |
| # Check for sub-packages left over. Force uninstall any that exist. |
| uninstalled_packages = [] |
| packages = self._network_api.GetInstalledPackages() |
| for package in packages: |
| try: |
| package_full_name = package['PackageFullName'] |
| if package_full_name.find( |
| _DEFAULT_PACKAGE_NAME) != -1 or package_full_name.find( |
| _STUB_PACKAGE_NAME) != -1: |
| if package_full_name not in uninstalled_packages: |
| self._LogLn('Existing YouTube app found on device. Uninstalling: ' + |
| package_full_name) |
| uninstalled_packages.append(package_full_name) |
| self.WinAppDeployCmd('uninstall -package ' + package_full_name) |
| except KeyError: |
| # Some packages don't include all fields. Ignore those. |
| pass |
| except subprocess.CalledProcessError as err: |
| self._LogLn(err.output) |
| |
| def DeleteLooseApps(self): |
| self._network_api.ClearLooseAppFiles() |
| |
| def Deploy(self): |
| # starboard_arguments.txt is packaged with the appx. It instructs the app |
| # to wait for the NetArgs thread to send command-line args via the socket. |
| self.InstallStarboardArgument(self._global_args) |
| |
| self.PackageCobalt() |
| appx_package_file = TryGetPackagedBinary(self._package_root_dir) |
| if not appx_package_file: |
| raise IOError('Packaged appx not found in package directory. Perhaps ' |
| 'package_cobalt script did not complete successfully.') |
| |
| existing_package = self.CheckPackageIsDeployed(_DEFAULT_PACKAGE_NAME) |
| if existing_package: |
| self._LogLn('Existing YouTube app found on device. Uninstalling.') |
| self.WinAppDeployCmd('uninstall -package ' + existing_package) |
| |
| if not self.CheckPackageIsDeployed(_DEBUG_VC_LIBS_PACKAGE_NAME): |
| self._LogLn('Required dependency missing. Attempting to install.') |
| self.WinAppDeployCmd(f'install -file "{_DEBUG_VC_LIBS_PATH}"') |
| |
| self._LogLn('Deleting temporary files') |
| self._network_api.ClearTempFiles() |
| |
| try: |
| self.WinAppDeployCmd(f'install -file {appx_package_file}') |
| except subprocess.CalledProcessError: |
| # Install exited with non-zero status code, clear everything out, restart, |
| # and attempt another install. |
| self._LogLn('Error installing appx. Attempting a clean install...') |
| self.UninstallSubPackages() |
| self.DeleteLooseApps() |
| self.RestartDevkit() |
| self.WinAppDeployCmd(f'install -file {appx_package_file}') |
| |
| # Cleanup starboard arguments file. |
| self.InstallStarboardArgument(None) |
| |
| # Validate that app was installed correctly by checking to make sure |
| # that the full package name can now be found. |
| def CheckPackageIsDeployed(self, package_name=_DEFAULT_PACKAGE_NAME): |
| package_list = self.WinAppDeployCmd('list') |
| package_index = package_list.find(package_name) |
| if package_index == -1: |
| return False |
| return package_list[package_index:].split('\n')[0].strip() |
| |
| def RunTest(self, appx_name: str): |
| self.net_args_thread = None |
| attempt_num = 0 |
| retry_wait_s = _TEST_RETRY_WAIT |
| while attempt_num < _TEST_MAX_TRIES: |
| if not self.net_args_thread or not self.net_args_thread.is_alive(): |
| # This thread must start before the app executes or else it is possible |
| # the app will hang at _network_api.ExecuteBinary() |
| self.net_args_thread = net_args.NetArgsThread(self.device_id, |
| _XB1_NET_ARG_PORT, |
| self._target_args) |
| if self._network_api.ExecuteBinary(_DEFAULT_PACKAGE_NAME, appx_name): |
| break |
| |
| if not self.net_args_thread.ArgsSent(): |
| self._LogLn( |
| 'Net Args were not sent to the test! This will likely cause ' |
| 'the test to fail!') |
| attempt_num += 1 |
| self._LogLn(f'Retry attempt {attempt_num}.') |
| time.sleep(retry_wait_s) |
| retry_wait_s *= _TEST_RETRY_BACKOFF_FACTOR |
| if hasattr(self, 'net_args_thread'): |
| self.net_args_thread.join() |
| |
| def InitDevice(self): |
| if not self._network_api.IsInDevMode(): |
| raise IOError('\n\n**** Please set the XBOX at ' + self._device_id + |
| ' to dev mode!!!! ****\n') |
| self.SignIn() |
| |
| def Run(self): |
| # Only upload and install Appx on the first run. |
| if FirstRun(): |
| if self._do_restart: |
| self.RestartDevkit() |
| |
| self.InitDevice() |
| if self._do_deploy: |
| self.Deploy() |
| else: |
| self._LogLn('Skipping deploy step.') |
| |
| if not self.CheckPackageIsDeployed(_DEFAULT_PACKAGE_NAME): |
| raise IOError('Could not resolve ' + _DEFAULT_PACKAGE_NAME + ' to\n' + |
| 'it\'s full package name after install! This means that' + |
| '\n the package is not deployed correctly!\n\n') |
| |
| if not self._do_run: |
| self._LogLn('Skipping running step.') |
| return 0 |
| |
| status, _ = self.Run2() |
| if status == TargetStatus.CRASH: |
| return 1 |
| else: |
| return 0 |
| |
| def Run2(self): |
| try: |
| self.Kill() # Kill existing running app. |
| # While binary is running, extract the net log and stream it to |
| # the output. |
| self.net_log_thread = net_log.NetLogThread(self.device_id, |
| _XB1_NET_LOG_PORT) |
| |
| appx_name = ToAppxFriendlyName(self.target_name) |
| |
| self.RunTest(appx_name) |
| |
| while self._network_api.IsBinaryRunning(self.target_name): |
| self._Log(self.net_log_thread.GetLog()) |
| |
| self._Log(self.net_log_thread.GetLog()) |
| self._LogLn('Program Exited...') |
| |
| except KeyboardInterrupt: |
| self._LogLn('User cancelled...') |
| except IOError as io_err: |
| _, _, exc_tb = sys.exc_info() |
| fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] |
| msg = (f'Exception happened at {fname}({exc_tb.tb_lineno}) during ' |
| f'to run cycle of {self.target_name} this ' |
| 'is common if no one is signed in to the xbox.\n' |
| f' Error: {repr(io_err)}') |
| raise IOError(msg) from io_err |
| finally: |
| if hasattr(self, 'net_args_thread'): |
| self.net_args_thread.join() |
| if hasattr(self, 'net_log_thread'): |
| self.net_log_thread.join() |
| |
| self.Kill() |
| self._LogLn('Finished running...') |
| if self._DetectAndHandleAnyCrashes(self.target_name): |
| return TargetStatus.CRASH, 0 |
| else: |
| return TargetStatus.NA, 0 |
| |
| def _DetectAndHandleAnyCrashes(self, target_name): |
| crashes_detected = False |
| (files, |
| _) = self._network_api.ListPackageFilesAndDirs(_DEFAULT_PACKAGE_NAME, 'AC') |
| for f in files: |
| if f.startswith(target_name) and f.endswith('dmp'): |
| self._LogLn('\n***** Application ' + target_name + |
| ' crashed! *****\nDump file: ' + f) |
| crashes_detected = True |
| data = self._network_api.FetchPackageFile( |
| _DEFAULT_PACKAGE_NAME, |
| 'AC/' + f, |
| raise_on_failure=True, |
| is_text=False) |
| # Ensure that directory exists. |
| dump_dir_path = os.path.join(os.environ['LOCALAPPDATA'], 'CrashDumps', |
| 'xbox_remote') |
| if not os.path.exists(dump_dir_path): |
| os.makedirs(dump_dir_path) |
| |
| dump_file_path = os.path.join(dump_dir_path, f) |
| # Clean previous dump file, if it exists. |
| if os.path.exists(dump_file_path): |
| try: |
| os.remove(dump_file_path) |
| except IOError as io_err: |
| self._LogLn('Error, could not remove old file ' + str(io_err)) |
| |
| try: |
| with open(dump_file_path, 'wb') as fp: |
| fp.write(data) |
| self._LogLn('Copied dump to ' + dump_file_path) |
| mini_dump_printer.PrintMiniDump(dump_file_path, self.output_file) |
| except Exception as err: # pylint: disable=broad-except |
| self._LogLn('Failed to dump file ' + dump_file_path + ' because: ' + |
| str(err)) |
| return crashes_detected |
| |
| def Kill(self): |
| self._Log('Killing Process (if running).\n') |
| running_processes = self._network_api.GetRunningProcesses() |
| exe_name = self.target_name + '.exe' |
| filtered_processes = list( |
| filter(lambda p: p['ImageName'] == exe_name, running_processes)) |
| if len(filtered_processes) == 0: |
| return |
| process_under_test = filtered_processes[0] |
| if not process_under_test['IsRunning']: |
| return |
| pid = process_under_test['ProcessId'] |
| self._Log('Going to kill pid ' + str(pid) + '\n') |
| |
| self._network_api.KillProcess(pid) |
| self._Log('Kill signal sent. Waiting for the process to die.\n') |
| self._network_api.WaitForBinaryToFinishRunning( |
| self.target_name, _PROCESS_KILL_TIMEOUT_SECONDS) |
| self._Log('Done killing.\n') |
| |
| def GetDeviceIp(self): |
| """Gets the device IP.""" |
| return self.device_id |
| |
| def GetDeviceOutputPath(self): |
| # TODO: Implement |
| return None |