mirror of
				https://github.com/RGBCube/serenity
				synced 2025-10-31 21:32:44 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			413 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			C++
		
	
	
	
	
	
			
		
		
	
	
			413 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			C++
		
	
	
	
	
	
| /*
 | |
|  * Copyright (c) 2021, Andrew Kaster <akaster@serenityos.org>
 | |
|  *
 | |
|  * SPDX-License-Identifier: BSD-2-Clause
 | |
|  */
 | |
| 
 | |
| #include <AK/LexicalPath.h>
 | |
| #include <LibCore/ArgsParser.h>
 | |
| #include <LibCore/ConfigFile.h>
 | |
| #include <LibCore/File.h>
 | |
| #include <LibCore/System.h>
 | |
| #include <LibCoredump/Backtrace.h>
 | |
| #include <LibMain/Main.h>
 | |
| #include <LibRegex/Regex.h>
 | |
| #include <LibTest/TestRunner.h>
 | |
| #include <signal.h>
 | |
| #include <spawn.h>
 | |
| #include <stdlib.h>
 | |
| #include <sys/wait.h>
 | |
| #include <unistd.h>
 | |
| 
 | |
| namespace Test {
 | |
| TestRunner* TestRunner::s_the = nullptr;
 | |
| }
 | |
| using Test::get_time_in_ms;
 | |
| using Test::print_modifiers;
 | |
| 
 | |
| struct FileResult {
 | |
|     LexicalPath file_path;
 | |
|     double time_taken { 0 };
 | |
|     Test::Result result { Test::Result::Pass };
 | |
|     int stdout_err_fd { -1 };
 | |
|     pid_t child_pid { 0 };
 | |
| };
 | |
| 
 | |
| String g_currently_running_test;
 | |
| 
 | |
| class TestRunner : public ::Test::TestRunner {
 | |
| public:
 | |
|     TestRunner(String test_root, Regex<PosixExtended> exclude_regex, NonnullRefPtr<Core::ConfigFile> config, Regex<PosixExtended> skip_regex, bool run_skipped_tests, bool print_progress, bool print_json, bool print_all_output, bool print_times = true)
 | |
|         : ::Test::TestRunner(move(test_root), print_times, print_progress, print_json)
 | |
|         , m_exclude_regex(move(exclude_regex))
 | |
|         , m_config(move(config))
 | |
|         , m_skip_regex(move(skip_regex))
 | |
|         , m_run_skipped_tests(run_skipped_tests)
 | |
|         , m_print_all_output(print_all_output)
 | |
|     {
 | |
|         if (!run_skipped_tests) {
 | |
|             m_skip_directories = m_config->read_entry("Global", "SkipDirectories", "").split(' ');
 | |
|             m_skip_files = m_config->read_entry("Global", "SkipTests", "").split(' ');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     virtual ~TestRunner() = default;
 | |
| 
 | |
| protected:
 | |
|     virtual void do_run_single_test(String const& test_path, size_t current_text_index, size_t num_tests) override;
 | |
|     virtual Vector<String> get_test_paths() const override;
 | |
|     virtual Vector<String> const* get_failed_test_names() const override { return &m_failed_test_names; }
 | |
| 
 | |
|     virtual FileResult run_test_file(String const& test_path);
 | |
| 
 | |
|     bool should_skip_test(LexicalPath const& test_path);
 | |
| 
 | |
|     Regex<PosixExtended> m_exclude_regex;
 | |
|     NonnullRefPtr<Core::ConfigFile> m_config;
 | |
|     Vector<String> m_skip_directories;
 | |
|     Vector<String> m_skip_files;
 | |
|     Vector<String> m_failed_test_names;
 | |
|     Regex<PosixExtended> m_skip_regex;
 | |
|     bool m_run_skipped_tests { false };
 | |
|     bool m_print_all_output { false };
 | |
| };
 | |
| 
 | |
| Vector<String> TestRunner::get_test_paths() const
 | |
| {
 | |
|     Vector<String> paths;
 | |
|     Test::iterate_directory_recursively(m_test_root, [&](String const& file_path) {
 | |
|         if (access(file_path.characters(), R_OK | X_OK) != 0)
 | |
|             return;
 | |
|         auto result = m_exclude_regex.match(file_path, PosixFlags::Global);
 | |
|         if (!result.success) // must NOT match the regex to be a valid test file
 | |
|             paths.append(file_path);
 | |
|     });
 | |
|     quick_sort(paths);
 | |
|     return paths;
 | |
| }
 | |
| 
 | |
| bool TestRunner::should_skip_test(LexicalPath const& test_path)
 | |
| {
 | |
|     if (m_run_skipped_tests)
 | |
|         return false;
 | |
| 
 | |
|     for (String const& dir : m_skip_directories) {
 | |
|         if (test_path.dirname().contains(dir))
 | |
|             return true;
 | |
|     }
 | |
|     for (String const& file : m_skip_files) {
 | |
|         if (test_path.basename().contains(file))
 | |
|             return true;
 | |
|     }
 | |
|     auto result = m_skip_regex.match(test_path.basename(), PosixFlags::Global);
 | |
|     if (result.success)
 | |
|         return true;
 | |
| 
 | |
|     return false;
 | |
| }
 | |
| 
 | |
| void TestRunner::do_run_single_test(String const& test_path, size_t current_test_index, size_t num_tests)
 | |
| {
 | |
|     g_currently_running_test = test_path;
 | |
|     auto test_relative_path = LexicalPath::relative_path(test_path, m_test_root);
 | |
|     outln(" START  {} ({}/{})", test_relative_path, current_test_index, num_tests);
 | |
|     fflush(stdout); // we really want to see the start text in case the test hangs
 | |
|     auto test_result = run_test_file(test_path);
 | |
| 
 | |
|     switch (test_result.result) {
 | |
|     case Test::Result::Pass:
 | |
|         ++m_counts.tests_passed;
 | |
|         break;
 | |
|     case Test::Result::Skip:
 | |
|         ++m_counts.tests_skipped;
 | |
|         break;
 | |
|     case Test::Result::Fail:
 | |
|         ++m_counts.tests_failed;
 | |
|         break;
 | |
|     case Test::Result::Crashed:
 | |
|         ++m_counts.tests_failed; // FIXME: tests_crashed
 | |
|         break;
 | |
|     }
 | |
|     if (test_result.result != Test::Result::Skip)
 | |
|         ++m_counts.files_total;
 | |
| 
 | |
|     m_total_elapsed_time_in_ms += test_result.time_taken;
 | |
| 
 | |
|     bool crashed_or_failed = test_result.result == Test::Result::Fail || test_result.result == Test::Result::Crashed;
 | |
|     bool print_stdout_stderr = crashed_or_failed || m_print_all_output;
 | |
|     if (crashed_or_failed) {
 | |
|         m_failed_test_names.append(test_path);
 | |
|         print_modifiers({ Test::BG_RED, Test::FG_BLACK, Test::FG_BOLD });
 | |
|         out("{}", test_result.result == Test::Result::Fail ? " FAIL  " : "CRASHED");
 | |
|         print_modifiers({ Test::CLEAR });
 | |
|         if (test_result.result == Test::Result::Crashed) {
 | |
|             auto pid_search_string = String::formatted("_{}_", test_result.child_pid);
 | |
|             Core::DirIterator iterator("/tmp/coredump"sv);
 | |
|             if (!iterator.has_error()) {
 | |
|                 while (iterator.has_next()) {
 | |
|                     auto path = iterator.next_full_path();
 | |
|                     if (!path.contains(pid_search_string))
 | |
|                         continue;
 | |
| 
 | |
|                     auto reader = Coredump::Reader::create(path);
 | |
|                     if (!reader)
 | |
|                         break;
 | |
| 
 | |
|                     dbgln("Last crash backtrace for {} (was pid {}):", test_path, test_result.child_pid);
 | |
|                     reader->for_each_thread_info([&](auto thread_info) {
 | |
|                         Coredump::Backtrace thread_backtrace(*reader, thread_info);
 | |
|                         auto tid = thread_info.tid; // Note: Yoinking this out of the struct because we can't pass a reference to it (as it's a misaligned field in a packed struct)
 | |
|                         dbgln("Thread {}", tid);
 | |
|                         for (auto const& entry : thread_backtrace.entries())
 | |
|                             dbgln("- {}", entry.to_string(true));
 | |
|                         return IterationDecision::Continue;
 | |
|                     });
 | |
|                     break;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     } else {
 | |
|         print_modifiers({ Test::BG_GREEN, Test::FG_BLACK, Test::FG_BOLD });
 | |
|         out(" PASS  ");
 | |
|         print_modifiers({ Test::CLEAR });
 | |
|     }
 | |
| 
 | |
|     out(" {}", test_relative_path);
 | |
| 
 | |
|     print_modifiers({ Test::CLEAR, Test::ITALIC, Test::FG_GRAY });
 | |
|     if (test_result.time_taken < 1000) {
 | |
|         outln(" ({}ms)", static_cast<int>(test_result.time_taken));
 | |
|     } else {
 | |
|         outln(" ({:3}s)", test_result.time_taken / 1000.0);
 | |
|     }
 | |
|     print_modifiers({ Test::CLEAR });
 | |
| 
 | |
|     if (test_result.result != Test::Result::Pass) {
 | |
|         print_modifiers({ Test::FG_GRAY, Test::FG_BOLD });
 | |
|         out("         Test:   ");
 | |
|         if (crashed_or_failed) {
 | |
|             print_modifiers({ Test::CLEAR, Test::FG_RED });
 | |
|             outln("{} ({})", test_result.file_path.basename(), test_result.result == Test::Result::Fail ? "failed" : "crashed");
 | |
|         } else {
 | |
|             print_modifiers({ Test::CLEAR, Test::FG_ORANGE });
 | |
|             outln("{} (skipped)", test_result.file_path.basename());
 | |
|         }
 | |
|         print_modifiers({ Test::CLEAR });
 | |
|     }
 | |
| 
 | |
|     // Make sure our clear modifiers goes through before we dump file output via write(2)
 | |
|     fflush(stdout);
 | |
| 
 | |
|     if (print_stdout_stderr && test_result.stdout_err_fd > 0) {
 | |
|         int ret = lseek(test_result.stdout_err_fd, 0, SEEK_SET);
 | |
|         VERIFY(ret == 0);
 | |
|         for (;;) {
 | |
|             char buf[32768];
 | |
|             ssize_t nread = read(test_result.stdout_err_fd, buf, sizeof(buf));
 | |
|             if (nread == 0)
 | |
|                 break;
 | |
|             if (nread < 0) {
 | |
|                 perror("read");
 | |
|                 break;
 | |
|             }
 | |
|             size_t already_written = 0;
 | |
|             while (already_written < (size_t)nread) {
 | |
|                 ssize_t nwritten = write(STDOUT_FILENO, buf + already_written, nread - already_written);
 | |
|                 if (nwritten < 0) {
 | |
|                     perror("write");
 | |
|                     break;
 | |
|                 }
 | |
|                 already_written += nwritten;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     close(test_result.stdout_err_fd);
 | |
| }
 | |
| 
 | |
| FileResult TestRunner::run_test_file(String const& test_path)
 | |
| {
 | |
|     double start_time = get_time_in_ms();
 | |
| 
 | |
|     auto path_for_test = LexicalPath(test_path);
 | |
|     if (should_skip_test(path_for_test)) {
 | |
|         return FileResult { move(path_for_test), 0.0, Test::Result::Skip, -1 };
 | |
|     }
 | |
| 
 | |
|     // FIXME: actual error handling, mark test as :yaksplode: if any are bad instead of VERIFY
 | |
|     posix_spawn_file_actions_t file_actions;
 | |
|     posix_spawn_file_actions_init(&file_actions);
 | |
|     char child_out_err_path[] = "/tmp/run-tests.XXXXXX";
 | |
|     int child_out_err_file = mkstemp(child_out_err_path);
 | |
|     VERIFY(child_out_err_file >= 0);
 | |
| 
 | |
|     String dirname = path_for_test.dirname();
 | |
|     String basename = path_for_test.basename();
 | |
| 
 | |
|     (void)posix_spawn_file_actions_adddup2(&file_actions, child_out_err_file, STDOUT_FILENO);
 | |
|     (void)posix_spawn_file_actions_adddup2(&file_actions, child_out_err_file, STDERR_FILENO);
 | |
|     (void)posix_spawn_file_actions_addchdir(&file_actions, dirname.characters());
 | |
| 
 | |
|     Vector<char const*, 4> argv;
 | |
|     argv.append(basename.characters());
 | |
|     auto extra_args = m_config->read_entry(path_for_test.basename(), "Arguments", "").split(' ');
 | |
|     for (auto& arg : extra_args)
 | |
|         argv.append(arg.characters());
 | |
|     argv.append(nullptr);
 | |
| 
 | |
|     pid_t child_pid = -1;
 | |
|     // FIXME: Do we really want to copy test runner's entire env?
 | |
|     int ret = posix_spawn(&child_pid, test_path.characters(), &file_actions, nullptr, const_cast<char* const*>(argv.data()), environ);
 | |
|     VERIFY(ret == 0);
 | |
|     VERIFY(child_pid > 0);
 | |
| 
 | |
|     int wstatus;
 | |
| 
 | |
|     Test::Result test_result = Test::Result::Fail;
 | |
|     for (size_t num_waits = 0; num_waits < 2; ++num_waits) {
 | |
|         ret = waitpid(child_pid, &wstatus, 0); // intentionally not setting WCONTINUED
 | |
|         if (ret != child_pid)
 | |
|             break; // we'll end up with a failure
 | |
| 
 | |
|         if (WIFEXITED(wstatus)) {
 | |
|             if (wstatus == 0) {
 | |
|                 test_result = Test::Result::Pass;
 | |
|             }
 | |
|             break;
 | |
|         } else if (WIFSIGNALED(wstatus)) {
 | |
|             test_result = Test::Result::Crashed;
 | |
|             break;
 | |
|         } else if (WIFSTOPPED(wstatus)) {
 | |
|             outln("{} was stopped unexpectedly, sending SIGCONT", test_path);
 | |
|             kill(child_pid, SIGCONT);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // Remove the child's stdout from /tmp. This does cause the temp file to be observable
 | |
|     // while the test is executing, but if it hangs that might even be a bonus :)
 | |
|     ret = unlink(child_out_err_path);
 | |
|     VERIFY(ret == 0);
 | |
| 
 | |
|     return FileResult { move(path_for_test), get_time_in_ms() - start_time, test_result, child_out_err_file, child_pid };
 | |
| }
 | |
| 
 | |
| ErrorOr<int> serenity_main(Main::Arguments arguments)
 | |
| {
 | |
| 
 | |
|     auto program_name = LexicalPath::basename(arguments.strings[0]);
 | |
| 
 | |
| #ifdef SIGINFO
 | |
|     TRY(Core::System::signal(SIGINFO, [](int) {
 | |
|         static char buffer[4096];
 | |
|         auto& counts = ::Test::TestRunner::the()->counts();
 | |
|         int len = snprintf(buffer, sizeof(buffer), "Pass: %d, Fail: %d, Skip: %d\nCurrent test: %s\n", counts.tests_passed, counts.tests_failed, counts.tests_skipped, g_currently_running_test.characters());
 | |
|         write(STDOUT_FILENO, buffer, len);
 | |
|     }));
 | |
| #endif
 | |
| 
 | |
|     bool print_progress =
 | |
| #ifdef __serenity__
 | |
|         true; // Use OSC 9 to print progress
 | |
| #else
 | |
|         false;
 | |
| #endif
 | |
|     bool print_json = false;
 | |
|     bool print_all_output = false;
 | |
|     bool run_benchmarks = false;
 | |
|     bool run_skipped_tests = false;
 | |
|     char const* specified_test_root = nullptr;
 | |
|     String test_glob;
 | |
|     String exclude_pattern;
 | |
|     String config_file;
 | |
| 
 | |
|     Core::ArgsParser args_parser;
 | |
|     args_parser.add_option(Core::ArgsParser::Option {
 | |
|         .requires_argument = true,
 | |
|         .help_string = "Show progress with OSC 9 (true, false)",
 | |
|         .long_name = "show-progress",
 | |
|         .short_name = 'p',
 | |
|         .accept_value = [&](auto* str) {
 | |
|             if ("true"sv == str)
 | |
|                 print_progress = true;
 | |
|             else if ("false"sv == str)
 | |
|                 print_progress = false;
 | |
|             else
 | |
|                 return false;
 | |
|             return true;
 | |
|         },
 | |
|     });
 | |
|     args_parser.add_option(print_json, "Show results as JSON", "json", 'j');
 | |
|     args_parser.add_option(print_all_output, "Show all test output", "verbose", 'v');
 | |
|     args_parser.add_option(run_benchmarks, "Run benchmarks as well", "benchmarks", 'b');
 | |
|     args_parser.add_option(run_skipped_tests, "Run all matching tests, even those marked as 'skip'", "all", 'a');
 | |
|     args_parser.add_option(test_glob, "Only run tests matching the given glob", "filter", 'f', "glob");
 | |
|     args_parser.add_option(exclude_pattern, "Regular expression to use to exclude paths from being considered tests", "exclude-pattern", 'e', "pattern");
 | |
|     args_parser.add_option(config_file, "Configuration file to use", "config-file", 'c', "filename");
 | |
|     args_parser.add_positional_argument(specified_test_root, "Tests root directory", "path", Core::ArgsParser::Required::No);
 | |
|     args_parser.parse(arguments);
 | |
| 
 | |
|     test_glob = String::formatted("*{}*", test_glob);
 | |
| 
 | |
|     if (getenv("DISABLE_DBG_OUTPUT")) {
 | |
|         AK::set_debug_enabled(false);
 | |
|     }
 | |
| 
 | |
|     // Make UBSAN deadly for all tests we run by default.
 | |
|     TRY(Core::System::setenv("UBSAN_OPTIONS", "halt_on_error=1", true));
 | |
| 
 | |
|     if (!run_benchmarks)
 | |
|         TRY(Core::System::setenv("TESTS_ONLY", "1", true));
 | |
| 
 | |
|     String test_root;
 | |
| 
 | |
|     if (specified_test_root) {
 | |
|         test_root = String { specified_test_root };
 | |
|     } else {
 | |
|         test_root = "/usr/Tests";
 | |
|     }
 | |
|     if (!Core::File::is_directory(test_root)) {
 | |
|         warnln("Test root is not a directory: {}", test_root);
 | |
|         return 1;
 | |
|     }
 | |
| 
 | |
|     test_root = Core::File::real_path_for(test_root);
 | |
| 
 | |
|     auto void_or_error = Core::System::chdir(test_root);
 | |
|     if (void_or_error.is_error()) {
 | |
|         warnln("chdir failed: {}", void_or_error.error());
 | |
|         return void_or_error.release_error();
 | |
|     }
 | |
| 
 | |
|     auto config_or_error = config_file.is_empty() ? Core::ConfigFile::open_for_app("Tests") : Core::ConfigFile::open(config_file);
 | |
|     if (config_or_error.is_error()) {
 | |
|         warnln("Failed to open configuration file ({}): {}", config_file.is_empty() ? "User config for Tests" : config_file.characters(), config_or_error.error());
 | |
|         return config_or_error.release_error();
 | |
|     }
 | |
|     auto config = config_or_error.release_value();
 | |
| 
 | |
|     if (config->num_groups() == 0)
 | |
|         warnln("Empty configuration file ({}) loaded!", config_file.is_empty() ? "User config for Tests" : config_file.characters());
 | |
| 
 | |
|     if (exclude_pattern.is_empty())
 | |
|         exclude_pattern = config->read_entry("Global", "NotTestsPattern", "$^"); // default is match nothing (aka match end then beginning)
 | |
| 
 | |
|     Regex<PosixExtended> exclude_regex(exclude_pattern, {});
 | |
|     if (exclude_regex.parser_result.error != regex::Error::NoError) {
 | |
|         warnln("Exclude pattern \"{}\" is invalid", exclude_pattern);
 | |
|         return 1;
 | |
|     }
 | |
| 
 | |
|     // we need to preconfigure this, because we can't autoinitialize Regex types
 | |
|     // in the Testrunner
 | |
|     auto skip_regex_pattern = config->read_entry("Global", "SkipRegex", "$^");
 | |
|     Regex<PosixExtended> skip_regex { skip_regex_pattern, {} };
 | |
|     if (skip_regex.parser_result.error != regex::Error::NoError) {
 | |
|         warnln("SkipRegex pattern \"{}\" is invalid", skip_regex_pattern);
 | |
|         return 1;
 | |
|     }
 | |
| 
 | |
|     TestRunner test_runner(test_root, move(exclude_regex), move(config), move(skip_regex), run_skipped_tests, print_progress, print_json, print_all_output);
 | |
|     test_runner.run(test_glob);
 | |
| 
 | |
|     return test_runner.counts().tests_failed;
 | |
| }
 | 
