blob: 5fcd63d36e64b72795178b8fae4795772e38321c [file] [log] [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 pixel_test_pages
from gpu_tests.skia_gold import gpu_skia_gold_properties as sgp
from gpu_tests.skia_gold import gpu_skia_gold_session_manager as sgsm
from skia_gold_common import skia_gold_session as sgs
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
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 = None
_skia_gold_temp_dir = None
_skia_gold_session_manager = None
_skia_gold_properties = 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(TEST_DATA_DIRS)
cls._skia_gold_temp_dir = tempfile.mkdtemp()
@classmethod
def GetSkiaGoldProperties(cls) -> sgp.GpuSkiaGoldProperties:
if not cls._skia_gold_properties:
cls._skia_gold_properties = sgp.GpuSkiaGoldProperties(
cls.GetOriginalFinderOptions())
return cls._skia_gold_properties
@classmethod
def GetSkiaGoldSessionManager(cls) -> sgsm.GpuSkiaGoldSessionManager:
if not cls._skia_gold_session_manager:
cls._skia_gold_session_manager = sgsm.GpuSkiaGoldSessionManager(
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_option(
'--git-revision', help='Chrome revision being tested.', default=None)
parser.add_option(
'--test-machine-name',
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.',
default='')
parser.add_option(
'--dont-restore-color-profile-after-test',
dest='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_option(
'--gerrit-issue',
help='For Skia Gold integration. Gerrit issue ID.',
default='')
parser.add_option(
'--gerrit-patchset',
help='For Skia Gold integration. Gerrit patch set number.',
default='')
parser.add_option(
'--buildbucket-id',
help='For Skia Gold integration. Buildbucket build ID.',
default='')
parser.add_option(
'--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.
parser.add_option(
'--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_option(
'--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 '
'arge 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_option(
'--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_option(
'--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_option(
'--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, page: pixel_test_pages.PixelTestPage
) -> _ImageParameters:
if not cls._image_parameters:
cls._ComputeGpuInfo(page)
return cls._image_parameters
@classmethod
def _ComputeGpuInfo(cls, page: pixel_test_pages.PixelTestPage) -> 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 page.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)
@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,
page: pixel_test_pages.PixelTestPage) -> Dict[str, str]:
"""Get all the JSON metadata that will be passed to golctl."""
img_params = self.GetImageParameters(page)
# 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)
# 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),
}
# 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(page):
gpu_keys['ignore'] = '1'
return gpu_keys
def _UploadTestResultToSkiaGold(self, image_name: str,
screenshot: ct.Screenshot,
page: pixel_test_pages.PixelTestPage) -> 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.
page: the GPU PixelTestPage 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(page)
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)
status, error = gold_session.RunComparison(
name=image_name,
png_file=png_temp_file,
inexact_matching_args=page.matching_algorithm.GetCmdline(),
use_luci=use_luci)
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:
# We currently don't have an internal instance + public mirror like the
# general Chrome Gold instance, so just report the "internal" link, which
# points to the correct instance.
_, triage_link = gold_session.GetTriageLinks(image_name)
if not triage_link:
logging.error('Failed to get triage link for %s, raw output: %s',
image_name, error)
logging.error('Reason for no triage link: %s',
gold_session.GetTriageLinkOmissionReason(image_name))
elif gold_properties.IsTryjobRun():
self.artifacts.CreateLink('triage_link_for_entire_cl', triage_link)
else:
self.artifacts.CreateLink('gold_triage_link', 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(page):
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,
page: pixel_test_pages.PixelTestPage) -> bool:
"""Determines if a Gold failure should actually be surfaced.
Args:
page: The GPU PixelTestPage 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(page):
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(page: pixel_test_pages.PixelTestPage) -> bool:
"""Returns whether a grace period is currently active for a test.
Args:
page: The GPU PixelTestPage object for the test in question.
Returns:
True if a grace period is defined for |page| and has not yet expired.
Otherwise, False.
"""
return page.grace_period_end and date.today() <= page.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 _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__])