Report test_runner.py test case output to ResultDB
Records test output from individual test_runner.py test case failures,
then reports it to ResultDB.
Note that any output from commands directly spawned by test_runner.py
will now be intercepted, and only reported after the test completes.
(This is not yet true for the image differ, however.)
This change refactors the test case-running logic into a new, per-case
_TestCaseRunner class. This reduces the need to pass state through to
individual test case steps.
Also cleans up the stale TODO from crbug.com/pdfium/1657, and drops
KeyboardInterruptError as irrelevant in multiprocessing mode.
Bug: pdfium:1657, pdfium:1916
Change-Id: I6b030d16d5ffc32ef0b9488c699decc54d771110
Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/99951
Reviewed-by: Lei Zhang <thestig@chromium.org>
Reviewed-by: Tom Sepez <tsepez@chromium.org>
Commit-Queue: K. Moon <kmoon@chromium.org>
diff --git a/testing/tools/test_runner.py b/testing/tools/test_runner.py
index 2412246..44a3f3d 100644
--- a/testing/tools/test_runner.py
+++ b/testing/tools/test_runner.py
@@ -35,10 +35,6 @@
TEXT_TESTS = ['javascript']
-class KeyboardInterruptError(Exception):
- """Custom exception used to wrap `KeyboardInterrupt` exceptions."""
-
-
def DeleteFiles(files):
"""Utility function to delete a list of files"""
for f in files:
@@ -106,14 +102,24 @@
elif artifact.skia_gold_status == result_types.FAIL:
self.skia_gold_failures.append(artifact.GetSkiaGoldId())
+ # Log test result.
+ print(f'{result_status}: {test_result.test_id}')
+ if result_status != result_types.PASS:
+ if test_result.reason:
+ print(f'Failure reason: {test_result.reason}')
+ if test_result.log:
+ print(f'Test output:\n{test_result.log}')
+
+ # Report test result to ResultDB.
if self.resultdb:
# TODO(crbug.com/pdfium/1916): Populate more ResultDB fields.
self.resultdb.Post(
test_id=test_result.test_id,
status=result_status,
duration=test_result.duration_milliseconds,
- test_log=None,
- test_file=None)
+ test_log=test_result.log,
+ test_file=None,
+ failure_reason=test_result.reason)
def Run(self):
# Running a test defines a number of attributes on the fly.
@@ -285,13 +291,6 @@
self.HandleResult(self.test_cases.GetTestCase(result.test_id), result)
# Report test results.
- #
- # For some reason, summary will be cut off from stdout on windows if
- # _PrintSummary() is called at the end
- # TODO(crbug.com/pdfium/1657): Once resolved, move _PrintSummary() back
- # down to the end
- self._PrintSummary()
-
if self.surprises:
self.surprises.sort()
print('\nUnexpected Successes:')
@@ -316,6 +315,8 @@
for failure in self.skia_gold_failures:
print(failure)
+ self._PrintSummary()
+
if self.failures:
if not self.options.ignore_errors:
return 1
@@ -362,12 +363,10 @@
def _RunPdfiumTest(test_case):
"""Runs a PDFium test case."""
- try:
- with _TestTimer() as timer:
- timer.result = _per_process_state.GenerateAndTest(test_case)
- return timer.result
- except KeyboardInterrupt as exc:
- raise KeyboardInterruptError() from exc
+ test_case_runner = _TestCaseRunner(test_case)
+ with test_case_runner:
+ test_case_runner.test_result = test_case_runner.GenerateAndTest()
+ return test_case_runner.test_result
# `_PerProcessState` singleton. This is initialized when creating the
@@ -455,97 +454,157 @@
process_name=self.process_name)
return self.skia_tester
- def GenerateAndTest(self, test_case):
+
+class _TestCaseRunner:
+ """Runner for a single test case."""
+
+ def __init__(self, test_case):
+ self.test_case = test_case
+ self.test_result = None
+ self.duration_start = 0
+
+ self.source_dir, self.input_filename = os.path.split(
+ self.test_case.input_path)
+ self.pdf_path = os.path.join(self.working_dir, f'{self.test_id}.pdf')
+
+ def __enter__(self):
+ self.duration_start = time.perf_counter_ns()
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if not self.test_result:
+ self.test_result = self.test_case.NewResult(
+ result_types.UNKNOWN, reason='No test result recorded')
+ duration = time.perf_counter_ns() - self.duration_start
+ self.test_result.duration_milliseconds = duration * 1e-6
+
+ @property
+ def options(self):
+ return _per_process_state.options
+
+ @property
+ def test_id(self):
+ return self.test_case.test_id
+
+ @property
+ def working_dir(self):
+ return _per_process_state.working_dir
+
+ def IsResultSuppressed(self):
+ return _per_process_state.test_suppressor.IsResultSuppressed(
+ self.input_filename)
+
+ def IsImageDiffSuppressed(self):
+ return _per_process_state.test_suppressor.IsImageDiffSuppressed(
+ self.input_filename)
+
+ def RunCommand(self, command, stdout=None):
+ """Runs a test command.
+
+ Args:
+ command: The list of command arguments.
+ stdout: Optional `file`-like object to send standard output.
+
+ Returns:
+ The test result.
+ """
+ if stdout:
+ stderr = subprocess.PIPE
+ else:
+ stdout = subprocess.PIPE
+ stderr = subprocess.STDOUT
+
+ completed_process = subprocess.run(
+ command, stdout=stdout, stderr=stderr, check=False, encoding='utf-8')
+ if completed_process.returncode != 0:
+ if stdout == subprocess.PIPE:
+ test_log = completed_process.stdout
+ else:
+ test_log = completed_process.stderr
+ return self.test_case.NewResult(
+ result_types.FAIL,
+ log=test_log,
+ reason='Command {} exited with code {}'.format(
+ completed_process.args, completed_process.returncode))
+
+ return self.test_case.NewResult(result_types.PASS)
+
+ def GenerateAndTest(self):
"""Generate test input and run pdfium_test."""
- source_dir, input_filename = os.path.split(test_case.input_path)
- pdf_path = os.path.join(self.working_dir, f'{test_case.test_id}.pdf')
# Remove any existing generated images from previous runs.
- actual_images = self.image_differ.GetActualFiles(input_filename, source_dir,
- self.working_dir)
+ actual_images = _per_process_state.image_differ.GetActualFiles(
+ self.input_filename, self.source_dir, self.working_dir)
DeleteFiles(actual_images)
- sys.stdout.flush()
+ test_result = self.Generate()
+ if not test_result.IsPass():
+ return test_result
- raised_exception = self.Generate(source_dir, input_filename,
- test_case.test_id, pdf_path)
-
- if raised_exception is not None:
- print('FAILURE: {}; {}'.format(input_filename, raised_exception))
- return test_case.NewResult(result_types.FAIL)
-
- image_artifacts = None
- if self.test_type in TEXT_TESTS:
- expected_txt_path = os.path.join(source_dir,
- f'{test_case.test_id}_expected.txt')
- raised_exception = self.TestText(input_filename, test_case.test_id,
- expected_txt_path, pdf_path)
+ if _per_process_state.test_type in TEXT_TESTS:
+ test_result = self.TestText()
else:
- raised_exception, image_artifacts = self.TestPixel(pdf_path, source_dir)
+ test_result = self.TestPixel()
+ if not test_result.IsPass():
+ return test_result
- if raised_exception is not None:
- print('FAILURE: {}; {}'.format(input_filename, raised_exception))
- return test_case.NewResult(
- result_types.FAIL, image_artifacts=image_artifacts)
-
+ # TODO(crbug.com/pdfium/1916): Output needs to be captured. Also, this only
+ # applies to pixel tests, not text tests.
if actual_images:
- if self.image_differ.HasDifferences(input_filename, source_dir,
- self.working_dir):
- self.RegenerateIfNeeded_(input_filename, source_dir)
- return test_case.NewResult(
- result_types.FAIL, image_artifacts=image_artifacts)
- else:
- if (self.enforce_expected_images and
- not self.test_suppressor.IsImageDiffSuppressed(input_filename)):
- self.RegenerateIfNeeded_(input_filename, source_dir)
- print('FAILURE: {}; Missing expected images'.format(input_filename))
- return test_case.NewResult(
- result_types.FAIL, image_artifacts=image_artifacts)
+ if _per_process_state.image_differ.HasDifferences(self.input_filename,
+ self.source_dir,
+ self.working_dir):
+ test_result.status = result_types.FAIL
+ test_result.reason = 'Images differ'
+ self._RegenerateIfNeeded()
+ return test_result
+ elif _per_process_state.enforce_expected_images:
+ if not self.IsImageDiffSuppressed():
+ test_result.status = result_types.FAIL
+ test_result.reason = 'Missing expected images'
+ self._RegenerateIfNeeded()
+ return test_result
- if self.delete_output_on_success:
+ if _per_process_state.delete_output_on_success:
DeleteFiles(actual_images)
- return test_case.NewResult(
- result_types.PASS, image_artifacts=image_artifacts)
+ return test_result
# TODO(crbug.com/pdfium/1508): Add support for an option to automatically
# generate Skia/SkiaPaths specific expected results.
- def RegenerateIfNeeded_(self, input_filename, source_dir):
- if (not self.options.regenerate_expected or
- self.test_suppressor.IsResultSuppressed(input_filename) or
- self.test_suppressor.IsImageDiffSuppressed(input_filename)):
+ def _RegenerateIfNeeded(self):
+ if not self.options.regenerate_expected:
return
+ if self.IsResultSuppressed() or self.IsImageDiffSuppressed():
+ return
+ _per_process_state.image_differ.Regenerate(
+ self.input_filename,
+ self.source_dir,
+ self.working_dir,
+ platform_only=self.options.regenerate_expected == 'platform')
- platform_only = (self.options.regenerate_expected == 'platform')
- self.image_differ.Regenerate(input_filename, source_dir, self.working_dir,
- platform_only)
-
- def Generate(self, source_dir, input_filename, input_root, pdf_path):
- original_path = os.path.join(source_dir, input_filename)
- input_path = os.path.join(source_dir, input_root + '.in')
-
- input_event_path = os.path.join(source_dir, input_root + '.evt')
+ def Generate(self):
+ input_event_path = os.path.join(self.source_dir, f'{self.test_id}.evt')
if os.path.exists(input_event_path):
- output_event_path = os.path.splitext(pdf_path)[0] + '.evt'
+ output_event_path = f'{os.path.splitext(self.pdf_path)[0]}.evt'
shutil.copyfile(input_event_path, output_event_path)
- if not os.path.exists(input_path):
- if os.path.exists(original_path):
- shutil.copyfile(original_path, pdf_path)
- return None
+ template_path = os.path.join(self.source_dir, f'{self.test_id}.in')
+ if not os.path.exists(template_path):
+ if os.path.exists(self.test_case.input_path):
+ shutil.copyfile(self.test_case.input_path, self.pdf_path)
+ return self.test_case.NewResult(result_types.PASS)
- sys.stdout.flush()
-
- return common.RunCommand([
- sys.executable, self.fixup_path, '--output-dir=' + self.working_dir,
- input_path
+ return self.RunCommand([
+ sys.executable, _per_process_state.fixup_path,
+ f'--output-dir={self.working_dir}', template_path
])
- def TestText(self, input_filename, input_root, expected_txt_path, pdf_path):
- txt_path = os.path.join(self.working_dir, input_root + '.txt')
-
+ def TestText(self):
+ txt_path = os.path.join(self.working_dir, f'{self.test_id}.txt')
with open(txt_path, 'w') as outfile:
cmd_to_run = [
- self.pdfium_test_path, '--send-events', '--time=' + TEST_SEED_TIME
+ _per_process_state.pdfium_test_path, '--send-events',
+ f'--time={TEST_SEED_TIME}'
]
if self.options.disable_javascript:
@@ -554,51 +613,50 @@
if self.options.disable_xfa:
cmd_to_run.append('--disable-xfa')
- cmd_to_run.append(pdf_path)
- try:
- subprocess.check_call(cmd_to_run, stdout=outfile)
- except subprocess.CalledProcessError as e:
- return e
+ cmd_to_run.append(self.pdf_path)
+ test_result = self.RunCommand(cmd_to_run, stdout=outfile)
+ if not test_result.IsPass():
+ return test_result
# If the expected file does not exist, the output is expected to be empty.
+ expected_txt_path = os.path.join(self.source_dir,
+ f'{self.test_id}_expected.txt')
if not os.path.exists(expected_txt_path):
return self._VerifyEmptyText(txt_path)
# If JavaScript is disabled, the output should be empty.
# However, if the test is suppressed and JavaScript is disabled, do not
# verify that the text is empty so the suppressed test does not surprise.
- if (self.options.disable_javascript and
- not self.test_suppressor.IsResultSuppressed(input_filename)):
+ if self.options.disable_javascript and not self.IsResultSuppressed():
return self._VerifyEmptyText(txt_path)
- cmd = [sys.executable, self.text_diff_path, expected_txt_path, txt_path]
- return common.RunCommand(cmd)
+ return self.RunCommand([
+ sys.executable, _per_process_state.text_diff_path, expected_txt_path,
+ txt_path
+ ])
def _VerifyEmptyText(self, txt_path):
- try:
- with open(txt_path, "r") as txt_file:
- txt_data = txt_file.readlines()
- if not txt_data:
- return None
- sys.stdout.write('Unexpected output:\n')
- for line in txt_data:
- sys.stdout.write(line)
- raise Exception('%s should be empty.' % txt_path)
- except Exception as e:
- return e
+ with open(txt_path, "r") as txt_file:
+ txt_data = txt_file.read()
+
+ if txt_data:
+ return self.test_case.NewResult(
+ result_types.FAIL, log=txt_data, reason=f'{txt_path} should be empty')
+
+ return self.test_case.NewResult(result_types.PASS)
# TODO(crbug.com/pdfium/1656): Remove when ready to fully switch over to
# Skia Gold
- def TestPixel(self, pdf_path, source_dir):
+ def TestPixel(self):
cmd_to_run = [
- self.pdfium_test_path, '--send-events', '--png', '--md5',
- '--time=' + TEST_SEED_TIME
+ _per_process_state.pdfium_test_path, '--send-events', '--png', '--md5',
+ f'--time={TEST_SEED_TIME}'
]
- if 'use_ahem' in source_dir or 'use_symbolneu' in source_dir:
- cmd_to_run.append('--font-dir=%s' % self.font_dir)
+ if 'use_ahem' in self.source_dir or 'use_symbolneu' in self.source_dir:
+ cmd_to_run.append(f'--font-dir={_per_process_state.font_dir}')
else:
- cmd_to_run.append('--font-dir=%s' % self.third_party_font_dir)
+ cmd_to_run.append(f'--font-dir={_per_process_state.third_party_font_dir}')
cmd_to_run.append('--croscore-font-names')
if self.options.disable_javascript:
@@ -613,21 +671,25 @@
if self.options.reverse_byte_order:
cmd_to_run.append('--reverse-byte-order')
- cmd_to_run.append(pdf_path)
+ cmd_to_run.append(self.pdf_path)
raised_exception, results = common.RunCommandExtractHashedFiles(cmd_to_run)
- if results:
- results = [
- self._NewImageArtifact(image_path=image_path, md5_hash=md5_hash)
- for image_path, md5_hash in results
- ]
- return raised_exception, results
+ if raised_exception:
+ return self.test_case.NewResult(
+ result_types.FAIL, reason=str(raised_exception))
+
+ return self.test_case.NewResult(
+ result_types.PASS,
+ image_artifacts=[
+ self._NewImageArtifact(image_path=image_path, md5_hash=md5_hash)
+ for image_path, md5_hash in results
+ ])
def _NewImageArtifact(self, *, image_path, md5_hash):
artifact = ImageArtifact(image_path=image_path, md5_hash=md5_hash)
if self.options.run_skia_gold:
- if self.GetSkiaGoldTester().UploadTestResultToSkiaGold(
+ if _per_process_state.GetSkiaGoldTester().UploadTestResultToSkiaGold(
artifact.GetSkiaGoldId(), artifact.image_path):
artifact.skia_gold_status = result_types.PASS
else:
@@ -660,12 +722,16 @@
test_id: The corresponding test case ID.
status: The overall `result_types` status.
duration_milliseconds: Test time in milliseconds.
+ log: Optional log of the test's output.
image_artfacts: Optional list of image artifacts.
+ reason: Optional reason why the test failed.
"""
test_id: str
status: str
duration_milliseconds: float = None
+ log: str = None
image_artifacts: list = None
+ reason: str = None
def IsPass(self):
"""Whether the test passed."""
@@ -724,21 +790,3 @@
def _GetTestId(input_basename):
"""Constructs a test ID by stripping the last extension from the basename."""
return os.path.splitext(input_basename)[0]
-
-
-class _TestTimer:
- """Context manager for measuring the duration of a test."""
-
- def __init__(self):
- self.duration_start = 0
- self.result = None
-
- def __enter__(self):
- self.duration_start = time.perf_counter_ns()
- return self
-
- def __exit__(self, exc_type, exc_value, traceback):
- if not self.result:
- return
- duration = time.perf_counter_ns() - self.duration_start
- self.result.duration_milliseconds = duration * 1e-6