Skip to content

Commit 011cdda

Browse files
committed
Test Runner: --maxfail: Allow interrupt of test run after N failures
Also: * Test Runner: If >50 tests interrupted, print number of tests rather than all names of tests
1 parent fdffd6f commit 011cdda

File tree

4 files changed

+92
-15
lines changed

4 files changed

+92
-15
lines changed

src/crystal/main.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ def sys_unraisablehook(args) -> None:
293293
help='Print additional diagnostic information. Only applies with --parallel.',
294294
action='store_true',
295295
)
296+
test_parser.add_argument(
297+
'--maxfail',
298+
help='Stop running tests after N failures.',
299+
type=int,
300+
default=None,
301+
metavar='N',
302+
)
296303

297304
# Define main command
298305
parser.add_argument(
@@ -444,6 +451,12 @@ def sys_unraisablehook(args) -> None:
444451
(not hasattr(parsed_args, 'parallel') or not parsed_args.parallel)):
445452
print('error: -j/--jobs can only be used with -p/--parallel', file=sys.stderr)
446453
sys.exit(2)
454+
455+
# Validate --maxfail must be positive
456+
if (hasattr(parsed_args, 'maxfail') and parsed_args.maxfail is not None and
457+
parsed_args.maxfail <= 0):
458+
print('error: --maxfail must be a positive integer', file=sys.stderr)
459+
sys.exit(2)
447460
else:
448461
parsed_args.test = None
449462

@@ -471,12 +484,14 @@ def sys_unraisablehook(args) -> None:
471484

472485
jobs = parsed_args.jobs if hasattr(parsed_args, 'jobs') else None
473486
verbose = parsed_args.verbose if hasattr(parsed_args, 'verbose') else False
487+
maxfail = parsed_args.maxfail if hasattr(parsed_args, 'maxfail') else None
474488

475489
# NOTE: Run on main thread so that it can handle KeyboardInterrupt
476490
is_ok = run_tests_parallel(
477491
parsed_args.test,
478492
jobs=jobs,
479-
verbose=verbose
493+
verbose=verbose,
494+
maxfail=maxfail,
480495
)
481496
exit_code = 0 if is_ok else 1
482497
sys.exit(exit_code)
@@ -786,7 +801,8 @@ def bg_task() -> None:
786801

787802
is_ok = False
788803
try:
789-
is_ok = run_tests_serial(parsed_args.test, interactive=is_interactive)
804+
maxfail = parsed_args.maxfail if hasattr(parsed_args, 'maxfail') else None
805+
is_ok = run_tests_serial(parsed_args.test, interactive=is_interactive, maxfail=maxfail)
790806
finally:
791807
exit_code = 0 if is_ok else 1
792808
if is_coverage():

src/crystal/tests/runner/parallel.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import argparse
77
from collections.abc import Sequence
88
from contextlib import closing
9-
from crystal.tests.runner.shared import normalize_test_names
9+
from crystal.tests.runner.shared import MAX_INTERRUPTED_TEST_COUNT_TO_REPORT, normalize_test_names
1010
from crystal.tests.util.cli import get_crystal_command
1111
from crystal.util.bulkheads import capture_crashes_to_stderr
1212
from crystal.util.pipes import create_selectable_pipe, Pipe, ReadablePipeEnd
@@ -82,6 +82,7 @@ def run_tests(
8282
raw_test_names: list[str],
8383
*, jobs: int | None,
8484
verbose: bool,
85+
maxfail: int | None = None,
8586
) -> bool:
8687
from crystal.tests.index import TEST_FUNCS
8788

