xref: /aosp_15_r20/external/pdfium/testing/tools/test_runner.py (revision 3ac0a46f773bac49fa9476ec2b1cf3f8da5ec3a4)
1#!/usr/bin/env python3
2# Copyright 2016 The PDFium Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import argparse
7from dataclasses import dataclass, field
8from datetime import timedelta
9from io import BytesIO
10import multiprocessing
11import os
12import re
13import shutil
14import subprocess
15import sys
16import time
17
18import common
19import pdfium_root
20import pngdiffer
21from skia_gold import skia_gold
22import suppressor
23
24pdfium_root.add_source_directory_to_import_path(os.path.join('build', 'util'))
25from lib.results import result_sink, result_types
26
27
28# Arbitrary timestamp, expressed in seconds since the epoch, used to make sure
29# that tests that depend on the current time are stable. Happens to be the
30# timestamp of the first commit to repo, 2014/5/9 17:48:50.
31TEST_SEED_TIME = "1399672130"
32
33# List of test types that should run text tests instead of pixel tests.
34TEXT_TESTS = ['javascript']
35
36# Timeout (in seconds) for individual test commands.
37# TODO(crbug.com/pdfium/1967): array_buffer.in is slow under MSan, so need a
38# very generous 5 minute timeout for now.
39TEST_TIMEOUT = timedelta(minutes=5).total_seconds()
40
41
42class TestRunner:
43
44  def __init__(self, dirname):
45    # Currently the only used directories are corpus, javascript, and pixel,
46    # which all correspond directly to the type for the test being run. In the
47    # future if there are tests that don't have this clean correspondence, then
48    # an argument for the type will need to be added.
49    self.per_process_config = _PerProcessConfig(
50        test_dir=dirname, test_type=dirname)
51
52  @property
53  def options(self):
54    return self.per_process_config.options
55
56  def IsSkiaGoldEnabled(self):
57    return (self.options.run_skia_gold and
58            not self.per_process_config.test_type in TEXT_TESTS)
59
60  def IsExecutionSuppressed(self, input_path):
61    return self.per_process_state.test_suppressor.IsExecutionSuppressed(
62        input_path)
63
64  def IsResultSuppressed(self, input_filename):
65    return self.per_process_state.test_suppressor.IsResultSuppressed(
66        input_filename)
67
68  def HandleResult(self, test_case, test_result):
69    input_filename = os.path.basename(test_case.input_path)
70
71    test_result.status = self._SuppressStatus(input_filename,
72                                              test_result.status)
73    if test_result.status == result_types.UNKNOWN:
74      self.result_suppressed_cases.append(input_filename)
75      self.surprises.append(test_case.input_path)
76    elif test_result.status == result_types.SKIP:
77      self.result_suppressed_cases.append(input_filename)
78    elif not test_result.IsPass():
79      self.failures.append(test_case.input_path)
80
81    for artifact in test_result.image_artifacts:
82      if artifact.skia_gold_status == result_types.PASS:
83        if self.IsResultSuppressed(artifact.image_path):
84          self.skia_gold_unexpected_successes.append(artifact.GetSkiaGoldId())
85        else:
86          self.skia_gold_successes.append(artifact.GetSkiaGoldId())
87      elif artifact.skia_gold_status == result_types.FAIL:
88        self.skia_gold_failures.append(artifact.GetSkiaGoldId())
89
90    # Log test result.
91    print(f'{test_result.status}: {test_result.test_id}')
92    if not test_result.IsPass():
93      if test_result.reason:
94        print(f'Failure reason: {test_result.reason}')
95      if test_result.log:
96        print(f'Test output:\n{test_result.log}')
97      for artifact in test_result.image_artifacts:
98        if artifact.skia_gold_status == result_types.FAIL:
99          print(f'Failed Skia Gold: {artifact.image_path}')
100        if artifact.image_diff:
101          print(f'Failed image diff: {artifact.image_diff.reason}')
102
103    # Report test result to ResultDB.
104    if self.resultdb:
105      only_artifacts = None
106      only_failure_reason = test_result.reason
107      if len(test_result.image_artifacts) == 1:
108        only = test_result.image_artifacts[0]
109        only_artifacts = only.GetDiffArtifacts()
110        if only.GetDiffReason():
111          only_failure_reason += f': {only.GetDiffReason()}'
112      self.resultdb.Post(
113          test_id=test_result.test_id,
114          status=test_result.status,
115          duration=test_result.duration_milliseconds,
116          test_log=test_result.log,
117          test_file=None,
118          artifacts=only_artifacts,
119          failure_reason=only_failure_reason)
120
121      # Milo only supports a single diff per test, so if we have multiple pages,
122      # report each page as its own "test."
123      if len(test_result.image_artifacts) > 1:
124        for page, artifact in enumerate(test_result.image_artifacts):
125          self.resultdb.Post(
126              test_id=f'{test_result.test_id}/{page}',
127              status=self._SuppressArtifactStatus(test_result,
128                                                  artifact.GetDiffStatus()),
129              duration=None,
130              test_log=None,
131              test_file=None,
132              artifacts=artifact.GetDiffArtifacts(),
133              failure_reason=artifact.GetDiffReason())
134
135  def _SuppressStatus(self, input_filename, status):
136    if not self.IsResultSuppressed(input_filename):
137      return status
138
139    if status == result_types.PASS:
140      # There isn't an actual status for succeeded-but-ignored, so use the
141      # "abort" status to differentiate this from failed-but-ignored.
142      #
143      # Note that this appears as a preliminary failure in Gerrit.
144      return result_types.UNKNOWN
145
146    # There isn't an actual status for failed-but-ignored, so use the "skip"
147    # status to differentiate this from succeeded-but-ignored.
148    return result_types.SKIP
149
150  def _SuppressArtifactStatus(self, test_result, status):
151    if status != result_types.FAIL:
152      return status
153
154    if test_result.status != result_types.SKIP:
155      return status
156
157    return result_types.SKIP
158
159  def Run(self):
160    # Running a test defines a number of attributes on the fly.
161    # pylint: disable=attribute-defined-outside-init
162
163    relative_test_dir = self.per_process_config.test_dir
164    if relative_test_dir != 'corpus':
165      relative_test_dir = os.path.join('resources', relative_test_dir)
166
167    parser = argparse.ArgumentParser()
168
169    parser.add_argument(
170        '--build-dir',
171        default=os.path.join('out', 'Debug'),
172        help='relative path from the base source directory')
173
174    parser.add_argument(
175        '-j',
176        default=multiprocessing.cpu_count(),
177        dest='num_workers',
178        type=int,
179        help='run NUM_WORKERS jobs in parallel')
180
181    parser.add_argument(
182        '--disable-javascript',
183        action='store_true',
184        help='Prevents JavaScript from executing in PDF files.')
185
186    parser.add_argument(
187        '--disable-xfa',
188        action='store_true',
189        help='Prevents processing XFA forms.')
190
191    parser.add_argument(
192        '--render-oneshot',
193        action='store_true',
194        help='Sets whether to use the oneshot renderer.')
195
196    parser.add_argument(
197        '--run-skia-gold',
198        action='store_true',
199        default=False,
200        help='When flag is on, skia gold tests will be run.')
201
202    parser.add_argument(
203        '--regenerate_expected',
204        action='store_true',
205        help='Regenerates expected images. For each failing image diff, this '
206        'will regenerate the most specific expected image file that exists. '
207        'This also will suggest removals of unnecessary expected image files '
208        'by renaming them with an additional ".bak" extension, although these '
209        'removals should be reviewed manually. Use "git clean" to quickly deal '
210        'with any ".bak" files.')
211
212    parser.add_argument(
213        '--reverse-byte-order',
214        action='store_true',
215        help='Run image-based tests using --reverse-byte-order.')
216
217    parser.add_argument(
218        '--ignore_errors',
219        action='store_true',
220        help='Prevents the return value from being non-zero '
221        'when image comparison fails.')
222
223    parser.add_argument(
224        '--use-renderer',
225        choices=('agg', 'gdi', 'skia'),
226        help='Forces the renderer to use.')
227
228    parser.add_argument(
229        'inputted_file_paths',
230        nargs='*',
231        help='Path to test files to run, relative to '
232        f'testing/{relative_test_dir}. If omitted, runs all test files under '
233        f'testing/{relative_test_dir}.',
234        metavar='relative/test/path')
235
236    skia_gold.add_skia_gold_args(parser)
237
238    self.per_process_config.options = parser.parse_args()
239
240    finder = self.per_process_config.NewFinder()
241    pdfium_test_path = self.per_process_config.GetPdfiumTestPath(finder)
242    if not os.path.exists(pdfium_test_path):
243      print(f"FAILURE: Can't find test executable '{pdfium_test_path}'")
244      print('Use --build-dir to specify its location.')
245      return 1
246
247    error_message = self.per_process_config.InitializeFeatures(pdfium_test_path)
248    if error_message:
249      print('FAILURE:', error_message)
250      return 1
251
252    self.per_process_state = _PerProcessState(self.per_process_config)
253    shutil.rmtree(self.per_process_state.working_dir, ignore_errors=True)
254    os.makedirs(self.per_process_state.working_dir)
255
256    error_message = self.per_process_state.image_differ.CheckMissingTools(
257        self.options.regenerate_expected)
258    if error_message:
259      print('FAILURE:', error_message)
260      return 1
261
262    self.resultdb = result_sink.TryInitClient()
263    if self.resultdb:
264      print('Detected ResultSink environment')
265
266    # Collect test cases.
267    walk_from_dir = finder.TestingDir(relative_test_dir)
268
269    self.test_cases = TestCaseManager()
270    self.execution_suppressed_cases = []
271    input_file_re = re.compile('^.+[.](in|pdf)$')
272    if self.options.inputted_file_paths:
273      for file_name in self.options.inputted_file_paths:
274        input_path = os.path.join(walk_from_dir, file_name)
275        if not os.path.isfile(input_path):
276          print(f"Can't find test file '{file_name}'")
277          return 1
278
279        self.test_cases.NewTestCase(input_path)
280    else:
281      for file_dir, _, filename_list in os.walk(walk_from_dir):
282        for input_filename in filename_list:
283          if input_file_re.match(input_filename):
284            input_path = os.path.join(file_dir, input_filename)
285            if self.IsExecutionSuppressed(input_path):
286              self.execution_suppressed_cases.append(input_path)
287              continue
288            if not os.path.isfile(input_path):
289              continue
290
291            self.test_cases.NewTestCase(input_path)
292
293    # Execute test cases.
294    self.failures = []
295    self.surprises = []
296    self.skia_gold_successes = []
297    self.skia_gold_unexpected_successes = []
298    self.skia_gold_failures = []
299    self.result_suppressed_cases = []
300
301    if self.IsSkiaGoldEnabled():
302      assert self.options.gold_output_dir
303      # Clear out and create top level gold output directory before starting
304      skia_gold.clear_gold_output_dir(self.options.gold_output_dir)
305
306    with multiprocessing.Pool(
307        processes=self.options.num_workers,
308        initializer=_InitializePerProcessState,
309        initargs=[self.per_process_config]) as pool:
310      if self.per_process_config.test_type in TEXT_TESTS:
311        test_function = _RunTextTest
312      else:
313        test_function = _RunPixelTest
314      for result in pool.imap(test_function, self.test_cases):
315        self.HandleResult(self.test_cases.GetTestCase(result.test_id), result)
316
317    # Report test results.
318    if self.surprises:
319      self.surprises.sort()
320      print('\nUnexpected Successes:')
321      for surprise in self.surprises:
322        print(surprise)
323
324    if self.failures:
325      self.failures.sort()
326      print('\nSummary of Failures:')
327      for failure in self.failures:
328        print(failure)
329
330    if self.skia_gold_unexpected_successes:
331      self.skia_gold_unexpected_successes.sort()
332      print('\nUnexpected Skia Gold Successes:')
333      for surprise in self.skia_gold_unexpected_successes:
334        print(surprise)
335
336    if self.skia_gold_failures:
337      self.skia_gold_failures.sort()
338      print('\nSummary of Skia Gold Failures:')
339      for failure in self.skia_gold_failures:
340        print(failure)
341
342    self._PrintSummary()
343
344    if self.failures:
345      if not self.options.ignore_errors:
346        return 1
347
348    return 0
349
350  def _PrintSummary(self):
351    number_test_cases = len(self.test_cases)
352    number_failures = len(self.failures)
353    number_suppressed = len(self.result_suppressed_cases)
354    number_successes = number_test_cases - number_failures - number_suppressed
355    number_surprises = len(self.surprises)
356    print('\nTest cases executed:', number_test_cases)
357    print('  Successes:', number_successes)
358    print('  Suppressed:', number_suppressed)
359    print('  Surprises:', number_surprises)
360    print('  Failures:', number_failures)
361    if self.IsSkiaGoldEnabled():
362      number_gold_failures = len(self.skia_gold_failures)
363      number_gold_successes = len(self.skia_gold_successes)
364      number_gold_surprises = len(self.skia_gold_unexpected_successes)
365      number_total_gold_tests = sum(
366          [number_gold_failures, number_gold_successes, number_gold_surprises])
367      print('\nSkia Gold Test cases executed:', number_total_gold_tests)
368      print('  Skia Gold Successes:', number_gold_successes)
369      print('  Skia Gold Surprises:', number_gold_surprises)
370      print('  Skia Gold Failures:', number_gold_failures)
371      skia_tester = self.per_process_state.GetSkiaGoldTester()
372      if self.skia_gold_failures and skia_tester.IsTryjobRun():
373        cl_triage_link = skia_tester.GetCLTriageLink()
374        print('  Triage link for CL:', cl_triage_link)
375        skia_tester.WriteCLTriageLink(cl_triage_link)
376    print()
377    print('Test cases not executed:', len(self.execution_suppressed_cases))
378
379  def SetDeleteOutputOnSuccess(self, new_value):
380    """Set whether to delete generated output if the test passes."""
381    self.per_process_config.delete_output_on_success = new_value
382
383  def SetEnforceExpectedImages(self, new_value):
384    """Set whether to enforce that each test case provide an expected image."""
385    self.per_process_config.enforce_expected_images = new_value
386
387
388def _RunTextTest(test_case):
389  """Runs a text test case."""
390  test_case_runner = _TestCaseRunner(test_case)
391  with test_case_runner:
392    test_case_runner.test_result = test_case_runner.GenerateAndTest(
393        test_case_runner.TestText)
394  return test_case_runner.test_result
395
396
397def _RunPixelTest(test_case):
398  """Runs a pixel test case."""
399  test_case_runner = _TestCaseRunner(test_case)
400  with test_case_runner:
401    test_case_runner.test_result = test_case_runner.GenerateAndTest(
402        test_case_runner.TestPixel)
403  return test_case_runner.test_result
404
405
406# `_PerProcessState` singleton. This is initialized when creating the
407# `multiprocessing.Pool()`. `TestRunner.Run()` creates its own separate
408# instance of `_PerProcessState` as well.
409_per_process_state = None
410
411
412def _InitializePerProcessState(config):
413  """Initializes the `_per_process_state` singleton."""
414  global _per_process_state
415  assert not _per_process_state
416  _per_process_state = _PerProcessState(config)
417
418
419@dataclass
420class _PerProcessConfig:
421  """Configuration for initializing `_PerProcessState`.
422
423  Attributes:
424    test_dir: The name of the test directory.
425    test_type: The test type.
426    delete_output_on_success: Whether to delete output on success.
427    enforce_expected_images: Whether to enforce expected images.
428    options: The dictionary of command line options.
429    features: The set of features supported by `pdfium_test`.
430    rendering_option: The renderer to use (agg, gdi, or skia).
431  """
432  test_dir: str
433  test_type: str
434  delete_output_on_success: bool = False
435  enforce_expected_images: bool = False
436  options: dict = None
437  features: set = None
438  rendering_option: str = None
439
440  def NewFinder(self):
441    return common.DirectoryFinder(self.options.build_dir)
442
443  def GetPdfiumTestPath(self, finder):
444    return finder.ExecutablePath('pdfium_test')
445
446  def InitializeFeatures(self, pdfium_test_path):
447    output = subprocess.check_output([pdfium_test_path, '--show-config'],
448                                     timeout=TEST_TIMEOUT)
449    self.features = set(output.decode('utf-8').strip().split(','))
450
451    if 'SKIA' in self.features:
452      self.rendering_option = 'skia'
453    else:
454      self.rendering_option = 'agg'
455
456    if self.options.use_renderer == 'agg':
457      self.rendering_option = 'agg'
458    elif self.options.use_renderer == 'gdi':
459      if 'GDI' not in self.features:
460        return 'pdfium_test does not support the GDI renderer'
461      self.rendering_option = 'gdi'
462    elif self.options.use_renderer == 'skia':
463      if 'SKIA' not in self.features:
464        return 'pdfium_test does not support the Skia renderer'
465      self.rendering_option = 'skia'
466
467    return None
468
469
470class _PerProcessState:
471  """State defined per process."""
472
473  def __init__(self, config):
474    self.test_dir = config.test_dir
475    self.test_type = config.test_type
476    self.delete_output_on_success = config.delete_output_on_success
477    self.enforce_expected_images = config.enforce_expected_images
478    self.options = config.options
479    self.features = config.features
480
481    finder = config.NewFinder()
482    self.pdfium_test_path = config.GetPdfiumTestPath(finder)
483    self.fixup_path = finder.ScriptPath('fixup_pdf_template.py')
484    self.text_diff_path = finder.ScriptPath('text_diff.py')
485    self.font_dir = os.path.join(finder.TestingDir(), 'resources', 'fonts')
486    self.third_party_font_dir = finder.ThirdPartyFontsDir()
487
488    self.source_dir = finder.TestingDir()
489    self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir))
490
491    self.test_suppressor = suppressor.Suppressor(
492        finder, self.features, self.options.disable_javascript,
493        self.options.disable_xfa, config.rendering_option)
494    self.image_differ = pngdiffer.PNGDiffer(finder,
495                                            self.options.reverse_byte_order,
496                                            config.rendering_option)
497
498    self.process_name = multiprocessing.current_process().name
499    self.skia_tester = None
500
501  def __getstate__(self):
502    raise RuntimeError('Cannot pickle per-process state')
503
504  def GetSkiaGoldTester(self):
505    """Gets the `SkiaGoldTester` singleton for this worker."""
506    if not self.skia_tester:
507      self.skia_tester = skia_gold.SkiaGoldTester(
508          source_type=self.test_type,
509          skia_gold_args=self.options,
510          process_name=self.process_name)
511    return self.skia_tester
512
513
514class _TestCaseRunner:
515  """Runner for a single test case."""
516
517  def __init__(self, test_case):
518    self.test_case = test_case
519    self.test_result = None
520    self.duration_start = 0
521
522    self.source_dir, self.input_filename = os.path.split(
523        self.test_case.input_path)
524    self.pdf_path = os.path.join(self.working_dir, f'{self.test_id}.pdf')
525    self.actual_images = None
526
527  def __enter__(self):
528    self.duration_start = time.perf_counter_ns()
529    return self
530
531  def __exit__(self, exc_type, exc_value, traceback):
532    if not self.test_result:
533      self.test_result = self.test_case.NewResult(
534          result_types.UNKNOWN, reason='No test result recorded')
535    duration = time.perf_counter_ns() - self.duration_start
536    self.test_result.duration_milliseconds = duration * 1e-6
537
538  @property
539  def options(self):
540    return _per_process_state.options
541
542  @property
543  def test_id(self):
544    return self.test_case.test_id
545
546  @property
547  def working_dir(self):
548    return _per_process_state.working_dir
549
550  def IsResultSuppressed(self):
551    return _per_process_state.test_suppressor.IsResultSuppressed(
552        self.input_filename)
553
554  def IsImageDiffSuppressed(self):
555    return _per_process_state.test_suppressor.IsImageDiffSuppressed(
556        self.input_filename)
557
558  def GetImageMatchingAlgorithm(self):
559    return _per_process_state.test_suppressor.GetImageMatchingAlgorithm(
560        self.input_filename)
561
562  def RunCommand(self, command, stdout=None):
563    """Runs a test command.
564
565    Args:
566      command: The list of command arguments.
567      stdout: Optional `file`-like object to send standard output.
568
569    Returns:
570      The test result.
571    """
572
573    # Standard output and error are directed to the test log. If `stdout` was
574    # provided, redirect standard output to it instead.
575    if stdout:
576      assert stdout != subprocess.PIPE
577      try:
578        stdout.fileno()
579      except OSError:
580        # `stdout` doesn't have a file descriptor, so it can't be passed to
581        # `subprocess.run()` directly.
582        original_stdout = stdout
583        stdout = subprocess.PIPE
584      stderr = subprocess.PIPE
585    else:
586      stdout = subprocess.PIPE
587      stderr = subprocess.STDOUT
588
589    test_result = self.test_case.NewResult(result_types.PASS)
590    try:
591      run_result = subprocess.run(
592          command,
593          stdout=stdout,
594          stderr=stderr,
595          timeout=TEST_TIMEOUT,
596          check=False)
597      if run_result.returncode != 0:
598        test_result.status = result_types.FAIL
599        test_result.reason = 'Command {} exited with code {}'.format(
600            run_result.args, run_result.returncode)
601    except subprocess.TimeoutExpired as timeout_expired:
602      run_result = timeout_expired
603      test_result.status = result_types.TIMEOUT
604      test_result.reason = 'Command {} timed out'.format(run_result.cmd)
605
606    if stdout == subprocess.PIPE and stderr == subprocess.PIPE:
607      # Copy captured standard output, if any, to the original `stdout`.
608      if run_result.stdout:
609        original_stdout.write(run_result.stdout)
610
611    if not test_result.IsPass():
612      # On failure, report captured output to the test log.
613      if stderr == subprocess.STDOUT:
614        test_result.log = run_result.stdout
615      else:
616        test_result.log = run_result.stderr
617      test_result.log = test_result.log.decode(errors='backslashreplace')
618    return test_result
619
620  def GenerateAndTest(self, test_function):
621    """Generate test input and run pdfium_test."""
622    test_result = self.Generate()
623    if not test_result.IsPass():
624      return test_result
625
626    return test_function()
627
628  def _RegenerateIfNeeded(self):
629    if not self.options.regenerate_expected:
630      return
631    if self.IsResultSuppressed() or self.IsImageDiffSuppressed():
632      return
633    _per_process_state.image_differ.Regenerate(
634        self.input_filename,
635        self.source_dir,
636        self.working_dir,
637        image_matching_algorithm=self.GetImageMatchingAlgorithm())
638
639  def Generate(self):
640    input_event_path = os.path.join(self.source_dir, f'{self.test_id}.evt')
641    if os.path.exists(input_event_path):
642      output_event_path = f'{os.path.splitext(self.pdf_path)[0]}.evt'
643      shutil.copyfile(input_event_path, output_event_path)
644
645    template_path = os.path.join(self.source_dir, f'{self.test_id}.in')
646    if not os.path.exists(template_path):
647      if os.path.exists(self.test_case.input_path):
648        shutil.copyfile(self.test_case.input_path, self.pdf_path)
649      return self.test_case.NewResult(result_types.PASS)
650
651    return self.RunCommand([
652        sys.executable, _per_process_state.fixup_path,
653        f'--output-dir={self.working_dir}', template_path
654    ])
655
656  def TestText(self):
657    txt_path = os.path.join(self.working_dir, f'{self.test_id}.txt')
658    with open(txt_path, 'w') as outfile:
659      cmd_to_run = [
660          _per_process_state.pdfium_test_path, '--send-events',
661          f'--time={TEST_SEED_TIME}'
662      ]
663
664      if self.options.disable_javascript:
665        cmd_to_run.append('--disable-javascript')
666
667      if self.options.disable_xfa:
668        cmd_to_run.append('--disable-xfa')
669
670      cmd_to_run.append(self.pdf_path)
671      test_result = self.RunCommand(cmd_to_run, stdout=outfile)
672      if not test_result.IsPass():
673        return test_result
674
675    # If the expected file does not exist, the output is expected to be empty.
676    expected_txt_path = os.path.join(self.source_dir,
677                                     f'{self.test_id}_expected.txt')
678    if not os.path.exists(expected_txt_path):
679      return self._VerifyEmptyText(txt_path)
680
681    # If JavaScript is disabled, the output should be empty.
682    # However, if the test is suppressed and JavaScript is disabled, do not
683    # verify that the text is empty so the suppressed test does not surprise.
684    if self.options.disable_javascript and not self.IsResultSuppressed():
685      return self._VerifyEmptyText(txt_path)
686
687    return self.RunCommand([
688        sys.executable, _per_process_state.text_diff_path, expected_txt_path,
689        txt_path
690    ])
691
692  def _VerifyEmptyText(self, txt_path):
693    with open(txt_path, "rb") as txt_file:
694      txt_data = txt_file.read()
695
696    if txt_data:
697      return self.test_case.NewResult(
698          result_types.FAIL,
699          log=txt_data.decode(errors='backslashreplace'),
700          reason=f'{txt_path} should be empty')
701
702    return self.test_case.NewResult(result_types.PASS)
703
704  # TODO(crbug.com/pdfium/1656): Remove when ready to fully switch over to
705  # Skia Gold
706  def TestPixel(self):
707    # Remove any existing generated images from previous runs.
708    self.actual_images = _per_process_state.image_differ.GetActualFiles(
709        self.input_filename, self.source_dir, self.working_dir)
710    self._CleanupPixelTest()
711
712    # Generate images.
713    cmd_to_run = [
714        _per_process_state.pdfium_test_path, '--send-events', '--png', '--md5',
715        f'--time={TEST_SEED_TIME}'
716    ]
717
718    if 'use_ahem' in self.source_dir or 'use_symbolneu' in self.source_dir:
719      cmd_to_run.append(f'--font-dir={_per_process_state.font_dir}')
720    else:
721      cmd_to_run.append(f'--font-dir={_per_process_state.third_party_font_dir}')
722      cmd_to_run.append('--croscore-font-names')
723
724    if self.options.disable_javascript:
725      cmd_to_run.append('--disable-javascript')
726
727    if self.options.disable_xfa:
728      cmd_to_run.append('--disable-xfa')
729
730    if self.options.render_oneshot:
731      cmd_to_run.append('--render-oneshot')
732
733    if self.options.reverse_byte_order:
734      cmd_to_run.append('--reverse-byte-order')
735
736    if self.options.use_renderer:
737      cmd_to_run.append(f'--use-renderer={self.options.use_renderer}')
738
739    cmd_to_run.append(self.pdf_path)
740
741    with BytesIO() as command_output:
742      test_result = self.RunCommand(cmd_to_run, stdout=command_output)
743      if not test_result.IsPass():
744        return test_result
745
746      test_result.image_artifacts = []
747      for line in command_output.getvalue().splitlines():
748        # Expect this format: MD5:<path to image file>:<hexadecimal MD5 hash>
749        line = bytes.decode(line).strip()
750        if line.startswith('MD5:'):
751          image_path, md5_hash = line[4:].rsplit(':', 1)
752          test_result.image_artifacts.append(
753              self._NewImageArtifact(
754                  image_path=image_path.strip(), md5_hash=md5_hash.strip()))
755
756    if self.actual_images:
757      image_diffs = _per_process_state.image_differ.ComputeDifferences(
758          self.input_filename,
759          self.source_dir,
760          self.working_dir,
761          image_matching_algorithm=self.GetImageMatchingAlgorithm())
762      if image_diffs:
763        test_result.status = result_types.FAIL
764        test_result.reason = 'Images differ'
765
766        # Merge image diffs into test result.
767        diff_map = {}
768        diff_log = []
769        for diff in image_diffs:
770          diff_map[diff.actual_path] = diff
771          diff_log.append(f'{os.path.basename(diff.actual_path)} vs. ')
772          if diff.expected_path:
773            diff_log.append(f'{os.path.basename(diff.expected_path)}\n')
774          else:
775            diff_log.append('missing expected file\n')
776
777        for artifact in test_result.image_artifacts:
778          artifact.image_diff = diff_map.get(artifact.image_path)
779        test_result.log = ''.join(diff_log)
780
781    elif _per_process_state.enforce_expected_images:
782      if not self.IsImageDiffSuppressed():
783        test_result.status = result_types.FAIL
784        test_result.reason = 'Missing expected images'
785
786    if not test_result.IsPass():
787      self._RegenerateIfNeeded()
788      return test_result
789
790    if _per_process_state.delete_output_on_success:
791      self._CleanupPixelTest()
792    return test_result
793
794  def _NewImageArtifact(self, *, image_path, md5_hash):
795    artifact = ImageArtifact(image_path=image_path, md5_hash=md5_hash)
796
797    if self.options.run_skia_gold:
798      if _per_process_state.GetSkiaGoldTester().UploadTestResultToSkiaGold(
799          artifact.GetSkiaGoldId(), artifact.image_path):
800        artifact.skia_gold_status = result_types.PASS
801      else:
802        artifact.skia_gold_status = result_types.FAIL
803
804    return artifact
805
806  def _CleanupPixelTest(self):
807    for image_file in self.actual_images:
808      if os.path.exists(image_file):
809        os.remove(image_file)
810
811
812@dataclass
813class TestCase:
814  """Description of a test case to run.
815
816  Attributes:
817    test_id: A unique identifier for the test.
818    input_path: The absolute path to the test file.
819  """
820  test_id: str
821  input_path: str
822
823  def NewResult(self, status, **kwargs):
824    """Derives a new test result corresponding to this test case."""
825    return TestResult(test_id=self.test_id, status=status, **kwargs)
826
827
828@dataclass
829class TestResult:
830  """Results from running a test case.
831
832  Attributes:
833    test_id: The corresponding test case ID.
834    status: The overall `result_types` status.
835    duration_milliseconds: Test time in milliseconds.
836    log: Optional log of the test's output.
837    image_artfacts: Optional list of image artifacts.
838    reason: Optional reason why the test failed.
839  """
840  test_id: str
841  status: str
842  duration_milliseconds: float = None
843  log: str = None
844  image_artifacts: list = field(default_factory=list)
845  reason: str = None
846
847  def IsPass(self):
848    """Whether the test passed."""
849    return self.status == result_types.PASS
850
851
852@dataclass
853class ImageArtifact:
854  """Image artifact for a test result.
855
856  Attributes:
857    image_path: The absolute path to the image file.
858    md5_hash: The MD5 hash of the pixel buffer.
859    skia_gold_status: Optional Skia Gold status.
860    image_diff: Optional image diff.
861  """
862  image_path: str
863  md5_hash: str
864  skia_gold_status: str = None
865  image_diff: pngdiffer.ImageDiff = None
866
867  def GetSkiaGoldId(self):
868    # The output filename without image extension becomes the test ID. For
869    # example, "/path/to/.../testing/corpus/example_005.pdf.0.png" becomes
870    # "example_005.pdf.0".
871    return _GetTestId(os.path.basename(self.image_path))
872
873  def GetDiffStatus(self):
874    return result_types.FAIL if self.image_diff else result_types.PASS
875
876  def GetDiffReason(self):
877    return self.image_diff.reason if self.image_diff else None
878
879  def GetDiffArtifacts(self):
880    if not self.image_diff:
881      return None
882    if not self.image_diff.expected_path or not self.image_diff.diff_path:
883      return None
884    return {
885        'actual_image':
886            _GetArtifactFromFilePath(self.image_path),
887        'expected_image':
888            _GetArtifactFromFilePath(self.image_diff.expected_path),
889        'image_diff':
890            _GetArtifactFromFilePath(self.image_diff.diff_path)
891    }
892
893
894class TestCaseManager:
895  """Manages a collection of test cases."""
896
897  def __init__(self):
898    self.test_cases = {}
899
900  def __len__(self):
901    return len(self.test_cases)
902
903  def __iter__(self):
904    return iter(self.test_cases.values())
905
906  def NewTestCase(self, input_path, **kwargs):
907    """Creates and registers a new test case."""
908    input_basename = os.path.basename(input_path)
909    test_id = _GetTestId(input_basename)
910    if test_id in self.test_cases:
911      raise ValueError(
912          f'Test ID "{test_id}" derived from "{input_basename}" must be unique')
913
914    test_case = TestCase(test_id=test_id, input_path=input_path, **kwargs)
915    self.test_cases[test_id] = test_case
916    return test_case
917
918  def GetTestCase(self, test_id):
919    """Looks up a test case previously registered by `NewTestCase()`."""
920    return self.test_cases[test_id]
921
922
923def _GetTestId(input_basename):
924  """Constructs a test ID by stripping the last extension from the basename."""
925  return os.path.splitext(input_basename)[0]
926
927
928def _GetArtifactFromFilePath(file_path):
929  """Constructs a ResultSink artifact from a file path."""
930  return {'filePath': file_path}
931