Skip to content

Commit 7dd9069

Browse files
authored
Add SingleLineTestRunner/Result. NFC (#25755)
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]" Followups that I'm a planning: 1. Enable `--buffer` by default which will then enable this runner by default 2. Integrate with parallel running (for now you need use `-j1` to see the effect of this runner) 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. Hopefully once this change lands I can simplify #25752.
1 parent de3e4e7 commit 7dd9069

File tree

4 files changed

+188
-5
lines changed

4 files changed

+188
-5
lines changed

test/color_runner.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 unittest
8+
9+
from tools.colored_logger import CYAN, GREEN, RED, with_color
10+
11+
12+
class BufferingMixin:
13+
"""This class takes care of redirecting `logging` output in `buffer=True` mode.
14+
15+
To use this class inherit from it along with a one of the standard unittest result
16+
classes.
17+
"""
18+
def _setupStdout(self):
19+
super()._setupStdout()
20+
# In addition to redirecting sys.stderr and sys.stdout, also update the python
21+
# loggers which hold cached versions of these handles.
22+
if self.buffer:
23+
for handler in logging.root.handlers:
24+
if handler.stream == self._original_stderr:
25+
handler.stream = self._stderr_buffer
26+
27+
def _restoreStdout(self):
28+
super()._restoreStdout()
29+
if self.buffer:
30+
for handler in logging.root.handlers:
31+
if handler.stream == self._stderr_buffer:
32+
handler.stream = self._original_stderr
33+
34+
35+
class ProgressMixin:
36+
test_count = 0
37+
progress_counter = 0
38+
39+
def startTest(self, test):
40+
assert self.test_count > 0
41+
self.progress_counter += 1
42+
if self.showAll:
43+
progress = f'[{self.progress_counter}/{self.test_count}] '
44+
self.stream.write(with_color(CYAN, progress))
45+
super().startTest(test)
46+
47+
48+
class ColorTextResult(BufferingMixin, ProgressMixin, unittest.TextTestResult):
49+
"""Adds color the printed test result."""
50+
def _write_status(self, test, status):
51+
# Add some color to the status message
52+
if status == 'ok':
53+
color = GREEN
54+
elif status.isupper():
55+
color = RED
56+
else:
57+
color = CYAN
58+
super()._write_status(test, with_color(color, status))
59+
60+
61+
class ColorTextRunner(unittest.TextTestRunner):
62+
"""Subclass of TextTestRunner that uses ColorTextResult"""
63+
resultclass = ColorTextResult # type: ignore
64+
65+
def _makeResult(self):
66+
result = super()._makeResult()
67+
result.test_count = self.test_count
68+
return result
69+
70+
def run(self, test):
71+
self.test_count = test.countTestCases()
72+
return super().run(test)

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: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@
4040
import common
4141
import jsrun
4242
import parallel_testsuite
43+
from color_runner import ColorTextRunner
4344
from common import errlog
45+
from single_line_runner import SingleLineTestRunner
4446

45-
from tools import config, shared, utils
47+
from tools import colored_logger, config, shared, utils
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+
# When 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 = ColorTextRunner(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,14 @@ def parse_args():
499508

500509
options = parser.parse_args()
501510

511+
if options.ansi is None:
512+
options.ansi = colored_logger.ansi_color_available()
513+
else:
514+
if options.ansi:
515+
colored_logger.enable(force=True)
516+
else:
517+
colored_logger.disable()
518+
502519
if options.failfast:
503520
if options.max_failures != 2**31 - 1:
504521
utils.exit_with_error('--failfast and --max-failures are mutually exclusive!')

test/single_line_runner.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 shutil
7+
import unittest
8+
9+
from color_runner import BufferingMixin, ColorTextRunner
10+
11+
from tools.colored_logger import CYAN, GREEN, RED, with_color
12+
13+
14+
def clearline(stream):
15+
stream.write('\r\033[K')
16+
stream.flush()
17+
18+
19+
def term_width():
20+
return shutil.get_terminal_size()[0]
21+
22+
23+
class SingleLineTestResult(BufferingMixin, unittest.TextTestResult):
24+
"""Similar to the standard TextTestResult but uses ANSI escape codes
25+
for color output and reusing a single line on the terminal.
26+
"""
27+
28+
def __init__(self, *args, **kwargs):
29+
super().__init__(*args, **kwargs)
30+
self.progress_counter = 0
31+
32+
def writeStatusLine(self, line):
33+
clearline(self._original_stderr)
34+
self._original_stderr.write(line)
35+
self._original_stderr.flush()
36+
37+
def updateStatus(self, test, msg, color):
38+
progress = f'[{self.progress_counter}/{self.test_count}] '
39+
# Format the line so that it fix within the terminal width, unless it's less then min_len
40+
# in which case there is not much we can do, and we just overflow the line.
41+
min_len = len(progress) + len(msg) + 5
42+
test_name = str(test)
43+
if term_width() > min_len:
44+
max_name = term_width() - min_len
45+
test_name = test_name[:max_name]
46+
line = f'{with_color(CYAN, progress)}{test_name} ... {with_color(color, msg)}'
47+
self.writeStatusLine(line)
48+
49+
def startTest(self, test):
50+
self.progress_counter += 1
51+
assert self.test_count > 0
52+
# Note: We explicitly do not use `super()` here but instead call `unittest.TestResult`. i.e.
53+
# we skip the superclass (since we don't want its specific behaviour) and instead call its
54+
# superclass.
55+
unittest.TestResult.startTest(self, test)
56+
if self.progress_counter == 1:
57+
self.updateStatus(test, '', GREEN)
58+
59+
def addSuccess(self, test):
60+
unittest.TestResult.addSuccess(self, test)
61+
self.updateStatus(test, 'ok', GREEN)
62+
63+
def addFailure(self, test, err):
64+
unittest.TestResult.addFailure(self, test, err)
65+
self.updateStatus(test, 'FAIL', RED)
66+
67+
def addError(self, test, err):
68+
unittest.TestResult.addError(self, test, err)
69+
self.updateStatus(test, 'ERROR', RED)
70+
71+
def addExpectedFailure(self, test, err):
72+
unittest.TestResult.addExpectedFailure(self, test, err)
73+
self.updateStatus(test, 'expected failure', RED)
74+
75+
def addUnexpectedSuccess(self, test, err):
76+
unittest.TestResult.addUnexpectedSuccess(self, test, err)
77+
self.updateStatus(test, 'UNEXPECTED SUCCESS', RED)
78+
79+
def addSkip(self, test, reason):
80+
unittest.TestResult.addSkip(self, test, reason)
81+
self.updateStatus(test, f"skipped '{reason}'", CYAN)
82+
83+
def printErrors(self):
84+
# All tests have been run at this point so print a final newline
85+
# to end out status line
86+
self._original_stderr.write('\n')
87+
super().printErrors()
88+
89+
90+
class SingleLineTestRunner(ColorTextRunner):
91+
"""Subclass of TextTestResult that uses SingleLineTestResult"""
92+
resultclass = SingleLineTestResult # type: ignore
93+
94+
def __init__(self, *args, **kwargs):
95+
super().__init__(*args, buffer=True, **kwargs)

0 commit comments

Comments
 (0)