Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
safetynet_compare.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# Copyright 2017 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"""Compares the performance of two versions of the pdfium code."""
6
7import argparse
8import functools
9import glob
10import json
11import multiprocessing
12import os
13import re
14import shutil
15import subprocess
16import sys
17import tempfile
18
19from common import GetBooleanGnArg
20from common import PrintErr
21from common import RunCommandPropagateErr
22from githelper import GitHelper
23from safetynet_conclusions import ComparisonConclusions
24from safetynet_conclusions import PrintConclusionsDictHumanReadable
25from safetynet_conclusions import RATING_IMPROVEMENT
26from safetynet_conclusions import RATING_REGRESSION
27from safetynet_image import ImageComparison
28
29
30def RunSingleTestCaseParallel(this, run_label, build_dir, test_case):
31 result = this.RunSingleTestCase(run_label, build_dir, test_case)
32 return (test_case, result)
33
34
36 """A comparison between two branches of pdfium."""
37
38 def __init__(self, args):
39 self.git = GitHelper()
40 self.args = args
41 self._InitPaths()
42
43 def _InitPaths(self):
44 if self.args.this_repo:
45 self.safe_script_dir = self.args.build_dir
46 else:
47 self.safe_script_dir = os.path.join('testing', 'tools')
48
49 self.safe_measure_script_path = os.path.abspath(
50 os.path.join(self.safe_script_dir, 'safetynet_measure.py'))
51
52 input_file_re = re.compile('^.+[.]pdf$')
53 self.test_cases = []
54 for input_path in self.args.input_paths:
55 if os.path.isfile(input_path):
56 self.test_cases.append(input_path)
57 elif os.path.isdir(input_path):
58 for file_dir, _, filename_list in os.walk(input_path):
59 for input_filename in filename_list:
60 if input_file_re.match(input_filename):
61 file_path = os.path.join(file_dir, input_filename)
62 if os.path.isfile(file_path):
63 self.test_cases.append(file_path)
64
65 self.after_build_dir = self.args.build_dir
66 if self.args.build_dir_before:
67 self.before_build_dir = self.args.build_dir_before
68 else:
70
71 def Run(self):
72 """Runs comparison by checking out branches, building and measuring them.
73
74 Returns:
75 Exit code for the script.
76 """
77 if self.args.this_repo:
79
80 if self.args.branch_after:
81 if self.args.this_repo:
82 before, after = self._ProfileTwoOtherBranchesInThisRepo(
83 self.args.branch_before, self.args.branch_after)
84 else:
85 before, after = self._ProfileTwoOtherBranches(self.args.branch_before,
86 self.args.branch_after)
87 elif self.args.branch_before:
88 if self.args.this_repo:
89 before, after = self._ProfileCurrentAndOtherBranchInThisRepo(
90 self.args.branch_before)
91 else:
92 before, after = self._ProfileCurrentAndOtherBranch(
93 self.args.branch_before)
94 else:
95 if self.args.this_repo:
97 else:
98 before, after = self._ProfileLocalChangesAndCurrentBranch()
99
100 conclusions = self._DrawConclusions(before, after)
101 conclusions_dict = conclusions.GetOutputDict()
102 conclusions_dict.setdefault('metadata', {})['profiler'] = self.args.profiler
103
104 self._PrintConclusions(conclusions_dict)
105
106 self._CleanUp(conclusions)
107
108 if self.args.png_dir:
109 image_comparison = ImageComparison(
110 self.after_build_dir, self.args.png_dir, ('before', 'after'),
111 self.args.num_workers, self.args.png_threshold)
112 image_comparison.Run(open_in_browser=not self.args.machine_readable)
113
114 return 0
115
117 """Freezes a version of the measuring script.
118
119 This is needed to make sure we are comparing the pdfium library changes and
120 not script changes that may happen between the two branches.
121 """
122 self.__FreezeFile(os.path.join('testing', 'tools', 'safetynet_measure.py'))
123 self.__FreezeFile(os.path.join('testing', 'tools', 'common.py'))
124
125 def __FreezeFile(self, filename):
126 RunCommandPropagateErr(['cp', filename, self.safe_script_dir],
127 exit_status_on_error=1)
128
129 def _ProfileTwoOtherBranchesInThisRepo(self, before_branch, after_branch):
130 """Profiles two branches that are not the current branch.
131
132 This is done in the local repository and changes may not be restored if the
133 script fails or is interrupted.
134
135 after_branch does not need to descend from before_branch, they will be
136 measured the same way
137
138 Args:
139 before_branch: One branch to profile.
140 after_branch: Other branch to profile.
141
142 Returns:
143 A tuple (before, after), where each of before and after is a dict
144 mapping a test case name to the profiling values for that test case
145 in the given branch.
146 """
147 branch_to_restore = self.git.GetCurrentBranchName()
148
149 self._StashLocalChanges()
150
151 self._CheckoutBranch(after_branch)
153 after = self._MeasureCurrentBranch('after', self.after_build_dir)
154
155 self._CheckoutBranch(before_branch)
157 before = self._MeasureCurrentBranch('before', self.before_build_dir)
158
159 self._CheckoutBranch(branch_to_restore)
161
162 return before, after
163
164 def _ProfileTwoOtherBranches(self, before_branch, after_branch):
165 """Profiles two branches that are not the current branch.
166
167 This is done in new, cloned repositories, therefore it is safer but slower
168 and requires downloads.
169
170 after_branch does not need to descend from before_branch, they will be
171 measured the same way
172
173 Args:
174 before_branch: One branch to profile.
175 after_branch: Other branch to profile.
176
177 Returns:
178 A tuple (before, after), where each of before and after is a dict
179 mapping a test case name to the profiling values for that test case
180 in the given branch.
181 """
182 after = self._ProfileSeparateRepo('after', self.after_build_dir,
183 after_branch)
184 before = self._ProfileSeparateRepo('before', self.before_build_dir,
185 before_branch)
186 return before, after
187
189 """Profiles the current branch (with uncommitted changes) and another one.
190
191 This is done in the local repository and changes may not be restored if the
192 script fails or is interrupted.
193
194 The current branch does not need to descend from other_branch.
195
196 Args:
197 other_branch: Other branch to profile that is not the current.
198
199 Returns:
200 A tuple (before, after), where each of before and after is a dict
201 mapping a test case name to the profiling values for that test case
202 in the given branch. The current branch is considered to be "after" and
203 the other branch is considered to be "before".
204 """
205 branch_to_restore = self.git.GetCurrentBranchName()
206
208 after = self._MeasureCurrentBranch('after', self.after_build_dir)
209
210 self._StashLocalChanges()
211
212 self._CheckoutBranch(other_branch)
214 before = self._MeasureCurrentBranch('before', self.before_build_dir)
215
216 self._CheckoutBranch(branch_to_restore)
218
219 return before, after
220
221 def _ProfileCurrentAndOtherBranch(self, other_branch):
222 """Profiles the current branch (with uncommitted changes) and another one.
223
224 This is done in new, cloned repositories, therefore it is safer but slower
225 and requires downloads.
226
227 The current branch does not need to descend from other_branch.
228
229 Args:
230 other_branch: Other branch to profile that is not the current. None will
231 compare to the same branch.
232
233 Returns:
234 A tuple (before, after), where each of before and after is a dict
235 mapping a test case name to the profiling values for that test case
236 in the given branch. The current branch is considered to be "after" and
237 the other branch is considered to be "before".
238 """
240 after = self._MeasureCurrentBranch('after', self.after_build_dir)
241
242 before = self._ProfileSeparateRepo('before', self.before_build_dir,
243 other_branch)
244
245 return before, after
246
248 """Profiles the current branch with and without uncommitted changes.
249
250 This is done in the local repository and changes may not be restored if the
251 script fails or is interrupted.
252
253 Returns:
254 A tuple (before, after), where each of before and after is a dict
255 mapping a test case name to the profiling values for that test case
256 using the given version. The current branch without uncommitted changes is
257 considered to be "before" and with uncommitted changes is considered to be
258 "after".
259 """
261 after = self._MeasureCurrentBranch('after', self.after_build_dir)
262
263 pushed = self._StashLocalChanges()
264 if not pushed and not self.args.build_dir_before:
265 PrintErr('Warning: No local changes to compare')
266
267 before_build_dir = self.before_build_dir
268
269 self._BuildCurrentBranch(before_build_dir)
270 before = self._MeasureCurrentBranch('before', before_build_dir)
271
273
274 return before, after
275
277 """Profiles the current branch with and without uncommitted changes.
278
279 This is done in new, cloned repositories, therefore it is safer but slower
280 and requires downloads.
281
282 Returns:
283 A tuple (before, after), where each of before and after is a dict
284 mapping a test case name to the profiling values for that test case
285 using the given version. The current branch without uncommitted changes is
286 considered to be "before" and with uncommitted changes is considered to be
287 "after".
288 """
289 return self._ProfileCurrentAndOtherBranch(other_branch=None)
290
291 def _ProfileSeparateRepo(self, run_label, relative_build_dir, branch):
292 """Profiles a branch in a a temporary git repository.
293
294 Args:
295 run_label: String to differentiate this version of the code in output
296 files from other versions.
297 relative_build_dir: Path to the build dir in the current working dir to
298 clone build args from.
299 branch: Branch to checkout in the new repository. None will
300 profile the same branch checked out in the original repo.
301 Returns:
302 A dict mapping each test case name to the profiling values for that
303 test case.
304 """
305 build_dir = self._CreateTempRepo('repo_%s' % run_label, relative_build_dir,
306 branch)
307
308 self._BuildCurrentBranch(build_dir)
309 return self._MeasureCurrentBranch(run_label, build_dir)
310
311 def _CreateTempRepo(self, dir_name, relative_build_dir, branch):
312 """Clones a temporary git repository out of the current working dir.
313
314 Args:
315 dir_name: Name for the temporary repository directory
316 relative_build_dir: Path to the build dir in the current working dir to
317 clone build args from.
318 branch: Branch to checkout in the new repository. None will keep checked
319 out the same branch as the local repo.
320 Returns:
321 Path to the build directory of the new repository.
322 """
323 cwd = os.getcwd()
324
325 repo_dir = tempfile.mkdtemp(suffix='-%s' % dir_name)
326 src_dir = os.path.join(repo_dir, 'pdfium')
327
328 self.git.CloneLocal(os.getcwd(), src_dir)
329
330 if branch is not None:
331 os.chdir(src_dir)
332 self.git.Checkout(branch)
333
334 os.chdir(repo_dir)
335 PrintErr('Syncing...')
336
337 cmd = [
338 'gclient', 'config', '--unmanaged',
339 'https://pdfium.googlesource.com/pdfium.git'
340 ]
341 if self.args.cache_dir:
342 cmd.append('--cache-dir=%s' % self.args.cache_dir)
343 RunCommandPropagateErr(cmd, exit_status_on_error=1)
344
345 RunCommandPropagateErr(['gclient', 'sync', '--force'],
346 exit_status_on_error=1)
347
348 PrintErr('Done.')
349
350 build_dir = os.path.join(src_dir, relative_build_dir)
351 os.makedirs(build_dir)
352 os.chdir(src_dir)
353
354 source_gn_args = os.path.join(cwd, relative_build_dir, 'args.gn')
355 dest_gn_args = os.path.join(build_dir, 'args.gn')
356 shutil.copy(source_gn_args, dest_gn_args)
357
358 RunCommandPropagateErr(['gn', 'gen', relative_build_dir],
359 exit_status_on_error=1)
360
361 os.chdir(cwd)
362
363 return build_dir
364
365 def _CheckoutBranch(self, branch):
366 PrintErr("Checking out branch '%s'" % branch)
367 self.git.Checkout(branch)
368
370 PrintErr('Stashing local changes')
371 return self.git.StashPush()
372
374 PrintErr('Restoring local changes')
375 self.git.StashPopAll()
376
377 def _BuildCurrentBranch(self, build_dir):
378 """Synchronizes and builds the current version of pdfium.
379
380 Args:
381 build_dir: String with path to build directory
382 """
383 PrintErr('Syncing...')
384 RunCommandPropagateErr(['gclient', 'sync', '--force'],
385 exit_status_on_error=1)
386 PrintErr('Done.')
387
388 PrintErr('Building...')
389 cmd = ['autoninja', '-C', build_dir, 'pdfium_test']
390 RunCommandPropagateErr(cmd, stdout_has_errors=True, exit_status_on_error=1)
391 PrintErr('Done.')
392
393 def _MeasureCurrentBranch(self, run_label, build_dir):
394 PrintErr('Measuring...')
395 if self.args.num_workers > 1 and len(self.test_cases) > 1:
396 results = self._RunAsync(run_label, build_dir)
397 else:
398 results = self._RunSync(run_label, build_dir)
399 PrintErr('Done.')
400
401 return results
402
403 def _RunSync(self, run_label, build_dir):
404 """Profiles the test cases synchronously.
405
406 Args:
407 run_label: String to differentiate this version of the code in output
408 files from other versions.
409 build_dir: String with path to build directory
410
411 Returns:
412 A dict mapping each test case name to the profiling values for that
413 test case.
414 """
415 results = {}
416
417 for test_case in self.test_cases:
418 result = self.RunSingleTestCase(run_label, build_dir, test_case)
419 if result is not None:
420 results[test_case] = result
421
422 return results
423
424 def _RunAsync(self, run_label, build_dir):
425 """Profiles the test cases asynchronously.
426
427 Uses as many workers as configured by --num-workers.
428
429 Args:
430 run_label: String to differentiate this version of the code in output
431 files from other versions.
432 build_dir: String with path to build directory
433
434 Returns:
435 A dict mapping each test case name to the profiling values for that
436 test case.
437 """
438 results = {}
439 pool = multiprocessing.Pool(self.args.num_workers)
440 worker_func = functools.partial(RunSingleTestCaseParallel, self, run_label,
441 build_dir)
442
443 try:
444 # The timeout is a workaround for http://bugs.python.org/issue8296
445 # which prevents KeyboardInterrupt from working.
446 one_year_in_seconds = 3600 * 24 * 365
447 worker_results = (
448 pool.map_async(worker_func, self.test_cases).get(one_year_in_seconds))
449 for worker_result in worker_results:
450 test_case, result = worker_result
451 if result is not None:
452 results[test_case] = result
453 except KeyboardInterrupt:
454 pool.terminate()
455 sys.exit(1)
456 else:
457 pool.close()
458
459 pool.join()
460
461 return results
462
463 def RunSingleTestCase(self, run_label, build_dir, test_case):
464 """Profiles a single test case.
465
466 Args:
467 run_label: String to differentiate this version of the code in output
468 files from other versions.
469 build_dir: String with path to build directory
470 test_case: Path to the test case.
471
472 Returns:
473 The measured profiling value for that test case.
474 """
475 command = [
476 self.safe_measure_script_path, test_case,
477 '--build-dir=%s' % build_dir
478 ]
479
480 if self.args.interesting_section:
481 command.append('--interesting-section')
482
483 if self.args.profiler:
484 command.append('--profiler=%s' % self.args.profiler)
485
486 profile_file_path = self._GetProfileFilePath(run_label, test_case)
487 if profile_file_path:
488 command.append('--output-path=%s' % profile_file_path)
489
490 if self.args.png_dir:
491 command.append('--png')
492
493 if self.args.pages:
494 command.extend(['--pages', self.args.pages])
495
496 output = RunCommandPropagateErr(command)
497
498 if output is None:
499 return None
500
501 if self.args.png_dir:
502 self._MoveImages(test_case, run_label)
503
504 # Get the time number as output, making sure it's just a number
505 output = output.strip()
506 if re.match('^[0-9]+$', output):
507 return int(output)
508
509 return None
510
511 def _MoveImages(self, test_case, run_label):
512 png_dir = os.path.join(self.args.png_dir, run_label)
513 if not os.path.exists(png_dir):
514 os.makedirs(png_dir)
515
516 test_case_dir, test_case_filename = os.path.split(test_case)
517 test_case_png_matcher = '%s.*.png' % test_case_filename
518 for output_png in glob.glob(
519 os.path.join(test_case_dir, test_case_png_matcher)):
520 shutil.move(output_png, png_dir)
521
522 def _GetProfileFilePath(self, run_label, test_case):
523 if self.args.output_dir:
524 output_filename = (
525 'callgrind.out.%s.%s' % (test_case.replace('/', '_'), run_label))
526 return os.path.join(self.args.output_dir, output_filename)
527 return None
528
529 def _DrawConclusions(self, times_before_branch, times_after_branch):
530 """Draws conclusions comparing results of test runs in two branches.
531
532 Args:
533 times_before_branch: A dict mapping each test case name to the
534 profiling values for that test case in the branch to be considered
535 as the baseline.
536 times_after_branch: A dict mapping each test case name to the
537 profiling values for that test case in the branch to be considered
538 as the new version.
539
540 Returns:
541 ComparisonConclusions with all test cases processed.
542 """
543 conclusions = ComparisonConclusions(self.args.threshold_significant)
544
545 for test_case in sorted(self.test_cases):
546 before = times_before_branch.get(test_case)
547 after = times_after_branch.get(test_case)
548 conclusions.ProcessCase(test_case, before, after)
549
550 return conclusions
551
552 def _PrintConclusions(self, conclusions_dict):
553 """Prints the conclusions as the script output.
554
555 Depending on the script args, this can output a human or a machine-readable
556 version of the conclusions.
557
558 Args:
559 conclusions_dict: Dict to print returned from
560 ComparisonConclusions.GetOutputDict().
561 """
562 if self.args.machine_readable:
563 print(json.dumps(conclusions_dict))
564 else:
565 PrintConclusionsDictHumanReadable(
566 conclusions_dict, colored=True, key=self.args.case_order)
567
568 def _CleanUp(self, conclusions):
569 """Removes profile output files for uninteresting cases.
570
571 Cases without significant regressions or improvements and considered
572 uninteresting.
573
574 Args:
575 conclusions: A ComparisonConclusions.
576 """
577 if not self.args.output_dir:
578 return
579
580 if self.args.profiler != 'callgrind':
581 return
582
583 for case_result in conclusions.GetCaseResults().values():
584 if case_result.rating not in [RATING_REGRESSION, RATING_IMPROVEMENT]:
585 self._CleanUpOutputFile('before', case_result.case_name)
586 self._CleanUpOutputFile('after', case_result.case_name)
587
588 def _CleanUpOutputFile(self, run_label, case_name):
589 """Removes one profile output file.
590
591 If the output file does not exist, fails silently.
592
593 Args:
594 run_label: String to differentiate a version of the code in output
595 files from other versions.
596 case_name: String identifying test case for which to remove the output
597 file.
598 """
599 try:
600 os.remove(self._GetProfileFilePath(run_label, case_name))
601 except OSError:
602 pass
603
604
605def main():
606 parser = argparse.ArgumentParser()
607 parser.add_argument(
608 'input_paths',
609 nargs='+',
610 help='pdf files or directories to search for pdf files '
611 'to run as test cases')
612 parser.add_argument(
613 '--branch-before',
614 help='git branch to use as "before" for comparison. '
615 'Omitting this will use the current branch '
616 'without uncommitted changes as the baseline.')
617 parser.add_argument(
618 '--branch-after',
619 help='git branch to use as "after" for comparison. '
620 'Omitting this will use the current branch '
621 'with uncommitted changes.')
622 parser.add_argument(
623 '--build-dir',
624 default=os.path.join('out', 'Release'),
625 help='relative path from the base source directory '
626 'to the build directory')
627 parser.add_argument(
628 '--build-dir-before',
629 help='relative path from the base source directory '
630 'to the build directory for the "before" branch, if '
631 'different from the build directory for the '
632 '"after" branch')
633 parser.add_argument(
634 '--cache-dir',
635 default=None,
636 help='directory with a new or preexisting cache for '
637 'downloads. Default is to not use a cache.')
638 parser.add_argument(
639 '--this-repo',
640 action='store_true',
641 help='use the repository where the script is instead of '
642 'checking out a temporary one. This is faster and '
643 'does not require downloads, but although it '
644 'restores the state of the local repo, if the '
645 'script is killed or crashes the changes can remain '
646 'stashed and you may be on another branch.')
647 parser.add_argument(
648 '--profiler',
649 default='callgrind',
650 help='which profiler to use. Supports callgrind, '
651 'perfstat, and none. Default is callgrind.')
652 parser.add_argument(
653 '--interesting-section',
654 action='store_true',
655 help='whether to measure just the interesting section or '
656 'the whole test harness. Limiting to only the '
657 'interesting section does not work on Release since '
658 'the delimiters are optimized out')
659 parser.add_argument(
660 '--pages',
661 help='selects some pages to be rendered. Page numbers '
662 'are 0-based. "--pages A" will render only page A. '
663 '"--pages A-B" will render pages A to B '
664 '(inclusive).')
665 parser.add_argument(
666 '--num-workers',
667 default=multiprocessing.cpu_count(),
668 type=int,
669 help='run NUM_WORKERS jobs in parallel')
670 parser.add_argument(
671 '--output-dir', help='directory to write the profile data output files')
672 parser.add_argument(
673 '--png-dir',
674 default=None,
675 help='outputs pngs to the specified directory that can '
676 'be compared with a static html generated. Will '
677 'affect performance measurements.')
678 parser.add_argument(
679 '--png-threshold',
680 default=0.0,
681 type=float,
682 help='Requires --png-dir. Threshold above which a png '
683 'is considered to have changed.')
684 parser.add_argument(
685 '--threshold-significant',
686 default=0.02,
687 type=float,
688 help='variations in performance above this factor are '
689 'considered significant')
690 parser.add_argument(
691 '--machine-readable',
692 action='store_true',
693 help='whether to get output for machines. If enabled the '
694 'output will be a json with the format specified in '
695 'ComparisonConclusions.GetOutputDict(). Default is '
696 'human-readable.')
697 parser.add_argument(
698 '--case-order',
699 default=None,
700 help='what key to use when sorting test cases in the '
701 'output. Accepted values are "after", "before", '
702 '"ratio" and "rating". Default is sorting by test '
703 'case path.')
704
705 args = parser.parse_args()
706
707 # Always start at the pdfium src dir, which is assumed to be two level above
708 # this script.
709 pdfium_src_dir = os.path.join(
710 os.path.dirname(__file__), os.path.pardir, os.path.pardir)
711 os.chdir(pdfium_src_dir)
712
713 git = GitHelper()
714
715 if args.branch_after and not args.branch_before:
716 PrintErr('--branch-after requires --branch-before to be specified.')
717 return 1
718
719 if args.branch_after and not git.BranchExists(args.branch_after):
720 PrintErr('Branch "%s" does not exist' % args.branch_after)
721 return 1
722
723 if args.branch_before and not git.BranchExists(args.branch_before):
724 PrintErr('Branch "%s" does not exist' % args.branch_before)
725 return 1
726
727 if args.output_dir:
728 args.output_dir = os.path.expanduser(args.output_dir)
729 if not os.path.isdir(args.output_dir):
730 PrintErr('"%s" is not a directory' % args.output_dir)
731 return 1
732
733 if args.png_dir:
734 args.png_dir = os.path.expanduser(args.png_dir)
735 if not os.path.isdir(args.png_dir):
736 PrintErr('"%s" is not a directory' % args.png_dir)
737 return 1
738
739 if args.threshold_significant <= 0.0:
740 PrintErr('--threshold-significant should receive a positive float')
741 return 1
742
743 if args.png_threshold:
744 if not args.png_dir:
745 PrintErr('--png-threshold requires --png-dir to be specified.')
746 return 1
747
748 if args.png_threshold <= 0.0:
749 PrintErr('--png-threshold should receive a positive float')
750 return 1
751
752 if args.pages:
753 if not re.match(r'^\d+(-\d+)?$', args.pages):
754 PrintErr('Supported formats for --pages are "--pages 7" and '
755 '"--pages 3-6"')
756 return 1
757
758 run = CompareRun(args)
759 return run.Run()
760
761
762if __name__ == '__main__':
763 sys.exit(main())
_ProfileSeparateRepo(self, run_label, relative_build_dir, branch)
_CleanUpOutputFile(self, run_label, case_name)
_ProfileTwoOtherBranches(self, before_branch, after_branch)
_GetProfileFilePath(self, run_label, test_case)
RunSingleTestCase(self, run_label, build_dir, test_case)
_CreateTempRepo(self, dir_name, relative_build_dir, branch)
_DrawConclusions(self, times_before_branch, times_after_branch)
_MeasureCurrentBranch(self, run_label, build_dir)
_ProfileTwoOtherBranchesInThisRepo(self, before_branch, after_branch)
RunSingleTestCaseParallel(this, run_label, build_dir, test_case)
static QDBusError::ErrorType get(const char *name)
QDebug print(QDebug debug, QSslError::SslError error)
multiPart append(textPart)