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 self.per_process_config.test_type not 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 default_renderer: str = None 439 rendering_option: str = None 440 441 def NewFinder(self): 442 return common.DirectoryFinder(self.options.build_dir) 443 444 def GetPdfiumTestPath(self, finder): 445 return finder.ExecutablePath('pdfium_test') 446 447 def InitializeFeatures(self, pdfium_test_path): 448 output = subprocess.check_output([pdfium_test_path, '--show-config'], 449 timeout=TEST_TIMEOUT) 450 self.features = set(output.decode('utf-8').strip().split(',')) 451 452 if 'SKIA' in self.features: 453 self.default_renderer = 'skia' 454 else: 455 self.default_renderer = 'agg' 456 self.rendering_option = self.default_renderer 457 458 if self.options.use_renderer == 'agg': 459 self.rendering_option = 'agg' 460 elif self.options.use_renderer == 'gdi': 461 if 'GDI' not in self.features: 462 return 'pdfium_test missing GDI renderer support' 463 self.rendering_option = 'gdi' 464 elif self.options.use_renderer == 'skia': 465 if 'SKIA' not in self.features: 466 return 'pdfium_test missing Skia renderer support' 467 self.rendering_option = 'skia' 468 469 return None 470 471 472class _PerProcessState: 473 """State defined per process.""" 474 475 def __init__(self, config): 476 self.test_dir = config.test_dir 477 self.test_type = config.test_type 478 self.delete_output_on_success = config.delete_output_on_success 479 self.enforce_expected_images = config.enforce_expected_images 480 self.options = config.options 481 self.features = config.features 482 483 finder = config.NewFinder() 484 self.pdfium_test_path = config.GetPdfiumTestPath(finder) 485 self.fixup_path = finder.ScriptPath('fixup_pdf_template.py') 486 self.text_diff_path = finder.ScriptPath('text_diff.py') 487 self.font_dir = os.path.join(finder.TestingDir(), 'resources', 'fonts') 488 self.third_party_font_dir = finder.ThirdPartyFontsDir() 489 490 self.source_dir = finder.TestingDir() 491 self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir)) 492 493 self.test_suppressor = suppressor.Suppressor( 494 finder, self.features, self.options.disable_javascript, 495 self.options.disable_xfa, config.rendering_option) 496 self.image_differ = pngdiffer.PNGDiffer(finder, 497 self.options.reverse_byte_order, 498 config.rendering_option, 499 config.default_renderer) 500 501 self.process_name = multiprocessing.current_process().name 502 self.skia_tester = None 503 504 def __getstate__(self): 505 raise RuntimeError('Cannot pickle per-process state') 506 507 def GetSkiaGoldTester(self): 508 """Gets the `SkiaGoldTester` singleton for this worker.""" 509 if not self.skia_tester: 510 self.skia_tester = skia_gold.SkiaGoldTester( 511 source_type=self.test_type, 512 skia_gold_args=self.options, 513 process_name=self.process_name) 514 return self.skia_tester 515 516 517class _TestCaseRunner: 518 """Runner for a single test case.""" 519 520 def __init__(self, test_case): 521 self.test_case = test_case 522 self.test_result = None 523 self.duration_start = 0 524 525 self.source_dir, self.input_filename = os.path.split( 526 self.test_case.input_path) 527 self.pdf_path = os.path.join(self.working_dir, f'{self.test_id}.pdf') 528 self.actual_images = None 529 530 def __enter__(self): 531 self.duration_start = time.perf_counter_ns() 532 return self 533 534 def __exit__(self, exc_type, exc_value, traceback): 535 if not self.test_result: 536 self.test_result = self.test_case.NewResult( 537 result_types.UNKNOWN, reason='No test result recorded') 538 duration = time.perf_counter_ns() - self.duration_start 539 self.test_result.duration_milliseconds = duration * 1e-6 540 541 @property 542 def options(self): 543 return _per_process_state.options 544 545 @property 546 def test_id(self): 547 return self.test_case.test_id 548 549 @property 550 def working_dir(self): 551 return _per_process_state.working_dir 552 553 def IsResultSuppressed(self): 554 return _per_process_state.test_suppressor.IsResultSuppressed( 555 self.input_filename) 556 557 def IsImageDiffSuppressed(self): 558 return _per_process_state.test_suppressor.IsImageDiffSuppressed( 559 self.input_filename) 560 561 def GetImageMatchingAlgorithm(self): 562 return _per_process_state.test_suppressor.GetImageMatchingAlgorithm( 563 self.input_filename) 564 565 def RunCommand(self, command, stdout=None): 566 """Runs a test command. 567 568 Args: 569 command: The list of command arguments. 570 stdout: Optional `file`-like object to send standard output. 571 572 Returns: 573 The test result. 574 """ 575 576 # Standard output and error are directed to the test log. If `stdout` was 577 # provided, redirect standard output to it instead. 578 if stdout: 579 assert stdout != subprocess.PIPE 580 try: 581 stdout.fileno() 582 except OSError: 583 # `stdout` doesn't have a file descriptor, so it can't be passed to 584 # `subprocess.run()` directly. 585 original_stdout = stdout 586 stdout = subprocess.PIPE 587 stderr = subprocess.PIPE 588 else: 589 stdout = subprocess.PIPE 590 stderr = subprocess.STDOUT 591 592 test_result = self.test_case.NewResult(result_types.PASS) 593 try: 594 run_result = subprocess.run( 595 command, 596 stdout=stdout, 597 stderr=stderr, 598 timeout=TEST_TIMEOUT, 599 check=False) 600 if run_result.returncode != 0: 601 test_result.status = result_types.FAIL 602 test_result.reason = 'Command {} exited with code {}'.format( 603 run_result.args, run_result.returncode) 604 except subprocess.TimeoutExpired as timeout_expired: 605 run_result = timeout_expired 606 test_result.status = result_types.TIMEOUT 607 test_result.reason = 'Command {} timed out'.format(run_result.cmd) 608 609 if stdout == subprocess.PIPE and stderr == subprocess.PIPE: 610 # Copy captured standard output, if any, to the original `stdout`. 611 if run_result.stdout: 612 original_stdout.write(run_result.stdout) 613 614 if not test_result.IsPass(): 615 # On failure, report captured output to the test log. 616 if stderr == subprocess.STDOUT: 617 test_result.log = run_result.stdout 618 else: 619 test_result.log = run_result.stderr 620 test_result.log = test_result.log.decode(errors='backslashreplace') 621 return test_result 622 623 def GenerateAndTest(self, test_function): 624 """Generate test input and run pdfium_test.""" 625 test_result = self.Generate() 626 if not test_result.IsPass(): 627 return test_result 628 629 return test_function() 630 631 def _RegenerateIfNeeded(self): 632 if not self.options.regenerate_expected: 633 return 634 if self.IsResultSuppressed() or self.IsImageDiffSuppressed(): 635 return 636 _per_process_state.image_differ.Regenerate( 637 self.input_filename, 638 self.source_dir, 639 self.working_dir, 640 image_matching_algorithm=self.GetImageMatchingAlgorithm()) 641 642 def Generate(self): 643 input_event_path = os.path.join(self.source_dir, f'{self.test_id}.evt') 644 if os.path.exists(input_event_path): 645 output_event_path = f'{os.path.splitext(self.pdf_path)[0]}.evt' 646 shutil.copyfile(input_event_path, output_event_path) 647 648 template_path = os.path.join(self.source_dir, f'{self.test_id}.in') 649 if not os.path.exists(template_path): 650 if os.path.exists(self.test_case.input_path): 651 shutil.copyfile(self.test_case.input_path, self.pdf_path) 652 return self.test_case.NewResult(result_types.PASS) 653 654 return self.RunCommand([ 655 sys.executable, _per_process_state.fixup_path, 656 f'--output-dir={self.working_dir}', template_path 657 ]) 658 659 def TestText(self): 660 txt_path = os.path.join(self.working_dir, f'{self.test_id}.txt') 661 with open(txt_path, 'w') as outfile: 662 cmd_to_run = [ 663 _per_process_state.pdfium_test_path, '--send-events', 664 f'--time={TEST_SEED_TIME}' 665 ] 666 667 if self.options.disable_javascript: 668 cmd_to_run.append('--disable-javascript') 669 670 if self.options.disable_xfa: 671 cmd_to_run.append('--disable-xfa') 672 673 cmd_to_run.append(self.pdf_path) 674 test_result = self.RunCommand(cmd_to_run, stdout=outfile) 675 if not test_result.IsPass(): 676 return test_result 677 678 # If the expected file does not exist, the output is expected to be empty. 679 expected_txt_path = os.path.join(self.source_dir, 680 f'{self.test_id}_expected.txt') 681 if not os.path.exists(expected_txt_path): 682 return self._VerifyEmptyText(txt_path) 683 684 # If JavaScript is disabled, the output should be empty. 685 # However, if the test is suppressed and JavaScript is disabled, do not 686 # verify that the text is empty so the suppressed test does not surprise. 687 if self.options.disable_javascript and not self.IsResultSuppressed(): 688 return self._VerifyEmptyText(txt_path) 689 690 return self.RunCommand([ 691 sys.executable, _per_process_state.text_diff_path, expected_txt_path, 692 txt_path 693 ]) 694 695 def _VerifyEmptyText(self, txt_path): 696 with open(txt_path, 'rb') as txt_file: 697 txt_data = txt_file.read() 698 699 if txt_data: 700 return self.test_case.NewResult( 701 result_types.FAIL, 702 log=txt_data.decode(errors='backslashreplace'), 703 reason=f'{txt_path} should be empty') 704 705 return self.test_case.NewResult(result_types.PASS) 706 707 # TODO(crbug.com/pdfium/1656): Remove when ready to fully switch over to 708 # Skia Gold 709 def TestPixel(self): 710 # Remove any existing generated images from previous runs. 711 self.actual_images = _per_process_state.image_differ.GetActualFiles( 712 self.input_filename, self.source_dir, self.working_dir) 713 self._CleanupPixelTest() 714 715 # Generate images. 716 cmd_to_run = [ 717 _per_process_state.pdfium_test_path, '--send-events', '--png', '--md5', 718 f'--time={TEST_SEED_TIME}' 719 ] 720 721 if 'use_ahem' in self.source_dir: 722 font_path = os.path.join(_per_process_state.font_dir, 'ahem') 723 cmd_to_run.append(f'--font-dir={font_path}') 724 elif 'use_symbolneu' in self.source_dir: 725 font_path = os.path.join(_per_process_state.font_dir, 'symbolneu') 726 cmd_to_run.append(f'--font-dir={font_path}') 727 else: 728 cmd_to_run.append(f'--font-dir={_per_process_state.third_party_font_dir}') 729 cmd_to_run.append('--croscore-font-names') 730 731 if self.options.disable_javascript: 732 cmd_to_run.append('--disable-javascript') 733 734 if self.options.disable_xfa: 735 cmd_to_run.append('--disable-xfa') 736 737 if self.options.render_oneshot: 738 cmd_to_run.append('--render-oneshot') 739 740 if self.options.reverse_byte_order: 741 cmd_to_run.append('--reverse-byte-order') 742 743 if self.options.use_renderer: 744 cmd_to_run.append(f'--use-renderer={self.options.use_renderer}') 745 746 cmd_to_run.append(self.pdf_path) 747 748 with BytesIO() as command_output: 749 test_result = self.RunCommand(cmd_to_run, stdout=command_output) 750 if not test_result.IsPass(): 751 return test_result 752 753 test_result.image_artifacts = [] 754 for line in command_output.getvalue().splitlines(): 755 # Expect this format: MD5:<path to image file>:<hexadecimal MD5 hash> 756 line = bytes.decode(line).strip() 757 if line.startswith('MD5:'): 758 image_path, md5_hash = line[4:].rsplit(':', 1) 759 test_result.image_artifacts.append( 760 self._NewImageArtifact( 761 image_path=image_path.strip(), md5_hash=md5_hash.strip())) 762 763 if self.actual_images: 764 image_diffs = _per_process_state.image_differ.ComputeDifferences( 765 self.input_filename, 766 self.source_dir, 767 self.working_dir, 768 image_matching_algorithm=self.GetImageMatchingAlgorithm()) 769 if image_diffs: 770 test_result.status = result_types.FAIL 771 test_result.reason = 'Images differ' 772 773 # Merge image diffs into test result. 774 diff_map = {} 775 diff_log = [] 776 for diff in image_diffs: 777 diff_map[diff.actual_path] = diff 778 diff_log.append(f'{os.path.basename(diff.actual_path)} vs. ') 779 if diff.expected_path: 780 diff_log.append(f'{os.path.basename(diff.expected_path)}\n') 781 else: 782 diff_log.append('missing expected file\n') 783 784 for artifact in test_result.image_artifacts: 785 artifact.image_diff = diff_map.get(artifact.image_path) 786 test_result.log = ''.join(diff_log) 787 788 elif _per_process_state.enforce_expected_images: 789 if not self.IsImageDiffSuppressed(): 790 test_result.status = result_types.FAIL 791 test_result.reason = 'Missing expected images' 792 793 if not test_result.IsPass(): 794 self._RegenerateIfNeeded() 795 return test_result 796 797 if _per_process_state.delete_output_on_success: 798 self._CleanupPixelTest() 799 return test_result 800 801 def _NewImageArtifact(self, *, image_path, md5_hash): 802 artifact = ImageArtifact(image_path=image_path, md5_hash=md5_hash) 803 804 if self.options.run_skia_gold: 805 if _per_process_state.GetSkiaGoldTester().UploadTestResultToSkiaGold( 806 artifact.GetSkiaGoldId(), artifact.image_path): 807 artifact.skia_gold_status = result_types.PASS 808 else: 809 artifact.skia_gold_status = result_types.FAIL 810 811 return artifact 812 813 def _CleanupPixelTest(self): 814 for image_file in self.actual_images: 815 if os.path.exists(image_file): 816 os.remove(image_file) 817 818 819@dataclass 820class TestCase: 821 """Description of a test case to run. 822 823 Attributes: 824 test_id: A unique identifier for the test. 825 input_path: The absolute path to the test file. 826 """ 827 test_id: str 828 input_path: str 829 830 def NewResult(self, status, **kwargs): 831 """Derives a new test result corresponding to this test case.""" 832 return TestResult(test_id=self.test_id, status=status, **kwargs) 833 834 835@dataclass 836class TestResult: 837 """Results from running a test case. 838 839 Attributes: 840 test_id: The corresponding test case ID. 841 status: The overall `result_types` status. 842 duration_milliseconds: Test time in milliseconds. 843 log: Optional log of the test's output. 844 image_artfacts: Optional list of image artifacts. 845 reason: Optional reason why the test failed. 846 """ 847 test_id: str 848 status: str 849 duration_milliseconds: float = None 850 log: str = None 851 image_artifacts: list = field(default_factory=list) 852 reason: str = None 853 854 def IsPass(self): 855 """Whether the test passed.""" 856 return self.status == result_types.PASS 857 858 859@dataclass 860class ImageArtifact: 861 """Image artifact for a test result. 862 863 Attributes: 864 image_path: The absolute path to the image file. 865 md5_hash: The MD5 hash of the pixel buffer. 866 skia_gold_status: Optional Skia Gold status. 867 image_diff: Optional image diff. 868 """ 869 image_path: str 870 md5_hash: str 871 skia_gold_status: str = None 872 image_diff: pngdiffer.ImageDiff = None 873 874 def GetSkiaGoldId(self): 875 # The output filename without image extension becomes the test ID. For 876 # example, "/path/to/.../testing/corpus/example_005.pdf.0.png" becomes 877 # "example_005.pdf.0". 878 return _GetTestId(os.path.basename(self.image_path)) 879 880 def GetDiffStatus(self): 881 return result_types.FAIL if self.image_diff else result_types.PASS 882 883 def GetDiffReason(self): 884 return self.image_diff.reason if self.image_diff else None 885 886 def GetDiffArtifacts(self): 887 if not self.image_diff: 888 return None 889 if not self.image_diff.expected_path or not self.image_diff.diff_path: 890 return None 891 return { 892 'actual_image': 893 _GetArtifactFromFilePath(self.image_path), 894 'expected_image': 895 _GetArtifactFromFilePath(self.image_diff.expected_path), 896 'image_diff': 897 _GetArtifactFromFilePath(self.image_diff.diff_path) 898 } 899 900 901class TestCaseManager: 902 """Manages a collection of test cases.""" 903 904 def __init__(self): 905 self.test_cases = {} 906 907 def __len__(self): 908 return len(self.test_cases) 909 910 def __iter__(self): 911 return iter(self.test_cases.values()) 912 913 def NewTestCase(self, input_path, **kwargs): 914 """Creates and registers a new test case.""" 915 input_basename = os.path.basename(input_path) 916 test_id = _GetTestId(input_basename) 917 if test_id in self.test_cases: 918 raise ValueError( 919 f'Test ID "{test_id}" derived from "{input_basename}" must be unique') 920 921 test_case = TestCase(test_id=test_id, input_path=input_path, **kwargs) 922 self.test_cases[test_id] = test_case 923 return test_case 924 925 def GetTestCase(self, test_id): 926 """Looks up a test case previously registered by `NewTestCase()`.""" 927 return self.test_cases[test_id] 928 929 930def _GetTestId(input_basename): 931 """Constructs a test ID by stripping the last extension from the basename.""" 932 return os.path.splitext(input_basename)[0] 933 934 935def _GetArtifactFromFilePath(file_path): 936 """Constructs a ResultSink artifact from a file path.""" 937 return {'filePath': file_path} 938