Skip to content

Commit c2a9253

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 #25752, which implements similar thing in the parallel_runner.
1 parent 7738b5e commit c2a9253

File tree

3 files changed

+129
-4
lines changed

3 files changed

+129
-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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 SingleLineTestResult(unittest.TextTestResult):
23+
"""Similar to the standard TextTestResult but uses ANSI escape codes
24+
for color output and reusing a single line on the terminal.
25+
26+
This class also takes care of redirectting `logging` output when
27+
in `buffer=True` mode.
28+
"""
29+
test_count = 0
30+
31+
def __init__(self, *args, **kwargs):
32+
super().__init__(*args, **kwargs)
33+
self.progress_counter = 0
34+
35+
def writeStatusLine(self, line):
36+
clearline(self._original_stderr)
37+
self._original_stderr.write(line)
38+
self._original_stderr.flush()
39+
40+
def updateStatus(self, test, msg, color):
41+
self.progress_counter += 1
42+
progress = f'[{self.progress_counter}/{self.test_count}] '
43+
# Format the line so that it fix within the terminal width, unless its less then min_len
44+
# in which case there is not much we can do, and we just overflow the line.
45+
min_len = len(progress) + len(msg) + 5
46+
test_name = str(test)
47+
if term_width() > min_len:
48+
max_name = term_width() - min_len
49+
test_name = test_name[:max_name]
50+
line = f'{with_color(CYAN, progress)}{test_name} ... {with_color(color, msg)}'
51+
self.writeStatusLine(line)
52+
53+
def startTest(self, test):
54+
assert self.test_count > 0
55+
# Note: We explicitly do not use `super()` here but instead call `unittest.TestResult`. i.e.
56+
# we skip the superclass (since we don't want its specific behaviour) and instead call its
57+
# superclass.
58+
unittest.TestResult.startTest(self, test)
59+
# When we start a test in buffering mode, also update the python
60+
# loggers to redirect to the _stderr_buffer
61+
if self.buffer:
62+
for handler in logging.root.handlers:
63+
if handler.stream == self._original_stderr:
64+
handler.stream = self._stderr_buffer
65+
if self.progress_counter == 0:
66+
self.writeStatusLine('.. awaiting first test result')
67+
68+
def stopTest(self, test):
69+
unittest.TestResult.stopTest(self, test)
70+
if self.buffer:
71+
for handler in logging.root.handlers:
72+
if handler.stream == self._stderr_buffer:
73+
handler.stream = self._original_stderr
74+
75+
def addSuccess(self, test):
76+
unittest.TestResult.addSuccess(self, test)
77+
self.updateStatus(test, 'ok', GREEN)
78+
79+
def addFailure(self, test, err):
80+
unittest.TestResult.addFailure(self, test, err)
81+
self.updateStatus(test, 'FAIL', RED)
82+
83+
def addError(self, test, err):
84+
unittest.TestResult.addError(self, test, err)
85+
self.updateStatus(test, 'ERROR', RED)
86+
87+
def addExpectedFailure(self, test, err):
88+
unittest.TestResult.addExpectedFailure(self, test, err)
89+
self.updateStatus(test, 'expected failure', RED)
90+
91+
def addUnexpectedSuccess(self, test, err):
92+
unittest.TestResult.addUnexpectedSuccess(self, test, err)
93+
self.updateStatus(test, 'UNEXPECTED SUCCESS', RED)
94+
95+
def addSkip(self, test, reason):
96+
unittest.TestResult.addSkip(self, test, reason)
97+
self.updateStatus(test, f"skipped '{reason}'", CYAN)
98+
99+
100+
class SingleLineTestRunner(unittest.TextTestRunner):
101+
"""Subclass of TextTestResult that uses SingleLineTestResult"""
102+
resultclass = SingleLineTestResult
103+
104+
def __init__(self, *args, **kwargs):
105+
super().__init__(*args, buffer=True, **kwargs)
106+
107+
def _makeResult(self):
108+
result = super()._makeResult()
109+
result.test_count = self.test_count
110+
return result
111+
112+
def run(self, test):
113+
self.test_count = test.countTestCases()
114+
return super().run(test)

0 commit comments

Comments
 (0)