Skip to content

Commit 4a14cd3

Browse files
committed
Add SingleLineTestRunner/Result. NFC
This test runner does a few things differ the base TextTestRunner: 1. It improves the behviour of `--buffer` by also buffering/redirecting logging output that occurs during the test run. 2. It displays all results on a single line, each result erasing the contents of the line before re-drawing it. 3. It uses ANSI colors to the show the results. 4. It should the progress as each results is displayed so its easy to see how far you are through the test suite "[XX/YY]" I also updated parallel_testsuite.py use the same "XX/YY" progress rather than a percent. See emscripten-core#25752, which implements similar thing in the parallel_runner.
1 parent 36d6f60 commit 4a14cd3

File tree

3 files changed

+142
-4
lines changed

3 files changed

+142
-4
lines changed

test/parallel_testsuite.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,8 @@ def addTest(self, test):
125125
test.is_parallel = True
126126

127127
def printOneResult(self, res):
128-
percent = int(self.progress_counter * 100 / self.num_tests)
129-
progress = f'[{percent:2d}%] '
130128
self.progress_counter += 1
129+
progress = f'[{self.progress_counter}/{self.num_tests}] '
131130

132131
if res.test_result == 'success':
133132
msg = 'ok'
@@ -165,7 +164,7 @@ def run(self, result):
165164
# multiprocessing.set_start_method('spawn')
166165

167166
tests = self.get_sorted_tests()
168-
self.num_tests = len(tests)
167+
self.num_tests = self.countTestCases()
169168
contains_browser_test = any(test.is_browser_test() for test in tests)
170169
use_cores = cap_max_workers_in_pool(min(self.max_cores, len(tests), num_cores()), contains_browser_test)
171170
errlog(f'Using {use_cores} parallel test processes')

test/runner.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
import jsrun
4242
import parallel_testsuite
4343
from common import errlog
44+
from single_line_runner import SingleLineTestRunner
4445

4546
from tools import config, shared, utils
47+
from tools.colored_logger import ansi_color_available
4648

4749
logger = logging.getLogger("runner")
4850

@@ -427,8 +429,12 @@ def run_tests(options, suites):
427429
testRunner = xmlrunner.XMLTestRunner(output=output, verbosity=2,
428430
failfast=options.failfast)
429431
print('Writing XML test output to ' + os.path.abspath(output.name))
432+
elif options.buffer and options.ansi and not options.verbose:
433+
# And buffering is enabled and ansi color output is available use our nice single-line
434+
# result display.
435+
testRunner = SingleLineTestRunner(verbosity=2, failfast=options.failfast)
430436
else:
431-
testRunner = unittest.TextTestRunner(verbosity=2, buffer=options.buffer, failfast=options.failfast)
437+
testRunner = unittest.TextTestRunner(verbosity=2, failfast=options.failfast)
432438

433439
total_core_time = 0
434440
run_start_time = time.perf_counter()
@@ -467,6 +473,9 @@ def parse_args():
467473
parser.add_argument('--no-clean', action='store_true',
468474
help='Do not clean the temporary directory before each test run')
469475
parser.add_argument('--verbose', '-v', action='store_true')
476+
# TODO: Replace with BooleanOptionalAction once we can depend on python3.9
477+
parser.add_argument('--ansi', action='store_true', default=None)
478+
parser.add_argument('--no-ansi', action='store_false', dest='ansi', default=None)
470479
parser.add_argument('--all-engines', action='store_true')
471480
parser.add_argument('--detect-leaks', action='store_true')
472481
parser.add_argument('--skip-slow', action='store_true', help='Skip tests marked as slow')
@@ -499,6 +508,9 @@ def parse_args():
499508

500509
options = parser.parse_args()
501510

511+
if options.ansi is None:
512+
options.ansi = ansi_color_available()
513+
502514
if options.failfast:
503515
if options.max_failures != 2**31 - 1:
504516
utils.exit_with_error('--failfast and --max-failures are mutually exclusive!')

