Skip to content

Commit 3aa4dfc

Browse files
committed
Add optional colorized docker test output
Add a --color flag to the local docker runner and pass it through to sbin/run_tests.py. Implement ANSI colorization for streamed unittest output so per-test statuses and final summaries are easier to scan: - ok in green - skipped in yellow - FAIL/ERROR in red - "Ran ..." summary in cyan Color mode supports auto/always/never and respects NO_COLOR, FORCE_COLOR, and CLICOLOR_FORCE in auto mode. Keep non-color mode as a fast raw passthrough path.
1 parent de69ca3 commit 3aa4dfc

3 files changed

Lines changed: 135 additions & 6 deletions

File tree

docker/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ Text and Package Control versions) plus the generated schedule.
7676
ut-run-tests . --dry-run
7777
```
7878

79+
## Colored output
80+
81+
Use `--color` to control ANSI colors in test output:
82+
83+
- `--color auto` (default): color only when stdout is a TTY
84+
- `--color always`: force color
85+
- `--color never`: disable color
86+
87+
```sh
88+
ut-run-tests . --color always
89+
```
90+
7991
## Run a single test file
8092

8193
```sh

docker/run_tests.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def main(argv: list[str] | None = None) -> int:
8383
failfast=args.failfast,
8484
reload_package_on_testing=args.reload_package_on_testing,
8585
dry_run=args.dry_run,
86+
color=args.color,
8687
tests_dir=tests_dir,
8788
pattern=pattern,
8889
)
@@ -139,6 +140,12 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
139140
action="store_true",
140141
help="Only print runtime metadata and schedule.",
141142
)
143+
test_group.add_argument(
144+
"--color",
145+
choices=("auto", "always", "never"),
146+
default="auto",
147+
help="Colorize test output (default: auto).",
148+
)
142149

