xref: /aosp_15_r20/external/cronet/build/lacros/test_runner.py (revision 6777b5387eb2ff775bb5750e3f5d96f37fb7352b)
1#!/usr/bin/env python3
2#
3# Copyright 2020 The Chromium Authors
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6"""This script facilitates running tests for lacros on Linux.
7
8  In order to run lacros tests on Linux, please first follow bit.ly/3juQVNJ
9  to setup build directory with the lacros-chrome-on-linux build configuration,
10  and corresponding test targets are built successfully.
11
12Example usages
13
14  ./build/lacros/test_runner.py test out/lacros/url_unittests
15  ./build/lacros/test_runner.py test out/lacros/browser_tests
16
17  The commands above run url_unittests and browser_tests respectively, and more
18  specifically, url_unitests is executed directly while browser_tests is
19  executed with the latest version of prebuilt ash-chrome, and the behavior is
20  controlled by |_TARGETS_REQUIRE_ASH_CHROME|, and it's worth noting that the
21  list is maintained manually, so if you see something is wrong, please upload a
22  CL to fix it.
23
24  ./build/lacros/test_runner.py test out/lacros/browser_tests \\
25      --gtest_filter=BrowserTest.Title
26
27  The above command only runs 'BrowserTest.Title', and any argument accepted by
28  the underlying test binary can be specified in the command.
29
30  ./build/lacros/test_runner.py test out/lacros/browser_tests \\
31    --ash-chrome-version=120.0.6099.0
32
33  The above command runs tests with a given version of ash-chrome, which is
34  useful to reproduce test failures. A list of prebuilt versions can
35  be found at:
36  https://chrome-infra-packages.appspot.com/p/chromium/testing/linux-ash-chromium/x86_64/ash.zip
37  Click on any instance, you should see the version number for that instance.
38  Also, there are refs, which points to the instance for that channel. It should
39  be close the prod version but no guarantee.
40  For legacy refs, like legacy119, it point to the latest version for that
41  milestone.
42
43  ./testing/xvfb.py ./build/lacros/test_runner.py test out/lacros/browser_tests
44
45  The above command starts ash-chrome with xvfb instead of an X11 window, and
46  it's useful when running tests without a display attached, such as sshing.
47
48  For version skew testing when passing --ash-chrome-path-override, the runner
49  will try to find the ash major version and Lacros major version. If ash is
50  newer(major version larger), the runner will not run any tests and just
51  returns success.
52
53Interactively debugging tests
54
55  Any of the previous examples accept the switches
56    --gdb
57    --lldb
58  to run the tests in the corresponding debugger.
59"""
60
61import argparse
62import json
63import os
64import logging
65import re
66import shutil
67import signal
68import subprocess
69import sys
70import tempfile
71import time
72import zipfile
73
74_SRC_ROOT = os.path.abspath(
75    os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir))
76sys.path.append(os.path.join(_SRC_ROOT, 'third_party', 'depot_tools'))
77
78
79# The cipd path for prebuilt ash chrome.
80_ASH_CIPD_PATH = 'chromium/testing/linux-ash-chromium/x86_64/ash.zip'
81
82
83# Directory to cache downloaded ash-chrome versions to avoid re-downloading.
84_PREBUILT_ASH_CHROME_DIR = os.path.join(os.path.dirname(__file__),
85                                        'prebuilt_ash_chrome')
86
87# File path to the asan symbolizer executable.
88_ASAN_SYMBOLIZER_PATH = os.path.join(_SRC_ROOT, 'tools', 'valgrind', 'asan',
89                                     'asan_symbolize.py')
90
91# Number of seconds to wait for ash-chrome to start.
92ASH_CHROME_TIMEOUT_SECONDS = (
93    300 if os.environ.get('ASH_WRAPPER', None) else 25)
94
95# List of targets that require ash-chrome as a Wayland server in order to run.
96_TARGETS_REQUIRE_ASH_CHROME = [
97    'app_shell_unittests',
98    'aura_unittests',
99    'browser_tests',
100    'components_unittests',
101    'compositor_unittests',
102    'content_unittests',
103    'dbus_unittests',
104    'extensions_unittests',
105    'media_unittests',
106    'message_center_unittests',
107    'snapshot_unittests',
108    'sync_integration_tests',
109    'unit_tests',
110    'views_unittests',
111    'wm_unittests',
112
113    # regex patterns.
114    '.*_browsertests',
115    '.*interactive_ui_tests'
116]
117
118# List of targets that require ash-chrome to support crosapi mojo APIs.
119_TARGETS_REQUIRE_MOJO_CROSAPI = [
120    # TODO(jamescook): Add 'browser_tests' after multiple crosapi connections
121    # are allowed. For now we only enable crosapi in targets that run tests
122    # serially.
123    'interactive_ui_tests',
124    'lacros_chrome_browsertests',
125]
126
127# Default test filter file for each target. These filter files will be
128# used by default if no other filter file get specified.
129_DEFAULT_FILTER_FILES_MAPPING = {
130    'browser_tests': 'linux-lacros.browser_tests.filter',
131    'components_unittests': 'linux-lacros.components_unittests.filter',
132    'content_browsertests': 'linux-lacros.content_browsertests.filter',
133    'interactive_ui_tests': 'linux-lacros.interactive_ui_tests.filter',
134    'lacros_chrome_browsertests':
135    'linux-lacros.lacros_chrome_browsertests.filter',
136    'sync_integration_tests': 'linux-lacros.sync_integration_tests.filter',
137    'unit_tests': 'linux-lacros.unit_tests.filter',
138}
139
140
141def _GetAshChromeDirPath(version):
142  """Returns a path to the dir storing the downloaded version of ash-chrome."""
143  return os.path.join(_PREBUILT_ASH_CHROME_DIR, version)
144
145
146def _remove_unused_ash_chrome_versions(version_to_skip):
147  """Removes unused ash-chrome versions to save disk space.
148
149  Currently, when an ash-chrome zip is downloaded and unpacked, the atime/mtime
150  of the dir and the files are NOW instead of the time when they were built, but
151  there is no garanteen it will always be the behavior in the future, so avoid
152  removing the current version just in case.
153
154  Args:
155    version_to_skip (str): the version to skip removing regardless of its age.
156  """
157  days = 7
158  expiration_duration = 60 * 60 * 24 * days
159
160  for f in os.listdir(_PREBUILT_ASH_CHROME_DIR):
161    if f == version_to_skip:
162      continue
163
164    p = os.path.join(_PREBUILT_ASH_CHROME_DIR, f)
165    if os.path.isfile(p):
166      # The prebuilt ash-chrome dir is NOT supposed to contain any files, remove
167      # them to keep the directory clean.
168      os.remove(p)
169      continue
170    chrome_path = os.path.join(p, 'test_ash_chrome')
171    if not os.path.exists(chrome_path):
172      chrome_path = p
173    age = time.time() - os.path.getatime(chrome_path)
174    if age > expiration_duration:
175      logging.info(
176          'Removing ash-chrome: "%s" as it hasn\'t been used in the '
177          'past %d days', p, days)
178      shutil.rmtree(p)
179
180
181def _GetLatestVersionOfAshChrome():
182  '''Get the latest ash chrome version.
183
184  Get the package version info with canary ref.
185
186  Returns:
187    A string with the chrome version.
188
189  Raises:
190    RuntimeError: if we can not get the version.
191  '''
192  cp = subprocess.run(
193      ['cipd', 'describe', _ASH_CIPD_PATH, '-version', 'canary'],
194      capture_output=True)
195  assert (cp.returncode == 0)
196  groups = re.search(r'version:(?P<version>[\d\.]+)', str(cp.stdout))
197  if not groups:
198    raise RuntimeError('Can not find the version. Error message: %s' %
199                       cp.stdout)
200  return groups.group('version')
201
202
203def _DownloadAshChromeFromCipd(path, version):
204  '''Download the ash chrome with the requested version.
205
206  Args:
207    path: string for the downloaded ash chrome folder.
208    version: string for the ash chrome version.
209
210  Returns:
211    A string representing the path for the downloaded ash chrome.
212  '''
213  with tempfile.TemporaryDirectory() as temp_dir:
214    ensure_file_path = os.path.join(temp_dir, 'ensure_file.txt')
215    f = open(ensure_file_path, 'w+')
216    f.write(_ASH_CIPD_PATH + ' version:' + version)
217    f.close()
218    subprocess.run(
219        ['cipd', 'ensure', '-ensure-file', ensure_file_path, '-root', path])
220
221
222def _DoubleCheckDownloadedAshChrome(path, version):
223  '''Check the downloaded ash is the expected version.
224
225  Double check by running the chrome binary with --version.
226
227  Args:
228    path: string for the downloaded ash chrome folder.
229    version: string for the expected ash chrome version.
230
231  Raises:
232    RuntimeError if no test_ash_chrome binary can be found.
233  '''
234  test_ash_chrome = os.path.join(path, 'test_ash_chrome')
235  if not os.path.exists(test_ash_chrome):
236    raise RuntimeError('Can not find test_ash_chrome binary under %s' % path)
237  cp = subprocess.run([test_ash_chrome, '--version'], capture_output=True)
238  assert (cp.returncode == 0)
239  if str(cp.stdout).find(version) == -1:
240    logging.warning(
241        'The downloaded ash chrome version is %s, but the '
242        'expected ash chrome is %s. There is a version mismatch. Please '
243        'file a bug to OS>Lacros so someone can take a look.' %
244        (cp.stdout, version))
245
246
247def _DownloadAshChromeIfNecessary(version):
248  """Download a given version of ash-chrome if not already exists.
249
250  Args:
251    version: A string representing the version, such as "793554".
252
253  Raises:
254      RuntimeError: If failed to download the specified version, for example,
255          if the version is not present on gcs.
256  """
257
258  def IsAshChromeDirValid(ash_chrome_dir):
259    # This function assumes that once 'chrome' is present, other dependencies
260    # will be present as well, it's not always true, for example, if the test
261    # runner process gets killed in the middle of unzipping (~2 seconds), but
262    # it's unlikely for the assumption to break in practice.
263    return os.path.isdir(ash_chrome_dir) and os.path.isfile(
264        os.path.join(ash_chrome_dir, 'test_ash_chrome'))
265
266  ash_chrome_dir = _GetAshChromeDirPath(version)
267  if IsAshChromeDirValid(ash_chrome_dir):
268    return
269
270  shutil.rmtree(ash_chrome_dir, ignore_errors=True)
271  os.makedirs(ash_chrome_dir)
272  _DownloadAshChromeFromCipd(ash_chrome_dir, version)
273  _DoubleCheckDownloadedAshChrome(ash_chrome_dir, version)
274  _remove_unused_ash_chrome_versions(version)
275
276
277def _WaitForAshChromeToStart(tmp_xdg_dir, lacros_mojo_socket_file,
278                             enable_mojo_crosapi, ash_ready_file):
279  """Waits for Ash-Chrome to be up and running and returns a boolean indicator.
280
281  Determine whether ash-chrome is up and running by checking whether two files
282  (lock file + socket) have been created in the |XDG_RUNTIME_DIR| and the lacros
283  mojo socket file has been created if enabling the mojo "crosapi" interface.
284  TODO(crbug.com/1107966): Figure out a more reliable hook to determine the
285  status of ash-chrome, likely through mojo connection.
286
287  Args:
288    tmp_xdg_dir (str): Path to the XDG_RUNTIME_DIR.
289    lacros_mojo_socket_file (str): Path to the lacros mojo socket file.
290    enable_mojo_crosapi (bool): Whether to bootstrap the crosapi mojo interface
291        between ash and the lacros test binary.
292    ash_ready_file (str): Path to a non-existing file. After ash is ready for
293        testing, the file will be created.
294
295  Returns:
296    A boolean indicating whether Ash-chrome is up and running.
297  """
298
299  def IsAshChromeReady(tmp_xdg_dir, lacros_mojo_socket_file,
300                       enable_mojo_crosapi, ash_ready_file):
301    # There should be 2 wayland files.
302    if len(os.listdir(tmp_xdg_dir)) < 2:
303      return False
304    if enable_mojo_crosapi and not os.path.exists(lacros_mojo_socket_file):
305      return False
306    return os.path.exists(ash_ready_file)
307
308  time_counter = 0
309  while not IsAshChromeReady(tmp_xdg_dir, lacros_mojo_socket_file,
310                             enable_mojo_crosapi, ash_ready_file):
311    time.sleep(0.5)
312    time_counter += 0.5
313    if time_counter > ASH_CHROME_TIMEOUT_SECONDS:
314      break
315
316  return IsAshChromeReady(tmp_xdg_dir, lacros_mojo_socket_file,
317                          enable_mojo_crosapi, ash_ready_file)
318
319
320def _ExtractAshMajorVersion(file_path):
321  """Extract major version from file_path.
322
323  File path like this:
324  ../../lacros_version_skew_tests_v94.0.4588.0/test_ash_chrome
325
326  Returns:
327    int representing the major version. Or 0 if it can't extract
328        major version.
329  """
330  m = re.search(
331      'lacros_version_skew_tests_v(?P<version>[0-9]+).[0-9]+.[0-9]+.[0-9]+/',
332      file_path)
333  if (m and 'version' in m.groupdict().keys()):
334    return int(m.group('version'))
335  logging.warning('Can not find the ash version in %s.' % file_path)
336  # Returns ash major version as 0, so we can still run tests.
337  # This is likely happen because user is running in local environments.
338  return 0
339
340
341def _FindLacrosMajorVersionFromMetadata():
342  # This handles the logic on bots. When running on bots,
343  # we don't copy source files to test machines. So we build a
344  # metadata.json file which contains version information.
345  if not os.path.exists('metadata.json'):
346    logging.error('Can not determine current version.')
347    # Returns 0 so it can't run any tests.
348    return 0
349  version = ''
350  with open('metadata.json', 'r') as file:
351    content = json.load(file)
352    version = content['content']['version']
353  return int(version[:version.find('.')])
354
355
356def _FindLacrosMajorVersion():
357  """Returns the major version in the current checkout.
358
359  It would try to read src/chrome/VERSION. If it's not available,
360  then try to read metadata.json.
361
362  Returns:
363    int representing the major version. Or 0 if it fails to
364    determine the version.
365  """
366  version_file = os.path.abspath(
367      os.path.join(os.path.abspath(os.path.dirname(__file__)),
368                   '../../chrome/VERSION'))
369  # This is mostly happens for local development where
370  # src/chrome/VERSION exists.
371  if os.path.exists(version_file):
372    lines = open(version_file, 'r').readlines()
373    return int(lines[0][lines[0].find('=') + 1:-1])
374  return _FindLacrosMajorVersionFromMetadata()
375
376
377def _ParseSummaryOutput(forward_args):
378  """Find the summary output file path.
379
380  Args:
381    forward_args (list): Args to be forwarded to the test command.
382
383  Returns:
384    None if not found, or str representing the output file path.
385  """
386  logging.warning(forward_args)
387  for arg in forward_args:
388    if arg.startswith('--test-launcher-summary-output='):
389      return arg[len('--test-launcher-summary-output='):]
390  return None
391
392
393def _IsRunningOnBots(forward_args):
394  """Detects if the script is running on bots or not.
395
396  Args:
397    forward_args (list): Args to be forwarded to the test command.
398
399  Returns:
400    True if the script is running on bots. Otherwise returns False.
401  """
402  return '--test-launcher-bot-mode' in forward_args
403
404
405def _KillNicely(proc, timeout_secs=2, first_wait_secs=0):
406  """Kills a subprocess nicely.
407
408  Args:
409    proc: The subprocess to kill.
410    timeout_secs: The timeout to wait in seconds.
411    first_wait_secs: The grace period before sending first SIGTERM in seconds.
412  """
413  if not proc:
414    return
415
416  if first_wait_secs:
417    try:
418      proc.wait(first_wait_secs)
419      return
420    except subprocess.TimeoutExpired:
421      pass
422
423  if proc.poll() is None:
424    proc.terminate()
425    try:
426      proc.wait(timeout_secs)
427    except subprocess.TimeoutExpired:
428      proc.kill()
429      proc.wait()
430
431
432def _ClearDir(dirpath):
433  """Deletes everything within the directory.
434
435  Args:
436    dirpath: The path of the directory.
437  """
438  for e in os.scandir(dirpath):
439    if e.is_dir():
440      shutil.rmtree(e.path, ignore_errors=True)
441    elif e.is_file():
442      os.remove(e.path)
443
444
445def _LaunchDebugger(args, forward_args, test_env):
446  """Launches the requested debugger.
447
448  This is used to wrap the test invocation in a debugger. It returns the
449  created Popen class of the debugger process.
450
451  Args:
452      args (dict): Args for this script.
453      forward_args (list): Args to be forwarded to the test command.
454      test_env (dict): Computed environment variables for the test.
455  """
456  logging.info('Starting debugger.')
457
458  # Redirect fatal signals to "ignore." When running an interactive debugger,
459  # these signals should go only to the debugger so the user can break back out
460  # of the debugged test process into the debugger UI without killing this
461  # parent script.
462  for sig in (signal.SIGTERM, signal.SIGINT):
463    signal.signal(sig, signal.SIG_IGN)
464
465  # Force the tests into single-process-test mode for debugging unless manually
466  # specified. Otherwise the tests will run in a child process that the debugger
467  # won't be attached to and the debugger won't do anything.
468  if not ("--single-process" in forward_args
469          or "--single-process-tests" in forward_args):
470    forward_args += ["--single-process-tests"]
471
472    # Adding --single-process-tests can cause some tests to fail when they're
473    # run in the same process. Forcing the user to specify a filter will prevent
474    # a later error.
475    if not [i for i in forward_args if i.startswith("--gtest_filter")]:
476      logging.error("""Interactive debugging requested without --gtest_filter
477
478This script adds --single-process-tests to support interactive debugging but
479some tests will fail in this mode unless run independently. To debug a test
480specify a --gtest_filter=Foo.Bar to name the test you want to debug.
481""")
482      sys.exit(1)
483
484  # This code attempts to source the debugger configuration file. Some
485  # users will have this in their init but sourcing it more than once is
486  # harmless and helps people that haven't configured it.
487  if args.gdb:
488    gdbinit_file = os.path.normpath(
489        os.path.join(os.path.realpath(__file__), "../../../tools/gdb/gdbinit"))
490    debugger_command = [
491        'gdb', '--init-eval-command', 'source ' + gdbinit_file, '--args'
492    ]
493  else:
494    lldbinit_dir = os.path.normpath(
495        os.path.join(os.path.realpath(__file__), "../../../tools/lldb"))
496    debugger_command = [
497        'lldb', '-O',
498        "script sys.path[:0] = ['%s']" % lldbinit_dir, '-O',
499        'script import lldbinit', '--'
500    ]
501  debugger_command += [args.command] + forward_args
502  return subprocess.Popen(debugger_command, env=test_env)
503
504
505def _RunTestWithAshChrome(args, forward_args):
506  """Runs tests with ash-chrome.
507
508  Args:
509    args (dict): Args for this script.
510    forward_args (list): Args to be forwarded to the test command.
511  """
512  if args.ash_chrome_path_override:
513    ash_chrome_file = args.ash_chrome_path_override
514    ash_major_version = _ExtractAshMajorVersion(ash_chrome_file)
515    lacros_major_version = _FindLacrosMajorVersion()
516    if ash_major_version > lacros_major_version:
517      logging.warning('''Not running any tests, because we do not \
518support version skew testing for Lacros M%s against ash M%s''' %
519                      (lacros_major_version, ash_major_version))
520      # Create an empty output.json file so result adapter can read
521      # the file. Or else result adapter will report no file found
522      # and result infra failure.
523      output_json = _ParseSummaryOutput(forward_args)
524      if output_json:
525        with open(output_json, 'w') as f:
526          f.write("""{"all_tests":[],"disabled_tests":[],"global_tags":[],
527"per_iteration_data":[],"test_locations":{}}""")
528      # Although we don't run any tests, this is considered as success.
529      return 0
530    if not os.path.exists(ash_chrome_file):
531      logging.error("""Can not find ash chrome at %s. Did you download \
532the ash from CIPD? If you don't plan to build your own ash, you need \
533to download first. Example commandlines:
534 $ cipd auth-login
535 $ echo "chromium/testing/linux-ash-chromium/x86_64/ash.zip \
536version:92.0.4515.130" > /tmp/ensure-file.txt
537 $ cipd ensure -ensure-file /tmp/ensure-file.txt \
538-root lacros_version_skew_tests_v92.0.4515.130
539 Then you can use --ash-chrome-path-override=\
540lacros_version_skew_tests_v92.0.4515.130/test_ash_chrome
541""" % ash_chrome_file)
542      return 1
543  elif args.ash_chrome_path:
544    ash_chrome_file = args.ash_chrome_path
545  else:
546    ash_chrome_version = (args.ash_chrome_version
547                          or _GetLatestVersionOfAshChrome())
548    _DownloadAshChromeIfNecessary(ash_chrome_version)
549    logging.info('Ash-chrome version: %s', ash_chrome_version)
550
551    ash_chrome_file = os.path.join(_GetAshChromeDirPath(ash_chrome_version),
552                                   'test_ash_chrome')
553  try:
554    # Starts Ash-Chrome.
555    tmp_xdg_dir_name = tempfile.mkdtemp()
556    tmp_ash_data_dir_name = tempfile.mkdtemp()
557    tmp_unique_ash_dir_name = tempfile.mkdtemp()
558
559    # Please refer to below file for how mojo connection is set up in testing.
560    # //chrome/browser/ash/crosapi/test_mojo_connection_manager.h
561    lacros_mojo_socket_file = '%s/lacros.sock' % tmp_ash_data_dir_name
562    lacros_mojo_socket_arg = ('--lacros-mojo-socket-for-testing=%s' %
563                              lacros_mojo_socket_file)
564    ash_ready_file = '%s/ash_ready.txt' % tmp_ash_data_dir_name
565    enable_mojo_crosapi = any(t == os.path.basename(args.command)
566                              for t in _TARGETS_REQUIRE_MOJO_CROSAPI)
567    ash_wayland_socket_name = 'wayland-exo'
568
569    ash_process = None
570    ash_env = os.environ.copy()
571    ash_env['XDG_RUNTIME_DIR'] = tmp_xdg_dir_name
572    ash_cmd = [
573        ash_chrome_file,
574        '--user-data-dir=%s' % tmp_ash_data_dir_name,
575        '--enable-wayland-server',
576        '--no-startup-window',
577        '--disable-input-event-activation-protection',
578        '--disable-lacros-keep-alive',
579        '--disable-login-lacros-opening',
580        '--enable-field-trial-config',
581        '--enable-logging=stderr',
582        '--enable-features=LacrosSupport,LacrosPrimary,LacrosOnly',
583        '--ash-ready-file-path=%s' % ash_ready_file,
584        '--wayland-server-socket=%s' % ash_wayland_socket_name,
585    ]
586    if '--enable-pixel-output-in-tests' not in forward_args:
587      ash_cmd.append('--disable-gl-drawing-for-tests')
588
589    if enable_mojo_crosapi:
590      ash_cmd.append(lacros_mojo_socket_arg)
591
592    # Users can specify a wrapper for the ash binary to do things like
593    # attaching debuggers. For example, this will open a new terminal window
594    # and run GDB.
595    #   $ export ASH_WRAPPER="gnome-terminal -- gdb --ex=r --args"
596    ash_wrapper = os.environ.get('ASH_WRAPPER', None)
597    if ash_wrapper:
598      logging.info('Running ash with "ASH_WRAPPER": %s', ash_wrapper)
599      ash_cmd = list(ash_wrapper.split()) + ash_cmd
600
601    ash_process = None
602    ash_process_has_started = False
603    total_tries = 3
604    num_tries = 0
605    ash_start_time = None
606
607    # Create a log file if the user wanted to have one.
608    ash_log = None
609    ash_log_path = None
610
611    run_tests_in_debugger = args.gdb or args.lldb
612
613    if args.ash_logging_path:
614      ash_log_path = args.ash_logging_path
615    # Put ash logs in a separate file on bots.
616    # For asan builds, the ash log is not symbolized. In order to
617    # read the stack strace, we don't redirect logs to another file.
618    elif _IsRunningOnBots(forward_args) and not args.combine_ash_logs_on_bots:
619      summary_file = _ParseSummaryOutput(forward_args)
620      if summary_file:
621        ash_log_path = os.path.join(os.path.dirname(summary_file),
622                                    'ash_chrome.log')
623    elif run_tests_in_debugger:
624      # The debugger is unusable when all Ash logs are getting dumped to the
625      # same terminal. Redirect to a log file if there isn't one specified.
626      logging.info("Running in the debugger and --ash-logging-path is not " +
627                   "specified, defaulting to the current directory.")
628      ash_log_path = 'ash_chrome.log'
629
630    if ash_log_path:
631      ash_log = open(ash_log_path, 'a')
632      logging.info('Writing ash-chrome logs to: %s', ash_log_path)
633
634    ash_stdout = ash_log or None
635    test_stdout = None
636
637    # Setup asan symbolizer.
638    ash_symbolize_process = None
639    test_symbolize_process = None
640    should_symbolize = False
641    if args.asan_symbolize_output and os.path.exists(_ASAN_SYMBOLIZER_PATH):
642      should_symbolize = True
643      ash_symbolize_stdout = ash_stdout
644      ash_stdout = subprocess.PIPE
645      test_stdout = subprocess.PIPE
646
647    while not ash_process_has_started and num_tries < total_tries:
648      num_tries += 1
649      ash_start_time = time.monotonic()
650      logging.info('Starting ash-chrome: ' + ' '.join(ash_cmd))
651
652      # Using preexec_fn=os.setpgrp here will detach the forked process from
653      # this process group before exec-ing Ash. This prevents interactive
654      # Control-C from being seen by Ash. Otherwise Control-C in a debugger
655      # can kill Ash out from under the debugger. In non-debugger cases, this
656      # script attempts to clean up the spawned processes nicely.
657      ash_process = subprocess.Popen(ash_cmd,
658                                     env=ash_env,
659                                     preexec_fn=os.setpgrp,
660                                     stdout=ash_stdout,
661                                     stderr=subprocess.STDOUT)
662
663      if should_symbolize:
664        logging.info('Symbolizing ash logs with asan symbolizer.')
665        ash_symbolize_process = subprocess.Popen([_ASAN_SYMBOLIZER_PATH],
666                                                 stdin=ash_process.stdout,
667                                                 preexec_fn=os.setpgrp,
668                                                 stdout=ash_symbolize_stdout,
669                                                 stderr=subprocess.STDOUT)
670        # Allow ash_process to receive a SIGPIPE if symbolize process exits.
671        ash_process.stdout.close()
672
673      ash_process_has_started = _WaitForAshChromeToStart(
674          tmp_xdg_dir_name, lacros_mojo_socket_file, enable_mojo_crosapi,
675          ash_ready_file)
676      if ash_process_has_started:
677        break
678
679      logging.warning('Starting ash-chrome timed out after %ds',
680                      ASH_CHROME_TIMEOUT_SECONDS)
681      logging.warning('Are you using test_ash_chrome?')
682      logging.warning('Printing the output of "ps aux" for debugging:')
683      subprocess.call(['ps', 'aux'])
684      _KillNicely(ash_process)
685      _KillNicely(ash_symbolize_process, first_wait_secs=1)
686
687      # Clean up for retry.
688      _ClearDir(tmp_xdg_dir_name)
689      _ClearDir(tmp_ash_data_dir_name)
690
691    if not ash_process_has_started:
692      raise RuntimeError('Timed out waiting for ash-chrome to start')
693
694    ash_elapsed_time = time.monotonic() - ash_start_time
695    logging.info('Started ash-chrome in %.3fs on try %d.', ash_elapsed_time,
696                 num_tries)
697
698    # Starts tests.
699    if enable_mojo_crosapi:
700      forward_args.append(lacros_mojo_socket_arg)
701
702    forward_args.append('--ash-chrome-path=' + ash_chrome_file)
703    forward_args.append('--unique-ash-dir=' + tmp_unique_ash_dir_name)
704
705    test_env = os.environ.copy()
706    test_env['WAYLAND_DISPLAY'] = ash_wayland_socket_name
707    test_env['EGL_PLATFORM'] = 'surfaceless'
708    test_env['XDG_RUNTIME_DIR'] = tmp_xdg_dir_name
709
710    if run_tests_in_debugger:
711      test_process = _LaunchDebugger(args, forward_args, test_env)
712    else:
713      logging.info('Starting test process.')
714      test_process = subprocess.Popen([args.command] + forward_args,
715                                      env=test_env,
716                                      stdout=test_stdout,
717                                      stderr=subprocess.STDOUT)
718      if should_symbolize:
719        logging.info('Symbolizing test logs with asan symbolizer.')
720        test_symbolize_process = subprocess.Popen([_ASAN_SYMBOLIZER_PATH],
721                                                  stdin=test_process.stdout)
722        # Allow test_process to receive a SIGPIPE if symbolize process exits.
723        test_process.stdout.close()
724    return test_process.wait()
725
726  finally:
727    _KillNicely(ash_process)
728    # Give symbolizer processes time to finish writing with first_wait_secs.
729    _KillNicely(ash_symbolize_process, first_wait_secs=1)
730    _KillNicely(test_symbolize_process, first_wait_secs=1)
731
732    shutil.rmtree(tmp_xdg_dir_name, ignore_errors=True)
733    shutil.rmtree(tmp_ash_data_dir_name, ignore_errors=True)
734    shutil.rmtree(tmp_unique_ash_dir_name, ignore_errors=True)
735
736
737def _RunTestDirectly(args, forward_args):
738  """Runs tests by invoking the test command directly.
739
740  args (dict): Args for this script.
741  forward_args (list): Args to be forwarded to the test command.
742  """
743  try:
744    p = None
745    p = subprocess.Popen([args.command] + forward_args)
746    return p.wait()
747  finally:
748    _KillNicely(p)
749
750
751def _HandleSignal(sig, _):
752  """Handles received signals to make sure spawned test process are killed.
753
754  sig (int): An integer representing the received signal, for example SIGTERM.
755  """
756  logging.warning('Received signal: %d, killing spawned processes', sig)
757
758  # Don't do any cleanup here, instead, leave it to the finally blocks.
759  # Assumption is based on https://docs.python.org/3/library/sys.html#sys.exit:
760  # cleanup actions specified by finally clauses of try statements are honored.
761
762  # https://tldp.org/LDP/abs/html/exitcodes.html:
763  # Exit code 128+n -> Fatal error signal "n".
764  sys.exit(128 + sig)
765
766
767def _ExpandFilterFileIfNeeded(test_target, forward_args):
768  if (test_target in _DEFAULT_FILTER_FILES_MAPPING.keys() and not any(
769      [arg.startswith('--test-launcher-filter-file') for arg in forward_args])):
770    file_path = os.path.abspath(
771        os.path.join(os.path.dirname(__file__), '..', '..', 'testing',
772                     'buildbot', 'filters',
773                     _DEFAULT_FILTER_FILES_MAPPING[test_target]))
774    forward_args.append(f'--test-launcher-filter-file={file_path}')
775
776
777def _RunTest(args, forward_args):
778  """Runs tests with given args.
779
780  args (dict): Args for this script.
781  forward_args (list): Args to be forwarded to the test command.
782
783  Raises:
784      RuntimeError: If the given test binary doesn't exist or the test runner
785          doesn't know how to run it.
786  """
787
788  if not os.path.isfile(args.command):
789    raise RuntimeError('Specified test command: "%s" doesn\'t exist' %
790                       args.command)
791
792  test_target = os.path.basename(args.command)
793  _ExpandFilterFileIfNeeded(test_target, forward_args)
794
795  # |_TARGETS_REQUIRE_ASH_CHROME| may not always be accurate as it is updated
796  # with a best effort only, therefore, allow the invoker to override the
797  # behavior with a specified ash-chrome version, which makes sure that
798  # automated CI/CQ builders would always work correctly.
799  requires_ash_chrome = any(
800      re.match(t, test_target) for t in _TARGETS_REQUIRE_ASH_CHROME)
801  if not requires_ash_chrome and not args.ash_chrome_version:
802    return _RunTestDirectly(args, forward_args)
803
804  return _RunTestWithAshChrome(args, forward_args)
805
806
807def Main():
808  for sig in (signal.SIGTERM, signal.SIGINT):
809    signal.signal(sig, _HandleSignal)
810
811  logging.basicConfig(level=logging.INFO)
812  arg_parser = argparse.ArgumentParser()
813  arg_parser.usage = __doc__
814
815  subparsers = arg_parser.add_subparsers()
816
817  test_parser = subparsers.add_parser('test', help='Run tests')
818  test_parser.set_defaults(func=_RunTest)
819
820  test_parser.add_argument(
821      'command',
822      help='A single command to invoke the tests, for example: '
823      '"./url_unittests". Any argument unknown to this test runner script will '
824      'be forwarded to the command, for example: "--gtest_filter=Suite.Test"')
825
826  version_group = test_parser.add_mutually_exclusive_group()
827  version_group.add_argument(
828      '--ash-chrome-version',
829      type=str,
830      help='Version of an prebuilt ash-chrome to use for testing, for example: '
831      '"120.0.6099.0", and the version corresponds to the commit position of '
832      'commits on the main branch. If not specified, will use the latest '
833      'version available')
834  version_group.add_argument(
835      '--ash-chrome-path',
836      type=str,
837      help='Path to an locally built ash-chrome to use for testing. '
838      'In general you should build //chrome/test:test_ash_chrome.')
839
840  debugger_group = test_parser.add_mutually_exclusive_group()
841  debugger_group.add_argument('--gdb',
842                              action='store_true',
843                              help='Run the test in GDB.')
844  debugger_group.add_argument('--lldb',
845                              action='store_true',
846                              help='Run the test in LLDB.')
847
848  # This is for version skew testing. The current CI/CQ builder builds
849  # an ash chrome and pass it using --ash-chrome-path. In order to use the same
850  # builder for version skew testing, we use a new argument to override
851  # the ash chrome.
852  test_parser.add_argument(
853      '--ash-chrome-path-override',
854      type=str,
855      help='The same as --ash-chrome-path. But this will override '
856      '--ash-chrome-path or --ash-chrome-version if any of these '
857      'arguments exist.')
858  test_parser.add_argument(
859      '--ash-logging-path',
860      type=str,
861      help='File & path to ash-chrome logging output while running Lacros '
862      'browser tests. If not provided, no output will be generated.')
863  test_parser.add_argument('--combine-ash-logs-on-bots',
864                           action='store_true',
865                           help='Whether to combine ash logs on bots.')
866  test_parser.add_argument(
867      '--asan-symbolize-output',
868      action='store_true',
869      help='Whether to run subprocess log outputs through the asan symbolizer.')
870
871  args = arg_parser.parse_known_args()
872  if not hasattr(args[0], "func"):
873    # No command specified.
874    print(__doc__)
875    sys.exit(1)
876
877  return args[0].func(args[0], args[1])
878
879
880if __name__ == '__main__':
881  sys.exit(Main())
882