LibWeb+LibTest: Move the progress bar/status printing stuff to LibTest

This commit is contained in:
Ali Mohammad Pur 2026-04-22 21:34:33 +02:00 committed by Ali Mohammad Pur
parent ce7b69ff31
commit 909013b972
3 changed files with 456 additions and 96 deletions

View file

@ -0,0 +1,378 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Assertions.h>
#include <AK/ByteString.h>
#include <AK/Format.h>
#include <AK/Noncopyable.h>
#include <AK/StringBuilder.h>
#include <AK/StringView.h>
#include <AK/Types.h>
#include <fcntl.h>
#include <stdio.h>
#if !defined(AK_OS_WINDOWS)
# include <sys/ioctl.h>
# include <unistd.h>
#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<typename Callback>
void line(Callback&& callback)
{
m_builder.append("\033[2K"sv);
callback();
m_builder.append('\n');
}
template<typename... Callbacks>
void lines(Callbacks&&... callback)
{
(line(forward<Callbacks>(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<auto N>
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<typename Callback>
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;
};
}

View file

@ -8,27 +8,30 @@
#include "Application.h"
#include <AK/Enumerate.h>
#include <AK/Math.h>
#include <AK/QuickSort.h>
#include <AK/SaturatingMath.h>
#include <AK/StringBuilder.h>
#include <LibCore/EventLoop.h>
#include <LibCore/File.h>
#include <LibCore/System.h>
#include <LibCore/Timer.h>
#include <LibDiff/Format.h>
#include <LibDiff/Generator.h>
#include <LibGfx/Size.h>
#include <LibTest/LiveDisplay.h>
#ifndef AK_OS_WINDOWS
# include <signal.h>
# include <sys/ioctl.h>
# include <unistd.h>
#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;
}

View file

@ -52,6 +52,7 @@ namespace TestWeb {
static Vector<ViewDisplayState> s_view_display_states;
static Vector<Function<void()>> s_view_run_next_test;
static RefPtr<Core::Promise<Empty>> s_all_tests_complete;
static Vector<ByteString> s_skipped_tests;
static Vector<ByteString> s_loaded_from_http_server;