1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
   23
   24
   25
   26
   27
   28
   29
   30
   31
   32
   33
   34
   35
   36
   37
   38
   39
   40
   41
   42
   43
   44
   45
   46
   47
   48
   49
   50
   51
   52
   53
   54
   55
   56
   57
   58
   59
   60
   61
   62
   63
   64
   65
   66
   67
   68
   69
   70
   71
   72
   73
   74
   75
   76
   77
   78
   79
   80
   81
   82
   83
   84
   85
   86
   87
   88
   89
   90
   91
   92
   93
   94
   95
   96
   97
   98
   99
  100
  101
  102
  103
  104
  105
  106
  107
  108
  109
  110
  111
  112
  113
  114
  115
  116
  117
  118
  119
  120
  121
  122
  123
  124
  125
  126
  127
  128
  129
  130
  131
  132
  133
  134
  135
  136
  137
  138
  139
  140
  141
  142
  143
  144
  145
  146
  147
  148
  149
  150
  151
  152
  153
  154
  155
  156
  157
  158
  159
  160
  161
  162
  163
  164
  165
  166
  167
  168
  169
  170
  171
  172
  173
  174
  175
  176
  177
  178
  179
  180
  181
  182
  183
  184
  185
  186
  187
  188
  189
  190
  191
  192
  193
  194
  195
  196
  197
  198
  199
  200
  201
  202
  203
  204
  205
  206
  207
  208
  209
  210
  211
  212
  213
  214
  215
  216
  217
  218
  219
  220
  221
  222
  223
  224
  225
  226
  227
  228
  229
  230
  231
  232
  233
  234
  235
  236
  237
  238
  239
  240
  241
  242
  243
  244
  245
  246
  247
  248
  249
  250
  251
  252
  253
  254
  255
  256
  257
  258
  259
  260
  261
  262
  263
  264
  265
  266
  267
  268
  269
  270
  271
  272
  273
  274
  275
  276
  277
  278
  279
  280
  281
  282
  283
  284
  285
  286
  287
  288
  289
  290
  291
  292
  293
  294
  295
  296
  297
  298
  299
  300
  301
  302
  303
  304
  305
  306
  307
  308
  309
  310
  311
  312
  313
  314
  315
  316
  317
  318
  319
  320
  321
  322
  323
  324
  325
  326
  327
  328
  329
  330
  331
  332
  333
  334
  335
  336
  337
  338
  339
  340
  341
  342
  343
  344
  345
  346
  347
  348
  349
  350
  351
  352
  353
  354
  355
  356
  357
  358
  359
  360
  361
  362
  363
  364
  365
  366
  367
  368
  369
  370
  371
  372
  373
  374
  375
  376
  377
  378
  379
  380
  381
  382
  383
  384
  385
  386
  387
  388
  389
  390
  391
  392
  393
  394
  395
  396
  397
  398
  399
  400
  401
  402
  403
  404
  405
  406
  407
  408
  409
  410
  411
  412
  413
  414
  415
  416
  417
  418
  419
  420
  421
  422
  423
  424
  425
  426
  427
  428
  429
  430
  431
  432
  433
  434
  435
  436
  437
  438
  439
  440
  441
  442
  443
  444
  445
  446
  447
  448
  449
  450
  451
  452
  453
  454
  455
  456
  457
  458
  459
  460
  461
  462
  463
  464
  465
  466
  467
  468
  469
  470
  471
  472
  473
  474
  475
  476
  477
  478
  479
  480
  481
  482
  483
  484
  485
  486
  487
  488
  489
  490
  491
  492
  493
  494
  495
  496
  497
  498
  499
  500
  501
  502
  503
  504
  505
  506
  507
  508
  509
  510
  511
  512
  513
  514
  515
  516
  517
  518
  519
  520
  521
  522
  523
  524
  525
  526
  527
  528
  529
  530
  531
  532
  533
  534
  535
  536
  537
  538
  539
  540
  541
  542
  543
  544
  545
  546
  547
  548
  549
  550
  551
  552
  553
  554
  555
  556
  557
  558
  559
  560
  561
  562
  563
  564
  565
  566
  567
  568
  569
  570
  571
  572
  573
  574
  575
  576
  577
  578
  579
  580
  581
  582
  583
  584
  585
  586
  587
  588
  589
  590
  591
  592
  593
  594
  595
  596
  597
  598
  599
  600
  601
  602
  603
  604
  605
  606
  607
  608
  609
  610
  611
  612
  613
  614
  615
  616
  617
  618
  619
  620
  621
  622
  623
  624
  625
  626
  627
  628
  629
  630
  631
  632
  633
  634
  635
  636
  637
  638
  639
  640
  641
  642
  643
  644
  645
  646
  647
  648
  649
  650
  651

