From 6945119a252da7eae9bc863c1c7c4d632a88e19f Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 16:41:38 -0700 Subject: [PATCH 1/2] Tests: serve the network tests from a loopback HTTP server The XMLHttpRequest and fetch suites fetched https://github.com/ and a mesh from raw.githubusercontent.com to check status codes, statusText, percent-encoding of non-ASCII paths and abort-in-flight. Any hiccup on those hosts (or a runner without outbound network) failed the run, as the 504s on the recent upstream reruns showed. The test host now starts a small HTTP/1.1 server on 127.0.0.1 with an ephemeral port and publishes its origin to the scripts as the `hostTestServer` global. It answers "/" with 200, unknown paths with 404 "Not Found", "/assets/" with 200 only when the request-target decodes to the expected UTF-8 file name (so the client's own encoding is what gets checked), and "/delay/" after a delay so the abort tests are guaranteed to abort a request that is still in flight. The connection-refused tests keep using 127.0.0.1:1. Plain sockets on every platform (Winsock on Windows, poll-driven accept loop, MSG_NOSIGNAL/SO_NOSIGPIPE for clients that go away mid-request); the Android test library links it too. --- .../Android/app/src/main/cpp/CMakeLists.txt | 3 +- Tests/UnitTests/CMakeLists.txt | 4 +- Tests/UnitTests/Scripts/tests.ts | 49 ++- Tests/UnitTests/Shared/Shared.cpp | 7 +- Tests/UnitTests/Shared/TestHttpServer.cpp | 355 ++++++++++++++++++ Tests/UnitTests/Shared/TestHttpServer.h | 36 ++ 6 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 Tests/UnitTests/Shared/TestHttpServer.cpp create mode 100644 Tests/UnitTests/Shared/TestHttpServer.h diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt index 2e31e0fb..cc1d2f38 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt @@ -22,7 +22,8 @@ add_library(UnitTestsJNI SHARED ${UNIT_TESTS_DIR}/Shared/StandardStreamLogger.cpp ${UNIT_TESTS_DIR}/Shared/TimeoutDispatcher.cpp ${UNIT_TESTS_DIR}/Shared/Shared.h - ${UNIT_TESTS_DIR}/Shared/Shared.cpp) + ${UNIT_TESTS_DIR}/Shared/Shared.cpp + ${UNIT_TESTS_DIR}/Shared/TestHttpServer.cpp) if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Shared/V8ForegroundTaskRunner.cpp) diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index f3676d7d..f77cdf2c 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -12,7 +12,9 @@ set(SOURCES "Shared/StandardStreamLogger.cpp" "Shared/TimeoutDispatcher.cpp" "Shared/Shared.cpp" - "Shared/Shared.h") + "Shared/Shared.h" + "Shared/TestHttpServer.cpp" + "Shared/TestHttpServer.h") if(APPLE) if(IOS) diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index 654c60b6..f28a4e82 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -7,6 +7,21 @@ Mocha.reporter('spec'); declare const hostPlatform: string; declare const hostEngine: string; +declare const hostTestServer: string; + +// Loopback HTTP server the host starts for this run (Shared/TestHttpServer.h); the network tests use +// it instead of public hosts so they never depend on github.com being reachable from the runner. +const testServer = { + ok: `${hostTestServer}/`, + notFound: `${hostTestServer}/babylonJS/BabylonNative404`, + // The same file name, "στρογγυλεμένος % κύβος.glb", in three spellings the client must all put on + // the wire as one correctly percent-encoded request-target. + unicodeAssetEscaped: `${hostTestServer}/assets/%CF%83%CF%84%CF%81%CE%BF%CE%B3%CE%B3%CF%85%CE%BB%CE%B5%CE%BC%CE%AD%CE%BD%CE%BF%CF%82%20%25%20%CE%BA%CF%8D%CE%B2%CE%BF%CF%82.glb`, + unicodeAssetUnescaped: `${hostTestServer}/assets/στρογγυλεμένος%20%25%20κύβος.glb`, + unicodeAssetUnescapedWithSpaces: `${hostTestServer}/assets/στρογγυλεμένος %25 κύβος.glb`, + // Responds after two seconds, so an abort issued right after send() is guaranteed to land in-flight. + slow: `${hostTestServer}/delay/2000`, +}; declare const setExitCode: (code: number) => void; @@ -124,39 +139,39 @@ describe("XMLHTTPRequest", function () { this.timeout(0); it("should have readyState=4 when load ends", async function () { - const xhr = await createRequest("GET", "https://github.com/"); + const xhr = await createRequest("GET", testServer.ok); expect(xhr.readyState).to.equal(4); }); it("should have status=200 for a file that exists", async function () { - const xhr = await createRequest("GET", "https://github.com/"); + const xhr = await createRequest("GET", testServer.ok); expect(xhr.status).to.equal(200); }); it("should load URLs with escaped unicode characters", async function () { - const xhr = await createRequest("GET", "https://raw.githubusercontent.com/BabylonJS/Assets/master/meshes/%CF%83%CF%84%CF%81%CE%BF%CE%B3%CE%B3%CF%85%CE%BB%CE%B5%CE%BC%CE%AD%CE%BD%CE%BF%CF%82%20%25%20%CE%BA%CF%8D%CE%B2%CE%BF%CF%82.glb"); + const xhr = await createRequest("GET", testServer.unicodeAssetEscaped); expect(xhr.status).to.equal(200); }); it("should load URLs with unescaped unicode characters", async function () { - const xhr = await createRequest("GET", "https://raw.githubusercontent.com/BabylonJS/Assets/master/meshes/στρογγυλεμένος%20%25%20κύβος.glb"); + const xhr = await createRequest("GET", testServer.unicodeAssetUnescaped); expect(xhr.status).to.equal(200); }); it("should load URLs with unescaped unicode characters and spaces", async function () { - const xhr = await createRequest("GET", "https://raw.githubusercontent.com/BabylonJS/Assets/master/meshes/στρογγυλεμένος %25 κύβος.glb"); + const xhr = await createRequest("GET", testServer.unicodeAssetUnescapedWithSpaces); expect(xhr.status).to.equal(200); }); it("should have status=404 for a file that does not exist", async function () { - const xhr = await createRequest("GET", "https://github.com/babylonJS/BabylonNative404"); + const xhr = await createRequest("GET", testServer.notFound); expect(xhr.status).to.equal(404); }); it("should expose statusText", async function () { - const okXhr = await createRequest("GET", "https://github.com/"); + const okXhr = await createRequest("GET", testServer.ok); expect(okXhr.statusText).to.equal("OK"); - const notFoundXhr = await createRequest("GET", "https://github.com/babylonJS/BabylonNative404"); + const notFoundXhr = await createRequest("GET", testServer.notFound); expect(notFoundXhr.statusText).to.equal("Not Found"); }); @@ -176,7 +191,7 @@ describe("XMLHTTPRequest", function () { clearTimeout(guard); resolve({ errorFired, loadendFired, status: xhr.status, readyState: xhr.readyState }); }); - xhr.open("GET", "https://github.com/babylonJS/BabylonNative404"); + xhr.open("GET", testServer.notFound); xhr.send(); }); expect(result.status).to.equal(404); @@ -306,21 +321,21 @@ describe("fetch", function () { this.timeout(30000); it("should resolve with ok=true and status=200 for a resource that exists", async function () { - const response = await fetch("https://github.com/"); + const response = await fetch(testServer.ok); expect(response.ok).to.equal(true); expect(response.status).to.equal(200); }); it("should resolve (not reject) with ok=false and status=404 for a resource that does not exist", async function () { - const response = await fetch("https://github.com/babylonJS/BabylonNative404"); + const response = await fetch(testServer.notFound); expect(response.ok).to.equal(false); expect(response.status).to.equal(404); }); it("should expose statusText", async function () { - const okResponse = await fetch("https://github.com/"); + const okResponse = await fetch(testServer.ok); expect(okResponse.statusText).to.equal("OK"); - const notFoundResponse = await fetch("https://github.com/babylonJS/BabylonNative404"); + const notFoundResponse = await fetch(testServer.notFound); expect(notFoundResponse.statusText).to.equal("Not Found"); }); @@ -362,7 +377,7 @@ describe("fetch", function () { }); it("headers.get() should be case-insensitive and headers.has() should work", async function () { - const response = await fetch("https://github.com/"); + const response = await fetch(testServer.ok); expect(response.headers.has("Content-Type")).to.equal(true); expect(response.headers.get("CONTENT-TYPE")).to.equal(response.headers.get("content-type")); }); @@ -375,7 +390,7 @@ describe("fetch", function () { }); it("should accept a method in the init object", async function () { - const response = await fetch("https://github.com/", { method: "GET" }); + const response = await fetch(testServer.ok, { method: "GET" }); expect(response.status).to.equal(200); }); @@ -436,7 +451,7 @@ describe("fetch", function () { let error: any; try { - await fetch("https://github.com/", { signal: controller.signal } as any); + await fetch(testServer.ok, { signal: controller.signal } as any); } catch (e) { error = e; } @@ -447,7 +462,7 @@ describe("fetch", function () { it("should reject with an AbortError when aborted in-flight", async function () { this.timeout(30000); const controller = new AbortController(); - const promise = fetch("https://github.com/", { signal: controller.signal } as any); + const promise = fetch(testServer.slow, { signal: controller.signal } as any); // Abort before the response can arrive. controller.abort(); diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Shared/Shared.cpp index 1c7e9ff7..cdfc2da4 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Shared/Shared.cpp @@ -1,4 +1,5 @@ #include "Shared.h" +#include "TestHttpServer.h" #include #include #include @@ -62,9 +63,12 @@ TEST(JavaScript, All) options.WaitForDebugger = true; } + // The network tests talk to this loopback server (see TestHttpServer.h) rather than to public hosts. + Babylon::Test::TestHttpServer testServer{}; + Babylon::AppRuntime runtime{options}; - runtime.Dispatch([&exitCodePromise](Napi::Env env) mutable { + runtime.Dispatch([&exitCodePromise, &testServer](Napi::Env env) mutable { Babylon::Polyfills::Console::Initialize(env, [env](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { std::cout << "[" << EnumToString(logLevel) << "] " << message; if (logLevel == Babylon::Polyfills::Console::LogLevel::Error) @@ -101,6 +105,7 @@ TEST(JavaScript, All) env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); env.Global().Set("hostEngine", Napi::Value::From(env, NAPI_JAVASCRIPT_ENGINE)); + env.Global().Set("hostTestServer", Napi::Value::From(env, testServer.Origin())); }); Babylon::ScriptLoader loader{runtime}; diff --git a/Tests/UnitTests/Shared/TestHttpServer.cpp b/Tests/UnitTests/Shared/TestHttpServer.cpp new file mode 100644 index 00000000..4f534f1b --- /dev/null +++ b/Tests/UnitTests/Shared/TestHttpServer.cpp @@ -0,0 +1,355 @@ +#include "TestHttpServer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +namespace Babylon::Test +{ + namespace + { +#ifdef _WIN32 + using Socket = SOCKET; + constexpr Socket InvalidSocket = INVALID_SOCKET; + constexpr int SendFlags = 0; + + void CloseSocket(Socket socket) + { + closesocket(socket); + } + + int WaitReadable(Socket socket, int timeoutMilliseconds) + { + WSAPOLLFD descriptor{socket, POLLRDNORM, 0}; + return WSAPoll(&descriptor, 1, timeoutMilliseconds); + } +#else + using Socket = int; + constexpr Socket InvalidSocket = -1; +#ifdef MSG_NOSIGNAL + // A client that aborted mid-request has closed its end; writing to it must not raise SIGPIPE. + constexpr int SendFlags = MSG_NOSIGNAL; +#else + constexpr int SendFlags = 0; +#endif + + void CloseSocket(Socket socket) + { + ::close(socket); + } + + int WaitReadable(Socket socket, int timeoutMilliseconds) + { + pollfd descriptor{socket, POLLIN, 0}; + return ::poll(&descriptor, 1, timeoutMilliseconds); + } +#endif + + // How long a connected client may stay silent before the connection is dropped. + constexpr int ClientTimeoutMilliseconds = 5000; + + std::string PercentDecode(const std::string& text) + { + const auto hexValue = [](char character) -> int { + if (character >= '0' && character <= '9') + { + return character - '0'; + } + if (character >= 'a' && character <= 'f') + { + return character - 'a' + 10; + } + if (character >= 'A' && character <= 'F') + { + return character - 'A' + 10; + } + return -1; + }; + + std::string result; + result.reserve(text.size()); + for (size_t index = 0; index < text.size(); ++index) + { + if (text[index] == '%' && index + 2 < text.size()) + { + const int high = hexValue(text[index + 1]); + const int low = hexValue(text[index + 2]); + if (high >= 0 && low >= 0) + { + result.push_back(static_cast(high * 16 + low)); + index += 2; + continue; + } + } + result.push_back(text[index]); + } + return result; + } + + // The one asset the encoding tests request: "στρογγυλεμένος % κύβος.glb", spelled here in its + // fully escaped form so the file's own encoding never matters. + const std::string& ExpectedAssetPath() + { + static const std::string path = + "/assets/" + PercentDecode("%CF%83%CF%84%CF%81%CE%BF%CE%B3%CE%B3%CF%85%CE%BB%CE%B5%CE%BC%CE%AD%CE%BD%CE%BF%CF%82" + "%20%25%20%CE%BA%CF%8D%CE%B2%CE%BF%CF%82.glb"); + return path; + } + + std::string ToLower(std::string text) + { + for (auto& character : text) + { + if (character >= 'A' && character <= 'Z') + { + character = static_cast(character - 'A' + 'a'); + } + } + return text; + } + + // Reads the request head into `head` and drains the body announced by Content-Length, so the + // peer never sees a reset before it has finished sending. Returns false when the client goes + // away or stays silent. + bool ReadRequest(Socket client, std::string& head) + { + std::string buffer; + char chunk[4096]; + size_t headEnd = std::string::npos; + while (headEnd == std::string::npos) + { + if (buffer.size() > 64 * 1024 || WaitReadable(client, ClientTimeoutMilliseconds) <= 0) + { + return false; + } + const auto received = ::recv(client, chunk, static_cast(sizeof(chunk)), 0); + if (received <= 0) + { + return false; + } + buffer.append(chunk, static_cast(received)); + headEnd = buffer.find("\r\n\r\n"); + } + head = buffer.substr(0, headEnd); + + size_t contentLength = 0; + const std::string loweredHead = ToLower(head); + const auto lengthHeader = loweredHead.find("\r\ncontent-length:"); + if (lengthHeader != std::string::npos) + { + contentLength = static_cast(std::strtoull(loweredHead.c_str() + lengthHeader + 17, nullptr, 10)); + } + size_t bodyReceived = buffer.size() - (headEnd + 4); + while (bodyReceived < contentLength) + { + if (WaitReadable(client, ClientTimeoutMilliseconds) <= 0) + { + return false; + } + const auto received = ::recv(client, chunk, static_cast(sizeof(chunk)), 0); + if (received <= 0) + { + return false; + } + bodyReceived += static_cast(received); + } + return true; + } + + struct Response + { + int Status; + const char* Reason; + std::string Body; + }; + + Response Route(const std::string& target, const std::atomic& stopping) + { + std::string path = target; + const auto query = path.find('?'); + if (query != std::string::npos) + { + path.erase(query); + } + path = PercentDecode(path); + + if (path == "/") + { + return {200, "OK", "OK"}; + } + if (path == ExpectedAssetPath()) + { + return {200, "OK", "glTF"}; + } + const std::string delayPrefix = "/delay/"; + if (path.compare(0, delayPrefix.size(), delayPrefix) == 0) + { + const auto milliseconds = std::strtol(path.c_str() + delayPrefix.size(), nullptr, 10); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds{milliseconds}; + while (!stopping && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds{20}); + } + return {200, "OK", "OK"}; + } + return {404, "Not Found", "Not Found"}; + } + + void SendAll(Socket client, const std::string& data) + { + size_t sent = 0; + while (sent < data.size()) + { + const auto written = ::send(client, data.data() + sent, static_cast(data.size() - sent), SendFlags); + if (written <= 0) + { + return; + } + sent += static_cast(written); + } + } + } + + struct TestHttpServer::Impl + { + Impl() + { +#ifdef _WIN32 + WSADATA wsaData{}; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) + { + throw std::runtime_error{"TestHttpServer: WSAStartup failed"}; + } +#endif + m_listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (m_listener == InvalidSocket) + { + throw std::runtime_error{"TestHttpServer: socket() failed"}; + } + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; // ephemeral + if (::bind(m_listener, reinterpret_cast(&address), sizeof(address)) != 0 || ::listen(m_listener, 16) != 0) + { + CloseSocket(m_listener); + throw std::runtime_error{"TestHttpServer: bind/listen on 127.0.0.1 failed"}; + } + + sockaddr_in bound{}; + socklen_t boundLength = sizeof(bound); + if (::getsockname(m_listener, reinterpret_cast(&bound), &boundLength) != 0) + { + CloseSocket(m_listener); + throw std::runtime_error{"TestHttpServer: getsockname failed"}; + } + Origin = "http://127.0.0.1:" + std::to_string(ntohs(bound.sin_port)); + + m_acceptThread = std::thread{[this] { AcceptLoop(); }}; + } + + ~Impl() + { + m_stopping = true; + if (m_acceptThread.joinable()) + { + m_acceptThread.join(); + } + for (auto& worker : m_workers) + { + worker.join(); + } + CloseSocket(m_listener); +#ifdef _WIN32 + WSACleanup(); +#endif + } + + std::string Origin; + + private: + void AcceptLoop() + { + while (!m_stopping) + { + if (WaitReadable(m_listener, 100) <= 0) + { + continue; + } + const Socket client = ::accept(m_listener, nullptr, nullptr); + if (client == InvalidSocket) + { + continue; + } + std::lock_guard lock{m_workersMutex}; + m_workers.emplace_back([this, client] { Serve(client); }); + } + } + + void Serve(Socket client) + { +#ifdef SO_NOSIGPIPE + const int one = 1; + ::setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); +#endif + std::string head; + if (ReadRequest(client, head)) + { + // request-line = method SP request-target SP HTTP-version + const auto methodEnd = head.find(' '); + const auto targetEnd = methodEnd == std::string::npos ? std::string::npos : head.find(' ', methodEnd + 1); + Response response = targetEnd == std::string::npos + ? Response{400, "Bad Request", "Bad Request"} + : Route(head.substr(methodEnd + 1, targetEnd - methodEnd - 1), m_stopping); + std::string message = "HTTP/1.1 " + std::to_string(response.Status) + " " + response.Reason + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Length: " + std::to_string(response.Body.size()) + "\r\n" + + "Connection: close\r\n\r\n"; + if (head.compare(0, 5, "HEAD ") != 0) + { + message += response.Body; + } + SendAll(client, message); + } + CloseSocket(client); + } + + Socket m_listener{InvalidSocket}; + std::atomic m_stopping{false}; + std::thread m_acceptThread; + std::mutex m_workersMutex; + std::vector m_workers; + }; + + TestHttpServer::TestHttpServer() + : m_impl{std::make_unique()} + { + } + + TestHttpServer::~TestHttpServer() = default; + + const std::string& TestHttpServer::Origin() const + { + return m_impl->Origin; + } +} diff --git a/Tests/UnitTests/Shared/TestHttpServer.h b/Tests/UnitTests/Shared/TestHttpServer.h new file mode 100644 index 00000000..20d87440 --- /dev/null +++ b/Tests/UnitTests/Shared/TestHttpServer.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace Babylon::Test +{ + // A loopback HTTP/1.1 server the JavaScript network tests (XMLHttpRequest, fetch) talk to + // instead of public hosts, so a run never depends on github.com answering -- or on the CI + // runner having outbound network at all. The request-target is percent-decoded before routing: + // + // / 200 "OK" + // /assets/ 200 when decodes to the expected UTF-8 file name (so the + // client's own percent-encoding is what gets exercised), else 404 + // /delay/ 200 after the delay, for the abort-in-flight tests + // anything else 404 "Not Found" + // + // Every response is text/plain with Connection: close. The port is ephemeral; scripts reach the + // server through the `hostTestServer` global the host sets to Origin(). + class TestHttpServer + { + public: + TestHttpServer(); + ~TestHttpServer(); + + TestHttpServer(const TestHttpServer&) = delete; + TestHttpServer& operator=(const TestHttpServer&) = delete; + + // "http://127.0.0.1:", no trailing slash. + const std::string& Origin() const; + + private: + struct Impl; + std::unique_ptr m_impl; + }; +} From 2d32a1d5a0d772cf664e9158eeed99ded7ea094d Mon Sep 17 00:00:00 2001 From: Matt Hargett Date: Wed, 16 Sep 2026 17:02:31 -0700 Subject: [PATCH 2/2] Tests (Android): allow cleartext HTTP so the app can reach its own loopback test server --- Tests/UnitTests/Android/app/src/main/AndroidManifest.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTests/Android/app/src/main/AndroidManifest.xml b/Tests/UnitTests/Android/app/src/main/AndroidManifest.xml index c3537fae..28f114d3 100644 --- a/Tests/UnitTests/Android/app/src/main/AndroidManifest.xml +++ b/Tests/UnitTests/Android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + +