Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/env python
      2 # Copyright 2016 The PDFium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 import cStringIO
      7 import functools
      8 import multiprocessing
      9 import optparse
     10 import os
     11 import re
     12 import shutil
     13 import subprocess
     14 import sys
     15 
     16 import common
     17 import gold
     18 import pngdiffer
     19 import suppressor
     20 
     21 class KeyboardInterruptError(Exception): pass
     22 
     23 # Nomenclature:
     24 #   x_root - "x"
     25 #   x_filename - "x.ext"
     26 #   x_path - "path/to/a/b/c/x.ext"
     27 #   c_dir - "path/to/a/b/c"
     28 
     29 def TestOneFileParallel(this, test_case):
     30   """Wrapper to call GenerateAndTest() and redirect output to stdout."""
     31   try:
     32     input_filename, source_dir = test_case
     33     result = this.GenerateAndTest(input_filename, source_dir);
     34     return (result, input_filename, source_dir)
     35   except KeyboardInterrupt:
     36     raise KeyboardInterruptError()
     37 
     38 
     39 class TestRunner:
     40   def __init__(self, dirname):
     41     self.test_dir = dirname
     42     self.enforce_expected_images = False
     43     self.oneshot_renderer = False
     44 
     45   # GenerateAndTest returns a tuple <success, outputfiles> where
     46   # success is a boolean indicating whether the tests passed comparison
     47   # tests and outputfiles is a list tuples:
     48   #          (path_to_image, md5_hash_of_pixelbuffer)
     49   def GenerateAndTest(self, input_filename, source_dir):
     50     input_root, _ = os.path.splitext(input_filename)
     51     expected_txt_path = os.path.join(source_dir, input_root + '_expected.txt')
     52 
     53     pdf_path = os.path.join(self.working_dir, input_root + '.pdf')
     54 
     55     # Remove any existing generated images from previous runs.
     56     actual_images = self.image_differ.GetActualFiles(input_filename, source_dir,
     57                                                      self.working_dir)
     58     for image in actual_images:
     59       if os.path.exists(image):
     60         os.remove(image)
     61 
     62     sys.stdout.flush()
     63 
     64     raised_exception = self.Generate(source_dir, input_filename, input_root,
     65                                      pdf_path)
     66 
     67     if raised_exception is not None:
     68       print 'FAILURE: %s; %s' % (input_filename, raised_exception)
     69       return False, []
     70 
     71     results = []
     72     if os.path.exists(expected_txt_path):
     73       raised_exception = self.TestText(input_root, expected_txt_path, pdf_path)
     74     else:
     75       raised_exception, results = self.TestPixel(input_root, pdf_path)
     76 
     77     if raised_exception is not None:
     78       print 'FAILURE: %s; %s' % (input_filename, raised_exception)
     79       return False, results
     80 
     81     if actual_images:
     82       if self.image_differ.HasDifferences(input_filename, source_dir,
     83                                           self.working_dir):
     84         if (self.options.regenerate_expected
     85             and not self.test_suppressor.IsResultSuppressed(input_filename)
     86             and not self.test_suppressor.IsImageDiffSuppressed(input_filename)):
     87           platform_only = (self.options.regenerate_expected == 'platform')
     88           self.image_differ.Regenerate(input_filename, source_dir,
     89                                        self.working_dir, platform_only)
     90         return False, results
     91     else:
     92       if (self.enforce_expected_images
     93           and not self.test_suppressor.IsImageDiffSuppressed(input_filename)):
     94         print 'FAILURE: %s; Missing expected images' % input_filename
     95         return False, results
     96 
     97     return True, results
     98 
     99   def Generate(self, source_dir, input_filename, input_root, pdf_path):
    100     original_path = os.path.join(source_dir, input_filename)
    101     input_path = os.path.join(source_dir, input_root + '.in')
    102 
    103     input_event_path = os.path.join(source_dir, input_root + '.evt')
    104     if os.path.exists(input_event_path):
    105       output_event_path = os.path.splitext(pdf_path)[0] + '.evt'
    106       shutil.copyfile(input_event_path, output_event_path)
    107 
    108     if not os.path.exists(input_path):
    109       if os.path.exists(original_path):
    110         shutil.copyfile(original_path, pdf_path)
    111       return None
    112 
    113     sys.stdout.flush()
    114 
    115     return common.RunCommand(
    116         [sys.executable, self.fixup_path, '--output-dir=' + self.working_dir,
    117             input_path])
    118 
    119   def TestText(self, input_root, expected_txt_path, pdf_path):
    120     txt_path = os.path.join(self.working_dir, input_root + '.txt')
    121 
    122     with open(txt_path, 'w') as outfile:
    123       cmd_to_run = [self.pdfium_test_path, '--send-events', pdf_path]
    124       subprocess.check_call(cmd_to_run, stdout=outfile)
    125 
    126     cmd = [sys.executable, self.text_diff_path, expected_txt_path, txt_path]
    127     return common.RunCommand(cmd)
    128 
    129   def TestPixel(self, input_root, pdf_path):
    130     cmd_to_run = [self.pdfium_test_path, '--send-events', '--png', '--md5']
    131     if self.oneshot_renderer:
    132       cmd_to_run.append('--render-oneshot')
    133     cmd_to_run.append(pdf_path)
    134     return common.RunCommandExtractHashedFiles(cmd_to_run)
    135 
    136   def HandleResult(self, input_filename, input_path, result):
    137     success, image_paths = result
    138 
    139     if image_paths:
    140       for img_path, md5_hash in image_paths:
    141         # The output filename without image extension becomes the test name.
    142         # For example, "/path/to/.../testing/corpus/example_005.pdf.0.png"
    143         # becomes "example_005.pdf.0".
    144         test_name = os.path.splitext(os.path.split(img_path)[1])[0]
    145 
    146         if not self.test_suppressor.IsResultSuppressed(input_filename):
    147           matched = self.gold_baseline.MatchLocalResult(test_name, md5_hash)
    148           if matched == gold.GoldBaseline.MISMATCH:
    149             print 'Skia Gold hash mismatch for test case: %s' % test_name
    150           elif matched ==  gold.GoldBaseline.NO_BASELINE:
    151             print 'No Skia Gold baseline found for test case: %s' % test_name
    152 
    153         if self.gold_results:
    154           self.gold_results.AddTestResult(test_name, md5_hash, img_path)
    155 
    156     if self.test_suppressor.IsResultSuppressed(input_filename):
    157       self.result_suppressed_cases.append(input_filename)
    158       if success:
    159         self.surprises.append(input_path)
    160     else:
    161       if not success:
    162         self.failures.append(input_path)
    163 
    164   def Run(self):
    165     parser = optparse.OptionParser()
    166 
    167     parser.add_option('--build-dir', default=os.path.join('out', 'Debug'),
    168                       help='relative path from the base source directory')
    169 
    170     parser.add_option('-j', default=multiprocessing.cpu_count(),
    171                       dest='num_workers', type='int',
    172                       help='run NUM_WORKERS jobs in parallel')
    173 
    174     parser.add_option('--gold_properties', default='', dest="gold_properties",
    175                       help='Key value pairs that are written to the top level '
    176                            'of the JSON file that is ingested by Gold.')
    177 
    178     parser.add_option('--gold_key', default='', dest="gold_key",
    179                       help='Key value pairs that are added to the "key" field '
    180                            'of the JSON file that is ingested by Gold.')
    181 
    182     parser.add_option('--gold_output_dir', default='', dest="gold_output_dir",
    183                       help='Path of where to write the JSON output to be '
    184                            'uploaded to Gold.')
    185 
    186     parser.add_option('--gold_ignore_hashes', default='',
    187                       dest="gold_ignore_hashes",
    188                       help='Path to a file with MD5 hashes we wish to ignore.')
    189 
    190     parser.add_option('--regenerate_expected', default='',
    191                       dest="regenerate_expected",
    192                       help='Regenerates expected images. Valid values are '
    193                            '"all" to regenerate all expected pngs, and '
    194                            '"platform" to regenerate only platform-specific '
    195                            'expected pngs.')
    196 
    197     parser.add_option('--ignore_errors', action="store_true",
    198                       dest="ignore_errors",
    199                       help='Prevents the return value from being non-zero '
    200                            'when image comparison fails.')
    201 
    202     self.options, self.args = parser.parse_args()
    203 
    204     if (self.options.regenerate_expected
    205         and self.options.regenerate_expected not in ['all', 'platform']) :
    206       print 'FAILURE: --regenerate_expected must be "all" or "platform"'
    207       return 1
    208 
    209     finder = common.DirectoryFinder(self.options.build_dir)
    210     self.fixup_path = finder.ScriptPath('fixup_pdf_template.py')
    211     self.text_diff_path = finder.ScriptPath('text_diff.py')
    212 
    213     self.source_dir = finder.TestingDir()
    214     if self.test_dir != 'corpus':
    215       test_dir = finder.TestingDir(os.path.join('resources', self.test_dir))
    216     else:
    217       test_dir = finder.TestingDir(self.test_dir)
    218 
    219     self.pdfium_test_path = finder.ExecutablePath('pdfium_test')
    220     if not os.path.exists(self.pdfium_test_path):
    221       print "FAILURE: Can't find test executable '%s'" % self.pdfium_test_path
    222       print 'Use --build-dir to specify its location.'
    223       return 1
    224 
    225     self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir))
    226     if not os.path.exists(self.working_dir):
    227       os.makedirs(self.working_dir)
    228 
    229     self.feature_string = subprocess.check_output([self.pdfium_test_path,
    230                                                    '--show-config'])
    231     self.test_suppressor = suppressor.Suppressor(finder, self.feature_string)
    232     self.image_differ = pngdiffer.PNGDiffer(finder)
    233 
    234     self.gold_baseline = gold.GoldBaseline(self.options.gold_properties)
    235 
    236     walk_from_dir = finder.TestingDir(test_dir);
    237 
    238     self.test_cases = []
    239     self.execution_suppressed_cases = []
    240     input_file_re = re.compile('^.+[.](in|pdf)$')
    241     if self.args:
    242       for file_name in self.args:
    243         file_name.replace('.pdf', '.in')
    244         input_path = os.path.join(walk_from_dir, file_name)
    245         if not os.path.isfile(input_path):
    246           print "Can't find test file '%s'" % file_name
    247           return 1
    248 
    249         self.test_cases.append((os.path.basename(input_path),
    250                            os.path.dirname(input_path)))
    251     else:
    252       for file_dir, _, filename_list in os.walk(walk_from_dir):
    253         for input_filename in filename_list:
    254           if input_file_re.match(input_filename):
    255             input_path = os.path.join(file_dir, input_filename)
    256             if self.test_suppressor.IsExecutionSuppressed(input_path):
    257               self.execution_suppressed_cases.append(input_path)
    258             else:
    259               if os.path.isfile(input_path):
    260                 self.test_cases.append((input_filename, file_dir))
    261 
    262     self.failures = []
    263     self.surprises = []
    264     self.result_suppressed_cases = []
    265 
    266     # Collect Gold results if an output directory was named.
    267     self.gold_results = None
    268     if self.options.gold_output_dir:
    269       self.gold_results = gold.GoldResults('pdfium',
    270                                            self.options.gold_output_dir,
    271                                            self.options.gold_properties,
    272                                            self.options.gold_key,
    273                                            self.options.gold_ignore_hashes)
    274 
    275     if self.options.num_workers > 1 and len(self.test_cases) > 1:
    276       try:
    277         pool = multiprocessing.Pool(self.options.num_workers)
    278         worker_func = functools.partial(TestOneFileParallel, self)
    279 
    280         worker_results = pool.imap(worker_func, self.test_cases)
    281         for worker_result in worker_results:
    282           result, input_filename, source_dir = worker_result
    283           input_path = os.path.join(source_dir, input_filename)
    284 
    285           self.HandleResult(input_filename, input_path, result)
    286 
    287       except KeyboardInterrupt:
    288         pool.terminate()
    289       finally:
    290         pool.close()
    291         pool.join()
    292     else:
    293       for test_case in self.test_cases:
    294         input_filename, input_file_dir = test_case
    295         result = self.GenerateAndTest(input_filename, input_file_dir)
    296         self.HandleResult(input_filename,
    297                           os.path.join(input_file_dir, input_filename), result)
    298 
    299     if self.gold_results:
    300       self.gold_results.WriteResults()
    301 
    302     if self.surprises:
    303       self.surprises.sort()
    304       print '\n\nUnexpected Successes:'
    305       for surprise in self.surprises:
    306         print surprise
    307 
    308     if self.failures:
    309       self.failures.sort()
    310       print '\n\nSummary of Failures:'
    311       for failure in self.failures:
    312         print failure
    313 
    314     self._PrintSummary()
    315 
    316     if self.failures:
    317       if not self.options.ignore_errors:
    318         return 1
    319 
    320     return 0
    321 
    322   def _PrintSummary(self):
    323     number_test_cases = len(self.test_cases)
    324     number_failures = len(self.failures)
    325     number_suppressed = len(self.result_suppressed_cases)
    326     number_successes = number_test_cases - number_failures - number_suppressed
    327     number_surprises = len(self.surprises)
    328     print
    329     print 'Test cases executed: %d' % number_test_cases
    330     print '  Successes: %d' % number_successes
    331     print '  Suppressed: %d' % number_suppressed
    332     print '    Surprises: %d' % number_surprises
    333     print '  Failures: %d' % number_failures
    334     print
    335     print 'Test cases not executed: %d' % len(self.execution_suppressed_cases)
    336 
    337   def SetEnforceExpectedImages(self, new_value):
    338     """Set whether to enforce that each test case provide an expected image."""
    339     self.enforce_expected_images = new_value
    340 
    341   def SetOneShotRenderer(self, new_value):
    342     """Set whether to use the oneshot renderer. """
    343     self.oneshot_renderer = new_value
    344