Add animation detection support to test runner

This commit is contained in:
Feoramund
2025-05-20 19:28:07 -04:00
parent 3c40a54dcd
commit 1b407ef207
2 changed files with 80 additions and 61 deletions
+75 -58
View File
@@ -45,6 +45,7 @@ PER_THREAD_MEMORY : int : #config(ODIN_TEST_THREAD_MEMORY, mem.ROLLBACK_S
// The format is: `package.test_name,test_name_only,...` // The format is: `package.test_name,test_name_only,...`
TEST_NAMES : string : #config(ODIN_TEST_NAMES, "") TEST_NAMES : string : #config(ODIN_TEST_NAMES, "")
// Show the fancy animated progress report. // Show the fancy animated progress report.
// This requires terminal color support, as well as STDOUT to not be redirected to a file.
FANCY_OUTPUT : bool : #config(ODIN_TEST_FANCY, true) FANCY_OUTPUT : bool : #config(ODIN_TEST_FANCY, true)
// Copy failed tests to the clipboard when done. // Copy failed tests to the clipboard when done.
USE_CLIPBOARD : bool : #config(ODIN_TEST_CLIPBOARD, false) USE_CLIPBOARD : bool : #config(ODIN_TEST_CLIPBOARD, false)
@@ -72,6 +73,7 @@ get_log_level :: #force_inline proc() -> runtime.Logger_Level {
} }
@(private) global_log_colors_disabled: bool @(private) global_log_colors_disabled: bool
@(private) global_ansi_disabled: bool
JSON :: struct { JSON :: struct {
total: int, total: int,
@@ -219,7 +221,12 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
stdout := io.to_writer(os.stream_from_handle(os.stdout)) stdout := io.to_writer(os.stream_from_handle(os.stdout))
stderr := io.to_writer(os.stream_from_handle(os.stderr)) stderr := io.to_writer(os.stream_from_handle(os.stderr))
// The animations are only ever shown through STDOUT;
// STDERR is used exclusively for logging regardless of error level.
global_log_colors_disabled = !terminal.color_enabled || !terminal.is_terminal(os.stderr) global_log_colors_disabled = !terminal.color_enabled || !terminal.is_terminal(os.stderr)
global_ansi_disabled = !terminal.is_terminal(os.stdout)
should_show_animations := FANCY_OUTPUT && terminal.color_enabled && !global_ansi_disabled
// -- Prepare test data. // -- Prepare test data.
@@ -278,12 +285,12 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
total_done_count := 0 total_done_count := 0
total_test_count := len(internal_tests) total_test_count := len(internal_tests)
when !FANCY_OUTPUT {
// This is strictly for updating the window title when the progress // This is strictly for updating the window title when the progress
// report is disabled. We're otherwise able to depend on the call to // report is disabled. We're otherwise able to depend on the call to
// `needs_to_redraw`. // `needs_to_redraw`.
last_done_count := -1 last_done_count := -1
}
if total_test_count == 0 { if total_test_count == 0 {
// Exit early. // Exit early.
@@ -352,31 +359,31 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
fmt.assertf(alloc_error == nil, "Error allocating memory for test report: %v", alloc_error) fmt.assertf(alloc_error == nil, "Error allocating memory for test report: %v", alloc_error)
defer destroy_report(&report) defer destroy_report(&report)
when FANCY_OUTPUT {
// We cannot make use of the ANSI save/restore cursor codes, because they
// work by absolute screen coordinates. This will cause unnecessary
// scrollback if we print at the bottom of someone's terminal.
ansi_redraw_string := fmt.aprintf(
// ANSI for "go up N lines then erase the screen from the cursor forward."
ansi.CSI + "%i" + ansi.CPL + ansi.CSI + ansi.ED +
// We'll combine this with the window title format string, since it
// can be printed at the same time.
"%s",
// 1 extra line for the status bar.
1 + len(report.packages), OSC_WINDOW_TITLE)
assert(len(ansi_redraw_string) > 0, "Error allocating ANSI redraw string.")
defer delete(ansi_redraw_string)
thread_count_status_string: string = --- // We cannot make use of the ANSI save/restore cursor codes, because they
{ // work by absolute screen coordinates. This will cause unnecessary
PADDING :: PROGRESS_COLUMN_SPACING + PROGRESS_WIDTH // scrollback if we print at the bottom of someone's terminal.
ansi_redraw_string := fmt.aprintf(
// ANSI for "go up N lines then erase the screen from the cursor forward."
ansi.CSI + "%i" + ansi.CPL + ansi.CSI + ansi.ED +
// We'll combine this with the window title format string, since it
// can be printed at the same time.
"%s",
// 1 extra line for the status bar.
1 + len(report.packages), OSC_WINDOW_TITLE)
assert(len(ansi_redraw_string) > 0, "Error allocating ANSI redraw string.")
defer delete(ansi_redraw_string)
unpadded := fmt.tprintf("%i thread%s", thread_count, "" if thread_count == 1 else "s") thread_count_status_string: string = ---
thread_count_status_string = fmt.aprintf("%- *[1]s", unpadded, report.pkg_column_len + PADDING) {
assert(len(thread_count_status_string) > 0, "Error allocating thread count status string.") PADDING :: PROGRESS_COLUMN_SPACING + PROGRESS_WIDTH
}
defer delete(thread_count_status_string) unpadded := fmt.tprintf("%i thread%s", thread_count, "" if thread_count == 1 else "s")
thread_count_status_string = fmt.aprintf("%- *[1]s", unpadded, report.pkg_column_len + PADDING)
assert(len(thread_count_status_string) > 0, "Error allocating thread count status string.")
} }
defer delete(thread_count_status_string)
task_data_slots: []Task_Data = --- task_data_slots: []Task_Data = ---
task_data_slots, alloc_error = make([]Task_Data, thread_count) task_data_slots, alloc_error = make([]Task_Data, thread_count)
@@ -496,11 +503,13 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
setup_signal_handler() setup_signal_handler()
fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_HIDE) if !global_ansi_disabled {
fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_HIDE)
}
when FANCY_OUTPUT { signals_were_raised := false
signals_were_raised := false
if should_show_animations {
redraw_report(stdout, report) redraw_report(stdout, report)
draw_status_bar(stdout, thread_count_status_string, total_done_count, total_test_count) draw_status_bar(stdout, thread_count_status_string, total_done_count, total_test_count)
} }
@@ -718,22 +727,22 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
break main_loop break main_loop
} }
when FANCY_OUTPUT {
// Because the bounds checking procs send directly to STDERR with // Because the bounds checking procs send directly to STDERR with
// no way to redirect or handle them, we need to at least try to // no way to redirect or handle them, we need to at least try to
// let the user see those messages when using the animated progress // let the user see those messages when using the animated progress
// report. This flag may be set by the block of code below if a // report. This flag may be set by the block of code below if a
// signal is raised. // signal is raised.
// //
// It'll be purely by luck if the output is interleaved properly, // It'll be purely by luck if the output is interleaved properly,
// given the nature of non-thread-safe printing. // given the nature of non-thread-safe printing.
// //
// At worst, if Odin did not print any error for this signal, we'll // At worst, if Odin did not print any error for this signal, we'll
// just re-display the progress report. The fatal log error message // just re-display the progress report. The fatal log error message
// should be enough to clue the user in that something dire has // should be enough to clue the user in that something dire has
// occurred. // occurred.
bypass_progress_overwrite := false bypass_progress_overwrite := false
}
if test_index, reason, ok := should_stop_test(); ok { if test_index, reason, ok := should_stop_test(); ok {
#no_bounds_check report.all_test_states[test_index] = .Failed #no_bounds_check report.all_test_states[test_index] = .Failed
@@ -767,7 +776,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
log.fatalf("Caught signal to stop test #%i %s.%s for: %v.", test_index, it.pkg, it.name, reason) log.fatalf("Caught signal to stop test #%i %s.%s for: %v.", test_index, it.pkg, it.name, reason)
} }
when FANCY_OUTPUT { if should_show_animations {
bypass_progress_overwrite = true bypass_progress_overwrite = true
signals_were_raised = true signals_were_raised = true
} }
@@ -781,7 +790,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
// -- Redraw. // -- Redraw.
when FANCY_OUTPUT { if should_show_animations {
if len(log_messages) == 0 && !needs_to_redraw(report) { if len(log_messages) == 0 && !needs_to_redraw(report) {
continue main_loop continue main_loop
} }
@@ -791,7 +800,9 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
} }
} else { } else {
if total_done_count != last_done_count { if total_done_count != last_done_count {
fmt.wprintf(stdout, OSC_WINDOW_TITLE, total_done_count, total_test_count) if !global_ansi_disabled {
fmt.wprintf(stdout, OSC_WINDOW_TITLE, total_done_count, total_test_count)
}
last_done_count = total_done_count last_done_count = total_done_count
} }
@@ -816,7 +827,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
clear(&log_messages) clear(&log_messages)
bytes.buffer_reset(&batch_buffer) bytes.buffer_reset(&batch_buffer)
when FANCY_OUTPUT { if should_show_animations {
redraw_report(batch_writer, report) redraw_report(batch_writer, report)
draw_status_bar(batch_writer, thread_count_status_string, total_done_count, total_test_count) draw_status_bar(batch_writer, thread_count_status_string, total_done_count, total_test_count)
fmt.wprint(stdout, bytes.buffer_to_string(&batch_buffer)) fmt.wprint(stdout, bytes.buffer_to_string(&batch_buffer))
@@ -837,7 +848,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
finished_in := time.since(start_time) finished_in := time.since(start_time)
when !FANCY_OUTPUT { if !should_show_animations || !terminal.is_terminal(os.stderr) {
// One line to space out the results, since we don't have the status // One line to space out the results, since we don't have the status
// bar in plain mode. // bar in plain mode.
fmt.wprintln(batch_writer) fmt.wprintln(batch_writer)
@@ -851,24 +862,28 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
if total_done_count != total_test_count { if total_done_count != total_test_count {
not_run_count := total_test_count - total_done_count not_run_count := total_test_count - total_done_count
message := " %i %s left undone." if global_log_colors_disabled else " " + SGR_READY + "%i" + SGR_RESET + " %s left undone."
fmt.wprintf(batch_writer, fmt.wprintf(batch_writer,
" " + SGR_READY + "%i" + SGR_RESET + " %s left undone.", message,
not_run_count, not_run_count,
"test was" if not_run_count == 1 else "tests were") "test was" if not_run_count == 1 else "tests were")
} }
if total_success_count == total_test_count { if total_success_count == total_test_count {
message := " %s successful." if global_log_colors_disabled else " %s " + SGR_SUCCESS + "successful." + SGR_RESET
fmt.wprintfln(batch_writer, fmt.wprintfln(batch_writer,
" %s " + SGR_SUCCESS + "successful." + SGR_RESET, message,
"The test was" if total_test_count == 1 else "All tests were") "The test was" if total_test_count == 1 else "All tests were")
} else if total_failure_count > 0 { } else if total_failure_count > 0 {
if total_failure_count == total_test_count { if total_failure_count == total_test_count {
message := " %s failed." if global_log_colors_disabled else " %s " + SGR_FAILED + "failed." + SGR_RESET
fmt.wprintfln(batch_writer, fmt.wprintfln(batch_writer,
" %s " + SGR_FAILED + "failed." + SGR_RESET, message,
"The test" if total_test_count == 1 else "All tests") "The test" if total_test_count == 1 else "All tests")
} else { } else {
message := " %i test%s failed." if global_log_colors_disabled else " " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed."
fmt.wprintfln(batch_writer, fmt.wprintfln(batch_writer,
" " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed.", message,
total_failure_count, total_failure_count,
"" if total_failure_count == 1 else "s") "" if total_failure_count == 1 else "s")
} }
@@ -922,9 +937,11 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
} }
} }
fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_SHOW) if !global_ansi_disabled {
fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_SHOW)
}
when FANCY_OUTPUT { if should_show_animations {
if signals_were_raised { if signals_were_raised {
fmt.wprintln(batch_writer, ` fmt.wprintln(batch_writer, `
Signals were raised during this test run. Log messages are likely to have collided with each other. Signals were raised during this test run. Log messages are likely to have collided with each other.
+5 -3
View File
@@ -63,9 +63,11 @@ stop_test_callback :: proc "c" (sig: libc.int) {
// NOTE(Feoramund): Using these write calls in a signal handler is // NOTE(Feoramund): Using these write calls in a signal handler is
// undefined behavior in C99 but possibly tolerated in POSIX 2008. // undefined behavior in C99 but possibly tolerated in POSIX 2008.
// Either way, we may as well try to salvage what we can. // Either way, we may as well try to salvage what we can.
show_cursor := ansi.CSI + ansi.DECTCEM_SHOW if !global_ansi_disabled {
libc.fwrite(raw_data(show_cursor), size_of(byte), len(show_cursor), libc.stdout) show_cursor := ansi.CSI + ansi.DECTCEM_SHOW
libc.fflush(libc.stdout) libc.fwrite(raw_data(show_cursor), size_of(byte), len(show_cursor), libc.stdout)
libc.fflush(libc.stdout)
}
// This is an attempt at being compliant by avoiding printf. // This is an attempt at being compliant by avoiding printf.
sigbuf: [8]byte sigbuf: [8]byte