diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 74dc184700d..72d7d0fa92e 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -6,9 +6,19 @@ find_package(GTest REQUIRED) # Custom target for all tests defined in this file add_custom_target(xrpl.tests) +# Test helpers +add_library(xrpl.helpers.test STATIC) +target_sources(xrpl.helpers.test PRIVATE + helpers/TestSink.cpp +) +target_include_directories(xrpl.helpers.test PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(xrpl.helpers.test PRIVATE xrpl.libxrpl) + # Common library dependencies for the rest of the tests. add_library(xrpl.imports.test INTERFACE) -target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl) +target_link_libraries(xrpl.imports.test INTERFACE gtest::gtest xrpl.libxrpl xrpl.helpers.test) # One test for each module. xrpl_add_test(basics) diff --git a/src/tests/libxrpl/helpers/TestSink.cpp b/src/tests/libxrpl/helpers/TestSink.cpp new file mode 100644 index 00000000000..a71e9f7cc0b --- /dev/null +++ b/src/tests/libxrpl/helpers/TestSink.cpp @@ -0,0 +1,127 @@ +#include + +#include + +#include // for getenv + +#if BOOST_OS_WINDOWS +#include // for _isatty, _fileno +#include // for stdout +#else +#include // for isatty, STDOUT_FILENO +#endif + +#include + +namespace xrpl { + +TestSink::TestSink(beast::severities::Severity threshold) + : Sink(threshold, false) +{ +} + +void +TestSink::write(beast::severities::Severity level, std::string const& text) +{ + if (level < threshold()) + return; + writeAlways(level, text); +} + +void +TestSink::writeAlways( + beast::severities::Severity level, + std::string const& text) +{ + auto supportsColor = [] { + // 1. Check for "NO_COLOR" environment variable (Standard convention) + if (std::getenv("NO_COLOR") != nullptr) + { + return false; + } + + // 2. Check for "CLICOLOR_FORCE" (Force color) + if (std::getenv("CLICOLOR_FORCE") != nullptr) + { + return true; + } + + // 3. Platform-specific check to see if stdout is a terminal +#if BOOST_OS_WINDOWS + // Windows: Check if the output handle is a character device + // _fileno(stdout) is usually 1 + // _isatty returns non-zero if the handle is a character device, 0 + // otherwise. + return _isatty(_fileno(stdout)) != 0; +#else + // Linux/macOS: Check if file descriptor 1 (stdout) is a TTY + // STDOUT_FILENO is 1 + // isatty returns 1 if the file descriptor is a TTY, 0 otherwise. + return isatty(STDOUT_FILENO) != 0; +#endif + }(); + + auto color = [level]() { + switch (level) + { + case beast::severities::kTrace: + return "\033[34m"; // blue + case beast::severities::kDebug: + return "\033[32m"; // green + case beast::severities::kInfo: + return "\033[36m"; // cyan + case beast::severities::kWarning: + return "\033[33m"; // yellow + case beast::severities::kError: + return "\033[31m"; // red + case beast::severities::kFatal: + default: + break; + } + return "\033[31m"; // red + }(); + + auto prefix = [level]() { + switch (level) + { + case beast::severities::kTrace: + return "TRC:"; + case beast::severities::kDebug: + return "DBG:"; + case beast::severities::kInfo: + return "INF:"; + case beast::severities::kWarning: + return "WRN:"; + case beast::severities::kError: + return "ERR:"; + case beast::severities::kFatal: + default: + break; + } + return "FTL:"; + }(); + + auto& stream = [level]() -> std::ostream& { + switch (level) + { + case beast::severities::kError: + case beast::severities::kFatal: + return std::cerr; + default: + return std::cout; + } + }(); + + constexpr auto reset = "\033[0m"; + + if (supportsColor) + { + stream << color << prefix << " " << text << reset << std::endl; + } + else + { + stream << prefix << " " << text << std::endl; + } +} + +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TestSink.h b/src/tests/libxrpl/helpers/TestSink.h new file mode 100644 index 00000000000..fc3223b04b5 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestSink.h @@ -0,0 +1,27 @@ +#ifndef XRPL_DEBUGSINK_H +#define XRPL_DEBUGSINK_H + +#include + +namespace xrpl { +class TestSink : public beast::Journal::Sink +{ +public: + static TestSink& + instance() + { + static TestSink sink{}; + return sink; + } + + TestSink(beast::severities::Severity threshold = beast::severities::kDebug); + + void + write(beast::severities::Severity level, std::string const& text) override; + + void + writeAlways(beast::severities::Severity level, std::string const& text) + override; +}; +} // namespace xrpl +#endif // XRPL_DEBUGSINK_H diff --git a/src/tests/libxrpl/net/HTTPClient.cpp b/src/tests/libxrpl/net/HTTPClient.cpp index cfd206edded..5ff0bfc3365 100644 --- a/src/tests/libxrpl/net/HTTPClient.cpp +++ b/src/tests/libxrpl/net/HTTPClient.cpp @@ -2,15 +2,22 @@ #include #include +#include +#include #include +#include +#include #include #include #include #include +#include #include #include +#include +#include #include using namespace xrpl; @@ -24,16 +31,19 @@ class TestHTTPServer boost::asio::io_context ioc_; boost::asio::ip::tcp::acceptor acceptor_; boost::asio::ip::tcp::endpoint endpoint_; - std::atomic running_{true}; + bool running_{true}; + bool finished_{false}; unsigned short port_; // Custom headers to return - std::map custom_headers_; - std::string response_body_; - unsigned int status_code_{200}; + std::map customHeaders_; + std::string responseBody_; + unsigned int statusCode_{200}; + + beast::Journal j_; public: - TestHTTPServer() : acceptor_(ioc_), port_(0) + TestHTTPServer() : acceptor_(ioc_), port_(0), j_(TestSink::instance()) { // Bind to any available port endpoint_ = {boost::asio::ip::tcp::v4(), 0}; @@ -45,12 +55,19 @@ class TestHTTPServer // Get the actual port that was assigned port_ = acceptor_.local_endpoint().port(); - accept(); + // Start the accept coroutine + boost::asio::co_spawn(ioc_, accept(), boost::asio::detached); } + TestHTTPServer(TestHTTPServer&&) = delete; + TestHTTPServer& + operator=(TestHTTPServer&&) = delete; + ~TestHTTPServer() { - stop(); + XRPL_ASSERT( + finished(), + "xrpl::TestHTTPServer::~TestHTTPServer : accept future ready"); } boost::asio::io_context& @@ -68,22 +85,21 @@ class TestHTTPServer void setHeader(std::string const& name, std::string const& value) { - custom_headers_[name] = value; + customHeaders_[name] = value; } void setResponseBody(std::string const& body) { - response_body_ = body; + responseBody_ = body; } void setStatusCode(unsigned int code) { - status_code_ = code; + statusCode_ = code; } -private: void stop() { @@ -91,78 +107,84 @@ class TestHTTPServer acceptor_.close(); } - void + bool + finished() const + { + return finished_; + } + +private: + boost::asio::awaitable accept() { - if (!running_) - return; - - acceptor_.async_accept( - ioc_, - endpoint_, - [&](boost::system::error_code const& error, - boost::asio::ip::tcp::socket peer) { + while (running_) + { + try + { + auto socket = + co_await acceptor_.async_accept(boost::asio::use_awaitable); + if (!running_) - return; + break; - if (!error) - { - handleConnection(std::move(peer)); - } - }); + // Handle this connection + co_await handleConnection(std::move(socket)); + } + catch (std::exception const& e) + { + // Accept or handle failed, stop accepting + JLOG(j_.debug()) << "Error: " << e.what(); + break; + } + } + + finished_ = true; } - void + boost::asio::awaitable handleConnection(boost::asio::ip::tcp::socket socket) { try { - // Read the HTTP request boost::beast::flat_buffer buffer; boost::beast::http::request req; - boost::beast::http::read(socket, buffer, req); + + // Read the HTTP request asynchronously + co_await boost::beast::http::async_read( + socket, buffer, req, boost::asio::use_awaitable); // Create response boost::beast::http::response res; res.version(req.version()); - res.result(status_code_); + res.result(statusCode_); res.set(boost::beast::http::field::server, "TestServer"); - // Add custom headers - for (auto const& [name, value] : custom_headers_) - { - res.set(name, value); - } - // Set body and prepare payload first - res.body() = response_body_; + res.body() = responseBody_; res.prepare_payload(); - // Override Content-Length with custom headers after prepare_payload - // This allows us to test case-insensitive header parsing - for (auto const& [name, value] : custom_headers_) + // Override Content-Length with custom headers after + // prepare_payload. This allows us to test case-insensitive + // header parsing. + for (auto const& [name, value] : customHeaders_) { - if (boost::iequals(name, "Content-Length")) - { - res.erase(boost::beast::http::field::content_length); - res.set(name, value); - } + res.set(name, value); } - // Send response - boost::beast::http::write(socket, res); + // Send response asynchronously + co_await boost::beast::http::async_write( + socket, res, boost::asio::use_awaitable); // Shutdown socket gracefully - boost::system::error_code ec; - socket.shutdown(boost::asio::ip::tcp::socket::shutdown_send, ec); + boost::system::error_code shutdownEc; + socket.shutdown( + boost::asio::ip::tcp::socket::shutdown_send, shutdownEc); } - catch (std::exception const&) + catch (std::exception const& e) { - // Connection handling errors are expected + // Error reading or writing, just close the connection + JLOG(j_.debug()) << "Connection error: " << e.what(); } - - if (running_) - accept(); } }; @@ -171,13 +193,13 @@ bool runHTTPTest( TestHTTPServer& server, std::string const& path, - std::atomic& completed, - std::atomic& result_status, - std::string& result_data, - boost::system::error_code& result_error) + bool& completed, + int& resultStatus, + std::string& resultData, + boost::system::error_code& resultError) { // Create a null journal for testing - beast::Journal j{beast::Journal::getNullSink()}; + beast::Journal j{TestSink::instance()}; // Initialize HTTPClient SSL context HTTPClient::initializeSSLContext("", "", false, j); @@ -193,9 +215,9 @@ runHTTPTest( [&](boost::system::error_code const& ec, int status, std::string const& data) -> bool { - result_error = ec; - result_status = status; - result_data = data; + resultError = ec; + resultStatus = status; + resultData = data; completed = true; return false; // don't retry }, @@ -203,13 +225,19 @@ runHTTPTest( // Run the IO context until completion auto start = std::chrono::steady_clock::now(); - while (!completed && - std::chrono::steady_clock::now() - start < std::chrono::seconds(10)) + while (server.ioc().run_one() != 0) { - if (server.ioc().run_one() == 0) + if (std::chrono::steady_clock::now() - start >= + std::chrono::seconds(10) || + server.finished()) { break; } + + if (completed) + { + server.stop(); + } } return completed; @@ -220,7 +248,7 @@ runHTTPTest( TEST(HTTPClient, case_insensitive_content_length) { // Test different cases of Content-Length header - std::vector header_cases = { + std::vector headerCases = { "Content-Length", // Standard case "content-length", // Lowercase - this tests the regex icase fix "CONTENT-LENGTH", // Uppercase @@ -228,53 +256,48 @@ TEST(HTTPClient, case_insensitive_content_length) "content-Length" // Mixed case 2 }; - for (auto const& header_name : header_cases) + for (auto const& headerName : headerCases) { TestHTTPServer server; - std::string test_body = "Hello World!"; - server.setResponseBody(test_body); - server.setHeader(header_name, std::to_string(test_body.size())); + std::string testBody = "Hello World!"; + server.setResponseBody(testBody); + server.setHeader(headerName, std::to_string(testBody.size())); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, - "/test", - completed, - result_status, - result_data, - result_error); + bool testCompleted = runHTTPTest( + server, "/test", completed, resultStatus, resultData, resultError); // Verify results - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_EQ(result_data, test_body); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_EQ(resultData, testBody); } } TEST(HTTPClient, basic_http_request) { TestHTTPServer server; - std::string test_body = "Test response body"; - server.setResponseBody(test_body); + std::string testBody = "Test response body"; + server.setResponseBody(testBody); server.setHeader("Content-Type", "text/plain"); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, "/basic", completed, result_status, result_data, result_error); + bool testCompleted = runHTTPTest( + server, "/basic", completed, resultStatus, resultData, resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_EQ(result_data, test_body); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_EQ(resultData, testBody); } TEST(HTTPClient, empty_response) @@ -283,45 +306,45 @@ TEST(HTTPClient, empty_response) server.setResponseBody(""); // Empty body server.setHeader("Content-Length", "0"); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( - server, "/empty", completed, result_status, result_data, result_error); + bool testCompleted = runHTTPTest( + server, "/empty", completed, resultStatus, resultData, resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, 200); - EXPECT_TRUE(result_data.empty()); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, 200); + EXPECT_TRUE(resultData.empty()); } TEST(HTTPClient, different_status_codes) { - std::vector status_codes = {200, 404, 500}; + std::vector statusCodes = {200, 404, 500}; - for (auto status : status_codes) + for (auto status : statusCodes) { TestHTTPServer server; server.setStatusCode(status); server.setResponseBody("Status " + std::to_string(status)); - std::atomic completed{false}; - std::atomic result_status{0}; - std::string result_data; - boost::system::error_code result_error; + bool completed{false}; + int resultStatus{0}; + std::string resultData; + boost::system::error_code resultError; - bool test_completed = runHTTPTest( + bool testCompleted = runHTTPTest( server, "/status", completed, - result_status, - result_data, - result_error); + resultStatus, + resultData, + resultError); - EXPECT_TRUE(test_completed); - EXPECT_FALSE(result_error); - EXPECT_EQ(result_status, static_cast(status)); + EXPECT_TRUE(testCompleted); + EXPECT_FALSE(resultError); + EXPECT_EQ(resultStatus, static_cast(status)); } }