2024-10-10 12:56:27 -03:00
|
|
|
/*
|
2025-06-06 21:11:13 -03:00
|
|
|
* Copyright (c) 2022, Dex♪ <dexes.ttp@gmail.com>
|
|
|
|
|
* Copyright (c) 2023-2025, Tim Flynn <trflynn89@ladybird.org>
|
|
|
|
|
* Copyright (c) 2023, Andreas Kling <andreas@ladybird.org>
|
|
|
|
|
* Copyright (c) 2023-2024, Sam Atkins <sam@ladybird.org>
|
2025-07-16 05:24:15 -03:00
|
|
|
* Copyright (c) 2025, Jelle Raaijmakers <jelle@ladybird.org>
|
2024-10-10 12:56:27 -03:00
|
|
|
*
|
|
|
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
|
|
|
*/
|
|
|
|
|
|
2025-06-06 21:11:13 -03:00
|
|
|
#include "Application.h"
|
2026-02-13 04:29:20 -03:00
|
|
|
#include "Debug.h"
|
2026-04-16 15:01:55 -03:00
|
|
|
#include "Display.h"
|
2026-04-11 16:12:17 -03:00
|
|
|
#include "TestRunCapture.h"
|
2025-06-06 21:11:13 -03:00
|
|
|
#include "TestWeb.h"
|
|
|
|
|
#include "TestWebView.h"
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <AK/ByteBuffer.h>
|
|
|
|
|
#include <AK/Enumerate.h>
|
2026-01-13 13:33:48 -03:00
|
|
|
#include <AK/Function.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <AK/LexicalPath.h>
|
2025-08-17 20:16:39 -03:00
|
|
|
#include <AK/NumberFormat.h>
|
2026-05-29 06:20:45 -03:00
|
|
|
#include <AK/Platform.h>
|
2024-10-13 10:23:27 -03:00
|
|
|
#include <AK/QuickSort.h>
|
2025-12-01 06:36:50 -03:00
|
|
|
#include <AK/Random.h>
|
2026-01-13 13:33:48 -03:00
|
|
|
#include <AK/ScopeGuard.h>
|
|
|
|
|
#include <AK/Span.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <LibCore/ConfigFile.h>
|
|
|
|
|
#include <LibCore/DirIterator.h>
|
|
|
|
|
#include <LibCore/Directory.h>
|
|
|
|
|
#include <LibCore/EventLoop.h>
|
|
|
|
|
#include <LibCore/File.h>
|
2026-02-24 04:17:16 -03:00
|
|
|
#include <LibCore/MappedFile.h>
|
2026-02-27 12:38:48 -03:00
|
|
|
#include <LibCore/Process.h>
|
2026-01-13 07:23:09 -03:00
|
|
|
#include <LibCore/StandardPaths.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <LibCore/Timer.h>
|
|
|
|
|
#include <LibDiff/Format.h>
|
|
|
|
|
#include <LibDiff/Generator.h>
|
|
|
|
|
#include <LibFileSystem/FileSystem.h>
|
|
|
|
|
#include <LibGfx/Bitmap.h>
|
2026-02-24 04:17:16 -03:00
|
|
|
#include <LibGfx/ImageFormats/ImageDecoder.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <LibGfx/ImageFormats/PNGWriter.h>
|
2025-06-06 21:11:13 -03:00
|
|
|
#include <LibGfx/SystemTheme.h>
|
2025-07-16 05:24:15 -03:00
|
|
|
#include <LibURL/Parser.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
#include <LibURL/URL.h>
|
|
|
|
|
#include <LibWeb/HTML/SelectedFile.h>
|
2026-01-13 07:23:09 -03:00
|
|
|
#include <LibWebView/Process.h>
|
2025-06-06 21:11:13 -03:00
|
|
|
#include <LibWebView/Utilities.h>
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
#include <signal.h>
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2025-06-06 21:11:13 -03:00
|
|
|
namespace TestWeb {
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 07:23:09 -03:00
|
|
|
static Vector<ViewDisplayState> s_view_display_states;
|
2026-01-13 13:33:48 -03:00
|
|
|
static Vector<Function<void()>> s_view_run_next_test;
|
2026-05-12 15:30:48 -03:00
|
|
|
static HashMap<WebView::ViewImplementation const*, size_t> s_view_index_by_view;
|
2026-04-22 16:34:33 -03:00
|
|
|
|
2025-08-17 20:16:39 -03:00
|
|
|
static RefPtr<Core::Promise<Empty>> s_all_tests_complete;
|
2024-10-10 12:56:27 -03:00
|
|
|
static Vector<ByteString> s_skipped_tests;
|
2026-02-19 12:07:26 -03:00
|
|
|
static Vector<ByteString> s_loaded_from_http_server;
|
2026-01-13 13:33:48 -03:00
|
|
|
static HashMap<WebView::ViewImplementation const*, size_t> s_current_test_index_by_view;
|
|
|
|
|
|
|
|
|
|
struct TestRunContext {
|
|
|
|
|
Vector<Test>& tests;
|
|
|
|
|
size_t& tests_remaining;
|
|
|
|
|
size_t& total_tests;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static TestRunContext* s_run_context { nullptr };
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
Vector<ViewDisplayState>& view_states() { return s_view_display_states; }
|
|
|
|
|
size_t total_tests() { return s_run_context ? s_run_context->total_tests : 0; }
|
|
|
|
|
|
2026-02-26 21:38:34 -03:00
|
|
|
static ErrorOr<ByteString> prepare_output_path(Test const& test)
|
2026-01-13 07:23:09 -03:00
|
|
|
{
|
|
|
|
|
auto& app = Application::the();
|
2026-02-26 21:38:34 -03:00
|
|
|
auto base_path = LexicalPath::join(app.results_directory, test.safe_relative_path);
|
|
|
|
|
TRY(Core::Directory::create(base_path.dirname(), Core::Directory::CreateDirectories::Yes));
|
|
|
|
|
return base_path.string();
|
|
|
|
|
}
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2026-03-04 20:31:23 -03:00
|
|
|
static bool is_valid_test_name(StringView test_name)
|
|
|
|
|
{
|
2026-04-29 17:31:28 -03:00
|
|
|
auto valid_test_file_suffixes = { ".htm"sv, ".html"sv, ".svg"sv, ".xhtml"sv, ".xht"sv, ".pdf"sv };
|
2026-03-04 20:31:23 -03:00
|
|
|
return AK::any_of(valid_test_file_suffixes, [&](auto suffix) { return test_name.ends_with(suffix); });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 13:06:07 -03:00
|
|
|
static ErrorOr<ByteString> real_path_for_test_input(ByteString const& path)
|
|
|
|
|
{
|
|
|
|
|
auto maybe_real_path = FileSystem::real_path(path);
|
|
|
|
|
if (maybe_real_path.is_error())
|
|
|
|
|
warnln("Unable to get real path for TestConfig.ini entry '{}': {}", path, maybe_real_path.error());
|
|
|
|
|
return maybe_real_path;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 20:31:23 -03:00
|
|
|
static ErrorOr<void> enumerate_test_files_recursively(StringView directory, Vector<ByteString>& output)
|
|
|
|
|
{
|
|
|
|
|
Core::DirIterator it(directory, Core::DirIterator::Flags::SkipDots);
|
|
|
|
|
while (it.has_next()) {
|
|
|
|
|
auto full_path = it.next_full_path();
|
|
|
|
|
if (FileSystem::is_directory(full_path)) {
|
|
|
|
|
TRY(enumerate_test_files_recursively(full_path, output));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!is_valid_test_name(full_path))
|
|
|
|
|
continue;
|
2026-03-09 13:06:07 -03:00
|
|
|
output.append(TRY(real_path_for_test_input(full_path)));
|
2026-03-04 20:31:23 -03:00
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ErrorOr<void> add_config_paths(StringView test_root_path, Vector<ByteString> const& keys, Vector<ByteString>& output)
|
|
|
|
|
{
|
|
|
|
|
for (auto const& key : keys) {
|
|
|
|
|
auto path = LexicalPath::join(test_root_path, key).string();
|
|
|
|
|
if (key.ends_with('/')) {
|
|
|
|
|
TRY(enumerate_test_files_recursively(path, output));
|
|
|
|
|
} else {
|
2026-03-09 13:06:07 -03:00
|
|
|
output.append(TRY(real_path_for_test_input(path)));
|
2026-03-04 20:31:23 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 08:54:22 -03:00
|
|
|
static ByteString unique_localhost_hostname(StringView prefix)
|
|
|
|
|
{
|
|
|
|
|
return ByteString::formatted("{}-{}.localhost", prefix, generate_random_uuid().to_byte_string());
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
static ErrorOr<void> load_test_config(StringView test_root_path)
|
|
|
|
|
{
|
|
|
|
|
auto config_path = LexicalPath::join(test_root_path, "TestConfig.ini"sv);
|
|
|
|
|
auto config_or_error = Core::ConfigFile::open(config_path.string());
|
|
|
|
|
|
|
|
|
|
if (config_or_error.is_error()) {
|
|
|
|
|
if (config_or_error.error().code() == ENOENT)
|
|
|
|
|
return {};
|
|
|
|
|
warnln("Unable to open test config {}", config_path);
|
|
|
|
|
return config_or_error.release_error();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto config = config_or_error.release_value();
|
|
|
|
|
for (auto const& group : config->groups()) {
|
|
|
|
|
if (group == "Skipped"sv) {
|
2026-03-04 20:31:23 -03:00
|
|
|
TRY(add_config_paths(test_root_path, config->keys(group), s_skipped_tests));
|
2026-02-19 12:07:26 -03:00
|
|
|
} else if (group == "LoadFromHttpServer"sv) {
|
2026-03-04 20:31:23 -03:00
|
|
|
TRY(add_config_paths(test_root_path, config->keys(group), s_loaded_from_http_server));
|
2024-10-10 12:56:27 -03:00
|
|
|
} else {
|
|
|
|
|
warnln("Unknown group '{}' in config {}", group, config_path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
LibWeb: Add compositor scroll state snapshots
Record the scroll geometry that CompositorThread can safely reason about
while the main thread is painting. The snapshot contains stable scroll
node IDs, parent links, scroll bounds, sticky inputs, and wheel blocker
regions in display-list coordinate space.
Add AsyncScrollingState and AsyncScrollTree under LibWeb/Compositor. The
state is the immutable main-thread snapshot; the tree is the mutable
compositor-side copy that can replay scroll deltas and sticky offsets
without touching DOM, layout, or paintables.
Expose the state through internals and add text tests for tree shape,
parent links, sticky areas, blocker hit testing, nested scrollers, and
admission decisions. Keep the directory skipped unless the feature is
enabled with --enable-async-scrolling.
2026-05-11 16:27:11 -03:00
|
|
|
static ErrorOr<void> skip_async_scrolling_tests_unless_enabled(Application const& app)
|
|
|
|
|
{
|
|
|
|
|
if (WebView::Application::web_content_options().enable_async_scrolling == WebView::EnableAsyncScrolling::Yes)
|
|
|
|
|
return {};
|
|
|
|
|
|
|
|
|
|
auto path = LexicalPath::join(app.test_root_path, "Text/input/async-scrolling/"sv).string();
|
|
|
|
|
if (!FileSystem::exists(path))
|
|
|
|
|
return {};
|
|
|
|
|
return enumerate_test_files_recursively(path, s_skipped_tests);
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-30 10:08:47 -03:00
|
|
|
static ErrorOr<void> collect_dump_tests(Application const& app, Vector<Test>& tests, StringView path, StringView trail, TestMode mode)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
|
|
|
|
Core::DirIterator it(ByteString::formatted("{}/input/{}", path, trail), Core::DirIterator::Flags::SkipDots);
|
|
|
|
|
|
|
|
|
|
while (it.has_next()) {
|
|
|
|
|
auto name = it.next_path();
|
|
|
|
|
auto input_path = TRY(FileSystem::real_path(ByteString::formatted("{}/input/{}/{}", path, trail, name)));
|
|
|
|
|
|
|
|
|
|
if (FileSystem::is_directory(input_path)) {
|
2024-11-30 10:08:47 -03:00
|
|
|
TRY(collect_dump_tests(app, tests, path, ByteString::formatted("{}/{}", trail, name), mode));
|
2024-10-10 12:56:27 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-22 19:29:21 -03:00
|
|
|
if (!is_valid_test_name(name))
|
2024-10-10 12:56:27 -03:00
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
auto expectation_path = ByteString::formatted("{}/expected/{}/{}.txt", path, trail, LexicalPath::title(name));
|
2024-11-30 10:08:47 -03:00
|
|
|
auto relative_path = LexicalPath::relative_path(input_path, app.test_root_path).release_value();
|
2026-01-13 13:33:48 -03:00
|
|
|
tests.append({ mode, input_path, move(expectation_path), relative_path, relative_path });
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-30 10:08:47 -03:00
|
|
|
static ErrorOr<void> collect_ref_tests(Application const& app, Vector<Test>& tests, StringView path, StringView trail)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
2024-11-05 09:49:08 -03:00
|
|
|
Core::DirIterator it(ByteString::formatted("{}/input/{}", path, trail), Core::DirIterator::Flags::SkipDots);
|
|
|
|
|
while (it.has_next()) {
|
|
|
|
|
auto name = it.next_path();
|
|
|
|
|
auto input_path = TRY(FileSystem::real_path(ByteString::formatted("{}/input/{}/{}", path, trail, name)));
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2024-11-05 09:49:08 -03:00
|
|
|
if (FileSystem::is_directory(input_path)) {
|
2024-11-30 10:08:47 -03:00
|
|
|
TRY(collect_ref_tests(app, tests, path, ByteString::formatted("{}/{}", trail, name)));
|
2024-11-05 09:49:08 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-27 20:16:18 -03:00
|
|
|
if (!is_valid_test_name(name))
|
|
|
|
|
continue;
|
|
|
|
|
|
2024-11-30 10:08:47 -03:00
|
|
|
auto relative_path = LexicalPath::relative_path(input_path, app.test_root_path).release_value();
|
2026-01-13 13:33:48 -03:00
|
|
|
tests.append({ TestMode::Ref, input_path, {}, relative_path, relative_path });
|
2024-11-05 09:49:08 -03:00
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 06:20:45 -03:00
|
|
|
static StringView screenshot_platform_name()
|
|
|
|
|
{
|
|
|
|
|
#if defined(AK_OS_MACOS)
|
|
|
|
|
return "macos"sv;
|
|
|
|
|
#elif defined(AK_OS_LINUX)
|
|
|
|
|
return "linux"sv;
|
|
|
|
|
#elif defined(AK_OS_WINDOWS)
|
|
|
|
|
return "windows"sv;
|
|
|
|
|
#else
|
|
|
|
|
# error "Unhandled platform for screenshot expectations"
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ByteString screenshot_expectation_path(StringView path, StringView trail, StringView name)
|
|
|
|
|
{
|
|
|
|
|
auto title = LexicalPath::title(name);
|
|
|
|
|
auto platform_expectation_path = ByteString::formatted("{}/expected-{}/{}/{}.png", path, screenshot_platform_name(), trail, title);
|
|
|
|
|
if (FileSystem::exists(platform_expectation_path))
|
|
|
|
|
return platform_expectation_path;
|
|
|
|
|
return ByteString::formatted("{}/expected/{}/{}.png", path, trail, title);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:30:48 -03:00
|
|
|
static void log_active_test_views(StringView reason)
|
|
|
|
|
{
|
|
|
|
|
outln();
|
|
|
|
|
outln("test-web: {}", reason);
|
|
|
|
|
outln("test-web: active views:");
|
|
|
|
|
|
|
|
|
|
auto now = UnixDateTime::now();
|
|
|
|
|
WebView::ViewImplementation::for_each_view([&](WebView::ViewImplementation const& view) {
|
|
|
|
|
pid_t pid = 0;
|
|
|
|
|
if (auto view_index = s_view_index_by_view.get(&view); view_index.has_value() && *view_index < s_view_display_states.size())
|
|
|
|
|
pid = s_view_display_states[*view_index].pid;
|
|
|
|
|
|
|
|
|
|
out(" - View {} (pid {}): ", view.view_id(), pid);
|
|
|
|
|
|
|
|
|
|
auto maybe_index = s_current_test_index_by_view.get(&view);
|
|
|
|
|
if (maybe_index.has_value() && s_run_context) {
|
|
|
|
|
auto const& test = s_run_context->tests[*maybe_index];
|
|
|
|
|
outln("{} (duration: {})", test.relative_path, human_readable_time(now - test.start_time));
|
|
|
|
|
} else {
|
|
|
|
|
outln("{} (no active test)", view.url());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return IterationDecision::Continue;
|
|
|
|
|
});
|
|
|
|
|
outln();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 06:04:43 -03:00
|
|
|
static ErrorOr<void> write_harness_status(StringView reason)
|
|
|
|
|
{
|
|
|
|
|
auto& app = Application::the();
|
|
|
|
|
auto& display = Display::the();
|
|
|
|
|
auto status_path = LexicalPath::join(app.results_directory, "harness-status.txt"sv).string();
|
|
|
|
|
auto status_file = TRY(Core::File::open(status_path, Core::File::OpenMode::Write | Core::File::OpenMode::Truncate));
|
|
|
|
|
|
|
|
|
|
TRY(status_file->write_formatted("reason: {}\n", reason));
|
|
|
|
|
TRY(status_file->write_formatted("completed: {}/{}\n", display.completed_tests, total_tests()));
|
|
|
|
|
TRY(status_file->write_formatted("pass: {}\n", display.pass_count));
|
|
|
|
|
TRY(status_file->write_formatted("fail: {}\n", display.fail_count));
|
|
|
|
|
TRY(status_file->write_formatted("timeout: {}\n", display.timeout_count));
|
|
|
|
|
TRY(status_file->write_formatted("crashed: {}\n", display.crashed_count));
|
|
|
|
|
TRY(status_file->write_formatted("skipped: {}\n", display.skipped_count));
|
|
|
|
|
if (s_run_context)
|
|
|
|
|
TRY(status_file->write_formatted("remaining: {}\n", s_run_context->tests_remaining));
|
|
|
|
|
|
|
|
|
|
TRY(status_file->write_until_depleted("active views:\n"sv.bytes()));
|
|
|
|
|
|
|
|
|
|
auto now = UnixDateTime::now();
|
|
|
|
|
WebView::ViewImplementation::for_each_view([&](WebView::ViewImplementation const& view) {
|
|
|
|
|
pid_t pid = 0;
|
|
|
|
|
if (auto view_index = s_view_index_by_view.get(&view); view_index.has_value() && *view_index < s_view_display_states.size())
|
|
|
|
|
pid = s_view_display_states[*view_index].pid;
|
|
|
|
|
|
|
|
|
|
(void)status_file->write_formatted(" - View {} (pid {}): ", view.view_id(), pid);
|
|
|
|
|
|
|
|
|
|
auto maybe_index = s_current_test_index_by_view.get(&view);
|
|
|
|
|
if (maybe_index.has_value() && s_run_context) {
|
|
|
|
|
auto const& test = s_run_context->tests[*maybe_index];
|
|
|
|
|
(void)status_file->write_formatted("{} (duration: {})\n", test.relative_path, human_readable_time(now - test.start_time));
|
|
|
|
|
} else {
|
|
|
|
|
(void)status_file->write_formatted("{} (no active test)\n", view.url());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return IterationDecision::Continue;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void try_write_harness_status(StringView reason)
|
|
|
|
|
{
|
|
|
|
|
if (auto result = write_harness_status(reason); result.is_error())
|
|
|
|
|
warnln("Failed to write test-web harness status: {}", result.error());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:16 -03:00
|
|
|
static ErrorOr<void> collect_screenshot_tests(Application const& app, Vector<Test>& tests, StringView path, StringView trail)
|
|
|
|
|
{
|
|
|
|
|
Core::DirIterator it(ByteString::formatted("{}/input/{}", path, trail), Core::DirIterator::Flags::SkipDots);
|
|
|
|
|
while (it.has_next()) {
|
|
|
|
|
auto name = it.next_path();
|
|
|
|
|
auto input_path = TRY(FileSystem::real_path(ByteString::formatted("{}/input/{}/{}", path, trail, name)));
|
|
|
|
|
|
|
|
|
|
if (FileSystem::is_directory(input_path)) {
|
|
|
|
|
TRY(collect_screenshot_tests(app, tests, path, ByteString::formatted("{}/{}", trail, name)));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!is_valid_test_name(name))
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-05-29 06:20:45 -03:00
|
|
|
auto expectation_path = screenshot_expectation_path(path, trail, name);
|
2026-02-24 04:17:16 -03:00
|
|
|
auto relative_path = LexicalPath::relative_path(input_path, app.test_root_path).release_value();
|
|
|
|
|
tests.append({ TestMode::Screenshot, input_path, move(expectation_path), relative_path, relative_path });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-10 01:11:26 -03:00
|
|
|
static ErrorOr<void> collect_crash_tests(Application const& app, Vector<Test>& tests, StringView path, StringView trail)
|
2024-11-22 19:29:21 -03:00
|
|
|
{
|
|
|
|
|
Core::DirIterator it(ByteString::formatted("{}/{}", path, trail), Core::DirIterator::Flags::SkipDots);
|
|
|
|
|
while (it.has_next()) {
|
|
|
|
|
auto name = it.next_path();
|
|
|
|
|
auto input_path = TRY(FileSystem::real_path(ByteString::formatted("{}/{}/{}", path, trail, name)));
|
|
|
|
|
|
|
|
|
|
if (FileSystem::is_directory(input_path)) {
|
2025-02-10 01:11:26 -03:00
|
|
|
TRY(collect_crash_tests(app, tests, path, ByteString::formatted("{}/{}", trail, name)));
|
2024-11-22 19:29:21 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!is_valid_test_name(name))
|
|
|
|
|
continue;
|
|
|
|
|
|
2025-02-10 01:11:26 -03:00
|
|
|
auto relative_path = LexicalPath::relative_path(input_path, app.test_root_path).release_value();
|
2026-01-13 13:33:48 -03:00
|
|
|
tests.append({ TestMode::Crash, input_path, {}, relative_path, relative_path });
|
2024-11-22 19:29:21 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 09:23:59 -03:00
|
|
|
static String generate_wait_for_test_string(StringView wait_class, StringView on_finish_script = ""sv)
|
2025-09-10 10:33:13 -03:00
|
|
|
{
|
|
|
|
|
return MUST(String::formatted(R"(
|
|
|
|
|
function hasTestWaitClass() {{
|
|
|
|
|
return document.documentElement.classList.contains('{}');
|
|
|
|
|
}}
|
|
|
|
|
|
2026-04-29 09:23:59 -03:00
|
|
|
function afterFontsAndPaint(callback) {{
|
|
|
|
|
document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(callback)));
|
|
|
|
|
}}
|
|
|
|
|
|
2025-09-10 10:33:13 -03:00
|
|
|
if (!hasTestWaitClass()) {{
|
2026-04-29 09:23:59 -03:00
|
|
|
afterFontsAndPaint(() => internals.signalTestIsDone("PASS"));
|
2025-09-10 10:33:13 -03:00
|
|
|
}} else {{
|
|
|
|
|
const observer = new MutationObserver(() => {{
|
|
|
|
|
if (!hasTestWaitClass()) {{
|
2026-03-30 12:58:32 -03:00
|
|
|
observer.disconnect();
|
2025-09-10 10:33:13 -03:00
|
|
|
internals.signalTestIsDone("PASS");
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
observer.observe(document.documentElement, {{
|
|
|
|
|
attributes: true,
|
|
|
|
|
attributeFilter: ['class'],
|
|
|
|
|
}});
|
2026-04-29 09:23:59 -03:00
|
|
|
{}
|
2025-09-10 10:33:13 -03:00
|
|
|
}}
|
|
|
|
|
)"sv,
|
2026-04-29 09:23:59 -03:00
|
|
|
wait_class, on_finish_script));
|
2025-09-10 10:33:13 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 07:59:54 -03:00
|
|
|
static auto wait_for_test_completion = generate_wait_for_test_string("test-wait"sv);
|
2026-04-29 09:23:59 -03:00
|
|
|
|
|
|
|
|
// https://web-platform-tests.org/writing-tests/reftests.html#waiting-for-content-to-render
|
|
|
|
|
static auto wait_for_reftest_completion = generate_wait_for_test_string("reftest-wait"sv, R"(
|
|
|
|
|
afterFontsAndPaint(() => document.documentElement.dispatchEvent(new Event("TestRendered", { bubbles: true })));
|
|
|
|
|
)"sv);
|
2025-09-10 10:33:13 -03:00
|
|
|
|
2026-04-29 17:31:28 -03:00
|
|
|
// For PDF tests: by the time this runs (after on_load_finish), ladybirdpdf has already fired
|
|
|
|
|
// and openPdf() is in progress; initializedPromise lets us hook in at the right moment.
|
2026-05-06 12:17:18 -03:00
|
|
|
// This script ensures we wait until the text and annotation layers are rendered for the first page.
|
|
|
|
|
// pdf.js renders pages lazily, so only those pages visible in the viewport are rendered. We currently fail for PDFs
|
|
|
|
|
// with more than one page, as we don't currently guarantee rendering of all pages.
|
2026-04-29 17:31:28 -03:00
|
|
|
static auto const wait_for_pdf_done = R"(
|
|
|
|
|
window.PDFViewerApplication.initializedPromise.then(() => {
|
2026-05-06 12:17:18 -03:00
|
|
|
const eventBus = window.PDFViewerApplication.eventBus;
|
|
|
|
|
let pagesCount = null;
|
|
|
|
|
let textRendered = false;
|
|
|
|
|
let annotationRendered = false;
|
|
|
|
|
const checkDone = () => {
|
|
|
|
|
if (pagesCount !== null && textRendered && annotationRendered) {
|
|
|
|
|
internals.signalTestIsDone(`Pages: ${pagesCount}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
eventBus.on("pagesloaded", evt => {
|
|
|
|
|
pagesCount = evt.pagesCount;
|
|
|
|
|
if (pagesCount !== 1) {
|
|
|
|
|
const message = `PDF tests must use a single-page PDF (got ${pagesCount} pages).`;
|
|
|
|
|
document.body.replaceChildren(document.createTextNode(message));
|
|
|
|
|
internals.signalTestIsDone(`Pages: ${pagesCount}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
checkDone();
|
2026-04-29 17:31:28 -03:00
|
|
|
});
|
2026-05-06 12:17:18 -03:00
|
|
|
eventBus.on("textlayerrendered", evt => { if (evt.pageNumber === 1) { textRendered = true; checkDone(); } });
|
|
|
|
|
eventBus.on("annotationlayerrendered", evt => { if (evt.pageNumber === 1) { annotationRendered = true; checkDone(); } });
|
2026-04-29 17:31:28 -03:00
|
|
|
});
|
|
|
|
|
)"_string;
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
static ErrorOr<void> generate_result_files(ReadonlySpan<Test> tests, ReadonlySpan<TestCompletion> non_passing_tests)
|
2026-01-13 07:23:09 -03:00
|
|
|
{
|
|
|
|
|
auto& app = Application::the();
|
2026-04-16 15:01:55 -03:00
|
|
|
auto& display = Display::the();
|
|
|
|
|
|
2026-04-11 17:25:53 -03:00
|
|
|
bool const has_helper_logs = FileSystem::exists(LexicalPath::join(app.results_directory, "helper-process-logs.html"sv).string());
|
2026-04-11 18:09:25 -03:00
|
|
|
auto const generated_at = UnixDateTime::now();
|
2026-01-13 07:23:09 -03:00
|
|
|
|
|
|
|
|
// Write results.js (as JS to avoid fetch CORS issues with file://)
|
|
|
|
|
StringBuilder js;
|
|
|
|
|
js.append("const RESULTS_DATA = {\n"sv);
|
2026-01-12 17:20:21 -03:00
|
|
|
js.appendff(" \"summary\": {{ \"total\": {}, \"fail\": {}, \"timeout\": {}, \"crashed\": {}, \"skipped\": {} }},\n",
|
2026-04-16 15:01:55 -03:00
|
|
|
total_tests(),
|
|
|
|
|
display.fail_count,
|
|
|
|
|
display.timeout_count,
|
|
|
|
|
display.crashed_count,
|
|
|
|
|
display.skipped_count);
|
2026-04-11 18:09:25 -03:00
|
|
|
js.appendff(" \"generatedAt\": {},\n", generated_at.seconds_since_epoch());
|
|
|
|
|
js.appendff(" \"invocationCommandLine\": {},\n", JsonValue(app.invocation_command_line).serialized());
|
2026-04-11 17:25:53 -03:00
|
|
|
js.appendff(" \"hasLogs\": {},\n", has_helper_logs ? "true" : "false");
|
2026-01-13 07:23:09 -03:00
|
|
|
js.append(" \"tests\": [\n"sv);
|
|
|
|
|
|
|
|
|
|
bool first = true;
|
|
|
|
|
for (auto const& result : non_passing_tests) {
|
|
|
|
|
if (result.result == TestResult::Skipped && app.verbosity < Application::VERBOSITY_LEVEL_LOG_SKIPPED_TESTS)
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
if (!first)
|
|
|
|
|
js.append(",\n"sv);
|
|
|
|
|
first = false;
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto const& test = tests[result.test_index];
|
2026-02-26 21:38:34 -03:00
|
|
|
auto base_path = TRY(prepare_output_path(test));
|
2026-04-11 16:12:17 -03:00
|
|
|
bool has_std_logs = FileSystem::exists(ByteString::formatted("{}.logs.html", base_path));
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
js.appendff(" {{ \"name\": \"{}\", \"result\": \"{}\", \"mode\": \"{}\", \"hasLogs\": {}",
|
2026-01-13 13:33:48 -03:00
|
|
|
test.safe_relative_path,
|
2026-01-13 07:23:09 -03:00
|
|
|
test_result_to_string(result.result),
|
2026-01-13 13:33:48 -03:00
|
|
|
test_mode_to_string(test.mode),
|
2026-04-11 16:12:17 -03:00
|
|
|
has_std_logs ? "true" : "false");
|
2026-02-24 04:17:16 -03:00
|
|
|
if ((test.mode == TestMode::Ref || test.mode == TestMode::Screenshot) && test.diff_pixel_error_count > 0)
|
2026-02-17 10:32:23 -03:00
|
|
|
js.appendff(", \"pixelErrors\": {}, \"maxChannelDiff\": {}", test.diff_pixel_error_count, test.diff_maximum_error);
|
|
|
|
|
js.append(" }"sv);
|
2026-01-13 07:23:09 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
js.append("\n ]\n};\n"sv);
|
|
|
|
|
|
|
|
|
|
auto js_path = LexicalPath::join(app.results_directory, "results.js"sv).string();
|
2026-01-13 14:57:16 -03:00
|
|
|
auto js_file = TRY(Core::File::open(js_path, Core::File::OpenMode::Write | Core::File::OpenMode::Truncate));
|
2026-01-13 07:23:09 -03:00
|
|
|
TRY(js_file->write_until_depleted(js.string_view().bytes()));
|
|
|
|
|
|
|
|
|
|
// Copy index.html from source tree
|
|
|
|
|
auto source_html_path = LexicalPath::join(app.test_root_path, "test-web/results-index.html"sv).string();
|
|
|
|
|
auto dest_html_path = LexicalPath::join(app.results_directory, "index.html"sv).string();
|
2026-01-13 14:57:16 -03:00
|
|
|
auto source_html = TRY(Core::File::open(source_html_path, Core::File::OpenMode::Read));
|
|
|
|
|
auto html_contents = TRY(source_html->read_until_eof());
|
|
|
|
|
auto dest_html = TRY(Core::File::open(dest_html_path, Core::File::OpenMode::Write | Core::File::OpenMode::Truncate));
|
|
|
|
|
TRY(dest_html->write_until_depleted(html_contents));
|
2026-01-13 07:23:09 -03:00
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ErrorOr<void> write_test_diff_to_results(Test const& test, ByteBuffer const& expectation)
|
|
|
|
|
{
|
2026-02-26 21:38:34 -03:00
|
|
|
auto base_path = TRY(prepare_output_path(test));
|
2026-01-13 07:23:09 -03:00
|
|
|
|
|
|
|
|
// Write expected output
|
|
|
|
|
auto expected_path = ByteString::formatted("{}.expected.txt", base_path);
|
|
|
|
|
auto expected_file = TRY(Core::File::open(expected_path, Core::File::OpenMode::Write));
|
|
|
|
|
TRY(expected_file->write_until_depleted(expectation));
|
|
|
|
|
|
|
|
|
|
// Write actual output
|
|
|
|
|
auto actual_path = ByteString::formatted("{}.actual.txt", base_path);
|
|
|
|
|
auto actual_file = TRY(Core::File::open(actual_path, Core::File::OpenMode::Write));
|
|
|
|
|
TRY(actual_file->write_until_depleted(test.text.bytes()));
|
|
|
|
|
|
2026-01-12 17:20:21 -03:00
|
|
|
// Write diff (plain text for tools)
|
2026-01-13 07:23:09 -03:00
|
|
|
auto diff_path = ByteString::formatted("{}.diff.txt", base_path);
|
|
|
|
|
auto diff_file = TRY(Core::File::open(diff_path, Core::File::OpenMode::Write));
|
|
|
|
|
|
|
|
|
|
auto hunks = TRY(Diff::from_text(expectation, test.text, 3));
|
|
|
|
|
TRY(Diff::write_unified_header(test.expectation_path, test.expectation_path, *diff_file));
|
|
|
|
|
for (auto const& hunk : hunks)
|
|
|
|
|
TRY(Diff::write_unified(hunk, *diff_file, Diff::ColorOutput::No));
|
|
|
|
|
|
2026-01-12 17:20:21 -03:00
|
|
|
// Write diff (colorized HTML for viewer)
|
|
|
|
|
auto html_path = ByteString::formatted("{}.diff.html", base_path);
|
|
|
|
|
auto html_file = TRY(Core::File::open(html_path, Core::File::OpenMode::Write));
|
|
|
|
|
|
|
|
|
|
TRY(html_file->write_until_depleted(R"html(<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<style>
|
|
|
|
|
body { margin: 0; background: #0d1117; }
|
|
|
|
|
pre { margin: 0; padding: 16px; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.5; }
|
|
|
|
|
.add { background: #12261e; color: #3fb950; border-left: 3px solid #238636; padding-left: 8px; margin-left: -11px; }
|
|
|
|
|
.del { background: #2d1619; color: #f85149; border-left: 3px solid #f85149; padding-left: 8px; margin-left: -11px; }
|
|
|
|
|
.hunk { color: #58a6ff; font-weight: 500; }
|
|
|
|
|
.ctx { color: #8b949e; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body><pre>)html"sv));
|
|
|
|
|
|
|
|
|
|
// Write header
|
|
|
|
|
TRY(html_file->write_until_depleted("<span class=\"ctx\">"sv));
|
|
|
|
|
TRY(html_file->write_formatted("--- {}\n", test.expectation_path));
|
|
|
|
|
TRY(html_file->write_formatted("+++ {}\n", test.expectation_path));
|
|
|
|
|
TRY(html_file->write_until_depleted("</span>"sv));
|
|
|
|
|
|
|
|
|
|
// Write hunks with colorization
|
|
|
|
|
for (auto const& hunk : hunks) {
|
|
|
|
|
TRY(html_file->write_formatted("<span class=\"hunk\">{}</span>\n", hunk.location));
|
|
|
|
|
|
|
|
|
|
for (auto const& line : hunk.lines) {
|
|
|
|
|
auto escaped = escape_html_entities(line.content);
|
|
|
|
|
switch (line.operation) {
|
|
|
|
|
case Diff::Line::Operation::Addition:
|
|
|
|
|
TRY(html_file->write_formatted("<span class=\"add\">+{}</span>\n", escaped));
|
|
|
|
|
break;
|
|
|
|
|
case Diff::Line::Operation::Removal:
|
|
|
|
|
TRY(html_file->write_formatted("<span class=\"del\">-{}</span>\n", escaped));
|
|
|
|
|
break;
|
|
|
|
|
case Diff::Line::Operation::Context:
|
|
|
|
|
TRY(html_file->write_formatted("<span class=\"ctx\"> {}</span>\n", escaped));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TRY(html_file->write_until_depleted("</pre></body></html>"sv));
|
|
|
|
|
|
2026-01-13 07:23:09 -03:00
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
static void expand_test_with_variants(TestRunContext& context, size_t base_test_index, ReadonlySpan<String> variants)
|
|
|
|
|
{
|
|
|
|
|
VERIFY(!variants.is_empty());
|
|
|
|
|
|
|
|
|
|
context.tests.ensure_capacity(context.tests.size() + variants.size());
|
|
|
|
|
auto const& base_test = context.tests[base_test_index];
|
|
|
|
|
|
|
|
|
|
for (auto const& variant : variants) {
|
|
|
|
|
Test variant_test;
|
|
|
|
|
variant_test.mode = base_test.mode;
|
2026-02-13 04:55:19 -03:00
|
|
|
variant_test.run_index = base_test.run_index;
|
|
|
|
|
variant_test.total_runs = base_test.total_runs;
|
2026-01-13 13:33:48 -03:00
|
|
|
variant_test.input_path = base_test.input_path;
|
|
|
|
|
variant_test.variant = variant;
|
|
|
|
|
|
|
|
|
|
// relative_path uses '?' for display, safe_relative_path uses '@' for filesystem
|
|
|
|
|
auto variant_suffix = StringView { variant }.substring_view(1);
|
|
|
|
|
variant_test.relative_path = ByteString::formatted("{}?{}", base_test.relative_path, variant_suffix);
|
|
|
|
|
variant_test.safe_relative_path = ByteString::formatted("{}@{}", base_test.safe_relative_path, variant_suffix);
|
|
|
|
|
|
|
|
|
|
// Expected file: test@variant_suffix.txt
|
|
|
|
|
auto dir = LexicalPath::dirname(base_test.expectation_path);
|
|
|
|
|
auto title = LexicalPath::title(LexicalPath::basename(base_test.input_path));
|
|
|
|
|
if (dir.is_empty())
|
|
|
|
|
variant_test.expectation_path = ByteString::formatted("{}@{}.txt", title, variant_suffix);
|
|
|
|
|
else
|
|
|
|
|
variant_test.expectation_path = ByteString::formatted("{}/{}@{}.txt", dir, title, variant_suffix);
|
|
|
|
|
|
|
|
|
|
// Set the index before appending so it matches the position in the vector
|
|
|
|
|
variant_test.index = context.tests.size();
|
|
|
|
|
context.tests.unchecked_append(move(variant_test));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add variants.size() because the original test will decrement tests_remaining when
|
|
|
|
|
// it completes as Expanded, and each variant will also decrement when it completes.
|
|
|
|
|
context.tests_remaining += variants.size();
|
|
|
|
|
|
|
|
|
|
// For display, add (variants.size() - 1) since Expanded tests don't count in s_completed_tests
|
|
|
|
|
context.total_tests += variants.size() - 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 19:22:57 -03:00
|
|
|
static void run_dump_test(TestWebView& view, TestRunContext& context, Test& test, URL::URL const& url)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
2026-01-13 13:33:48 -03:00
|
|
|
auto test_index = test.index;
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto handle_completed_test = [&context, test_index, url]() -> ErrorOr<TestResult> {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2025-03-02 13:11:32 -03:00
|
|
|
if (test.expectation_path.is_empty()) {
|
2025-03-02 13:14:59 -03:00
|
|
|
if (test.mode != TestMode::Crash)
|
|
|
|
|
outln("{}", test.text);
|
2024-10-10 12:56:27 -03:00
|
|
|
return TestResult::Pass;
|
2025-03-02 13:11:32 -03:00
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
|
|
|
|
|
auto open_expectation_file = [&](auto mode) {
|
|
|
|
|
auto expectation_file_or_error = Core::File::open(test.expectation_path, mode);
|
|
|
|
|
if (expectation_file_or_error.is_error())
|
2026-04-16 18:29:12 -03:00
|
|
|
warnln(ByteString::formatted("Failed opening '{}': {}", test.expectation_path, expectation_file_or_error.error()));
|
2024-10-10 12:56:27 -03:00
|
|
|
|
|
|
|
|
return expectation_file_or_error;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ByteBuffer expectation;
|
|
|
|
|
|
|
|
|
|
if (auto expectation_file = open_expectation_file(Core::File::OpenMode::Read); !expectation_file.is_error()) {
|
|
|
|
|
expectation = TRY(expectation_file.value()->read_until_eof());
|
|
|
|
|
|
|
|
|
|
auto result_trimmed = StringView { test.text }.trim("\n"sv, TrimMode::Right);
|
|
|
|
|
auto expectation_trimmed = StringView { expectation }.trim("\n"sv, TrimMode::Right);
|
|
|
|
|
|
|
|
|
|
if (result_trimmed == expectation_trimmed)
|
|
|
|
|
return TestResult::Pass;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Application::the().rebaseline) {
|
2024-11-15 13:08:09 -03:00
|
|
|
TRY(Core::Directory::create(LexicalPath { test.expectation_path }.parent().string(), Core::Directory::CreateDirectories::Yes));
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
auto expectation_file = TRY(open_expectation_file(Core::File::OpenMode::Write));
|
|
|
|
|
TRY(expectation_file->write_until_depleted(test.text));
|
|
|
|
|
|
|
|
|
|
return TestResult::Pass;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 07:23:09 -03:00
|
|
|
TRY(write_test_diff_to_results(test, expectation));
|
2026-04-16 15:01:55 -03:00
|
|
|
Display::the().print_failure_diff(url, test, expectation);
|
2024-10-10 12:56:27 -03:00
|
|
|
return TestResult::Fail;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto on_test_complete = [&view, test_index, handle_completed_test]() {
|
2024-10-10 12:56:27 -03:00
|
|
|
if (auto result = handle_completed_test(); result.is_error())
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_complete({ test_index, TestResult::Fail });
|
2024-10-10 12:56:27 -03:00
|
|
|
else
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_complete({ test_index, result.value() });
|
2024-10-10 12:56:27 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (test.mode == TestMode::Layout) {
|
2026-04-29 17:31:28 -03:00
|
|
|
view.on_load_finish = [&view, &context, test_index, url](auto const& loaded_url) {
|
2024-10-10 12:56:27 -03:00
|
|
|
// We don't want subframe loads to trigger the test finish.
|
|
|
|
|
if (!url.equals(loaded_url, URL::ExcludeFragment::Yes))
|
|
|
|
|
return;
|
|
|
|
|
|
2026-04-29 17:31:28 -03:00
|
|
|
auto const& test = context.tests[test_index];
|
|
|
|
|
auto const& script = LexicalPath(test.input_path).extension() == "pdf"sv
|
|
|
|
|
? wait_for_pdf_done
|
|
|
|
|
: wait_for_test_completion;
|
|
|
|
|
view.run_javascript(script);
|
2026-04-06 07:59:54 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
view.on_test_finish = [&view, &context, test_index, on_test_complete = move(on_test_complete)](auto const&) {
|
2024-10-10 12:56:27 -03:00
|
|
|
// NOTE: We take a screenshot here to force the lazy layout of SVG-as-image documents to happen.
|
|
|
|
|
// It also causes a lot more code to run, which is good for finding bugs. :^)
|
2026-02-13 04:55:19 -03:00
|
|
|
view.take_screenshot()->when_resolved([&view, &context, test_index, on_test_complete = move(on_test_complete)](auto const&) {
|
2025-07-06 09:45:04 -03:00
|
|
|
auto promise = view.request_internal_page_info(WebView::PageInfoType::LayoutTree | WebView::PageInfoType::PaintTree | WebView::PageInfoType::StackingContextTree);
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
promise->when_resolved([&context, test_index, on_test_complete = move(on_test_complete)](auto const& text) {
|
|
|
|
|
context.tests[test_index].text = text;
|
2024-10-10 12:56:27 -03:00
|
|
|
on_test_complete();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-03-02 13:14:59 -03:00
|
|
|
} else if (test.mode == TestMode::Text) {
|
2026-01-13 13:33:48 -03:00
|
|
|
// Set up variant detection callback.
|
2026-01-19 01:39:20 -03:00
|
|
|
view.on_test_variant_metadata = [&view, &context, test_index, on_test_complete](JsonValue metadata) {
|
2026-01-13 13:33:48 -03:00
|
|
|
// Verify this IPC response is for the current test on this view (use index to avoid dangling pointer issues)
|
|
|
|
|
auto current_index = s_current_test_index_by_view.get(&view);
|
|
|
|
|
if (!current_index.has_value() || *current_index != test_index)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
if (test.variant.has_value())
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
auto const& variants_array = metadata.as_array();
|
|
|
|
|
|
|
|
|
|
if (!variants_array.is_empty()) {
|
|
|
|
|
Vector<String> variants;
|
|
|
|
|
variants.ensure_capacity(variants_array.size());
|
|
|
|
|
for (auto const& variant : variants_array.values())
|
|
|
|
|
variants.unchecked_append(variant.as_string());
|
|
|
|
|
|
|
|
|
|
expand_test_with_variants(context, test_index, variants);
|
|
|
|
|
view.on_test_complete({ test_index, TestResult::Expanded });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto& test_after_check = context.tests[test_index];
|
|
|
|
|
test_after_check.did_check_variants = true;
|
|
|
|
|
if (test_after_check.did_finish_test)
|
2026-01-19 01:39:20 -03:00
|
|
|
on_test_complete();
|
2026-01-13 13:33:48 -03:00
|
|
|
};
|
|
|
|
|
|
2026-05-26 22:52:40 -03:00
|
|
|
view.on_load_finish = [&view, &context, test_index, on_test_complete](auto const& loaded_url) {
|
|
|
|
|
// page_did_finish_loading is already top-level-only (Document.cpp gates it on navigable->is_traversable()).
|
|
|
|
|
// We accept *any* top-level URL here — not just the test's original URL; otherwise a test that navigates
|
|
|
|
|
// away will hang forever — because the original page's load IPC may arrive after the navigation target's
|
|
|
|
|
// signal, and the URL filter will then mask the load event for the new page, too.
|
|
|
|
|
(void)loaded_url;
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto& test = context.tests[test_index];
|
2024-10-10 12:56:27 -03:00
|
|
|
test.did_finish_loading = true;
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
if (!test.variant.has_value())
|
|
|
|
|
view.run_javascript("internals.loadTestVariants();"_string);
|
|
|
|
|
else
|
|
|
|
|
test.did_check_variants = true;
|
|
|
|
|
|
2025-03-02 13:11:32 -03:00
|
|
|
if (test.expectation_path.is_empty()) {
|
|
|
|
|
auto promise = view.request_internal_page_info(WebView::PageInfoType::Text);
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
promise->when_resolved([&context, test_index, on_test_complete = move(on_test_complete)](auto const& text) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2025-03-02 13:11:32 -03:00
|
|
|
test.text = text;
|
|
|
|
|
on_test_complete();
|
|
|
|
|
});
|
2026-01-13 13:33:48 -03:00
|
|
|
} else if (test.did_finish_test && test.did_check_variants) {
|
2024-10-10 12:56:27 -03:00
|
|
|
on_test_complete();
|
2025-03-02 13:11:32 -03:00
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_finish = [&context, test_index, on_test_complete](auto const& text) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2024-10-10 12:56:27 -03:00
|
|
|
test.text = text;
|
|
|
|
|
test.did_finish_test = true;
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
if (test.did_finish_loading && test.did_check_variants)
|
2024-10-10 12:56:27 -03:00
|
|
|
on_test_complete();
|
|
|
|
|
};
|
2025-03-02 13:14:59 -03:00
|
|
|
} else if (test.mode == TestMode::Crash) {
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_load_finish = [on_test_complete, url, &view, &context, test_index](auto const& loaded_url) {
|
2025-03-02 13:14:59 -03:00
|
|
|
// We don't want subframe loads to trigger the test finish.
|
|
|
|
|
if (!url.equals(loaded_url, URL::ExcludeFragment::Yes))
|
|
|
|
|
return;
|
2025-09-10 10:33:13 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto& test = context.tests[test_index];
|
2025-07-14 08:35:11 -03:00
|
|
|
test.did_finish_loading = true;
|
2026-04-06 07:59:54 -03:00
|
|
|
view.run_javascript(wait_for_test_completion);
|
2025-09-10 10:33:13 -03:00
|
|
|
|
2025-07-14 08:35:11 -03:00
|
|
|
if (test.did_finish_test)
|
|
|
|
|
on_test_complete();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_finish = [&context, test_index, on_test_complete](auto const&) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2025-07-14 08:35:11 -03:00
|
|
|
test.did_finish_test = true;
|
|
|
|
|
|
|
|
|
|
if (test.did_finish_loading)
|
|
|
|
|
on_test_complete();
|
2025-03-02 13:14:59 -03:00
|
|
|
};
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
view.load(url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:16 -03:00
|
|
|
static ErrorOr<void> dump_screenshot_to_file(Gfx::Bitmap const& bitmap, StringView path)
|
|
|
|
|
{
|
|
|
|
|
auto screenshot_file = TRY(Core::File::open(path, Core::File::OpenMode::Write));
|
|
|
|
|
auto encoded_data = TRY(Gfx::PNGWriter::encode(bitmap));
|
|
|
|
|
TRY(screenshot_file->write_until_depleted(encoded_data));
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ErrorOr<void> write_screenshot_failure_results(Test& test, Gfx::Bitmap const& actual, Gfx::Bitmap const& expected)
|
|
|
|
|
{
|
2026-02-26 21:38:34 -03:00
|
|
|
auto base_path = TRY(prepare_output_path(test));
|
2026-02-24 04:17:16 -03:00
|
|
|
TRY(dump_screenshot_to_file(actual, ByteString::formatted("{}.actual.png", base_path)));
|
|
|
|
|
TRY(dump_screenshot_to_file(expected, ByteString::formatted("{}.expected.png", base_path)));
|
|
|
|
|
|
|
|
|
|
// Generate a diff image and compute stats.
|
|
|
|
|
if (actual.width() == expected.width() && actual.height() == expected.height()) {
|
|
|
|
|
auto diff = actual.diff(expected);
|
|
|
|
|
test.diff_pixel_error_count = diff.pixel_error_count;
|
|
|
|
|
test.diff_maximum_error = diff.maximum_error;
|
|
|
|
|
|
|
|
|
|
auto diff_bitmap = TRY(Gfx::Bitmap::create(Gfx::BitmapFormat::BGRA8888, { actual.width(), actual.height() }));
|
|
|
|
|
for (int y = 0; y < actual.height(); ++y) {
|
|
|
|
|
for (int x = 0; x < actual.width(); ++x) {
|
|
|
|
|
auto pixel = actual.get_pixel(x, y);
|
|
|
|
|
if (pixel != expected.get_pixel(x, y))
|
|
|
|
|
diff_bitmap->set_pixel(x, y, Gfx::Color(255, 0, 0));
|
|
|
|
|
else
|
|
|
|
|
diff_bitmap->set_pixel(x, y, pixel.mixed_with(expected.get_pixel(x, y), 0.5f).mixed_with(Gfx::Color::White, 0.8f));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TRY(dump_screenshot_to_file(*diff_bitmap, ByteString::formatted("{}.diff.png", base_path)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 19:22:57 -03:00
|
|
|
static void run_ref_test(TestWebView& view, TestRunContext& context, Test& test, URL::URL const& url)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
2026-01-13 13:33:48 -03:00
|
|
|
auto test_index = test.index;
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto handle_completed_test = [&view, &context, test_index, url]() -> ErrorOr<TestResult> {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2026-06-02 07:31:14 -03:00
|
|
|
switch (test.ref_test_expectation_type) {
|
|
|
|
|
case RefTestExpectationType::None:
|
|
|
|
|
return TestResult::Fail;
|
|
|
|
|
case RefTestExpectationType::Match:
|
|
|
|
|
case RefTestExpectationType::Mismatch: {
|
|
|
|
|
auto should_match = test.ref_test_expectation_type == RefTestExpectationType::Match;
|
|
|
|
|
auto screenshot_matches = fuzzy_screenshot_match(url, view.url(), *test.actual_screenshot,
|
|
|
|
|
*test.expectation_screenshot, test.fuzzy_matches, should_match);
|
|
|
|
|
if (should_match == screenshot_matches)
|
|
|
|
|
return TestResult::Pass;
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-06-02 07:31:14 -03:00
|
|
|
TRY(write_screenshot_failure_results(test, *test.actual_screenshot, *test.expectation_screenshot));
|
|
|
|
|
return TestResult::Fail;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VERIFY_NOT_REACHED();
|
2024-10-10 12:56:27 -03:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto on_test_complete = [&view, test_index, handle_completed_test]() {
|
2024-10-10 12:56:27 -03:00
|
|
|
if (auto result = handle_completed_test(); result.is_error())
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_complete({ test_index, TestResult::Fail });
|
2024-10-10 12:56:27 -03:00
|
|
|
else
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_complete({ test_index, result.value() });
|
2024-10-10 12:56:27 -03:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 08:45:12 -03:00
|
|
|
view.on_load_finish = [&view, &context, test_index, url](auto const& loaded_url) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
|
|
|
|
|
// We don't want subframe loads to trigger this.
|
|
|
|
|
if (test.ref_test_expectation_url.has_value()) {
|
|
|
|
|
// Match against the expectation URL.
|
|
|
|
|
if (!test.ref_test_expectation_url->equals(loaded_url, URL::ExcludeFragment::Yes))
|
|
|
|
|
return;
|
|
|
|
|
} else {
|
|
|
|
|
// Match against the test URL.
|
|
|
|
|
if (!url.equals(loaded_url, URL::ExcludeFragment::Yes))
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!test.did_inject_js) {
|
|
|
|
|
test.did_inject_js = true;
|
|
|
|
|
view.run_javascript(wait_for_reftest_completion);
|
|
|
|
|
}
|
2025-03-18 13:40:05 -03:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
view.on_test_finish = [&view, &context, test_index, on_test_complete = move(on_test_complete)](auto const&) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2024-10-10 12:56:27 -03:00
|
|
|
if (test.actual_screenshot) {
|
2025-07-16 05:24:15 -03:00
|
|
|
// The reference has finished loading; take another screenshot and move on to handling the result.
|
2026-01-13 13:33:48 -03:00
|
|
|
view.take_screenshot()->when_resolved([&view, &context, test_index, on_test_complete = move(on_test_complete)](RefPtr<Gfx::Bitmap const> screenshot) {
|
|
|
|
|
context.tests[test_index].expectation_screenshot = move(screenshot);
|
2024-12-23 18:15:06 -03:00
|
|
|
view.reset_zoom();
|
2024-10-10 12:56:27 -03:00
|
|
|
on_test_complete();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2025-07-16 05:24:15 -03:00
|
|
|
// When the test initially finishes, we take a screenshot and request the reference test metadata.
|
2026-01-13 13:33:48 -03:00
|
|
|
view.take_screenshot()->when_resolved([&view, &context, test_index](RefPtr<Gfx::Bitmap const> screenshot) {
|
|
|
|
|
context.tests[test_index].actual_screenshot = move(screenshot);
|
2024-12-23 18:15:06 -03:00
|
|
|
view.reset_zoom();
|
2025-07-16 05:24:15 -03:00
|
|
|
view.run_javascript("internals.loadReferenceTestMetadata();"_string);
|
2024-10-10 12:56:27 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 07:31:14 -03:00
|
|
|
view.on_reference_test_metadata = [&view, &context, test_index, on_test_complete = move(on_test_complete)](JsonValue const& metadata) {
|
2026-01-13 13:33:48 -03:00
|
|
|
auto& test = context.tests[test_index];
|
2025-07-16 05:24:15 -03:00
|
|
|
auto metadata_object = metadata.as_object();
|
|
|
|
|
|
|
|
|
|
auto match_references = metadata_object.get_array("match_references"sv);
|
|
|
|
|
auto mismatch_references = metadata_object.get_array("mismatch_references"sv);
|
|
|
|
|
|
2025-07-16 06:39:55 -03:00
|
|
|
// Read fuzzy configurations.
|
|
|
|
|
test.fuzzy_matches.clear_with_capacity();
|
|
|
|
|
auto fuzzy_values = metadata_object.get_array("fuzzy"sv);
|
|
|
|
|
for (size_t i = 0; i < fuzzy_values->size(); ++i) {
|
|
|
|
|
auto fuzzy_configuration = fuzzy_values->at(i).as_object();
|
|
|
|
|
|
|
|
|
|
Optional<URL::URL> reference_url;
|
|
|
|
|
auto reference = fuzzy_configuration.get_string("reference"sv);
|
|
|
|
|
if (reference.has_value())
|
|
|
|
|
reference_url = URL::Parser::basic_parse(reference.release_value());
|
|
|
|
|
|
|
|
|
|
auto content = fuzzy_configuration.get_string("content"sv).release_value();
|
|
|
|
|
auto fuzzy_match_or_error = parse_fuzzy_match(reference_url, content);
|
|
|
|
|
if (fuzzy_match_or_error.is_error()) {
|
2025-08-06 07:51:36 -03:00
|
|
|
warnln("Failed to parse fuzzy configuration '{}' (reference: {}): {}", content, reference_url, fuzzy_match_or_error.error());
|
2025-07-16 06:39:55 -03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.fuzzy_matches.append(fuzzy_match_or_error.release_value());
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-16 05:24:15 -03:00
|
|
|
// Read (mis)match reference tests to load.
|
|
|
|
|
// FIXME: Currently we only support single match or mismatch reference.
|
|
|
|
|
String reference_to_load;
|
|
|
|
|
if (!match_references->is_empty()) {
|
|
|
|
|
if (match_references->size() > 1)
|
|
|
|
|
dbgln("FIXME: Only a single ref test match reference is supported");
|
|
|
|
|
|
|
|
|
|
test.ref_test_expectation_type = RefTestExpectationType::Match;
|
|
|
|
|
reference_to_load = match_references->at(0).as_string();
|
2026-06-02 07:31:14 -03:00
|
|
|
} else if (!mismatch_references->is_empty()) {
|
2025-07-16 05:24:15 -03:00
|
|
|
if (mismatch_references->size() > 1)
|
|
|
|
|
dbgln("FIXME: Only a single ref test mismatch reference is supported");
|
|
|
|
|
|
|
|
|
|
test.ref_test_expectation_type = RefTestExpectationType::Mismatch;
|
|
|
|
|
reference_to_load = mismatch_references->at(0).as_string();
|
|
|
|
|
}
|
2026-06-02 07:31:14 -03:00
|
|
|
|
|
|
|
|
if (test.ref_test_expectation_type == RefTestExpectationType::None) {
|
|
|
|
|
// Skip loading the expectation page entirely since none was specified
|
|
|
|
|
warnln("Test '{}' does not specify an expectation file (e.g. '<link rel=\"match\" href=\"...\" />' tag), test will fail.", test.input_path);
|
|
|
|
|
on_test_complete();
|
|
|
|
|
} else {
|
|
|
|
|
// Clear flag so we can inject the JS into the reference page.
|
|
|
|
|
test.ref_test_expectation_url = URL::Parser::basic_parse(reference_to_load).release_value();
|
|
|
|
|
test.did_inject_js = false;
|
|
|
|
|
view.load(test.ref_test_expectation_url.value());
|
|
|
|
|
}
|
2025-07-16 05:24:15 -03:00
|
|
|
};
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
view.load(url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 19:22:57 -03:00
|
|
|
static void run_screenshot_test(TestWebView& view, TestRunContext& context, Test& test, URL::URL const& url)
|
2026-02-24 04:17:16 -03:00
|
|
|
{
|
|
|
|
|
auto test_index = test.index;
|
|
|
|
|
|
|
|
|
|
auto handle_completed_test = [&context, test_index, url]() -> ErrorOr<TestResult> {
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
auto& actual = *test.actual_screenshot;
|
|
|
|
|
|
2026-02-24 08:07:39 -03:00
|
|
|
// Try to load and compare against existing expected PNG first.
|
|
|
|
|
auto expectation_file_or_error = Core::MappedFile::map(test.expectation_path);
|
|
|
|
|
if (!expectation_file_or_error.is_error()) {
|
|
|
|
|
auto decoder = TRY(Gfx::ImageDecoder::try_create_for_raw_bytes(expectation_file_or_error.value()->bytes()));
|
|
|
|
|
if (decoder) {
|
|
|
|
|
auto frame = TRY(decoder->frame(0));
|
|
|
|
|
test.expectation_screenshot = move(frame.image);
|
|
|
|
|
|
|
|
|
|
auto const& expected = *test.expectation_screenshot;
|
|
|
|
|
auto screenshot_matches = fuzzy_screenshot_match(url, url, actual, expected, test.fuzzy_matches, true);
|
|
|
|
|
if (screenshot_matches)
|
|
|
|
|
return TestResult::Pass;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Screenshots don't match (or expected file doesn't exist yet).
|
2026-02-24 04:17:16 -03:00
|
|
|
if (Application::the().rebaseline) {
|
|
|
|
|
TRY(Core::Directory::create(LexicalPath { test.expectation_path }.parent().string(), Core::Directory::CreateDirectories::Yes));
|
|
|
|
|
TRY(dump_screenshot_to_file(actual, test.expectation_path));
|
2026-02-27 12:38:48 -03:00
|
|
|
|
|
|
|
|
auto optipng_or_error = Core::Process::spawn({
|
|
|
|
|
.executable = "optipng"sv,
|
|
|
|
|
.search_for_executable_in_path = true,
|
|
|
|
|
.arguments = { "-strip"sv, "all"sv, test.expectation_path },
|
|
|
|
|
});
|
|
|
|
|
if (optipng_or_error.is_error()) {
|
|
|
|
|
warnln("Warning: Failed to run optipng: {}", optipng_or_error.error());
|
|
|
|
|
} else {
|
|
|
|
|
auto exit_code_or_error = optipng_or_error.value().wait_for_termination();
|
|
|
|
|
if (exit_code_or_error.is_error())
|
|
|
|
|
warnln("Warning: Failed to wait for optipng: {}", exit_code_or_error.error());
|
|
|
|
|
else if (exit_code_or_error.value() != 0)
|
|
|
|
|
warnln("Warning: optipng exited with code {}", exit_code_or_error.value());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:16 -03:00
|
|
|
return TestResult::Pass;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 08:07:39 -03:00
|
|
|
// Not rebaselining and no valid expectation loaded.
|
|
|
|
|
if (!test.expectation_screenshot)
|
2026-02-24 04:17:16 -03:00
|
|
|
return Error::from_string_literal("Could not decode expected screenshot PNG");
|
|
|
|
|
|
2026-02-24 08:07:39 -03:00
|
|
|
TRY(write_screenshot_failure_results(test, actual, *test.expectation_screenshot));
|
2026-02-24 04:17:16 -03:00
|
|
|
return TestResult::Fail;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
auto on_test_complete = [&view, test_index, handle_completed_test]() {
|
|
|
|
|
if (auto result = handle_completed_test(); result.is_error())
|
|
|
|
|
view.on_test_complete({ test_index, TestResult::Fail });
|
|
|
|
|
else
|
|
|
|
|
view.on_test_complete({ test_index, result.value() });
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-30 13:25:40 -03:00
|
|
|
view.on_load_finish = [&view, &context, test_index, url](auto const& loaded_url) {
|
|
|
|
|
// We don't want subframe loads to trigger this.
|
|
|
|
|
if (!url.equals(loaded_url, URL::ExcludeFragment::Yes))
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
if (!test.did_inject_js) {
|
|
|
|
|
test.did_inject_js = true;
|
|
|
|
|
view.run_javascript(wait_for_reftest_completion);
|
|
|
|
|
}
|
2026-02-24 04:17:16 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
view.on_test_finish = [&view, &context, test_index, on_test_complete = move(on_test_complete)](auto const&) {
|
|
|
|
|
// Take a screenshot of the rendered test page.
|
|
|
|
|
view.take_screenshot()->when_resolved([&view, &context, test_index, on_test_complete = move(on_test_complete)](RefPtr<Gfx::Bitmap const> screenshot) {
|
|
|
|
|
context.tests[test_index].actual_screenshot = move(screenshot);
|
|
|
|
|
view.reset_zoom();
|
|
|
|
|
// Load reference test metadata for fuzzy matching config.
|
|
|
|
|
view.run_javascript("internals.loadReferenceTestMetadata();"_string);
|
|
|
|
|
|
|
|
|
|
view.on_reference_test_metadata = [&context, test_index, on_test_complete = move(on_test_complete)](JsonValue const& metadata) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
auto metadata_object = metadata.as_object();
|
|
|
|
|
|
|
|
|
|
// Read fuzzy configurations (ignore match/mismatch references for Screenshot tests).
|
|
|
|
|
test.fuzzy_matches.clear_with_capacity();
|
|
|
|
|
auto fuzzy_values = metadata_object.get_array("fuzzy"sv);
|
|
|
|
|
for (size_t i = 0; i < fuzzy_values->size(); ++i) {
|
|
|
|
|
auto fuzzy_configuration = fuzzy_values->at(i).as_object();
|
|
|
|
|
|
|
|
|
|
auto content = fuzzy_configuration.get_string("content"sv).release_value();
|
|
|
|
|
auto fuzzy_match_or_error = parse_fuzzy_match({}, content);
|
|
|
|
|
if (fuzzy_match_or_error.is_error()) {
|
|
|
|
|
warnln("Failed to parse fuzzy configuration '{}': {}", content, fuzzy_match_or_error.error());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.fuzzy_matches.append(fuzzy_match_or_error.release_value());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on_test_complete();
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
view.load(url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
static void run_test(TestWebView& view, TestRunContext& context, size_t test_index, Application& app, TestRunCapture& test_run_capture)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
2026-01-13 13:33:48 -03:00
|
|
|
s_current_test_index_by_view.set(&view, test_index);
|
2025-08-17 20:16:39 -03:00
|
|
|
|
2026-03-19 19:18:40 -03:00
|
|
|
auto& test = context.tests[test_index];
|
2026-04-11 16:12:17 -03:00
|
|
|
test_run_capture.begin_test_output_capture(view, test);
|
2026-03-19 19:18:40 -03:00
|
|
|
auto timeout_in_milliseconds = app.per_test_timeout_in_seconds * 1000;
|
|
|
|
|
|
|
|
|
|
test.timeout_timer = Core::Timer::create_single_shot(timeout_in_milliseconds, [&view, &context, test_index]() {
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
if (!test.did_start_test)
|
|
|
|
|
dbgln("Timeout during pre-navigation for {}, WebContent process may be unresponsive", test.relative_path);
|
|
|
|
|
|
|
|
|
|
view.on_test_complete({ test_index, TestResult::Timeout });
|
|
|
|
|
});
|
|
|
|
|
test.timeout_timer->start();
|
|
|
|
|
|
2026-03-19 19:22:57 -03:00
|
|
|
view.on_set_test_timeout = [&context, test_index, timeout_in_milliseconds](double milliseconds) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
|
|
|
|
if (milliseconds > timeout_in_milliseconds)
|
|
|
|
|
test.timeout_timer->restart(AK::clamp_to<int>(milliseconds));
|
|
|
|
|
};
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
// Clear the current document.
|
|
|
|
|
// FIXME: Implement a debug-request to do this more thoroughly.
|
|
|
|
|
auto promise = Core::Promise<Empty>::construct();
|
|
|
|
|
|
|
|
|
|
view.on_load_finish = [promise](auto const& url) {
|
2025-02-15 07:51:45 -03:00
|
|
|
if (!url.equals(URL::about_blank()))
|
2024-10-10 12:56:27 -03:00
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
Core::deferred_invoke([promise]() {
|
|
|
|
|
promise->resolve({});
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-18 13:37:32 -03:00
|
|
|
view.on_test_finish = {};
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
promise->when_resolved([&view, test_index, &app, &context](auto) {
|
|
|
|
|
auto& test = context.tests[test_index];
|
2026-03-19 19:18:40 -03:00
|
|
|
test.did_start_test = true;
|
|
|
|
|
|
2026-02-10 16:20:55 -03:00
|
|
|
auto real_path = MUST(FileSystem::real_path(test.input_path));
|
|
|
|
|
auto headers_path = ByteString::formatted("{}.headers", real_path);
|
|
|
|
|
|
2026-02-19 12:07:26 -03:00
|
|
|
Optional<URL::URL> url;
|
|
|
|
|
if (FileSystem::exists(headers_path) || s_loaded_from_http_server.contains_slow(test.input_path)) {
|
|
|
|
|
// Some tests need to be served via the echo server so, for example, HTTP headers from .headers files are
|
|
|
|
|
// sent, or so that the resulting HTML document has a HTTP based origin (e.g for testing cookies).
|
2026-02-10 16:20:55 -03:00
|
|
|
auto echo_server_port = Application::web_content_options().echo_server_port;
|
|
|
|
|
VERIFY(echo_server_port.has_value());
|
|
|
|
|
auto relative_path = LexicalPath::relative_path(real_path, app.test_root_path);
|
|
|
|
|
VERIFY(relative_path.has_value());
|
2026-05-30 08:54:22 -03:00
|
|
|
url = URL::Parser::basic_parse(ByteString::formatted("http://{}:{}/static/{}", unique_localhost_hostname("test-web"sv), echo_server_port.value(), relative_path.value())).release_value();
|
2026-02-10 16:20:55 -03:00
|
|
|
} else {
|
|
|
|
|
url = URL::create_with_file_scheme(real_path).release_value();
|
|
|
|
|
}
|
2025-01-19 15:13:59 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Append variant query string if present (variant is "?foo=bar", set_query expects "foo=bar")
|
|
|
|
|
if (test.variant.has_value())
|
2026-02-19 12:07:26 -03:00
|
|
|
url->set_query(MUST(test.variant->substring_from_byte_offset_with_shared_superstring(1)));
|
2026-01-13 13:33:48 -03:00
|
|
|
|
2025-01-19 15:13:59 -03:00
|
|
|
switch (test.mode) {
|
2025-02-23 20:04:52 -03:00
|
|
|
case TestMode::Crash:
|
2025-01-19 15:13:59 -03:00
|
|
|
case TestMode::Text:
|
|
|
|
|
case TestMode::Layout:
|
2026-03-19 19:22:57 -03:00
|
|
|
run_dump_test(view, context, test, *url);
|
2025-01-19 15:13:59 -03:00
|
|
|
return;
|
|
|
|
|
case TestMode::Ref:
|
2026-03-19 19:22:57 -03:00
|
|
|
run_ref_test(view, context, test, *url);
|
2025-01-19 15:13:59 -03:00
|
|
|
return;
|
2026-02-24 04:17:16 -03:00
|
|
|
case TestMode::Screenshot:
|
2026-03-19 19:22:57 -03:00
|
|
|
run_screenshot_test(view, context, test, *url);
|
2026-02-24 04:17:16 -03:00
|
|
|
return;
|
2025-01-19 15:13:59 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VERIFY_NOT_REACHED();
|
|
|
|
|
});
|
|
|
|
|
|
2025-02-15 07:51:45 -03:00
|
|
|
view.load(URL::about_blank());
|
2025-01-19 15:13:59 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
static void set_ui_callbacks_for_tests(TestWebView& view, TestRunCapture& test_run_capture)
|
2025-01-19 15:13:59 -03:00
|
|
|
{
|
2024-10-10 12:56:27 -03:00
|
|
|
view.on_request_file_picker = [&](auto const& accepted_file_types, auto allow_multiple_files) {
|
|
|
|
|
// Create some dummy files for tests.
|
|
|
|
|
Vector<Web::HTML::SelectedFile> selected_files;
|
|
|
|
|
|
|
|
|
|
bool add_txt_files = accepted_file_types.filters.is_empty();
|
|
|
|
|
bool add_cpp_files = false;
|
|
|
|
|
|
|
|
|
|
for (auto const& filter : accepted_file_types.filters) {
|
|
|
|
|
filter.visit(
|
|
|
|
|
[](Web::HTML::FileFilter::FileType) {},
|
|
|
|
|
[&](Web::HTML::FileFilter::MimeType const& mime_type) {
|
|
|
|
|
if (mime_type.value == "text/plain"sv)
|
|
|
|
|
add_txt_files = true;
|
|
|
|
|
},
|
|
|
|
|
[&](Web::HTML::FileFilter::Extension const& extension) {
|
|
|
|
|
if (extension.value == "cpp"sv)
|
|
|
|
|
add_cpp_files = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (add_txt_files) {
|
|
|
|
|
selected_files.empend("file1"sv, MUST(ByteBuffer::copy("Contents for file1"sv.bytes())));
|
|
|
|
|
|
|
|
|
|
if (allow_multiple_files == Web::HTML::AllowMultipleFiles::Yes) {
|
|
|
|
|
selected_files.empend("file2"sv, MUST(ByteBuffer::copy("Contents for file2"sv.bytes())));
|
|
|
|
|
selected_files.empend("file3"sv, MUST(ByteBuffer::copy("Contents for file3"sv.bytes())));
|
|
|
|
|
selected_files.empend("file4"sv, MUST(ByteBuffer::copy("Contents for file4"sv.bytes())));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (add_cpp_files) {
|
|
|
|
|
selected_files.empend("file1.cpp"sv, MUST(ByteBuffer::copy("int main() {{ return 1; }}"sv.bytes())));
|
|
|
|
|
|
|
|
|
|
if (allow_multiple_files == Web::HTML::AllowMultipleFiles::Yes) {
|
|
|
|
|
selected_files.empend("file2.cpp"sv, MUST(ByteBuffer::copy("int main() {{ return 2; }}"sv.bytes())));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
view.file_picker_closed(move(selected_files));
|
|
|
|
|
};
|
|
|
|
|
|
2025-01-19 15:13:59 -03:00
|
|
|
view.on_request_alert = [&](auto const&) {
|
|
|
|
|
// For tests, just close the alert right away to unblock JS execution.
|
|
|
|
|
view.alert_closed();
|
|
|
|
|
};
|
2025-11-21 17:48:46 -03:00
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
view.on_web_content_crashed = [&view, &test_run_capture]() {
|
|
|
|
|
test_run_capture.write_test_output(view);
|
2026-01-13 19:32:45 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
if (auto index = s_current_test_index_by_view.get(&view); index.has_value()) {
|
|
|
|
|
view.on_test_complete({ *index, TestResult::Crashed });
|
2026-01-13 19:32:45 -03:00
|
|
|
}
|
2025-11-21 17:48:46 -03:00
|
|
|
};
|
2026-02-20 12:21:22 -03:00
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
view.on_web_content_process_change_for_cross_site_navigation = [&view, &test_run_capture]() {
|
|
|
|
|
test_run_capture.rebind_test_output_capture(view);
|
2026-02-20 12:21:22 -03:00
|
|
|
};
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
2025-06-06 21:11:13 -03:00
|
|
|
static ErrorOr<int> run_tests(Core::AnonymousBuffer const& theme, Web::DevicePixelSize window_size)
|
2024-10-10 12:56:27 -03:00
|
|
|
{
|
|
|
|
|
auto& app = Application::the();
|
2026-04-16 15:01:55 -03:00
|
|
|
auto& display = Display::the();
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
TRY(load_test_config(app.test_root_path));
|
LibWeb: Add compositor scroll state snapshots
Record the scroll geometry that CompositorThread can safely reason about
while the main thread is painting. The snapshot contains stable scroll
node IDs, parent links, scroll bounds, sticky inputs, and wheel blocker
regions in display-list coordinate space.
Add AsyncScrollingState and AsyncScrollTree under LibWeb/Compositor. The
state is the immutable main-thread snapshot; the tree is the mutable
compositor-side copy that can replay scroll deltas and sticky offsets
without touching DOM, layout, or paintables.
Expose the state through internals and add text tests for tree shape,
parent links, sticky areas, blocker hit testing, nested scrollers, and
admission decisions. Keep the directory skipped unless the feature is
enabled with --enable-async-scrolling.
2026-05-11 16:27:11 -03:00
|
|
|
TRY(skip_async_scrolling_tests_unless_enabled(app));
|
2024-10-10 12:56:27 -03:00
|
|
|
|
|
|
|
|
Vector<Test> tests;
|
2025-02-04 16:23:11 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Parse explicit variants from filters (e.g., "test.html?variant=foo")
|
|
|
|
|
HashMap<ByteString, String> explicit_variants;
|
|
|
|
|
for (auto& glob : app.test_globs) {
|
|
|
|
|
if (auto query_pos = glob.find('?'); query_pos.has_value()) {
|
|
|
|
|
auto base_glob = glob.substring(0, query_pos.value());
|
|
|
|
|
auto variant = MUST(String::from_utf8(glob.substring_view(query_pos.value())));
|
|
|
|
|
explicit_variants.set(ByteString::formatted("*{}*", base_glob), variant);
|
|
|
|
|
glob = ByteString::formatted("*{}*", base_glob);
|
|
|
|
|
} else {
|
|
|
|
|
glob = ByteString::formatted("*{}*", glob);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-02-04 16:23:11 -03:00
|
|
|
if (app.test_globs.is_empty())
|
|
|
|
|
app.test_globs.append("*"sv);
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2024-11-30 10:08:47 -03:00
|
|
|
TRY(collect_dump_tests(app, tests, ByteString::formatted("{}/Layout", app.test_root_path), "."sv, TestMode::Layout));
|
|
|
|
|
TRY(collect_dump_tests(app, tests, ByteString::formatted("{}/Text", app.test_root_path), "."sv, TestMode::Text));
|
|
|
|
|
TRY(collect_ref_tests(app, tests, ByteString::formatted("{}/Ref", app.test_root_path), "."sv));
|
2025-02-10 01:11:26 -03:00
|
|
|
TRY(collect_crash_tests(app, tests, ByteString::formatted("{}/Crash", app.test_root_path), "."sv));
|
2026-02-24 04:17:16 -03:00
|
|
|
TRY(collect_screenshot_tests(app, tests, ByteString::formatted("{}/Screenshot", app.test_root_path), "."sv));
|
2024-10-10 12:56:27 -03:00
|
|
|
|
|
|
|
|
tests.remove_all_matching([&](auto const& test) {
|
2024-12-28 21:29:26 -03:00
|
|
|
static constexpr Array support_file_patterns {
|
|
|
|
|
"*/wpt-import/*/support/*"sv,
|
|
|
|
|
"*/wpt-import/*/resources/*"sv,
|
|
|
|
|
"*/wpt-import/common/*"sv,
|
2025-07-20 00:19:43 -03:00
|
|
|
"*/wpt-import/images/*"sv,
|
2024-12-28 21:29:26 -03:00
|
|
|
};
|
2026-01-10 20:50:05 -03:00
|
|
|
auto normalize_path = [](ByteString const& path) { return path.replace("\\"sv, "/"sv); };
|
|
|
|
|
auto const test_input_path = normalize_path(test.input_path);
|
|
|
|
|
auto const test_relative_path = normalize_path(test.relative_path);
|
|
|
|
|
bool is_support_file = any_of(support_file_patterns, [&](auto pattern) { return test_input_path.matches(pattern); });
|
|
|
|
|
bool match_glob = any_of(app.test_globs, [&](auto const& glob) { return test_relative_path.matches(glob, CaseSensitivity::CaseSensitive); });
|
2024-12-14 20:08:50 -03:00
|
|
|
return is_support_file || !match_glob;
|
2024-10-10 12:56:27 -03:00
|
|
|
});
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Apply explicit variants from filters
|
|
|
|
|
for (auto& test : tests) {
|
|
|
|
|
for (auto const& [glob, variant] : explicit_variants) {
|
|
|
|
|
if (test.relative_path.matches(glob, CaseSensitivity::CaseSensitive)) {
|
|
|
|
|
test.variant = variant;
|
|
|
|
|
auto variant_suffix = variant.bytes_as_string_view().substring_view(1);
|
|
|
|
|
test.relative_path = ByteString::formatted("{}?{}", test.relative_path, variant_suffix);
|
|
|
|
|
test.safe_relative_path = ByteString::formatted("{}@{}", test.safe_relative_path, variant_suffix);
|
|
|
|
|
auto dir = LexicalPath::dirname(test.expectation_path);
|
|
|
|
|
auto title = LexicalPath::title(LexicalPath::basename(test.input_path));
|
|
|
|
|
if (dir.is_empty())
|
|
|
|
|
test.expectation_path = ByteString::formatted("{}@{}.txt", title, variant_suffix);
|
|
|
|
|
else
|
|
|
|
|
test.expectation_path = ByteString::formatted("{}/{}@{}.txt", dir, title, variant_suffix);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-27 18:52:12 -03:00
|
|
|
if (app.shuffle)
|
|
|
|
|
shuffle(tests);
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
if (app.test_dry_run) {
|
|
|
|
|
outln("Found {} tests...", tests.size());
|
|
|
|
|
|
|
|
|
|
for (auto const& [i, test] : enumerate(tests))
|
2024-11-30 10:08:47 -03:00
|
|
|
outln("{}/{}: {}", i + 1, tests.size(), test.relative_path);
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2025-06-06 16:42:12 -03:00
|
|
|
return 0;
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
2024-10-14 10:44:57 -03:00
|
|
|
if (tests.is_empty()) {
|
2025-02-04 16:23:11 -03:00
|
|
|
if (app.test_globs.is_empty())
|
2024-10-14 10:44:57 -03:00
|
|
|
return Error::from_string_literal("No tests found");
|
|
|
|
|
return Error::from_string_literal("No tests found matching filter");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 04:55:19 -03:00
|
|
|
if (app.repeat_count > 1) {
|
|
|
|
|
auto base_tests = move(tests);
|
|
|
|
|
tests.ensure_capacity(base_tests.size() * app.repeat_count);
|
|
|
|
|
|
|
|
|
|
for (size_t run_index = 1; run_index <= app.repeat_count; ++run_index) {
|
|
|
|
|
for (auto const& base_test : base_tests) {
|
|
|
|
|
Test test = base_test;
|
|
|
|
|
test.run_index = run_index;
|
|
|
|
|
test.total_runs = app.repeat_count;
|
|
|
|
|
test.safe_relative_path = LexicalPath::join(ByteString::formatted("run-{}", run_index), test.safe_relative_path).string();
|
|
|
|
|
tests.append(move(test));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-16 15:01:55 -03:00
|
|
|
size_t total_tests = tests.size();
|
|
|
|
|
auto concurrency = min(app.test_concurrency, total_tests);
|
2024-10-10 12:56:27 -03:00
|
|
|
size_t loaded_web_views = 0;
|
2025-06-06 21:11:13 -03:00
|
|
|
Vector<NonnullOwnPtr<TestWebView>> views;
|
2025-06-06 08:16:28 -03:00
|
|
|
views.ensure_capacity(concurrency);
|
|
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
TestRunCapture test_run_capture;
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
for (size_t i = 0; i < concurrency; ++i) {
|
2025-06-06 21:11:13 -03:00
|
|
|
auto view = TestWebView::create(theme, window_size);
|
2025-06-06 08:16:28 -03:00
|
|
|
view->on_load_finish = [&](auto const&) { ++loaded_web_views; };
|
2025-08-23 15:34:58 -03:00
|
|
|
// FIXME: Figure out a better way to ensure that tests use default browser settings.
|
|
|
|
|
view->reset_zoom();
|
2025-06-06 08:16:28 -03:00
|
|
|
|
|
|
|
|
views.unchecked_append(move(view));
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We need to wait for the initial about:blank load to complete before starting the tests, otherwise we may load the
|
|
|
|
|
// test URL before the about:blank load completes. WebContent currently cannot handle this, and will drop the test URL.
|
|
|
|
|
Core::EventLoop::current().spin_until([&]() {
|
|
|
|
|
return loaded_web_views == concurrency;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Initialize view display states (used for idle tracking even when not on TTY)
|
|
|
|
|
s_view_display_states.resize(concurrency);
|
|
|
|
|
for (auto [i, view] : enumerate(views)) {
|
|
|
|
|
s_view_display_states[i].pid = view->web_content_pid();
|
|
|
|
|
s_view_display_states[i].active = false;
|
2026-05-12 15:30:48 -03:00
|
|
|
s_view_index_by_view.set(view.ptr(), i);
|
2026-01-13 13:33:48 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
display.begin_run();
|
|
|
|
|
ScopeGuard clear_live_display = [&] { display.clear_live_display(); };
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Initialize per-view functions (for waking idle views)
|
|
|
|
|
s_view_run_next_test.resize_and_keep_capacity(concurrency);
|
|
|
|
|
|
2025-08-17 20:16:39 -03:00
|
|
|
s_all_tests_complete = Core::Promise<Empty>::construct();
|
2024-10-10 12:56:27 -03:00
|
|
|
auto tests_remaining = tests.size();
|
|
|
|
|
auto current_test = 0uz;
|
|
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
TestRunContext context { tests, tests_remaining, total_tests };
|
2026-01-13 13:33:48 -03:00
|
|
|
s_run_context = &context;
|
|
|
|
|
ScopeGuard clear_run_context = [&] { s_run_context = nullptr; };
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
Vector<TestCompletion> non_passing_tests;
|
2026-04-16 15:01:55 -03:00
|
|
|
bool fail_fast_triggered = false;
|
2025-10-02 09:19:15 -03:00
|
|
|
|
|
|
|
|
for (auto [view_id, view] : enumerate(views)) {
|
2026-04-11 16:12:17 -03:00
|
|
|
set_ui_callbacks_for_tests(*view, test_run_capture);
|
2026-05-21 11:36:25 -03:00
|
|
|
view->clear_content_blockers();
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto cleanup_test = [&, view = view.ptr()](size_t test_index, TestResult test_result) {
|
|
|
|
|
view->on_load_finish = {};
|
|
|
|
|
view->on_test_finish = {};
|
|
|
|
|
view->on_reference_test_metadata = {};
|
|
|
|
|
view->on_test_variant_metadata = {};
|
|
|
|
|
view->on_set_test_timeout = {};
|
|
|
|
|
|
|
|
|
|
// Disconnect child crash handlers so old child crashes don't affect the next test
|
|
|
|
|
view->disconnect_child_crash_handlers();
|
|
|
|
|
|
2026-02-13 13:30:04 -03:00
|
|
|
// Don't try to reset state if WebContent crashed - it's gone
|
|
|
|
|
if (test_result != TestResult::Crashed) {
|
2026-05-21 13:35:49 -03:00
|
|
|
view->clear_content_blockers();
|
2026-01-13 13:33:48 -03:00
|
|
|
view->reset_zoom();
|
2026-02-13 13:30:04 -03:00
|
|
|
view->reset_viewport_size(window_size);
|
|
|
|
|
}
|
2026-01-13 13:33:48 -03:00
|
|
|
|
|
|
|
|
auto& test = tests[test_index];
|
|
|
|
|
if (test.timeout_timer) {
|
|
|
|
|
test.timeout_timer->stop();
|
|
|
|
|
test.timeout_timer.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s_current_test_index_by_view.remove(view);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// run_next_test handles: reset promise, attach callback, pick test, run test
|
|
|
|
|
auto run_next_test = [&, view = view.ptr(), cleanup_test, view_id]() {
|
2026-04-16 15:01:55 -03:00
|
|
|
if (app.fail_fast && fail_fast_triggered) {
|
2026-02-13 05:19:39 -03:00
|
|
|
if (view_id < s_view_display_states.size())
|
|
|
|
|
s_view_display_states[view_id].active = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Check without incrementing first - only consume an index if we have a test
|
|
|
|
|
if (current_test >= tests.size()) {
|
|
|
|
|
// Mark this view as idle (for variant wake-up tracking)
|
|
|
|
|
if (view_id < s_view_display_states.size())
|
2026-01-13 07:23:09 -03:00
|
|
|
s_view_display_states[view_id].active = false;
|
2024-10-10 12:56:27 -03:00
|
|
|
return;
|
2026-01-13 07:23:09 -03:00
|
|
|
}
|
2026-01-13 13:33:48 -03:00
|
|
|
auto index = current_test++;
|
2024-10-13 10:23:27 -03:00
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
auto& test = tests[index];
|
2024-10-13 10:23:27 -03:00
|
|
|
test.start_time = UnixDateTime::now();
|
2026-01-13 13:33:48 -03:00
|
|
|
test.index = index;
|
|
|
|
|
|
|
|
|
|
// Mark this view as active (for variant wake-up tracking)
|
2026-04-16 15:01:55 -03:00
|
|
|
display.on_test_started(view_id, test, view->web_content_pid());
|
2026-05-13 06:04:43 -03:00
|
|
|
try_write_harness_status(ByteString::formatted("running {}", test.relative_path));
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Reset promise and attach completion callback
|
|
|
|
|
view->reset_test_promise();
|
2026-04-16 15:01:55 -03:00
|
|
|
view->test_promise().when_resolved([&tests, &tests_remaining, &non_passing_tests, &app, view, cleanup_test, view_id, &test_run_capture, &fail_fast_triggered](auto result) {
|
2026-01-13 13:33:48 -03:00
|
|
|
cleanup_test(result.test_index, result.result);
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
auto& test = tests[result.test_index];
|
2026-01-12 20:15:11 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Clear screenshots to free memory
|
|
|
|
|
test.actual_screenshot.clear();
|
|
|
|
|
test.expectation_screenshot.clear();
|
2025-11-21 17:48:46 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
test.end_time = UnixDateTime::now();
|
2024-10-13 10:23:27 -03:00
|
|
|
|
2026-04-11 16:12:17 -03:00
|
|
|
// Write captured std logs to results directory.
|
2026-01-20 23:16:46 -03:00
|
|
|
// NOTE: On crashes, we already flushed it in on_web_content_crashed.
|
2026-04-11 16:12:17 -03:00
|
|
|
if (result.result != TestResult::Crashed)
|
|
|
|
|
test_run_capture.write_test_output(*view);
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2026-02-13 05:19:39 -03:00
|
|
|
bool const is_non_passing_result = result.result != TestResult::Pass && result.result != TestResult::Expanded;
|
|
|
|
|
bool const should_trigger_fail_fast = result.result == TestResult::Fail || result.result == TestResult::Timeout || result.result == TestResult::Crashed;
|
|
|
|
|
|
|
|
|
|
if (is_non_passing_result)
|
2026-01-13 13:33:48 -03:00
|
|
|
non_passing_tests.append(result);
|
|
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
Display::the().on_test_finished(view_id, test, result.result);
|
2026-05-13 06:04:43 -03:00
|
|
|
try_write_harness_status(ByteString::formatted("finished {}: {}", test.relative_path, test_result_to_string(result.result)));
|
2026-02-13 05:19:39 -03:00
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
if (app.fail_fast && !fail_fast_triggered && should_trigger_fail_fast) {
|
|
|
|
|
fail_fast_triggered = true;
|
2026-02-13 05:19:39 -03:00
|
|
|
auto const pid = view->web_content_pid();
|
2026-04-16 15:01:55 -03:00
|
|
|
Display::the().on_fail_fast(test, result.result, pid);
|
2026-05-13 06:04:43 -03:00
|
|
|
try_write_harness_status(ByteString::formatted("fail-fast after {}: {}", test.relative_path, test_result_to_string(result.result)));
|
2026-02-13 05:19:39 -03:00
|
|
|
|
|
|
|
|
if (s_all_tests_complete)
|
|
|
|
|
s_all_tests_complete->reject(Error::from_string_literal("Fail-fast"));
|
|
|
|
|
Core::EventLoop::current().quit(1);
|
|
|
|
|
|
|
|
|
|
if (result.result == TestResult::Timeout)
|
|
|
|
|
maybe_attach_on_fail_fast_timeout(pid);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
if (--tests_remaining == 0) {
|
|
|
|
|
s_all_tests_complete->resolve({});
|
|
|
|
|
} else {
|
|
|
|
|
// Use deferred_invoke to avoid destroying callback while inside it
|
|
|
|
|
Core::deferred_invoke([view_id]() {
|
|
|
|
|
// Wake any idle views to help with remaining tests
|
|
|
|
|
for (size_t i = 0; i < s_view_run_next_test.size(); ++i) {
|
|
|
|
|
if (i < s_view_display_states.size() && !s_view_display_states[i].active && s_view_run_next_test[i])
|
|
|
|
|
s_view_run_next_test[i]();
|
|
|
|
|
}
|
|
|
|
|
// Run next test for this view
|
|
|
|
|
s_view_run_next_test[view_id]();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
Core::deferred_invoke([&, index]() mutable {
|
|
|
|
|
if (s_skipped_tests.contains_slow(tests[index].input_path))
|
|
|
|
|
view->on_test_complete({ index, TestResult::Skipped });
|
|
|
|
|
else
|
2026-04-11 16:12:17 -03:00
|
|
|
run_test(*view, context, index, app, test_run_capture);
|
2026-01-13 13:33:48 -03:00
|
|
|
});
|
|
|
|
|
};
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
// Store in static vector for access by variant expansion wake-up
|
|
|
|
|
s_view_run_next_test[view_id] = move(run_next_test);
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 13:33:48 -03:00
|
|
|
Core::deferred_invoke([view_id]() {
|
|
|
|
|
s_view_run_next_test[view_id]();
|
2024-10-10 12:56:27 -03:00
|
|
|
});
|
2025-06-06 08:16:28 -03:00
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
auto result_or_rejection
|
|
|
|
|
= s_all_tests_complete->await();
|
|
|
|
|
display.clear_live_display();
|
2026-05-12 15:30:48 -03:00
|
|
|
|
2026-05-13 06:04:43 -03:00
|
|
|
bool const test_run_stopped_early = result_or_rejection.is_error() || tests_remaining > 0;
|
|
|
|
|
if (result_or_rejection.is_error()) {
|
|
|
|
|
auto reason = ByteString::formatted("test run stopped early: {}", result_or_rejection.error());
|
|
|
|
|
log_active_test_views(reason);
|
|
|
|
|
try_write_harness_status(reason);
|
|
|
|
|
} else if (tests_remaining > 0) {
|
|
|
|
|
auto reason = ByteString::formatted("event loop stopped with {} unfinished tests", tests_remaining);
|
|
|
|
|
log_active_test_views(reason);
|
|
|
|
|
try_write_harness_status(reason);
|
|
|
|
|
} else {
|
|
|
|
|
try_write_harness_status("completed"sv);
|
|
|
|
|
}
|
2026-05-12 15:30:48 -03:00
|
|
|
|
2026-05-13 06:04:43 -03:00
|
|
|
display.print_run_complete(tests, non_passing_tests, tests_remaining);
|
2024-10-13 10:23:27 -03:00
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
if (app.dump_gc_graph) {
|
2025-06-06 08:16:28 -03:00
|
|
|
for (auto& view : views) {
|
2026-02-01 16:52:16 -03:00
|
|
|
if (auto path = view->dump_gc_graph(); path.is_error()) {
|
2024-10-10 12:56:27 -03:00
|
|
|
warnln("Failed to dump GC graph: {}", path.error());
|
2026-02-01 16:52:16 -03:00
|
|
|
} else {
|
2024-10-10 12:56:27 -03:00
|
|
|
outln("GC graph dumped to {}", path.value());
|
2026-02-01 16:52:16 -03:00
|
|
|
auto source_root = LexicalPath(app.test_root_path).parent().parent().string();
|
|
|
|
|
outln("GC graph explorer: file://{}/Meta/gc-heap-explorer.html?script=file://{}", source_root, path.value());
|
|
|
|
|
}
|
2025-06-06 08:16:28 -03:00
|
|
|
}
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
2026-04-11 17:25:53 -03:00
|
|
|
bool has_helper_output = test_run_capture.write_helper_process_output();
|
2024-10-10 12:56:27 -03:00
|
|
|
|
2026-01-13 07:23:09 -03:00
|
|
|
// Generate result files (JSON data and HTML index)
|
2026-05-13 06:04:43 -03:00
|
|
|
if (app.quiet || app.verbosity < Application::VERBOSITY_LEVEL_LOG_TEST_OUTPUT || !non_passing_tests.is_empty() || has_helper_output || test_run_stopped_early) {
|
2026-01-20 13:29:13 -03:00
|
|
|
if (auto result = generate_result_files(tests, non_passing_tests); result.is_error())
|
|
|
|
|
warnln("Failed to generate result files: {}", result.error());
|
|
|
|
|
else
|
|
|
|
|
outln("Results: file://{}/index.html", app.results_directory);
|
|
|
|
|
}
|
2026-01-13 07:23:09 -03:00
|
|
|
|
2026-04-16 15:01:55 -03:00
|
|
|
return display.fail_count + display.timeout_count + display.crashed_count + tests_remaining;
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
|
|
|
|
|
2025-08-17 20:16:39 -03:00
|
|
|
static void handle_signal(int signal)
|
|
|
|
|
{
|
|
|
|
|
// Quit our event loop. This makes `::exec()` return as soon as possible, and signals to WebView::Application that
|
|
|
|
|
// we should no longer automatically restart processes in `::process_did_exit()`.
|
|
|
|
|
Core::EventLoop::current().quit(0);
|
|
|
|
|
|
2026-05-12 15:30:48 -03:00
|
|
|
StringView signal_name = "signal received"sv;
|
|
|
|
|
if (signal == SIGINT)
|
|
|
|
|
signal_name = "SIGINT received"sv;
|
|
|
|
|
else if (signal == SIGTERM)
|
|
|
|
|
signal_name = "SIGTERM received"sv;
|
2025-08-17 20:16:39 -03:00
|
|
|
|
2026-05-12 15:30:48 -03:00
|
|
|
if (signal == SIGINT || signal == SIGTERM)
|
|
|
|
|
log_active_test_views(signal_name);
|
|
|
|
|
else
|
|
|
|
|
log_active_test_views(ByteString::formatted("signal {} received", signal));
|
2026-05-13 06:04:43 -03:00
|
|
|
try_write_harness_status(signal_name);
|
2025-08-17 20:16:39 -03:00
|
|
|
|
|
|
|
|
// Stop running tests
|
2026-05-12 15:30:48 -03:00
|
|
|
if (signal == SIGINT)
|
|
|
|
|
s_all_tests_complete->reject(Error::from_string_view("SIGINT received"sv));
|
|
|
|
|
else if (signal == SIGTERM)
|
|
|
|
|
s_all_tests_complete->reject(Error::from_string_view("SIGTERM received"sv));
|
|
|
|
|
else
|
|
|
|
|
s_all_tests_complete->reject(Error::from_string_literal("Unexpected signal received"));
|
2025-08-17 20:16:39 -03:00
|
|
|
}
|
|
|
|
|
|
2024-10-10 12:56:27 -03:00
|
|
|
}
|
2025-06-06 21:11:13 -03:00
|
|
|
|
2025-07-08 07:14:08 -03:00
|
|
|
ErrorOr<int> ladybird_main(Main::Arguments arguments)
|
2025-06-06 21:11:13 -03:00
|
|
|
{
|
|
|
|
|
#if defined(LADYBIRD_BINARY_PATH)
|
2025-06-10 20:20:46 -03:00
|
|
|
auto app = TRY(TestWeb::Application::create(arguments, LADYBIRD_BINARY_PATH));
|
2025-06-06 21:11:13 -03:00
|
|
|
#else
|
2025-06-10 20:20:46 -03:00
|
|
|
auto app = TRY(TestWeb::Application::create(arguments, OptionalNone {}));
|
2025-06-06 21:11:13 -03:00
|
|
|
#endif
|
2026-04-11 18:09:25 -03:00
|
|
|
app->invocation_command_line = MUST(String::join(' ', arguments.strings));
|
2025-06-06 21:11:13 -03:00
|
|
|
|
2026-02-13 04:55:19 -03:00
|
|
|
if (app->repeat_count > 1 && app->rebaseline) {
|
|
|
|
|
warnln("Error: --repeat cannot be used together with --rebaseline.");
|
|
|
|
|
warnln("Run once with --rebaseline, or drop --rebaseline when repeating.");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
2026-06-02 07:31:15 -03:00
|
|
|
|
|
|
|
|
if (app->test_root_path.is_empty()) {
|
|
|
|
|
warnln("Error: --test-path must be passed to specify the location of the tests.");
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-17 20:16:39 -03:00
|
|
|
Core::EventLoop::register_signal(SIGINT, TestWeb::handle_signal);
|
|
|
|
|
Core::EventLoop::register_signal(SIGTERM, TestWeb::handle_signal);
|
|
|
|
|
|
2025-06-06 21:11:13 -03:00
|
|
|
auto theme_path = LexicalPath::join(WebView::s_ladybird_resource_root, "themes"sv, "Default.ini"sv);
|
|
|
|
|
auto theme = TRY(Gfx::load_system_theme(theme_path.string()));
|
|
|
|
|
|
|
|
|
|
auto const& browser_options = TestWeb::Application::browser_options();
|
|
|
|
|
Web::DevicePixelSize window_size { browser_options.window_width, browser_options.window_height };
|
|
|
|
|
|
|
|
|
|
app->test_root_path = LexicalPath::absolute_path(TRY(FileSystem::current_working_directory()), app->test_root_path);
|
2026-01-13 07:23:09 -03:00
|
|
|
|
|
|
|
|
app->results_directory = LexicalPath::absolute_path(TRY(FileSystem::current_working_directory()), app->results_directory);
|
|
|
|
|
TRY(Core::Directory::create(app->results_directory, Core::Directory::CreateDirectories::Yes));
|
|
|
|
|
|
2025-06-06 21:11:13 -03:00
|
|
|
TRY(app->launch_test_fixtures());
|
|
|
|
|
|
|
|
|
|
return TestWeb::run_tests(theme, window_size);
|
|
|
|
|
}
|