chromium/build/fuchsia/test/browser_runner.py

# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Executes a browser with devtools enabled on the target."""

import os
import re
import subprocess
import tempfile
import time
from typing import List, Optional

from common import run_continuous_ffx_command, ssh_run, REPO_ALIAS
from ffx_integration import run_symbolizer

WEB_ENGINE_SHELL = 'web-engine-shell'
CAST_STREAMING_SHELL = 'cast-streaming-shell'


class BrowserRunner:
    """Manages the browser process on the target."""

    def __init__(self,
                 browser_type: str,
                 target_id: Optional[str] = None,
                 output_dir: Optional[str] = None):
        self._browser_type = browser_type
        assert self._browser_type in [WEB_ENGINE_SHELL, CAST_STREAMING_SHELL]
        self._target_id = target_id
        self._output_dir = output_dir or os.environ['CHROMIUM_OUTPUT_DIR']
        assert self._output_dir
        self._browser_proc = None
        self._symbolizer_proc = None
        self._devtools_port = None
        self._log_fs = None

        output_root = os.path.join(self._output_dir, 'gen', 'fuchsia_web')
        if self._browser_type == WEB_ENGINE_SHELL:
            self._id_files = [
                os.path.join(output_root, 'shell', 'web_engine_shell',
                             'ids.txt'),
                os.path.join(output_root, 'webengine', 'web_engine_with_webui',
                             'ids.txt'),
            ]
        else:  # self._browser_type == CAST_STREAMING_SHELL:
            self._id_files = [
                os.path.join(output_root, 'shell', 'cast_streaming_shell',
                             'ids.txt'),
                os.path.join(output_root, 'webengine', 'web_engine',
                             'ids.txt'),
            ]

    @property
    def browser_type(self) -> str:
        """Returns the type of the browser for the tests."""
        return self._browser_type

    @property
    def devtools_port(self) -> int:
        """Returns the randomly assigned devtools-port, shouldn't be called
        before executing the start."""
        assert self._devtools_port
        return self._devtools_port

    @property
    def log_file(self) -> str:
        """Returns the log file of the browser instance, shouldn't be called
        before executing the start."""
        assert self._log_fs
        return self._log_fs.name

    @property
    def browser_pid(self) -> int:
        """Returns the process id of the ffx instance which starts the browser
        on the test device, shouldn't be called before executing the start."""
        assert self._browser_proc
        return self._browser_proc.pid

    def _read_devtools_port(self):
        search_regex = r'Remote debugging port: (\d+)'

        def try_reading_port(log_file) -> int:
            for line in log_file:
                tokens = re.search(search_regex, line)
                if tokens:
                    return int(tokens.group(1))
            return None

        with open(self.log_file, encoding='utf-8') as log_file:
            start = time.time()
            while time.time() - start < 180:
                port = try_reading_port(log_file)
                if port:
                    return port
                time.sleep(1)
            assert False, 'Failed to wait for the devtools port.'

    def start(self, extra_args: List[str]) -> None:
        """Starts the selected browser, |extra_args| are attached to the command
        line."""
        browser_cmd = ['test', 'run']
        if self.browser_type == WEB_ENGINE_SHELL:
            browser_cmd.extend([
                f'fuchsia-pkg://{REPO_ALIAS}/web_engine_shell#meta/'
                f'web_engine_shell.cm',
                '--',
                '--web-engine-package-name=web_engine_with_webui',
                '--remote-debugging-port=0',
                '--enable-web-instance-tmp',
                '--with-webui',
                'about:blank',
            ])
        else:  # if self.browser_type == CAST_STREAMING_SHELL:
            browser_cmd.extend([
                f'fuchsia-pkg://{REPO_ALIAS}/cast_streaming_shell#meta/'
                f'cast_streaming_shell.cm',
                '--',
                '--remote-debugging-port=0',
            ])
        # Use flags used on WebEngine in production devices.
        browser_cmd.extend([
            '--',
            '--enable-low-end-device-mode',
            '--force-gpu-mem-available-mb=64',
            '--force-gpu-mem-discardable-limit-mb=32',
            '--force-max-texture-size=2048',
            '--gpu-rasterization-msaa-sample-count=0',
            '--min-height-for-gpu-raster-tile=128',
            '--webgl-msaa-sample-count=0',
            '--max-decoded-image-size-mb=10',
        ])
        if extra_args:
            browser_cmd.extend(extra_args)
        self._browser_proc = run_continuous_ffx_command(
            cmd=browser_cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            target_id=self._target_id)
        # The stdout will be forwarded to the symbolizer, then to the _log_fs.
        self._log_fs = tempfile.NamedTemporaryFile()
        self._symbolizer_proc = run_symbolizer(self._id_files,
                                               self._browser_proc.stdout,
                                               self._log_fs)
        self._devtools_port = self._read_devtools_port()

    def stop_browser(self) -> None:
        """Stops the browser on the target, as well as the local symbolizer, the
        _log_fs is preserved. Calling this function for a second time won't have
        any effect."""
        if not self.is_browser_running():
            return
        self._browser_proc.kill()
        self._browser_proc = None
        self._symbolizer_proc.kill()
        self._symbolizer_proc = None
        self._devtools_port = None
        # The process may be stopped already, ignoring the no process found
        # error.
        ssh_run(['killall', 'web_instance.cmx'], self._target_id, check=False)

    def is_browser_running(self) -> bool:
        """Checks if the browser is still running."""
        if self._browser_proc:
            assert self._symbolizer_proc
            assert self._devtools_port
            return True
        assert not self._symbolizer_proc
        assert not self._devtools_port
        return False

    def close(self) -> None:
        """Cleans up everything."""
        self.stop_browser()
        self._log_fs.close()
        self._log_fs = None