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