From 909013b97221bb2af53e54f642b3e78b986d5e89 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Pur Date: Wed, 22 Apr 2026 21:34:33 +0200 Subject: [PATCH] LibWeb+LibTest: Move the progress bar/status printing stuff to LibTest --- Libraries/LibTest/LiveDisplay.h | 378 ++++++++++++++++++++++++++++++ Tests/LibWeb/test-web/Display.cpp | 173 ++++++-------- Tests/LibWeb/test-web/main.cpp | 1 + 3 files changed, 456 insertions(+), 96 deletions(-) create mode 100644 Libraries/LibTest/LiveDisplay.h diff --git a/Libraries/LibTest/LiveDisplay.h b/Libraries/LibTest/LiveDisplay.h new file mode 100644 index 0000000000..2278ea25d1 --- /dev/null +++ b/Libraries/LibTest/LiveDisplay.h @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#if !defined(AK_OS_WINDOWS) +# include +# include +#endif + +namespace Test { + +inline bool stdout_is_tty() +{ +#if defined(AK_OS_WINDOWS) + return false; +#else + return isatty(STDOUT_FILENO); +#endif +} + +inline size_t query_terminal_width(int fd, size_t fallback = 80) +{ +#if !defined(AK_OS_WINDOWS) + struct winsize ws; + if (ioctl(fd, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; +#endif + (void)fd; + return fallback; +} + +class LiveDisplay { + AK_MAKE_NONCOPYABLE(LiveDisplay); + AK_MAKE_NONMOVABLE(LiveDisplay); + +public: + enum Color : u8 { + None, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + }; + + struct LabelColor { + Color prefix; + Color text; + }; + + struct Counter { + StringView label; + Color color; + size_t value; + }; + + struct Options { + size_t reserved_lines { 3 }; + // If non-empty, stdout and stderr are redirected to this file for the lifetime of the live display, and the display itself is written to the terminal. + ByteString log_file_path; + }; + + class RenderTarget { + AK_MAKE_NONCOPYABLE(RenderTarget); + AK_MAKE_NONMOVABLE(RenderTarget); + friend class LiveDisplay; + + public: + StringBuilder& builder() { return m_builder; } + size_t terminal_width() const { return m_terminal_width; } + + template + void line(Callback&& callback) + { + m_builder.append("\033[2K"sv); + callback(); + m_builder.append('\n'); + } + + template + void lines(Callbacks&&... callback) + { + (line(forward(callback)), ...); + } + + void label(StringView prefix, StringView text, LabelColor color = { Yellow, None }) + { + m_builder.append(LiveDisplay::ansi_on(color.prefix)); + m_builder.append(prefix); + m_builder.append(LiveDisplay::ansi_reset(color.prefix)); + + size_t available = m_terminal_width > prefix.length() ? m_terminal_width - prefix.length() : 10; + + m_builder.append(LiveDisplay::ansi_on(color.text)); + if (text.length() > available && available > 3) { + m_builder.append("..."sv); + m_builder.append(text.substring_view(text.length() - available + 3)); + } else { + m_builder.append(text); + } + m_builder.append(LiveDisplay::ansi_reset(color.text)); + } + + template + void counter(Counter const (&counters)[N]) + { + bool first = true; + for (auto const& c : counters) { + if (!first) + m_builder.append(", "sv); + first = false; + m_builder.append(LiveDisplay::ansi_bold_on(c.color)); + m_builder.appendff("{}:", c.label); + m_builder.append("\033[0m"sv); + m_builder.appendff(" {}", c.value); + } + } + + void progress_bar(size_t completed, size_t total, StringView suffix = {}) + { + auto counter_begin = m_builder.length(); + m_builder.appendff("{}/{} ", completed, total); + if (!suffix.is_empty()) { + m_builder.append(suffix); + m_builder.append(' '); + } + size_t counter_length = m_builder.length() - counter_begin; + size_t bar_width = m_terminal_width > counter_length + 3 ? m_terminal_width - counter_length - 3 : 20; + size_t filled = total > 0 ? (completed * bar_width) / total : 0; + size_t empty = bar_width > filled ? bar_width - filled : 0; + + m_builder.append("\033[32m["sv); + for (size_t j = 0; j < filled; ++j) + m_builder.append("█"sv); + if (empty > 0 && filled < bar_width) { + m_builder.append("\033[33m▓\033[0m\033[90m"sv); + for (size_t j = 1; j < empty; ++j) + m_builder.append("░"sv); + } + m_builder.append("\033[32m]\033[0m"sv); + } + + private: + RenderTarget(StringBuilder& builder, size_t terminal_width) + : m_builder(builder) + , m_terminal_width(terminal_width) + { + } + + StringBuilder& m_builder; + size_t m_terminal_width; + }; + + LiveDisplay() = default; + ~LiveDisplay() { end(); } + + bool begin(Options options) + { +#if defined(AK_OS_WINDOWS) + (void)options; + return false; +#else + if (m_active) + return false; + + m_reserved_lines = options.reserved_lines; + m_log_file_path = move(options.log_file_path); + + if (!m_log_file_path.is_empty()) { + int log_fd = ::open(m_log_file_path.characters(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (log_fd < 0) + return false; + + m_saved_stdout_fd = dup(STDOUT_FILENO); + m_saved_stderr_fd = dup(STDERR_FILENO); + if (m_saved_stdout_fd < 0 || m_saved_stderr_fd < 0) { + close(log_fd); + return false; + } + + (void)fflush(stdout); + (void)fflush(stderr); + (void)dup2(log_fd, STDOUT_FILENO); + (void)dup2(log_fd, STDERR_FILENO); + close(log_fd); + + int display_fd = dup(m_saved_stdout_fd); + if (display_fd < 0) + return false; + m_output = fdopen(display_fd, "w"); + if (!m_output) { + close(display_fd); + return false; + } + (void)setvbuf(m_output, nullptr, _IONBF, 0); + } else { + m_output = stdout; + } + + refresh_terminal_width(); + + for (size_t i = 0; i < m_reserved_lines; ++i) + (void)fputc('\n', m_output); + (void)fflush(m_output); + + m_active = true; + return true; +#endif + } + + void end() + { +#if !defined(AK_OS_WINDOWS) + if (!m_active) + return; + + clear(); + + bool redirected = m_saved_stdout_fd >= 0 || m_saved_stderr_fd >= 0; + if (redirected) { + if (m_output) { + (void)fclose(m_output); + m_output = nullptr; + } + (void)fflush(stdout); + (void)fflush(stderr); + if (m_saved_stdout_fd >= 0) { + (void)dup2(m_saved_stdout_fd, STDOUT_FILENO); + close(m_saved_stdout_fd); + m_saved_stdout_fd = -1; + } + if (m_saved_stderr_fd >= 0) { + (void)dup2(m_saved_stderr_fd, STDERR_FILENO); + close(m_saved_stderr_fd); + m_saved_stderr_fd = -1; + } + (void)setvbuf(stdout, nullptr, _IOLBF, 0); + (void)setvbuf(stderr, nullptr, _IONBF, 0); + } else { + m_output = nullptr; + } + m_active = false; +#endif + } + + bool is_active() const { return m_active; } + FILE* output() const { return m_output; } + size_t terminal_width() const { return m_terminal_width; } + size_t reserved_lines() const { return m_reserved_lines; } + void set_reserved_lines(size_t n) { m_reserved_lines = n; } + ByteString const& log_file_path() const { return m_log_file_path; } + bool redirected_stdio() const { return m_saved_stdout_fd >= 0; } + + void refresh_terminal_width() + { +#if !defined(AK_OS_WINDOWS) + int fd = m_output ? fileno(m_output) : STDOUT_FILENO; +#else + int fd = 0; // Not actually used. +#endif + m_terminal_width = query_terminal_width(fd); + } + + // Erase the reserved display area, leaving the cursor at the top of it. + void clear() + { + if (!m_active || !m_output) + return; + StringBuilder builder; + for (size_t i = 0; i < m_reserved_lines; ++i) + builder.append("\033[A\r\033[2K"sv); + write(builder.string_view()); + } + + template + void render(Callback&& callback) + { + if (!m_active || !m_output) + return; + + StringBuilder builder; + for (size_t i = 0; i < m_reserved_lines; ++i) + builder.append("\033[A"sv); + builder.append("\r"sv); + + RenderTarget target { builder, m_terminal_width }; + callback(target); + + write(builder.string_view()); + } + +private: + static constexpr StringView ansi_on(Color c) + { + switch (c) { + case None: + return ""sv; + case Red: + return "\033[31m"sv; + case Green: + return "\033[32m"sv; + case Yellow: + return "\033[33m"sv; + case Blue: + return "\033[34m"sv; + case Magenta: + return "\033[35m"sv; + case Cyan: + return "\033[36m"sv; + case Gray: + return "\033[90m"sv; + } + VERIFY_NOT_REACHED(); + } + + static constexpr StringView ansi_bold_on(Color c) + { + switch (c) { + case None: + return "\033[1m"sv; + case Red: + return "\033[1;31m"sv; + case Green: + return "\033[1;32m"sv; + case Yellow: + return "\033[1;33m"sv; + case Blue: + return "\033[1;34m"sv; + case Magenta: + return "\033[1;35m"sv; + case Cyan: + return "\033[1;36m"sv; + case Gray: + return "\033[1;90m"sv; + } + VERIFY_NOT_REACHED(); + } + + static constexpr StringView ansi_reset(Color c) + { + return c == None ? ""sv : "\033[0m"sv; + } + + void write(StringView data) + { + (void)fwrite(data.characters_without_null_termination(), 1, data.length(), m_output); + (void)fflush(m_output); + } + + bool m_active { false }; + size_t m_reserved_lines { 0 }; + size_t m_terminal_width { 80 }; + FILE* m_output { nullptr }; + int m_saved_stdout_fd { -1 }; +#if !defined(AK_OS_WINDOWS) + int m_saved_stderr_fd { -1 }; +#endif + ByteString m_log_file_path; +}; + +} diff --git a/Tests/LibWeb/test-web/Display.cpp b/Tests/LibWeb/test-web/Display.cpp index 7f8f24fb9a..2a6286617a 100644 --- a/Tests/LibWeb/test-web/Display.cpp +++ b/Tests/LibWeb/test-web/Display.cpp @@ -8,27 +8,30 @@ #include "Application.h" #include -#include #include #include #include +#include #include -#include #include #include #include -#include +#include #ifndef AK_OS_WINDOWS +# include # include +# include #endif namespace TestWeb { static constexpr size_t LIVE_DISPLAY_TERMINAL_HEADROOM = 4; // allow for external cruft like tmux panels static constexpr size_t LIVE_DISPLAY_STATUS_LINES = 4; // 2 empty + 1 for status + 1 for progress bar -static size_t s_display_rows = 80; -static size_t s_display_columns = 24; +static size_t s_display_rows = 24; + +static ::Test::LiveDisplay s_live_display; + static size_t count_digits(size_t value); Display& Display::the() @@ -40,33 +43,39 @@ Display& Display::the() void Display::begin_run() { auto& app = Application::the(); - is_tty = Core::System::isatty(STDOUT_FILENO).value_or(false); - is_live_display_active = !app.quiet && is_tty && app.verbosity < Application::VERBOSITY_LEVEL_LOG_TEST_OUTPUT; + is_tty = ::Test::stdout_is_tty(); + bool const want_live_display = !app.quiet && is_tty && app.verbosity < Application::VERBOSITY_LEVEL_LOG_TEST_OUTPUT; outln("Running {} tests...", total_tests()); + if (!want_live_display) + return; + + size_t terminal_rows = 24; +#ifndef AK_OS_WINDOWS + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0) + terminal_rows = ws.ws_row; +#endif + s_display_rows = AK::clamp( + AK::saturating_sub(terminal_rows, LIVE_DISPLAY_TERMINAL_HEADROOM), + LIVE_DISPLAY_STATUS_LINES + 1, + view_states().size() + LIVE_DISPLAY_STATUS_LINES); + + is_live_display_active = s_live_display.begin({ .reserved_lines = s_display_rows, .log_file_path = {} }); if (!is_live_display_active) return; #ifndef AK_OS_WINDOWS - struct winsize ws; - if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) { - s_display_rows = ws.ws_row > 0 ? ws.ws_row : 24; - s_display_columns = ws.ws_col > 0 ? ws.ws_col : 80; - } + Core::EventLoop::register_signal(SIGWINCH, [](int) { + Core::EventLoop::current().deferred_invoke([] { + s_live_display.refresh_terminal_width(); + }); + }); #endif - s_display_rows = AK::clamp( - AK::saturating_sub(s_display_rows, LIVE_DISPLAY_TERMINAL_HEADROOM), - LIVE_DISPLAY_STATUS_LINES + 1, - view_states().size() + LIVE_DISPLAY_STATUS_LINES); display_timer = Core::Timer::create_repeating(1000, [this] { render_live_display(); }); display_timer->start(); - - for (size_t i = 0; i < s_display_rows; i++) { - outln(); - } - (void)fflush(stdout); } void Display::on_test_started(size_t view_index, Test const& test, pid_t pid) @@ -214,83 +223,60 @@ void Display::print_failure_diff(URL::URL const& url, Test const& test, void Display::render_live_display() const { - if (!is_tty || !is_live_display_active) + if (!s_live_display.is_active()) return; auto now = UnixDateTime::now(); - StringBuilder output; - for (size_t i = 0; i < s_display_rows; ++i) - output.append("\033[A"sv); - output.append("\r"sv); + s_live_display.render([&](::Test::LiveDisplay::RenderTarget& t) { + size_t const reserved = s_live_display.reserved_lines(); + size_t num_view_lines = reserved > LIVE_DISPLAY_STATUS_LINES ? reserved - LIVE_DISPLAY_STATUS_LINES : 0; + bool const need_hidden_line = num_view_lines > 1 && num_view_lines < view_states().size(); + if (need_hidden_line) + num_view_lines--; - size_t num_view_lines = s_display_rows - LIVE_DISPLAY_STATUS_LINES; - if (num_view_lines > 1 && num_view_lines < view_states().size()) - num_view_lines--; // "Hidden views" line - - for (size_t i = 0; i < num_view_lines; ++i) { - output.append("\033[2K"sv); - - if (i < view_states().size()) { - auto const& state = view_states()[i]; - if (state.active && state.pid > 0) { - auto duration = (now - state.start_time).to_truncated_seconds(); - auto prefix = ByteString::formatted("\033[33m⏺\033[0m {} ({}s): ", state.pid, duration); - size_t const prefix_visible_length = ByteString::formatted("⏺ {} ({}s): ", state.pid, duration).length(); - size_t const available_width = s_display_columns > prefix_visible_length - ? s_display_columns - prefix_visible_length - : 10; - ByteString name = state.test_name; - if (name.length() > available_width && available_width > 3) - name = ByteString::formatted("...{}", name.substring_view(name.length() - available_width + 3)); - - output.appendff("{}{}", prefix, name); - } else { - output.append("\033[90m⏺ (idle)\033[0m"sv); - } + for (size_t i = 0; i < num_view_lines; ++i) { + t.line([&] { + if (i >= view_states().size()) + return; + auto const& state = view_states()[i]; + if (state.active && state.pid > 0) { + auto duration = (now - state.start_time).to_truncated_seconds(); + auto prefix = ByteString::formatted("⏺ {} ({}s): ", state.pid, duration); + t.label(prefix, state.test_name); + } else { + t.label("⏺ (idle)"sv, {}, { .prefix = ::Test::LiveDisplay::Gray, .text = ::Test::LiveDisplay::None }); + } + }); } - output.append("\n"sv); - } - if (num_view_lines < view_states().size()) { - output.append("\033[2K\033[90m... "sv); - output.appendff("{} more views hidden\033[0m", view_states().size() - num_view_lines); - output.append("\n"sv); - } - output.append("\033[2K\n\033[2K"sv); - output.appendff("\033[1;32mPass:\033[0m {}, ", pass_count); - output.appendff("\033[1;31mFail:\033[0m {}, ", fail_count); - output.appendff("\033[1;90mSkipped:\033[0m {}, ", skipped_count); - output.appendff("\033[1;33mTimeout:\033[0m {}, ", timeout_count); - output.appendff("\033[1;35mCrashed:\033[0m {}", crashed_count); - output.append("\n\033[2K\n\033[2K"sv); - - if (total_tests() > 0) { - auto counter_start = output.length(); - output.appendff("{}/{} ", completed_tests, total_tests()); - if (Application::the().repeat_count > 1) - output.appendff("run {}/{} ", current_run, Application::the().repeat_count); - - auto const counter_length = output.length() - counter_start; - size_t const bar_width = s_display_columns > counter_length + 3 ? s_display_columns - counter_length - 3 : 20; - size_t const filled = (completed_tests * bar_width) / total_tests(); - size_t const empty = bar_width - filled; - - output.append("\033[32m["sv); - for (size_t j = 0; j < filled; ++j) { - output.append("█"sv); + if (need_hidden_line) { + t.line([&] { + auto label = ByteString::formatted("... {} more views hidden", view_states().size() - num_view_lines); + t.label(label, {}, { .prefix = ::Test::LiveDisplay::Gray, .text = ::Test::LiveDisplay::None }); + }); } - if (empty > 0 && filled < bar_width) { - output.append("\033[33m▓\033[0m\033[90m"sv); - for (size_t j = 1; j < empty; ++j) { - output.append("░"sv); - } - } - output.append("\033[32m]\033[0m"sv); - } - output.append("\n"sv); - out("{}", output.string_view()); - (void)fflush(stdout); + t.lines( + [] {}, + [&] { + t.counter({ + { .label = "Pass"sv, .color = ::Test::LiveDisplay::Green, .value = pass_count }, + { .label = "Fail"sv, .color = ::Test::LiveDisplay::Red, .value = fail_count }, + { .label = "Skipped"sv, .color = ::Test::LiveDisplay::Gray, .value = skipped_count }, + { .label = "Timeout"sv, .color = ::Test::LiveDisplay::Yellow, .value = timeout_count }, + { .label = "Crashed"sv, .color = ::Test::LiveDisplay::Magenta, .value = crashed_count }, + }); + }, + [] {}, + [&] { + if (total_tests() == 0) + return; + ByteString suffix; + if (Application::the().repeat_count > 1) + suffix = ByteString::formatted("run {}/{}", current_run, Application::the().repeat_count); + t.progress_bar(completed_tests, total_tests(), suffix); + }); + }); } void Display::clear_live_display() @@ -301,12 +287,7 @@ void Display::clear_live_display() display_timer->stop(); display_timer = nullptr; } - for (size_t i = 0; i < s_display_rows; ++i) { - out("\033[A\033[2K"sv); - } - out("\r"sv); - (void)fflush(stdout); - + s_live_display.end(); is_live_display_active = false; } diff --git a/Tests/LibWeb/test-web/main.cpp b/Tests/LibWeb/test-web/main.cpp index 50d24183eb..8984429320 100644 --- a/Tests/LibWeb/test-web/main.cpp +++ b/Tests/LibWeb/test-web/main.cpp @@ -52,6 +52,7 @@ namespace TestWeb { static Vector s_view_display_states; static Vector> s_view_run_next_test; + static RefPtr> s_all_tests_complete; static Vector s_skipped_tests; static Vector s_loaded_from_http_server;