xref: /aosp_15_r20/external/cronet/build/fuchsia/test/ffx_integration.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
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