blob: 7fe2ee95b522328095151503c14fa945a3e46bea [file] [log] [blame]
Lei Zhangc5568a22022-01-08 06:42:01 +00001#!/usr/bin/env python3
K. Moon832a6942022-10-31 20:11:31 +00002# Copyright 2016 The PDFium Authors
dsinclair2a8a20c2016-04-25 09:46:17 -07003# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
Stephanie Kim606d0852021-03-04 16:02:41 +00006import argparse
K. Moon245050a2022-10-27 22:15:22 +00007from dataclasses import dataclass, field
K. Moon355229b2022-12-22 18:15:04 +00008from datetime import timedelta
K. Moonc676ff72022-12-22 18:16:41 +00009from io import BytesIO
dsinclair849284d2016-05-17 06:13:36 -070010import multiprocessing
dsinclair2a8a20c2016-04-25 09:46:17 -070011import os
12import re
dsinclair849284d2016-05-17 06:13:36 -070013import shutil
dsinclair2a8a20c2016-04-25 09:46:17 -070014import subprocess
15import sys
K. Moon60a072f2022-10-19 16:17:50 +000016import time
dsinclair2a8a20c2016-04-25 09:46:17 -070017
18import common
K. Moona997e9b2022-10-12 05:30:33 +000019import pdfium_root
dsinclair2a8a20c2016-04-25 09:46:17 -070020import pngdiffer
Stephanie Kim606d0852021-03-04 16:02:41 +000021from skia_gold import skia_gold
K. Moond1aaef42022-10-14 18:30:47 +000022import suppressor
Stephanie Kim606d0852021-03-04 16:02:41 +000023
K. Moona997e9b2022-10-12 05:30:33 +000024pdfium_root.add_source_directory_to_import_path(os.path.join('build', 'util'))
25from lib.results import result_sink, result_types
26
dsinclair2a8a20c2016-04-25 09:46:17 -070027
Ryan Harrison70cca362018-08-10 18:55:46 +000028# 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
Lei Zhangfe3ab672019-11-19 19:09:40 +000033# List of test types that should run text tests instead of pixel tests.
34TEXT_TESTS = ['javascript']
35
K. Moon355229b2022-12-22 18:15:04 +000036# 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
Lei Zhang30543372019-11-19 19:02:30 +000041
dsinclair2a8a20c2016-04-25 09:46:17 -070042class TestRunner:
Lei Zhang30543372019-11-19 19:02:30 +000043
dsinclair2a8a20c2016-04-25 09:46:17 -070044 def __init__(self, dirname):
Ryan Harrison80302c72018-05-10 18:27:25 +000045 # 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.
K. Moon56c46822022-10-18 22:37:42 +000049 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
K. Moon245050a2022-10-27 22:15:22 +000071 test_result.status = self._SuppressStatus(input_filename,
72 test_result.status)
73 if test_result.status == result_types.UNKNOWN:
K. Moon56c46822022-10-18 22:37:42 +000074 self.result_suppressed_cases.append(input_filename)
K. Moon245050a2022-10-27 22:15:22 +000075 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)
K. Moon56c46822022-10-18 22:37:42 +000080
K. Moon245050a2022-10-27 22:15:22 +000081 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())
K. Moon11d077f2022-10-20 00:46:33 +000089
K. Moon16a4de22022-10-22 01:50:53 +000090 # Log test result.
K. Moon245050a2022-10-27 22:15:22 +000091 print(f'{test_result.status}: {test_result.test_id}')
92 if not test_result.IsPass():
K. Moon16a4de22022-10-22 01:50:53 +000093 if test_result.reason:
94 print(f'Failure reason: {test_result.reason}')
95 if test_result.log:
K. Moon355229b2022-12-22 18:15:04 +000096 decoded_log = bytes.decode(test_result.log, errors='backslashreplace')
97 print(f'Test output:\n{decoded_log}')
K. Moon245050a2022-10-27 22:15:22 +000098 for artifact in test_result.image_artifacts:
99 if artifact.skia_gold_status == result_types.FAIL:
100 print(f'Failed Skia Gold: {artifact.image_path}')
101 if artifact.image_diff:
102 print(f'Failed image diff: {artifact.image_diff.reason}')
K. Moon16a4de22022-10-22 01:50:53 +0000103
104 # Report test result to ResultDB.
K. Moon56c46822022-10-18 22:37:42 +0000105 if self.resultdb:
K. Moon245050a2022-10-27 22:15:22 +0000106 only_artifacts = None
107 if len(test_result.image_artifacts) == 1:
108 only_artifacts = test_result.image_artifacts[0].GetDiffArtifacts()
K. Moon56c46822022-10-18 22:37:42 +0000109 self.resultdb.Post(
110 test_id=test_result.test_id,
K. Moon245050a2022-10-27 22:15:22 +0000111 status=test_result.status,
K. Moon60a072f2022-10-19 16:17:50 +0000112 duration=test_result.duration_milliseconds,
K. Moon16a4de22022-10-22 01:50:53 +0000113 test_log=test_result.log,
114 test_file=None,
K. Moon245050a2022-10-27 22:15:22 +0000115 artifacts=only_artifacts,
K. Moon16a4de22022-10-22 01:50:53 +0000116 failure_reason=test_result.reason)
K. Moon56c46822022-10-18 22:37:42 +0000117
K. Moon245050a2022-10-27 22:15:22 +0000118 # Milo only supports a single diff per test, so if we have multiple pages,
119 # report each page as its own "test."
120 if len(test_result.image_artifacts) > 1:
121 for page, artifact in enumerate(test_result.image_artifacts):
122 self.resultdb.Post(
123 test_id=f'{test_result.test_id}/{page}',
124 status=self._SuppressArtifactStatus(test_result,
125 artifact.GetDiffStatus()),
126 duration=None,
127 test_log=None,
128 test_file=None,
129 artifacts=artifact.GetDiffArtifacts(),
130 failure_reason=artifact.GetDiffReason())
131
132 def _SuppressStatus(self, input_filename, status):
133 if not self.IsResultSuppressed(input_filename):
134 return status
135
136 if status == result_types.PASS:
137 # There isn't an actual status for succeeded-but-ignored, so use the
138 # "abort" status to differentiate this from failed-but-ignored.
139 #
140 # Note that this appears as a preliminary failure in Gerrit.
141 return result_types.UNKNOWN
142
143 # There isn't an actual status for failed-but-ignored, so use the "skip"
144 # status to differentiate this from succeeded-but-ignored.
145 return result_types.SKIP
146
147 def _SuppressArtifactStatus(self, test_result, status):
148 if status != result_types.FAIL:
149 return status
150
151 if test_result.status != result_types.SKIP:
152 return status
153
154 return result_types.SKIP
155
K. Moon56c46822022-10-18 22:37:42 +0000156 def Run(self):
157 # Running a test defines a number of attributes on the fly.
158 # pylint: disable=attribute-defined-outside-init
159
160 relative_test_dir = self.per_process_config.test_dir
161 if relative_test_dir != 'corpus':
162 relative_test_dir = os.path.join('resources', relative_test_dir)
163
164 parser = argparse.ArgumentParser()
165
166 parser.add_argument(
167 '--build-dir',
168 default=os.path.join('out', 'Debug'),
169 help='relative path from the base source directory')
170
171 parser.add_argument(
172 '-j',
173 default=multiprocessing.cpu_count(),
174 dest='num_workers',
175 type=int,
176 help='run NUM_WORKERS jobs in parallel')
177
178 parser.add_argument(
179 '--disable-javascript',
K. Moon28dea142023-02-10 01:24:05 +0000180 action='store_true',
K. Moon56c46822022-10-18 22:37:42 +0000181 help='Prevents JavaScript from executing in PDF files.')
182
183 parser.add_argument(
184 '--disable-xfa',
K. Moon28dea142023-02-10 01:24:05 +0000185 action='store_true',
K. Moon56c46822022-10-18 22:37:42 +0000186 help='Prevents processing XFA forms.')
187
188 parser.add_argument(
189 '--render-oneshot',
K. Moon28dea142023-02-10 01:24:05 +0000190 action='store_true',
K. Moon56c46822022-10-18 22:37:42 +0000191 help='Sets whether to use the oneshot renderer.')
192
193 parser.add_argument(
194 '--run-skia-gold',
195 action='store_true',
196 default=False,
197 help='When flag is on, skia gold tests will be run.')
198
199 # TODO: Remove when pdfium recipe stops passing this argument
200 parser.add_argument(
201 '--gold_properties',
202 default='',
K. Moon28dea142023-02-10 01:24:05 +0000203 help='Key value pairs that are written to the top level of the JSON '
204 'file that is ingested by Gold.')
K. Moon56c46822022-10-18 22:37:42 +0000205
206 # TODO: Remove when pdfium recipe stops passing this argument
207 parser.add_argument(
208 '--gold_ignore_hashes',
209 default='',
K. Moon56c46822022-10-18 22:37:42 +0000210 help='Path to a file with MD5 hashes we wish to ignore.')
211
212 parser.add_argument(
213 '--regenerate_expected',
K. Moon28dea142023-02-10 01:24:05 +0000214 action='store_true',
215 help='Regenerates expected images. For each failing image diff, this '
216 'will regenerate the most specific expected image file that exists.')
K. Moon56c46822022-10-18 22:37:42 +0000217
218 parser.add_argument(
219 '--reverse-byte-order',
220 action='store_true',
K. Moon56c46822022-10-18 22:37:42 +0000221 help='Run image-based tests using --reverse-byte-order.')
222
223 parser.add_argument(
224 '--ignore_errors',
K. Moon28dea142023-02-10 01:24:05 +0000225 action='store_true',
K. Moon56c46822022-10-18 22:37:42 +0000226 help='Prevents the return value from being non-zero '
227 'when image comparison fails.')
228
229 parser.add_argument(
230 'inputted_file_paths',
231 nargs='*',
232 help='Path to test files to run, relative to '
233 f'testing/{relative_test_dir}. If omitted, runs all test files under '
234 f'testing/{relative_test_dir}.',
235 metavar='relative/test/path')
236
237 skia_gold.add_skia_gold_args(parser)
238
239 self.per_process_config.options = parser.parse_args()
240
K. Moon56c46822022-10-18 22:37:42 +0000241 finder = self.per_process_config.NewFinder()
242 pdfium_test_path = self.per_process_config.GetPdfiumTestPath(finder)
243 if not os.path.exists(pdfium_test_path):
244 print(f"FAILURE: Can't find test executable '{pdfium_test_path}'")
245 print('Use --build-dir to specify its location.')
246 return 1
247 self.per_process_config.InitializeFeatures(pdfium_test_path)
248
249 self.per_process_state = _PerProcessState(self.per_process_config)
250 shutil.rmtree(self.per_process_state.working_dir, ignore_errors=True)
251 os.makedirs(self.per_process_state.working_dir)
252
253 error_message = self.per_process_state.image_differ.CheckMissingTools(
254 self.options.regenerate_expected)
255 if error_message:
256 print('FAILURE:', error_message)
257 return 1
258
259 self.resultdb = result_sink.TryInitClient()
260 if self.resultdb:
261 print('Detected ResultSink environment')
262
263 # Collect test cases.
264 walk_from_dir = finder.TestingDir(relative_test_dir)
265
266 self.test_cases = TestCaseManager()
267 self.execution_suppressed_cases = []
268 input_file_re = re.compile('^.+[.](in|pdf)$')
269 if self.options.inputted_file_paths:
270 for file_name in self.options.inputted_file_paths:
271 input_path = os.path.join(walk_from_dir, file_name)
272 if not os.path.isfile(input_path):
273 print(f"Can't find test file '{file_name}'")
274 return 1
275
276 self.test_cases.NewTestCase(input_path)
277 else:
278 for file_dir, _, filename_list in os.walk(walk_from_dir):
279 for input_filename in filename_list:
280 if input_file_re.match(input_filename):
281 input_path = os.path.join(file_dir, input_filename)
282 if self.IsExecutionSuppressed(input_path):
283 self.execution_suppressed_cases.append(input_path)
284 continue
285 if not os.path.isfile(input_path):
286 continue
287
288 self.test_cases.NewTestCase(input_path)
289
290 # Execute test cases.
291 self.failures = []
292 self.surprises = []
293 self.skia_gold_successes = []
294 self.skia_gold_unexpected_successes = []
295 self.skia_gold_failures = []
296 self.result_suppressed_cases = []
297
298 if self.IsSkiaGoldEnabled():
299 assert self.options.gold_output_dir
300 # Clear out and create top level gold output directory before starting
301 skia_gold.clear_gold_output_dir(self.options.gold_output_dir)
302
303 with multiprocessing.Pool(
304 processes=self.options.num_workers,
305 initializer=_InitializePerProcessState,
306 initargs=[self.per_process_config]) as pool:
K. Moon98204b62022-10-24 21:42:21 +0000307 if self.per_process_config.test_type in TEXT_TESTS:
308 test_function = _RunTextTest
309 else:
310 test_function = _RunPixelTest
311 for result in pool.imap(test_function, self.test_cases):
K. Moon56c46822022-10-18 22:37:42 +0000312 self.HandleResult(self.test_cases.GetTestCase(result.test_id), result)
313
K. Moon56c46822022-10-18 22:37:42 +0000314 # Report test results.
K. Moon56c46822022-10-18 22:37:42 +0000315 if self.surprises:
316 self.surprises.sort()
317 print('\nUnexpected Successes:')
318 for surprise in self.surprises:
319 print(surprise)
320
321 if self.failures:
322 self.failures.sort()
323 print('\nSummary of Failures:')
324 for failure in self.failures:
325 print(failure)
326
327 if self.skia_gold_unexpected_successes:
328 self.skia_gold_unexpected_successes.sort()
329 print('\nUnexpected Skia Gold Successes:')
330 for surprise in self.skia_gold_unexpected_successes:
331 print(surprise)
332
333 if self.skia_gold_failures:
334 self.skia_gold_failures.sort()
335 print('\nSummary of Skia Gold Failures:')
336 for failure in self.skia_gold_failures:
337 print(failure)
338
K. Moon16a4de22022-10-22 01:50:53 +0000339 self._PrintSummary()
340
K. Moon56c46822022-10-18 22:37:42 +0000341 if self.failures:
342 if not self.options.ignore_errors:
343 return 1
344
345 return 0
346
347 def _PrintSummary(self):
348 number_test_cases = len(self.test_cases)
349 number_failures = len(self.failures)
350 number_suppressed = len(self.result_suppressed_cases)
351 number_successes = number_test_cases - number_failures - number_suppressed
352 number_surprises = len(self.surprises)
353 print('\nTest cases executed:', number_test_cases)
354 print(' Successes:', number_successes)
355 print(' Suppressed:', number_suppressed)
356 print(' Surprises:', number_surprises)
357 print(' Failures:', number_failures)
358 if self.IsSkiaGoldEnabled():
359 number_gold_failures = len(self.skia_gold_failures)
360 number_gold_successes = len(self.skia_gold_successes)
361 number_gold_surprises = len(self.skia_gold_unexpected_successes)
362 number_total_gold_tests = sum(
363 [number_gold_failures, number_gold_successes, number_gold_surprises])
364 print('\nSkia Gold Test cases executed:', number_total_gold_tests)
365 print(' Skia Gold Successes:', number_gold_successes)
366 print(' Skia Gold Surprises:', number_gold_surprises)
367 print(' Skia Gold Failures:', number_gold_failures)
368 skia_tester = self.per_process_state.GetSkiaGoldTester()
369 if self.skia_gold_failures and skia_tester.IsTryjobRun():
370 cl_triage_link = skia_tester.GetCLTriageLink()
371 print(' Triage link for CL:', cl_triage_link)
372 skia_tester.WriteCLTriageLink(cl_triage_link)
373 print()
374 print('Test cases not executed:', len(self.execution_suppressed_cases))
375
376 def SetDeleteOutputOnSuccess(self, new_value):
377 """Set whether to delete generated output if the test passes."""
378 self.per_process_config.delete_output_on_success = new_value
379
380 def SetEnforceExpectedImages(self, new_value):
381 """Set whether to enforce that each test case provide an expected image."""
382 self.per_process_config.enforce_expected_images = new_value
383
384
K. Moon98204b62022-10-24 21:42:21 +0000385def _RunTextTest(test_case):
386 """Runs a text test case."""
K. Moon16a4de22022-10-22 01:50:53 +0000387 test_case_runner = _TestCaseRunner(test_case)
388 with test_case_runner:
K. Moon98204b62022-10-24 21:42:21 +0000389 test_case_runner.test_result = test_case_runner.GenerateAndTest(
390 test_case_runner.TestText)
391 return test_case_runner.test_result
392
393
394def _RunPixelTest(test_case):
395 """Runs a pixel test case."""
396 test_case_runner = _TestCaseRunner(test_case)
397 with test_case_runner:
398 test_case_runner.test_result = test_case_runner.GenerateAndTest(
399 test_case_runner.TestPixel)
K. Moon16a4de22022-10-22 01:50:53 +0000400 return test_case_runner.test_result
K. Moon56c46822022-10-18 22:37:42 +0000401
402
K. Moon56c46822022-10-18 22:37:42 +0000403# `_PerProcessState` singleton. This is initialized when creating the
404# `multiprocessing.Pool()`. `TestRunner.Run()` creates its own separate
405# instance of `_PerProcessState` as well.
406_per_process_state = None
407
408
409def _InitializePerProcessState(config):
410 """Initializes the `_per_process_state` singleton."""
411 global _per_process_state
412 assert not _per_process_state
413 _per_process_state = _PerProcessState(config)
414
415
416@dataclass
417class _PerProcessConfig:
418 """Configuration for initializing `_PerProcessState`.
419
420 Attributes:
421 test_dir: The name of the test directory.
422 test_type: The test type.
423 delete_output_on_success: Whether to delete output on success.
424 enforce_expected_images: Whether to enforce expected images.
425 options: The dictionary of command line options.
426 features: The list of features supported by `pdfium_test`.
427 """
428 test_dir: str
429 test_type: str
430 delete_output_on_success: bool = False
431 enforce_expected_images: bool = False
432 options: dict = None
433 features: list = None
434
435 def NewFinder(self):
436 return common.DirectoryFinder(self.options.build_dir)
437
438 def GetPdfiumTestPath(self, finder):
439 return finder.ExecutablePath('pdfium_test')
440
441 def InitializeFeatures(self, pdfium_test_path):
K. Moon355229b2022-12-22 18:15:04 +0000442 output = subprocess.check_output([pdfium_test_path, '--show-config'],
443 timeout=TEST_TIMEOUT)
K. Moon56c46822022-10-18 22:37:42 +0000444 self.features = output.decode('utf-8').strip().split(',')
445
446
447class _PerProcessState:
448 """State defined per process."""
449
450 def __init__(self, config):
451 self.test_dir = config.test_dir
452 self.test_type = config.test_type
453 self.delete_output_on_success = config.delete_output_on_success
454 self.enforce_expected_images = config.enforce_expected_images
455 self.options = config.options
456 self.features = config.features
457
458 finder = config.NewFinder()
459 self.pdfium_test_path = config.GetPdfiumTestPath(finder)
460 self.fixup_path = finder.ScriptPath('fixup_pdf_template.py')
461 self.text_diff_path = finder.ScriptPath('text_diff.py')
462 self.font_dir = os.path.join(finder.TestingDir(), 'resources', 'fonts')
463 self.third_party_font_dir = finder.ThirdPartyFontsDir()
464
465 self.source_dir = finder.TestingDir()
466 self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir))
467
468 self.test_suppressor = suppressor.Suppressor(
469 finder, self.features, self.options.disable_javascript,
470 self.options.disable_xfa)
471 self.image_differ = pngdiffer.PNGDiffer(finder, self.features,
472 self.options.reverse_byte_order)
473
474 self.process_name = multiprocessing.current_process().name
475 self.skia_tester = None
476
477 def __getstate__(self):
478 raise RuntimeError('Cannot pickle per-process state')
479
480 def GetSkiaGoldTester(self):
481 """Gets the `SkiaGoldTester` singleton for this worker."""
482 if not self.skia_tester:
483 self.skia_tester = skia_gold.SkiaGoldTester(
484 source_type=self.test_type,
485 skia_gold_args=self.options,
486 process_name=self.process_name)
487 return self.skia_tester
K. Moon20ab33b2022-10-13 20:54:40 +0000488
K. Moon16a4de22022-10-22 01:50:53 +0000489
490class _TestCaseRunner:
491 """Runner for a single test case."""
492
493 def __init__(self, test_case):
494 self.test_case = test_case
495 self.test_result = None
496 self.duration_start = 0
497
498 self.source_dir, self.input_filename = os.path.split(
499 self.test_case.input_path)
500 self.pdf_path = os.path.join(self.working_dir, f'{self.test_id}.pdf')
K. Moon98204b62022-10-24 21:42:21 +0000501 self.actual_images = None
K. Moon16a4de22022-10-22 01:50:53 +0000502
503 def __enter__(self):
504 self.duration_start = time.perf_counter_ns()
505 return self
506
507 def __exit__(self, exc_type, exc_value, traceback):
508 if not self.test_result:
509 self.test_result = self.test_case.NewResult(
510 result_types.UNKNOWN, reason='No test result recorded')
511 duration = time.perf_counter_ns() - self.duration_start
512 self.test_result.duration_milliseconds = duration * 1e-6
513
514 @property
515 def options(self):
516 return _per_process_state.options
517
518 @property
519 def test_id(self):
520 return self.test_case.test_id
521
522 @property
523 def working_dir(self):
524 return _per_process_state.working_dir
525
526 def IsResultSuppressed(self):
527 return _per_process_state.test_suppressor.IsResultSuppressed(
528 self.input_filename)
529
530 def IsImageDiffSuppressed(self):
531 return _per_process_state.test_suppressor.IsImageDiffSuppressed(
532 self.input_filename)
533
534 def RunCommand(self, command, stdout=None):
535 """Runs a test command.
536
537 Args:
538 command: The list of command arguments.
539 stdout: Optional `file`-like object to send standard output.
540
541 Returns:
542 The test result.
543 """
K. Moonc676ff72022-12-22 18:16:41 +0000544
545 # Standard output and error are directed to the test log. If `stdout` was
546 # provided, redirect standard output to it instead.
K. Moon16a4de22022-10-22 01:50:53 +0000547 if stdout:
K. Moonc676ff72022-12-22 18:16:41 +0000548 assert stdout != subprocess.PIPE
549 try:
550 stdout.fileno()
551 except OSError:
552 # `stdout` doesn't have a file descriptor, so it can't be passed to
553 # `subprocess.run()` directly.
554 original_stdout = stdout
555 stdout = subprocess.PIPE
K. Moon16a4de22022-10-22 01:50:53 +0000556 stderr = subprocess.PIPE
557 else:
558 stdout = subprocess.PIPE
559 stderr = subprocess.STDOUT
560
K. Moon355229b2022-12-22 18:15:04 +0000561 test_result = self.test_case.NewResult(result_types.PASS)
562 try:
563 run_result = subprocess.run(
564 command,
565 stdout=stdout,
566 stderr=stderr,
567 timeout=TEST_TIMEOUT,
568 check=False)
569 if run_result.returncode != 0:
570 test_result.status = result_types.FAIL
571 test_result.reason = 'Command {} exited with code {}'.format(
572 run_result.args, run_result.returncode)
573 except subprocess.TimeoutExpired as timeout_expired:
574 run_result = timeout_expired
575 test_result.status = result_types.TIMEOUT
576 test_result.reason = 'Command {} timed out'.format(run_result.cmd)
K. Moon16a4de22022-10-22 01:50:53 +0000577
K. Moonc676ff72022-12-22 18:16:41 +0000578 if stdout == subprocess.PIPE and stderr == subprocess.PIPE:
Lei Zhanga2f47182023-01-20 20:53:10 +0000579 # Copy captured standard output, if any, to the original `stdout`.
580 if run_result.stdout:
581 original_stdout.write(run_result.stdout)
K. Moonc676ff72022-12-22 18:16:41 +0000582
K. Moon355229b2022-12-22 18:15:04 +0000583 if not test_result.IsPass():
K. Moonc676ff72022-12-22 18:16:41 +0000584 # On failure, report captured output to the test log.
585 if stderr == subprocess.STDOUT:
K. Moon355229b2022-12-22 18:15:04 +0000586 test_result.log = run_result.stdout
587 else:
588 test_result.log = run_result.stderr
589 return test_result
K. Moon16a4de22022-10-22 01:50:53 +0000590
K. Moon98204b62022-10-24 21:42:21 +0000591 def GenerateAndTest(self, test_function):
K. Moond1aaef42022-10-14 18:30:47 +0000592 """Generate test input and run pdfium_test."""
K. Moon16a4de22022-10-22 01:50:53 +0000593 test_result = self.Generate()
594 if not test_result.IsPass():
595 return test_result
dsinclair2a8a20c2016-04-25 09:46:17 -0700596
K. Moon98204b62022-10-24 21:42:21 +0000597 return test_function()
dsinclair2a8a20c2016-04-25 09:46:17 -0700598
K. Moon16a4de22022-10-22 01:50:53 +0000599 def _RegenerateIfNeeded(self):
600 if not self.options.regenerate_expected:
Henrique Nakashima15bc9742018-04-26 15:55:07 +0000601 return
K. Moon16a4de22022-10-22 01:50:53 +0000602 if self.IsResultSuppressed() or self.IsImageDiffSuppressed():
603 return
K. Moon28dea142023-02-10 01:24:05 +0000604 _per_process_state.image_differ.Regenerate(self.input_filename,
605 self.source_dir,
606 self.working_dir)
Henrique Nakashima15bc9742018-04-26 15:55:07 +0000607
K. Moon16a4de22022-10-22 01:50:53 +0000608 def Generate(self):
609 input_event_path = os.path.join(self.source_dir, f'{self.test_id}.evt')
dsinclair849284d2016-05-17 06:13:36 -0700610 if os.path.exists(input_event_path):
K. Moon16a4de22022-10-22 01:50:53 +0000611 output_event_path = f'{os.path.splitext(self.pdf_path)[0]}.evt'
dsinclair849284d2016-05-17 06:13:36 -0700612 shutil.copyfile(input_event_path, output_event_path)
613
K. Moon16a4de22022-10-22 01:50:53 +0000614 template_path = os.path.join(self.source_dir, f'{self.test_id}.in')
615 if not os.path.exists(template_path):
616 if os.path.exists(self.test_case.input_path):
617 shutil.copyfile(self.test_case.input_path, self.pdf_path)
618 return self.test_case.NewResult(result_types.PASS)
dsinclair2a8a20c2016-04-25 09:46:17 -0700619
K. Moon16a4de22022-10-22 01:50:53 +0000620 return self.RunCommand([
621 sys.executable, _per_process_state.fixup_path,
622 f'--output-dir={self.working_dir}', template_path
Lei Zhang30543372019-11-19 19:02:30 +0000623 ])
dsinclair2a8a20c2016-04-25 09:46:17 -0700624
K. Moon16a4de22022-10-22 01:50:53 +0000625 def TestText(self):
626 txt_path = os.path.join(self.working_dir, f'{self.test_id}.txt')
dsinclair2a8a20c2016-04-25 09:46:17 -0700627 with open(txt_path, 'w') as outfile:
Lei Zhang30543372019-11-19 19:02:30 +0000628 cmd_to_run = [
K. Moon16a4de22022-10-22 01:50:53 +0000629 _per_process_state.pdfium_test_path, '--send-events',
630 f'--time={TEST_SEED_TIME}'
Lei Zhang30543372019-11-19 19:02:30 +0000631 ]
Daniel Hosseinian77b3a432019-12-18 17:54:37 +0000632
633 if self.options.disable_javascript:
634 cmd_to_run.append('--disable-javascript')
635
Daniel Hosseinian09dbeac2020-01-24 19:41:31 +0000636 if self.options.disable_xfa:
637 cmd_to_run.append('--disable-xfa')
638
K. Moon16a4de22022-10-22 01:50:53 +0000639 cmd_to_run.append(self.pdf_path)
640 test_result = self.RunCommand(cmd_to_run, stdout=outfile)
641 if not test_result.IsPass():
642 return test_result
dsinclair2a8a20c2016-04-25 09:46:17 -0700643
Daniel Hosseinian77b3a432019-12-18 17:54:37 +0000644 # If the expected file does not exist, the output is expected to be empty.
K. Moon16a4de22022-10-22 01:50:53 +0000645 expected_txt_path = os.path.join(self.source_dir,
646 f'{self.test_id}_expected.txt')
Lei Zhangfe3ab672019-11-19 19:09:40 +0000647 if not os.path.exists(expected_txt_path):
648 return self._VerifyEmptyText(txt_path)
649
Daniel Hosseinian77b3a432019-12-18 17:54:37 +0000650 # If JavaScript is disabled, the output should be empty.
651 # However, if the test is suppressed and JavaScript is disabled, do not
652 # verify that the text is empty so the suppressed test does not surprise.
K. Moon16a4de22022-10-22 01:50:53 +0000653 if self.options.disable_javascript and not self.IsResultSuppressed():
Daniel Hosseinian77b3a432019-12-18 17:54:37 +0000654 return self._VerifyEmptyText(txt_path)
655
K. Moon16a4de22022-10-22 01:50:53 +0000656 return self.RunCommand([
657 sys.executable, _per_process_state.text_diff_path, expected_txt_path,
658 txt_path
659 ])
dsinclair2a8a20c2016-04-25 09:46:17 -0700660
Lei Zhangfe3ab672019-11-19 19:09:40 +0000661 def _VerifyEmptyText(self, txt_path):
K. Moon355229b2022-12-22 18:15:04 +0000662 with open(txt_path, "rb") as txt_file:
K. Moon16a4de22022-10-22 01:50:53 +0000663 txt_data = txt_file.read()
664
665 if txt_data:
666 return self.test_case.NewResult(
667 result_types.FAIL, log=txt_data, reason=f'{txt_path} should be empty')
668
669 return self.test_case.NewResult(result_types.PASS)
Lei Zhangfe3ab672019-11-19 19:09:40 +0000670
Stephanie Kim0314ade2021-03-11 00:27:35 +0000671 # TODO(crbug.com/pdfium/1656): Remove when ready to fully switch over to
672 # Skia Gold
K. Moon16a4de22022-10-22 01:50:53 +0000673 def TestPixel(self):
K. Moon98204b62022-10-24 21:42:21 +0000674 # Remove any existing generated images from previous runs.
675 self.actual_images = _per_process_state.image_differ.GetActualFiles(
676 self.input_filename, self.source_dir, self.working_dir)
677 self._CleanupPixelTest()
678
679 # Generate images.
Lei Zhang30543372019-11-19 19:02:30 +0000680 cmd_to_run = [
K. Moon16a4de22022-10-22 01:50:53 +0000681 _per_process_state.pdfium_test_path, '--send-events', '--png', '--md5',
682 f'--time={TEST_SEED_TIME}'
Lei Zhang30543372019-11-19 19:02:30 +0000683 ]
Ryan Harrison1118a662018-05-31 19:26:52 +0000684
K. Moon16a4de22022-10-22 01:50:53 +0000685 if 'use_ahem' in self.source_dir or 'use_symbolneu' in self.source_dir:
686 cmd_to_run.append(f'--font-dir={_per_process_state.font_dir}')
Lei Zhang17b67222022-04-01 18:02:29 +0000687 else:
K. Moon16a4de22022-10-22 01:50:53 +0000688 cmd_to_run.append(f'--font-dir={_per_process_state.third_party_font_dir}')
Lei Zhang17b67222022-04-01 18:02:29 +0000689 cmd_to_run.append('--croscore-font-names')
Ryan Harrison1118a662018-05-31 19:26:52 +0000690
Daniel Hosseinian77b3a432019-12-18 17:54:37 +0000691 if self.options.disable_javascript:
692 cmd_to_run.append('--disable-javascript')
693
Daniel Hosseinian09dbeac2020-01-24 19:41:31 +0000694 if self.options.disable_xfa:
695 cmd_to_run.append('--disable-xfa')
696
Hui Yingstc511fd22021-10-25 18:03:10 +0000697 if self.options.render_oneshot:
698 cmd_to_run.append('--render-oneshot')
699
Lei Zhangafce8532019-11-20 18:09:41 +0000700 if self.options.reverse_byte_order:
701 cmd_to_run.append('--reverse-byte-order')
702
K. Moon16a4de22022-10-22 01:50:53 +0000703 cmd_to_run.append(self.pdf_path)
dsinclair2a8a20c2016-04-25 09:46:17 -0700704
K. Moonc676ff72022-12-22 18:16:41 +0000705 with BytesIO() as command_output:
706 test_result = self.RunCommand(cmd_to_run, stdout=command_output)
707 if not test_result.IsPass():
708 return test_result
K. Moon16a4de22022-10-22 01:50:53 +0000709
K. Moonc676ff72022-12-22 18:16:41 +0000710 test_result.image_artifacts = []
711 for line in command_output.getvalue().splitlines():
712 # Expect this format: MD5:<path to image file>:<hexadecimal MD5 hash>
713 line = bytes.decode(line).strip()
714 if line.startswith('MD5:'):
715 image_path, md5_hash = line[4:].rsplit(':', 1)
716 test_result.image_artifacts.append(
717 self._NewImageArtifact(
718 image_path=image_path.strip(), md5_hash=md5_hash.strip()))
K. Moond1aaef42022-10-14 18:30:47 +0000719
K. Moon98204b62022-10-24 21:42:21 +0000720 if self.actual_images:
K. Moon245050a2022-10-27 22:15:22 +0000721 image_diffs = _per_process_state.image_differ.ComputeDifferences(
722 self.input_filename, self.source_dir, self.working_dir)
723 if image_diffs:
K. Moon98204b62022-10-24 21:42:21 +0000724 test_result.status = result_types.FAIL
725 test_result.reason = 'Images differ'
K. Moon245050a2022-10-27 22:15:22 +0000726
727 # Merge image diffs into test result.
728 diff_map = {}
729 diff_log = []
730 for diff in image_diffs:
731 diff_map[diff.actual_path] = diff
K. Moon28dea142023-02-10 01:24:05 +0000732 diff_log.append(f'{os.path.basename(diff.actual_path)} vs. ')
733 if diff.expected_path:
734 diff_log.append(f'{os.path.basename(diff.expected_path)}\n')
735 else:
736 diff_log.append('missing expected file\n')
K. Moon245050a2022-10-27 22:15:22 +0000737
738 for artifact in test_result.image_artifacts:
739 artifact.image_diff = diff_map.get(artifact.image_path)
K. Moon355229b2022-12-22 18:15:04 +0000740 test_result.log = ''.join(diff_log).encode()
K. Moon245050a2022-10-27 22:15:22 +0000741
K. Moon98204b62022-10-24 21:42:21 +0000742 elif _per_process_state.enforce_expected_images:
743 if not self.IsImageDiffSuppressed():
744 test_result.status = result_types.FAIL
745 test_result.reason = 'Missing expected images'
746
747 if not test_result.IsPass():
748 self._RegenerateIfNeeded()
749 return test_result
750
751 if _per_process_state.delete_output_on_success:
752 self._CleanupPixelTest()
753 return test_result
754
K. Moon11d077f2022-10-20 00:46:33 +0000755 def _NewImageArtifact(self, *, image_path, md5_hash):
756 artifact = ImageArtifact(image_path=image_path, md5_hash=md5_hash)
757
758 if self.options.run_skia_gold:
K. Moon16a4de22022-10-22 01:50:53 +0000759 if _per_process_state.GetSkiaGoldTester().UploadTestResultToSkiaGold(
K. Moon11d077f2022-10-20 00:46:33 +0000760 artifact.GetSkiaGoldId(), artifact.image_path):
761 artifact.skia_gold_status = result_types.PASS
762 else:
763 artifact.skia_gold_status = result_types.FAIL
764
765 return artifact
766
K. Moon98204b62022-10-24 21:42:21 +0000767 def _CleanupPixelTest(self):
768 for image_file in self.actual_images:
769 if os.path.exists(image_file):
770 os.remove(image_file)
771
K. Moon2a09fff2022-10-18 20:32:06 +0000772
K. Moond1aaef42022-10-14 18:30:47 +0000773@dataclass
774class TestCase:
775 """Description of a test case to run.
776
777 Attributes:
778 test_id: A unique identifier for the test.
779 input_path: The absolute path to the test file.
780 """
781 test_id: str
782 input_path: str
783
784 def NewResult(self, status, **kwargs):
785 """Derives a new test result corresponding to this test case."""
786 return TestResult(test_id=self.test_id, status=status, **kwargs)
787
788
789@dataclass
790class TestResult:
791 """Results from running a test case.
792
793 Attributes:
794 test_id: The corresponding test case ID.
795 status: The overall `result_types` status.
K. Moon60a072f2022-10-19 16:17:50 +0000796 duration_milliseconds: Test time in milliseconds.
K. Moon16a4de22022-10-22 01:50:53 +0000797 log: Optional log of the test's output.
K. Moond1aaef42022-10-14 18:30:47 +0000798 image_artfacts: Optional list of image artifacts.
K. Moon16a4de22022-10-22 01:50:53 +0000799 reason: Optional reason why the test failed.
K. Moond1aaef42022-10-14 18:30:47 +0000800 """
801 test_id: str
802 status: str
K. Moon60a072f2022-10-19 16:17:50 +0000803 duration_milliseconds: float = None
K. Moon16a4de22022-10-22 01:50:53 +0000804 log: str = None
K. Moon245050a2022-10-27 22:15:22 +0000805 image_artifacts: list = field(default_factory=list)
K. Moon16a4de22022-10-22 01:50:53 +0000806 reason: str = None
K. Moond1aaef42022-10-14 18:30:47 +0000807
808 def IsPass(self):
809 """Whether the test passed."""
810 return self.status == result_types.PASS
811
812
813@dataclass
814class ImageArtifact:
815 """Image artifact for a test result.
816
817 Attributes:
818 image_path: The absolute path to the image file.
819 md5_hash: The MD5 hash of the pixel buffer.
K. Moon11d077f2022-10-20 00:46:33 +0000820 skia_gold_status: Optional Skia Gold status.
K. Moon245050a2022-10-27 22:15:22 +0000821 image_diff: Optional image diff.
K. Moond1aaef42022-10-14 18:30:47 +0000822 """
823 image_path: str
824 md5_hash: str
K. Moon11d077f2022-10-20 00:46:33 +0000825 skia_gold_status: str = None
K. Moon245050a2022-10-27 22:15:22 +0000826 image_diff: pngdiffer.ImageDiff = None
K. Moon11d077f2022-10-20 00:46:33 +0000827
828 def GetSkiaGoldId(self):
829 # The output filename without image extension becomes the test ID. For
830 # example, "/path/to/.../testing/corpus/example_005.pdf.0.png" becomes
831 # "example_005.pdf.0".
832 return _GetTestId(os.path.basename(self.image_path))
K. Moond1aaef42022-10-14 18:30:47 +0000833
K. Moon245050a2022-10-27 22:15:22 +0000834 def GetDiffStatus(self):
835 return result_types.FAIL if self.image_diff else result_types.PASS
836
837 def GetDiffReason(self):
838 return self.image_diff.reason if self.image_diff else None
839
840 def GetDiffArtifacts(self):
841 if not self.image_diff:
842 return None
843 if not self.image_diff.expected_path or not self.image_diff.diff_path:
844 return None
845 return {
846 'actual_image':
847 _GetArtifactFromFilePath(self.image_path),
848 'expected_image':
849 _GetArtifactFromFilePath(self.image_diff.expected_path),
850 'image_diff':
851 _GetArtifactFromFilePath(self.image_diff.diff_path)
852 }
853
K. Moond1aaef42022-10-14 18:30:47 +0000854
855class TestCaseManager:
856 """Manages a collection of test cases."""
857
858 def __init__(self):
859 self.test_cases = {}
860
861 def __len__(self):
862 return len(self.test_cases)
863
864 def __iter__(self):
865 return iter(self.test_cases.values())
866
867 def NewTestCase(self, input_path, **kwargs):
868 """Creates and registers a new test case."""
869 input_basename = os.path.basename(input_path)
K. Moon11d077f2022-10-20 00:46:33 +0000870 test_id = _GetTestId(input_basename)
K. Moond1aaef42022-10-14 18:30:47 +0000871 if test_id in self.test_cases:
872 raise ValueError(
873 f'Test ID "{test_id}" derived from "{input_basename}" must be unique')
874
875 test_case = TestCase(test_id=test_id, input_path=input_path, **kwargs)
876 self.test_cases[test_id] = test_case
877 return test_case
878
879 def GetTestCase(self, test_id):
880 """Looks up a test case previously registered by `NewTestCase()`."""
881 return self.test_cases[test_id]
K. Moon60a072f2022-10-19 16:17:50 +0000882
883
K. Moon11d077f2022-10-20 00:46:33 +0000884def _GetTestId(input_basename):
885 """Constructs a test ID by stripping the last extension from the basename."""
886 return os.path.splitext(input_basename)[0]
K. Moon245050a2022-10-27 22:15:22 +0000887
888
889def _GetArtifactFromFilePath(file_path):
890 """Constructs a ResultSink artifact from a file path."""
891 return {'filePath': file_path}