blob: da2731691a36d52997c1817eeb1017bc199ce4b9 [file] [log] [blame]
# Copyright 2019 The Chromium Authors. All rights reserved.
# 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 json
import logging
import os
import re
import subprocess
from subprocess import CalledProcessError
import shutil
import sys
import tempfile
from gpu_tests import gpu_integration_test
from gpu_tests import path_util
from gpu_tests import color_profile_manager
from py_utils import cloud_storage
from telemetry.util import image_util
from telemetry.util import rgba_color
GPU_RELATIVE_PATH = "content/test/data/gpu/"
GPU_DATA_DIR = os.path.join(path_util.GetChromiumSrcDir(), GPU_RELATIVE_PATH)
TEST_DATA_DIRS = [GPU_DATA_DIR,
os.path.join(
path_util.GetChromiumSrcDir(), 'media/test/data')]
goldctl_bin = os.path.join(
path_util.GetChromiumSrcDir(), 'tools', 'skia_goldctl')
if sys.platform == 'win32':
goldctl_bin = os.path.join(goldctl_bin, 'win', 'goldctl') + '.exe'
elif sys.platform == 'darwin':
goldctl_bin = os.path.join(goldctl_bin, 'mac', 'goldctl')
else:
goldctl_bin = os.path.join(goldctl_bin, 'linux', 'goldctl')
SKIA_GOLD_INSTANCE = 'chrome-gpu'
class _ImageParameters(object):
def __init__(self):
# Parameters for cloud storage reference images.
self.vendor_id = None
self.device_id = None
self.vendor_string = None
self.device_string = None
self.msaa = False
self.model_name = None
class SkiaGoldIntegrationTestBase(gpu_integration_test.GpuIntegrationTest):
"""Base class for all tests that upload results to Skia Gold."""
# The command line options (which are passed to subclasses'
# GenerateGpuTests) *must* be configured here, via a call to
# SetParsedCommandLineOptions. If they are not, an error will be
# raised when running the tests.
_parsed_command_line_options = None
_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
@classmethod
def SetParsedCommandLineOptions(cls, options):
cls._parsed_command_line_options = options
@classmethod
def GetParsedCommandLineOptions(cls):
return cls._parsed_command_line_options
@classmethod
def SetUpProcess(cls):
options = cls.GetParsedCommandLineOptions()
color_profile_manager.ForceUntilExitSRGB(
options.dont_restore_color_profile_after_test)
super(SkiaGoldIntegrationTestBase, cls).SetUpProcess()
cls.CustomizeBrowserArgs(cls._AddDefaultArgs([]))
cls.StartBrowser()
cls.SetStaticServerDirs(TEST_DATA_DIRS)
cls._skia_gold_temp_dir = tempfile.mkdtemp()
@staticmethod
def _AddDefaultArgs(browser_args):
if not browser_args:
browser_args = []
# All tests receive the following options.
return [
'--force-color-profile=srgb',
'--ensure-forced-color-profile',
'--enable-gpu-benchmarking',
'--test-type=gpu'] + browser_args
@classmethod
def StopBrowser(cls):
super(SkiaGoldIntegrationTestBase, cls).StopBrowser()
cls.ResetGpuInfo()
@classmethod
def TearDownProcess(cls):
super(SkiaGoldIntegrationTestBase, cls).TearDownProcess()
if not cls.GetParsedCommandLineOptions().local_run:
shutil.rmtree(cls._skia_gold_temp_dir)
@classmethod
def AddCommandlineArgs(cls, parser):
super(SkiaGoldIntegrationTestBase, cls).AddCommandlineArgs(parser)
parser.add_option(
'--build-revision',
help='Chrome revision being tested.',
default="unknownrev")
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(
'--review-patch-issue',
help='For Skia Gold integration. Gerrit issue ID.',
default='')
parser.add_option(
'--review-patch-set',
help='For Skia Gold integration. Gerrit patch set number.',
default='')
parser.add_option(
'--buildbucket-build-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.')
parser.add_option(
'--local-run',
action='store_true', default=False,
help='Runs the tests in a manner more suitable for local testing. '
'Specifically, runs goldctl in extra_imgtest_args mode (no upload) '
'and outputs local links to generated images. Implies '
'--no-luci-auth.')
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.')
@classmethod
def ResetGpuInfo(cls):
cls._image_parameters = None
@classmethod
def GetImageParameters(cls, tab, page):
if not cls._image_parameters:
cls._ComputeGpuInfo(tab, page)
return cls._image_parameters
@classmethod
def _ComputeGpuInfo(cls, tab, page):
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 blacklist.
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
@classmethod
def _UploadBitmapToCloudStorage(cls, bucket, name, bitmap, public=False):
# 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, screenshot):
machine_name = re.sub(r'\W+', '_',
cls.GetParsedCommandLineOptions().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,
cls.GetParsedCommandLineOptions().build_revision)
cls._UploadBitmapToCloudStorage(
base_bucket, image_name_with_revision_and_machine, screenshot,
public=True)
def _CompareScreenshotSamples(self, tab, screenshot, expected_colors,
tolerance, device_pixel_ratio,
test_machine_name):
# First scan through the expected_colors and see if there are any scale
# factor overrides that would preempt the device pixel ratio. This
# is mainly a workaround for complex tests like the Maps test.
for expectation in expected_colors:
if 'scale_factor_overrides' in expectation:
for override in expectation['scale_factor_overrides']:
# Require exact matches to avoid confusion, because some
# machine models and names might be subsets of others
# (e.g. Nexus 5 vs Nexus 5X).
if ('device_type' in override and
(tab.browser.platform.GetDeviceTypeName() ==
override['device_type'])):
logging.warning(
'Overriding device_pixel_ratio ' + str(device_pixel_ratio) +
' with scale factor ' + str(override['scale_factor']) +
' for device type ' + override['device_type'])
device_pixel_ratio = override['scale_factor']
break
if (test_machine_name and 'machine_name' in override and
override["machine_name"] == test_machine_name):
logging.warning(
'Overriding device_pixel_ratio ' + str(device_pixel_ratio) +
' with scale factor ' + str(override['scale_factor']) +
' for machine name ' + test_machine_name)
device_pixel_ratio = override['scale_factor']
break
# Only support one "scale_factor_overrides" in the expectation format.
break
for expectation in expected_colors:
if "scale_factor_overrides" in expectation:
continue
location = expectation["location"]
size = expectation["size"]
x0 = int(location[0] * device_pixel_ratio)
x1 = int((location[0] + size[0]) * device_pixel_ratio)
y0 = int(location[1] * device_pixel_ratio)
y1 = int((location[1] + size[1]) * device_pixel_ratio)
for x in range(x0, x1):
for y in range(y0, y1):
if (x < 0 or y < 0 or x >= image_util.Width(screenshot) or
y >= image_util.Height(screenshot)):
self.fail(
('Expected pixel location [%d, %d] is out of range on ' +
'[%d, %d] image') %
(x, y, image_util.Width(screenshot),
image_util.Height(screenshot)))
actual_color = image_util.GetPixelColor(screenshot, x, y)
expected_color = rgba_color.RgbaColor(
expectation["color"][0],
expectation["color"][1],
expectation["color"][2],
expectation["color"][3] if len(expectation["color"]) > 3 else 255)
if not actual_color.IsEqual(expected_color, tolerance):
self.fail('Expected pixel at ' + str(location) +
' (actual pixel (' + str(x) + ', ' + str(y) + ')) ' +
' to be ' +
str(expectation["color"]) + " but got [" +
str(actual_color.r) + ", " +
str(actual_color.g) + ", " +
str(actual_color.b) + ", " +
str(actual_color.a) + "]")
def ToHex(self, num):
return hex(int(num))
def ToHexOrNone(self, num):
return 'None' if num == None else self.ToHex(num)
def ToNonEmptyStrOrNone(self, val):
return 'None' if val == '' else str(val)
def _UrlToImageName(self, url):
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 _GetBuildIdArgs(self):
# Get all the information that goldctl requires.
parsed_options = self.GetParsedCommandLineOptions()
build_id_args = [
'--commit',
parsed_options.build_revision,
]
# If --review-patch-issue is passed, then we assume we're running on a
# trybot.
if parsed_options.review_patch_issue:
build_id_args += [
'--issue',
parsed_options.review_patch_issue,
'--patchset',
parsed_options.review_patch_set,
'--jobid',
parsed_options.buildbucket_build_id
]
return build_id_args
def GetGoldJsonKeys(self, tab, page):
"""Get all the JSON metadata that will be passed to golctl."""
img_params = self.GetImageParameters(tab, page)
# All values need to be strings, otherwise goldctl fails.
gpu_keys = {
'vendor_id': self.ToHexOrNone(img_params.vendor_id),
'device_id': self.ToHexOrNone(img_params.device_id),
'vendor_string': self.ToNonEmptyStrOrNone(img_params.vendor_string),
'device_string': self.ToNonEmptyStrOrNone(img_params.device_string),
'msaa': str(img_params.msaa),
'model_name': self.ToNonEmptyStrOrNone(img_params.model_name),
}
return gpu_keys
def _UploadTestResultToSkiaGold(self, image_name, screenshot,
tab, page, build_id_args=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.
tab: the Telemetry Tab object that the test was run in.
page: the GPU PixelTestPage object for the test.
build_id_args: a list of build-identifying flags and values.
"""
if not isinstance(build_id_args, list) or '--commit' not in build_id_args:
raise Exception('Requires build args to be specified, including --commit')
# 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(tab, page)
json_temp_file = tempfile.NamedTemporaryFile(
suffix='.json', dir=self._skia_gold_temp_dir).name
failure_file = tempfile.NamedTemporaryFile(
suffix='.txt', dir=self._skia_gold_temp_dir).name
with open(json_temp_file, 'w+') as f:
json.dump(gpu_keys, f)
# Figure out any extra args we need to pass to goldctl.
extra_imgtest_args = []
extra_auth_args = []
parsed_options = self.GetParsedCommandLineOptions()
if parsed_options.local_run:
extra_imgtest_args.append('--dryrun')
elif not parsed_options.no_luci_auth:
extra_auth_args = ['--luci']
# Run goldctl for a result.
try:
subprocess.check_output([goldctl_bin, 'auth',
'--work-dir', self._skia_gold_temp_dir]
+ extra_auth_args,
stderr=subprocess.STDOUT)
cmd = ([goldctl_bin, 'imgtest', 'add', '--passfail',
'--test-name', image_name,
'--instance', SKIA_GOLD_INSTANCE,
'--keys-file', json_temp_file,
'--png-file', png_temp_file,
'--work-dir', self._skia_gold_temp_dir,
'--failure-file', failure_file] +
build_id_args + extra_imgtest_args)
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
except CalledProcessError as e:
# The triage link for the image is output to the failure file, so report
# that if it's available so it shows up in Milo. If for whatever reason
# the file is not present or malformed, the triage link will still be
# present in the stdout of the goldctl command.
# If we're running on a trybot, instead generate a link to all results
# for the CL so that the user can visit a single page instead of
# clicking on multiple links on potentially multiple bots.
if parsed_options.review_patch_issue:
cl_images = ('https://%s-gold.skia.org/search?'
'issue=%s&new_clstore=true' % (
SKIA_GOLD_INSTANCE, parsed_options.review_patch_issue))
self.artifacts.CreateLink('triage_link_for_entire_cl', cl_images)
else:
try:
with open(failure_file, 'r') as ff:
self.artifacts.CreateLink('gold_triage_link', ff.read())
except Exception:
logging.error('Failed to read contents of goldctl failure file')
logging.error('goldctl failed with output: %s', e.output)
if parsed_options.local_run:
logging.error(
'Image produced by %s: file://%s', image_name, png_temp_file)
gold_images = ('https://%s-gold.skia.org/search?'
'match=name&metric=combined&pos=true&'
'query=name%%3D%s&unt=false' % (
SKIA_GOLD_INSTANCE, image_name))
logging.error(
'Approved images for %s in Gold: %s', image_name, gold_images)
if self._ShouldReportGoldFailure(page):
raise Exception('goldctl command failed, see above for details')
def _ShouldReportGoldFailure(self, page):
"""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.GetParsedCommandLineOptions()
# 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. However, fail if we're on a trybot so that as many images
# can be triaged as possible before a new test is committed.
if (page.grace_period_end and date.today() <= page.grace_period_end and
not parsed_options.review_patch_issue):
return False
return True
def _ValidateScreenshotSamplesWithSkiaGold(self, tab, page, screenshot,
device_pixel_ratio,
build_id_args):
"""Samples the given screenshot and verifies pixel color values.
In case any of the samples do not match the expected color, it raises
a Failure and uploads the image to Gold.
Args:
tab: the Telemetry Tab object that the test was run in.
page: the GPU PixelTestPage object for the test.
screenshot: the screenshot of the test page as a Telemetry Bitmap.
device_pixel_ratio: the device pixel ratio for the test device as a float.
build_id_args: a list of build-identifying flags and values.
"""
try:
self._CompareScreenshotSamples(
tab, screenshot, page.expected_colors, page.tolerance,
device_pixel_ratio,
self.GetParsedCommandLineOptions().test_machine_name)
except Exception:
# An exception raised from self.fail() indicates a failure.
image_name = self._UrlToImageName(page.name)
# We want to report the screenshot comparison failure, not any failures
# related to Gold.
try:
self._UploadTestResultToSkiaGold(
image_name, screenshot,
tab, page,
build_id_args=build_id_args)
except Exception as e:
logging.error(str(e))
raise
@classmethod
def GenerateGpuTests(cls, options):
del options
return []
def RunActualGpuTest(self, options):
raise NotImplementedError(
'RunActualGpuTest must be overridden in a subclass')
def load_tests(loader, tests, pattern):
del loader, tests, pattern # Unused.
return gpu_integration_test.LoadAllTestsInModule(sys.modules[__name__])