content / test / gpu / gpu_tests / skia_gold_integration_test_base.py [blame]

# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from datetime import date
import logging
import os
import re
import shutil
import sys
import tempfile
from typing import Any, Dict, List, Optional
import unittest

from gpu_tests import color_profile_manager
from gpu_tests import common_browser_args as cba
from gpu_tests import common_typing as ct
from gpu_tests import gpu_helper
from gpu_tests import gpu_integration_test
from gpu_tests import skia_gold_matching_algorithms as algo

from skia_gold_common import skia_gold_properties as sgp
from skia_gold_common import skia_gold_session as sgs
from skia_gold_common import skia_gold_session_manager as sgsm

import gpu_path_util

from py_utils import cloud_storage

from telemetry.util import image_util

TEST_DATA_DIRS = [
    gpu_path_util.GPU_DATA_DIR,
    os.path.join(gpu_path_util.CHROMIUM_SRC_DIR, 'media', 'test', 'data'),
]

SKIA_GOLD_CORPUS = 'chrome-gpu'


class _ImageParameters():
  def __init__(self):
    # Parameters for cloud storage reference images.
    self.vendor_id: Optional[int] = None
    self.device_id: Optional[int] = None
    self.vendor_string: Optional[str] = None
    self.device_string: Optional[str] = None
    self.msaa: bool = False
    self.model_name: Optional[str] = None
    self.driver_version: Optional[str] = None
    self.driver_vendor: Optional[str] = None
    self.display_server: Optional[str] = None
    self.skia_graphite_status: Optional[str] = None


# This and its subclasses could potentially be switched to using dataclasses,
# but due to Python's method resolution order, inheritance breaks if a parent
# class has fields with defaults and a child class has fields without defaults.
# This might be possible to resolve with kw_only, but that requires at least
# Python 3.10.
# The third party attrs module supposedly handles this situation
# (https://stackoverflow.com/a/53085935), but attempting to do so on version
# 21.4.0 fails.
class SkiaGoldTestCase():
  """Base class for any Gold-enabled test case definition.

  Only information used within SkiaGoldIntegrationTestBase should be stored
  here. Additional information should be stored in the appropriate subclass.
  """
  # pylint: disable=too-many-arguments
  def __init__(
      self,
      name: str,
      gpu_process_disabled: bool = False,
      grace_period_end: Optional[date] = None,
      matching_algorithm: Optional[algo.SkiaGoldMatchingAlgorithm] = None,
      refresh_after_finish: bool = False):
    """
    Args:
      name: A string containing the name of the test.
      gpu_process_disabled: Whether the test runs with the GPU process disabled.
      grace_period_end: An optional datetime.date, before which Gold comparison
          failures will be ignored. This allows a newly added test to be
          exempted for a (hopefully) short period after being added. This is so
          that any slightly different different but valid images that get
          produced by the CI builders can be triaged without turning the
          builders red.
      matching_algorithm: A
          skia_gold_matching_algoriths.SkiaGoldMatchingAlgorithm that specifies
          which matching algorithm Skia Gold should use for the test. Defaults
          to exact matching.
      refresh_after_finish: Whether to refresh the entire page after the test
          finishes.
    """
    self.name = name
    self.gpu_process_disabled = gpu_process_disabled
    self.grace_period_end = grace_period_end
    self.matching_algorithm = (matching_algorithm
                               or algo.ExactMatchingAlgorithm())
    self.refresh_after_finish = refresh_after_finish

  #pylint: enable=too-many-arguments


