diff options
author | jam@chromium.org <jam@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2014-04-07 23:00:08 +0000 |
---|---|---|
committer | jam@chromium.org <jam@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2014-04-07 23:00:08 +0000 |
commit | db86ab950e6080b47a1614559c927c5097cf7422 (patch) | |
tree | a37660da46d5db696a8692e929dcf4639e2b2849 /chrome/test | |
parent | 56afb710b66563f20c2e55e915687c573940a2ee (diff) | |
download | chromium_src-db86ab950e6080b47a1614559c927c5097cf7422.zip chromium_src-db86ab950e6080b47a1614559c927c5097cf7422.tar.gz chromium_src-db86ab950e6080b47a1614559c927c5097cf7422.tar.bz2 |
undelete ispy and put it in chrome/test/ispy
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@262239 0039d316-1c4b-4281-b951-d872f2087c98
Diffstat (limited to 'chrome/test')
29 files changed, 2173 insertions, 0 deletions
diff --git a/chrome/test/ispy/OWNERS b/chrome/test/ispy/OWNERS new file mode 100644 index 0000000..7fa81ec --- /dev/null +++ b/chrome/test/ispy/OWNERS @@ -0,0 +1,2 @@ +craigdh@chromium.org +frankf@chromium.org diff --git a/chrome/test/ispy/__init__.py b/chrome/test/ispy/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chrome/test/ispy/__init__.py diff --git a/chrome/test/ispy/app.yaml b/chrome/test/ispy/app.yaml new file mode 100644 index 0000000..869464f --- /dev/null +++ b/chrome/test/ispy/app.yaml @@ -0,0 +1,17 @@ +application: google.com:ispy +version: 1 +runtime: python27 +api_version: 1 +threadsafe: True + +handlers: +- url: /.* + script: server.app.application + +libraries: +- name: webapp2 + version: latest +- name: jinja2 + version: latest +- name: PIL + version: latest diff --git a/chrome/test/ispy/client/__init__.py b/chrome/test/ispy/client/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chrome/test/ispy/client/__init__.py diff --git a/chrome/test/ispy/client/boto_bucket.py b/chrome/test/ispy/client/boto_bucket.py new file mode 100644 index 0000000..5ea9c97 --- /dev/null +++ b/chrome/test/ispy/client/boto_bucket.py @@ -0,0 +1,88 @@ +# Copyright 2013 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. + +"""Implementation of CloudBucket using Google Cloud Storage as the backend.""" +import os +import sys + +# boto requires depot_tools/third_party be in the path. Use +# src/tools/find_depot_tools.py to add this directory. +sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, + os.pardir, os.pardir, os.pardir, 'tools')) +import find_depot_tools +DEPOT_TOOLS_PATH = find_depot_tools.add_depot_tools_to_path() +sys.path.append(os.path.join(os.path.abspath(DEPOT_TOOLS_PATH), 'third_party')) +import boto + +from ..common import cloud_bucket + + +class BotoCloudBucket(cloud_bucket.BaseCloudBucket): + """Interfaces with GS using the boto library.""" + + def __init__(self, key, secret, bucket_name): + """Initializes the bucket with a key, secret, and bucket_name. + + Args: + key: the API key to access GS. + secret: the API secret to access GS. + bucket_name: the name of the bucket to connect to. + """ + uri = boto.storage_uri('', 'gs') + conn = uri.connect(key, secret) + self.bucket = conn.get_bucket(bucket_name) + + def _GetKey(self, path): + key = boto.gs.key.Key(self.bucket) + key.key = path + return key + + # override + def UploadFile(self, path, contents, content_type): + key = self._GetKey(path) + key.set_metadata('Content-Type', content_type) + key.set_contents_from_string(contents) + # Open permissions for the appengine account to read/write. + key.add_email_grant('FULL_CONTROL', + 'ispy.google.com@appspot.gserviceaccount.com') + + # override + def DownloadFile(self, path): + key = self._GetKey(path) + if key.exists(): + return key.get_contents_as_string() + else: + raise cloud_bucket.FileNotFoundError + + # override + def UpdateFile(self, path, contents): + key = self._GetKey(path) + if key.exists(): + key.set_contents_from_string(contents) + else: + raise cloud_bucket.FileNotFoundError + + # override + def RemoveFile(self, path): + key = self._GetKey(path) + key.delete() + + # override + def FileExists(self, path): + key = self._GetKey(path) + return key.exists() + + # override + def GetImageURL(self, path): + key = self._GetKey(path) + if key.exists(): + # Corrects a bug in boto that incorrectly generates a url + # to a resource in Google Cloud Storage. + return key.generate_url(3600).replace('AWSAccessKeyId', 'GoogleAccessId') + else: + raise cloud_bucket.FileNotFoundError(path) + + # override + def GetAllPaths(self, prefix): + return (key.key for key in self.bucket.get_all_keys(prefix=prefix)) diff --git a/chrome/test/ispy/client/dom.py b/chrome/test/ispy/client/dom.py new file mode 100644 index 0000000..facac13 --- /dev/null +++ b/chrome/test/ispy/client/dom.py @@ -0,0 +1,29 @@ +# Copyright 2014 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. + + +def GetScriptToWaitForUnchangingDOM(): + """Gets Javascript that waits until the DOM is stable for 5 seconds. + + Times out if the DOM is not stable within 30 seconds. + + Returns: + Javascript as a string. + """ + return """ + var target = document.body; + var callback = arguments[arguments.length - 1] + + var timeout_id = setTimeout(function() { + callback() + }, 5000); + + var observer = new MutationObserver(function(mutations) { + clearTimeout(timeout_id); + timeout_id = setTimeout(function() { + callback(); + }, 5000); + }).observe(target, {attributes: true, childList: true, + characterData: true, subtree: true}); + """ diff --git a/chrome/test/ispy/client/wait_on_ajax.js b/chrome/test/ispy/client/wait_on_ajax.js new file mode 100644 index 0000000..da1fce9 --- /dev/null +++ b/chrome/test/ispy/client/wait_on_ajax.js @@ -0,0 +1,18 @@ +// Copyright 2013 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. + +var target = document.body; +var callback = arguments[arguments.length - 1] + +var timeout_id = setTimeout(function() { + callback() +}, 5000); + +var observer = new MutationObserver(function(mutations) { + clearTimeout(timeout_id); + timeout_id = setTimeout(function() { + callback(); + }, 5000); +}).observe(target, {attributes: true, childList: true, + characterData: true, subtree: true}); diff --git a/chrome/test/ispy/common/__init__.py b/chrome/test/ispy/common/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chrome/test/ispy/common/__init__.py diff --git a/chrome/test/ispy/common/cloud_bucket.py b/chrome/test/ispy/common/cloud_bucket.py new file mode 100644 index 0000000..39134c9 --- /dev/null +++ b/chrome/test/ispy/common/cloud_bucket.py @@ -0,0 +1,91 @@ +# Copyright 2013 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. + +"""Abstract injector class for GS requests.""" + + +class FileNotFoundError(Exception): + """Thrown by a subclass of CloudBucket when a file is not found.""" + pass + + +class BaseCloudBucket(object): + """An abstract base class for working with GS.""" + + def UploadFile(self, path, contents, content_type): + """Uploads a file to GS. + + Args: + path: where in GS to upload the file. + contents: the contents of the file to be uploaded. + content_type: the MIME Content-Type of the file. + """ + raise NotImplementedError + + def DownloadFile(self, path): + """Downsloads a file from GS. + + Args: + path: the location in GS to download the file from. + + Returns: + String contents of the file downloaded. + + Raises: + bucket_injector.NotFoundException: if the file is not found. + """ + raise NotImplementedError + + def UpdateFile(self, path, contents): + """Uploads a file to GS. + + Args: + path: location of the file in GS to update. + contents: the contents of the file to be updated. + """ + raise NotImplementedError + + def RemoveFile(self, path): + """Removes a file from GS. + + Args: + path: the location in GS to download the file from. + """ + raise NotImplementedError + + def FileExists(self, path): + """Checks if a file exists in GS. + + Args: + path: the location in GS of the file. + + Returns: + boolean representing whether the file exists in GS. + """ + raise NotImplementedError + + def GetImageURL(self, path): + """Gets a URL to an item in GS from its path. + + Args: + path: the location in GS of a file. + + Returns: + an url to a file in GS. + + Raises: + bucket_injector.NotFoundException: if the file is not found. + """ + raise NotImplementedError + + def GetAllPaths(self, prefix): + """Gets paths to files in GS that start with a prefix. + + Args: + prefix: the prefix to filter files in GS. + + Returns: + a generator of paths to files in GS. + """ + raise NotImplementedError diff --git a/chrome/test/ispy/common/constants.py b/chrome/test/ispy/common/constants.py new file mode 100644 index 0000000..a8b1956 --- /dev/null +++ b/chrome/test/ispy/common/constants.py @@ -0,0 +1,7 @@ +# Copyright 2013 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. + +"""Constants for I-Spy.""" + +BUCKET = 'ispy-bucket' diff --git a/chrome/test/ispy/common/image_tools.py b/chrome/test/ispy/common/image_tools.py new file mode 100644 index 0000000..aaa0748 --- /dev/null +++ b/chrome/test/ispy/common/image_tools.py @@ -0,0 +1,322 @@ +# Copyright 2013 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. + +"""Utilities for performing pixel-by-pixel image comparision.""" + +import itertools +import StringIO +from PIL import Image + + +def _AreTheSameSize(images): + """Returns whether a set of images are the size size. + + Args: + images: a list of images to compare. + + Returns: + boolean. + + Raises: + Exception: One image or fewer is passed in. + """ + if len(images) > 1: + return all(images[0].size == img.size for img in images[1:]) + else: + raise Exception('No images passed in.') + + +def _GetDifferenceWithMask(image1, image2, mask=None, + masked_color=(225, 225, 225, 255), + same_color=(255, 255, 255, 255), + different_color=(210, 0, 0, 255)): + """Returns an image representing the difference between the two images. + + This function computes the difference between two images taking into + account a mask if it is provided. The final three arguments represent + the coloration of the generated image. + + Args: + image1: the first image to compare. + image2: the second image to compare. + mask: an optional mask image consisting of only black and white pixels + where white pixels indicate the portion of the image to be masked out. + masked_color: the color of a masked section in the resulting image. + same_color: the color of an unmasked section that is the same. + between images 1 and 2 in the resulting image. + different_color: the color of an unmasked section that is different + between images 1 and 2 in the resulting image. + + Returns: + A 2-tuple with an image representing the unmasked difference between the + two input images and the number of different pixels. + + Raises: + Exception: if image1, image2, and mask are not the same size. + """ + image_mask = mask + if not mask: + image_mask = Image.new('RGBA', image1.size, (0, 0, 0, 255)) + if not _AreTheSameSize([image1, image2, image_mask]): + raise Exception('images and mask must be the same size.') + image_diff = Image.new('RGBA', image1.size, (0, 0, 0, 255)) + data = [] + diff_pixels = 0 + for m, px1, px2 in itertools.izip(image_mask.getdata(), + image1.getdata(), + image2.getdata()): + if m == (255, 255, 255, 255): + data.append(masked_color) + elif px1 == px2: + data.append(same_color) + else: + data.append(different_color) + diff_pixels += 1 + + image_diff.putdata(data) + return (image_diff, diff_pixels) + + +def CreateMask(images): + """Computes a mask for a set of images. + + Returns a difference mask that is computed from the images + which are passed in. The mask will have a white pixel + anywhere that the input images differ and a black pixel + everywhere else. + + Args: + images: list of images to compute the mask from. + + Returns: + an image of only black and white pixels where white pixels represent + areas in the input images that have differences. + + Raises: + Exception: if the images passed in are not of the same size. + Exception: if fewer than one image is passed in. + """ + if not images: + raise Exception('mask must be created from one or more images.') + mask = Image.new('RGBA', images[0].size, (0, 0, 0, 255)) + image = images[0] + for other_image in images[1:]: + mask = _GetDifferenceWithMask( + image, + other_image, + mask, + masked_color=(255, 255, 255, 255), + same_color=(0, 0, 0, 255), + different_color=(255, 255, 255, 255))[0] + return mask + + +def AddMasks(masks): + """Combines a list of mask images into one mask image. + + Args: + masks: a list of mask-images. + + Returns: + a new mask that represents the sum of the masked + regions of the passed in list of mask-images. + + Raises: + Exception: if masks is an empty list, or if masks are not the same size. + """ + if not masks: + raise Exception('masks must be a list containing at least one image.') + if len(masks) > 1 and not _AreTheSameSize(masks): + raise Exception('masks in list must be of the same size.') + white = (255, 255, 255, 255) + black = (0, 0, 0, 255) + masks_data = [mask.getdata() for mask in masks] + image = Image.new('RGBA', masks[0].size, black) + image.putdata([white if white in px_set else black + for px_set in itertools.izip(*masks_data)]) + return image + + +def ConvertDiffToMask(diff): + """Converts a Diff image into a Mask image. + + Args: + diff: the diff image to convert. + + Returns: + a new mask image where everything that was masked or different in the diff + is now masked. + """ + white = (255, 255, 255, 255) + black = (0, 0, 0, 255) + diff_data = diff.getdata() + image = Image.new('RGBA', diff.size, black) + image.putdata([black if px == white else white for px in diff_data]) + return image + + +def VisualizeImageDifferences(image1, image2, mask=None): + """Returns an image repesenting the unmasked differences between two images. + + Iterates through the pixel values of two images and an optional + mask. If the pixel values are the same, or the pixel is masked, + (0,0,0) is stored for that pixel. Otherwise, (255,255,255) is stored. + This ultimately produces an image where unmasked differences between + the two images are white pixels, and everything else is black. + + Args: + image1: an RGB image + image2: another RGB image of the same size as image1. + mask: an optional RGB image consisting of only white and black pixels + where the white pixels represent the parts of the images to be masked + out. + + Returns: + A 2-tuple with an image representing the unmasked difference between the + two input images and the number of different pixels. + + Raises: + Exception: if the two images and optional mask are different sizes. + """ + return _GetDifferenceWithMask(image1, image2, mask) + + +def InflateMask(image, passes): + """A function that adds layers of pixels around the white edges of a mask. + + This function evaluates a 'frontier' of valid pixels indices. Initially, + this frontier contains all indices in the image. However, with each pass + only the pixels' indices which were added to the mask by inflation + are added to the next pass's frontier. This gives the algorithm a + large upfront cost that scales negligably when the number of passes + is increased. + + Args: + image: the RGBA PIL.Image mask to inflate. + passes: the number of passes to inflate the image by. + + Returns: + A RGBA PIL.Image. + """ + inflated = Image.new('RGBA', image.size) + new_dataset = list(image.getdata()) + old_dataset = list(image.getdata()) + + frontier = set(range(len(old_dataset))) + new_frontier = set() + + l = [-1, 1] + + def _ShadeHorizontal(index, px): + col = index % image.size[0] + if px == (255, 255, 255, 255): + for x in l: + if 0 <= col + x < image.size[0]: + if old_dataset[index + x] != (255, 255, 255, 255): + new_frontier.add(index + x) + new_dataset[index + x] = (255, 255, 255, 255) + + def _ShadeVertical(index, px): + row = index / image.size[0] + if px == (255, 255, 255, 255): + for x in l: + if 0 <= row + x < image.size[1]: + if old_dataset[index + image.size[0] * x] != (255, 255, 255, 255): + new_frontier.add(index + image.size[0] * x) + new_dataset[index + image.size[0] * x] = (255, 255, 255, 255) + + for _ in range(passes): + for index in frontier: + _ShadeHorizontal(index, old_dataset[index]) + _ShadeVertical(index, old_dataset[index]) + old_dataset, new_dataset = new_dataset, new_dataset + frontier, new_frontier = new_frontier, set() + inflated.putdata(new_dataset) + return inflated + + +def TotalDifferentPixels(image1, image2, mask=None): + """Computes the number of different pixels between two images. + + Args: + image1: the first RGB image to be compared. + image2: the second RGB image to be compared. + mask: an optional RGB image of only black and white pixels + where white pixels indicate the parts of the image to be masked out. + + Returns: + the number of differing pixels between the images. + + Raises: + Exception: if the images to be compared and the mask are not the same size. + """ + image_mask = mask + if not mask: + image_mask = Image.new('RGBA', image1.size, (0, 0, 0, 255)) + if _AreTheSameSize([image1, image2, image_mask]): + total_diff = 0 + for px1, px2, m in itertools.izip(image1.getdata(), + image2.getdata(), + image_mask.getdata()): + if m == (255, 255, 255, 255): + continue + elif px1 != px2: + total_diff += 1 + else: + continue + return total_diff + else: + raise Exception('images and mask must be the same size') + + +def SameImage(image1, image2, mask=None): + """Returns a boolean representing whether the images are the same. + + Returns a boolean indicating whether two images are similar + enough to be considered the same. Essentially wraps the + TotalDifferentPixels function. + + + Args: + image1: an RGB image to compare. + image2: an RGB image to compare. + mask: an optional image of only black and white pixels + where white pixels are masked out + + Returns: + True if the images are similar, False otherwise. + + Raises: + Exception: if the images (and mask) are different sizes. + """ + different_pixels = TotalDifferentPixels(image1, image2, mask) + return different_pixels == 0 + + +def EncodePNG(image): + """Returns the PNG file-contents of the image. + + Args: + image: an RGB image to be encoded. + + Returns: + a base64 encoded string representing the image. + """ + f = StringIO.StringIO() + image.save(f, 'PNG') + encoded_image = f.getvalue() + f.close() + return encoded_image + + +def DecodePNG(png): + """Returns a RGB image from PNG file-contents. + + Args: + encoded_image: PNG file-contents of an RGB image. + + Returns: + an RGB image + """ + return Image.open(StringIO.StringIO(png)) diff --git a/chrome/test/ispy/common/image_tools_unittest.py b/chrome/test/ispy/common/image_tools_unittest.py new file mode 100644 index 0000000..017c172 --- /dev/null +++ b/chrome/test/ispy/common/image_tools_unittest.py @@ -0,0 +1,183 @@ +# Copyright 2013 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. + +import unittest +import sys +import os +from PIL import Image + +import image_tools + + +def _GenImage(size, color): + return Image.new('RGBA', size, color) + + +def _AllPixelsOfColor(image, color): + return not any(px != color for px in image.getdata()) + + +class ImageToolsTest(unittest.TestCase): + + def setUp(self): + self.black25 = _GenImage((25, 25), (0, 0, 0, 255)) + self.black50 = _GenImage((50, 50), (0, 0, 0, 255)) + self.white25 = _GenImage((25, 25), (255, 255, 255, 255)) + self.white50 = _GenImage((50, 50), (255, 255, 255, 255)) + + def testAreTheSameSize(self): + self.assertTrue(image_tools._AreTheSameSize([self.black25, self.black25])) + self.assertTrue(image_tools._AreTheSameSize([self.white25, self.white25])) + self.assertTrue(image_tools._AreTheSameSize([self.black50, self.black50])) + self.assertTrue(image_tools._AreTheSameSize([self.white50, self.white50])) + self.assertTrue(image_tools._AreTheSameSize([self.black25, self.white25])) + self.assertTrue(image_tools._AreTheSameSize([self.black50, self.white50])) + + self.assertFalse(image_tools._AreTheSameSize([self.black50, self.black25])) + self.assertFalse(image_tools._AreTheSameSize([self.white50, self.white25])) + self.assertFalse(image_tools._AreTheSameSize([self.black25, self.white50])) + self.assertFalse(image_tools._AreTheSameSize([self.black50, self.white25])) + + self.assertRaises(Exception, image_tools._AreTheSameSize, []) + self.assertRaises(Exception, image_tools._AreTheSameSize, [self.black50]) + + def testGetDifferenceWithMask(self): + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.black25, self.black25)[0], (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.white25, self.black25)[0], (210, 0, 0, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.black25, self.black25, mask=self.black25)[0], + (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.black25, self.black25, mask=self.white25)[0], + (225, 225, 225, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.black25, self.white25, mask=self.black25)[0], + (210, 0, 0, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools._GetDifferenceWithMask( + self.black25, self.white25, mask=self.white25)[0], + (225, 225, 225, 255))) + self.assertRaises(Exception, image_tools._GetDifferenceWithMask, + self.white25, + self.black50) + self.assertRaises(Exception, image_tools._GetDifferenceWithMask, + self.white25, + self.white25, + mask=self.black50) + + def testCreateMask(self): + m1 = image_tools.CreateMask([self.black25, self.white25]) + self.assertTrue(_AllPixelsOfColor(m1, (255, 255, 255, 255))) + m2 = image_tools.CreateMask([self.black25, self.black25]) + self.assertTrue(_AllPixelsOfColor(m2, (0, 0, 0, 255))) + m3 = image_tools.CreateMask([self.white25, self.white25]) + self.assertTrue(_AllPixelsOfColor(m3, (0, 0, 0, 255))) + + def testAddMasks(self): + m1 = image_tools.CreateMask([self.black25, self.white25]) + m2 = image_tools.CreateMask([self.black25, self.black25]) + m3 = image_tools.CreateMask([self.black50, self.black50]) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m1]), + (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m2]), + (0, 0, 0, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m1, m2]), + (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m1, m1]), + (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m2, m2]), + (0, 0, 0, 255))) + self.assertTrue(_AllPixelsOfColor(image_tools.AddMasks([m3]), + (0, 0, 0, 255))) + self.assertRaises(Exception, image_tools.AddMasks, []) + self.assertRaises(Exception, image_tools.AddMasks, [m1, m3]) + + def testTotalDifferentPixels(self): + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.white25), + 0) + self.assertEquals(image_tools.TotalDifferentPixels(self.black25, + self.black25), + 0) + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.black25), + 25*25) + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.black25, + mask=self.white25), + 0) + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.white25, + mask=self.white25), + 0) + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.black25, + mask=self.black25), + 25*25) + self.assertEquals(image_tools.TotalDifferentPixels(self.white25, + self.white25, + mask=self.black25), + 0) + self.assertRaises(Exception, image_tools.TotalDifferentPixels, + self.white25, self.white50) + self.assertRaises(Exception, image_tools.TotalDifferentPixels, + self.white25, self.white25, mask=self.white50) + + def testSameImage(self): + self.assertTrue(image_tools.SameImage(self.white25, self.white25)) + self.assertFalse(image_tools.SameImage(self.white25, self.black25)) + + self.assertTrue(image_tools.SameImage(self.white25, self.black25, + mask=self.white25)) + self.assertFalse(image_tools.SameImage(self.white25, self.black25, + mask=self.black25)) + self.assertTrue(image_tools.SameImage(self.black25, self.black25)) + self.assertTrue(image_tools.SameImage(self.black25, self.black25, + mask=self.white25)) + self.assertTrue(image_tools.SameImage(self.white25, self.white25, + mask=self.white25)) + self.assertRaises(Exception, image_tools.SameImage, + self.white25, self.white50) + self.assertRaises(Exception, image_tools.SameImage, + self.white25, self.white25, + mask=self.white50) + + def testInflateMask(self): + cross_image = Image.new('RGBA', (3, 3)) + white_image = Image.new('RGBA', (3, 3)) + dot_image = Image.new('RGBA', (3, 3)) + b = (0, 0, 0, 255) + w = (255, 255, 255, 255) + dot_image.putdata([b, b, b, + b, w, b, + b, b, b]) + cross_image.putdata([b, w, b, + w, w, w, + b, w, b]) + white_image.putdata([w, w, w, + w, w, w, + w, w, w]) + self.assertEquals(list(image_tools.InflateMask(dot_image, 1).getdata()), + list(cross_image.getdata())) + self.assertEquals(list(image_tools.InflateMask(dot_image, 0).getdata()), + list(dot_image.getdata())) + self.assertEquals(list(image_tools.InflateMask(dot_image, 2).getdata()), + list(white_image.getdata())) + self.assertEquals(list(image_tools.InflateMask(dot_image, 3).getdata()), + list(white_image.getdata())) + self.assertEquals(list(image_tools.InflateMask(self.black25, 1).getdata()), + list(self.black25.getdata())) + + def testPNGEncodeDecode(self): + self.assertTrue(_AllPixelsOfColor( + image_tools.DecodePNG( + image_tools.EncodePNG(self.white25)), (255, 255, 255, 255))) + self.assertTrue(_AllPixelsOfColor( + image_tools.DecodePNG( + image_tools.EncodePNG(self.black25)), (0, 0, 0, 255))) + + +if __name__ == '__main__': + unittest.main() diff --git a/chrome/test/ispy/common/ispy_utils.py b/chrome/test/ispy/common/ispy_utils.py new file mode 100644 index 0000000..a138f07 --- /dev/null +++ b/chrome/test/ispy/common/ispy_utils.py @@ -0,0 +1,304 @@ +# Copyright 2013 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. + +"""Internal utilities for managing I-Spy test results in Google Cloud Storage. + +See the ispy.ispy_api module for the external API. +""" + +import collections +import itertools +import json +import os +import sys + +import image_tools + + +_INVALID_EXPECTATION_CHARS = ['/', '\\', ' ', '"', '\''] + + +def IsValidExpectationName(expectation_name): + return not any(c in _INVALID_EXPECTATION_CHARS for c in expectation_name) + + +def GetExpectationPath(expectation, file_name=''): + """Get the path to a test file in the given test run and expectation. + + Args: + expectation: name of the expectation. + file_name: name of the file. + + Returns: + the path as a string relative to the bucket. + """ + return 'expectations/%s/%s' % (expectation, file_name) + + +def GetFailurePath(test_run, expectation, file_name=''): + """Get the path to a failure file in the given test run and test. + + Args: + test_run: name of the test run. + expectation: name of the expectation. + file_name: name of the file. + + Returns: + the path as a string relative to the bucket. + """ + return GetTestRunPath(test_run, '%s/%s' % (expectation, file_name)) + + +def GetTestRunPath(test_run, file_name=''): + """Get the path to a the given test run. + + Args: + test_run: name of the test run. + file_name: name of the file. + + Returns: + the path as a string relative to the bucket. + """ + return 'failures/%s/%s' % (test_run, file_name) + + +class ISpyUtils(object): + """Utility functions for working with an I-Spy google storage bucket.""" + + def __init__(self, cloud_bucket): + """Initialize with a cloud bucket instance to supply GS functionality. + + Args: + cloud_bucket: An object implementing the cloud_bucket.BaseCloudBucket + interface. + """ + self.cloud_bucket = cloud_bucket + + def UploadImage(self, full_path, image): + """Uploads an image to a location in GS. + + Args: + full_path: the path to the file in GS including the file extension. + image: a RGB PIL.Image to be uploaded. + """ + self.cloud_bucket.UploadFile( + full_path, image_tools.EncodePNG(image), 'image/png') + + def DownloadImage(self, full_path): + """Downloads an image from a location in GS. + + Args: + full_path: the path to the file in GS including the file extension. + + Returns: + The downloaded RGB PIL.Image. + + Raises: + cloud_bucket.NotFoundError: if the path to the image is not valid. + """ + return image_tools.DecodePNG(self.cloud_bucket.DownloadFile(full_path)) + + def UpdateImage(self, full_path, image): + """Updates an existing image in GS, preserving permissions and metadata. + + Args: + full_path: the path to the file in GS including the file extension. + image: a RGB PIL.Image. + """ + self.cloud_bucket.UpdateFile(full_path, image_tools.EncodePNG(image)) + + def GenerateExpectation(self, expectation, images): + """Creates and uploads an expectation to GS from a set of images and name. + + This method generates a mask from the uploaded images, then + uploads the mask and first of the images to GS as a expectation. + + Args: + expectation: name for this expectation, any existing expectation with the + name will be replaced. + images: a list of RGB encoded PIL.Images + + Raises: + ValueError: if the expectation name is invalid. + """ + if not IsValidExpectationName(expectation): + raise ValueError("Expectation name contains an illegal character: %s." % + str(_INVALID_EXPECTATION_CHARS)) + + mask = image_tools.InflateMask(image_tools.CreateMask(images), 7) + self.UploadImage( + GetExpectationPath(expectation, 'expected.png'), images[0]) + self.UploadImage(GetExpectationPath(expectation, 'mask.png'), mask) + + def PerformComparison(self, test_run, expectation, actual): + """Runs an image comparison, and uploads discrepancies to GS. + + Args: + test_run: the name of the test_run. + expectation: the name of the expectation to use for comparison. + actual: an RGB-encoded PIL.Image that is the actual result. + + Raises: + cloud_bucket.NotFoundError: if the given expectation is not found. + ValueError: if the expectation name is invalid. + """ + if not IsValidExpectationName(expectation): + raise ValueError("Expectation name contains an illegal character: %s." % + str(_INVALID_EXPECTATION_CHARS)) + + expectation_tuple = self.GetExpectation(expectation) + if not image_tools.SameImage( + actual, expectation_tuple.expected, mask=expectation_tuple.mask): + self.UploadImage( + GetFailurePath(test_run, expectation, 'actual.png'), actual) + diff, diff_pxls = image_tools.VisualizeImageDifferences( + expectation_tuple.expected, actual, mask=expectation_tuple.mask) + self.UploadImage(GetFailurePath(test_run, expectation, 'diff.png'), diff) + self.cloud_bucket.UploadFile( + GetFailurePath(test_run, expectation, 'info.txt'), + json.dumps({ + 'different_pixels': diff_pxls, + 'fraction_different': + diff_pxls / float(actual.size[0] * actual.size[1])}), + 'application/json') + + def GetExpectation(self, expectation): + """Returns the given expectation from GS. + + Args: + expectation: the name of the expectation to get. + + Returns: + A named tuple: 'Expectation', containing two images: expected and mask. + + Raises: + cloud_bucket.NotFoundError: if the test is not found in GS. + """ + Expectation = collections.namedtuple('Expectation', ['expected', 'mask']) + return Expectation(self.DownloadImage(GetExpectationPath(expectation, + 'expected.png')), + self.DownloadImage(GetExpectationPath(expectation, + 'mask.png'))) + + def ExpectationExists(self, expectation): + """Returns whether the given expectation exists in GS. + + Args: + expectation: the name of the expectation to check. + + Returns: + A boolean indicating whether the test exists. + """ + expected_image_exists = self.cloud_bucket.FileExists( + GetExpectationPath(expectation, 'expected.png')) + mask_image_exists = self.cloud_bucket.FileExists( + GetExpectationPath(expectation, 'mask.png')) + return expected_image_exists and mask_image_exists + + def FailureExists(self, test_run, expectation): + """Returns whether a failure for the expectation exists for the given run. + + Args: + test_run: the name of the test_run. + expectation: the name of the expectation that failed. + + Returns: + A boolean indicating whether the failure exists. + """ + actual_image_exists = self.cloud_bucket.FileExists( + GetFailurePath(test_run, expectation, 'actual.png')) + test_exists = self.ExpectationExists(expectation) + info_exists = self.cloud_bucket.FileExists( + GetFailurePath(test_run, expectation, 'info.txt')) + return test_exists and actual_image_exists and info_exists + + def RemoveExpectation(self, expectation): + """Removes an expectation and all associated failures with that test. + + Args: + expectation: the name of the expectation to remove. + """ + test_paths = self.cloud_bucket.GetAllPaths( + GetExpectationPath(expectation)) + for path in test_paths: + self.cloud_bucket.RemoveFile(path) + + def GenerateExpectationPinkOut(self, expectation, images, pint_out, rgb): + """Uploads an ispy-test to GS with the pink_out workaround. + + Args: + expectation: the name of the expectation to be uploaded. + images: a json encoded list of base64 encoded png images. + pink_out: an image. + RGB: a json list representing the RGB values of a color to mask out. + + Raises: + ValueError: if expectation name is invalid. + """ + if not IsValidExpectationName(expectation): + raise ValueError("Expectation name contains an illegal character: %s." % + str(_INVALID_EXPECTATION_CHARS)) + + # convert the pink_out into a mask + black = (0, 0, 0, 255) + white = (255, 255, 255, 255) + pink_out.putdata( + [black if px == (rgb[0], rgb[1], rgb[2], 255) else white + for px in pink_out.getdata()]) + mask = image_tools.CreateMask(images) + mask = image_tools.InflateMask(image_tools.CreateMask(images), 7) + combined_mask = image_tools.AddMasks([mask, pink_out]) + self.UploadImage(GetExpectationPath(expectation, 'expected.png'), images[0]) + self.UploadImage(GetExpectationPath(expectation, 'mask.png'), combined_mask) + + def RemoveFailure(self, test_run, expectation): + """Removes a failure from GS. + + Args: + test_run: the name of the test_run. + expectation: the expectation on which the failure to be removed occured. + """ + failure_paths = self.cloud_bucket.GetAllPaths( + GetFailurePath(test_run, expectation)) + for path in failure_paths: + self.cloud_bucket.RemoveFile(path) + + def GetFailure(self, test_run, expectation): + """Returns a given test failure's expected, diff, and actual images. + + Args: + test_run: the name of the test_run. + expectation: the name of the expectation the result corresponds to. + + Returns: + A named tuple: Failure containing three images: expected, diff, and + actual. + + Raises: + cloud_bucket.NotFoundError: if the result is not found in GS. + """ + expected = self.DownloadImage( + GetExpectationPath(expectation, 'expected.png')) + actual = self.DownloadImage( + GetFailurePath(test_run, expectation, 'actual.png')) + diff = self.DownloadImage( + GetFailurePath(test_run, expectation, 'diff.png')) + info = json.loads(self.cloud_bucket.DownloadFile( + GetFailurePath(test_run, expectation, 'info.txt'))) + Failure = collections.namedtuple( + 'Failure', ['expected', 'diff', 'actual', 'info']) + return Failure(expected, diff, actual, info) + + def GetAllPaths(self, prefix): + """Gets urls to all files in GS whose path starts with a given prefix. + + Args: + prefix: the prefix to filter files in GS by. + + Returns: + a list containing urls to all objects that started with + the prefix. + """ + return self.cloud_bucket.GetAllPaths(prefix) + diff --git a/chrome/test/ispy/common/ispy_utils_unittest.py b/chrome/test/ispy/common/ispy_utils_unittest.py new file mode 100644 index 0000000..2b55c2c --- /dev/null +++ b/chrome/test/ispy/common/ispy_utils_unittest.py @@ -0,0 +1,207 @@ +# Copyright 2013 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. + +import os +from PIL import Image +import sys +import unittest + +import cloud_bucket +import image_tools +import ispy_utils +import mock_cloud_bucket + + +class ISpyUtilsUnitTest(unittest.TestCase): + + def setUp(self): + # Set up structures that will be reused throughout testing. + self.bucket = mock_cloud_bucket.MockCloudBucket() + self.ispy_utils = ispy_utils.ISpyUtils(self.bucket) + self.white = Image.new('RGBA', (25, 25), (255, 255, 255, 255)) + self.red = Image.new('RGBA', (25, 25), (255, 0, 0, 255)) + self.black = Image.new('RGBA', (25, 25), (0, 0, 0, 255)) + self.masked = Image.new('RGBA', (25, 25), (210, 0, 0, 255)) + + def testUploadImage(self): + self.bucket.Reset() + # Upload some images to the datastore. + self.ispy_utils.UploadImage('path/to/white.png', self.white) + self.ispy_utils.UploadImage('path/to/black.png', self.black) + self.ispy_utils.UploadImage('path/to/red.png', self.red) + # Confirm that the images actually got uploaded. + self.assertEquals(self.bucket.datastore['path/to/white.png'], + image_tools.EncodePNG(self.white)) + self.assertEquals(self.bucket.datastore['path/to/black.png'], + image_tools.EncodePNG(self.black)) + self.assertEquals(self.bucket.datastore['path/to/red.png'], + image_tools.EncodePNG(self.red)) + + def testDownloadImage(self): + self.bucket.Reset() + # Upload some images to the datastore. + self.ispy_utils.UploadImage('path/to/white.png', self.white) + self.ispy_utils.UploadImage('path/to/black.png', self.black) + self.ispy_utils.UploadImage('path/to/red.png', self.red) + # Check that the DownloadImage function gets the correct images. + self.assertEquals( + image_tools.EncodePNG( + self.ispy_utils.DownloadImage('path/to/white.png')), + image_tools.EncodePNG(self.white)) + self.assertEquals( + image_tools.EncodePNG( + self.ispy_utils.DownloadImage('path/to/black.png')), + image_tools.EncodePNG(self.black)) + self.assertEquals( + image_tools.EncodePNG( + self.ispy_utils.DownloadImage('path/to/red.png')), + image_tools.EncodePNG(self.red)) + # Check that the DownloadImage function throws an error for a + # nonexistant image. + self.assertRaises(cloud_bucket.FileNotFoundError, + self.ispy_utils.DownloadImage, + 'path/to/yellow.png') + + def testUpdateImage(self): + self.bucket.Reset() + # Upload some images to the datastore. + self.ispy_utils.UploadImage('path/to/image.png', self.white) + self.assertEquals(self.bucket.datastore['path/to/image.png'], + image_tools.EncodePNG(self.white)) + self.ispy_utils.UpdateImage('path/to/image.png', self.black) + # Confirm that the image actually got updated. + self.assertEquals(self.bucket.datastore['path/to/image.png'], + image_tools.EncodePNG(self.black)) + + def testGenerateExpectation(self): + self.bucket.Reset() + # Upload some tests to the datastore. + self.ispy_utils.GenerateExpectation('test', [self.white, self.black]) + self.ispy_utils.GenerateExpectation('test1', [self.black, self.black]) + self.ispy_utils.GenerateExpectation('test2', [self.black]) + # Confirm that the tests were successfully uploaded. + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test', 'expected.png')], + image_tools.EncodePNG(self.white)) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test', 'mask.png')], + image_tools.EncodePNG(self.white)) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test1', 'expected.png')], + image_tools.EncodePNG(self.black)) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test1', 'mask.png')], + image_tools.EncodePNG(self.black)) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test2', 'expected.png')], + image_tools.EncodePNG(self.black)) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetExpectationPath('test2', 'mask.png')], + image_tools.EncodePNG(self.black)) + + def testPerformComparison(self): + self.bucket.Reset() + self.ispy_utils.GenerateExpectation('test1', [self.red, self.red]) + self.ispy_utils.PerformComparison('test', 'test1', self.black) + self.assertEquals(self.bucket.datastore[ + ispy_utils.GetFailurePath('test', 'test1', 'actual.png')], + image_tools.EncodePNG(self.black)) + self.ispy_utils.PerformComparison('test', 'test1', self.red) + self.assertTrue(self.bucket.datastore.has_key( + ispy_utils.GetFailurePath('test', 'test1', 'actual.png'))) + + def testGetExpectation(self): + self.bucket.Reset() + # Upload some tests to the datastore + self.ispy_utils.GenerateExpectation('test1', [self.white, self.black]) + self.ispy_utils.GenerateExpectation('test2', [self.red, self.white]) + test1 = self.ispy_utils.GetExpectation('test1') + test2 = self.ispy_utils.GetExpectation('test2') + # Check that GetExpectation gets the appropriate tests. + self.assertEquals(image_tools.EncodePNG(test1.expected), + image_tools.EncodePNG(self.white)) + self.assertEquals(image_tools.EncodePNG(test1.mask), + image_tools.EncodePNG(self.white)) + self.assertEquals(image_tools.EncodePNG(test2.expected), + image_tools.EncodePNG(self.red)) + self.assertEquals(image_tools.EncodePNG(test2.mask), + image_tools.EncodePNG(self.white)) + # Check that GetExpectation throws an error for a nonexistant test. + self.assertRaises( + cloud_bucket.FileNotFoundError, self.ispy_utils.GetExpectation, 'test3') + + def testExpectationExists(self): + self.bucket.Reset() + self.ispy_utils.GenerateExpectation('test1', [self.white, self.black]) + self.ispy_utils.GenerateExpectation('test2', [self.white, self.black]) + self.assertTrue(self.ispy_utils.ExpectationExists('test1')) + self.assertTrue(self.ispy_utils.ExpectationExists('test2')) + self.assertFalse(self.ispy_utils.ExpectationExists('test3')) + + def testFailureExists(self): + self.bucket.Reset() + self.ispy_utils.GenerateExpectation('test1', [self.white, self.white]) + self.ispy_utils.PerformComparison('test', 'test1', self.black) + self.ispy_utils.PerformComparison('test', 'test1', self.white) + self.assertTrue(self.ispy_utils.FailureExists('test', 'test1')) + self.assertFalse(self.ispy_utils.FailureExists('test', 'test2')) + + def testRemoveExpectation(self): + self.bucket.Reset() + self.ispy_utils.GenerateExpectation('test1', [self.white, self.white]) + self.ispy_utils.GenerateExpectation('test2', [self.white, self.white]) + self.assertTrue(self.ispy_utils.ExpectationExists('test1')) + self.assertTrue(self.ispy_utils.ExpectationExists('test2')) + self.ispy_utils.RemoveExpectation('test1') + self.assertFalse(self.ispy_utils.ExpectationExists('test1')) + self.assertTrue(self.ispy_utils.ExpectationExists('test2')) + self.ispy_utils.RemoveExpectation('test2') + self.assertFalse(self.ispy_utils.ExpectationExists('test1')) + self.assertFalse(self.ispy_utils.ExpectationExists('test2')) + + def testRemoveFailure(self): + self.bucket.Reset() + self.ispy_utils.GenerateExpectation('test1', [self.white, self.white]) + self.ispy_utils.GenerateExpectation('test2', [self.white, self.white]) + self.ispy_utils.PerformComparison('test', 'test1', self.black) + self.ispy_utils.RemoveFailure('test', 'test1') + self.assertFalse(self.ispy_utils.FailureExists('test', 'test1')) + self.assertTrue(self.ispy_utils.ExpectationExists('test1')) + self.assertFalse(self.ispy_utils.FailureExists('test', 'test2')) + self.assertTrue(self.ispy_utils.ExpectationExists('test2')) + + def testGetFailure(self): + self.bucket.Reset() + # Upload a result + self.ispy_utils.GenerateExpectation('test1', [self.red, self.red]) + self.ispy_utils.PerformComparison('test', 'test1', self.black) + res = self.ispy_utils.GetFailure('test', 'test1') + # Check that the function correctly got the result. + self.assertEquals(image_tools.EncodePNG(res.expected), + image_tools.EncodePNG(self.red)) + self.assertEquals(image_tools.EncodePNG(res.diff), + image_tools.EncodePNG(self.masked)) + self.assertEquals(image_tools.EncodePNG(res.actual), + image_tools.EncodePNG(self.black)) + # Check that the function raises an error when given non-existant results. + self.assertRaises(cloud_bucket.FileNotFoundError, + self.ispy_utils.GetFailure, 'test', 'test2') + + def testGetAllPaths(self): + self.bucket.Reset() + # Upload some tests. + self.ispy_utils.GenerateExpectation('test1', [self.white, self.black]) + # Check that the function gets all urls matching the prefix. + self.assertEquals( + set(self.ispy_utils.GetAllPaths( + ispy_utils.GetExpectationPath('test1'))), + set([ispy_utils.GetExpectationPath('test1', 'expected.png'), + ispy_utils.GetExpectationPath('test1', 'mask.png')])) + self.assertEquals( + set(self.ispy_utils.GetAllPaths( + ispy_utils.GetExpectationPath('test3'))), set()) + + +if __name__ == '__main__': + unittest.main() diff --git a/chrome/test/ispy/common/mock_cloud_bucket.py b/chrome/test/ispy/common/mock_cloud_bucket.py new file mode 100644 index 0000000..803fd57 --- /dev/null +++ b/chrome/test/ispy/common/mock_cloud_bucket.py @@ -0,0 +1,65 @@ +# Copyright 2013 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. + +"""Subclass of CloudBucket used for testing.""" + +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)) +import cloud_bucket + + +class MockCloudBucket(cloud_bucket.BaseCloudBucket): + """Subclass of CloudBucket used for testing.""" + + def __init__(self): + """Initializes the MockCloudBucket with its datastore. + + Returns: + An instance of MockCloudBucket. + """ + self.datastore = {} + + def Reset(self): + """Clears the MockCloudBucket's datastore.""" + self.datastore = {} + + # override + def UploadFile(self, path, contents, content_type): + self.datastore[path] = contents + + # override + def DownloadFile(self, path): + if self.datastore.has_key(path): + return self.datastore[path] + else: + raise cloud_bucket.FileNotFoundError + + # override + def UpdateFile(self, path, contents): + if not self.FileExists(path): + raise cloud_bucket.FileNotFoundError + self.UploadFile(path, contents, '') + + # override + def RemoveFile(self, path): + if self.datastore.has_key(path): + self.datastore.pop(path) + + # override + def FileExists(self, path): + return self.datastore.has_key(path) + + # override + def GetImageURL(self, path): + if self.datastore.has_key(path): + return path + else: + raise cloud_bucket.FileNotFoundError + + # override + def GetAllPaths(self, prefix): + return (item[0] for item in self.datastore.items() + if item[0].startswith(prefix)) diff --git a/chrome/test/ispy/ispy_api.py b/chrome/test/ispy/ispy_api.py new file mode 100644 index 0000000..e297368 --- /dev/null +++ b/chrome/test/ispy/ispy_api.py @@ -0,0 +1,229 @@ +# Copyright 2014 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. + +import json +import logging +import os +from distutils.version import LooseVersion +from PIL import Image + +from common import cloud_bucket +from common import ispy_utils + + +class ISpyApi(object): + """The public API for interacting with ISpy.""" + + def __init__(self, cloud_bucket): + """Initializes the utility class. + + Args: + cloud_bucket: a BaseCloudBucket in which to the version file, + expectations and results are to be stored. + """ + self._cloud_bucket = cloud_bucket + self._ispy = ispy_utils.ISpyUtils(self._cloud_bucket) + self._rebaselineable_cache = {} + + def UpdateExpectationVersion(self, chrome_version, version_file): + """Updates the most recent expectation version to the Chrome version. + + Should be called after generating a new set of expectations. + + Args: + chrome_version: the chrome version as a string of the form "31.0.123.4". + version_file: path to the version file in the cloud bucket. The version + file contains a json list of ordered Chrome versions for which + expectations exist. + """ + insert_pos = 0 + expectation_versions = [] + try: + expectation_versions = self._GetExpectationVersionList(version_file) + if expectation_versions: + try: + version = self._GetExpectationVersion( + chrome_version, expectation_versions) + if version == chrome_version: + return + insert_pos = expectation_versions.index(version) + except: + insert_pos = len(expectation_versions) + except cloud_bucket.FileNotFoundError: + pass + expectation_versions.insert(insert_pos, chrome_version) + logging.info('Updating expectation version...') + self._cloud_bucket.UploadFile( + version_file, json.dumps(expectation_versions), + 'application/json') + + def _GetExpectationVersion(self, chrome_version, expectation_versions): + """Returns the expectation version for the given Chrome version. + + Args: + chrome_version: the chrome version as a string of the form "31.0.123.4". + expectation_versions: Ordered list of Chrome versions for which + expectations exist, as stored in the version file. + + Returns: + Expectation version string. + """ + # Find the closest version that is not greater than the chrome version. + for version in expectation_versions: + if LooseVersion(version) <= LooseVersion(chrome_version): + return version + raise Exception('No expectation exists for Chrome %s' % chrome_version) + + def _GetExpectationVersionList(self, version_file): + """Gets the list of expectation versions from google storage. + + Args: + version_file: path to the version file in the cloud bucket. The version + file contains a json list of ordered Chrome versions for which + expectations exist. + + Returns: + Ordered list of Chrome versions. + """ + try: + return json.loads(self._cloud_bucket.DownloadFile(version_file)) + except: + return [] + + def _GetExpectationNameWithVersion(self, device_type, expectation, + chrome_version, version_file): + """Get the expectation to be used with the current Chrome version. + + Args: + device_type: string identifier for the device type. + expectation: name for the expectation to generate. + chrome_version: the chrome version as a string of the form "31.0.123.4". + + Returns: + Version as an integer. + """ + version = self._GetExpectationVersion( + chrome_version, self._GetExpectationVersionList(version_file)) + return self._CreateExpectationName(device_type, expectation, version) + + def _CreateExpectationName(self, device_type, expectation, version): + """Create the full expectation name from the expectation and version. + + Args: + device_type: string identifier for the device type, example: mako + expectation: base name for the expectation, example: google.com + version: expectation version, example: 31.0.23.1 + + Returns: + Full expectation name as a string, example: mako:google.com(31.0.23.1) + """ + return '%s:%s(%s)' % (device_type, expectation, version) + + def GenerateExpectation(self, device_type, expectation, chrome_version, + version_file, screenshots): + """Create an expectation for I-Spy. + + Args: + device_type: string identifier for the device type. + expectation: name for the expectation to generate. + chrome_version: the chrome version as a string of the form "31.0.123.4". + screenshots: a list of similar PIL.Images. + """ + # https://code.google.com/p/chromedriver/issues/detail?id=463 + expectation_with_version = self._CreateExpectationName( + device_type, expectation, chrome_version) + if self._ispy.ExpectationExists(expectation_with_version): + logging.warning( + 'I-Spy expectation \'%s\' already exists, overwriting.', + expectation_with_version) + logging.info('Generating I-Spy expectation...') + self._ispy.GenerateExpectation(expectation_with_version, screenshots) + + def PerformComparison(self, test_run, device_type, expectation, + chrome_version, version_file, screenshot): + """Compare a screenshot with the given expectation in I-Spy. + + Args: + test_run: name for the test run. + device_type: string identifier for the device type. + expectation: name for the expectation to compare against. + chrome_version: the chrome version as a string of the form "31.0.123.4". + screenshot: a PIL.Image to compare. + """ + # https://code.google.com/p/chromedriver/issues/detail?id=463 + logging.info('Performing I-Spy comparison...') + self._ispy.PerformComparison( + test_run, + self._GetExpectationNameWithVersion( + device_type, expectation, chrome_version, version_file), + screenshot) + + def CanRebaselineToTestRun(self, test_run): + """Returns whether the test run has associated expectations. + + Returns: + True if RebaselineToTestRun() can be called for this test run. + """ + if test_run in self._rebaselineable_cache: + return True + return self._cloud_bucket.FileExists( + ispy_utils.GetTestRunPath(test_run, 'rebaseline.txt')) + + def RebaselineToTestRun(self, test_run): + """Update the version file to use expectations associated with |test_run|. + + Args: + test_run: The name of the test run to rebaseline. + """ + rebaseline_path = ispy_utils.GetTestRunPath(test_run, 'rebaseline.txt') + rebaseline_attrib = json.loads( + self._cloud_bucket.DownloadFile(rebaseline_path)) + self.UpdateExpectationVersion( + rebaseline_attrib['version'], rebaseline_attrib['version_file']) + self._cloud_bucket.RemoveFile(rebaseline_path) + + def _SetTestRunRebaselineable(self, test_run, chrome_version, version_file): + """Writes a JSON file containing the data needed to rebaseline. + + Args: + test_run: The name of the test run to add the rebaseline file to. + chrome_version: the chrome version that can be rebaselined to (must have + associated Expectations). + version_file: the path of the version file associated with the test run. + """ + self._rebaselineable_cache[test_run] = True + self._cloud_bucket.UploadFile( + ispy_utils.GetTestRunPath(test_run, 'rebaseline.txt'), + json.dumps({ + 'version': chrome_version, + 'version_file': version_file}), + 'application/json') + + def PerformComparisonAndPrepareExpectation(self, test_run, device_type, + expectation, chrome_version, + version_file, screenshots): + """Perform comparison and generate an expectation that can used later. + + The test run web UI will have a button to set the Expectations generated for + this version as the expectation for comparison with later versions. + + Args: + test_run: The name of the test run to add the rebaseline file to. + device_type: string identifier for the device type. + chrome_version: the chrome version that can be rebaselined to (must have + associated Expectations). + version_file: the path of the version file associated with the test run. + screenshot: a list of similar PIL.Images. + """ + if not self.CanRebaselineToTestRun(test_run): + self._SetTestRunRebaselineable(test_run, chrome_version, version_file) + expectation_with_version = self._CreateExpectationName( + device_type, expectation, chrome_version) + self._ispy.GenerateExpectation(expectation_with_version, screenshots) + self._ispy.PerformComparison( + test_run, + self._GetExpectationNameWithVersion( + device_type, expectation, chrome_version, version_file), + screenshots[-1]) + diff --git a/chrome/test/ispy/ispy_api_unittest.py b/chrome/test/ispy/ispy_api_unittest.py new file mode 100755 index 0000000..2e6a476 --- /dev/null +++ b/chrome/test/ispy/ispy_api_unittest.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# +# Copyright 2014 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. + +import json +import unittest +from PIL import Image + +import ispy_api +from common import cloud_bucket +from common import mock_cloud_bucket + + +class ISpyApiTest(unittest.TestCase): + """Unittest for the ISpy API.""" + + def setUp(self): + self.cloud_bucket = mock_cloud_bucket.MockCloudBucket() + self.ispy = ispy_api.ISpyApi(self.cloud_bucket) + self.white_img = Image.new('RGBA', (10, 10), (255, 255, 255, 255)) + self.black_img = Image.new('RGBA', (10, 10), (0, 0, 0, 255)) + + def testGenerateExpectationsRunComparison(self): + self.ispy.GenerateExpectation( + 'device', 'test', '1.1.1.1', 'versions.json', + [self.white_img, self.white_img]) + self.ispy.UpdateExpectationVersion('1.1.1.1', 'versions.json') + self.ispy.PerformComparison( + 'test1', 'device', 'test', '1.1.1.1', 'versions.json', self.white_img) + expect_name = self.ispy._CreateExpectationName( + 'device', 'test', '1.1.1.1') + self.assertFalse(self.ispy._ispy.FailureExists('test1', expect_name)) + self.ispy.PerformComparison( + 'test2', 'device', 'test', '1.1.1.1','versions.json', self.black_img) + self.assertTrue(self.ispy._ispy.FailureExists('test2', expect_name)) + + def testUpdateExpectationVersion(self): + self.ispy.UpdateExpectationVersion('1.0.0.0', 'versions.json') + self.ispy.UpdateExpectationVersion('1.0.4.0', 'versions.json') + self.ispy.UpdateExpectationVersion('2.1.5.0', 'versions.json') + self.ispy.UpdateExpectationVersion('1.1.5.0', 'versions.json') + self.ispy.UpdateExpectationVersion('0.0.0.0', 'versions.json') + self.ispy.UpdateExpectationVersion('1.1.5.0', 'versions.json') + self.ispy.UpdateExpectationVersion('0.0.0.1', 'versions.json') + versions = json.loads(self.cloud_bucket.DownloadFile('versions.json')) + self.assertEqual(versions, + ['2.1.5.0', '1.1.5.0', '1.0.4.0', '1.0.0.0', '0.0.0.1', '0.0.0.0']) + + def testPerformComparisonAndPrepareExpectation(self): + self.assertFalse(self.ispy.CanRebaselineToTestRun('test')) + self.assertRaises( + cloud_bucket.FileNotFoundError, + self.ispy.PerformComparisonAndPrepareExpectation, + 'test', 'device', 'expect', '1.0', 'versions.json', + [self.white_img, self.white_img]) + self.assertTrue(self.ispy.CanRebaselineToTestRun('test')) + self.ispy.RebaselineToTestRun('test') + versions = json.loads(self.cloud_bucket.DownloadFile('versions.json')) + self.assertEqual(versions, ['1.0']) + self.ispy.PerformComparisonAndPrepareExpectation( + 'test1', 'device', 'expect', '1.1', 'versions.json', + [self.white_img, self.white_img]) + + +if __name__ == '__main__': + unittest.main() diff --git a/chrome/test/ispy/server/__init__.py b/chrome/test/ispy/server/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chrome/test/ispy/server/__init__.py diff --git a/chrome/test/ispy/server/app.py b/chrome/test/ispy/server/app.py new file mode 100644 index 0000000..6e90804 --- /dev/null +++ b/chrome/test/ispy/server/app.py @@ -0,0 +1,22 @@ +# Copyright 2013 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. + +import os +import sys +import webapp2 + +sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir)) +import debug_view_handler +import image_handler +import main_view_handler +import rebaseline_handler +import update_mask_handler + + +application = webapp2.WSGIApplication( + [('/update_mask', update_mask_handler.UpdateMaskHandler), + ('/rebaseline', rebaseline_handler.RebaselineHandler), + ('/debug_view', debug_view_handler.DebugViewHandler), + ('/image', image_handler.ImageHandler), + ('/', main_view_handler.MainViewHandler)], debug=True) diff --git a/chrome/test/ispy/server/debug_view_handler.py b/chrome/test/ispy/server/debug_view_handler.py new file mode 100644 index 0000000..96bfe3c --- /dev/null +++ b/chrome/test/ispy/server/debug_view_handler.py @@ -0,0 +1,45 @@ +# Copyright 2013 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. + +"""Request handler to display the debug view for a Failure.""" + +import jinja2 +import os +import sys +import webapp2 + +from common import ispy_utils + +import views + +JINJA = jinja2.Environment( + loader=jinja2.FileSystemLoader(os.path.dirname(views.__file__)), + extensions=['jinja2.ext.autoescape']) + + +class DebugViewHandler(webapp2.RequestHandler): + """Request handler to display the debug view for a failure.""" + + def get(self): + """Handles get requests to the /debug_view page. + + GET Parameters: + test_run: The test run. + expectation: The expectation name. + """ + test_run = self.request.get('test_run') + expectation = self.request.get('expectation') + expected_path = ispy_utils.GetExpectationPath(expectation, 'expected.png') + actual_path = ispy_utils.GetFailurePath(test_run, expectation, 'actual.png') + data = {} + + def _ImagePath(url): + return '/image?file_path=%s' % url + + data['expected'] = _ImagePath(expected_path) + data['actual'] = _ImagePath(actual_path) + data['test_run'] = test_run + data['expectation'] = expectation + template = JINJA.get_template('debug_view.html') + self.response.write(template.render(data)) diff --git a/chrome/test/ispy/server/gs_bucket.py b/chrome/test/ispy/server/gs_bucket.py new file mode 100644 index 0000000..a132f05 --- /dev/null +++ b/chrome/test/ispy/server/gs_bucket.py @@ -0,0 +1,72 @@ +# Copyright 2013 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. + +"""Implementation of CloudBucket using Google Cloud Storage as the backend.""" +import os +import sys + +import cloudstorage + +from common import cloud_bucket + + +class GoogleCloudStorageBucket(cloud_bucket.BaseCloudBucket): + """Subclass of cloud_bucket.CloudBucket with actual GS commands.""" + + def __init__(self, bucket): + """Initializes the bucket. + + Args: + bucket: the name of the bucket to connect to. + """ + self.bucket = '/' + bucket + + def _full_path(self, path): + return self.bucket + '/' + path.lstrip('/') + + # override + def UploadFile(self, path, contents, content_type): + gs_file = cloudstorage.open( + self._full_path(path), 'w', content_type=content_type) + gs_file.write(contents) + gs_file.close() + + # override + def DownloadFile(self, path): + try: + gs_file = cloudstorage.open(self._full_path(path), 'r') + r = gs_file.read() + gs_file.close() + except Exception as e: + raise Exception('%s: %s' % (self._full_path(path), str(e))) + return r + + # override + def UpdateFile(self, path, contents): + if not self.FileExists(path): + raise cloud_bucket.FileNotFoundError + gs_file = cloudstorage.open(self._full_path(path), 'w') + gs_file.write(contents) + gs_file.close() + + # override + def RemoveFile(self, path): + cloudstorage.delete(self._full_path(path)) + + # override + def FileExists(self, path): + try: + cloudstorage.stat(self._full_path(path)) + except cloudstorage.NotFoundError: + return False + return True + + # override + def GetImageURL(self, path): + return '/image?file_path=%s' % path + + # override + def GetAllPaths(self, prefix): + return (f.filename[len(self.bucket) + 1:] for f in + cloudstorage.listbucket(self.bucket, prefix=prefix)) diff --git a/chrome/test/ispy/server/image_handler.py b/chrome/test/ispy/server/image_handler.py new file mode 100644 index 0000000..d1f11f2 --- /dev/null +++ b/chrome/test/ispy/server/image_handler.py @@ -0,0 +1,38 @@ +# Copyright 2013 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. + +"""Request handler to display an image from Google Cloud Storage.""" + +import json +import os +import sys +import webapp2 + +from common import cloud_bucket +from common import constants + +import gs_bucket + + +class ImageHandler(webapp2.RequestHandler): + """A request handler to avoid the Same-Origin problem in the debug view.""" + + def get(self): + """Handles get requests to the ImageHandler. + + GET Parameters: + file_path: A path to an image resource in Google Cloud Storage. + """ + file_path = self.request.get('file_path') + if not file_path: + self.error(404) + return + bucket = gs_bucket.GoogleCloudStorageBucket(constants.BUCKET) + try: + image = bucket.DownloadFile(file_path) + except cloud_bucket.FileNotFoundError: + self.error(404) + else: + self.response.headers['Content-Type'] = 'image/png' + self.response.out.write(image) diff --git a/chrome/test/ispy/server/main_view_handler.py b/chrome/test/ispy/server/main_view_handler.py new file mode 100644 index 0000000..0738a0c --- /dev/null +++ b/chrome/test/ispy/server/main_view_handler.py @@ -0,0 +1,116 @@ +# Copyright 2013 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. + +"""Request handler to serve the main_view page.""" + +import jinja2 +import json +import os +import re +import sys +import webapp2 + +import ispy_api +from common import constants +from common import ispy_utils + +import gs_bucket +import views + +JINJA = jinja2.Environment( + loader=jinja2.FileSystemLoader(os.path.dirname(views.__file__)), + extensions=['jinja2.ext.autoescape']) + + +class MainViewHandler(webapp2.RequestHandler): + """Request handler to serve the main_view page.""" + + def get(self): + """Handles a get request to the main_view page. + + If the test_run parameter is specified, then a page displaying all of + the failed runs in the test_run will be shown. Otherwise a view listing + all of the test_runs available for viewing will be displayed. + """ + test_run = self.request.get('test_run') + bucket = gs_bucket.GoogleCloudStorageBucket(constants.BUCKET) + ispy = ispy_utils.ISpyUtils(bucket) + # Load the view. + if test_run: + self._GetForTestRun(test_run, ispy) + return + self._GetAllTestRuns(ispy) + + def _GetAllTestRuns(self, ispy): + """Renders a list view of all of the test_runs available in GS. + + Args: + ispy: An instance of ispy_api.ISpyApi. + """ + template = JINJA.get_template('list_view.html') + data = {} + test_runs = set([path.lstrip('/').split('/')[1] for path in + ispy.GetAllPaths('failures/')]) + base_url = '/?test_run=%s' + data['links'] = [(test_run, base_url % test_run) for test_run in test_runs] + self.response.write(template.render(data)) + + def _GetForTestRun(self, test_run, ispy): + """Renders a sorted list of failure-rows for a given test_run. + + This method will produce a list of failure-rows that are sorted + in descending order by number of different pixels. + + Args: + test_run: The name of the test_run to render failure rows from. + ispy: An instance of ispy_api.ISpyApi. + """ + paths = set([path for path in ispy.GetAllPaths('failures/' + test_run) + if path.endswith('actual.png')]) + can_rebaseline = ispy_api.ISpyApi( + ispy.cloud_bucket).CanRebaselineToTestRun(test_run) + rows = [self._CreateRow(test_run, path, ispy) for path in paths] + + # Function that sorts by the different_pixels field in the failure-info. + def _Sorter(a, b): + return cmp(b['percent_different'], + a['percent_different']) + template = JINJA.get_template('main_view.html') + self.response.write( + template.render({'comparisons': sorted(rows, _Sorter), + 'test_run': test_run, + 'can_rebaseline': can_rebaseline})) + + def _CreateRow(self, test_run, path, ispy): + """Creates one failure-row. + + This method builds a dictionary with the data necessary to display a + failure in the main_view html template. + + Args: + test_run: The name of the test_run the failure is in. + path: A path to the failure's actual.png file. + ispy: An instance of ispy_api.ISpyApi. + + Returns: + A dictionary with fields necessary to render a failure-row + in the main_view html template. + """ + res = {} + res['expectation'] = path.lstrip('/').split('/')[2] + res['test_run'] = test_run + res['info'] = json.loads(ispy.cloud_bucket.DownloadFile( + ispy_utils.GetFailurePath(res['test_run'], res['expectation'], + 'info.txt'))) + expected = ispy_utils.GetExpectationPath( + res['expectation'], 'expected.png') + diff = ispy_utils.GetFailurePath(test_run, res['expectation'], 'diff.png') + res['percent_different'] = res['info']['fraction_different'] * 100 + res['expected_path'] = expected + res['diff_path'] = diff + res['actual_path'] = path + res['expected'] = ispy.cloud_bucket.GetImageURL(expected) + res['diff'] = ispy.cloud_bucket.GetImageURL(diff) + res['actual'] = ispy.cloud_bucket.GetImageURL(path) + return res diff --git a/chrome/test/ispy/server/rebaseline_handler.py b/chrome/test/ispy/server/rebaseline_handler.py new file mode 100644 index 0000000..81bdb4b --- /dev/null +++ b/chrome/test/ispy/server/rebaseline_handler.py @@ -0,0 +1,38 @@ +# Copyright 2014 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. + +"""Request Handler that updates the Expectation version.""" + +import webapp2 + +import ispy_api +from common import constants + +import gs_bucket + + +class RebaselineHandler(webapp2.RequestHandler): + """Request handler to allow test mask updates.""" + + def post(self): + """Accepts post requests. + + Expects a test_run as a parameter and updates the associated version file to + use the expectations associated with that test run. + """ + test_run = self.request.get('test_run') + + # Fail if test_run parameter is missing. + if not test_run: + self.response.headers['Content-Type'] = 'json/application' + self.response.write(json.dumps( + {'error': '\'test_run\' must be supplied to rebaseline.'})) + return + # Otherwise, set up the utilities. + bucket = gs_bucket.GoogleCloudStorageBucket(constants.BUCKET) + ispy = ispy_api.ISpyApi(bucket) + # Update versions file. + ispy.RebaselineToTestRun(test_run) + # Redirect back to the sites list for the test run. + self.redirect('/?test_run=%s' % test_run) diff --git a/chrome/test/ispy/server/update_mask_handler.py b/chrome/test/ispy/server/update_mask_handler.py new file mode 100644 index 0000000..10cb964 --- /dev/null +++ b/chrome/test/ispy/server/update_mask_handler.py @@ -0,0 +1,59 @@ +# Copyright 2013 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. + +"""Request Handler to allow test mask updates.""" + +import webapp2 +import re +import sys +import os + +from common import constants +from common import image_tools +from common import ispy_utils + +import gs_bucket + + +class UpdateMaskHandler(webapp2.RequestHandler): + """Request handler to allow test mask updates.""" + + def post(self): + """Accepts post requests. + + This method will accept a post request containing device, site and + device_id parameters. This method takes the diff of the run + indicated by it's parameters and adds it to the mask of the run's + test. It will then delete the run it is applied to and redirect + to the device list view. + """ + test_run = self.request.get('test_run') + expectation = self.request.get('expectation') + + # Short-circuit if a parameter is missing. + if not (test_run and expectation): + self.response.headers['Content-Type'] = 'json/application' + self.response.write(json.dumps( + {'error': '\'test_run\' and \'expectation\' must be ' + 'supplied to update a mask.'})) + return + # Otherwise, set up the utilities. + self.bucket = gs_bucket.GoogleCloudStorageBucket(constants.BUCKET) + self.ispy = ispy_utils.ISpyUtils(self.bucket) + # Short-circuit if the failure does not exist. + if not self.ispy.FailureExists(test_run, expectation): + self.response.headers['Content-Type'] = 'json/application' + self.response.write(json.dumps( + {'error': 'Could not update mask because failure does not exist.'})) + return + # Get the failure namedtuple (which also computes the diff). + failure = self.ispy.GetFailure(test_run, expectation) + # Upload the new mask in place of the original. + self.ispy.UpdateImage( + ispy_utils.GetExpectationPath(expectation, 'mask.png'), + image_tools.ConvertDiffToMask(failure.diff)) + # Now that there is no diff for the two images, remove the failure. + self.ispy.RemoveFailure(test_run, expectation) + # Redirect back to the sites list for the test run. + self.redirect('/?test_run=%s' % test_run) diff --git a/chrome/test/ispy/server/views/__init__.py b/chrome/test/ispy/server/views/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chrome/test/ispy/server/views/__init__.py diff --git a/chrome/test/ispy/server/views/debug_view.html b/chrome/test/ispy/server/views/debug_view.html new file mode 100644 index 0000000..8371280 --- /dev/null +++ b/chrome/test/ispy/server/views/debug_view.html @@ -0,0 +1,47 @@ +<html> + <head> + <title>Debug {{ expectation }}</title> + <script language="javascript"> + var current = 0; + var toggle_interval = null; + + var toggle = function() { + current = (current + 1) % 2; + var image = document.getElementById("screenshot"); + image.src = (current ? "{{ actual }}" : "{{ expected }}"); + var title = document.getElementById("text"); + title.textContent = (current ? "Actual" : "Expected"); + } + + var setup = function() { + toggle(); + toggle_interval = window.setInterval(toggle, 1000); + } + + var manualToggle = function() { + if (toggle_interval != null) + window.clearInterval(toggle_interval); + toggle(); + } + + var confirmSubmit = function() { + return confirm("The area in this diff will be ignored in all future comparisions. Are you sure?"); + } + </script> + </head> + <body onload="setup();"> + <div> + <a href="javascript:void(0)" onclick="manualToggle();">Toggle</a> + → + <span id="text"></span> + </div> + <br> + <form action="/update_mask" method="post" onsubmit="return confirmSubmit();"> + <input type="hidden" name="test_run" value="{{ test_run }}"/> + <input type="hidden" name="expectation" value="{{ expectation }}"/> + <input type="submit" value="Ignore similar diffs in the future"/> + </form> + <br> + <img id="screenshot" src=""/> + </body> +</html> diff --git a/chrome/test/ispy/server/views/list_view.html b/chrome/test/ispy/server/views/list_view.html new file mode 100644 index 0000000..f6b5dc6 --- /dev/null +++ b/chrome/test/ispy/server/views/list_view.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +{% autoescape on %} +<html> + <head> + <title>I-Spy Test Runs</title> + <style> + #container { + display: table; + background-color:#DDD; + border: 1px solid #AAA; + width: 400px; + margin: 5px; + padding: 5px; + } + </style> + </head> + <body> + <h3>Test Runs</h3> + <div id="container"> + {% for link in links %} + <div> + <a href="{{ link[1] }}">{{ link[0] }}</a> + </div> + {% endfor %} + </div> + </body> +</html> +{% endautoescape %} diff --git a/chrome/test/ispy/server/views/main_view.html b/chrome/test/ispy/server/views/main_view.html new file mode 100644 index 0000000..d722c6a --- /dev/null +++ b/chrome/test/ispy/server/views/main_view.html @@ -0,0 +1,78 @@ +<!DOCTYPE html> +<html> + <head> + <title>{{ test_run }} failures</title> + <style> + .image { + max-height: 325px; + max-width: 325px; + } + .cell { + padding-right: 25px; + padding-left: 25px; + float: left; + width: 20%; + } + .imagelink { + border-width: 0px; + } + .info { + padding-bottom: 25px; + } + .row { + padding-top: 10px; + padding-bottom: 10px; + border-bottom: 2px solid #888; + height: 350px; + } + </style> + + <script language="javascript"> + var confirmSubmit = function() { + return confirm("The screenshots generated with this version of chrome will be used as the expected images for future comparisions. Are you sure?"); + } + </script> + </head> + <body> + <h3>Test Run: {{ test_run }}</h3> + {% if can_rebaseline %} + <form action="/rebaseline" method="post" onsubmit="return confirmSubmit();"> + <input type="hidden" name="test_run" value="{{ test_run }}"/> + <input type="submit" value="Set as LKGR"/> + </form> + <br> + {% endif %} + {% if not comparisons %} + <h2>No failures.</h2> + {% endif %} + {% for comp in comparisons %} + <div class="row"> + <div class="cell"> + Diff ({{ "%.1f"|format(comp['percent_different']) }}%)<br> + <a class="imagelink" href="{{ comp['diff'] }}"> + <img class="image" src={{ comp['diff'] }}> + </a> + </div> + <div class="cell"> + Expected<br> + <a class="imagelink" href="{{ comp['expected'] }}"> + <img class="image" src={{ comp['expected'] }}> + </a> + </div> + <div class="cell"> + Actual<br> + <a class="imagelink" href="{{ comp['actual'] }}"> + <img class="image" src={{ comp['actual'] }}> + </a> + </div> + <div class="cell"> + <br> + <div class="info"> + {{ comp['expectation'] }}<br> + <a href='/debug_view?test_run={{ comp['test_run'] }}&expectation={{ comp['expectation'] }}'>Debug View</a> + </div> + </div> + </div> + {% endfor %} + </body> +</html> |