• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env vpython3
2# Copyright 2021 The Chromium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7import contextlib
8import json
9import logging
10import os
11import posixpath
12import re
13import shutil
14import subprocess
15import sys
16import tempfile
17import time
18
19from collections import OrderedDict
20from PIL import Image
21
22SRC_DIR = os.path.abspath(
23    os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
24PAR_DIR = os.path.join(SRC_DIR, 'testing')
25OUT_DIR = os.path.join(SRC_DIR, 'out', 'Release')
26BLINK_DIR = os.path.join(SRC_DIR, 'third_party', 'blink')
27BLINK_TOOLS = os.path.join(BLINK_DIR, 'tools')
28BLINK_WEB_TESTS = os.path.join(BLINK_DIR, 'web_tests')
29BUILD_ANDROID = os.path.join(SRC_DIR, 'build', 'android')
30CATAPULT_DIR = os.path.join(SRC_DIR, 'third_party', 'catapult')
31PYUTILS = os.path.join(CATAPULT_DIR, 'common', 'py_utils')
32
33# Protocall buffer directories to import
34PYPROTO_LIB = os.path.join(OUT_DIR, 'pyproto', 'google')
35WEBVIEW_VARIATIONS_PROTO = os.path.join(OUT_DIR, 'pyproto',
36                                        'android_webview', 'proto')
37
38if PYUTILS not in sys.path:
39  sys.path.append(PYUTILS)
40
41if BUILD_ANDROID not in sys.path:
42  sys.path.append(BUILD_ANDROID)
43
44if BLINK_TOOLS not in sys.path:
45  sys.path.append(BLINK_TOOLS)
46
47if PYPROTO_LIB not in sys.path:
48  sys.path.append(PYPROTO_LIB)
49
50if WEBVIEW_VARIATIONS_PROTO not in sys.path:
51  sys.path.append(WEBVIEW_VARIATIONS_PROTO)
52
53sys.path.append(PAR_DIR)
54
55if 'compile_targets' not in sys.argv:
56  import aw_variations_seed_pb2
57
58import devil_chromium
59
60from blinkpy.common.host import Host
61from blinkpy.common.path_finder import PathFinder
62from blinkpy.web_tests.models import test_failures
63from blinkpy.web_tests.port.android import (
64    ANDROID_WEBVIEW, CHROME_ANDROID)
65from blinkpy.w3c.wpt_results_processor import WPTResultsProcessor
66
67from devil import devil_env
68from devil.android import apk_helper
69from devil.android import device_temp_file
70from devil.android import flag_changer
71from devil.android import logcat_monitor
72from devil.android.tools import script_common
73from devil.android.tools import system_app
74from devil.android.tools import webview_app
75from devil.utils import logging_common
76from pylib.local.device import local_device_environment
77from pylib.local.emulator import avd
78from py_utils.tempfile_ext import NamedTemporaryDirectory
79from scripts import common
80from skia_gold_infra.finch_skia_gold_properties import FinchSkiaGoldProperties
81from skia_gold_infra import finch_skia_gold_session_manager
82from skia_gold_infra import finch_skia_gold_utils
83from run_wpt_tests import get_device
84
85ANDROID_WEBLAYER = 'android_weblayer'
86LOGCAT_TAG = 'finch_test_runner_py'
87LOGCAT_FILTERS = [
88  'chromium:v',
89  'cr_*:v',
90  'DEBUG:I',
91  'StrictMode:D',
92  'WebView*:v',
93  '%s:I' % LOGCAT_TAG
94]
95logger = logging.getLogger(__name__)
96logger.setLevel(logging.INFO)
97TEST_CASES = {}
98
99def _is_version_greater_than_or_equal(version1, version2):
100  version1_parts = version1.split('.')
101  version2_parts = version2.split('.')
102  for i in range(4):
103    comp = int(version1_parts[i]) - int(version2_parts[i])
104    if  comp != 0:
105      return comp > 0
106
107  return True
108
109
110def _merge_results_dicts(dict_to_merge, test_results_dict):
111  if 'actual' in dict_to_merge:
112    test_results_dict.update(dict_to_merge)
113    return
114  for key in dict_to_merge.keys():
115    _merge_results_dicts(dict_to_merge[key],
116                             test_results_dict.setdefault(key, {}))
117
118
119# pylint: disable=super-with-arguments, abstract-method
120class FinchTestCase(common.BaseIsolatedScriptArgsAdapter):
121
122  def __init__(self, device):
123    self.host = Host()
124    self.fs = self.host.filesystem
125    self.path_finder = PathFinder(self.fs)
126    self.port = self.host.port_factory.get()
127    super(FinchTestCase, self).__init__()
128    self._add_extra_arguments()
129    self._parser = self._override_options(self._parser)
130    self._include_filename = None
131    self.layout_test_results_subdir = 'layout-test-results'
132    self._device = device
133    self.parse_args()
134    self.port.set_option_default('target', self.options.target)
135    self._browser_apk_helper = apk_helper.ToHelper(self.options.browser_apk)
136
137    self.browser_package_name = self._browser_apk_helper.GetPackageName()
138    self.browser_activity_name = (self.options.browser_activity_name or
139                                  self.default_browser_activity_name)
140    self.layout_test_results_subdir = None
141    self.test_specific_browser_args = []
142    if self.options.webview_provider_apk:
143      self.webview_provider_package_name = (
144          apk_helper.GetPackageName(self.options.webview_provider_apk))
145
146    # Initialize the Skia Gold session manager
147    self._skia_gold_corpus = 'finch-smoke-tests'
148    self._skia_gold_tmp_dir = None
149    self._skia_gold_session_manager = None
150
151  @classmethod
152  def app_user_sub_dir(cls):
153    """Returns sub directory within user directory"""
154    return 'app_%s' % cls.product_name()
155
156  @classmethod
157  def product_name(cls):
158    raise NotImplementedError
159
160  @property
161  def tests(self):
162    return [
163      'dom/collections/HTMLCollection-delete.html',
164      'dom/collections/HTMLCollection-supported-property-names.html',
165      'dom/collections/HTMLCollection-supported-property-indices.html',
166    ]
167
168  @property
169  def pixel_tests(self):
170    return []
171
172  @property
173  def default_browser_activity_name(self):
174    raise NotImplementedError
175
176  @property
177  def default_finch_seed_path(self):
178    raise NotImplementedError
179
180  @classmethod
181  def finch_seed_download_args(cls):
182    return []
183
184  def generate_test_output_args(self, output):
185        return ['--log-chromium=%s' % output]
186
187  def generate_test_filter_args(self, test_filter_str):
188        included_tests, excluded_tests = \
189            self._resolve_tests_from_isolate_filter(test_filter_str)
190        include_file, self._include_filename = self.fs.open_text_tempfile()
191        with include_file:
192            for test in included_tests:
193                include_file.write(test)
194                include_file.write('\n')
195        wpt_args = ['--include-file=%s' % self._include_filename]
196        for test in excluded_tests:
197            wpt_args.append('--exclude=%s' % test)
198        return wpt_args
199
200  def _override_options(self, base_parser):
201        """Create a parser that overrides existing options.
202
203        `argument.ArgumentParser` can extend other parsers and override their
204        options, with the caveat that the child parser only inherits options
205        that the parent had at the time of the child's initialization.
206
207        See Also:
208            https://docs.python.org/3/library/argparse.html#parents
209        """
210        parser = argparse.ArgumentParser(
211            parents=[base_parser],
212            # Allow overriding existing options in the parent parser.
213            conflict_handler='resolve',
214            epilog=('All unrecognized arguments are passed through '
215                    "to wptrunner. Use '--wpt-help' to see wptrunner's usage."),
216        )
217        parser.add_argument(
218            '--isolated-script-test-repeat',
219            '--repeat',
220            '--gtest_repeat',
221            metavar='REPEAT',
222            type=int,
223            default=1,
224            help='Number of times to run the tests')
225        parser.add_argument(
226            '--isolated-script-test-launcher-retry-limit',
227            '--test-launcher-retry-limit',
228            '--retry-unexpected',
229            metavar='RETRIES',
230            type=int,
231            help=(
232                'Maximum number of times to rerun unexpectedly failed tests. '
233                'Defaults to 3 unless given an explicit list of tests to run.'))
234        # `--gtest_filter` and `--isolated-script-test-filter` have slightly
235        # different formats and behavior, so keep them as separate options.
236        # See: crbug/1316164#c4
237
238        # TODO(crbug.com/1356318): This is a temporary hack to hide the
239        # inherited '--xvfb' option and force Xvfb to run always.
240        parser.add_argument('--xvfb', action='store_true', default=True,
241                            help=argparse.SUPPRESS)
242        return parser
243
244  def generate_test_repeat_args(self, repeat_count):
245    return ['--repeat=%d' % repeat_count]
246
247  def generate_test_launcher_retry_limit_args(self, retry_limit):
248    return ['--retry-unexpected=%d' % retry_limit]
249
250  def generate_sharding_args(self, total_shards, shard_index):
251    return ['--total-chunks=%d' % total_shards,
252            # shard_index is 0-based but WPT's this-chunk to be 1-based
253            '--this-chunk=%d' % (shard_index + 1),
254            # The default sharding strategy is to shard by directory. But
255            # we want to hash each test to determine which shard runs it.
256            # This allows running individual directories that have few
257            # tests across many shards.
258            '--chunk-type=hash']
259
260  def clean_up_after_test_run(self):
261    if self._include_filename:
262        self.fs.remove(self._include_filename)
263
264  def new_seed_downloaded(self):
265    # TODO(crbug.com/1285152): Implement seed download test
266    # for Chrome and WebLayer.
267    return True
268
269  def enable_internet(self):
270    self._device.RunShellCommand(
271        ['settings', 'put', 'global', 'airplane_mode_on', '0'])
272    self._device.RunShellCommand(
273        ['am', 'broadcast', '-a',
274         'android.intent.action.AIRPLANE_MODE'])
275    self._device.RunShellCommand(['svc', 'wifi', 'enable'])
276    self._device.RunShellCommand(['svc', 'data', 'enable'])
277
278  def disable_internet(self):
279    self._device.RunShellCommand(
280        ['settings', 'put', 'global', 'airplane_mode_on', '1'])
281    self._device.RunShellCommand(
282        ['am', 'broadcast', '-a',
283         'android.intent.action.AIRPLANE_MODE'])
284
285  @contextlib.contextmanager
286  def _archive_logcat(self, filename, endpoint_name):
287    start_point = 'START {}'.format(endpoint_name)
288    end_point = 'END {}'.format(endpoint_name)
289    with logcat_monitor.LogcatMonitor(
290        self._device.adb,
291        filter_specs=LOGCAT_FILTERS,
292        output_file=filename,
293        check_error=False):
294      try:
295        self._device.RunShellCommand(['log', '-p', 'i', '-t', LOGCAT_TAG,
296                                      start_point],
297                                     check_return=True)
298        yield
299      finally:
300        self._device.RunShellCommand(['log', '-p', 'i', '-t', LOGCAT_TAG,
301                                      end_point],
302                                     check_return=True)
303
304  def parse_args(self, args=None):
305    super(FinchTestCase, self).parse_args(args)
306    if (not self.options.finch_seed_path or
307        not os.path.exists(self.options.finch_seed_path)):
308      logger.warning('Could not find the finch seed passed '
309                     'as the argument for --finch-seed-path. '
310                     'Running tests on the default finch seed')
311      self.options.finch_seed_path = self.default_finch_seed_path
312
313  @property
314  def output_directory(self):
315      return self.path_finder.path_from_chromium_base('out',
316                                                      self.options.target)
317
318  @property
319  def mojo_js_directory(self):
320      return self.fs.join(self.output_directory, 'gen')
321
322  @property
323  def wpt_output(self):
324      return self.options.isolated_script_test_output
325
326  @property
327  def _raw_log_path(self):
328    return self.fs.join(self.output_directory, 'finch-smoke-raw-events.log')
329
330  def __enter__(self):
331    self._device.EnableRoot()
332    # Run below commands to ensure that the device can download a seed
333    self.disable_internet()
334    self._device.adb.Emu(['power', 'ac', 'on'])
335    self._skia_gold_tmp_dir = tempfile.mkdtemp()
336    self._skia_gold_session_manager = (
337        finch_skia_gold_session_manager.FinchSkiaGoldSessionManager(
338            self._skia_gold_tmp_dir, FinchSkiaGoldProperties(self.options)))
339    return self
340
341  def __exit__(self, exc_type, exc_val, exc_tb):
342    self._skia_gold_session_manager = None
343    if self._skia_gold_tmp_dir:
344      shutil.rmtree(self._skia_gold_tmp_dir)
345      self._skia_gold_tmp_dir = None
346
347  @property
348  def rest_args(self):
349    unknown_args = super(FinchTestCase, self).rest_args
350
351    rest_args = list()
352
353    rest_args.extend(self.wpt_rest_args(unknown_args))
354
355    rest_args.extend([
356      '--webdriver-arg=--disable-build-check',
357      '--device-serial',
358      self._device.serial,
359      '--webdriver-binary',
360      os.path.join('clang_x64', 'chromedriver'),
361      '--symbols-path',
362      self.output_directory,
363      '--package-name',
364      self.browser_package_name,
365      '--keep-app-data-directory',
366      '--test-type=testharness',
367    ])
368
369    for binary_arg in self.browser_command_line_args():
370      rest_args.append('--binary-arg=%s' % binary_arg)
371
372    for test in self.tests:
373      rest_args.extend(['--include', test])
374
375    return rest_args
376
377  @property
378  def wpt_binary(self):
379    default_wpt_binary = os.path.join(
380        common.SRC_DIR, "third_party", "wpt_tools", "wpt", "wpt")
381    return os.environ.get("WPT_BINARY", default_wpt_binary)
382
383  @property
384  def wpt_root_dir(self):
385      return self.path_finder.path_from_web_tests(
386          self.path_finder.wpt_prefix())
387
388  @property
389  def _wpt_run_args(self):
390      """The start of a 'wpt run' command."""
391      return [
392          self.wpt_binary,
393          # Use virtualenv packages installed by vpython, not wpt.
394          '--venv=%s' % self.path_finder.chromium_base(),
395          '--skip-venv-setup',
396          'run',
397      ]
398
399  def process_and_upload_results(self, test_name_prefix):
400    processor = WPTResultsProcessor(
401        self.host.filesystem,
402        self.port,
403        artifacts_dir=os.path.join(os.path.dirname(self.wpt_output),
404                                   self.layout_test_results_subdir),
405        test_name_prefix=test_name_prefix)
406    processor.recreate_artifacts_dir()
407    with self.fs.open_text_file_for_reading(self._raw_log_path) as raw_logs:
408        for event in map(json.loads, raw_logs):
409            if event.get('action') != 'shutdown':
410                processor.process_event(event)
411    processor.process_results_json(self.wpt_output)
412
413  def wpt_rest_args(self, unknown_args):
414    rest_args = list(self._wpt_run_args)
415    rest_args.extend([
416        '--no-pause-after-test',
417        '--no-capture-stdio',
418        '--no-manifest-download',
419        '--tests=%s' % self.wpt_root_dir,
420        '--metadata=%s' % self.wpt_root_dir,
421        '--mojojs-path=%s' % self.mojo_js_directory,
422        '--log-raw=%s' % self._raw_log_path,
423    ])
424
425    if self.options.default_exclude:
426        rest_args.extend(['--default-exclude'])
427
428    if self.options.verbose >= 3:
429        rest_args.extend([
430            '--log-mach=-',
431            '--log-mach-level=debug',
432            '--log-mach-verbose',
433        ])
434    if self.options.verbose >= 4:
435        rest_args.extend([
436            '--webdriver-arg=--verbose',
437            '--webdriver-arg="--log-path=-"',
438        ])
439
440    rest_args.append(self.wpt_product_name())
441    # We pass through unknown args as late as possible so that they can
442    # override earlier options. It also allows users to pass test names as
443    # positional args, which must not have option strings between them.
444    for unknown_arg in unknown_args:
445        # crbug/1274933#c14: Some developers had used the end-of-options
446        # marker '--' to pass through arguments to wptrunner.
447        # crrev.com/c/3573284 makes this no longer necessary.
448        if unknown_arg == '--':
449            logger.warning(
450                'Unrecognized options will automatically fall through '
451                'to wptrunner.')
452            logger.warning(
453                "There is no need to use the end-of-options marker '--'.")
454        else:
455            rest_args.append(unknown_arg)
456    return rest_args
457
458  @classmethod
459  def add_common_arguments(cls, parser):
460    parser.add_argument('--test-case',
461                        choices=TEST_CASES.keys(),
462                        # TODO(rmhasan): Remove default values after
463                        # adding arguments to test suites. Also make
464                        # this argument required.
465                        default='webview',
466                        help='Name of test case')
467    parser.add_argument('--finch-seed-path',
468                        type=os.path.realpath,
469                        help='Path to the finch seed')
470    parser.add_argument('--browser-apk',
471                        '--webview-shell-apk',
472                        '--weblayer-shell-apk',
473                        help='Path to the browser apk',
474                        type=os.path.realpath,
475                        required=True)
476    parser.add_argument('--webview-provider-apk',
477                        type=os.path.realpath,
478                        help='Path to the WebView provider apk')
479    parser.add_argument('--additional-apk',
480                        action='append',
481                        type=os.path.realpath,
482                        default=[],
483                        help='List of additional apk\'s to install')
484    parser.add_argument('--browser-activity-name',
485                        action='store',
486                        help='Browser activity name')
487    parser.add_argument('--use-webview-installer-tool',
488                        action='store_true',
489                        help='Use the WebView installer tool.')
490    parser.add_argument('--fake-variations-channel',
491                        action='store',
492                        default='stable',
493                        choices=['dev', 'canary', 'beta', 'stable'],
494                        help='Finch seed release channel')
495    parser.add_argument('-j',
496                        '--processes',
497                        type=lambda processes: max(0, int(processes)),
498                        default=1,
499                        help='Number of emulator to run.')
500    common.add_emulator_args(parser)
501    # Add arguments used by Skia Gold.
502    FinchSkiaGoldProperties.AddCommandLineArguments(parser)
503
504  def _add_extra_arguments(self):
505    parser = self._parser
506    parser.add_argument(
507      '-t',
508      '--target',
509      default='Release',
510      help='Target build subdirectory under //out')
511    parser.add_argument(
512      '--default-exclude',
513      action='store_true',
514      help=('Only run the tests explicitly given in arguments '
515            '(can run no tests, which will exit with code 0)'))
516    parser.add_argument(
517      '-v',
518      '--verbose',
519      action='count',
520      default=0,
521      help='Increase verbosity')
522    self.add_product_specific_argument_groups(parser)
523    self.add_common_arguments(parser)
524
525  @classmethod
526  def add_product_specific_argument_groups(cls, _):
527    pass
528
529  def _compare_screenshots_with_baselines(self, all_pixel_tests_results_dict):
530    """Compare pixel tests screenshots with baselines stored in skia gold
531
532    Args:
533      all_pixel_tests_results_dict: Results dictionary for all pixel tests
534
535    Returns:
536      1 if there was an error comparing images otherwise 0
537    """
538    skia_gold_session = (
539        self._skia_gold_session_manager.GetSkiaGoldSession(
540            {'platform': 'android'}, self._skia_gold_corpus))
541
542    def _process_test_leaf(test_result_dict):
543      if ('artifacts' not in test_result_dict or
544          'actual_image' not in test_result_dict['artifacts']):
545        return 0
546
547      return_code = 0
548      artifacts_dict = test_result_dict['artifacts']
549      curr_artifacts = list(artifacts_dict.keys())
550      for artifact_name in curr_artifacts:
551        artifact_path = artifacts_dict[artifact_name][0]
552        # Compare screenshots to baselines stored in Skia Gold
553        status, error = skia_gold_session.RunComparison(
554            artifact_path,
555            os.path.join(os.path.dirname(self.wpt_output), artifact_path))
556
557        if status:
558          test_result_dict['actual'] = 'FAIL'
559          all_pixel_tests_results_dict['num_failures_by_type'].setdefault(
560              'FAIL', 0)
561          all_pixel_tests_results_dict['num_failures_by_type']['FAIL'] += 1
562          triage_link = finch_skia_gold_utils.log_skia_gold_status_code(
563              skia_gold_session, artifact_path, status, error)
564          if triage_link:
565            artifacts_dict['%s_triage_link' % artifact_name] = [triage_link]
566          return_code = 1
567        else:
568          test_result_dict['actual'] = 'PASS'
569
570      return return_code
571
572    def _process_tests(node):
573      return_code = 0
574      if 'actual' in node:
575        return _process_test_leaf(node)
576      for next_node in node.values():
577        return_code |= _process_tests(next_node)
578      return return_code
579
580    return _process_tests(all_pixel_tests_results_dict['tests'])
581
582  @contextlib.contextmanager
583  def install_apks(self):
584    """Install apks for testing"""
585    self._device.Uninstall(self.browser_package_name)
586    self._device.Install(self.options.browser_apk, reinstall=True)
587    for apk_path in self.options.additional_apk:
588      self._device.Install(apk_path)
589
590    self._device.ClearApplicationState(
591        self.browser_package_name,
592        permissions=self._browser_apk_helper.GetPermissions())
593
594    # TODO(rmhasan): For R+ test devices, store the files in the
595    # app's data directory. This is needed for R+ devices because
596    # of the scoped storage feature.
597    tests_root_dir = posixpath.join(self._device.GetExternalStoragePath(),
598                                    'chromium_tests_root')
599    local_device_environment.place_nomedia_on_device(self._device,
600                                                     tests_root_dir)
601
602    # Store screenshot tests on the device's external storage.
603    for test_file in self.pixel_tests:
604      self._device.RunShellCommand(
605          ['mkdir', '-p',
606           posixpath.join(tests_root_dir,
607                          'pixel_tests',
608                          posixpath.dirname(test_file))],
609          check_return=True)
610      self._device.adb.Push(os.path.join(BLINK_WEB_TESTS, test_file),
611                            posixpath.join(tests_root_dir,
612                                           'pixel_tests',
613                                           test_file))
614
615    yield
616
617  def browser_command_line_args(self):
618    return (['--vmodule=variations_field_trial_creator.cc=1', '--v=1',
619             '--disable-field-trial-config',
620             '--fake-variations-channel=%s' %
621             self.options.fake_variations_channel] +
622            self.test_specific_browser_args)
623
624  def run_tests(self, test_run_variation, all_test_results_dict,
625                extra_browser_args=None, check_seed_loaded=False):
626    """Run browser test on test device
627
628    Args:
629      test_run_variation: Test run variation.
630      all_test_results_dict: Main results dictionary containing
631        results for all test variations.
632      extra_browser_args: Extra browser arguments.
633      check_seed_loaded: Check if the finch seed was loaded.
634
635    Returns:
636      The return code of all tests.
637    """
638    isolate_root_dir = os.path.dirname(
639        self.options.isolated_script_test_output)
640    logcat_filename = '{}_{}_test_run_logcat.txt'.format(
641        self.product_name(), test_run_variation)
642    self.layout_test_results_subdir = ('%s_smoke_test_artifacts' %
643                                       test_run_variation)
644    self.test_specific_browser_args = extra_browser_args or []
645
646    with self._archive_logcat(os.path.join(isolate_root_dir, logcat_filename),
647                              '{} {} tests'.format(self.product_name(),
648                                                   test_run_variation)):
649      # Make sure the browser is not running before the tests run
650      self.stop_browser()
651
652      if self.tests:
653        ret = super(FinchTestCase, self).run_test()
654        self.stop_browser()
655
656      command_line_file = '%s-command-line' % self.product_name()
657      # Set the browser command line file
658      with flag_changer.CustomCommandLineFlags(
659          self._device, command_line_file, self.browser_command_line_args()):
660        # Run screen shot tests
661        pixel_tests_results_dict, pixel_tests_ret = self._run_pixel_tests()
662        ret |= pixel_tests_ret
663
664    seed_loaded_result_dict = {'num_failures_by_type': {}, 'tests': {}}
665
666    test_harness_results_dict = {'num_failures_by_type': {}, 'tests': {}}
667    # If wpt tests are not run then the file path stored in self.wpt_output
668    # was not created. That is why this check exists.
669    if os.path.exists(self.wpt_output):
670      self.process_and_upload_results(test_run_variation)
671
672      with open(self.wpt_output, 'r') as test_harness_results:
673        test_harness_results_dict = json.load(test_harness_results)
674      # If there are wpt results then add the the test name prefix to the
675      # results metadata dictionary so that the test name prefix is added
676      # to the test name in test results UI.
677      test_harness_results_dict['metadata'] = {'test_name_prefix':
678                                               test_run_variation}
679      with open(self.wpt_output, 'w+') as test_results_file:
680        json.dump(test_harness_results_dict, test_results_file)
681
682    final_logcat_path = os.path.join(isolate_root_dir,
683                                     self.layout_test_results_subdir,
684                                     logcat_filename)
685    os.makedirs(os.path.dirname(final_logcat_path), exist_ok=True)
686    shutil.move(os.path.join(isolate_root_dir, logcat_filename),
687                final_logcat_path)
688    if check_seed_loaded:
689      # Check in the logcat if the seed was loaded
690      ret |= self._finch_seed_loaded(final_logcat_path, seed_loaded_result_dict)
691
692    for test_results_dict in (test_harness_results_dict,
693                              pixel_tests_results_dict,
694                              seed_loaded_result_dict):
695       _merge_results_dicts(
696           test_results_dict['tests'],
697           all_test_results_dict['tests'].setdefault(test_run_variation, {}))
698
699       for result, count in test_results_dict['num_failures_by_type'].items():
700         all_test_results_dict['num_failures_by_type'].setdefault(result, 0)
701         all_test_results_dict['num_failures_by_type'][result] += count
702
703    return ret
704
705  def _finch_seed_loaded(self, logcat_path, all_results_dict):
706    raise NotImplementedError
707
708  def _run_pixel_tests(self):
709    """Run pixel tests on device
710
711    Returns:
712      A tuple containing a dictionary of pixel test results
713      and the skia gold status code.
714    """
715    tests_root_dir = posixpath.join(
716        self._device.GetExternalStoragePath(),
717        'chromium_tests_root',
718        'pixel_tests')
719
720    pixel_tests_results_dict = {'tests':{}, 'num_failures_by_type': {}}
721    for test_file in self.pixel_tests:
722      logger.info('Running pixel test %s', test_file)
723      try:
724        # The test result will for each tests will be set after
725        # comparing the test screenshots to skia gold baselines.
726        url = 'file://{}'.format(
727            posixpath.join(tests_root_dir, test_file))
728        self.start_browser(url)
729
730        screenshot_artifact_relpath = os.path.join(
731            'pixel_tests_artifacts',
732            self.layout_test_results_subdir.replace('_artifacts', ''),
733            self.port.output_filename(test_file,
734                                      test_failures.FILENAME_SUFFIX_ACTUAL,
735                                      '.png'))
736        screenshot_artifact_abspath = os.path.join(
737            os.path.dirname(self.options.isolated_script_test_output),
738            screenshot_artifact_relpath)
739
740        self._device.TakeScreenshot(host_path=screenshot_artifact_abspath)
741
742        # Crop away the Android status bar and the WebView shell's support
743        # action bar. We will do this by removing one fifth of the image
744        # from the top.
745        top_bar_height_factor = 0.2
746
747        # Crop away the bottom navigation bar from the screenshot. We can
748        # do this by cropping away one tenth of the image from the bottom.
749        navigation_bar_height_factor = 0.1
750
751        image = Image.open(screenshot_artifact_abspath)
752        width, height = image.size
753        cropped_image = image.crop(
754            (0,
755             int(height * top_bar_height_factor),
756             width,
757             int(height * (1 - navigation_bar_height_factor))))
758        image.close()
759        cropped_image.save(screenshot_artifact_abspath)
760
761        test_results_dict = pixel_tests_results_dict['tests']
762        for key in test_file.split('/'):
763          test_results_dict = test_results_dict.setdefault(key, {})
764
765        test_results_dict['actual'] = 'PASS'
766        test_results_dict['expected'] = 'PASS'
767        test_results_dict['artifacts'] = {
768            'actual_image': [screenshot_artifact_relpath]}
769      finally:
770        self.stop_browser()
771
772    # Compare screenshots with baselines stored in Skia Gold.
773    return (pixel_tests_results_dict,
774            self._compare_screenshots_with_baselines(pixel_tests_results_dict))
775
776  def stop_browser(self):
777    logger.info('Stopping package %s', self.browser_package_name)
778    self._device.ForceStop(self.browser_package_name)
779    if self.options.webview_provider_apk:
780      logger.info('Stopping package %s', self.webview_provider_package_name)
781      self._device.ForceStop(
782          self.webview_provider_package_name)
783
784  def start_browser(self, url=None):
785    full_activity_name = '%s/%s' % (self.browser_package_name,
786                                    self.browser_activity_name)
787    logger.info('Starting activity %s', full_activity_name)
788    url = url or 'www.google.com'
789
790    self._device.RunShellCommand([
791          'am',
792          'start',
793          '-W',
794          '-n',
795          full_activity_name,
796          '-d',
797          url])
798    logger.info('Waiting 5 seconds')
799    time.sleep(5)
800
801  def _wait_for_local_state_file(self, local_state_file):
802    """Wait for local state file to be generated"""
803    max_wait_time_secs = 120
804    delta_secs = 10
805    total_wait_time_secs = 0
806
807    self.start_browser()
808
809    while total_wait_time_secs < max_wait_time_secs:
810      if self._device.PathExists(local_state_file):
811        logger.info('Local state file generated')
812        self.stop_browser()
813        return
814
815      logger.info('Waiting %d seconds for the local state file to generate',
816                  delta_secs)
817      time.sleep(delta_secs)
818      total_wait_time_secs += delta_secs
819
820    raise Exception('Timed out waiting for the '
821                    'local state file to be generated')
822
823  def install_seed(self):
824    """Install finch seed for testing
825
826    Returns:
827      The path to the new finch seed under the application data folder.
828    """
829    app_data_dir = posixpath.join(
830        self._device.GetApplicationDataDirectory(self.browser_package_name),
831        self.app_user_sub_dir())
832
833    device_local_state_file = posixpath.join(app_data_dir, 'Local State')
834    self._wait_for_local_state_file(device_local_state_file)
835
836    seed_path = posixpath.join(app_data_dir, 'local_variations_seed')
837    self._device.adb.Push(self.options.finch_seed_path, seed_path)
838
839    user_id = self._device.GetUidForPackage(self.browser_package_name)
840    self._device.RunShellCommand(['chown', user_id, seed_path], as_root=True)
841
842    return seed_path
843
844
845class ChromeFinchTestCase(FinchTestCase):
846
847  @classmethod
848  def product_name(cls):
849    """Returns name of product being tested"""
850    return 'chrome'
851
852  @property
853  def default_finch_seed_path(self):
854    return os.path.join(SRC_DIR, 'testing', 'scripts',
855                        'variations_smoke_test_data',
856                        'variations_seed_stable_chrome_android.json')
857
858  @classmethod
859  def wpt_product_name(cls):
860    return CHROME_ANDROID
861
862  @property
863  def default_browser_activity_name(self):
864    return 'org.chromium.chrome.browser.ChromeTabbedActivity'
865
866
867class WebViewFinchTestCase(FinchTestCase):
868
869  @classmethod
870  def product_name(cls):
871    """Returns name of product being tested"""
872    return 'webview'
873
874  @classmethod
875  def wpt_product_name(cls):
876    return ANDROID_WEBVIEW
877
878  @property
879  def pixel_tests(self):
880    return super(WebViewFinchTestCase, self).pixel_tests + [
881        'external/wpt/svg/render/reftests/blending-001.svg',
882        'external/wpt/svg/render/reftests/blending-svg-foreign-object.html',
883        'external/wpt/svg/render/reftests/filter-effects-on-pattern.html',
884        'external/wpt/svg/pservers/reftests/radialgradient-basic-002.svg',
885    ]
886
887  def _finch_seed_loaded(self, logcat_path, all_results_dict):
888    """Checks the logcat if the seed was loaded
889
890    Args:
891      logcat_path: Path to the logcat.
892      all_results_dict: Dictionary containing test results
893
894    Returns:
895      0 if the seed was loaded and experiments were loaded for finch seeds
896      other than the default seed. Otherwise it returns 1.
897    """
898    with open(logcat_path, 'r') as logcat:
899      logcat_content = logcat.read()
900
901    seed_loaded = 'cr_VariationsUtils: Loaded seed with age' in logcat_content
902    logcat_relpath = os.path.relpath(logcat_path,
903                                     os.path.dirname(self.wpt_output))
904    seed_loaded_results_dict = (
905        all_results_dict['tests'].setdefault(
906            'check_seed_loaded',
907            {'expected': 'PASS',
908             'artifacts': {'logcat_path': [logcat_relpath]}}))
909
910    if seed_loaded:
911      logger.info('The finch seed was loaded by WebView')
912      seed_loaded_results_dict['actual'] = 'PASS'
913    else:
914      logger.error('The finch seed was not loaded by WebView')
915      seed_loaded_results_dict['actual'] = 'FAIL'
916      all_results_dict['num_failures_by_type']['FAIL'] = 1
917
918    # If the value for the --finch-seed-path argument does not exist, then
919    # a default seed is consumed. The default seed may be too old to have it's
920    # experiments loaded.
921    if self.default_finch_seed_path != self.options.finch_seed_path:
922      # For WebView versions >= 110.0.5463.0 we should check for a new log
923      # message in the logcat that confirms that field trials were loaded
924      # from the seed. This message is guaranteed to be outputted when a valid
925      # seed is loaded. We check for this log for versions >= 110.0.5463.0
926      # because it is the first version of WebView that contains
927      # crrev.com/c/4076271.
928      webview_version = self._device.GetApplicationVersion(
929          self._device.GetWebViewProvider())
930      check_for_vlog = (webview_version and
931                        _is_version_greater_than_or_equal(webview_version,
932                                                          '110.0.5463.0'))
933      field_trial_check_name = 'check_for_logged_field_trials'
934
935      if check_for_vlog:
936        # This log was added in crrev.com/c/4076271, which is part of the
937        # M110 milestone.
938        field_trials_loaded = (
939            'CreateTrialsFromSeed complete with seed.version='
940            in logcat_content)
941        field_trial_check_name = 'check_for_variations_field_trial_creator_logs'
942        expected_results = 'PASS'
943        logger.info("Checking for variations_field_trial_creator.cc logs "
944                    "in the logcat")
945      else:
946        # Check for a field trial that is guaranteed to be activated by
947        # the finch seed.
948        field_trials_loaded = ('Active field trial '
949                               '"UMA-Uniformity-Trial-100-Percent" '
950                               'in group "group_01"') in logcat_content
951        # It is not guaranteed that the field trials will be logged. That
952        # is why this check is flaky.
953        expected_results = 'PASS FAIL'
954        logger.info("Checking for the UMA uniformity trial in the logcat")
955
956      field_trials_loaded_results_dict = (
957          all_results_dict['tests'].setdefault(
958              field_trial_check_name,
959              {'expected': expected_results,
960               'artifacts': {'logcat_path': [logcat_relpath]}}))
961
962      if field_trials_loaded:
963        logger.info('Experiments were loaded from the finch seed by WebView')
964        field_trials_loaded_results_dict['actual'] = 'PASS'
965      else:
966        logger.error('Experiments were not loaded from '
967                     'the finch seed by WebView')
968        field_trials_loaded_results_dict['actual'] = 'FAIL'
969        all_results_dict['num_failures_by_type'].setdefault('FAIL', 0)
970        all_results_dict['num_failures_by_type']['FAIL'] += 1
971
972        if 'FAIL' in expected_results:
973          # If the check for field trial configs is flaky then only
974          # use the seed_loaded variable to set the return code.
975          return 0 if seed_loaded else 1
976
977      return 0 if seed_loaded and field_trials_loaded else 1
978
979    logger.warning('The default seed is being tested, '
980                   'skipping checks for active field trials')
981    return 0 if seed_loaded else 1
982
983  @classmethod
984  def finch_seed_download_args(cls):
985    return [
986        '--finch-seed-expiration-age=0',
987        '--finch-seed-min-update-period=0',
988        '--finch-seed-min-download-period=0',
989        '--finch-seed-ignore-pending-download',
990        '--finch-seed-no-charging-requirement']
991
992  @property
993  def default_browser_activity_name(self):
994    return 'org.chromium.webview_shell.WebViewBrowserActivity'
995
996  @property
997  def default_finch_seed_path(self):
998    return os.path.join(SRC_DIR, 'testing', 'scripts',
999                        'variations_smoke_test_data',
1000                        'webview_test_seed')
1001
1002  @classmethod
1003  def add_product_specific_argument_groups(cls, parser):
1004    installer_tool_group = parser.add_argument_group(
1005      'WebView Installer tool arguments')
1006    installer_tool_group.add_argument(
1007      '--webview-installer-tool', type=os.path.realpath,
1008      help='Path to the WebView installer tool')
1009    installer_tool_group.add_argument(
1010      '--chrome-version', '-V', type=str, default=None,
1011      help='Chrome version to install with the WebView installer tool')
1012    installer_tool_group.add_argument(
1013      '--channel', '-c', help='Channel build of WebView to install',
1014      choices=['dev', 'canary', 'beta', 'stable'], default=None)
1015    installer_tool_group.add_argument(
1016      '--milestone', '-M', help='Milestone build of WebView to install')
1017    installer_tool_group.add_argument(
1018      '--package', '-P', default=None,
1019      help='Name of the WebView apk to install')
1020
1021
1022  def new_seed_downloaded(self):
1023    """Checks if a new seed was downloaded
1024
1025    Returns:
1026      True if a new seed was downloaded, otherwise False
1027    """
1028    app_data_dir = posixpath.join(
1029        self._device.GetApplicationDataDirectory(self.browser_package_name),
1030        self.app_user_sub_dir())
1031    remote_seed_path = posixpath.join(app_data_dir, 'variations_seed')
1032
1033    with NamedTemporaryDirectory() as tmp_dir:
1034      current_seed_path = os.path.join(tmp_dir, 'current_seed')
1035      self._device.adb.Pull(remote_seed_path, current_seed_path)
1036      with open(current_seed_path, 'rb') as current_seed_obj, \
1037          open(self.options.finch_seed_path, 'rb') as baseline_seed_obj:
1038        current_seed_content = current_seed_obj.read()
1039        baseline_seed_content = baseline_seed_obj.read()
1040        current_seed = aw_variations_seed_pb2.AwVariationsSeed.FromString(
1041            current_seed_content)
1042        baseline_seed = aw_variations_seed_pb2.AwVariationsSeed.FromString(
1043            baseline_seed_content)
1044        shutil.copy(current_seed_path, os.path.join(OUT_DIR, 'final_seed'))
1045
1046        logger.info("Downloaded seed's signature: %s", current_seed.signature)
1047        logger.info("Baseline seed's signature: %s", baseline_seed.signature)
1048        return current_seed_content != baseline_seed_content
1049
1050  def browser_command_line_args(self):
1051    return (super(WebViewFinchTestCase, self).browser_command_line_args() +
1052            ['--webview-verbose-logging'])
1053
1054  @contextlib.contextmanager
1055  def install_apks(self):
1056    """Install apks for testing"""
1057    with super(WebViewFinchTestCase, self).install_apks():
1058      if self.options.use_webview_installer_tool:
1059        install_webview = self._install_webview_with_tool()
1060      else:
1061        install_webview = webview_app.UseWebViewProvider(
1062          self._device, self.options.webview_provider_apk)
1063
1064      with install_webview:
1065        yield
1066
1067  @contextlib.contextmanager
1068  def _install_webview_with_tool(self):
1069    """Install WebView with the WebView installer tool"""
1070    original_webview_provider = self._device.GetWebViewProvider()
1071    current_webview_provider = None
1072
1073    try:
1074      cmd = [self.options.webview_installer_tool, '-vvv',
1075             '--product', self.product_name()]
1076      assert (self.options.chrome_version or
1077              self.options.milestone or self.options.channel), (
1078          'The --chrome-version, --milestone or --channel arguments must be '
1079          'used when installing WebView with the WebView installer tool')
1080      assert not(self.options.chrome_version and self.options.milestone), (
1081          'The --chrome-version and --milestone arguments cannot be '
1082          'used together')
1083
1084      if self.options.chrome_version:
1085        cmd.extend(['--chrome-version', self.options.chrome_version])
1086      elif self.options.milestone:
1087        cmd.extend(['--milestone', self.options.milestone])
1088
1089      if self.options.channel:
1090        cmd.extend(['--channel', self.options.channel])
1091
1092      if self.options.package:
1093        cmd.extend(['--package', self.options.package])
1094
1095      exit_code = subprocess.call(cmd)
1096      assert exit_code == 0, (
1097          'The WebView installer tool failed to install WebView')
1098
1099      current_webview_provider = self._device.GetWebViewProvider()
1100      yield
1101    finally:
1102      self._device.SetWebViewImplementation(original_webview_provider)
1103      # Restore the original webview provider
1104      if current_webview_provider:
1105        self._device.Uninstall(current_webview_provider)
1106
1107  def install_seed(self):
1108    """Install finch seed for testing
1109
1110    Returns:
1111      None
1112    """
1113    logcat_file = os.path.join(
1114        os.path.dirname(self.options.isolated_script_test_output),
1115        'install_seed_for_on_device.txt')
1116
1117    with self._archive_logcat(
1118        logcat_file,
1119        'install seed on device {}'.format(self._device.serial)):
1120      app_data_dir = posixpath.join(
1121          self._device.GetApplicationDataDirectory(self.browser_package_name),
1122          self.app_user_sub_dir())
1123      self._device.RunShellCommand(['mkdir', '-p', app_data_dir],
1124                                  run_as=self.browser_package_name)
1125
1126      seed_path = posixpath.join(app_data_dir, 'variations_seed')
1127      seed_new_path = posixpath.join(app_data_dir, 'variations_seed_new')
1128      seed_stamp = posixpath.join(app_data_dir, 'variations_stamp')
1129
1130      self._device.adb.Push(self.options.finch_seed_path, seed_path)
1131      self._device.adb.Push(self.options.finch_seed_path, seed_new_path)
1132      self._device.RunShellCommand(
1133          ['touch', seed_stamp], check_return=True,
1134          run_as=self.browser_package_name)
1135
1136      # We need to make the WebView shell package an owner of the seeds,
1137      # see crbug.com/1191169#c19
1138      user_id = self._device.GetUidForPackage(self.browser_package_name)
1139      logger.info('Setting owner of seed files to %r', user_id)
1140      self._device.RunShellCommand(['chown', user_id, seed_path], as_root=True)
1141      self._device.RunShellCommand(
1142          ['chown', user_id, seed_new_path], as_root=True)
1143
1144
1145class WebLayerFinchTestCase(FinchTestCase):
1146
1147  @classmethod
1148  def product_name(cls):
1149    """Returns name of product being tested"""
1150    return 'weblayer'
1151
1152  @classmethod
1153  def wpt_product_name(cls):
1154    return ANDROID_WEBLAYER
1155
1156  @property
1157  def default_browser_activity_name(self):
1158    return 'org.chromium.weblayer.shell.WebLayerShellActivity'
1159
1160  @property
1161  def default_finch_seed_path(self):
1162    return os.path.join(SRC_DIR, 'testing', 'scripts',
1163                        'variations_smoke_test_data',
1164                        'variations_seed_stable_weblayer.json')
1165
1166  @contextlib.contextmanager
1167  def install_apks(self):
1168    """Install apks for testing"""
1169    with super(WebLayerFinchTestCase, self).install_apks(), \
1170      webview_app.UseWebViewProvider(self._device,
1171                                     self.options.webview_provider_apk):
1172      yield
1173
1174
1175def main(args):
1176  TEST_CASES.update(
1177      {p.product_name(): p
1178       for p in [ChromeFinchTestCase, WebViewFinchTestCase,
1179                 WebLayerFinchTestCase]})
1180
1181  # Unfortunately, there's a circular dependency between the parser made
1182  # available from `FinchTestCase.add_extra_arguments` and the selection of the
1183  # correct test case. The workaround is a second parser used in `main` only
1184  # that shares some arguments with the script adapter parser. The second parser
1185  # handles --help, so not all arguments are documented. Important arguments
1186  # added by the script adapter are re-added here for visibility.
1187  parser = argparse.ArgumentParser()
1188  FinchTestCase.add_common_arguments(parser)
1189  parser.add_argument(
1190        '--isolated-script-test-output', type=str,
1191        required=False,
1192        help='path to write test results JSON object to')
1193
1194  script_common.AddDeviceArguments(parser)
1195  script_common.AddEnvironmentArguments(parser)
1196  logging_common.AddLoggingArguments(parser)
1197
1198  for test_class in TEST_CASES.values():
1199    test_class.add_product_specific_argument_groups(parser)
1200
1201  options, _ = parser.parse_known_args(args)
1202
1203  with get_device(options) as device, \
1204      TEST_CASES[options.test_case](device) as test_case, \
1205      test_case.install_apks():
1206    devil_chromium.Initialize(adb_path=options.adb_path)
1207    logging_common.InitializeLogging(options)
1208
1209    # TODO(rmhasan): Best practice in Chromium is to allow users to provide
1210    # their own adb binary to avoid adb server restarts. We should add a new
1211    # command line argument to wptrunner so that users can pass the path to
1212    # their adb binary.
1213    platform_tools_path = os.path.dirname(devil_env.config.FetchPath('adb'))
1214    os.environ['PATH'] = os.pathsep.join([platform_tools_path] +
1215                                         os.environ['PATH'].split(os.pathsep))
1216
1217    test_results_dict = OrderedDict({'version': 3, 'interrupted': False,
1218                                     'num_failures_by_type': {}, 'tests': {}})
1219
1220    if test_case.product_name() == 'webview':
1221      ret = test_case.run_tests('without_finch_seed', test_results_dict)
1222      test_case.install_seed()
1223      ret |= test_case.run_tests('with_finch_seed', test_results_dict,
1224                                 check_seed_loaded=True)
1225
1226      # enable wifi so that a new seed can be downloaded from the finch server
1227      test_case.enable_internet()
1228
1229      # TODO(b/187185389): Figure out why WebView needs an extra restart
1230      # to fetch and load a new finch seed.
1231      ret |= test_case.run_tests(
1232          'extra_restart', test_results_dict,
1233          extra_browser_args=test_case.finch_seed_download_args(),
1234          check_seed_loaded=True)
1235
1236      # Restart webview+shell to fetch new seed to variations_seed_new
1237      ret |= test_case.run_tests(
1238          'fetch_new_seed_restart', test_results_dict,
1239          extra_browser_args=test_case.finch_seed_download_args(),
1240          check_seed_loaded=True)
1241      # Restart webview+shell to copy from
1242      # variations_seed_new to variations_seed
1243      ret |= test_case.run_tests(
1244          'load_new_seed_restart', test_results_dict,
1245          extra_browser_args=test_case.finch_seed_download_args(),
1246          check_seed_loaded=True)
1247
1248      # Disable wifi so that new updates will not be downloaded which can cause
1249      # timeouts in the adb commands run below.
1250      test_case.disable_internet()
1251    else:
1252      installed_seed = test_case.install_seed()
1253      # If the seed is placed in a local path, we can pass it from the command
1254      # line, e.g. for Android.
1255      if installed_seed:
1256        extra_args = [f'--variations-test-seed-path={installed_seed}']
1257        ret = test_case.run_tests('with_finch_seed', test_results_dict,
1258            extra_browser_args=extra_args)
1259      else:
1260        ret = test_case.run_tests('with_finch_seed', test_results_dict)
1261      # Clears out the finch seed. Need to run finch_seed tests first.
1262      # See crbug/1305430
1263      device.ClearApplicationState(test_case.browser_package_name)
1264      ret |= test_case.run_tests('without_finch_seed', test_results_dict)
1265
1266    test_results_dict['seconds_since_epoch'] = int(time.time())
1267    test_results_dict['path_delimiter'] = '/'
1268
1269    with open(test_case.options.isolated_script_test_output, 'w') as json_out:
1270      json_out.write(json.dumps(test_results_dict, indent=4))
1271
1272    if not test_case.new_seed_downloaded():
1273      raise Exception('A new seed was not downloaded')
1274
1275  # Return zero exit code if tests pass
1276  return ret
1277
1278
1279def main_compile_targets(args):
1280  json.dump([], args.output)
1281
1282
1283if __name__ == '__main__':
1284  if 'compile_targets' in sys.argv:
1285    funcs = {
1286      'run': None,
1287      'compile_targets': main_compile_targets,
1288    }
1289    sys.exit(common.run_script(sys.argv[1:], funcs))
1290  sys.exit(main(sys.argv[1:]))
1291