1# Copyright 2022 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Provide helpers for running Fuchsia's `ffx`.""" 5 6import logging 7import os 8import json 9import subprocess 10import sys 11import tempfile 12 13from contextlib import AbstractContextManager 14from typing import IO, Iterable, List, Optional 15 16from common import run_continuous_ffx_command, run_ffx_command, SDK_ROOT 17 18RUN_SUMMARY_SCHEMA = \ 19 'https://fuchsia.dev/schema/ffx_test/run_summary-8d1dd964.json' 20 21 22def get_config(name: str) -> Optional[str]: 23 """Run a ffx config get command to retrieve the config value.""" 24 25 try: 26 return run_ffx_command(cmd=['config', 'get', name], 27 capture_output=True).stdout.strip() 28 except subprocess.CalledProcessError as cpe: 29 # A return code of 2 indicates no previous value set. 30 if cpe.returncode == 2: 31 return None 32 raise 33 34 35class ScopedFfxConfig(AbstractContextManager): 36 """Temporarily overrides `ffx` configuration. Restores the previous value 37 upon exit.""" 38 39 def __init__(self, name: str, value: str) -> None: 40 """ 41 Args: 42 name: The name of the property to set. 43 value: The value to associate with `name`. 44 """ 45 self._old_value = None 46 self._new_value = value 47 self._name = name 48 49 def __enter__(self): 50 """Override the configuration.""" 51 52 # Cache the old value. 53 self._old_value = get_config(self._name) 54 if self._new_value != self._old_value: 55 run_ffx_command(cmd=['config', 'set', self._name, self._new_value]) 56 return self 57 58 def __exit__(self, exc_type, exc_val, exc_tb) -> bool: 59 if self._new_value == self._old_value: 60 return False 61 62 # Allow removal of config to fail. 63 remove_cmd = run_ffx_command(cmd=['config', 'remove', self._name], 64 check=False) 65 if remove_cmd.returncode != 0: 66 logging.warning('Error when removing ffx config %s', self._name) 67 68 # Explicitly set the value back only if removing the new value doesn't 69 # already restore the old value. 70 if self._old_value is not None and \ 71 self._old_value != get_config(self._name): 72 run_ffx_command(cmd=['config', 'set', self._name, self._old_value]) 73 74 # Do not suppress exceptions. 75 return False 76 77 78class FfxTestRunner(AbstractContextManager): 79 """A context manager that manages a session for running a test via `ffx`. 80 81 Upon entry, an instance of this class configures `ffx` to retrieve files 82 generated by a test and prepares a directory to hold these files either in a 83 specified directory or in tmp. On exit, any previous configuration of 84 `ffx` is restored and the temporary directory, if used, is deleted. 85 86 The prepared directory is used when invoking `ffx test run`. 87 """ 88 89 def __init__(self, results_dir: Optional[str] = None) -> None: 90 """ 91 Args: 92 results_dir: Directory on the host where results should be stored. 93 """ 94 self._results_dir = results_dir 95 self._custom_artifact_directory = None 96 self._temp_results_dir = None 97 self._debug_data_directory = None 98 99 def __enter__(self): 100 if self._results_dir: 101 os.makedirs(self._results_dir, exist_ok=True) 102 else: 103 self._temp_results_dir = tempfile.TemporaryDirectory() 104 self._results_dir = self._temp_results_dir.__enter__() 105 return self 106 107 def __exit__(self, exc_type, exc_val, exc_tb) -> bool: 108 if self._temp_results_dir: 109 self._temp_results_dir.__exit__(exc_type, exc_val, exc_tb) 110 self._temp_results_dir = None 111 112 # Do not suppress exceptions. 113 return False 114 115 def run_test(self, 116 component_uri: str, 117 test_args: Optional[Iterable[str]] = None, 118 node_name: Optional[str] = None, 119 test_realm: Optional[str] = None) -> subprocess.Popen: 120 """Starts a subprocess to run a test on a target. 121 Args: 122 component_uri: The test component URI. 123 test_args: Arguments to the test package, if any. 124 node_name: The target on which to run the test. 125 Returns: 126 A subprocess.Popen object. 127 """ 128 command = [ 129 'test', 'run', '--output-directory', self._results_dir, 130 ] 131 if test_realm: 132 command.append("--realm") 133 command.append(test_realm) 134 command.append(component_uri) 135 if test_args: 136 command.append('--') 137 command.extend(test_args) 138 return run_continuous_ffx_command(command, 139 node_name, 140 stdout=subprocess.PIPE, 141 stderr=subprocess.STDOUT) 142 143 def _parse_test_outputs(self): 144 """Parses the output files generated by the test runner. 145 146 The instance's `_custom_artifact_directory` member is set to the 147 directory holding output files emitted by the test. 148 149 This function is idempotent, and performs no work if it has already been 150 called. 151 """ 152 if self._custom_artifact_directory: 153 return 154 155 run_summary_path = os.path.join(self._results_dir, 'run_summary.json') 156 try: 157 with open(run_summary_path) as run_summary_file: 158 run_summary = json.load(run_summary_file) 159 except IOError: 160 logging.exception('Error reading run summary file.') 161 return 162 except ValueError: 163 logging.exception('Error parsing run summary file %s', 164 run_summary_path) 165 return 166 167 assert run_summary['schema_id'] == RUN_SUMMARY_SCHEMA, \ 168 'Unsupported version found in %s' % run_summary_path 169 170 run_artifact_dir = run_summary.get('data', {})['artifact_dir'] 171 for artifact_path, artifact in run_summary.get( 172 'data', {})['artifacts'].items(): 173 if artifact['artifact_type'] == 'DEBUG': 174 self._debug_data_directory = os.path.join( 175 self._results_dir, run_artifact_dir, artifact_path) 176 break 177 178 if run_summary['data']['outcome'] == "NOT_STARTED": 179 logging.critical('Test execution was interrupted. Either the ' 180 'emulator crashed while the tests were still ' 181 'running or connection to the device was lost.') 182 sys.exit(1) 183 184 # There should be precisely one suite for the test that ran. 185 suites_list = run_summary.get('data', {}).get('suites') 186 if not suites_list: 187 logging.error('Missing or empty list of suites in %s', 188 run_summary_path) 189 return 190 suite_summary = suites_list[0] 191 192 # Get the top-level directory holding all artifacts for this suite. 193 artifact_dir = suite_summary.get('artifact_dir') 194 if not artifact_dir: 195 logging.error('Failed to find suite\'s artifact_dir in %s', 196 run_summary_path) 197 return 198 199 # Get the path corresponding to artifacts 200 for artifact_path, artifact in suite_summary['artifacts'].items(): 201 if artifact['artifact_type'] == 'CUSTOM': 202 self._custom_artifact_directory = os.path.join( 203 self._results_dir, artifact_dir, artifact_path) 204 break 205 206 def get_custom_artifact_directory(self) -> str: 207 """Returns the full path to the directory holding custom artifacts 208 emitted by the test or None if the directory could not be discovered. 209 """ 210 self._parse_test_outputs() 211 return self._custom_artifact_directory 212 213 def get_debug_data_directory(self): 214 """Returns the full path to the directory holding debug data 215 emitted by the test, or None if the path cannot be determined. 216 """ 217 self._parse_test_outputs() 218 return self._debug_data_directory 219 220 221def run_symbolizer(symbol_paths: List[str], input_fd: IO, 222 output_fd: IO) -> subprocess.Popen: 223 """Runs symbolizer that symbolizes |input| and outputs to |output|.""" 224 225 symbolize_cmd = ([ 226 'debug', 'symbolize', '--', '--omit-module-lines', '--build-id-dir', 227 os.path.join(SDK_ROOT, '.build-id') 228 ]) 229 for path in symbol_paths: 230 symbolize_cmd.extend(['--ids-txt', path]) 231 return run_continuous_ffx_command(symbolize_cmd, 232 stdin=input_fd, 233 stdout=output_fd, 234 stderr=subprocess.STDOUT) 235