@@ -210,6 +211,10 @@ def run_tests(
210211
# Create shared state for interrupt handling
211212
interrupted_event = threading.Event()
212213

214+
# Create shared state for --maxfail
215+
fail_count = [0] # mutable int, shared across worker threads
216+
fail_count_lock = threading.Lock()
217+
213218
# Create coordination state for simulated parent interrupt
214219
# (used when '!' appears in CRYSTAL_PARALLEL_WORKER_TASKS)
215220
workers_at_interrupt_point: list[threading.Event] = [
@@ -235,6 +240,9 @@ def run_worker_thread(worker_id: int) -> None:
235240
workers_at_interrupt_point[worker_id]
236241
if simulate_parent_interrupt else None
237242
),
243+
maxfail=maxfail,
244+
fail_count=fail_count,
245+
fail_count_lock=fail_count_lock,
238246
)
239247
with worker_results_lock:
240248
worker_results[worker_id] = result
@@ -569,7 +577,10 @@ def _format_summary(all_tests: 'list[TestResult]', total_duration: float) -> tup
569577
if interrupted_tests:
570578
output_lines.append('')
571579
output_lines.append('Rerun interrupted tests with:')
572-
output_lines.append(f'$ crystal test {" ".join(interrupted_tests)}')
580+
if len(interrupted_tests) < MAX_INTERRUPTED_TEST_COUNT_TO_REPORT:
581+
output_lines.append(f'$ crystal test {" ".join(interrupted_tests)}')
582+
else:
583+
output_lines.append(f'$ crystal test <{len(interrupted_tests)} tests>')
573584

574585
return ('\n'.join(output_lines), is_ok)
575586

@@ -621,6 +632,9 @@ def _run_worker(
621632
interrupt_read_pipe: 'ReadablePipeEnd',
622633
display_result_immediately: bool = True,
623634
at_interrupt_point_event: threading.Event | None = None,
635+
maxfail: int | None = None,
636+
fail_count: 'list[int] | None' = None,
637+
fail_count_lock: 'threading.Lock | None' = None,
624638
) -> WorkerResult:
625639
"""
626640
Run a worker subprocess in interactive mode, pulling tests from work_queue on-demand.
@@ -649,6 +663,9 @@ def _run_worker(
649663
If False, results are only returned in the WorkerResult.
650664
* at_interrupt_point_event -- Event to set when worker reaches _INTERRUPT_MARKER.
651665
The worker will then wait for interrupted_event to be set before continuing.
666+
* maxfail -- Stop running after this many failures, or None for no limit.
667+
* fail_count -- Shared mutable counter of failures across all workers.
668+
* fail_count_lock -- Lock protecting fail_count.
652669
653670
Returns:
654671
* WorkerResult containing test results and metadata.
@@ -775,6 +792,21 @@ def _run_worker(
775792
if display_result_immediately:
776793
_display_test_result(test_result)
777794

795+
# Check if --maxfail threshold has been reached
796+
if (maxfail is not None and
797+
fail_count is not None and
798+
fail_count_lock is not None and
799+
test_result.status in ('FAILURE', 'ERROR')):
800+
with fail_count_lock:
801+
fail_count[0] += 1
802+
reached_maxfail = (fail_count[0] >= maxfail)
803+
if reached_maxfail and not interrupted_event.is_set():
804+
if verbose:
805+
print(
806+
f'[Runner] Reached maxfail={maxfail}, interrupting workers...',
807+
file=sys.stderr)
808+
interrupted_event.set()
809+
778810
if process_is_interrupted:
779811
break
780812

src/crystal/tests/runner/serial.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from concurrent.futures import Future
33
from contextlib import contextmanager
44
from crystal.app_preferences import app_prefs
5-
from crystal.tests.runner.shared import available_modules_str, normalize_test_names
5+
from crystal.tests.runner.shared import MAX_INTERRUPTED_TEST_COUNT_TO_REPORT, available_modules_str, normalize_test_names
66
from crystal.tests.util.downloads import delay_between_downloads_minimized
77
from crystal.tests.util.runner import run_test
88
from crystal.tests.util.subtests import SubtestFailed
@@ -30,7 +30,7 @@
3030

3131

3232
@bg_affinity
33-
def run_tests(raw_test_names: list[str], *, interactive: bool = False) -> bool:
33+
def run_tests(raw_test_names: list[str], *, interactive: bool = False, maxfail: int | None = None) -> bool:
3434
"""
3535
Runs automated UI tests, printing a summary report,
3636
and returning whether the run was OK.
@@ -54,10 +54,10 @@ def run_tests(raw_test_names: list[str], *, interactive: bool = False) -> bool:
5454
else:
5555
test_names = [] # ignored
5656

57-
return _run_tests(test_names, interactive=interactive)
57+
return _run_tests(test_names, interactive=interactive, maxfail=maxfail)
5858

