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. :^)
This commit is contained in:
parent
46cd47753f
commit
d77a0a2daf
4 changed files with 315 additions and 34 deletions
|
|
@ -9,6 +9,7 @@
|
|||
#include "TestWeb.h"
|
||||
#include "TestWebView.h"
|
||||
|
||||
#include <AK/GenericLexer.h>
|
||||
#include <LibCore/Environment.h>
|
||||
#include <LibCore/EventLoop.h>
|
||||
#include <LibCore/Notifier.h>
|
||||
|
|
@ -18,30 +19,314 @@
|
|||
|
||||
namespace TestWeb {
|
||||
|
||||
ByteBuffer strip_sgr_sequences(StringView input)
|
||||
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)
|
||||
{
|
||||
// Regex equivalent: /\x1b\[[0-9;]*m/
|
||||
auto const* bytes = reinterpret_cast<unsigned char const*>(input.characters_without_null_termination());
|
||||
size_t length = input.length();
|
||||
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 };
|
||||
}
|
||||
|
||||
ByteBuffer output;
|
||||
output.ensure_capacity(length);
|
||||
struct ANSIStyle {
|
||||
Optional<Color> foreground;
|
||||
Optional<Color> background;
|
||||
bool bold = false;
|
||||
bool dim = false;
|
||||
bool italic = false;
|
||||
bool underline = false;
|
||||
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
if (bytes[i] == 0x1b && (i + 1) < length && bytes[i + 1] == '[') {
|
||||
size_t j = i + 2;
|
||||
while (j < length && (is_ascii_digit(bytes[j]) || bytes[j] == ';'))
|
||||
++j;
|
||||
if (j < length && bytes[j] == 'm') {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output.append(static_cast<u8>(bytes[i]));
|
||||
bool has_styles() const
|
||||
{
|
||||
return foreground.has_value() || background.has_value()
|
||||
|| bold || dim || italic || underline;
|
||||
}
|
||||
|
||||
return output;
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,6 @@ void maybe_attach_on_fail_fast_timeout(pid_t);
|
|||
void append_timeout_diagnostics_to_stderr(StringBuilder&, TestWebView&, Test const&, size_t view_id);
|
||||
void append_timeout_backtraces_to_stderr(StringBuilder&, TestWebView&, Test const&, size_t view_id);
|
||||
|
||||
ByteBuffer strip_sgr_sequences(StringView);
|
||||
StringBuilder convert_ansi_to_html(StringView);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,22 +294,18 @@ static ErrorOr<void> write_output_for_test(Test const& test, ViewOutputCapture&
|
|||
|
||||
// Write stdout if not empty
|
||||
if (!capture.stdout_buffer.is_empty()) {
|
||||
auto stdout_path = ByteString::formatted("{}.stdout.txt", base_path);
|
||||
auto stdout_path = ByteString::formatted("{}.stdout.html", base_path);
|
||||
auto file = TRY(Core::File::open(stdout_path, Core::File::OpenMode::Write));
|
||||
TRY(file->write_until_depleted(capture.stdout_buffer.string_view().bytes()));
|
||||
auto html = convert_ansi_to_html(capture.stdout_buffer.string_view());
|
||||
TRY(file->write_until_depleted(html.string_view()));
|
||||
}
|
||||
|
||||
// Write stderr if not empty
|
||||
if (!capture.stderr_buffer.is_empty()) {
|
||||
auto stderr_path = ByteString::formatted("{}.stderr.txt", base_path);
|
||||
auto stderr_path = ByteString::formatted("{}.stderr.html", base_path);
|
||||
auto file = TRY(Core::File::open(stderr_path, Core::File::OpenMode::Write));
|
||||
auto stderr_view = capture.stderr_buffer.string_view();
|
||||
if (stderr_view.contains('\x1b')) {
|
||||
auto stripped = strip_sgr_sequences(stderr_view);
|
||||
TRY(file->write_until_depleted(stripped.bytes()));
|
||||
} else {
|
||||
TRY(file->write_until_depleted(stderr_view.bytes()));
|
||||
}
|
||||
auto html = convert_ansi_to_html(capture.stderr_buffer.string_view());
|
||||
TRY(file->write_until_depleted(html.string_view()));
|
||||
}
|
||||
|
||||
// Clear buffers for next test
|
||||
|
|
@ -559,8 +555,8 @@ static ErrorOr<void> generate_result_files(ReadonlySpan<Test> tests, ReadonlySpa
|
|||
|
||||
auto const& test = tests[result.test_index];
|
||||
auto base_path = TRY(prepare_output_path(test));
|
||||
bool has_stdout = FileSystem::exists(ByteString::formatted("{}.stdout.txt", base_path));
|
||||
bool has_stderr = FileSystem::exists(ByteString::formatted("{}.stderr.txt", base_path));
|
||||
bool has_stdout = FileSystem::exists(ByteString::formatted("{}.stdout.html", base_path));
|
||||
bool has_stderr = FileSystem::exists(ByteString::formatted("{}.stderr.html", base_path));
|
||||
|
||||
js.appendff(" {{ \"name\": \"{}\", \"result\": \"{}\", \"mode\": \"{}\", \"hasStdout\": {}, \"hasStderr\": {}",
|
||||
test.safe_relative_path,
|
||||
|
|
|
|||
|
|
@ -645,8 +645,8 @@
|
|||
tabs.push({ id: 'compare', label: 'Compare', expected: `${test.name}.expected.png`, actual: `${test.name}.actual.png`, diff: `${test.name}.diff.png`, pixelErrors: test.pixelErrors, maxChannelDiff: test.maxChannelDiff, type: 'imageDiff' });
|
||||
}
|
||||
|
||||
if (test.hasStdout) tabs.push({ id: 'stdout', label: 'stdout', file: `${test.name}.stdout.txt`, type: 'text' });
|
||||
if (test.hasStderr) tabs.push({ id: 'stderr', label: 'stderr', file: `${test.name}.stderr.txt`, type: 'text' });
|
||||
if (test.hasStdout) tabs.push({ id: 'stdout', label: 'stdout', file: `${test.name}.stdout.html`, type: 'text' });
|
||||
if (test.hasStderr) tabs.push({ id: 'stderr', label: 'stderr', file: `${test.name}.stderr.html`, type: 'text' });
|
||||
|
||||
const bar = tabs.map((t, i) =>
|
||||
`<button class="tab-btn${i === 0 ? ' active' : ''}" onclick="showTab('${test.name.replace(/[^a-zA-Z0-9]/g, '-')}', ${JSON.stringify(t).replace(/"/g, '"')}, this)">${t.label}</button>`
|
||||
|
|
|
|||
Loading…
Reference in a new issue