143150
docker_group = parser.add_argument_group("docker options")
144151
docker_group.add_argument(
@@ -340,6 +347,7 @@ def build_docker_run_command(
340347
failfast: bool,
341348
reload_package_on_testing: bool,
342349
dry_run: bool,
350+
color: str,
343351
tests_dir: str | None,
344352
pattern: str | None,
345353
) -> list[str]:
@@ -374,6 +382,8 @@ def build_docker_run_command(
374382
if dry_run:
375383
command.append("--dry-run")
376384

385+
command.extend(["--color", color])
386+
377387
if tests_dir:
378388
command.extend(["--tests-dir", tests_dir])
379389

sbin/run_tests.py

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@
2828
SCHEDULE_RUNNER_TARGET = os.path.join(UT_DIR_PATH, "zzz_run_scheduler.py")
2929
RX_RESULT = re.compile(r'^(?P<result>OK|FAILED|ERROR)', re.MULTILINE)
3030
RX_DONE = re.compile(r'^UnitTesting: Done\.$', re.MULTILINE)
31+
RX_TEST_STATUS = re.compile(r'\.\.\. (ok|FAIL|ERROR|skipped)(\b.*)$')
32+
RX_SUMMARY_OK = re.compile(r'^OK(?:\b.*)?$')
33+
RX_SUMMARY_FAIL = re.compile(r'^(FAILED|ERROR)(?:\b.*)?$')
34+
35+
ANSI_RESET = "\033[0m"
36+
ANSI_GREEN = "\033[32m"
37+
ANSI_RED = "\033[31m"
38+
ANSI_YELLOW = "\033[33m"
39+
ANSI_CYAN = "\033[36m"
3140

3241
_is_windows = sys.platform == 'win32'
3342

@@ -113,9 +122,11 @@ def kill_sublime_text():
113122
subprocess.Popen("pkill plugin_host || true", shell=True)
114123

115124

116-
def read_output(path):
125+
def read_output(path, color='auto'):
117126
# todo: use notification instead of polling
118127
success = None
128+
use_color = should_use_color(color)
129+
pending = ""
119130

120131
def check_is_success(result):
121132
try:
@@ -131,8 +142,13 @@ def check_is_done(result):
131142
offset = f.tell()
132143
result = f.read()
133144

134-
print(result, end="")
135-
sys.stdout.flush()
145+
if result:
146+
if use_color:
147+
rendered, pending = colorize_output_chunk(result, pending)
148+
print(rendered, end="")
149+
else:
150+
print(result, end="")
151+
sys.stdout.flush()
136152

137153
# Keep checking while we don't have a definite result.
138154
success = check_is_success(result)
@@ -145,9 +161,93 @@ def check_is_done(result):
145161

146162
time.sleep(0.2)
147163

164+
if use_color and pending:
165+
print(colorize_output_line(pending), end="")
166+
sys.stdout.flush()
167+
148168
return success
149169

150170

171+
def should_use_color(mode):
172+
if mode == 'always':
173+
return True
174+
175+
if mode == 'never':
176+
return False
177+
178+
if os.environ.get('NO_COLOR') is not None:
179+
return False
180+
181+
if os.environ.get('CLICOLOR_FORCE') not in (None, '', '0'):
182+
return True
183+
184+
if os.environ.get('FORCE_COLOR') not in (None, '', '0'):
185+
return True
186+
187+
try:
188+
return sys.stdout.isatty()
189+
except Exception:
190+
return False
191+
192+
193+
def colorize_output_chunk(chunk, pending):
194+
if not chunk:
195+
return '', pending
196+
197+
text = pending + chunk
198+
lines = text.splitlines(True)
199+
200+
if lines and not lines[-1].endswith(('\n', '\r')):
201+
pending = lines.pop()
202+
else:
203+
pending = ''
204+
205+
rendered = ''.join(colorize_output_line(line) for line in lines)
206+
return rendered, pending
207+
208+
209+
def colorize_output_line(line):
210+
newline = ''
211+
body = line
212+
213+
if body.endswith('\r\n'):
214+
body = body[:-2]
215+
newline = '\r\n'
216+
elif body.endswith('\n') or body.endswith('\r'):
217+
body = body[:-1]
218+
newline = line[-1]
219+
220+
test_status_match = RX_TEST_STATUS.search(body)
221+
if test_status_match:
222+
status = test_status_match.group(1)
223+
suffix = test_status_match.group(2)
224+
body = (
225+
body[:test_status_match.start()]
226+
+ '... '
227+
+ colorize_status(status)
228+
+ suffix
229+
)
230+
231+
if RX_SUMMARY_OK.match(body):
232+
body = ANSI_GREEN + body + ANSI_RESET
233+
elif RX_SUMMARY_FAIL.match(body):
234+
body = ANSI_RED + body + ANSI_RESET
235+
elif body.startswith('Ran '):
236+
body = ANSI_CYAN + body + ANSI_RESET
237+
238+
return body + newline
239+
240+
241+
def colorize_status(status):
242+
if status == 'ok':
243+
return ANSI_GREEN + status + ANSI_RESET
244+
245+
if status == 'skipped':
246+
return ANSI_YELLOW + status + ANSI_RESET
247+
248+
return ANSI_RED + status + ANSI_RESET
249+
250+
151251
def restore_coverage_file(path, package):
152252
# restore .coverage if it exists, needed for coveralls
153253
if os.path.exists(path):
@@ -207,7 +307,7 @@ def detect_package_control_version():
207307
return str(version) if version else None
208308

209309

210-
def main(default_schedule_info, dry_run=False):
310+
def main(default_schedule_info, dry_run=False, color='auto'):
211311
package_under_test = default_schedule_info['package']
212312
output_dir = os.path.join(UT_OUTPUT_DIR_PATH, package_under_test)
213313
output_file = os.path.join(output_dir, "result")
@@ -247,7 +347,7 @@ def main(default_schedule_info, dry_run=False):
247347
time.sleep(2)
248348

249349
print("Start to read output...")
250-
if not read_output(output_file):
350+
if not read_output(output_file, color=color):
251351
sys.exit(1)
252352
restore_coverage_file(coverage_file, package_under_test)
253353
delete_file_if_exists(SCHEDULE_RUNNER_TARGET)
@@ -264,6 +364,13 @@ def main(default_schedule_info, dry_run=False):
264364
parser.add_option('--failfast', action='store_true')
265365
parser.add_option('--reload-package-on-testing', action='store_true')
266366
parser.add_option('--dry-run', action='store_true')
367+
parser.add_option(
368+
'--color',
369+
type='choice',
370+
choices=['auto', 'always', 'never'],
371+
default='auto',
372+
help='Colorize test output (auto, always, never).',
373+
)
267374

268375
options, remainder = parser.parse_args()
269376

@@ -294,4 +401,4 @@ def main(default_schedule_info, dry_run=False):
294401
if options.reload_package_on_testing:
295402
default_schedule_info['reload_package_on_testing'] = True
296403

297-
main(default_schedule_info, dry_run=options.dry_run)
404+
main(default_schedule_info, dry_run=options.dry_run, color=options.color)

0 commit comments

Comments
 (0)