5959

60-
def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
60+
def _run_tests(test_names: list[str], *, interactive: bool = False, maxfail: int | None = None) -> bool:
6161
from crystal.tests.index import TEST_FUNCS
6262

6363
# Ensure ancestor caller did already call set_tests_are_running()
@@ -83,6 +83,7 @@ def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
8383
test_func_by_name[test_name] = test_func
8484

8585
# Interactive mode: read test names from stdin one at a time
86+
fail_count = 0 # for --maxfail tracking
8687
try:
8788
while True:
8889
# Print prompt
@@ -152,6 +153,15 @@ def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
152153
except NoForegroundThreadError:
153154
# Fatal error; abort
154155
break
156+
157+
# Check if --maxfail threshold has been reached
158+
if maxfail is not None:
159+
last_result = result_for_test_func_id.get(test_func_id)
160+
if (last_result is not None and
161+
not isinstance(last_result, (SkipTest, _TestInterrupted))):
162+
fail_count += 1
163+
if fail_count >= maxfail:
164+
break
155165
except KeyboardInterrupt:
156166
# Proceed to print a summary section, and exit the process
157167
pass
@@ -166,9 +176,16 @@ def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
166176
if test_name not in test_names and test_func.__module__ not in test_names:
167177
continue
168178
test_funcs_to_run.append(test_func)
169-
179+
180+
def mark_remaining_tests_as_interrupted() -> None:
181+
for remaining_test_func in test_funcs_to_run[test_func_index:]:
182+
remaining_test_func_id = (remaining_test_func.__module__, remaining_test_func.__name__)
183+
if remaining_test_func_id not in result_for_test_func_id:
184+
result_for_test_func_id[remaining_test_func_id] = _TestInterrupted()
185+
170186
num_test_funcs_to_run = len(test_funcs_to_run) # cache
171187

188+
fail_count = 0 # for --maxfail tracking
172189
try:
173190
for (test_func_index, test_func) in enumerate(test_funcs_to_run):
174191
test_func_id = (test_func.__module__, test_func.__name__)
@@ -196,12 +213,18 @@ def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
196213
except NoForegroundThreadError:
197214
# Fatal error; abort
198215
break
216+
217+
# Check if --maxfail threshold has been reached
218+
if maxfail is not None:
219+
last_result = result_for_test_func_id.get(test_func_id)
220+
if (last_result is not None and
221+
not isinstance(last_result, (SkipTest, _TestInterrupted))):
222+
fail_count += 1
223+
if fail_count >= maxfail:
224+
mark_remaining_tests_as_interrupted()
225+
break
199226
except KeyboardInterrupt:
200-
# Mark all remaining tests as interrupted
201-
for remaining_test_func in test_funcs_to_run[test_func_index:]:
202-
remaining_test_func_id = (remaining_test_func.__module__, remaining_test_func.__name__)
203-
if remaining_test_func_id not in result_for_test_func_id:
204-
result_for_test_func_id[remaining_test_func_id] = _TestInterrupted()
227+
mark_remaining_tests_as_interrupted()
205228

206229
# Proceed to print a summary section, and exit the process
207230
pass
@@ -314,7 +337,10 @@ def _run_tests(test_names: list[str], *, interactive: bool = False) -> bool:
314337
if len(interrupted_test_names) != 0:
315338
print()
316339
print('Rerun interrupted tests with:')
317-
print(f'$ crystal test {" ".join(interrupted_test_names)}')
340+
if len(interrupted_test_names) < MAX_INTERRUPTED_TEST_COUNT_TO_REPORT:
341+
print(f'$ crystal test {" ".join(interrupted_test_names)}')
342+
else:
343+
print(f'$ crystal test <{len(interrupted_test_names)} tests>')
318344

319345
# Play bell sound in terminal
320346
print('\a', end='', flush=True)

src/crystal/tests/runner/shared.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
MAX_INTERRUPTED_TEST_COUNT_TO_REPORT = 50
2+
3+
14
def normalize_test_names(raw_test_names: list[str]) -> list[str]:
25
"""
36
Normalize test names from various formats into the canonical format.

0 commit comments

Comments
 (0)