1# Copyright 2024 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"""Executes a browser with devtools enabled on the target.""" 5 6import os 7import re 8import subprocess 9import tempfile 10import time 11from typing import List, Optional 12from urllib.parse import urlparse 13 14from common import run_continuous_ffx_command, ssh_run, REPO_ALIAS 15from ffx_integration import run_symbolizer 16 17WEB_ENGINE_SHELL = 'web-engine-shell' 18CAST_STREAMING_SHELL = 'cast-streaming-shell' 19 20 21class BrowserRunner: 22 """Manages the browser process on the target.""" 23 24 def __init__(self, 25 browser_type: str, 26 target_id: Optional[str] = None, 27 output_dir: Optional[str] = None): 28 self._browser_type = browser_type 29 assert self._browser_type in [WEB_ENGINE_SHELL, CAST_STREAMING_SHELL] 30 self._target_id = target_id 31 self._output_dir = output_dir or os.environ['CHROMIUM_OUTPUT_DIR'] 32 assert self._output_dir 33 self._browser_proc = None 34 self._symbolizer_proc = None 35 self._devtools_port = None 36 self._log_fs = None 37 38 output_root = os.path.join(self._output_dir, 'gen', 'fuchsia_web') 39 if self._browser_type == WEB_ENGINE_SHELL: 40 self._id_files = [ 41 os.path.join(output_root, 'shell', 'web_engine_shell', 42 'ids.txt'), 43 os.path.join(output_root, 'webengine', 'web_engine_with_webui', 44 'ids.txt'), 45 ] 46 else: # self._browser_type == CAST_STREAMING_SHELL: 47 self._id_files = [ 48 os.path.join(output_root, 'shell', 'cast_streaming_shell', 49 'ids.txt'), 50 os.path.join(output_root, 'webengine', 'web_engine', 51 'ids.txt'), 52 ] 53 54 @property 55 def browser_type(self) -> str: 56 """Returns the type of the browser for the tests.""" 57 return self._browser_type 58 59 @property 60 def devtools_port(self) -> int: 61 """Returns the randomly assigned devtools-port, shouldn't be called 62 before executing the start.""" 63 assert self._devtools_port 64 return self._devtools_port 65 66 @property 67 def log_file(self) -> str: 68 """Returns the log file of the browser instance, shouldn't be called 69 before executing the start.""" 70 assert self._log_fs 71 return self._log_fs.name 72 73 @property 74 def browser_pid(self) -> int: 75 """Returns the process id of the ffx instance which starts the browser 76 on the test device, shouldn't be called before executing the start.""" 77 assert self._browser_proc 78 return self._browser_proc.pid 79 80 def _read_devtools_port(self): 81 search_regex = r'DevTools listening on (.+)' 82 83 # The ipaddress of the emulator or device is preferred over the address 84 # reported by the devtools, former one is usually more accurate. 85 def try_reading_port(log_file) -> int: 86 for line in log_file: 87 tokens = re.search(search_regex, line) 88 if tokens: 89 url = urlparse(tokens.group(1)) 90 assert url.scheme == 'ws' 91 assert url.port is not None 92 return url.port 93 return None 94 95 with open(self.log_file, encoding='utf-8') as log_file: 96 start = time.time() 97 while time.time() - start < 180: 98 port = try_reading_port(log_file) 99 if port: 100 return port 101 self._browser_proc.poll() 102 assert not self._browser_proc.returncode, 'Browser stopped.' 103 time.sleep(1) 104 assert False, 'Failed to wait for the devtools port.' 105 106 def start(self, extra_args: List[str] = None) -> None: 107 """Starts the selected browser, |extra_args| are attached to the command 108 line.""" 109 browser_cmd = ['test', 'run'] 110 if self.browser_type == WEB_ENGINE_SHELL: 111 browser_cmd.extend([ 112 f'fuchsia-pkg://{REPO_ALIAS}/web_engine_shell#meta/' 113 f'web_engine_shell.cm', 114 '--', 115 '--web-engine-package-name=web_engine_with_webui', 116 '--remote-debugging-port=0', 117 '--enable-web-instance-tmp', 118 '--with-webui', 119 'about:blank', 120 ]) 121 else: # if self.browser_type == CAST_STREAMING_SHELL: 122 browser_cmd.extend([ 123 f'fuchsia-pkg://{REPO_ALIAS}/cast_streaming_shell#meta/' 124 f'cast_streaming_shell.cm', 125 '--', 126 '--remote-debugging-port=0', 127 ]) 128 # Use flags used on WebEngine in production devices. 129 browser_cmd.extend([ 130 '--', 131 '--enable-low-end-device-mode', 132 '--force-gpu-mem-available-mb=64', 133 '--force-gpu-mem-discardable-limit-mb=32', 134 '--force-max-texture-size=2048', 135 '--gpu-rasterization-msaa-sample-count=0', 136 '--min-height-for-gpu-raster-tile=128', 137 '--webgl-msaa-sample-count=0', 138 '--max-decoded-image-size-mb=10', 139 ]) 140 if extra_args: 141 browser_cmd.extend(extra_args) 142 self._browser_proc = run_continuous_ffx_command( 143 cmd=browser_cmd, 144 stdout=subprocess.PIPE, 145 stderr=subprocess.STDOUT, 146 target_id=self._target_id) 147 # The stdout will be forwarded to the symbolizer, then to the _log_fs. 148 self._log_fs = tempfile.NamedTemporaryFile() 149 self._symbolizer_proc = run_symbolizer(self._id_files, 150 self._browser_proc.stdout, 151 self._log_fs) 152 self._devtools_port = self._read_devtools_port() 153 154 def stop_browser(self) -> None: 155 """Stops the browser on the target, as well as the local symbolizer, the 156 _log_fs is preserved. Calling this function for a second time won't have 157 any effect.""" 158 if not self.is_browser_running(): 159 return 160 self._browser_proc.kill() 161 self._browser_proc = None 162 self._symbolizer_proc.kill() 163 self._symbolizer_proc = None 164 self._devtools_port = None 165 # The process may be stopped already, ignoring the no process found 166 # error. 167 ssh_run(['killall', 'web_instance.cmx'], self._target_id, check=False) 168 169 def is_browser_running(self) -> bool: 170 """Checks if the browser is still running.""" 171 if self._browser_proc: 172 assert self._symbolizer_proc 173 assert self._devtools_port 174 return True 175 assert not self._symbolizer_proc 176 assert not self._devtools_port 177 return False 178 179 def close(self) -> None: 180 """Cleans up everything.""" 181 self.stop_browser() 182 self._log_fs.close() 183 self._log_fs = None 184