test/single_line_runner.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright 2025 The Emscripten Authors. All rights reserved.
2+
# Emscripten is available under two separate licenses, the MIT license and the
3+
# University of Illinois/NCSA Open Source License. Both these licenses can be
4+
# found in the LICENSE file.
5+
6+
import logging
7+
import shutil
8+
import unittest
9+
10+
from tools.colored_logger import CYAN, GREEN, RED, with_color
11+
12+
13+
def clearline(stream):
14+
stream.write('\r\033[K')
15+
stream.flush()
16+
17+
18+
def term_width():
19+
return shutil.get_terminal_size()[0]
20+
21+
22+
class BufferingMixin:
23+
"""This class takes care of redirectting `logging` output in `buffer=True` mode.
24+
25+
To use this class inherit from it along with a one of the standard unittest result
26+
classe.
27+
"""
28+
def _setupStdout(self):
29+
super()._setupStdout()
30+
# In addition to redirecting sys.stderr and sys.stdout, also update the python
31+
# loggers have cached versions of these.
32+
if self.buffer:
33+
for handler in logging.root.handlers:
34+
if handler.stream == self._original_stderr:
35+
handler.stream = self._stderr_buffer
36+
37+
def _restoreStdout(self):
38+
super()._restoreStdout()
39+
if self.buffer:
40+
for handler in logging.root.handlers:
41+
if handler.stream == self._stderr_buffer:
42+
handler.stream = self._original_stderr
43+
44+
45+
class SingleLineTestResult(BufferingMixin, unittest.TextTestResult):
46+
"""Similar to the standard TextTestResult but uses ANSI escape codes
47+
for color output and reusing a single line on the terminal.
48+
"""
49+
test_count = 0
50+
51+
def __init__(self, *args, **kwargs):
52+
super().__init__(*args, **kwargs)
53+
self.progress_counter = 0
54+
55+
def writeStatusLine(self, line):
56+
clearline(self._original_stderr)
57+
self._original_stderr.write(line)
58+
self._original_stderr.flush()
59+
60+
def updateStatus(self, test, msg, color):
61+
self.progress_counter += 1
62+
progress = f'[{self.progress_counter}/{self.test_count}] '
63+
# Format the line so that it fix within the terminal width, unless its less then min_len
64+
# in which case there is not much we can do, and we just overflow the line.
65+
min_len = len(progress) + len(msg) + 5
66+
test_name = str(test)
67+
if term_width() > min_len:
68+
max_name = term_width() - min_len
69+
test_name = test_name[:max_name]
70+
line = f'{with_color(CYAN, progress)}{test_name} ... {with_color(color, msg)}'
71+
self.writeStatusLine(line)
72+
73+
def startTest(self, test):
74+
assert self.test_count > 0
75+
# Note: We explicitly do not use `super()` here but instead call `unittest.TestResult`. i.e.
76+
# we skip the superclass (since we don't want its specific behaviour) and instead call its
77+
# superclass.
78+
unittest.TestResult.startTest(self, test)
79+
if self.progress_counter == 0:
80+
self.writeStatusLine('.. awaiting first test result')
81+
82+
def addSuccess(self, test):
83+
unittest.TestResult.addSuccess(self, test)
84+
self.updateStatus(test, 'ok', GREEN)
85+
86+
def addFailure(self, test, err):
87+
unittest.TestResult.addFailure(self, test, err)
88+
self.updateStatus(test, 'FAIL', RED)
89+
90+
def addError(self, test, err):
91+
unittest.TestResult.addError(self, test, err)
92+
self.updateStatus(test, 'ERROR', RED)
93+
94+
def addExpectedFailure(self, test, err):
95+
unittest.TestResult.addExpectedFailure(self, test, err)
96+
self.updateStatus(test, 'expected failure', RED)
97+
98+
def addUnexpectedSuccess(self, test, err):
99+
unittest.TestResult.addUnexpectedSuccess(self, test, err)
100+
self.updateStatus(test, 'UNEXPECTED SUCCESS', RED)
101+
102+
def addSkip(self, test, reason):
103+
unittest.TestResult.addSkip(self, test, reason)
104+
self.updateStatus(test, f"skipped '{reason}'", CYAN)
105+
106+
def printErrors(self):
107+
# All tests have been run at this point so print a final newline
108+
# to end out status line
109+
self._original_stderr.write('\n')
110+
super().printErrors()
111+
112+
113+
class SingleLineTestRunner(unittest.TextTestRunner):
114+
"""Subclass of TextTestResult that uses SingleLineTestResult"""
115+
resultclass = SingleLineTestResult
116+
117+
def __init__(self, *args, **kwargs):
118+
super().__init__(*args, buffer=True, **kwargs)
119+
120+
def _makeResult(self):
121+
result = super()._makeResult()
122+
result.test_count = self.test_count
123+
return result
124+
125+
def run(self, test):
126+
self.test_count = test.countTestCases()
127+
return super().run(test)

0 commit comments

Comments
 (0)