class SkiaGoldIntegrationTestBase(gpu_integration_test.GpuIntegrationTest):
  """Base class for all tests that upload results to Skia Gold."""
  _error_image_cloud_storage_bucket = 'chromium-browser-gpu-tests'

  # This information is class-scoped, so that it can be shared across
  # invocations of tests; but it's zapped every time the browser is
  # restarted with different command line arguments.
  _image_parameters: Optional[_ImageParameters] = None

  _skia_gold_temp_dir: Optional[str] = None
  _skia_gold_session_manager: Optional[sgsm.SkiaGoldSessionManager] = None
  _skia_gold_properties: Optional[sgp.SkiaGoldProperties] = None

  # Loaded from disk at a later point.
  _dom_automation_controller_script: Optional[str] = None

  @classmethod
  def SetUpProcess(cls) -> None:
    super(SkiaGoldIntegrationTestBase, cls).SetUpProcess()
    options = cls.GetOriginalFinderOptions()
    color_profile_manager.ForceUntilExitSRGB(
        options.dont_restore_color_profile_after_test)
    cls.CustomizeBrowserArgs([])
    cls.StartBrowser()
    cls.SetStaticServerDirs(cls._GetStaticServerDirs())
    cls._skia_gold_temp_dir = tempfile.mkdtemp()

  @classmethod
  def _GetStaticServerDirs(cls) -> List[str]:
    return TEST_DATA_DIRS

  @classmethod
  def _SetClassVariablesFromOptions(cls, options: ct.ParsedCmdArgs) -> None:
    super()._SetClassVariablesFromOptions(options)
    if not cls._dom_automation_controller_script:
      with open(
          os.path.join(gpu_path_util.GPU_TEST_HARNESS_JAVASCRIPT_DIR,
                       'websocket_heartbeat.js')) as f:
        cls._dom_automation_controller_script = f.read()
      cls._dom_automation_controller_script += '\n'
      with open(
          os.path.join(gpu_path_util.GPU_TEST_HARNESS_JAVASCRIPT_DIR,
                       'dom_automation_controller.js')) as f:
        cls._dom_automation_controller_script += f.read()

  @classmethod
  def GetSkiaGoldProperties(cls) -> sgp.SkiaGoldProperties:
    if not cls._skia_gold_properties:
      cls._skia_gold_properties = sgp.SkiaGoldProperties(
          cls.GetOriginalFinderOptions())
    return cls._skia_gold_properties

  @classmethod
  def GetSkiaGoldSessionManager(cls) -> sgsm.SkiaGoldSessionManager:
    if not cls._skia_gold_session_manager:
      cls._skia_gold_session_manager = sgsm.SkiaGoldSessionManager(
          cls._skia_gold_temp_dir, cls.GetSkiaGoldProperties())
    return cls._skia_gold_session_manager

  @classmethod
  def GenerateBrowserArgs(cls, additional_args: List[str]) -> List[str]:
    """Adds default arguments to |additional_args|.

    See the parent class' method documentation for additional information.
    """
    default_args = super(SkiaGoldIntegrationTestBase,
                         cls).GenerateBrowserArgs(additional_args)
    default_args.extend([cba.ENABLE_GPU_BENCHMARKING, cba.TEST_TYPE_GPU])
    force_color_profile_arg = [
        arg for arg in default_args if arg.startswith('--force-color-profile=')
    ]
    if not force_color_profile_arg:
      default_args.extend([
          cba.FORCE_COLOR_PROFILE_SRGB,
          cba.ENSURE_FORCED_COLOR_PROFILE,
      ])
    return default_args

  @classmethod
  def StopBrowser(cls) -> None:
    super(SkiaGoldIntegrationTestBase, cls).StopBrowser()
    cls.ResetGpuInfo()

  @classmethod
  def TearDownProcess(cls) -> None:
    super(SkiaGoldIntegrationTestBase, cls).TearDownProcess()
    shutil.rmtree(cls._skia_gold_temp_dir)
    cls._skia_gold_session_manager = None

  @classmethod
  def AddCommandlineArgs(cls, parser: ct.CmdArgParser) -> None:
    super(SkiaGoldIntegrationTestBase, cls).AddCommandlineArgs(parser)
    parser.add_argument('--git-revision', help='Chrome revision being tested.')
    parser.add_argument(
        '--test-machine-name',
        default='',
        help=('Name of the test machine. Specifying this argument causes this '
              'script to upload failure images and diffs to cloud storage '
              'directly, instead of relying on the '
              'archive_gpu_pixel_test_results.py script.'))
    parser.add_argument(
        '--dont-restore-color-profile-after-test',
        action='store_true',
        default=False,
        help=("(Mainly on Mac) don't restore the system's original color "
              'profile after the test completes; leave the system using the '
              'sRGB color profile. See http://crbug.com/784456.'))
    parser.add_argument('--gerrit-issue',
                        default='',
                        help='For Skia Gold integration. Gerrit issue ID.')
    parser.add_argument(
        '--gerrit-patchset',
        default='',
        help='For Skia Gold integration. Gerrit patch set number.')
    parser.add_argument('--buildbucket-id',
                        default='',
                        help='For Skia Gold integration. Buildbucket build ID.')
    parser.add_argument(
        '--no-skia-gold-failure',
        action='store_true',
        default=False,
        help=('For Skia Gold integration. Always report that the test passed '
              'even if the Skia Gold image comparison reported a failure, but '
              'otherwise perform the same steps as usual.'))
    # Telemetry is *still* using optparse instead of argparse, so we can't have
    # these two options in a mutually exclusive group.
    # TODO(crbug.com/40807291): Use a mutually exclusive group once the
    # optparse -> argparse migration is complete.
    parser.add_argument(
        '--local-pixel-tests',
        action='store_true',
        default=None,
        help=('Specifies to run the test harness in local run mode or not. '
              'When run in local mode, uploading to Gold is disabled and links '
              'to help with local debugging are output. Running in local mode '
              'also implies --no-luci-auth. If both this and '
              '--no-local-pixel-tests are left unset, the test harness will '
              'attempt to detect whether it is running on a workstation or not '
              'and set this option accordingly.'))
    parser.add_argument(
        '--no-local-pixel-tests',
        action='store_false',
        dest='local_pixel_tests',
        help=('Specifies to run the test harness in non-local (bot) mode. When '
              'run in this mode, data is actually uploaded to Gold and triage '
              'links are generated. If both this and --local-pixel-tests are '
              'left unset, the test harness will attempt to detect whether it '
              'is running on a workstation or not and set this option '
              'accordingly.'))
    parser.add_argument(
        '--skia-gold-local-png-write-directory',
        help=('Specifies a directory to save local image diffs to instead of '
              'the default of a temporary directory. Only has an effect when '
              'running tests locally, not on a bot.'))
    parser.add_argument(
        '--no-luci-auth',
        action='store_true',
        default=False,
        help=("Don't use the service account provided by LUCI for "
              'authentication for Skia Gold, instead relying on gsutil to be '
              'pre-authenticated. Meant for testing locally instead of on the '
              'bots.'))
    parser.add_argument(
        '--service-account',
        help=('Specifies the service account to use instead of using '
              'LUCI_CONTEXT or whatever is configured in gsutil. Implies '
              '--no-luci-auth. Only meant for use in Skylab where the tests '
              'are automated but do not have LUCI_CONTEXT available.'))
    parser.add_argument(
        '--bypass-skia-gold-functionality',
        action='store_true',
        default=False,
        help=('Bypass all interaction with Skia Gold, effectively disabling '
              'the image comparison portion of any tests that use Gold. Only '
              'meant to be used in case a Gold outage occurs and cannot be '
              'fixed quickly.'))

  @classmethod
  def ResetGpuInfo(cls) -> None:
    cls._image_parameters = None

  @classmethod
  def GetImageParameters(cls, test_case: SkiaGoldTestCase) -> _ImageParameters:
    if not cls._image_parameters:
      cls._ComputeGpuInfo(test_case)
    return cls._image_parameters

  @classmethod
  def _ComputeGpuInfo(cls, test_case: SkiaGoldTestCase) -> None:
    if cls._image_parameters:
      return
    browser = cls.browser
    system_info = browser.GetSystemInfo()
    if not system_info:
      raise Exception('System info must be supported by the browser')
    if not system_info.gpu:
      raise Exception('GPU information was absent')
    device = system_info.gpu.devices[0]
    cls._image_parameters = _ImageParameters()
    params = cls._image_parameters
    if device.vendor_id and device.device_id:
      params.vendor_id = device.vendor_id
      params.device_id = device.device_id
    elif device.vendor_string and device.device_string:
      params.vendor_string = device.vendor_string
      params.device_string = device.device_string
    elif test_case.gpu_process_disabled:
      # Match the vendor and device IDs that the browser advertises
      # when the software renderer is active.
      params.vendor_id = 65535
      params.device_id = 65535
    else:
      raise Exception('GPU device information was incomplete')
    # TODO(senorblanco): This should probably be checking
    # for the presence of the extensions in system_info.gpu_aux_attributes
    # in order to check for MSAA, rather than sniffing the blocklist.
    params.msaa = not (('disable_chromium_framebuffer_multisample' in
                        system_info.gpu.driver_bug_workarounds) or
                       ('disable_multisample_render_to_texture' in system_info.
                        gpu.driver_bug_workarounds))
    params.model_name = system_info.model_name
    params.driver_version = device.driver_version
    params.driver_vendor = device.driver_vendor
    params.display_server = gpu_helper.GetDisplayServer(browser.browser_type)
    params.skia_graphite_status = gpu_helper.GetSkiaGraphiteStatus(
        system_info.gpu)

  @classmethod
  def _UploadBitmapToCloudStorage(cls,
                                  bucket: str,
                                  name: str,
                                  bitmap: Any,
                                  public: bool = False) -> None:
    # This sequence of steps works on all platforms to write a temporary
    # PNG to disk, following the pattern in bitmap_unittest.py. The key to
    # avoiding PermissionErrors seems to be to not actually try to write to
    # the temporary file object, but to re-open its name for all operations.
    temp_file = tempfile.NamedTemporaryFile(suffix='.png').name
    image_util.WritePngFile(bitmap, temp_file)
    cloud_storage.Insert(bucket, name, temp_file, publicly_readable=public)

  # Not used consistently, but potentially useful for debugging issues on the
  # bots, so kept around for future use.
  @classmethod
  def _UploadGoldErrorImageToCloudStorage(cls, image_name: str,
                                          screenshot: ct.Screenshot) -> None:
    revision = cls.GetSkiaGoldProperties().git_revision
    machine_name = re.sub(r'\W+', '_',
                          cls.GetOriginalFinderOptions().test_machine_name)
    base_bucket = '%s/gold_failures' % (cls._error_image_cloud_storage_bucket)
    image_name_with_revision_and_machine = '%s_%s_%s.png' % (
        image_name, machine_name, revision)
    cls._UploadBitmapToCloudStorage(
        base_bucket,
        image_name_with_revision_and_machine,
        screenshot,
        public=True)

  @staticmethod
  def _UrlToImageName(url: str) -> str:
    image_name = re.sub(r'^(http|https|file)://(/*)', '', url)
    image_name = re.sub(r'\.\./', '', image_name)
    image_name = re.sub(r'(\.|/|-)', '_', image_name)
    return image_name

  def GetGoldJsonKeys(self, test_case: SkiaGoldTestCase) -> Dict[str, str]:
    """Get all the JSON metadata that will be passed to goldctl."""
    img_params = self.GetImageParameters(test_case)
    # The frequently changing last part of the ANGLE driver version (revision of
    # some sort?) messes a bit with inexact matching since each revision will
    # be treated as a separate trace, so strip it off.
    _StripAngleRevisionFromDriver(img_params)
    # When running under the validating decoder, the device string is reported
    # as the actual device, while under the passthrough decoder it is an ANGLE
    # string that contains the device and some additional information. This
    # difference can be annoying when trying to set up forwarding rules for the
    # public Gold instance, so only use the actual device.
    _ConvertAngleDeviceStringToActualDevice(img_params)
    # All values need to be strings, otherwise goldctl fails.
    gpu_keys = {
        'vendor_id':
        _ToHexOrNone(img_params.vendor_id),
        'device_id':
        _ToHexOrNone(img_params.device_id),
        'vendor_string':
        _ToNonEmptyStrOrNone(img_params.vendor_string),
        'device_string':
        _ToNonEmptyStrOrNone(img_params.device_string),
        'msaa':
        str(img_params.msaa),
        'model_name':
        _ToNonEmptyStrOrNone(img_params.model_name),
        'os':
        _ToNonEmptyStrOrNone(self.browser.platform.GetOSName()),
        'os_version':
        _ToNonEmptyStrOrNone(self.browser.platform.GetOSVersionName()),
        'os_version_detail_string':
        _ToNonEmptyStrOrNone(self.browser.platform.GetOSVersionDetailString()),
        'driver_version':
        _ToNonEmptyStrOrNone(img_params.driver_version),
        'driver_vendor':
        _ToNonEmptyStrOrNone(img_params.driver_vendor),
        'display_server':
        _ToNonEmptyStrOrNone(img_params.display_server),
        'combined_hardware_identifier':
        _GetCombinedHardwareIdentifier(img_params),
        'browser_type':
        _ToNonEmptyStrOrNone(self.browser.browser_type),
        'skia_graphite_status':
        _ToNonEmptyStrOrNone(img_params.skia_graphite_status),
    }
    # If we have a grace period active, then the test is potentially flaky.
    # Include a pair that will cause Gold to ignore any untriaged images, which
    # will prevent it from automatically commenting on unrelated CLs that happen
    # to produce a new image.
    if _GracePeriodActive(test_case):
      # This is put in the regular keys dict instead of the optional one because
      # ignore rules do not apply to optional keys.
      gpu_keys['ignore'] = '1'
    return gpu_keys

  # pylint: disable=no-self-use
  def GetGoldOptionalKeys(self) -> Dict[str, str]:
    """Get all the optional JSON metadata that will be passed to goldctl.

    This optional data is unrelated to the configurations that images are
    produced on, e.g. a comment that will be surfaced in Gold's UI.
    """
    return {}

  # pylint: enable=no-self-use

  def _UploadTestResultToSkiaGold(self, image_name: str,
                                  screenshot: ct.Screenshot,
                                  test_case: SkiaGoldTestCase) -> None:
    """Compares the given image using Skia Gold and uploads the result.

    No uploading is done if the test is being run in local run mode. Compares
    the given screenshot to baselines provided by Gold, raising an Exception if
    a match is not found.

    Args:
      image_name: the name of the image being checked.
      screenshot: the image being checked as a Telemetry Bitmap.
      test_case: the GPU SkiaGoldTestCase object for the test.
    """
    # Write screenshot to PNG file on local disk.
    png_temp_file = tempfile.NamedTemporaryFile(
        suffix='.png', dir=self._skia_gold_temp_dir).name
    image_util.WritePngFile(screenshot, png_temp_file)

    gpu_keys = self.GetGoldJsonKeys(test_case)
    gold_session = self.GetSkiaGoldSessionManager().GetSkiaGoldSession(
        gpu_keys, corpus=SKIA_GOLD_CORPUS)
    gold_properties = self.GetSkiaGoldProperties()
    use_luci = not (gold_properties.local_pixel_tests
                    or gold_properties.no_luci_auth)
    optional_keys = self.GetGoldOptionalKeys()

    status, error = gold_session.RunComparison(
        name=image_name,
        png_file=png_temp_file,
        inexact_matching_args=test_case.matching_algorithm.GetCmdline(),
        use_luci=use_luci,
        service_account=gold_properties.service_account,
        optional_keys=optional_keys)
    if not status:
      return

    status_codes =\
        self.GetSkiaGoldSessionManager().GetSessionClass().StatusCodes
    if status == status_codes.AUTH_FAILURE:
      logging.error('Gold authentication failed with output %s', error)
    elif status == status_codes.INIT_FAILURE:
      logging.error('Gold initialization failed with output %s', error)
    elif status == status_codes.COMPARISON_FAILURE_REMOTE:
      public_triage_link, internal_triage_link = gold_session.GetTriageLinks(
          image_name)
      if not public_triage_link or not internal_triage_link:
        logging.error('Failed to get triage links for %s, raw output: %s',
                      image_name, error)
        logging.error('Reason for no triage links: %s',
                      gold_session.GetTriageLinkOmissionReason(image_name))
      elif gold_properties.IsTryjobRun():
        self.artifacts.CreateLink('public_triage_link_for_entire_cl',
                                  public_triage_link)
        self.artifacts.CreateLink('internal_triage_link_for_entire_cl',
                                  internal_triage_link)
      else:
        self.artifacts.CreateLink('public_gold_triage_link', public_triage_link)
        self.artifacts.CreateLink('internal_gold_triage_link',
                                  internal_triage_link)
    elif status == status_codes.COMPARISON_FAILURE_LOCAL:
      logging.error('Local comparison failed. Local diff files:')
      _OutputLocalDiffFiles(gold_session, image_name)
    elif status == status_codes.LOCAL_DIFF_FAILURE:
      logging.error(
          'Local comparison failed and an error occurred during diff '
          'generation: %s', error)
      # There might be some files, so try outputting them.
      logging.error('Local diff files:')
      _OutputLocalDiffFiles(gold_session, image_name)
    else:
      logging.error(
          'Given unhandled SkiaGoldSession StatusCode %s with error %s', status,
          error)
    if self._ShouldReportGoldFailure(test_case):
      raise Exception(
          'goldctl command returned non-zero exit code, see above for details. '
          'This probably just means that the test produced an image that has '
          'not been triaged as positive.')

  def _ShouldReportGoldFailure(self, test_case: SkiaGoldTestCase) -> bool:
    """Determines if a Gold failure should actually be surfaced.

    Args:
      test_case: The GPU SkiaGoldTestCase object for the test.

    Returns:
      True if the failure should be surfaced, i.e. the test should fail,
      otherwise False.
    """
    parsed_options = self.GetOriginalFinderOptions()
    # Don't surface if we're explicitly told not to.
    if parsed_options.no_skia_gold_failure:
      return False
    # Don't surface if the test was recently added and we're still within its
    # grace period.
    if _GracePeriodActive(test_case):
      return False
    return True

  @classmethod
  def GenerateGpuTests(cls, options: ct.ParsedCmdArgs) -> ct.TestGenerator:
    del options
    raise NotImplementedError(
        'GenerateGpuTests must be overridden in a subclass')

  def RunActualGpuTest(self, test_path: str, args: ct.TestArgs) -> None:
    raise NotImplementedError(
        'RunActualGpuTest must be overridden in a subclass')


