Files
ladybird/Tests/LibWeb/test-web/Debug.cpp
Zaggy1024 d77a0a2daf test-web: Convert ANSI control codes to HTML styling in dumped output
Instead of stripping all the control codes, let's use them to make our
test dumps more readable. :^)
2026-02-28 11:19:19 -06:00

705 lines
22 KiB
C++

/*
* Copyright (c) 2026, The Ladybird Developers
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "Debug.h"
#include "Application.h"
#include "TestWeb.h"
#include "TestWebView.h"
#include <AK/GenericLexer.h>
#include <LibCore/Environment.h>
#include <LibCore/EventLoop.h>
#include <LibCore/Notifier.h>
#include <LibCore/System.h>
#include <LibFileSystem/FileSystem.h>
#include <LibRequests/RequestClient.h>
namespace TestWeb {
static constexpr Array s_standard_colors = {
Color(0, 0, 0), // Black
Color(231, 0, 0), // Red
Color(0, 231, 0), // Green
Color(231, 196, 0), // Yellow
Color(35, 126, 231), // Blue
Color(231, 0, 231), // Magenta
Color(0, 231, 231), // Cyan
Color(255, 255, 255), // White
};
static constexpr Array s_bright_colors = [] {
auto array = s_standard_colors;
for (size_t i = 0; i < array.size(); i++) {
array[i] = array[i].lightened(1.5f);
}
return array;
}();
static Color indexed_color_to_rgb(u32 index)
{
if (index < 8)
return s_standard_colors[index];
if (index < 16)
return s_bright_colors[index - 8];
if (index < 232) {
// 6x6x6 color cube
auto n = index - 16;
u8 r = static_cast<u8>(n / 36);
u8 g = static_cast<u8>((n % 36) / 6);
u8 b = static_cast<u8>(n % 6);
return {
static_cast<u8>(r ? 55 + (40 * r) : 0),
static_cast<u8>(g ? 55 + (40 * g) : 0),
static_cast<u8>(b ? 55 + (40 * b) : 0),
};
}
if (index < 256) {
// Grayscale ramp
u8 level = static_cast<u8>(8 + (10 * (index - 232)));
return { level, level, level };
}
return { Color::NamedColor::LightGray };
}
struct ANSIStyle {
Optional<Color> foreground;
Optional<Color> background;
bool bold = false;
bool dim = false;
bool italic = false;
bool underline = false;
bool has_styles() const
{
return foreground.has_value() || background.has_value()
|| bold || dim || italic || underline;
}
void reset() { *this = {}; }
};
static void append_style_span(StringBuilder& html, ANSIStyle const& style)
{
if (!style.has_styles())
return;
html.append("<span style=\""sv);
if (style.foreground.has_value())
html.appendff("color:{};", style.foreground->to_string());
if (style.background.has_value())
html.appendff("background:{};", style.background->to_string());
if (style.bold)
html.append("font-weight:bold;"sv);
if (style.dim)
html.append("opacity:0.6;"sv);
if (style.italic)
html.append("font-style:italic;"sv);
if (style.underline)
html.append("text-decoration:underline;"sv);
html.append("\">"sv);
}
static void apply_sgr_codes(ReadonlySpan<u32> codes, ANSIStyle& style)
{
if (codes.is_empty()) {
style.reset();
return;
}
size_t i = 0;
auto has_codes = [&](auto count) {
return i + count <= codes.size();
};
auto take_code = [&] {
return codes[i++];
};
while (i < codes.size()) {
u32 code = take_code();
switch (code) {
case 0:
style.reset();
break;
case 1:
style.bold = true;
break;
case 2:
style.dim = true;
break;
case 3:
style.italic = true;
break;
case 4:
style.underline = true;
break;
case 22:
style.bold = false;
style.dim = false;
break;
case 23:
style.italic = false;
break;
case 24:
style.underline = false;
break;
case 30:
case 31:
case 32:
case 33:
case 34:
case 35:
case 36:
case 37:
style.foreground = s_standard_colors[code - 30];
break;
case 38: {
if (!has_codes(1))
break;
u32 mode = take_code();
if (mode == 5 && has_codes(1)) {
style.foreground = indexed_color_to_rgb(take_code());
} else if (mode == 2 && has_codes(3)) {
u8 r = static_cast<u8>(take_code());
u8 g = static_cast<u8>(take_code());
u8 b = static_cast<u8>(take_code());
style.foreground = Color(r, g, b);
}
break;
}
case 39:
style.foreground = {};
break;
case 40:
case 41:
case 42:
case 43:
case 44:
case 45:
case 46:
case 47:
style.background = s_standard_colors[code - 40];
break;
case 48: {
if (!has_codes(1))
break;
u32 mode = take_code();
if (mode == 5 && has_codes(1)) {
style.background = indexed_color_to_rgb(take_code());
} else if (mode == 2 && has_codes(3)) {
u8 r = static_cast<u8>(take_code());
u8 g = static_cast<u8>(take_code());
u8 b = static_cast<u8>(take_code());
style.background = Color(r, g, b);
}
break;
}
case 49:
style.background = {};
break;
case 90:
case 91:
case 92:
case 93:
case 94:
case 95:
case 96:
case 97:
style.foreground = s_bright_colors[code - 90];
break;
case 100:
case 101:
case 102:
case 103:
case 104:
case 105:
case 106:
case 107:
style.background = s_bright_colors[code - 100];
break;
default:
break;
}
}
}
static bool is_csi_parameter_byte(char c)
{
return c >= '0' && c <= '?';
}
static bool is_csi_intermediate_byte(char c)
{
return c >= ' ' && c <= '/';
}
static bool is_csi_final_byte(char c)
{
return c >= '@' && c <= '~';
}
StringBuilder convert_ansi_to_html(StringView input)
{
GenericLexer lexer(input);
StringBuilder html;
html.append(R"html(<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; background: #0d1117; color: #e6edf3; }
pre { margin: 0; padding: 16px; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.5; }
</style>
</head>
<body><pre>)html"sv);
ANSIStyle style;
bool span_open = false;
constexpr char introducer = '\x1b';
while (!lexer.is_eof()) {
if (lexer.consume_specific(introducer)) {
if (lexer.consume_specific('[')) {
// CSI sequence: parse numeric parameters, then intermediate and final bytes.
Vector<u32, 8> codes;
while (!lexer.is_eof() && is_csi_parameter_byte(lexer.peek())) {
if (auto code = lexer.consume_decimal_integer<u32>(); !code.is_error()) {
codes.append(code.value());
lexer.consume_specific(';');
} else if (lexer.consume_specific(';')) {
// Empty parameter defaults to 0.
codes.append(0);
} else {
// Non-digit, non-';' parameter byte (e.g. '?' in private mode sequences).
lexer.ignore_while(is_csi_parameter_byte);
break;
}
}
lexer.ignore_while(is_csi_intermediate_byte);
if (!lexer.is_eof() && is_csi_final_byte(lexer.peek())) {
char final_byte = lexer.consume();
if (final_byte == 'm') {
if (span_open) {
html.append("</span>"sv);
span_open = false;
}
apply_sgr_codes(codes, style);
if (style.has_styles()) {
append_style_span(html, style);
span_open = true;
}
}
}
} else if (lexer.consume_specific(']')) {
// OSC sequence: consume until BEL or ST.
while (!lexer.is_eof()) {
if (lexer.consume_specific('\x07'))
break;
if (lexer.consume_specific("\x1b\\"sv))
break;
lexer.ignore();
}
} else if (!lexer.is_eof()) {
// Other two-byte escape sequence.
lexer.ignore();
}
continue;
}
static constexpr auto is_skipped_character = [](char c) {
return c != introducer && is_ascii_control(c) && c != '\n' && c != '\t';
};
// Consume a run of visible text (plus newlines and tabs) and HTML-escape it.
auto run = lexer.consume_until([](char c) {
return c == introducer || is_skipped_character(c);
});
html.append(escape_html_entities(run));
lexer.ignore_while(is_skipped_character);
}
if (span_open)
html.append("</span>"sv);
html.append("</pre></body></html>"sv);
return html;
}
static bool stdin_and_stdout_are_ttys()
{
auto stdin_is_tty_or_error = Core::System::isatty(STDIN_FILENO);
auto stdout_is_tty_or_error = Core::System::isatty(STDOUT_FILENO);
return !stdin_is_tty_or_error.is_error() && stdin_is_tty_or_error.value()
&& !stdout_is_tty_or_error.is_error() && stdout_is_tty_or_error.value();
}
static void maybe_attach_lldb_to_process(pid_t pid)
{
if (pid <= 0)
return;
Vector<ByteString> arguments;
arguments.append("-p"sv);
arguments.append(ByteString::number(pid));
auto process_or_error = Core::Process::spawn({
.executable = "lldb"sv,
.search_for_executable_in_path = true,
.arguments = arguments,
});
if (process_or_error.is_error()) {
warnln("Failed to spawn lldb: {}", process_or_error.error());
return;
}
Core::Process lldb = process_or_error.release_value();
if (auto wait_result = lldb.wait_for_termination(); wait_result.is_error())
warnln("Failed waiting for lldb: {}", wait_result.error());
}
static void maybe_attach_gdb_to_process(pid_t pid)
{
if (pid <= 0)
return;
Vector<ByteString> arguments;
arguments.append("-q"sv);
arguments.append("-p"sv);
arguments.append(ByteString::number(pid));
auto process_or_error = Core::Process::spawn({
.executable = "gdb"sv,
.search_for_executable_in_path = true,
.arguments = arguments,
});
if (process_or_error.is_error()) {
warnln("Failed to spawn gdb: {}", process_or_error.error());
return;
}
Core::Process gdb = process_or_error.release_value();
if (auto wait_result = gdb.wait_for_termination(); wait_result.is_error())
warnln("Failed waiting for gdb: {}", wait_result.error());
}
static void append_diagnostics_header(StringBuilder& builder, Test const& test, size_t view_id, StringView current_url)
{
auto& app = Application::the();
builder.appendff("\n==== timeout diagnostics ====\n");
builder.appendff("time: {}\n", UnixDateTime::now().to_byte_string());
builder.appendff("test: {}\n", test.relative_path);
builder.appendff("run: {}/{}\n", test.run_index, test.total_runs);
builder.appendff("view: {}\n", view_id);
builder.appendff("test-concurrency: {}\n", app.test_concurrency);
builder.appendff("current-url: {}\n\n", current_url);
}
static Optional<String> request_page_info_with_timeout(TestWebView& view, WebView::PageInfoType page_info_type, u32 timeout_ms)
{
struct PageInfoState : public RefCounted<PageInfoState> {
Optional<String> text;
bool finished { false };
bool timed_out { false };
};
auto state = make_ref_counted<PageInfoState>();
auto timeout_timer = Core::Timer::create_single_shot(static_cast<int>(timeout_ms), [state] {
state->timed_out = true;
});
auto promise = view.request_internal_page_info(page_info_type);
promise->when_resolved([state](String& resolved) {
if (state->timed_out)
return;
state->text = resolved;
state->finished = true;
});
timeout_timer->start();
Core::EventLoop::current().spin_until([state] {
return state->finished || state->timed_out;
});
return state->text;
}
static void append_page_info(StringBuilder& builder, StringView title, Optional<String> const& text)
{
builder.appendff("---- {} ----\n", title);
if (text.has_value()) {
builder.append(text->bytes_as_string_view());
if (!text->bytes_as_string_view().ends_with('\n'))
builder.append("\n"sv);
} else {
builder.append("(Timed out waiting for page info)\n"sv);
}
builder.append("\n"sv);
}
static ErrorOr<void> run_tool_and_append_output(StringBuilder& builder, StringView tool_name, Vector<ByteString> const& arguments, u32 timeout_ms)
{
#ifdef AK_OS_WINDOWS
(void)builder;
(void)tool_name;
(void)arguments;
(void)timeout_ms;
return Error::from_string_literal("No Windows yet");
#else
auto pipe_fds = TRY(Core::System::pipe2(0));
int read_fd = pipe_fds[0];
int write_fd = pipe_fds[1];
Vector<Core::ProcessSpawnOptions::FileActionType> file_actions;
file_actions.append(Core::FileAction::CloseFile { .fd = read_fd });
file_actions.append(Core::FileAction::DupFd { .write_fd = write_fd, .fd = STDOUT_FILENO });
file_actions.append(Core::FileAction::DupFd { .write_fd = write_fd, .fd = STDERR_FILENO });
file_actions.append(Core::FileAction::CloseFile { .fd = write_fd });
auto process_or_error = Core::Process::spawn({
.executable = tool_name,
.search_for_executable_in_path = true,
.arguments = arguments,
.file_actions = move(file_actions),
});
if (process_or_error.is_error()) {
(void)Core::System::close(read_fd);
(void)Core::System::close(write_fd);
return process_or_error.release_error();
}
Core::Process process = process_or_error.release_value();
(void)Core::System::close(write_fd);
bool finished = false;
bool timed_out = false;
auto notifier = Core::Notifier::construct(read_fd, Core::Notifier::Type::Read);
notifier->on_activation = [&] {
Array<u8, 4096> buffer;
auto nread_or_error = Core::System::read(read_fd, buffer);
if (nread_or_error.is_error() || nread_or_error.value() == 0) {
finished = true;
return;
}
builder.append(StringView { buffer.data(), nread_or_error.value() });
};
auto timeout_timer = Core::Timer::create_single_shot(static_cast<int>(timeout_ms), [&] {
timed_out = true;
});
timeout_timer->start();
Core::EventLoop::current().spin_until([&] {
return finished || timed_out;
});
notifier->close();
if (!finished)
(void)Core::System::kill(process.pid(), SIGKILL);
(void)Core::System::waitpid(process.pid(), 0);
(void)Core::System::close(read_fd);
return {};
#endif
}
static bool tool_exists_on_path(StringView tool_name)
{
StringView path_view = Core::Environment::get("PATH"sv).value_or(DEFAULT_PATH_SV);
for (StringView dir : path_view.split_view(':')) {
if (dir.is_empty())
continue;
ByteString candidate = LexicalPath::join(dir, tool_name).string();
if (!FileSystem::exists(candidate))
continue;
if (FileSystem::is_directory(candidate))
continue;
return true;
}
return false;
}
enum class BacktraceTool : u8 {
None,
LLDB,
GDB,
Sample,
};
static BacktraceTool choose_backtrace_tool_for_process(pid_t)
{
#if defined(AK_OS_MACOS)
if (tool_exists_on_path("lldb"sv))
return BacktraceTool::LLDB;
if (tool_exists_on_path("sample"sv))
return BacktraceTool::Sample;
if (tool_exists_on_path("gdb"sv))
return BacktraceTool::GDB;
return BacktraceTool::None;
#else
bool have_lldb = tool_exists_on_path("lldb"sv);
bool have_gdb = tool_exists_on_path("gdb"sv);
if (have_lldb && have_gdb) {
# ifdef AK_COMPILER_CLANG
return BacktraceTool::LLDB;
# else
return BacktraceTool::GDB;
# endif
}
if (have_lldb)
return BacktraceTool::LLDB;
if (have_gdb)
return BacktraceTool::GDB;
return BacktraceTool::None;
#endif
}
static void append_backtrace_for_process(StringBuilder& builder, StringView process_kind, pid_t pid)
{
builder.appendff("---- {} pid {} stacks ----\n", process_kind, pid);
if (pid <= 0) {
builder.append("(No pid)\n\n"sv);
return;
}
switch (choose_backtrace_tool_for_process(pid)) {
case BacktraceTool::LLDB: {
constexpr u32 backtrace_timeout_ms = 60 * 1000; // DWARF indexing on mac can take ages
Vector<ByteString> arguments;
arguments.append("--no-lldbinit"sv);
arguments.append("-b"sv);
arguments.append("-p"sv);
arguments.append(ByteString::number(pid));
arguments.append("-o"sv);
#if defined(AK_OS_MACOS)
// On macOS, "thread backtrace all" can be slow due to expensive debug info lookups.
arguments.append("thread backtrace -c 50 unique"sv);
#else
arguments.append("thread backtrace all"sv);
#endif
arguments.append("-o"sv);
arguments.append("detach"sv);
arguments.append("-o"sv);
arguments.append("quit"sv);
builder.append("[lldb]\n"sv);
StringBuilder lldb_output;
auto result = run_tool_and_append_output(lldb_output, "lldb"sv, arguments, backtrace_timeout_ms);
if (result.is_error()) {
builder.appendff("(lldb failed: {})\n", result.error());
break;
}
builder.append(lldb_output.string_view());
break;
}
case BacktraceTool::GDB: {
constexpr u32 backtrace_timeout_ms = 2500;
Vector<ByteString> arguments;
arguments.append("-q"sv);
arguments.append("-n"sv);
arguments.append("-batch"sv);
arguments.append("-p"sv);
arguments.append(ByteString::number(pid));
arguments.append("-ex"sv);
arguments.append("set pagination off"sv);
arguments.append("-ex"sv);
arguments.append("thread apply all bt full"sv);
arguments.append("-ex"sv);
arguments.append("detach"sv);
arguments.append("-ex"sv);
arguments.append("quit"sv);
builder.append("[gdb]\n"sv);
if (auto result = run_tool_and_append_output(builder, "gdb"sv, arguments, backtrace_timeout_ms); result.is_error())
builder.appendff("(gdb failed: {})\n", result.error());
break;
}
case BacktraceTool::Sample: {
constexpr u32 backtrace_timeout_ms = 2500;
Vector<ByteString> arguments;
arguments.append(ByteString::number(pid));
arguments.append("1"sv);
arguments.append("1"sv);
builder.append("[sample]\n"sv);
if (auto result = run_tool_and_append_output(builder, "sample"sv, arguments, backtrace_timeout_ms); result.is_error())
builder.appendff("(sample failed: {})\n", result.error());
break;
}
case BacktraceTool::None:
builder.append("(no supported backtrace tool found on PATH)\n"sv);
break;
}
builder.append("\n"sv);
}
void maybe_attach_on_fail_fast_timeout(pid_t pid)
{
if (pid <= 0)
return;
if (!stdin_and_stdout_are_ttys())
return;
outln("Fail-fast timeout in WebContent pid {}.", pid);
outln("You may attach a debugger now (test-web will wait)."sv);
outln("- Press Enter to continue shutdown + exit"sv);
outln("- Type 'gdb' then Enter to attach with gdb first"sv);
outln("- Type 'lldb' then Enter to attach with lldb first"sv);
MUST(Core::System::write(1, "> "sv.bytes()));
auto standard_input_or_error = Core::File::standard_input();
if (standard_input_or_error.is_error())
return;
Array<u8, 64> input_buffer {};
auto buffered_standard_input_or_error = Core::InputBufferedFile::create(standard_input_or_error.release_value());
if (buffered_standard_input_or_error.is_error())
return;
auto& buffered_standard_input = buffered_standard_input_or_error.value();
auto response_or_error = buffered_standard_input->read_line(input_buffer);
if (response_or_error.is_error())
return;
ByteString response { response_or_error.value() };
response = response.trim_whitespace();
if (response.equals_ignoring_ascii_case("gdb"sv)) {
maybe_attach_gdb_to_process(pid);
return;
}
if (response.equals_ignoring_ascii_case("lldb"sv))
maybe_attach_lldb_to_process(pid);
}
void append_timeout_diagnostics_to_stderr(StringBuilder& stderr_builder, TestWebView& view, Test const& test, size_t view_id)
{
append_diagnostics_header(stderr_builder, test, view_id, view.url().to_byte_string());
append_page_info(stderr_builder, "PageInfoType::Text"sv, request_page_info_with_timeout(view, WebView::PageInfoType::Text, 750));
append_page_info(stderr_builder, "PageInfoType::LayoutTree"sv, request_page_info_with_timeout(view, WebView::PageInfoType::LayoutTree, 750));
append_backtrace_for_process(stderr_builder, "webcontent"sv, view.web_content_pid());
}
void append_timeout_backtraces_to_stderr(StringBuilder& stderr_builder, TestWebView& view, Test const& test, size_t view_id)
{
append_diagnostics_header(stderr_builder, test, view_id, view.url().to_byte_string());
append_backtrace_for_process(stderr_builder, "webcontent"sv, view.web_content_pid());
}
}