From b96e90a0901defe6145442ced148d423335a0ec4 Mon Sep 17 00:00:00 2001 From: Cedric Guillemet <1312968+CedricGuillemet@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:06:06 +0200 Subject: [PATCH 1/4] Use UrlLib transport fixes branch Track the branch for BabylonJS/UrlLib#39 while the transport fixes and shared data URL support are under review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 001e7798-8d3b-44be-8a99-5e8c1541297e --- CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a195c1eb..e9c1f19a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,7 +49,8 @@ FetchContent_Declare(llhttp EXCLUDE_FROM_ALL) FetchContent_Declare(UrlLib GIT_REPOSITORY https://github.com/BabylonJS/UrlLib.git - GIT_TAG 0c991337a1160ba7a2d062bf8e342d0a66f48dc9 + # Track https://github.com/BabylonJS/UrlLib/pull/39 while it is under review. + GIT_TAG cedricguillemet-urllib-transport-fixes EXCLUDE_FROM_ALL) FetchContent_Declare(quickjs-ng GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git From a6c6fb888577c1d912a56bb9fa957dd8ccd11490 Mon Sep 17 00:00:00 2001 From: Cedric Guillemet <1312968+CedricGuillemet@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:57:57 +0200 Subject: [PATCH 2/4] Add UrlLib consumer transport regressions Exercise Fetch and XMLHttpRequest against an in-process loopback server and shared data URLs. Cover POST headers and byte preservation, missing response Content-Type, malformed data input, cancellation, and request reuse. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 001e7798-8d3b-44be-8a99-5e8c1541297e --- Tests/README.md | 66 ++++ Tests/UnitTests/CMakeLists.txt | 5 + .../Android/app/src/main/AndroidManifest.xml | 2 +- .../Android/app/src/main/cpp/CMakeLists.txt | 2 + Tests/UnitTests/Source/HttpTestServer.cpp | 347 ++++++++++++++++++ Tests/UnitTests/Source/HttpTestServer.h | 18 + Tests/UnitTests/Source/Scripts/globals.d.ts | 6 + .../Source/Scripts/httpTestHelpers.ts | 46 +++ .../UnitTests/Source/Scripts/tests.dataUrl.ts | 177 +++++++++ .../Source/Scripts/tests.httpTransport.ts | 199 ++++++++++ .../Source/Scripts/tests.javaScript.all.ts | 12 +- Tests/UnitTests/Source/Tests.JavaScript.cpp | 11 +- Tests/UnitTests/Source/iOS/Info.plist | 5 + 13 files changed, 892 insertions(+), 4 deletions(-) create mode 100644 Tests/README.md create mode 100644 Tests/UnitTests/Source/HttpTestServer.cpp create mode 100644 Tests/UnitTests/Source/HttpTestServer.h create mode 100644 Tests/UnitTests/Source/Scripts/httpTestHelpers.ts create mode 100644 Tests/UnitTests/Source/Scripts/tests.dataUrl.ts create mode 100644 Tests/UnitTests/Source/Scripts/tests.httpTransport.ts diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 00000000..9207ae9c --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,66 @@ +# Unit tests + +Build and run the Windows tests from the repository root: + +```powershell +cmake -S . -B Build\QuickJS -A x64 -D NAPI_JAVASCRIPT_ENGINE=QuickJS +cmake --build Build\QuickJS --config RelWithDebInfo --target UnitTests -- /m +Set-Location Build\QuickJS\Tests\UnitTests\RelWithDebInfo +.\UnitTests.exe +``` + +Use `NAPI_JAVASCRIPT_ENGINE=V8` and a separate build directory to run with V8. +The executable must run from its output directory so `app:///Assets/` resolves +the test assets. + +## UrlLib consumer regressions + +The `UrlLib HTTP transport` and `UrlLib data URLs` JavaScript suites exercise +the real Fetch and XMLHttpRequest polyfills against the configured UrlLib +dependency. They cover the fixes in +[BabylonJS/UrlLib#39](https://github.com/BabylonJS/UrlLib/pull/39). + +The HTTP fixture runs inside the test process on an ephemeral IPv4 loopback +port. It echoes the received POST bytes and exposes the actual request method, +Content-Type, and body length in diagnostic response headers. It also serves +responses without Content-Type, including empty/204 responses and HTTP errors. +Connections have bounded reads/writes; fixture errors fail the native test. +Only the test apps enable cleartext HTTP for this fixture. + +Coverage includes: + +- Case-insensitive POST Content-Type through record, header-pair, and + Headers-like `forEach` initializers; exact MIME parameters; omitted MIME; + UTF-8, empty, and embedded-NUL string bodies; response JSON/text/bytes/Blob. +- Invalid Windows request MIME and reuse of the failed XHR without stale headers. +- Missing response Content-Type in Fetch and both XHR response modes, retaining + Windows' existing 2xx-to-200 normalization and non-success HTTP statuses. +- Percent/base64 data URLs, MIME/defaults, binary bytes, fragments, malformed + input, unsupported POST, cancellation, and XHR reuse across shared/HTTP transports. + +POST tests explicitly skip Linux because that UrlLib backend does not implement +POST. The JS APIs currently accept only string request bodies, so invalid-UTF-8 +POST bytes remain covered by UrlLib's native tests, not these consumer tests. +The echo response explicitly declares UTF-8 and uses lowercase diagnostic header +names; this does not test unrelated platform charset defaults or response-header +normalization. Blob MIME assertions retain the current polyfill's casing. + +To run just these offline scenarios (from the executable's output directory): + +```powershell +$env:JSRUNTIMEHOST_TEST_GREP = '^UrlLib ' +.\UnitTests.exe --gtest_filter=JavaScript.All --gtest_repeat=3 +Remove-Item Env:\JSRUNTIMEHOST_TEST_GREP +``` + +`JSRUNTIMEHOST_TEST_GREP` is a Mocha regular expression. An expression that matches +no tests fails rather than reporting an empty successful run. Unset it to restore +the full JavaScript suite. Native tests can be selected using the usual GoogleTest +flags. + +For a negative control, configure the same build with +`-D FETCHCONTENT_SOURCE_DIR_URLLIB=`, +rebuild, and run the same tests. Run the missing-Content-Type case separately: +the old Windows implementation can terminate the process with an access violation. +Remove the override with `cmake -S . -B -U FETCHCONTENT_SOURCE_DIR_URLLIB` +and rebuild before testing or using the fixed dependency again. diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index 4171ff13..28aea5c0 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -23,6 +23,8 @@ file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES "Source/App.cpp" "Source/App.h" + "Source/HttpTestServer.cpp" + "Source/HttpTestServer.h" "Source/Tests.AppRuntime.cpp" "Source/Tests.Console.cpp" "Source/Tests.DelayedTaskScheduler.cpp" @@ -78,6 +80,9 @@ if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") endif() target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") target_compile_definitions(UnitTests PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") +if(WIN32) + target_link_libraries(UnitTests PRIVATE ws2_32) +endif() # The V8JSI Node-API shim does not implement napi_create_dataview, so the # CreateDataViewRejectsOverflowingRange test is compiled out on that backend. diff --git a/Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml b/Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml index c3537fae..5c8c9b13 100644 --- a/Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml +++ b/Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt index aa933dba..f848c42c 100644 --- a/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt @@ -20,6 +20,8 @@ add_library(UnitTestsJNI SHARED JNI.cpp ${UNIT_TESTS_DIR}/Source/App.cpp ${UNIT_TESTS_DIR}/Source/App.h + ${UNIT_TESTS_DIR}/Source/HttpTestServer.cpp + ${UNIT_TESTS_DIR}/Source/HttpTestServer.h ${UNIT_TESTS_DIR}/Source/Tests.AppRuntime.cpp ${UNIT_TESTS_DIR}/Source/Tests.Console.cpp ${UNIT_TESTS_DIR}/Source/Tests.DelayedTaskScheduler.cpp diff --git a/Tests/UnitTests/Source/HttpTestServer.cpp b/Tests/UnitTests/Source/HttpTestServer.cpp new file mode 100644 index 00000000..57fea34b --- /dev/null +++ b/Tests/UnitTests/Source/HttpTestServer.cpp @@ -0,0 +1,347 @@ +#include "HttpTestServer.h" + +#if defined(_WIN32) +#include +#include +#else +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +#if defined(_WIN32) + using NativeSocket = SOCKET; + using SocketLength = int; + constexpr auto InvalidSocket = INVALID_SOCKET; +#else + using NativeSocket = int; + using SocketLength = socklen_t; + constexpr auto InvalidSocket = -1; +#endif + + struct Socket + { + NativeSocket Value; + + explicit Socket(NativeSocket value) + : Value{value} + { + } + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + + ~Socket() + { + if (Value != InvalidSocket) + { +#if defined(_WIN32) + ::closesocket(Value); +#else + ::close(Value); +#endif + } + } + }; + + std::string Lowercase(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + bool Wait(NativeSocket socket, bool writing, const std::atomic& stop, + std::chrono::steady_clock::time_point deadline) + { + while (!stop.load()) + { + if (std::chrono::steady_clock::now() >= deadline) + { + throw std::runtime_error{"HTTP test server: connection timed out"}; + } + fd_set descriptors; + FD_ZERO(&descriptors); + FD_SET(socket, &descriptors); + timeval timeout{0, 100000}; + const auto ready = ::select( +#if defined(_WIN32) + 0, +#else + socket + 1, +#endif + writing ? nullptr : &descriptors, writing ? &descriptors : nullptr, nullptr, &timeout); + if (ready < 0) + { + throw std::runtime_error{"HTTP test server: select failed"}; + } + if (ready > 0) + { + return true; + } + } + return false; + } + + void Send(NativeSocket socket, std::string_view bytes, const std::atomic& stop) + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5}; + while (!bytes.empty() && Wait(socket, true, stop, deadline)) + { + const auto count = ::send(socket, bytes.data(), static_cast(bytes.size()), +#if defined(MSG_NOSIGNAL) + MSG_NOSIGNAL +#else + 0 +#endif + ); + if (count <= 0) + { + throw std::runtime_error{"HTTP test server: send failed"}; + } + bytes.remove_prefix(static_cast(count)); + } + } + + void Respond(NativeSocket socket, const std::atomic& stop) + { +#if defined(_WIN32) + const DWORD timeout = 1000; +#else + const timeval timeout{1, 0}; +#endif + if (::setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeout), sizeof(timeout)) != 0 || + ::setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeout), sizeof(timeout)) != 0) + { + throw std::runtime_error{"HTTP test server: could not set socket timeouts"}; + } +#if defined(__APPLE__) + const int noSignal = 1; + if (::setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, sizeof(noSignal)) != 0) + { + throw std::runtime_error{"HTTP test server: SO_NOSIGPIPE failed"}; + } +#endif + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5}; + std::string request; + size_t bodyStart = std::string::npos; + size_t bodySize{}; + std::map headers; + while (Wait(socket, false, stop, deadline)) + { + std::array buffer; + const auto count = ::recv(socket, buffer.data(), static_cast(buffer.size()), 0); + if (count <= 0) + { + throw std::runtime_error{"HTTP test server: incomplete request"}; + } + request.append(buffer.data(), static_cast(count)); + if (request.size() > 65536) + { + throw std::runtime_error{"HTTP test server: request exceeds test fixture limit"}; + } + if (bodyStart == std::string::npos) + { + const auto end = request.find("\r\n\r\n"); + if (end == std::string::npos) + { + continue; + } + bodyStart = end + 4; + for (auto line = request.find("\r\n") + 2; line < end;) + { + const auto next = request.find("\r\n", line); + const auto colon = request.find(':', line); + if (colon == std::string::npos || colon >= next) + { + throw std::runtime_error{"HTTP test server: malformed request header"}; + } + const auto value = request.find_first_not_of(" \t", colon + 1); + headers.emplace(Lowercase(request.substr(line, colon - line)), + value < next ? request.substr(value, next - value) : ""); + line = next + 2; + } + if (headers.contains("transfer-encoding")) + { + throw std::runtime_error{"HTTP test server expects length-delimited string bodies"}; + } + if (const auto length = headers.find("content-length"); length != headers.end()) + { + const auto& value = length->second; + const auto parsed = std::from_chars(value.data(), value.data() + value.size(), bodySize); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || bodySize > 32768) + { + throw std::runtime_error{"HTTP test server: invalid Content-Length"}; + } + } + if (const auto expect = headers.find("expect"); + expect != headers.end() && Lowercase(expect->second) == "100-continue") + { + Send(socket, "HTTP/1.1 100 Continue\r\n\r\n", stop); + } + } + if (request.size() >= bodyStart + bodySize) + { + break; + } + } + if (stop.load()) + { + return; + } + + const auto methodEnd = request.find(' '); + const auto pathEnd = request.find(' ', methodEnd + 1); + if (methodEnd == std::string::npos || pathEnd == std::string::npos) + { + throw std::runtime_error{"HTTP test server: malformed request line"}; + } + const auto method = request.substr(0, methodEnd); + const auto path = request.substr(methodEnd + 1, pathEnd - methodEnd - 1); + std::string status{"200 OK"}; + std::string body; + std::string responseHeaders; + if (path == "/echo") + { + body = request.substr(bodyStart, bodySize); + responseHeaders = "Content-Type: text/plain; charset=utf-8\r\n"; + responseHeaders += "x-request-method: " + method + "\r\n"; + responseHeaders += "x-request-body-length: " + std::to_string(body.size()) + "\r\n"; + responseHeaders += "x-request-content-type: " + (headers.contains("content-type") ? headers.at("content-type") : "") + "\r\n"; + responseHeaders += "x-request-test: " + (headers.contains("x-test") ? headers.at("x-test") : "") + "\r\n"; + } + else if (path == "/no-content-type") + { + body = "{\"message\":\"hello\"}"; + } + else if (path == "/empty") + { + } + else if (path == "/no-content") + { + status = "204 No Content"; + } + else if (path == "/not-found") + { + status = "404 Not Found"; + } + else if (path == "/server-error") + { + status = "500 Internal Server Error"; + } + else if (path == "/not-modified") + { + status = "304 Not Modified"; + } + else + { + throw std::runtime_error{"HTTP test server: unexpected path " + path}; + } + + Send(socket, "HTTP/1.1 " + status + "\r\n" + responseHeaders + "Content-Length: " + std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body, stop); + } +} + +struct HttpTestServer::Impl +{ +#if defined(_WIN32) + struct Winsock + { + Winsock() + { + WSADATA data{}; + if (::WSAStartup(MAKEWORD(2, 2), &data) != 0) + { + throw std::runtime_error{"HTTP test server: WSAStartup failed"}; + } + } + ~Winsock() { ::WSACleanup(); } + } Sockets; +#endif + Socket Listener{::socket(AF_INET, SOCK_STREAM, 0)}; + uint16_t Port{}; + std::exception_ptr Error; + std::atomic Stopping{}; + std::thread Worker; + + void Stop() + { + Stopping.store(true); + if (Worker.joinable()) + { + Worker.join(); + } + } + + ~Impl() { Stop(); } + + Impl() + { + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + SocketLength length = sizeof(address); + if (Listener.Value == InvalidSocket || + ::bind(Listener.Value, reinterpret_cast(&address), sizeof(address)) != 0 || + ::getsockname(Listener.Value, reinterpret_cast(&address), &length) != 0 || + ::listen(Listener.Value, 8) != 0) + { + throw std::runtime_error{"HTTP test server: could not listen on loopback"}; + } + Port = ntohs(address.sin_port); + Worker = std::thread{[this] { + try + { + while (Wait(Listener.Value, false, Stopping, (std::chrono::steady_clock::time_point::max)())) + { + const Socket connection{::accept(Listener.Value, nullptr, nullptr)}; + if (connection.Value == InvalidSocket) + { + throw std::runtime_error{"HTTP test server: accept failed"}; + } + Respond(connection.Value, Stopping); + } + } + catch (...) + { + Error = std::current_exception(); + } + }}; + } +}; + +HttpTestServer::HttpTestServer() + : m_impl{std::make_unique()} +{ +} + +HttpTestServer::~HttpTestServer() = default; + +std::string HttpTestServer::Url() const +{ + return "http://127.0.0.1:" + std::to_string(m_impl->Port); +} + +void HttpTestServer::Stop() +{ + m_impl->Stop(); + if (m_impl->Error) + { + std::rethrow_exception(m_impl->Error); + } +} diff --git a/Tests/UnitTests/Source/HttpTestServer.h b/Tests/UnitTests/Source/HttpTestServer.h new file mode 100644 index 00000000..0b402c10 --- /dev/null +++ b/Tests/UnitTests/Source/HttpTestServer.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +class HttpTestServer +{ +public: + HttpTestServer(); + ~HttpTestServer(); + + std::string Url() const; + void Stop(); + +private: + struct Impl; + std::unique_ptr m_impl; +}; diff --git a/Tests/UnitTests/Source/Scripts/globals.d.ts b/Tests/UnitTests/Source/Scripts/globals.d.ts index 78e1a8ce..0aa19778 100644 --- a/Tests/UnitTests/Source/Scripts/globals.d.ts +++ b/Tests/UnitTests/Source/Scripts/globals.d.ts @@ -1,4 +1,10 @@ declare const hostPlatform: string; declare const hostEngine: string; +declare const httpTestUrl: string; +declare const testFilter: string | undefined; +interface XMLHttpRequest { + readonly errorCode: string; + readonly errorDetail: string; +} declare const napiGetPropertyNames: (object: any) => string[]; declare const napiGetPropertyNamesRaw: (object: any) => string[]; diff --git a/Tests/UnitTests/Source/Scripts/httpTestHelpers.ts b/Tests/UnitTests/Source/Scripts/httpTestHelpers.ts new file mode 100644 index 00000000..20217db4 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/httpTestHelpers.ts @@ -0,0 +1,46 @@ +export interface XhrResult { + xhr: XMLHttpRequest; + errors: number; +} + +export function sendXhr(url: string, options: { + method?: string; + body?: string; + headers?: Record; + responseType?: XMLHttpRequestResponseType; + xhr?: XMLHttpRequest; + abortBeforeSend?: boolean; +} = {}): Promise { + return new Promise((resolve, reject) => { + const xhr = options.xhr ?? new XMLHttpRequest(); + let errors = 0; + const timeout = setTimeout(() => { + xhr.abort(); + reject(new Error(`XHR timed out: ${url}`)); + }, 10000); + try { + xhr.open(options.method ?? "GET", url); + xhr.responseType = options.responseType ?? "arraybuffer"; + for (const [name, value] of Object.entries(options.headers ?? {})) { + xhr.setRequestHeader(name, value); + } + xhr.addEventListener("error", () => { ++errors; }); + xhr.addEventListener("loadend", () => { + clearTimeout(timeout); + // XHR clears its event handlers after loadend; reopen only after that cleanup. + setTimeout(() => resolve({ xhr, errors }), 0); + }); + if (options.abortBeforeSend) { + xhr.abort(); + } + xhr.send(options.body); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); +} + +export function bytes(text: string): number[] { + return Array.from(new TextEncoder().encode(text)); +} diff --git a/Tests/UnitTests/Source/Scripts/tests.dataUrl.ts b/Tests/UnitTests/Source/Scripts/tests.dataUrl.ts new file mode 100644 index 00000000..e8997a6c --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.dataUrl.ts @@ -0,0 +1,177 @@ +import { expect } from "chai"; +import { bytes, sendXhr } from "./httpTestHelpers"; + +describe("UrlLib data URLs", function () { + this.timeout(15000); + + const cases = [ + { name: "percent-encoded default MIME", url: "data:,hello%20world", body: bytes("hello world"), mime: "text/plain;charset=US-ASCII" }, + { name: "empty body", url: "data:,", body: [], mime: "text/plain;charset=US-ASCII" }, + { name: "empty base64", url: "data:;base64,", body: [], mime: "text/plain;charset=US-ASCII" }, + { name: "UTF-8 and case-insensitive scheme", url: "DATA:TEXT/PLAIN;CHARSET=UTF-8,%C3%A9", body: bytes("\u00e9"), mime: "text/plain;charset=UTF-8" }, + { name: "charset without media type", url: "data:;charset=utf-8,%E2%82%AC", body: bytes("\u20ac"), mime: "text/plain;charset=utf-8" }, + { name: "binary percent escapes", url: "data:application/octet-stream,%00%7f%80%ff", body: [0, 127, 128, 255], mime: "application/octet-stream" }, + { name: "PNG signature", url: "data:image/png;base64,iVBORw0KGgo=", body: [137, 80, 78, 71, 13, 10, 26, 10], mime: "image/png" }, + { name: "unpadded base64", url: "data:;BASE64,Zm8", body: bytes("fo"), mime: "text/plain;charset=US-ASCII" }, + { name: "forgiving base64 pad bits", url: "data:;base64,Zh==", body: bytes("f"), mime: "text/plain;charset=US-ASCII" }, + { name: "escaped base64 whitespace and padding", url: "data:;base64,Z%20g%3D%3D%0D%0A%09%0C", body: bytes("f"), mime: "text/plain;charset=US-ASCII" }, + { name: "standard base64 alphabet", url: "data:application/octet-stream;base64,%2F%2B%2F%2B", body: [255, 239, 254], mime: "application/octet-stream" }, + { name: "query bytes and excluded fragment", url: "data:text/plain,x?y#ignored", body: bytes("x?y"), mime: "text/plain" }, + { name: "literal plus, commas, and escaped delimiters", url: "data:text/plain,a+b,c%23%3F%25", body: bytes("a+b,c#?%"), mime: "text/plain" }, + { name: "literal malformed percent escapes", url: "data:text/plain,%oops%2%", body: bytes("%oops%2%"), mime: "text/plain" }, + { name: "quoted MIME parameter", url: 'data:text/plain;note="a;b",x', body: bytes("x"), mime: 'text/plain;note="a;b"' }, + { name: "base64 parameter is not an encoding marker", url: "data:text/plain;base64=value,Zg==", body: bytes("Zg=="), mime: "text/plain;base64=value" }, + { name: "charset does not transcode raw bytes", url: "data:text/plain;charset=iso-8859-1,%E9", body: [233], mime: "text/plain;charset=iso-8859-1" }, + ]; + + for (const entry of cases) { + it(`fetch decodes ${entry.name}`, async function () { + const response = await fetch(entry.url); + expect(response.status).to.equal(200); + expect(response.statusText).to.equal("OK"); + expect(response.ok).to.equal(true); + expect(response.url).to.equal(entry.url.split("#")[0]); + expect(response.headers.get("CONTENT-TYPE")).to.equal(entry.mime); + expect(Array.from(new Uint8Array(await response.clone().arrayBuffer()))).to.eql(entry.body); + const blob = await response.blob(); + expect(blob.type).to.equal(entry.mime); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).to.eql(entry.body); + }); + + it(`XHR decodes ${entry.name}`, async function () { + const { xhr, errors } = await sendXhr(entry.url); + expect(errors).to.equal(0); + expect(xhr.readyState).to.equal(4); + expect(xhr.status).to.equal(200); + expect(xhr.statusText).to.equal("OK"); + expect(xhr.responseURL).to.equal(entry.url.split("#")[0]); + expect(xhr.getResponseHeader("Content-Type")).to.equal(entry.mime); + expect(xhr.errorCode).to.equal(""); + expect(Array.from(new Uint8Array(xhr.response))).to.eql(entry.body); + }); + } + + it("preserves every byte value through Fetch and XHR", async function () { + const encoded = + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v" + + "MDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5f" + + "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6P" + + "kJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/" + + "wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v" + + "8PHy8/T19vf4+fr7/P3+/w=="; + const expected = Array.from({ length: 256 }, (_, index) => index); + const url = `data:application/octet-stream;base64,${encoded}`; + const response = await fetch(url); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).to.eql(expected); + const { xhr, errors } = await sendXhr(url); + expect(errors).to.equal(0); + expect(Array.from(new Uint8Array(xhr.response))).to.eql(expected); + }); + + it("exposes UTF-8 text, embedded NULs, and parsed JSON", async function () { + const text = "start\0caf\u00e9\0end"; + const url = `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`; + expect(await (await fetch(url)).text()).to.equal(text); + const { xhr, errors } = await sendXhr(url, { responseType: "text" }); + expect(errors).to.equal(0); + expect(xhr.responseText).to.equal(text); + expect(xhr.response).to.equal(text); + const value = { text, nested: [1, true] }; + const jsonUrl = `data:application/json,${encodeURIComponent(JSON.stringify(value))}`; + expect(await (await fetch(jsonUrl)).json()).to.eql(value); + }); + + for (const url of [ + "data:", "data:text/plain#fragment,not-body", "data:invalid,x", + "data:text/plain;broken,x", 'data:text/plain;charset="broken,x', + "data:;base64,A", "data:;base64,Zg=", "data:;base64,Zg===", + "data:;base64,Zm=8", "data:;base64,Zg==junk", "data:;base64,_w==", + "data:;base64,%FF", "data:;base64,Zg==?query", + ]) { + it(`rejects malformed input without a partial response: ${url}`, async function () { + let error: unknown; + try { + await fetch(url); + } catch (caught) { + error = caught; + } + expect(error).to.be.instanceOf(TypeError); + expect(error).to.have.property("message", "fetch failed"); + expect(error).to.have.nested.property("cause.status", 0); + expect(error).to.have.nested.property("cause.url", url); + expect(error).to.have.nested.property("cause.code", "DataUrlInvalid"); + const { xhr, errors } = await sendXhr(url); + expect(errors).to.equal(1); + expect(xhr.readyState).to.equal(4); + expect(xhr.status).to.equal(0); + expect(xhr.statusText).to.equal(""); + expect(xhr.errorCode).to.equal("DataUrlInvalid"); + expect(xhr.errorDetail).not.to.equal(""); + expect(xhr.response.byteLength).to.equal(0); + expect(xhr.getResponseHeader("content-type")).to.equal(null); + }); + } + + it("rejects POST rather than silently decoding a GET response", async function () { + let error: unknown; + try { + await fetch("data:,hello", { method: "POST", body: "ignored" }); + } catch (caught) { + error = caught; + } + expect(error).to.be.instanceOf(TypeError); + expect(error).to.have.nested.property("cause.status", 0); + expect(error).to.have.nested.property("cause.code", "DataUrlUnsupportedMethod"); + const { xhr, errors } = await sendXhr("data:,hello", { method: "POST", body: "ignored" }); + expect(errors).to.equal(1); + expect(xhr.status).to.equal(0); + expect(xhr.errorCode).to.equal("DataUrlUnsupportedMethod"); + expect(xhr.response.byteLength).to.equal(0); + }); + + for (const abortBeforeFetch of [true, false]) { + it(`honors Fetch abort ${abortBeforeFetch ? "before" : "immediately after"} sending`, async function () { + const controller = new AbortController(); + if (abortBeforeFetch) { + controller.abort(); + } + const pending = fetch("data:,hello", { signal: controller.signal }); + controller.abort(); + let error: unknown; + try { + await pending; + } catch (caught) { + error = caught; + } + expect(error).to.have.property("name", "AbortError"); + }); + } + + it("honors XHR cancellation before shared resolution", async function () { + const { xhr, errors } = await sendXhr("data:,hello", { abortBeforeSend: true }); + expect(errors).to.equal(1); + expect(xhr.status).to.equal(0); + expect(xhr.response.byteLength).to.equal(0); + expect(xhr.getResponseHeader("content-type")).to.equal(null); + }); + + it("clears XHR errors, bodies, and headers when switching transports", async function () { + const first = await sendXhr("data:;base64,!!!"); + expect(first.errors).to.equal(1); + expect(first.xhr.errorCode).to.equal("DataUrlInvalid"); + const xhr = first.xhr; + for (const entry of [ + { url: "data:text/plain,first", body: "first", mime: "text/plain" }, + { url: `${httpTestUrl}/no-content-type`, body: '{"message":"hello"}', mime: null }, + { url: "data:,", body: "", mime: "text/plain;charset=US-ASCII" }, + ]) { + const result = await sendXhr(entry.url, { xhr }); + expect(result.errors).to.equal(0); + expect(xhr.status).to.equal(200); + expect(xhr.errorCode).to.equal(""); + expect(xhr.errorDetail).to.equal(""); + expect(xhr.getResponseHeader("content-type")).to.equal(entry.mime); + expect(Array.from(new Uint8Array(xhr.response))).to.eql(bytes(entry.body)); + } + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.httpTransport.ts b/Tests/UnitTests/Source/Scripts/tests.httpTransport.ts new file mode 100644 index 00000000..eeb0f648 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.httpTransport.ts @@ -0,0 +1,199 @@ +import { expect } from "chai"; +import { bytes, sendXhr } from "./httpTestHelpers"; + +describe("UrlLib HTTP transport", function () { + this.timeout(15000); + + const body = JSON.stringify({ message: "caf\u00e9 \u20ac", nested: { ready: true } }); + + describe("POST", function () { + before(function () { + if (hostPlatform === "Unix") { + this.skip(); // UrlLib's Linux backend does not implement POST. + } + }); + + const initializers: { name: string; contentType: string; headers: () => Promise }[] = [ + ...["Content-Type", "content-type", "CoNtEnT-TyPe"].map(name => ({ + name: `record ${name}`, + contentType: "application/json", + headers: async () => ({ [name]: "application/json", "X-Test": "post" }), + })), + { + name: "header pairs with MIME parameters", + contentType: "application/json; charset=utf-8", + headers: async () => [["content-type", "application/json; charset=utf-8"], ["X-Test", "post"]], + }, + { + name: "response Headers-like object", + contentType: "application/json", + headers: async () => (await fetch("data:application/json,")).headers, + }, + ]; + + for (const entry of initializers) { + it(`fetch sends JSON with ${entry.name} and consumes the response`, async function () { + const response = await fetch(`${httpTestUrl}/echo`, { + method: "POST", + headers: await entry.headers(), + body, + }); + expect(response.status).to.equal(200); + expect(response.ok).to.equal(true); + expect(response.headers.get("x-request-method")).to.equal("POST"); + expect(response.headers.get("x-request-content-type")).to.equal(entry.contentType); + expect(response.headers.get("x-request-body-length")).to.equal(String(bytes(body).length)); + if (entry.name !== "response Headers-like object") { + expect(response.headers.get("x-request-test")).to.equal("post"); + } + expect(await response.clone().json()).to.eql(JSON.parse(body)); + expect(await response.clone().text()).to.equal(body); + expect(Array.from(new Uint8Array(await response.clone().arrayBuffer()))).to.eql(bytes(body)); + expect(await (await response.blob()).text()).to.equal(body); + }); + } + + for (const name of ["Content-Type", "content-type", "CoNtEnT-TyPe"]) { + it(`XHR sends JSON with ${name} and preserves MIME parameters`, async function () { + const { xhr, errors } = await sendXhr(`${httpTestUrl}/echo`, { + method: "POST", body, responseType: "text", + headers: { [name]: "application/json; charset=utf-8", "X-Test": "post" }, + }); + expect(errors).to.equal(0); + expect(xhr.readyState).to.equal(4); + expect(xhr.status).to.equal(200); + expect(xhr.getResponseHeader("x-request-method")).to.equal("POST"); + expect(xhr.getResponseHeader("x-request-content-type")).to.equal("application/json; charset=utf-8"); + expect(xhr.getResponseHeader("x-request-test")).to.equal("post"); + expect(xhr.getResponseHeader("x-request-body-length")).to.equal(String(bytes(body).length)); + expect(xhr.responseText).to.equal(body); + expect(JSON.parse(xhr.responseText)).to.eql(JSON.parse(body)); + }); + } + + for (const entry of [ + { name: "empty body", body: "" }, + { name: "UTF-8 and embedded NUL bytes", body: "\0start\0caf\u00e9 \u20ac\r\nend\0" }, + ]) { + for (const contentType of [undefined, "application/octet-stream"]) { + const label = `${entry.name}, ${contentType ? "explicit" : "omitted"} Content-Type`; + it(`fetch preserves ${label}`, async function () { + const response = await fetch(`${httpTestUrl}/echo`, { + method: "POST", body: entry.body, + headers: contentType ? { "content-type": contentType } : undefined, + }); + expect(response.status).to.equal(200); + expect(response.headers.get("x-request-body-length")).to.equal(String(bytes(entry.body).length)); + if (contentType || hostPlatform === "Win32" || hostPlatform === "UWP") { + expect(response.headers.get("x-request-content-type")).to.equal(contentType ?? ""); + } + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).to.eql(bytes(entry.body)); + }); + + it(`XHR preserves ${label}`, async function () { + const { xhr, errors } = await sendXhr(`${httpTestUrl}/echo`, { + method: "POST", body: entry.body, + headers: contentType ? { "content-type": contentType } : undefined, + }); + expect(errors).to.equal(0); + expect(xhr.status).to.equal(200); + expect(xhr.getResponseHeader("x-request-body-length")).to.equal(String(bytes(entry.body).length)); + if (contentType || hostPlatform === "Win32" || hostPlatform === "UWP") { + expect(xhr.getResponseHeader("x-request-content-type")).to.equal(contentType ?? ""); + } + expect(Array.from(new Uint8Array(xhr.response))).to.eql(bytes(entry.body)); + }); + } + } + + if (hostPlatform === "Win32" || hostPlatform === "UWP") { + it("rejects invalid MIME without retaining it in a reopened XHR", async function () { + let error: unknown; + try { + await fetch(`${httpTestUrl}/echo`, { + method: "POST", body, + headers: { "content-type": "not a media type" }, + }); + } catch (caught) { + error = caught; + } + expect(error).to.be.instanceOf(TypeError); + expect(error).to.have.nested.property("cause.status", 0); + + const failed = await sendXhr(`${httpTestUrl}/echo`, { + method: "POST", body, headers: { "content-type": "not a media type" }, + }); + expect(failed.errors).to.equal(1); + expect(failed.xhr.status).to.equal(0); + expect(failed.xhr.errorDetail).not.to.equal(""); + expect(failed.xhr.response.byteLength).to.equal(0); + expect(failed.xhr.getResponseHeader("content-type")).to.equal(null); + + const reopened = await sendXhr(`${httpTestUrl}/echo`, { + xhr: failed.xhr, method: "POST", body, + }); + expect(reopened.errors).to.equal(0); + expect(reopened.xhr.status).to.equal(200); + expect(reopened.xhr.errorDetail).to.equal(""); + expect(reopened.xhr.getResponseHeader("x-request-content-type")).to.equal(""); + expect(Array.from(new Uint8Array(reopened.xhr.response))).to.eql(bytes(body)); + }); + } + }); + + describe("missing response Content-Type", function () { + for (const entry of [ + { path: "/no-content-type", body: '{"message":"hello"}', status: 200 }, + { path: "/empty", body: "", status: 200 }, + { path: "/no-content", body: "", status: hostPlatform === "Win32" || hostPlatform === "UWP" ? 200 : 204 }, + ]) { + it(`fetch consumes ${entry.path}`, async function () { + const response = await fetch(httpTestUrl + entry.path); + expect(response.status).to.equal(entry.status); + expect(response.ok).to.equal(true); + expect(response.headers.has("content-type")).to.equal(false); + expect(response.headers.get("Content-Type")).to.equal(null); + expect(await response.clone().text()).to.equal(entry.body); + if (entry.body) { + expect(await response.clone().json()).to.eql({ message: "hello" }); + } + expect(Array.from(new Uint8Array(await response.clone().arrayBuffer()))).to.eql(bytes(entry.body)); + const blob = await response.blob(); + expect(blob.type).to.equal(""); + expect(await blob.text()).to.equal(entry.body); + }); + + for (const responseType of ["text", "arraybuffer"] as const) { + it(`XHR consumes ${entry.path} as ${responseType}`, async function () { + const { xhr, errors } = await sendXhr(httpTestUrl + entry.path, { responseType }); + expect(errors).to.equal(0); + expect(xhr.readyState).to.equal(4); + expect(xhr.status).to.equal(entry.status); + expect(xhr.getResponseHeader("content-type")).to.equal(null); + if (responseType === "text") { + expect(xhr.responseText).to.equal(entry.body); + } else { + expect(Array.from(new Uint8Array(xhr.response))).to.eql(bytes(entry.body)); + } + }); + } + } + }); + + for (const entry of [ + { path: "/not-found", status: 404 }, + { path: "/server-error", status: 500 }, + { path: "/not-modified", status: 304 }, + ]) { + it(`preserves HTTP ${entry.status} rather than treating it as a transport failure`, async function () { + const response = await fetch(httpTestUrl + entry.path); + expect(response.status).to.equal(entry.status); + expect(response.ok).to.equal(false); + expect(await response.text()).to.equal(""); + const { xhr, errors } = await sendXhr(httpTestUrl + entry.path); + expect(xhr.status).to.equal(entry.status); + expect(xhr.readyState).to.equal(4); + expect(errors).to.equal(1); + }); + } +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts b/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts index be45b72c..31ccaca5 100644 --- a/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts +++ b/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts @@ -2,6 +2,8 @@ import "./tests.setup"; import "./tests.abortController"; import "./tests.xmlHttpRequest"; import "./tests.fetch"; +import "./tests.httpTransport"; +import "./tests.dataUrl"; import "./tests.scheduling"; import "./tests.webSocket"; import "./tests.url"; @@ -19,9 +21,15 @@ import "./tests.webAssembly"; declare const setExitCode: (code: number) => void; function runTests() { - mocha.run((failures: number) => { + if (typeof testFilter === "string") { + mocha.grep(testFilter); + } + const runner = mocha.run((failures: number) => { + if (runner.total === 0) { + console.error("No JavaScript tests matched JSRUNTIMEHOST_TEST_GREP."); + } // Test program will wait for code to be set before exiting - if (failures > 0) { + if (failures > 0 || runner.total === 0) { // Failure setExitCode(1); } else { diff --git a/Tests/UnitTests/Source/Tests.JavaScript.cpp b/Tests/UnitTests/Source/Tests.JavaScript.cpp index c5b38b40..5cec1869 100644 --- a/Tests/UnitTests/Source/Tests.JavaScript.cpp +++ b/Tests/UnitTests/Source/Tests.JavaScript.cpp @@ -14,9 +14,11 @@ #include #include #include +#include #include #include #include +#include "HttpTestServer.h" namespace { @@ -38,6 +40,7 @@ namespace TEST(JavaScript, All) { + HttpTestServer httpServer; // Change this to true to wait for the JavaScript debugger to attach (only applies to V8) constexpr const bool waitForDebugger = false; @@ -60,7 +63,7 @@ TEST(JavaScript, All) Babylon::AppRuntime runtime{options}; - runtime.Dispatch([&exitCodePromise](Napi::Env env) mutable { + runtime.Dispatch([&exitCodePromise, httpUrl = httpServer.Url()](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) @@ -97,6 +100,11 @@ 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("httpTestUrl", httpUrl); + if (const auto* filter = std::getenv("JSRUNTIMEHOST_TEST_GREP")) + { + env.Global().Set("testFilter", filter); + } auto getPropertyNamesCallback = Napi::Function::New( env, [](const Napi::CallbackInfo& info) -> Napi::Value { @@ -140,4 +148,5 @@ TEST(JavaScript, All) auto exitCode{exitCodePromise.get_future().get()}; EXPECT_EQ(exitCode, 0); + EXPECT_NO_THROW(httpServer.Stop()); } diff --git a/Tests/UnitTests/Source/iOS/Info.plist b/Tests/UnitTests/Source/iOS/Info.plist index 80594b33..5c02896d 100644 --- a/Tests/UnitTests/Source/iOS/Info.plist +++ b/Tests/UnitTests/Source/iOS/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsLocalNetworking + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier From 851229e52ba879b0b8bb9218d903789c7f340bb6 Mon Sep 17 00:00:00 2001 From: Cedric Guillemet <1312968+CedricGuillemet@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:24:08 +0200 Subject: [PATCH 3/4] Remove test README from transport regression PR Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 001e7798-8d3b-44be-8a99-5e8c1541297e --- Tests/README.md | 66 ------------------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 Tests/README.md diff --git a/Tests/README.md b/Tests/README.md deleted file mode 100644 index 9207ae9c..00000000 --- a/Tests/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Unit tests - -Build and run the Windows tests from the repository root: - -```powershell -cmake -S . -B Build\QuickJS -A x64 -D NAPI_JAVASCRIPT_ENGINE=QuickJS -cmake --build Build\QuickJS --config RelWithDebInfo --target UnitTests -- /m -Set-Location Build\QuickJS\Tests\UnitTests\RelWithDebInfo -.\UnitTests.exe -``` - -Use `NAPI_JAVASCRIPT_ENGINE=V8` and a separate build directory to run with V8. -The executable must run from its output directory so `app:///Assets/` resolves -the test assets. - -## UrlLib consumer regressions - -The `UrlLib HTTP transport` and `UrlLib data URLs` JavaScript suites exercise -the real Fetch and XMLHttpRequest polyfills against the configured UrlLib -dependency. They cover the fixes in -[BabylonJS/UrlLib#39](https://github.com/BabylonJS/UrlLib/pull/39). - -The HTTP fixture runs inside the test process on an ephemeral IPv4 loopback -port. It echoes the received POST bytes and exposes the actual request method, -Content-Type, and body length in diagnostic response headers. It also serves -responses without Content-Type, including empty/204 responses and HTTP errors. -Connections have bounded reads/writes; fixture errors fail the native test. -Only the test apps enable cleartext HTTP for this fixture. - -Coverage includes: - -- Case-insensitive POST Content-Type through record, header-pair, and - Headers-like `forEach` initializers; exact MIME parameters; omitted MIME; - UTF-8, empty, and embedded-NUL string bodies; response JSON/text/bytes/Blob. -- Invalid Windows request MIME and reuse of the failed XHR without stale headers. -- Missing response Content-Type in Fetch and both XHR response modes, retaining - Windows' existing 2xx-to-200 normalization and non-success HTTP statuses. -- Percent/base64 data URLs, MIME/defaults, binary bytes, fragments, malformed - input, unsupported POST, cancellation, and XHR reuse across shared/HTTP transports. - -POST tests explicitly skip Linux because that UrlLib backend does not implement -POST. The JS APIs currently accept only string request bodies, so invalid-UTF-8 -POST bytes remain covered by UrlLib's native tests, not these consumer tests. -The echo response explicitly declares UTF-8 and uses lowercase diagnostic header -names; this does not test unrelated platform charset defaults or response-header -normalization. Blob MIME assertions retain the current polyfill's casing. - -To run just these offline scenarios (from the executable's output directory): - -```powershell -$env:JSRUNTIMEHOST_TEST_GREP = '^UrlLib ' -.\UnitTests.exe --gtest_filter=JavaScript.All --gtest_repeat=3 -Remove-Item Env:\JSRUNTIMEHOST_TEST_GREP -``` - -`JSRUNTIMEHOST_TEST_GREP` is a Mocha regular expression. An expression that matches -no tests fails rather than reporting an empty successful run. Unset it to restore -the full JavaScript suite. Native tests can be selected using the usual GoogleTest -flags. - -For a negative control, configure the same build with -`-D FETCHCONTENT_SOURCE_DIR_URLLIB=`, -rebuild, and run the same tests. Run the missing-Content-Type case separately: -the old Windows implementation can terminate the process with an access violation. -Remove the override with `cmake -S . -B -U FETCHCONTENT_SOURCE_DIR_URLLIB` -and rebuild before testing or using the fixed dependency again. From 8f2fae226652fd3f9a07c5b8a0317a780af136d7 Mon Sep 17 00:00:00 2001 From: Cedric Guillemet <1312968+CedricGuillemet@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:34:32 +0200 Subject: [PATCH 4/4] Pin UrlLib Android POST byte fix Use UrlLib 81ea0404f6d719e2bc306c20d99594c30c434b19 from PR39, including raw-byte Android POST transport. Keep all consumer regression tests unchanged. Pixel 9a QuickJS validation against the published pin: 331 JavaScript tests pass (3 pending), all 24 native tests pass, and instrumentation exits normally. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 001e7798-8d3b-44be-8a99-5e8c1541297e --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e9c1f19a..dca278c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,8 +49,8 @@ FetchContent_Declare(llhttp EXCLUDE_FROM_ALL) FetchContent_Declare(UrlLib GIT_REPOSITORY https://github.com/BabylonJS/UrlLib.git - # Track https://github.com/BabylonJS/UrlLib/pull/39 while it is under review. - GIT_TAG cedricguillemet-urllib-transport-fixes + # Pin https://github.com/BabylonJS/UrlLib/pull/39 including the Android POST byte fix. + GIT_TAG 81ea0404f6d719e2bc306c20d99594c30c434b19 EXCLUDE_FROM_ALL) FetchContent_Declare(quickjs-ng GIT_REPOSITORY https://github.com/quickjs-ng/quickjs.git