def _ToHex(num: str) -> str:
  return hex(int(num))


def _ToHexOrNone(num: Optional[str]) -> str:
  return 'None' if num is None else _ToHex(num)


def _ToNonEmptyStrOrNone(val: Optional[str]) -> str:
  return 'None' if val == '' else str(val)


def _GracePeriodActive(test_case: SkiaGoldTestCase) -> bool:
  """Returns whether a grace period is currently active for a test.

  Args:
    test_case: The GPU SkiaGoldTestCase object for the test in question.

  Returns:
    True if a grace period is defined for |test_case| and has not yet expired.
    Otherwise, False.
  """
  return (test_case.grace_period_end
          and date.today() <= test_case.grace_period_end)


def _StripAngleRevisionFromDriver(img_params: _ImageParameters) -> None:
  """Strips the revision off the end of an ANGLE driver version.

  E.g. 2.1.0.b50541b2d6c4 -> 2.1.0

  Modifies the string in place. No-ops if the driver vendor is not ANGLE.

  Args:
    img_params: An _ImageParameters instance to modify.
  """
  if 'ANGLE' not in img_params.driver_vendor or not img_params.driver_version:
    return
  # Assume that we're never going to have portions of the driver we care about
  # that are longer than 8 characters.
  driver_parts = img_params.driver_version.split('.')
  kept_parts = []
  for part in driver_parts:
    if len(part) > 8:
      break
    kept_parts.append(part)
  img_params.driver_version = '.'.join(kept_parts)


def _ConvertAngleDeviceStringToActualDevice(
    img_params: _ImageParameters) -> None:
  """Converts an ANGLE device string to only have the actual device.

  E.g. ANGLE(Qualcomm, Adreno (TM) 640, OpenGL ES ...) -> Adreno (TM) 640

  Modifies the string in place. No-ops if the driver string is not ANGLE.

  Args:
    img_params: An _ImageParameters instance to modify.
  """
  device_id = gpu_helper.GetANGLEGpuDeviceId(img_params.device_string)
  if device_id:
    img_params.device_string = device_id


def _GetCombinedHardwareIdentifier(img_params: _ImageParameters) -> str:
  """Combine all relevant hardware identifiers into a single key.

  This makes Gold forwarding more precise by allowing us to forward explicit
  configurations instead of individual components.
  """
  vendor_id = _ToHexOrNone(img_params.vendor_id)
  device_id = _ToHexOrNone(img_params.device_id)
  device_string = _ToNonEmptyStrOrNone(img_params.device_string)
  combined_hw_identifiers = ('vendor_id:{vendor_id}, '
                             'device_id:{device_id}, '
                             'device_string:{device_string}')
  combined_hw_identifiers = combined_hw_identifiers.format(
      vendor_id=vendor_id, device_id=device_id, device_string=device_string)
  return combined_hw_identifiers


def _OutputLocalDiffFiles(gold_session: sgs.SkiaGoldSession,
                          image_name: str) -> None:
  """Logs the local diff image files from the given SkiaGoldSession.

  Args:
    gold_session: A skia_gold_session.SkiaGoldSession instance to pull files
        from.
    image_name: A string containing the name of the image/test that was
        compared.
  """
  given_file = gold_session.GetGivenImageLink(image_name)
  closest_file = gold_session.GetClosestImageLink(image_name)
  diff_file = gold_session.GetDiffImageLink(image_name)
  failure_message = 'Unable to retrieve link'
  logging.error('Generated image: %s', given_file or failure_message)
  logging.error('Closest image: %s', closest_file or failure_message)
  logging.error('Diff image: %s', diff_file or failure_message)


def load_tests(loader: unittest.TestLoader, tests: Any,
               pattern: Any) -> unittest.TestSuite:
  del loader, tests, pattern  # Unused.
  return gpu_integration_test.LoadAllTestsInModule(sys.modules[__name__])