diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index c1ca23a6..f35b9403 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -66,11 +66,11 @@ jobs: target: google_apis arch: x86_64 emulator-options: -no-snapshot -no-window -no-boot-anim -no-audio - script: chmod +x Tests/UnitTests/Android/gradlew && Tests/UnitTests/Android/gradlew -p Tests/UnitTests/Android connectedAndroidTest -PabiFilters=x86_64 -PjsEngine=${{ inputs.js-engine }} -PndkVersion=${{ env.NDK_VERSION }} + script: chmod +x Tests/UnitTests/Source/Android/gradlew && Tests/UnitTests/Source/Android/gradlew -p Tests/UnitTests/Source/Android connectedAndroidTest -PabiFilters=x86_64 -PjsEngine=${{ inputs.js-engine }} -PndkVersion=${{ env.NDK_VERSION }} - name: Dump Test Results if: always() - working-directory: Tests/UnitTests/Android + working-directory: Tests/UnitTests/Source/Android run: | find ./app/build/outputs/androidTest-results -name "*.txt" -print0 | while IFS= read -r -d '' file; do echo "cat \"$file\"" @@ -82,5 +82,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: AndroidTestResults_${{ inputs.js-engine }} - path: Tests/UnitTests/Android/app/build/outputs/androidTest-results/connected + path: Tests/UnitTests/Source/Android/app/build/outputs/androidTest-results/connected if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0260b044..b84ede5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ /Build -Tests/UnitTests/dist/ diff --git a/Documentation/DebugJavaScriptDevTools.md b/Documentation/DebugJavaScriptDevTools.md index 3f49236c..ae27c960 100644 --- a/Documentation/DebugJavaScriptDevTools.md +++ b/Documentation/DebugJavaScriptDevTools.md @@ -8,7 +8,7 @@ Click `Configure...` and in the target discovery settings add an entry `localhos ![Target discovery settings](Images/DevTools/chrome-targets.png) ## Setting Up To Debug -Make sure to change the line in [tests.js](https://github.com/BabylonJS/JsRuntimeHost/blob/f487c7b3f89b407e95a53543a06a34f1a1fbb860/Tests/UnitTests/Scripts/tests.js#L2) to true, or else the DevTools won't have enough time to attach to the JavaScript Instance. If you are debugging your own script, you can also use [setTimeout()](https://developer.mozilla.org/en-US/docs/web/api/settimeout) to delay the execution of the code you are trying to debug while you wait to attach the debugger. +Set `waitForDebugger` to true in [Tests.JavaScript.cpp](../Tests/UnitTests/Source/Tests.JavaScript.cpp) so DevTools has time to attach to the JavaScript instance. If you are debugging your own script, you can also use [setTimeout()](https://developer.mozilla.org/en-US/docs/web/api/settimeout) to delay execution while you attach the debugger. ## Debugging on Android/Emulator diff --git a/README.md b/README.md index 7bd96831..334bed98 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Use a git-enabled terminal to run the following command. git clone https://github.com/BabylonJS/JsRuntimeHost.git ``` -The unit tests require some NPM packages. From the root of the repository on the command line, run the following commands: +The unit tests require some NPM packages. From the root of the repository on the command line, run the following commands. The CMake or Android Gradle build generates `tests.js` in its build directory and packages it as `Assets/tests.js`; installing packages alone does not build the bundle. ``` cd Tests @@ -50,7 +50,7 @@ First, download the latest release of Ninja, extract the binary, and add it to y Once you have Android Studio downloaded, you need to set up an Android emulator if you do not have a physical Android device. You can do this by selecting `Tools` -> `Device Manager` and then selecting a device. (We are using Pixel 2 API 27). Open the project located at -`JsRuntimeHost\Tests\UnitTests\Android` with Android Studio. Note that this can take a while to load. (The bottom right corner of the Android Studio window shows you what is currently being loaded.) +`JsRuntimeHost\Tests\UnitTests\Source\Android` with Android Studio. Note that this can take a while to load. (The bottom right corner of the Android Studio window shows you what is currently being loaded.) Then in the LEFT PANE, right click on `app`, and select `Run 'All Tests'`, as displayed in the image below. diff --git a/Tests/UnitTests/Scripts/symlink_target.js b/Tests/UnitTests/Assets/symlink_target.js similarity index 100% rename from Tests/UnitTests/Scripts/symlink_target.js rename to Tests/UnitTests/Assets/symlink_target.js diff --git a/Tests/UnitTests/CMakeLists.txt b/Tests/UnitTests/CMakeLists.txt index f3676d7d..69cc6cac 100644 --- a/Tests/UnitTests/CMakeLists.txt +++ b/Tests/UnitTests/CMakeLists.txt @@ -1,54 +1,71 @@ -set(SCRIPTS - "Scripts/symlink_target.js" - "dist/tests.js") - -set(TYPE_SCRIPTS - "Scripts/tests.ts") +set(TEST_BUNDLE "${CMAKE_CURRENT_BINARY_DIR}/Assets/tests.js") +set(SCRIPTS "${TEST_BUNDLE}") + +file(GLOB_RECURSE TEST_JAVASCRIPT CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/Source/Scripts/*.ts") +find_program(NPM_EXECUTABLE NAMES npm npm.cmd REQUIRED) +add_custom_command( + OUTPUT "${TEST_BUNDLE}" + COMMAND "${NPM_EXECUTABLE}" run build -- --output-path "${CMAKE_CURRENT_BINARY_DIR}/Assets" --no-output-compare-before-emit + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.." + MAIN_DEPENDENCY "${CMAKE_CURRENT_SOURCE_DIR}/Source/Scripts/tests.javaScript.all.ts" + DEPENDS ${TEST_JAVASCRIPT} + "${CMAKE_CURRENT_SOURCE_DIR}/../webpack.config.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../babel-plugin-replace-bigint.js" + "${CMAKE_CURRENT_SOURCE_DIR}/../package.json" + "${CMAKE_CURRENT_SOURCE_DIR}/../package-lock.json" + VERBATIM) + +set_source_files_properties(${TEST_JAVASCRIPT} PROPERTIES HEADER_FILE_ONLY TRUE) file(GLOB ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/Assets/*") set(SOURCES - "Shared/DelayedTaskScheduler.cpp" - "Shared/StandardStreamLogger.cpp" - "Shared/TimeoutDispatcher.cpp" - "Shared/Shared.cpp" - "Shared/Shared.h") + "Source/App.cpp" + "Source/App.h" + "Source/Tests.AppRuntime.cpp" + "Source/Tests.Console.cpp" + "Source/Tests.DelayedTaskScheduler.cpp" + "Source/Tests.JavaScript.cpp" + "Source/Tests.NodeApi.cpp" + "Source/Tests.Scheduling.cpp" + "Source/Tests.StandardStreamLogger.cpp" + "Source/Tests.TimeoutDispatcher.cpp") if(APPLE) if(IOS) set(SOURCES ${SOURCES} - "iOS/App.mm") + "Source/iOS/App.mm") set(PLIST_FILE - "${CMAKE_CURRENT_LIST_DIR}/iOS/Info.plist") + "${CMAKE_CURRENT_LIST_DIR}/Source/iOS/Info.plist") set(STORYBOARD - "${CMAKE_CURRENT_LIST_DIR}/iOS/Base.lproj/LaunchScreen.storyboard" - "${CMAKE_CURRENT_LIST_DIR}/iOS/Base.lproj/Main.storyboard") + "${CMAKE_CURRENT_LIST_DIR}/Source/iOS/Base.lproj/LaunchScreen.storyboard" + "${CMAKE_CURRENT_LIST_DIR}/Source/iOS/Base.lproj/Main.storyboard") set_source_files_properties( ${SCRIPTS} - ${TYPE_SCRIPTS} - PROPERTIES MACOSX_PACKAGE_LOCATION "Scripts") + PROPERTIES MACOSX_PACKAGE_LOCATION "Assets") set_source_files_properties( ${ASSETS} PROPERTIES MACOSX_PACKAGE_LOCATION "Assets") else() set(SOURCES ${SOURCES} - macOS/App.mm) + Source/macOS/App.mm) endif() elseif(WIN32) set(SOURCES ${SOURCES} - Win32/App.cpp) + Source/Win32/App.cpp) elseif(UNIX AND NOT ANDROID) set(SOURCES ${SOURCES} - Linux/App.cpp) + Source/Linux/App.cpp) endif() -add_executable(UnitTests ${SOURCES} ${SCRIPTS} ${TYPE_SCRIPTS} ${ASSETS}) +add_executable(UnitTests ${SOURCES} ${TEST_JAVASCRIPT} ${SCRIPTS} ${ASSETS}) if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") - target_sources(UnitTests PRIVATE "Shared/V8ForegroundTaskRunner.cpp") + target_sources(UnitTests PRIVATE "Source/Tests.V8ForegroundTaskRunner.cpp") target_link_libraries(UnitTests PRIVATE AppRuntimeInternal) endif() target_compile_definitions(UnitTests PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") @@ -97,30 +114,29 @@ if(IOS) XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET ${DEPLOYMENT_TARGET} XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.jsruntimehost.unittests") else() - foreach(SCRIPT ${SCRIPTS}) - get_filename_component(SCRIPT_NAME "${SCRIPT}" NAME) - add_custom_command( - OUTPUT "${CMAKE_CFG_INTDIR}/Scripts/${SCRIPT_NAME}" - COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_CURRENT_SOURCE_DIR}/${SCRIPT}" "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Scripts/${SCRIPT_NAME}" - COMMENT "Copying ${SCRIPT_NAME}" - MAIN_DEPENDENCY "${CMAKE_CURRENT_SOURCE_DIR}/${SCRIPT}") - endforeach() + # Incremental builds may still have resources from the former Scripts directory. + add_custom_command(TARGET UnitTests POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E rm -rf "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Scripts") + + if(CMAKE_CONFIGURATION_TYPES) + add_custom_command(TARGET UnitTests POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "$/Assets" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${TEST_BUNDLE}" "$/Assets/tests.js") + endif() add_custom_command(TARGET UnitTests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E create_symlink "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Scripts/symlink_target.js" "${CMAKE_CFG_INTDIR}/Scripts/symlink_1.js") + COMMAND ${CMAKE_COMMAND} -E create_symlink "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Assets/symlink_target.js" "${CMAKE_CFG_INTDIR}/Assets/symlink_1.js") add_custom_command(TARGET UnitTests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E create_symlink "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Scripts/symlink_1.js" "${CMAKE_CFG_INTDIR}/Scripts/symlink_2.js") + COMMAND ${CMAKE_COMMAND} -E create_symlink "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Assets/symlink_1.js" "${CMAKE_CFG_INTDIR}/Assets/symlink_2.js") foreach(ASSET ${ASSETS}) get_filename_component(ASSET_NAME "${ASSET}" NAME) add_custom_command(TARGET UnitTests POST_BUILD - COMMAND "${CMAKE_COMMAND}" -E copy "${ASSET}" "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Assets/${ASSET_NAME}" - COMMENT "Copying ${ASSET_NAME}") + COMMAND "${CMAKE_COMMAND}" -E copy "${ASSET}" "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/Assets/${ASSET_NAME}") endforeach() endif() set_property(TARGET UnitTests PROPERTY FOLDER Tests) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES} ${SCRIPTS}) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR}/Scripts PREFIX scripts FILES ${TYPE_SCRIPTS}) -source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR}/Assets PREFIX assets FILES ${ASSETS}) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES} ${TEST_JAVASCRIPT}) +source_group("Assets" FILES ${SCRIPTS} ${ASSETS}) diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts deleted file mode 100644 index 23b8e4e5..00000000 --- a/Tests/UnitTests/Scripts/tests.ts +++ /dev/null @@ -1,2309 +0,0 @@ -import * as Mocha from "mocha"; -import { expect } from "chai"; - -Mocha.setup('bdd'); -// @ts-ignore -Mocha.reporter('spec'); - -declare const hostPlatform: string; -declare const hostEngine: string; -declare const setExitCode: (code: number) => void; - - -describe("AbortController", function () { - it("should not throw while aborting with no callbacks", function () { - const controller = new AbortController(); - expect(controller.signal.aborted).to.equal(false); - - // Trigger with no callbacks - controller.abort(); - - expect(controller.signal.aborted).to.equal(true); - }); - - it("should not throw while aborting and correctly trigger both callbacks", function (done) { - const controller = new AbortController(); - expect(controller.signal.aborted).to.equal(false); - - let cb1 = false, cb2 = false; - - // Expect aborted to be true after abort() - controller.signal.onabort = () => { - expect(controller.signal.aborted).to.equal(true); - cb1 = true; - - if (cb1 && cb2) { - done(); - } - } - - controller.signal.addEventListener("abort", () => { - expect(controller.signal.aborted).to.equal(true); - cb2 = true; - - if (cb1 && cb2) { - done(); - } - }) - - controller.abort(); - }); - - it("should remove listener and not invoke callback function", function () { - const controller = new AbortController(); - expect(controller.signal.aborted).to.equal(false); - - // If this function is unsuccessfully removed it will assert when called - const onAbort = () => { - expect(controller.signal.aborted).to.equal(false); - }; - - controller.signal.addEventListener("abort", onAbort); - controller.signal.removeEventListener("abort", onAbort); - - controller.abort(); - - expect(controller.signal.aborted).to.equal(true); - }); - - it("AbortSignal.abort() returns a signal already aborted with an AbortError reason", function () { - const signal = (AbortSignal as any).abort(); - expect(signal.aborted).to.equal(true); - expect(signal.reason).to.be.an.instanceof(Error); - expect(signal.reason.name).to.equal("AbortError"); - }); - - it("throwIfAborted() throws the reason only once aborted", function () { - const controller = new AbortController(); - // Not aborted yet: must not throw. - (controller.signal as any).throwIfAborted(); - - controller.abort(); - expect(() => (controller.signal as any).throwIfAborted()).to.throw(); - }); - - it("abort(reason) records the provided reason", function () { - const controller = new AbortController(); - const reason = new Error("custom reason"); - controller.abort(reason); - expect((controller.signal as any).reason).to.equal(reason); - }); - - it("abort() with no reason defaults to an AbortError", function () { - const controller = new AbortController(); - controller.abort(); - const reason = (controller.signal as any).reason; - expect(reason).to.be.an.instanceof(Error); - expect(reason.name).to.equal("AbortError"); - }); -}); - -describe("XMLHTTPRequest", function () { - function createRequest(method: string, url: string, body: any = undefined, responseType: any = undefined): Promise { - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - if (responseType !== undefined) { - xhr.responseType = responseType; - } - xhr.addEventListener("loadend", () => resolve(xhr)); - xhr.send(body); - }); - } - - function createRequestWithHeaders(method: string, url: string, headers: any, body?: string): Promise { - return new Promise((resolve) => { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - headers.forEach((value: string, key: string) => xhr.setRequestHeader(key, value)); - xhr.addEventListener("loadend", () => resolve(xhr)); - xhr.send(body); - }); - } - - this.timeout(0); - - it("should have readyState=4 when load ends", async function () { - const xhr = await createRequest("GET", "https://github.com/"); - 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/"); - 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"); - 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"); - 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"); - 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"); - expect(xhr.status).to.equal(404); - }); - - it("should expose statusText", async function () { - const okXhr = await createRequest("GET", "https://github.com/"); - expect(okXhr.statusText).to.equal("OK"); - const notFoundXhr = await createRequest("GET", "https://github.com/babylonJS/BabylonNative404"); - expect(notFoundXhr.statusText).to.equal("Not Found"); - }); - - it("should fire 'error' event for a remote URL that returns HTTP 404", async function () { - // Regression test: previously the success-only continuation in XMLHttpRequest::Send - // skipped 'error' on async failures including non-2xx HTTP responses, so onerror - // observers never ran. See https://github.com/BabylonJS/JsRuntimeHost/pull/165. - this.timeout(30000); - const result = await new Promise<{ errorFired: boolean; loadendFired: boolean; status: number; readyState: number }>((resolve, reject) => { - const xhr = new XMLHttpRequest(); - let errorFired = false; - let loadendFired = false; - const guard = setTimeout(() => reject(new Error("XHR neither errored nor loadended within 25s")), 25000); - xhr.addEventListener("error", () => { errorFired = true; }); - xhr.addEventListener("loadend", () => { - loadendFired = true; - clearTimeout(guard); - resolve({ errorFired, loadendFired, status: xhr.status, readyState: xhr.readyState }); - }); - xhr.open("GET", "https://github.com/babylonJS/BabylonNative404"); - xhr.send(); - }); - expect(result.status).to.equal(404); - expect(result.errorFired).to.equal(true); - expect(result.loadendFired).to.equal(true); - expect(result.readyState).to.equal(4); - }); - - it("should expose errorCode/errorDetail diagnostics after a transport failure", async function () { - this.timeout(30000); - const xhr: any = await createRequest("GET", "http://127.0.0.1:1/"); - expect(xhr.status).to.equal(0); - // Non-standard, additive diagnostics: always strings; populated on Apple/Linux and empty - // on Windows/Android until those backends populate UrlLib's accessors. Either way the - // standard error event + status===0 behavior (asserted above) is unchanged. - expect(xhr.errorCode).to.be.a("string"); - expect(xhr.errorDetail).to.be.a("string"); - }); - - it("should expose empty errorCode/errorDetail after a successful request", async function () { - const xhr: any = await createRequest("GET", "app:///Scripts/symlink_target.js"); - expect(xhr.errorCode).to.equal(""); - expect(xhr.errorDetail).to.equal(""); - }); - - it("should throw something when opening //", async function () { - function openDoubleSlash() { - const xhr = new XMLHttpRequest(); - xhr.open("GET", "//"); - xhr.send(); - } - expect(openDoubleSlash).to.throw(); - }); - - it("should throw something when opening a url with no scheme", function () { - function openNoProtocol() { - const xhr = new XMLHttpRequest(); - xhr.open("GET", "noscheme.glb"); - xhr.send(); - } - expect(openNoProtocol).to.throw(); - }); - - it("should throw something when sending before opening", function () { - function sendWithoutOpening() { - const xhr = new XMLHttpRequest(); - xhr.send(); - } - expect(sendWithoutOpening).to.throw(); - }); - - // TODO: httpbin server seems to be flaky right now. Re-enable these tests later. - // if (hostPlatform !== "Unix") { - // it("should make a POST request with no body successfully", async function () { - // const xhr = await createRequest("POST", "https://httpbin.org/post"); - // expect(xhr).to.have.property("readyState", 4); - // expect(xhr).to.have.property("status", 200); - // }); - - // it("should make a POST request with body successfully", async function () { - // const xhr = await createRequest("POST", "https://httpbin.org/post", "sampleBody"); - // expect(xhr).to.have.property("readyState", 4); - // expect(xhr).to.have.property("status", 200); - // }); - // } - - // it("should make a GET request with headers successfully", async function () { - // const headersMap = new Map([["foo", "3"], ["bar", "3"]]); - // const xhr = await createRequestWithHeaders("GET", "https://httpbin.org/get", headersMap); - // expect(xhr).to.have.property("readyState", 4); - // expect(xhr).to.have.property("status", 200); - // }); - - // if (hostPlatform !== "Unix") { - // it("should make a POST request with body and headers successfully", async function () { - // const headersMap = new Map([["foo", "3"], ["bar", "3"]]); - // const xhr = await createRequestWithHeaders("POST", "https://httpbin.org/post", headersMap, "testBody"); - // expect(xhr).to.have.property("readyState", 4); - // expect(xhr).to.have.property("status", 200); - // }); - // } - - if (hostPlatform === "macOS" || hostPlatform === "Unix" || hostPlatform === "Win32") { - it("should load URL pointing to symlink", async function () { - const xhr = await createRequest("GET", "app:///Scripts/symlink_1.js"); - expect(xhr).to.have.property("responseText", "var symlink_target_js = true;"); - }); - - it("should load URL pointing to symlink that points to a symlink", async function () { - const xhr = await createRequest("GET", "app:///Scripts/symlink_2.js"); - expect(xhr).to.have.property("responseText", "var symlink_target_js = true;"); - }); - } - - it("should load URL as array buffer", async function () { - const xhr = await createRequest("GET", "app:///Scripts/symlink_target.js", undefined, "arraybuffer"); - var expected = new Uint8Array("var symlink_target_js = true;".split("").map(x => x.charCodeAt(0))); - var response = new Uint8Array(xhr.response); - expect(response).to.eql(expected); - }); - - it("should load a PLY file and parse vertex count from header using TextDecoder", async function () { - this.timeout(30000); - const xhr = await createRequest("GET", "app:///Assets/Halo_Believe.ply", undefined, "arraybuffer"); - expect(xhr.status).to.equal(200); - - const ubuf = new Uint8Array(xhr.response); - const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10)); - const headerEnd = "end_header\n"; - const headerEndIndex = header.indexOf(headerEnd); - expect(headerEndIndex).to.be.greaterThan(0); - - const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)![1]); - expect(vertexCount).to.equal(18713); - }); - - it("should not truncate responseText or response at an embedded null byte", async function () { - const xhr = await createRequest("GET", "app:///Assets/embedded_nulls.txt"); - expect(xhr.status).to.equal(200); - expect(xhr.responseText).to.equal("start\0middle\0end"); - expect(xhr.responseText.length).to.equal(16); - expect(xhr.response).to.equal(xhr.responseText); - }); -}); - -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/"); - 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"); - 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/"); - expect(okResponse.statusText).to.equal("OK"); - const notFoundResponse = await fetch("https://github.com/babylonJS/BabylonNative404"); - expect(notFoundResponse.statusText).to.equal("Not Found"); - }); - - it("text() should return the body as a string", async function () { - const response = await fetch("app:///Scripts/symlink_target.js"); - expect(await response.text()).to.equal("var symlink_target_js = true;"); - }); - - it("arrayBuffer() should return the body as bytes", async function () { - const response = await fetch("app:///Scripts/symlink_target.js"); - const expected = new Uint8Array("var symlink_target_js = true;".split("").map(x => x.charCodeAt(0))); - expect(new Uint8Array(await response.arrayBuffer())).to.eql(expected); - }); - - it("json() should parse a JSON body", async function () { - const response = await fetch("app:///Assets/sample.json"); - const json = await response.json(); - expect(json.name).to.equal("fetch-polyfill-test"); - expect(json.value).to.equal(42); - expect(json.nested.items).to.eql([1, 2, 3]); - }); - - it("json() should reject when the body is not valid JSON", async function () { - const response = await fetch("app:///Scripts/symlink_target.js"); - let rejected = false; - try { - await response.json(); - } catch { - rejected = true; - } - expect(rejected).to.equal(true); - }); - - it("blob() should return a Blob with the body bytes", async function () { - const response = await fetch("app:///Scripts/symlink_target.js"); - const blob = await response.blob(); - expect(blob.size).to.equal("var symlink_target_js = true;".length); - expect(await blob.text()).to.equal("var symlink_target_js = true;"); - }); - - it("headers.get() should be case-insensitive and headers.has() should work", async function () { - const response = await fetch("https://github.com/"); - expect(response.headers.has("Content-Type")).to.equal(true); - expect(response.headers.get("CONTENT-TYPE")).to.equal(response.headers.get("content-type")); - }); - - it("clone() should produce an independently readable response", async function () { - const response = await fetch("app:///Scripts/symlink_target.js"); - const clone = response.clone(); - expect(await response.text()).to.equal("var symlink_target_js = true;"); - expect(await clone.text()).to.equal("var symlink_target_js = true;"); - }); - - it("should accept a method in the init object", async function () { - const response = await fetch("https://github.com/", { method: "GET" }); - expect(response.status).to.equal(200); - }); - - it("should reject when no arguments are provided", async function () { - let rejected = false; - try { - await (fetch as any)(); - } catch { - rejected = true; - } - expect(rejected).to.equal(true); - }); - - it("should reject a transport failure with a TypeError carrying detail on cause", async function () { - this.timeout(30000); - let error: any; - try { - // Nothing listens on this loopback port, so the connection is refused -- a transport - // failure (status 0), distinct from an HTTP error status. - await fetch("http://127.0.0.1:1/"); - } catch (e) { - error = e; - } - expect(error, "fetch should have rejected").to.not.equal(undefined); - // Spec-conformant shape: network errors reject with a TypeError whose message is stable - // (browsers/Node/undici all keep it constant so crash-report grouping stays intact). - expect(error).to.be.an.instanceof(TypeError); - expect(error.message).to.equal("fetch failed"); - // The variable detail rides on `cause` (Node/undici shape), never on the stable message. - expect(error.cause, "error.cause should be populated").to.be.an("object"); - expect(error.cause.url).to.contain("127.0.0.1"); - expect(error.cause.status).to.equal(0); - // On backends where UrlLib populates transport detail (Apple/Linux) `code`/`detail` are - // present stable tokens; on backends that don't yet (Windows/Android) they are absent -- - // the stable observable shape above is preserved either way. - if (error.cause.code !== undefined) { - expect(error.cause.code).to.be.a("string").and.not.equal(""); - expect(error.cause.detail).to.be.a("string").and.not.equal(""); - } - }); - - it("should reject a missing app:// asset with a TypeError (distinct from a network failure)", async function () { - let error: any; - try { - await fetch("app:///does_not_exist.js"); - } catch (e) { - error = e; - } - expect(error, "fetch should have rejected").to.not.equal(undefined); - expect(error).to.be.an.instanceof(TypeError); - expect(error.message).to.equal("fetch failed"); - expect(error.cause.url).to.contain("does_not_exist.js"); - }); - - it("should reject immediately with an AbortError when the signal is already aborted", async function () { - const controller = new AbortController(); - controller.abort(); - - let error: any; - try { - await fetch("https://github.com/", { signal: controller.signal } as any); - } catch (e) { - error = e; - } - expect(error, "fetch should have rejected").to.not.equal(undefined); - expect(error.name).to.equal("AbortError"); - }); - - 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); - // Abort before the response can arrive. - controller.abort(); - - let error: any; - try { - await promise; - } catch (e) { - error = e; - } - expect(error, "fetch should have rejected").to.not.equal(undefined); - expect(error.name).to.equal("AbortError"); - }); -}); - -describe("setTimeout", function () { - this.timeout(5000); - - it("should return an id greater than zero", function () { - const id = setTimeout(() => { }, 0); - expect(id).to.be.greaterThan(0); - }); - - it("should return an id greater than zero when given an undefined function", function () { - const id = setTimeout(undefined as any, 0); - expect(id).to.be.greaterThan(0); - }); - - it("should call the given function after the given delay", function (done) { - const startTime = new Date().getTime(); - setTimeout(() => { - try { - expect(new Date().getTime() - startTime).to.be.at.least(10); - done(); - } - catch (e) { - done(e); - } - }, 10); - }); - - it("should call the given nested function after the given delay", function (done) { - const startTime = new Date().getTime(); - setTimeout(() => { - setTimeout(() => { - try { - expect(new Date().getTime() - startTime).to.be.at.least(20); - done(); - } - catch (e) { - done(e); - } - }, 10); - }, 10); - }); - - it("should call the given function after the given delay when the delay is a string representing a valid number", function (done) { - const startTime = new Date().getTime(); - setTimeout(() => { - try { - expect(new Date().getTime() - startTime).to.be.at.least(10); - done(); - } - catch (e) { - done(e); - } - }, "10" as any); - }); - - it("should call the given function after zero milliseconds when the delay is a string representing an invalid number", function (done) { - setTimeout(() => { - done(); - }, "a" as any); - }); - - it("should call the given function after other tasks execute when the given delay is zero", function (done) { - let trailingCodeExecuted = false; - setTimeout(() => { - try { - expect(trailingCodeExecuted).to.be.true; - done(); - } - catch (e) { - done(e); - } - }, 0); - trailingCodeExecuted = true; - }); - - it("should call the given function after other tasks execute when the given delay is undefined", function (done) { - let trailingCodeExecuted = false; - setTimeout(() => { - try { - expect(trailingCodeExecuted).to.be.true; - done(); - } - catch (e) { - done(e); - } - }, undefined); - trailingCodeExecuted = true; - }); - - // See https://github.com/BabylonJS/JsRuntimeHost/issues/9 - // it("should call the given functions in the correct order", function (done) { - // const called = []; - // for (let i = 9; i >= 0; i--) { - // setTimeout(() => { - // called.push(i); - // if (called.length === 10) { - // try { - // expect(called).to.deep.equal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - // done(); - // } - // catch (e) { - // done(e); - // } - // } - // }, i * 10); - // } - // }); -}); - -describe("clearTimeout", function () { - this.timeout(5000); - - it("should stop the timeout matching the given timeout id", function (done) { - const id = setTimeout(() => { - done(new Error("Timeout was not cleared")); - }, 0); - clearTimeout(id); - setTimeout(done, 100); - }); - - it("should do nothing if the given timeout id is undefined", function (done) { - setTimeout(() => { done(); }, 0); - clearTimeout(undefined); - }); - - it("should be interchangeable with clearInterval", function (done) { - const id = setTimeout(() => { - done(new Error("Interval was not cleared")); - }, 0); - clearInterval(id); - setTimeout(done, 100); - }); -}); - -describe("setInterval", function () { - this.timeout(5000); - - it("should return an id greater than zero", function () { - const id = setInterval(() => { }, 0); - clearInterval(id); - expect(id).to.be.greaterThan(0); - }); - - it("should call the given function at the given interval", function (done) { - let startTime = new Date().getTime(); - let tickCount = 0; - const id = setInterval(() => { - try { - tickCount++; - expect(new Date().getTime() - startTime).to.be.at.least(tickCount * 10); - if (tickCount > 2) { - clearInterval(id); - done(); - } - } - catch (e) { - console.log(`finished with error: ${e}`); - clearInterval(id); - done(e); - } - }, 10); - }); - - it("should not starve other queued work when the interval has no delay", function (done) { - // Regression test: a repeating timeout used to be re-armed on the timer - // thread immediately, before its callback had run on the JS thread. With a - // zero delay that produced an unbounded backlog of queued callbacks which - // starved every other item on the JS dispatch queue, so this setTimeout - // would never fire. - let finished = false; - const intervalId = setInterval(() => { }); - - const timeoutId = setTimeout(() => { - finished = true; - clearInterval(intervalId); - done(); - }, 100); - - setTimeout(() => { - if (!finished) { - clearInterval(intervalId); - clearTimeout(timeoutId); - done(new Error("setTimeout was starved by a zero delay setInterval")); - } - }, 2000); - }); - - it("should stop when cleared from within its own callback", function (done) { - // Exercises the re-arm path: a repeating timeout is now re-armed only - // after its callback returns, so a clear from inside the callback must - // win and no further ticks may occur. - let ticks = 0; - let id = 0; - id = setInterval(() => { - ticks++; - clearInterval(id); - }, 10); - - setTimeout(() => { - try { - expect(ticks).to.equal(1); - done(); - } - catch (e) { - done(e); - } - }, 200); - }); -}); - -describe("clearInterval", function () { - this.timeout(5000); - - it("should stop the interval matching the given interval id", function (done) { - const id = setInterval(() => { - done(new Error("Interval was not cleared")); - }, 0); - clearInterval(id); - setTimeout(done, 100); - }); - - it("should do nothing if the given interval id is undefined", function (done) { - setTimeout(() => { done(); }, 0); - clearInterval(undefined); - }); - - it("should be interchangeable with clearTimeout", function (done) { - const id = setInterval(() => { - done(new Error("Interval was not cleared")); - }, 0); - clearTimeout(id); - setTimeout(done, 100); - }); -}); - -// Websocket -if (hostPlatform !== "Unix") { - describe("WebSocket", function () { - this.timeout(10000); - - it("should connect correctly with one websocket connection", function (done) { - const ws = new WebSocket("wss://ws.postman-echo.com/raw"); - const testMessage = "testMessage"; - let error: unknown; - - ws.onopen = () => { - try { - expect(ws).to.have.property("readyState", 1); - expect(ws).to.have.property("url", "wss://ws.postman-echo.com/raw"); - ws.send(testMessage); - } - catch (e) { - error = e; - ws.close(); - } - }; - - ws.onmessage = (msg) => { - try { - expect(msg.data).to.equal(testMessage); - } - catch (e) { - error = e; - } - ws.close(); - }; - - ws.onclose = () => { - if (!error) { - try { - expect(ws).to.have.property("readyState", 3); - } - catch (e) { - error = e; - } - } - done(error); - }; - - ws.onerror = () => { - error = new Error("WebSocket failed"); - }; - }); - - it("should connect correctly with multiple websocket connections", function (done) { - this.timeout(10000); - const testMessage1 = "testMessage1"; - const testMessage2 = "testMessage2"; - let error: unknown; - - const ws1 = new WebSocket("wss://ws.postman-echo.com/raw"); - ws1.onopen = () => { - const ws2 = new WebSocket("wss://ws.postman-echo.com/raw"); - ws2.onopen = () => { - try { - expect(ws2).to.have.property("readyState", 1); - expect(ws2).to.have.property("url", "wss://ws.postman-echo.com/raw"); - ws2.send(testMessage2); - } - catch (e) { - error = e; - ws2.close(); - } - }; - - ws2.onmessage = (msg) => { - try { - expect(msg.data).to.equal(testMessage2); - } - catch (e) { - error = e; - } - ws2.close(); - }; - - ws2.onclose = () => { - if (!error) { - try { - expect(ws2).to.have.property("readyState", 3); - ws1.send(testMessage1); - } - catch (e) { - error = e; - ws1.close(); - } - } - else { - ws1.close(); - } - }; - - ws2.onerror = () => { - error = new Error("WebSocket failed"); - }; - } - - ws1.onmessage = (msg) => { - try { - expect(msg.data).to.equal(testMessage1); - } - catch (e) { - error = e; - } - ws1.close(); - } - - ws1.onclose = () => { - if (!error) { - try { - expect(ws1).to.have.property("readyState", 3); - } - catch (e) { - error = e; - } - } - done(error); - } - - ws1.onerror = () => { - error = new Error("WebSocket failed"); - }; - }); - - it("should trigger error callback with invalid server", function (done) { - this.timeout(10000); - // Random UUID-based hostname so the domain is guaranteed unregistered - // (RFC-reserved `.invalid` causes a >10s DNS path on Win32 x86 Chakra). - const ws = new WebSocket("wss://caddddfd-ee88-4771-b293-8a8e13b330ee.com"); - let errorFired = false; - ws.onerror = () => { - errorFired = true; - }; - ws.onclose = () => { - try { - expect(errorFired).to.be.true; - done(); - } - catch (e) { - done(e); - } - }; - }); - - it("should trigger error callback with invalid domain", function (done) { - this.timeout(10000); - const ws = new WebSocket("wss://example"); - let errorFired = false; - ws.onerror = () => { - errorFired = true; - }; - ws.onclose = () => { - try { - expect(errorFired).to.be.true; - done(); - } - catch (e) { - done(e); - } - }; - }); - }) -} - -// URL -describe("URL", function () { - - // Currently all of the properties that the polyfill has implemented - interface URLCheckOptions { - href: string; - hostname: string; - origin: string; - pathname: string; - search: string; - } - - function checkURL(url: URL, { href, hostname, origin, pathname, search }: URLCheckOptions): void { - expect(url).to.have.property("hostname", hostname); - expect(url).to.have.property("href", href); - expect(url).to.have.property("origin", origin); - expect(url).to.have.property("pathname", pathname); - expect(url).to.have.property("search", search); - } - - it("should load URL with no pathname / search", function () { - // Standard URL (No pathname, no search) - const url = new URL("https://httpbin.org"); - checkURL(url, { - href: "https://httpbin.org/", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/", - search: "" - }); - }); - - it("should load URL with pathname (no search)", function () { - // Augment URL with pathname (no search) - const url = new URL("https://httpbin.org/en-US/docs"); - checkURL(url, { - href: "https://httpbin.org/en-US/docs", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/en-US/docs", - search: "" - }); - }); - - it("should load URL with pathname and search", function () { - // Augment URL with pathname and search - const url = new URL("https://httpbin.org/en-US/docs?foo=1&bar=2"); - checkURL(url, { - href: "https://httpbin.org/en-US/docs?foo=1&bar=2", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/en-US/docs", - search: "?foo=1&bar=2" - }); - }); - - it("should load URL with pathname and search with multiple key value pairs", function () { - const url = new URL("https://httpbin.org/en-US/docs?c=3&b=2&a=1&d=4"); - checkURL(url, { - href: "https://httpbin.org/en-US/docs?c=3&b=2&a=1&d=4", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/en-US/docs", - search: "?c=3&b=2&a=1&d=4" - }); - }); - - it("should update href after URLSearchParams are changed", function () { - // Augment URL with pathname and search - const url = new URL("https://httpbin.org/en-US/docs?foo=1&bar=2"); - url.searchParams.set("foo", 999 as any); - // href should change to reflect searchParams change - checkURL(url, { - href: "https://httpbin.org/en-US/docs?foo=999&bar=2", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/en-US/docs", - search: "?foo=999&bar=2" - }); - }); - - it("should update href after URLSearchParams are changed (Starting with 0 params)", function () { - // Augment URL with pathname and search - const url = new URL("https://httpbin.org/en-US/docs"); - url.searchParams.set("foo", "999"); - // href should change to reflect searchParams change - checkURL(url, { - href: "https://httpbin.org/en-US/docs?foo=999", - hostname: "httpbin.org", - origin: "https://httpbin.org", - pathname: "/en-US/docs", - search: "?foo=999" - }); - }); - - // -------------------------------- URL Properties -------------------------------- - - it("should parse protocol correctly", function () { - const url = new URL("https://example.com/path"); - expect(url.protocol).to.equal("https:"); - }); - - it("should parse port correctly", function () { - const url = new URL("https://example.com:8080/path"); - expect(url.port).to.equal("8080"); - expect(url.host).to.equal("example.com:8080"); - }); - - it("should return empty port when not specified", function () { - const url = new URL("https://example.com/path"); - expect(url.port).to.equal(""); - expect(url.host).to.equal("example.com"); - }); - - it("should parse hash correctly", function () { - const url = new URL("https://example.com/path#section1"); - expect(url.hash).to.equal("#section1"); - }); - - it("should return empty hash when not present", function () { - const url = new URL("https://example.com/path"); - expect(url.hash).to.equal(""); - }); - - it("should parse username and password correctly", function () { - const url = new URL("https://user:pass@example.com/path"); - expect(url.username).to.equal("user"); - expect(url.password).to.equal("pass"); - }); - - it("should return empty username and password when not present", function () { - const url = new URL("https://example.com/path"); - expect(url.username).to.equal(""); - expect(url.password).to.equal(""); - }); - - it("should parse URL with all components", function () { - const url = new URL("https://user:pass@example.com:8080/path/to/resource?foo=1&bar=2#section"); - expect(url.protocol).to.equal("https:"); - expect(url.username).to.equal("user"); - expect(url.password).to.equal("pass"); - expect(url.hostname).to.equal("example.com"); - expect(url.port).to.equal("8080"); - expect(url.host).to.equal("example.com:8080"); - expect(url.pathname).to.equal("/path/to/resource"); - expect(url.search).to.equal("?foo=1&bar=2"); - expect(url.hash).to.equal("#section"); - expect(url.origin).to.equal("https://example.com:8080"); - }); - - // -------------------------------- URL Property Setters -------------------------------- - - it("should update protocol via setter", function () { - const url = new URL("https://example.com/path"); - url.protocol = "http:"; - expect(url.protocol).to.equal("http:"); - expect(url.href).to.equal("http://example.com/path"); - }); - - it("should add colon to protocol if missing", function () { - const url = new URL("https://example.com/path"); - url.protocol = "http"; - expect(url.protocol).to.equal("http:"); - }); - - it("should update hostname via setter", function () { - const url = new URL("https://example.com/path"); - url.hostname = "newhost.com"; - expect(url.hostname).to.equal("newhost.com"); - expect(url.href).to.equal("https://newhost.com/path"); - }); - - it("should update host via setter (hostname only)", function () { - const url = new URL("https://example.com:8080/path"); - url.host = "newhost.com"; - expect(url.hostname).to.equal("newhost.com"); - expect(url.port).to.equal(""); - expect(url.host).to.equal("newhost.com"); - }); - - it("should update host via setter (hostname and port)", function () { - const url = new URL("https://example.com/path"); - url.host = "newhost.com:9090"; - expect(url.hostname).to.equal("newhost.com"); - expect(url.port).to.equal("9090"); - expect(url.host).to.equal("newhost.com:9090"); - }); - - it("should update port via setter", function () { - const url = new URL("https://example.com/path"); - url.port = "3000"; - expect(url.port).to.equal("3000"); - expect(url.href).to.equal("https://example.com:3000/path"); - }); - - it("should update pathname via setter", function () { - const url = new URL("https://example.com/path"); - url.pathname = "/new/path"; - expect(url.pathname).to.equal("/new/path"); - expect(url.href).to.equal("https://example.com/new/path"); - }); - - it("should add leading slash to pathname if missing", function () { - const url = new URL("https://example.com/path"); - url.pathname = "new/path"; - expect(url.pathname).to.equal("/new/path"); - }); - - it("should update search via setter", function () { - const url = new URL("https://example.com/path"); - url.search = "?newkey=newvalue"; - expect(url.search).to.equal("?newkey=newvalue"); - expect(url.href).to.equal("https://example.com/path?newkey=newvalue"); - }); - - it("should update search via setter without leading question mark", function () { - const url = new URL("https://example.com/path"); - url.search = "key=value"; - expect(url.search).to.equal("?key=value"); - }); - - it("should update hash via setter", function () { - const url = new URL("https://example.com/path"); - url.hash = "#newsection"; - expect(url.hash).to.equal("#newsection"); - expect(url.href).to.equal("https://example.com/path#newsection"); - }); - - it("should add leading hash if missing", function () { - const url = new URL("https://example.com/path"); - url.hash = "newsection"; - expect(url.hash).to.equal("#newsection"); - }); - - it("should update username via setter", function () { - const url = new URL("https://example.com/path"); - url.username = "newuser"; - expect(url.username).to.equal("newuser"); - expect(url.href).to.equal("https://newuser@example.com/path"); - }); - - it("should update password via setter", function () { - const url = new URL("https://user@example.com/path"); - url.password = "newpass"; - expect(url.password).to.equal("newpass"); - expect(url.href).to.equal("https://user:newpass@example.com/path"); - }); - - it("should update href via setter", function () { - const url = new URL("https://example.com/path"); - url.href = "https://newsite.com/newpath?q=1#hash"; - expect(url.hostname).to.equal("newsite.com"); - expect(url.pathname).to.equal("/newpath"); - expect(url.search).to.equal("?q=1"); - expect(url.hash).to.equal("#hash"); - }); - - // -------------------------------- URL Instance Methods -------------------------------- - - it("should return href from toString()", function () { - const url = new URL("https://example.com/path?foo=1#section"); - expect(url.toString()).to.equal("https://example.com/path?foo=1#section"); - }); - - it("should return href from toJSON()", function () { - const url = new URL("https://example.com/path?foo=1#section"); - expect(url.toJSON()).to.equal("https://example.com/path?foo=1#section"); - }); - - // -------------------------------- URL Static Methods -------------------------------- - - it("should return true from canParse() for valid absolute URL", function () { - expect(URL.canParse("https://example.com")).to.equal(true); - }); - - it("should return false from canParse() for invalid URL", function () { - expect(URL.canParse("not-a-url")).to.equal(false); - }); - - it("should return true from canParse() for relative URL with valid base", function () { - expect(URL.canParse("/path", "https://example.com")).to.equal(true); - }); - - it("should return false from canParse() for relative URL with invalid base", function () { - expect(URL.canParse("/path", "not-a-url")).to.equal(false); - }); - - it("should return false from canParse() with no arguments", function () { - // @ts-expect-error - testing no arguments - expect(URL.canParse()).to.equal(false); - }); - - it("should return URL object from parse() for valid URL", function () { - const url = URL.parse("https://example.com/path"); - expect(url).to.not.equal(null); - expect(url!.hostname).to.equal("example.com"); - expect(url!.pathname).to.equal("/path"); - }); - - // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. - // it("should return null from parse() for invalid URL", function () { - // const url = URL.parse("not-a-url"); - // expect(url).to.equal(null); - // }); - - it("should return URL object from parse() with valid base", function () { - const url = URL.parse("/path", "https://example.com"); - expect(url).to.not.equal(null); - expect(url!.hostname).to.equal("example.com"); - expect(url!.pathname).to.equal("/path"); - }); - - // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. - // it("should return null from parse() with invalid base", function () { - // const url = URL.parse("/path", "not-a-url"); - // expect(url).to.equal(null); - // }); - - // -------------------------------- URL Base URL Resolution -------------------------------- - - it("should resolve relative URL with base URL", function () { - const url = new URL("/path/to/resource", "https://example.com"); - expect(url.href).to.equal("https://example.com/path/to/resource"); - }); - - it("should resolve relative path against base URL", function () { - const url = new URL("resource.html", "https://example.com/path/to/"); - expect(url.href).to.equal("https://example.com/path/to/resource.html"); - }); - - it("should resolve relative path against base URL with file", function () { - const url = new URL("resource.html", "https://example.com/path/to/index.html"); - expect(url.href).to.equal("https://example.com/path/to/resource.html"); - }); - - it("should resolve query-only relative URL", function () { - const url = new URL("?newquery=1", "https://example.com/path"); - expect(url.href).to.equal("https://example.com/path?newquery=1"); - }); - - it("should resolve hash-only relative URL", function () { - const url = new URL("#newhash", "https://example.com/path?query=1"); - expect(url.href).to.equal("https://example.com/path?query=1#newhash"); - }); - - it("should resolve protocol-relative URL", function () { - const url = new URL("//other.com/path", "https://example.com"); - expect(url.href).to.equal("https://other.com/path"); - }); - - it("should use absolute URL and ignore base when URL is absolute", function () { - const url = new URL("https://other.com/otherpath", "https://example.com/path"); - expect(url.href).to.equal("https://other.com/otherpath"); - }); - - it("should resolve dot segments in path (single dot)", function () { - const url = new URL("https://example.com/a/b/./c"); - expect(url.pathname).to.equal("/a/b/c"); - }); - - it("should resolve dot segments in path (double dot)", function () { - const url = new URL("https://example.com/a/b/../c"); - expect(url.pathname).to.equal("/a/c"); - }); - - it("should resolve complex dot segments", function () { - const url = new URL("https://example.com/a/b/c/../d/./e/../f"); - expect(url.pathname).to.equal("/a/b/d/f"); - }); - - it("should resolve relative path with dot segments", function () { - const url = new URL("../sibling/file.html", "https://example.com/path/to/current/"); - expect(url.pathname).to.equal("/path/to/sibling/file.html"); - }); - - // -------------------------------- URL Error Handling -------------------------------- - - // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. - // it("should throw for invalid URL without base", function () { - // expect(() => new URL("not-a-valid-url")).to.throw(); - // }); - - // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. - // it("should throw for relative URL without base", function () { - // expect(() => new URL("/path/to/resource")).to.throw(); - // }); - - // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. - // it("should throw for empty URL constructor", function () { - // // @ts-expect-error - testing no arguments - // expect(() => new URL()).to.throw(); - // }); - - // -------------------------------- URL Different Schemes -------------------------------- - - it("should parse file:// URL", function () { - const url = new URL("file:///path/to/file.txt"); - expect(url.protocol).to.equal("file:"); - expect(url.pathname).to.equal("/path/to/file.txt"); - }); - - it("should parse ftp:// URL", function () { - const url = new URL("ftp://ftp.example.com/file.zip"); - expect(url.protocol).to.equal("ftp:"); - expect(url.hostname).to.equal("ftp.example.com"); - }); - - it("should parse ws:// URL", function () { - const url = new URL("ws://example.com/socket"); - expect(url.protocol).to.equal("ws:"); - expect(url.hostname).to.equal("example.com"); - }); - - it("should parse wss:// URL", function () { - const url = new URL("wss://example.com/socket"); - expect(url.protocol).to.equal("wss:"); - expect(url.hostname).to.equal("example.com"); - }); - - it("should parse custom scheme URL", function () { - const url = new URL("app:///Scripts/test.js"); - expect(url.protocol).to.equal("app:"); - expect(url.pathname).to.equal("/Scripts/test.js"); - }); -}); - -// URL.createObjectURL / revokeObjectURL (blob: URL registry) -describe("URL.createObjectURL", function () { - this.timeout(0); - - it("mints a blob: URL for a Blob", function () { - const url = URL.createObjectURL(new Blob(["hello"], { type: "text/plain" })); - expect(url).to.be.a("string"); - expect(url.indexOf("blob:")).to.equal(0); - URL.revokeObjectURL(url); - }); - - it("throws when createObjectURL is given a non-Blob", function () { - expect(() => URL.createObjectURL({} as any)).to.throw(); - expect(() => URL.createObjectURL("not a blob" as any)).to.throw(); - }); - - it("resolves a blob: URL through fetch (text + content-type)", async function () { - const url = URL.createObjectURL(new Blob(["hello blob"], { type: "text/plain" })); - const response = await fetch(url); - expect(response.ok).to.equal(true); - expect(response.status).to.equal(200); - expect(response.headers.get("content-type")).to.equal("text/plain"); - expect(await response.text()).to.equal("hello blob"); - URL.revokeObjectURL(url); - }); - - it("resolves binary blob bytes through fetch", async function () { - const bytes = new Uint8Array([1, 2, 3, 4, 250]); - const url = URL.createObjectURL(new Blob([bytes])); - const response = await fetch(url); - const buffer = new Uint8Array(await response.arrayBuffer()); - expect(Array.from(buffer)).to.deep.equal([1, 2, 3, 4, 250]); - URL.revokeObjectURL(url); - }); - - it("resolves a blob: URL through XMLHttpRequest", async function () { - const url = URL.createObjectURL(new Blob(["xhr blob"], { type: "text/plain" })); - const xhr = await new Promise((resolve) => { - const req = new XMLHttpRequest(); - req.open("GET", url); - req.addEventListener("loadend", () => resolve(req)); - req.send(); - }); - expect(xhr.status).to.equal(200); - expect(xhr.statusText).to.equal("OK"); - expect(xhr.responseText).to.equal("xhr blob"); - expect(xhr.getResponseHeader("content-type")).to.equal("text/plain"); - URL.revokeObjectURL(url); - }); - - it("fetch rejects after the blob: URL is revoked", async function () { - const url = URL.createObjectURL(new Blob(["gone"])); - URL.revokeObjectURL(url); - let rejected = false; - try { - await fetch(url); - } catch (e) { - rejected = true; - } - expect(rejected).to.equal(true); - }); - - it("XMLHttpRequest reports status 0 and fires 'error' after revoke", async function () { - const url = URL.createObjectURL(new Blob(["gone"])); - URL.revokeObjectURL(url); - const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { - const req = new XMLHttpRequest(); - let errorFired = false; - req.addEventListener("error", () => { errorFired = true; }); - req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); - req.open("GET", url); - req.send(); - }); - expect(result.status).to.equal(0); - expect(result.errorFired).to.equal(true); - }); - - it("XMLHttpRequest honors a revoke between open() and send()", async function () { - const url = URL.createObjectURL(new Blob(["late revoke"])); - const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { - const req = new XMLHttpRequest(); - let errorFired = false; - req.addEventListener("error", () => { errorFired = true; }); - req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); - req.open("GET", url); - // Revoked after open() but before send(): the store is re-checked at send() time, so - // this must surface as a network error rather than serving stale bytes. - URL.revokeObjectURL(url); - req.send(); - }); - expect(result.status).to.equal(0); - expect(result.errorFired).to.equal(true); - }); -}); - -// URLSearchParams -describe("URLSearchParams", function () { - - // -------------------------------- URLSearchParams Get -------------------------------- - - it("should retrieve null from empty searchParams", function () { - // Get Empty - const params = new URLSearchParams(""); - - expect(params.get("foo")).to.equal(null); - }); - - it("should retrieve value from searchParams", function () { - // Get Value - const params = new URLSearchParams("?foo=1"); - - expect(params.get("foo")).to.equal("1"); - }); - - // -------------------------------- URLSearchParams Set -------------------------------- - - const paramsSet = new URLSearchParams(""); - - it("should throw exception when trying to set with less than 2 parameters", function () { - // `set` expects parameters, none given. - // @ts-expect-error - expect(() => paramsSet.set()).to.throw(); - }); - - it("should preserve the type and message of an error thrown from native code", function () { - // A native throw must reach JS unchanged. When napi_throw reported failure, the - // error was replaced by an InternalError reading "Uncaught C++ exception: ...", - // built by stringifying an error whose handle scope had already closed. - let caught: any; - try { - // @ts-expect-error - paramsSet.set(); - } catch (e) { - caught = e; - } - expect(caught).to.be.an.instanceOf(Error); - expect(caught.name).to.equal("Error"); - // Not an equality check: the JSI backend prefixes "Exception in HostFunction: ". - expect(caught.message).to.contain( - "Failed to execute 'set' on 'URLSearchParams': 2 arguments required, but only 0 present" - ); - expect(caught.message).to.not.contain("Uncaught C++ exception"); - }); - - it("should add a number and retrieve it as a string from searchParams", function () { - // Set Number - paramsSet.set("foo", 400 as any); - expect(paramsSet.get("foo")).to.equal("400"); - }); - - it("should add a string and retrieve it as a string from searchParams", function () { - // Set String - paramsSet.set("bar", "50"); - expect(paramsSet.get("bar")).to.equal("50"); - }); - - it("should add a boolean and retrieve it as a string from searchParams", function () { - // Set Boolean - paramsSet.set("baz", true as any); - expect(paramsSet.get("baz")).to.equal("true"); - }); - - it("should set an existing number and retrieve it as a string from searchParams", function () { - // Set Existing Value - paramsSet.set("foo", 9999 as any); - expect(paramsSet.get("foo")).to.equal("9999"); - }); - - // -------------------------------- URLSearchParams Has -------------------------------- - - it("should check value is in searchParams (True)", function () { - // Check existing value - const paramsHas = new URLSearchParams("?foo=1"); - expect(paramsHas.has("foo")).to.equal(true); - }); - - it("should check value is in searchParams (False)", function () { - // Check non-existing value - const paramsHas = new URLSearchParams("?foo=1"); - expect(paramsHas.has("Microsoft")).to.equal(false); - }); - - it("should check empty searchParams for value (False)", function () { - // Check empty params - const paramsEmpty = new URLSearchParams(""); - expect(paramsEmpty.has("foo")).to.equal(false); - }); - - // -------------------------------- URLSearchParams Construction -------------------------------- - - it("should retrieve search params set at construction", function () { - // Retrieve params via url.search, passed into ctor - const url = new URL("https://example.com?foo=1&bar=2"); - const params1 = new URLSearchParams(url.search); - expect(params1.get("foo")).to.equal("1"); - expect(params1.get("bar")).to.equal("2"); - }); - - it("should retrieve search params from url.searchParams object", function () { - // Get the URLSearchParams object directly from an URL object - const url = new URL("https://example.com?foo=1&bar=2"); - const params1a = url.searchParams; - expect(params1a.get("foo")).to.equal("1"); - expect(params1a.get("bar")).to.equal("2"); - }); - - it("should retrieve search params string constructed URLSearchParams", function () { - // Pass in a string literal - const params2 = new URLSearchParams("foo=1&bar=2"); - expect(params2.get("foo")).to.equal("1"); - expect(params2.get("bar")).to.equal("2"); - }); -}); - -describe("Console", function () { - it("should log a simple console log string without error", function () { - expect(() => console.log("I am a test string")).to.not.throw(); - }); - it("should log sequence of strings", function () { - expect(() => console.log("I", "am", "a", "test", "string", 2, 2.345, { foo: "bar" })).to.not.throw(); - }); - it("Should log string substitutions", function () { - expect(() => console.log("String sub: %s, float sub: %f, int sub: %d", "string", 3.1457, 3.1457)).to.not.throw(); - }); - it("Should allow numbers to substitute strings", function () { - expect(() => console.log("Print these numbers! %s %s", 1.2345, 1)).to.not.throw(); - }); - it("Should allow strings to substitute numbers", function () { - expect(() => console.log("Print these strings! %f %d", "foo", "bar")).to.not.throw(); - }); - it("Should allow for less substitution arguments than parameters", function () { - expect(() => console.log("%s", "I am a string", 12345)).to.not.throw(); - }); - it("Should allow for more substitution arguments than parameters", function () { - expect(() => console.log("%s %s", "I am a string")).to.not.throw(); - }); - it("Should allow a substitution argument with no parameter", function () { - expect(() => console.log("%s")).to.not.throw(); - }); - it("Should ignore invalid substitution arguments", function () { - expect(() => console.log("%y %s %k", "I am a string")).to.not.throw(); - expect(() => console.log("%y%s%k", "I am a string")).to.not.throw(); - expect(() => console.log("%", 756)).to.not.throw(); - expect(() => console.log("%%", 756)).to.not.throw(); - }); - it("Should allow logging NaN", function () { - expect(() => console.log("%d %f %s", NaN, NaN, NaN)).to.not.throw(); - expect(() => console.log("%d %f %s", 0 / 0, 0 / 0, 0 / 0)).to.not.throw(); - }); -}); - -describe("Blob", function () { - let emptyBlobs: Blob[], helloBlobs: Blob[], stringBlob: Blob, typedArrayBlob: Blob, arrayBufferBlob: Blob, blobBlob: Blob; - - before(function () { - emptyBlobs = [new Blob([]), new Blob([])]; - stringBlob = new Blob(["Hello"]); - typedArrayBlob = new Blob([new Uint8Array([72, 101, 108, 108, 111])]), - arrayBufferBlob = new Blob([new Uint8Array([72, 101, 108, 108, 111]).buffer]), - blobBlob = new Blob([new Blob(["Hello"])]), - helloBlobs = [stringBlob, typedArrayBlob, arrayBufferBlob, blobBlob] - }); - - // -------------------------------- Blob Construction -------------------------------- - it("creates empty blobs", function () { - for (const blob of emptyBlobs) { - expect(blob.size).to.equal(0); - expect(blob.type).to.equal(""); - } - }); - - it("creates blob from string array", function () { - expect(stringBlob.size).to.equal(5); - expect(stringBlob.type).to.equal(""); - }); - - it("creates blob from TypedArray", function () { - expect(typedArrayBlob.size).to.equal(5); - expect(typedArrayBlob.type).to.equal(""); - }); - - it("creates blob from ArrayBuffer", function () { - expect(arrayBufferBlob.size).to.equal(5); - expect(arrayBufferBlob.type).to.equal(""); - }); - - it("creates blob from another Blob", function () { - expect(blobBlob.size).to.equal(5); - expect(blobBlob.type).to.equal(""); - }); - - it("applies MIME type from options", function () { - const modelGltfJson = new Blob(["glTF"], { type: "model/gltf+json" }) - expect(modelGltfJson.type).to.equal("model/gltf+json"); - }); - - // -------------------------------- Blob.text() -------------------------------- - it("returns empty string for empty blobs", async function () { - for (const blob of emptyBlobs) { - const text = await blob.text(); - expect(text).to.equal(""); - } - }); - - it("returns correct string content for non-empty blobs", async function () { - for (const blob of helloBlobs) { - const text = await blob.text(); - expect(text).to.equal("Hello"); - } - }); - - it("handles multi-byte UTF-8 characters", async function () { - const utf8Blob = new Blob(["你好, 世界"]); - const text = await utf8Blob.text(); - expect(text).to.equal("你好, 世界"); - }); - - it("preserves line endings like default transparent mode", async function () { - const lineEndingsBlob = new Blob(["Hello\nWorld"]); - const text = await lineEndingsBlob.text(); - expect(text).to.equal("Hello\nWorld"); - }); - - // -------------------------------- Blob.bytes() -------------------------------- - it("returns empty Uint8Array for empty blobs", async function () { - for (const blob of emptyBlobs) { - const bytes = await blob.bytes(); - expect(bytes).to.be.instanceOf(Uint8Array); - expect(bytes.length).to.equal(0); - } - }); - - it("returns correct byte content from non-empty blobs", async function () { - for (const blob of helloBlobs) { - const bytes = await blob.bytes(); - expect(bytes).to.be.instanceOf(Uint8Array); - expect(bytes.length).to.equal(5); - expect(bytes[0]).to.equal(72); // 'H' - expect(bytes[4]).to.equal(111); // 'o' - } - }); - - // -------------------------------- Blob.arrayBuffer() -------------------------------- - it("returns empty buffer for empty blobs", async function () { - for (const blob of emptyBlobs) { - const buffer = await blob.arrayBuffer(); - expect(buffer).to.be.instanceOf(ArrayBuffer); - expect(buffer.byteLength).to.equal(0); - } - }); - - it("returns correct buffer content for non-empty blobs", async function () { - for (const blob of helloBlobs) { - const buffer = await blob.arrayBuffer(); - expect(buffer).to.be.instanceOf(ArrayBuffer); - expect(buffer.byteLength).to.equal(5); - - const view = new Uint8Array(buffer); - expect(view[0]).to.equal(72); // 'H' - expect(view[4]).to.equal(111); // 'o' - - } - }); -}); - -describe("napi class prototype isolation (#172)", function () { - // Regression coverage for #172: napi-defined class constructors must each - // get a fresh per-class object as their `.prototype` property. Previously - // on JSC every napi class shared the global Object.prototype, so writes - // to one class's prototype polluted every object and every plain `{}` - // erroneously satisfied `instanceof` for every napi class. - - it("Blob.prototype is not the global Object.prototype", function () { - expect(Blob.prototype).to.not.equal(Object.prototype); - }); - - it("Blob.prototype chains to Object.prototype", function () { - expect(Object.getPrototypeOf(Blob.prototype)).to.equal(Object.prototype); - }); - - it("Blob.prototype.constructor points back to Blob", function () { - expect(Blob.prototype.constructor).to.equal(Blob); - }); - - it("instances inherit from Blob.prototype", function () { - const blob = new Blob([]); - expect(Object.getPrototypeOf(blob)).to.equal(Blob.prototype); - }); - - it("plain objects are not instanceof Blob", function () { - expect({} instanceof Blob).to.equal(false); - }); - - it("writes to Blob.prototype do not pollute Object.prototype", function () { - const KEY = "__jrh172_blob_prototype_marker__"; - const proto = Blob.prototype as any; - try { - proto[KEY] = 1; - expect(KEY in {}).to.equal(false); - } finally { - delete proto[KEY]; - } - }); -}); - - -describe("Performance", function () { - this.timeout(1000); - - it("should have performance global defined", function () { - expect(performance).to.not.be.undefined; - }); - - it("should return a number from performance.now()", function () { - const now = performance.now(); - expect(now).to.be.a("number"); - }); - - it("should return a non-negative value from performance.now()", function () { - const now = performance.now(); - expect(now).to.be.at.least(0); - }); - - it("should return increasing values from performance.now()", function (done) { - const first = performance.now(); - setTimeout(() => { - const second = performance.now(); - expect(second).to.be.greaterThan(first); - done(); - }, 10); - }); - - it("should measure elapsed time accurately", function (done) { - const start = performance.now(); - const delay = 50; - setTimeout(() => { - const elapsed = performance.now() - start; - // setTimeout guarantees a minimum delay, not a maximum. - // Only check the lower bound to avoid flakes on busy CI agents. - expect(elapsed).to.be.at.least(delay - 5); - done(); - }, delay); - }); - - it("should return sub-millisecond precision", function () { - // Call performance.now() multiple times rapidly and check if we get fractional values - const samples: number[] = []; - for (let i = 0; i < 100; i++) { - samples.push(performance.now()); - } - // At least some samples should have fractional parts (sub-millisecond precision) - const hasFractional = samples.some(s => s % 1 !== 0); - expect(hasFractional).to.equal(true); - }); -}); - -describe("TextDecoder", function () { - it("should decode a Uint8Array to a string", function () { - const decoder = new TextDecoder(); - const encoded = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" - const result = decoder.decode(encoded); - expect(result).to.equal("Hello"); - }); - - it("should decode an empty Uint8Array to an empty string", function () { - const decoder = new TextDecoder(); - const result = decoder.decode(new Uint8Array([])); - expect(result).to.equal(""); - }); - - it("should decode an ArrayBuffer to a string", function () { - const decoder = new TextDecoder(); - const buffer = new Uint8Array([87, 111, 114, 108, 100]).buffer; // "World" - const result = decoder.decode(buffer); - expect(result).to.equal("World"); - }); - - it("should decode a TypedArray subarray with non-zero byteOffset", function () { - const decoder = new TextDecoder(); - const full = new Uint8Array([88, 72, 105]); // "XHi" - const sub = full.subarray(1); // [72, 105] -> "Hi" - const result = decoder.decode(sub); - expect(result).to.equal("Hi"); - }); - - it("should decode a Uint8Array containing a null byte", function () { - const decoder = new TextDecoder(); - const encoded = new Uint8Array([72, 0, 105]); // "H\0i" - const result = decoder.decode(encoded); - expect(result).to.equal("H\0i"); - expect(result.length).to.equal(3); - }); - - it("throwing from the constructor repeatedly does not corrupt native state", function () { - // Regression for a Chakra N-API ObjectWrap bug: when a wrapped - // constructor throws, the native instance is destroyed during stack - // unwinding but the wrap finalizer stayed attached to `this`, so a - // later GC ran the finalizer on freed memory (heap corruption). Throw - // many times to create many dangling wraps, then allocate/decode to - // exercise the heap and surface any corruption within this test run. - for (let i = 0; i < 100; ++i) { - expect(() => new TextDecoder("iso-8859-2")).to.throw(); - } - const decoder = new TextDecoder("utf-8"); - expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); - }); - - it("should accept the WHATWG 'utf8' label (no hyphen)", function () { - const decoder = new TextDecoder("utf8"); - const result = decoder.decode(new Uint8Array([72, 105])); // "Hi" - expect(result).to.equal("Hi"); - }); - - it("should accept utf-8 labels case-insensitively and with surrounding whitespace", function () { - for (const label of ["UTF-8", "UTF8", " utf-8 ", "\tUtf8\n"]) { - const decoder = new TextDecoder(label); - expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); - } - }); - - it("should accept the other WHATWG utf-8 aliases", function () { - for (const label of ["unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8", "x-unicode20utf8"]) { - const decoder = new TextDecoder(label); - expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); - } - }); - - it("should still throw for a genuinely unsupported encoding", function () { - expect(() => new TextDecoder("iso-8859-2")).to.throw(); - }); - - it("should decode utf-16le", function () { - const decoder = new TextDecoder("utf-16le"); - // "Hi" as UTF-16LE code units. - expect(decoder.decode(new Uint8Array([0x48, 0x00, 0x69, 0x00]))).to.equal("Hi"); - }); - - it("should decode utf-16be", function () { - const decoder = new TextDecoder("utf-16be"); - expect(decoder.decode(new Uint8Array([0x00, 0x48, 0x00, 0x69]))).to.equal("Hi"); - }); - - it("should accept the other WHATWG utf-16 aliases as little endian", function () { - for (const label of ["utf-16", "ucs-2", "unicode", "unicodeFEFF", "csunicode", "iso-10646-ucs-2"]) { - const decoder = new TextDecoder(label); - expect(decoder.decode(new Uint8Array([0x4F, 0x00, 0x4B, 0x00]))).to.equal("OK"); - } - expect(new TextDecoder("unicodeFFFE").decode(new Uint8Array([0x00, 0x4F, 0x00, 0x4B]))).to.equal("OK"); - }); - - it("should strip a leading byte order mark", function () { - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0xFF, 0xFE, 0x48, 0x00]))).to.equal("H"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xFE, 0xFF, 0x00, 0x48]))).to.equal("H"); - }); - - it("should decode utf-16 outside the BMP and preserve null code units", function () { - // U+1F600 as a surrogate pair, then U+0000, then "A". - const decoder = new TextDecoder("utf-16le"); - const result = decoder.decode(new Uint8Array([0x3D, 0xD8, 0x00, 0xDE, 0x00, 0x00, 0x41, 0x00])); - expect(result).to.equal("\u{1F600}\0A"); - expect(result.length).to.equal(4); - }); - - it("should replace a trailing odd UTF-16 byte with U+FFFD", function () { - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x48, 0x00, 0x00]))).to.equal("H\uFFFD"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0x00, 0x48, 0x00]))).to.equal("H\uFFFD"); - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00]))).to.equal("\uFFFD"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0x00]))).to.equal("\uFFFD"); - }); - - it("should replace unpaired UTF-16 surrogates with U+FFFD", function () { - // Lone lead U+D800. - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8]))).to.equal("\uFFFD"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00]))).to.equal("\uFFFD"); - // Lone trail U+DC00. - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xDC]))).to.equal("\uFFFD"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xDC, 0x00]))).to.equal("\uFFFD"); - // Lead followed by BMP 'A': replacement, then reprocess 'A'. - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8, 0x41, 0x00]))).to.equal("\uFFFDA"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00, 0x00, 0x41]))).to.equal("\uFFFDA"); - // Unpaired lead plus leftover odd byte is a single end-of-queue replacement. - expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8, 0x00]))).to.equal("\uFFFD"); - expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00, 0x00]))).to.equal("\uFFFD"); - }); -}); - -describe("TextEncoder", function () { - it("should expose encoding === 'utf-8'", function () { - const encoder = new TextEncoder(); - expect(encoder.encoding).to.equal("utf-8"); - }); - - it("should encode an ASCII string into UTF-8 bytes", function () { - const encoder = new TextEncoder(); - const bytes = encoder.encode("Hello"); - expect(Array.from(bytes)).to.eql([72, 101, 108, 108, 111]); - }); - - it("should return an empty Uint8Array when called with no argument", function () { - const encoder = new TextEncoder(); - const bytes = encoder.encode(); - expect(bytes.length).to.equal(0); - }); - - it("should return an empty Uint8Array for undefined input", function () { - const encoder = new TextEncoder(); - const bytes = encoder.encode(undefined); - expect(bytes.length).to.equal(0); - }); - - it("should encode a multi-byte UTF-8 string", function () { - const encoder = new TextEncoder(); - // "é" is U+00E9 -> 0xC3 0xA9 in UTF-8 - const bytes = encoder.encode("é"); - expect(Array.from(bytes)).to.eql([0xC3, 0xA9]); - }); - - it("should encode a string containing a null byte", function () { - const encoder = new TextEncoder(); - const bytes = encoder.encode("H\0i"); - expect(Array.from(bytes)).to.eql([72, 0, 105]); - }); -}); - -declare const File: any; -declare const FileReader: any; - -describe("File", function () { - // -------------------------------- Construction -------------------------------- - it("creates an empty File", function () { - const file = new File([], "empty.txt"); - expect(file.size).to.equal(0); - expect(file.type).to.equal(""); - expect(file.name).to.equal("empty.txt"); - }); - - it("creates a File from a string array", function () { - const file = new File(["Hello"], "hello.txt"); - expect(file.size).to.equal(5); - expect(file.name).to.equal("hello.txt"); - }); - - it("creates a File from a TypedArray", function () { - const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" - const file = new File([data], "typed.bin"); - expect(file.size).to.equal(5); - expect(file.name).to.equal("typed.bin"); - }); - - it("creates a File from an ArrayBuffer", function () { - const buffer = new Uint8Array([72, 101, 108, 108, 111]).buffer; - const file = new File([buffer], "buffer.bin"); - expect(file.size).to.equal(5); - }); - - it("creates a File from a Blob", function () { - const blob = new Blob(["Hello"]); - const file = new File([blob], "from-blob.txt"); - expect(file.size).to.equal(5); - }); - - it("applies MIME type from options", function () { - const file = new File(["{}"], "data.json", { type: "application/json" }); - expect(file.type).to.equal("application/json"); - }); - - it("defaults lastModified to a recent timestamp when not provided", function () { - const before = Date.now(); - const file = new File([], "x.txt"); - const after = Date.now(); - expect(file.lastModified).to.be.a("number"); - // Allow small clock-skew slack on either side. - expect(file.lastModified).to.be.at.least(before - 1000); - expect(file.lastModified).to.be.at.most(after + 1000); - }); - - it("honors lastModified from options", function () { - const file = new File([], "x.txt", { lastModified: 12345 }); - expect(file.lastModified).to.equal(12345); - }); - - it("coerces a non-string name to a string", function () { - const file = new File([], 42 as any); - expect(file.name).to.equal("42"); - }); - - it("coerces undefined and null name per WebIDL USVString", function () { - // Per the WHATWG File constructor's WebIDL signature, name is a - // non-optional USVString; ToString is applied regardless of input - // type, so passing undefined/null yields the string "undefined" / - // "null" rather than an empty string. - expect(new File([], undefined as any).name).to.equal("undefined"); - expect(new File([], null as any).name).to.equal("null"); - }); - - // TODO(JsRH#175): Re-enable once the Chakra Node-API shim surfaces - // exceptions thrown from class constructor callbacks back to JS. - // it("throws when fewer than 2 arguments are passed", function () { - // // File requires both fileBits and fileName per the WebIDL bindings. - // // Browsers throw TypeError on missing arguments; the native polyfill - // // must match that surface so consumers don't accidentally create a - // // File with empty name when their call site is misspelled. - // // Note: we only assert *that* it throws (not the specific error - // // type), because the JSI napi shim wraps thrown Napi::TypeError as - // // a generic JS Error when surfacing it across the host boundary. - // expect(() => new (File as any)()).to.throw(); - // expect(() => new (File as any)([])).to.throw(); - // }); - - // -------------------------------- Read API -------------------------------- - it("returns text via .text()", async function () { - const file = new File(["Hello"], "hello.txt"); - const text = await file.text(); - expect(text).to.equal("Hello"); - }); - - it("returns bytes via .bytes()", async function () { - const file = new File(["Hello"], "hello.txt"); - const bytes = await file.bytes(); - expect(bytes).to.be.instanceOf(Uint8Array); - expect(bytes.length).to.equal(5); - expect(bytes[0]).to.equal(72); // 'H' - expect(bytes[4]).to.equal(111); // 'o' - }); - - it("returns an ArrayBuffer via .arrayBuffer()", async function () { - const file = new File(["Hello"], "hello.txt"); - const buffer = await file.arrayBuffer(); - expect(buffer).to.be.instanceOf(ArrayBuffer); - expect(buffer.byteLength).to.equal(5); - }); - - it("handles multi-byte UTF-8 content", async function () { - const file = new File(["你好, 世界"], "utf8.txt"); - const text = await file.text(); - expect(text).to.equal("你好, 世界"); - }); - - // -------------------------------- Blob inheritance -------------------------------- - it("is an instance of Blob (prototype chain wired up)", function () { - // BJS core (fileTools, Offline/database, abstractEngine, - // thinNativeEngine) branches on `instanceof Blob`. File must - // satisfy that check for File inputs to take the Blob path, - // matching the WHATWG spec where File is a Blob subtype. - const file = new File(["x"], "x.txt"); - expect(file instanceof Blob).to.equal(true); - expect(file instanceof File).to.equal(true); - }); -}); - -describe("FileReader", function () { - // -------------------------------- State constants -------------------------------- - it("exposes EMPTY / LOADING / DONE as static constants", function () { - expect(FileReader.EMPTY).to.equal(0); - expect(FileReader.LOADING).to.equal(1); - expect(FileReader.DONE).to.equal(2); - }); - - it("exposes EMPTY / LOADING / DONE on instances", function () { - const reader = new FileReader(); - expect(reader.EMPTY).to.equal(0); - expect(reader.LOADING).to.equal(1); - expect(reader.DONE).to.equal(2); - }); - - it("does not pollute Object.prototype with EMPTY/LOADING/DONE", function () { - // Regression: in earlier drafts the JSC napi shim's - // func.Get("prototype") returns Object.prototype, so writing - // EMPTY/LOADING/DONE through it pollutes every plain object's - // for..in iteration and breaks consumers like Babylon.js's - // CameraInputsManager.attachElement. - const plain: any = {}; - const keys: string[] = []; - for (const k in plain) keys.push(k); - expect(keys).to.have.lengthOf(0); - - // And the keys must not be present as inherited enumerable - // properties on a fresh object either. - expect("EMPTY" in plain && !Object.prototype.hasOwnProperty.call(plain, "EMPTY")) - .to.equal(false); - }); - - // -------------------------------- Initial state -------------------------------- - it("initializes with EMPTY readyState and null result/error", function () { - const reader = new FileReader(); - expect(reader.readyState).to.equal(FileReader.EMPTY); - expect(reader.result).to.equal(null); - expect(reader.error).to.equal(null); - }); - - it("provides null on* event handler slots by default", function () { - const reader = new FileReader(); - expect(reader.onloadstart).to.equal(null); - expect(reader.onprogress).to.equal(null); - expect(reader.onload).to.equal(null); - expect(reader.onabort).to.equal(null); - expect(reader.onerror).to.equal(null); - expect(reader.onloadend).to.equal(null); - }); - - // -------------------------------- readAsText -------------------------------- - it("reads a Blob as text via onload", function (done) { - const reader = new FileReader(); - const blob = new Blob(["Hello"]); - reader.onload = function () { - try { - expect(reader.readyState).to.equal(FileReader.DONE); - expect(reader.result).to.equal("Hello"); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsText(blob); - }); - - it("reads a File as text via onload", function (done) { - const reader = new FileReader(); - const file = new File(["World"], "world.txt"); - reader.onload = function () { - try { - expect(reader.result).to.equal("World"); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsText(file); - }); - - it("fires onloadend after onload", function (done) { - const reader = new FileReader(); - const blob = new Blob(["abc"]); - let loadFired = false; - reader.onload = function () { - loadFired = true; - }; - reader.onloadend = function () { - try { - expect(loadFired).to.equal(true); - expect(reader.readyState).to.equal(FileReader.DONE); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsText(blob); - }); - - // -------------------------------- readAsArrayBuffer -------------------------------- - it("reads a Blob as an ArrayBuffer via onload", function (done) { - const reader = new FileReader(); - const blob = new Blob([new Uint8Array([1, 2, 3])]); - reader.onload = function () { - try { - expect(reader.result).to.be.instanceOf(ArrayBuffer); - expect(reader.result.byteLength).to.equal(3); - const view = new Uint8Array(reader.result); - expect(view[0]).to.equal(1); - expect(view[2]).to.equal(3); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsArrayBuffer(blob); - }); - - // -------------------------------- readAsDataURL -------------------------------- - it("reads a Blob as a base64 data URL", function (done) { - const reader = new FileReader(); - // "Hello" -> base64 SGVsbG8= - const blob = new Blob(["Hello"], { type: "text/plain" }); - reader.onload = function () { - try { - expect(reader.result).to.be.a("string"); - expect(reader.result).to.equal("data:text/plain;base64,SGVsbG8="); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsDataURL(blob); - }); - - it("falls back to application/octet-stream when the source blob has no type", function (done) { - const reader = new FileReader(); - const blob = new Blob(["Hello"]); - reader.onload = function () { - try { - expect(reader.result).to.equal("data:application/octet-stream;base64,SGVsbG8="); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsDataURL(blob); - }); - - // -------------------------------- addEventListener -------------------------------- - it("dispatches 'load' events to addEventListener listeners", function (done) { - const reader = new FileReader(); - const blob = new Blob(["abc"]); - let countA = 0; - let countB = 0; - const handlerA = function () { - countA++; - }; - const handlerB = function () { - countB++; - }; - reader.addEventListener("load", handlerA); - reader.addEventListener("load", handlerB); - // Per WHATWG, adding the same listener twice is a no-op, so handlerA - // should still fire exactly once. - reader.addEventListener("load", handlerA); - reader.onloadend = function () { - try { - expect(countA).to.equal(1); - expect(countB).to.equal(1); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsText(blob); - }); - - it("does not call a listener after removeEventListener", function (done) { - const reader = new FileReader(); - const blob = new Blob(["abc"]); - let called = false; - const handler = function () { - called = true; - }; - reader.addEventListener("load", handler); - reader.removeEventListener("load", handler); - reader.onloadend = function () { - try { - expect(called).to.equal(false); - done(); - } catch (e) { - done(e); - } - }; - reader.readAsText(blob); - }); - - // -------------------------------- abort -------------------------------- - it("transitions readyState to DONE after abort()", function (done) { - const reader = new FileReader(); - const blob = new Blob(["abc"]); - reader.readAsText(blob); - // Immediately abort before the queued read completes. - reader.abort(); - // Wait one microtask turn so any pending dispatch settles before we inspect state. - Promise.resolve().then(() => { - try { - expect(reader.readyState).to.equal(FileReader.DONE); - done(); - } catch (e) { - done(e); - } - }); - }); -}); - -describe("WebAssembly", function () { - this.timeout(30000); - - // Only the V8 AppRuntime pumps V8's foreground task queue, which is what lets these promises - // settle. The other engines' runtimes have the same class of gap and hang here instead of - // failing, so scope the suite rather than leave a 30s timeout on every non-V8 leg. - beforeEach(function () { - if (hostEngine !== "V8" || typeof WebAssembly === "undefined") { - this.skip(); - } - }); - - // Minimal valid module: the 8-byte header (magic + version) and no sections. - const emptyModule = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]); - - it("should settle the promise returned by WebAssembly.compile", async function () { - const module = await WebAssembly.compile(emptyModule); - expect(module).to.be.an.instanceof(WebAssembly.Module); - }); - - it("should settle the promise returned by WebAssembly.instantiate", async function () { - const result = await WebAssembly.instantiate(emptyModule); - expect(result.instance).to.be.an.instanceof(WebAssembly.Instance); - }); - - it("should reject the promise returned by WebAssembly.compile for invalid bytes", async function () { - let threw = false; - try { - await WebAssembly.compile(new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0xFF])); - } catch (e) { - threw = true; - } - expect(threw).to.equal(true); - }); -}); - -function runTests() { - mocha.run((failures: number) => { - // Test program will wait for code to be set before exiting - if (failures > 0) { - // Failure - setExitCode(1); - } else { - // Success - setExitCode(0); - } - }); -} - -runTests(); diff --git a/Tests/UnitTests/Android/.gitignore b/Tests/UnitTests/Source/Android/.gitignore similarity index 100% rename from Tests/UnitTests/Android/.gitignore rename to Tests/UnitTests/Source/Android/.gitignore diff --git a/Tests/UnitTests/Android/app/build.gradle b/Tests/UnitTests/Source/Android/app/build.gradle similarity index 60% rename from Tests/UnitTests/Android/app/build.gradle rename to Tests/UnitTests/Source/Android/app/build.gradle index c6137034..6ff65c50 100644 --- a/Tests/UnitTests/Android/app/build.gradle +++ b/Tests/UnitTests/Source/Android/app/build.gradle @@ -65,13 +65,18 @@ android { externalNativeBuild { cmake { path file('src/main/cpp/CMakeLists.txt') - buildStagingDirectory '../../../../Build/Android' + buildStagingDirectory '../../../../../Build/Android' version '3.22.1+' } } buildFeatures { viewBinding true } + sourceSets { + main { + assets.srcDirs = [] + } + } } dependencies { @@ -84,37 +89,36 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } -task copyScripts { - doLast { - // run copy at execution phase because npm command is not done at configuration phase - copy - { - from '../../dist' - include "*.js" - into 'src/main/assets/Scripts' - } - copy - { - from '../../Scripts' - include "*.js" - into 'src/main/assets/Scripts' - } - copy - { - from '../../Assets' - include "*.*" - into 'src/main/assets/Assets' - } +['Debug', 'Release'].each { variant -> + def variantName = variant.toLowerCase() + def bundleDir = file("$buildDir/generated/test-bundle/$variantName") + def assetsDir = file("$buildDir/generated/test-assets/$variantName") + def buildBundle = tasks.register("build${variant}TestBundle", Exec) { + dependsOn "merge${variant}NativeLibs" + workingDir file('../../../..') + commandLine System.getProperty('os.name').toLowerCase().contains('windows') ? 'npm.cmd' : 'npm', + 'run', 'build', '--', '--output-path', bundleDir.absolutePath + inputs.files(fileTree('../../../Source/Scripts') { include '**/*.ts' }) + inputs.files files('../../../../webpack.config.js', '../../../../babel-plugin-replace-bigint.js', + '../../../../package.json', '../../../../package-lock.json') + outputs.file file("$bundleDir/tests.js") } -} - -// Run copyScripts task after CMake external build -// And make sure merging assets into output is performed after the scripts copy -tasks.configureEach { task -> - if (task.name == 'mergeDebugNativeLibs') { - task.finalizedBy(copyScripts) + def copyAssets = tasks.register(variant == 'Debug' ? 'copyAssets' : "copy${variant}Assets", Sync) { + dependsOn buildBundle + from(bundleDir) { into 'Assets' } + from('../../../Assets') { into 'Assets' } + into assetsDir + doFirst { + delete 'src/main/assets/Scripts', 'src/main/assets/Assets/tests.js' + } } - if (task.name == 'mergeDebugAssets') { - task.dependsOn(copyScripts) + android.sourceSets.getByName(variantName).assets.srcDir(copyAssets) + // Lint reads the asset source directory independently of mergeAssets. + tasks.configureEach { task -> + if (task.name == "merge${variant}Assets" || + task.name == "lintVitalAnalyze${variant}" || + task.name == "generate${variant}LintVitalReportModel") { + task.dependsOn(copyAssets) + } } } diff --git a/Tests/UnitTests/Android/app/proguard-rules.pro b/Tests/UnitTests/Source/Android/app/proguard-rules.pro similarity index 100% rename from Tests/UnitTests/Android/app/proguard-rules.pro rename to Tests/UnitTests/Source/Android/app/proguard-rules.pro diff --git a/Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Main.java b/Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Main.java similarity index 100% rename from Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Main.java rename to Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Main.java diff --git a/Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Native.java b/Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Native.java similarity index 100% rename from Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Native.java rename to Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/Native.java diff --git a/Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/WebSocket.java b/Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/WebSocket.java similarity index 100% rename from Tests/UnitTests/Android/app/src/androidTest/java/com/jsruntimehost/unittests/WebSocket.java rename to Tests/UnitTests/Source/Android/app/src/androidTest/java/com/jsruntimehost/unittests/WebSocket.java diff --git a/Tests/UnitTests/Android/app/src/main/AndroidManifest.xml b/Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml similarity index 100% rename from Tests/UnitTests/Android/app/src/main/AndroidManifest.xml rename to Tests/UnitTests/Source/Android/app/src/main/AndroidManifest.xml diff --git a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt similarity index 66% rename from Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt rename to Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt index 2e31e0fb..aa933dba 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/CMakeLists.txt +++ b/Tests/UnitTests/Source/Android/app/src/main/cpp/CMakeLists.txt @@ -5,7 +5,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) project(UnitTestsJNI) -get_filename_component(UNIT_TESTS_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../.." ABSOLUTE) +get_filename_component(UNIT_TESTS_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../../../.." ABSOLUTE) get_filename_component(TESTS_DIR "${UNIT_TESTS_DIR}/.." ABSOLUTE) get_filename_component(REPO_ROOT_DIR "${TESTS_DIR}/.." ABSOLUTE) @@ -18,20 +18,28 @@ npm(install --silent WORKING_DIRECTORY ${TESTS_DIR}) add_library(UnitTestsJNI SHARED JNI.cpp - ${UNIT_TESTS_DIR}/Shared/DelayedTaskScheduler.cpp - ${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}/Source/App.cpp + ${UNIT_TESTS_DIR}/Source/App.h + ${UNIT_TESTS_DIR}/Source/Tests.AppRuntime.cpp + ${UNIT_TESTS_DIR}/Source/Tests.Console.cpp + ${UNIT_TESTS_DIR}/Source/Tests.DelayedTaskScheduler.cpp + ${UNIT_TESTS_DIR}/Source/Tests.JavaScript.cpp + ${UNIT_TESTS_DIR}/Source/Tests.NodeApi.cpp + ${UNIT_TESTS_DIR}/Source/Tests.Scheduling.cpp + ${UNIT_TESTS_DIR}/Source/Tests.StandardStreamLogger.cpp + ${UNIT_TESTS_DIR}/Source/Tests.TimeoutDispatcher.cpp) if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8") - target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Shared/V8ForegroundTaskRunner.cpp) + target_sources(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}/Source/Tests.V8ForegroundTaskRunner.cpp) target_link_libraries(UnitTestsJNI PRIVATE AppRuntimeInternal) endif() target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_PLATFORM="${JSRUNTIMEHOST_PLATFORM}") target_compile_definitions(UnitTestsJNI PRIVATE NAPI_JAVASCRIPT_ENGINE="${NAPI_JAVASCRIPT_ENGINE}") target_compile_definitions(UnitTestsJNI PRIVATE ARCANA_TEST_HOOKS) +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "JSI") + target_compile_definitions(UnitTestsJNI PRIVATE JSRUNTIMEHOST_NAPI_ENGINE_JSI) +endif() target_include_directories(UnitTestsJNI PRIVATE ${UNIT_TESTS_DIR}) diff --git a/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp b/Tests/UnitTests/Source/Android/app/src/main/cpp/JNI.cpp similarity index 98% rename from Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp rename to Tests/UnitTests/Source/Android/app/src/main/cpp/JNI.cpp index 85612af3..c7331ee6 100644 --- a/Tests/UnitTests/Android/app/src/main/cpp/JNI.cpp +++ b/Tests/UnitTests/Source/Android/app/src/main/cpp/JNI.cpp @@ -4,7 +4,7 @@ #include #include "Babylon/DebugTrace.h" #include "Babylon/StandardStreamLogger.h" -#include +#include extern "C" JNIEXPORT jint JNICALL Java_com_jsruntimehost_unittests_Native_javaScriptTests(JNIEnv* env, jclass clazz, jobject context) { diff --git a/Tests/UnitTests/Android/app/src/main/res/values/strings.xml b/Tests/UnitTests/Source/Android/app/src/main/res/values/strings.xml similarity index 100% rename from Tests/UnitTests/Android/app/src/main/res/values/strings.xml rename to Tests/UnitTests/Source/Android/app/src/main/res/values/strings.xml diff --git a/Tests/UnitTests/Android/build.gradle b/Tests/UnitTests/Source/Android/build.gradle similarity index 100% rename from Tests/UnitTests/Android/build.gradle rename to Tests/UnitTests/Source/Android/build.gradle diff --git a/Tests/UnitTests/Android/gradle.properties b/Tests/UnitTests/Source/Android/gradle.properties similarity index 100% rename from Tests/UnitTests/Android/gradle.properties rename to Tests/UnitTests/Source/Android/gradle.properties diff --git a/Tests/UnitTests/Android/gradle/wrapper/gradle-wrapper.jar b/Tests/UnitTests/Source/Android/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from Tests/UnitTests/Android/gradle/wrapper/gradle-wrapper.jar rename to Tests/UnitTests/Source/Android/gradle/wrapper/gradle-wrapper.jar diff --git a/Tests/UnitTests/Android/gradle/wrapper/gradle-wrapper.properties b/Tests/UnitTests/Source/Android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from Tests/UnitTests/Android/gradle/wrapper/gradle-wrapper.properties rename to Tests/UnitTests/Source/Android/gradle/wrapper/gradle-wrapper.properties diff --git a/Tests/UnitTests/Android/gradlew b/Tests/UnitTests/Source/Android/gradlew similarity index 100% rename from Tests/UnitTests/Android/gradlew rename to Tests/UnitTests/Source/Android/gradlew diff --git a/Tests/UnitTests/Android/gradlew.bat b/Tests/UnitTests/Source/Android/gradlew.bat similarity index 100% rename from Tests/UnitTests/Android/gradlew.bat rename to Tests/UnitTests/Source/Android/gradlew.bat diff --git a/Tests/UnitTests/Android/settings.gradle b/Tests/UnitTests/Source/Android/settings.gradle similarity index 100% rename from Tests/UnitTests/Android/settings.gradle rename to Tests/UnitTests/Source/Android/settings.gradle diff --git a/Tests/UnitTests/Source/App.cpp b/Tests/UnitTests/Source/App.cpp new file mode 100644 index 00000000..58d77530 --- /dev/null +++ b/Tests/UnitTests/Source/App.cpp @@ -0,0 +1,8 @@ +#include "App.h" +#include + +int RunTests() +{ + testing::InitGoogleTest(); + return RUN_ALL_TESTS(); +} diff --git a/Tests/UnitTests/Shared/Shared.h b/Tests/UnitTests/Source/App.h similarity index 100% rename from Tests/UnitTests/Shared/Shared.h rename to Tests/UnitTests/Source/App.h diff --git a/Tests/UnitTests/Linux/App.cpp b/Tests/UnitTests/Source/Linux/App.cpp similarity index 89% rename from Tests/UnitTests/Linux/App.cpp rename to Tests/UnitTests/Source/Linux/App.cpp index 7abee74f..b903ecad 100644 --- a/Tests/UnitTests/Linux/App.cpp +++ b/Tests/UnitTests/Source/Linux/App.cpp @@ -1,4 +1,4 @@ -#include "../Shared/Shared.h" +#include "../App.h" #include "Babylon/DebugTrace.h" #include diff --git a/Tests/UnitTests/Source/Scripts/globals.d.ts b/Tests/UnitTests/Source/Scripts/globals.d.ts new file mode 100644 index 00000000..3748de6f --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/globals.d.ts @@ -0,0 +1,2 @@ +declare const hostPlatform: string; +declare const hostEngine: string; diff --git a/Tests/UnitTests/Source/Scripts/tests.abortController.ts b/Tests/UnitTests/Source/Scripts/tests.abortController.ts new file mode 100644 index 00000000..28e30196 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.abortController.ts @@ -0,0 +1,89 @@ +import { expect } from "chai"; + +describe("AbortController", function () { + it("should not throw while aborting with no callbacks", function () { + const controller = new AbortController(); + expect(controller.signal.aborted).to.equal(false); + + // Trigger with no callbacks + controller.abort(); + + expect(controller.signal.aborted).to.equal(true); + }); + + it("should not throw while aborting and correctly trigger both callbacks", function (done) { + const controller = new AbortController(); + expect(controller.signal.aborted).to.equal(false); + + let cb1 = false, cb2 = false; + + // Expect aborted to be true after abort() + controller.signal.onabort = () => { + expect(controller.signal.aborted).to.equal(true); + cb1 = true; + + if (cb1 && cb2) { + done(); + } + } + + controller.signal.addEventListener("abort", () => { + expect(controller.signal.aborted).to.equal(true); + cb2 = true; + + if (cb1 && cb2) { + done(); + } + }) + + controller.abort(); + }); + + it("should remove listener and not invoke callback function", function () { + const controller = new AbortController(); + expect(controller.signal.aborted).to.equal(false); + + // If this function is unsuccessfully removed it will assert when called + const onAbort = () => { + expect(controller.signal.aborted).to.equal(false); + }; + + controller.signal.addEventListener("abort", onAbort); + controller.signal.removeEventListener("abort", onAbort); + + controller.abort(); + + expect(controller.signal.aborted).to.equal(true); + }); + + it("AbortSignal.abort() returns a signal already aborted with an AbortError reason", function () { + const signal = (AbortSignal as any).abort(); + expect(signal.aborted).to.equal(true); + expect(signal.reason).to.be.an.instanceof(Error); + expect(signal.reason.name).to.equal("AbortError"); + }); + + it("throwIfAborted() throws the reason only once aborted", function () { + const controller = new AbortController(); + // Not aborted yet: must not throw. + (controller.signal as any).throwIfAborted(); + + controller.abort(); + expect(() => (controller.signal as any).throwIfAborted()).to.throw(); + }); + + it("abort(reason) records the provided reason", function () { + const controller = new AbortController(); + const reason = new Error("custom reason"); + controller.abort(reason); + expect((controller.signal as any).reason).to.equal(reason); + }); + + it("abort() with no reason defaults to an AbortError", function () { + const controller = new AbortController(); + controller.abort(); + const reason = (controller.signal as any).reason; + expect(reason).to.be.an.instanceof(Error); + expect(reason.name).to.equal("AbortError"); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.blob.ts b/Tests/UnitTests/Source/Scripts/tests.blob.ts new file mode 100644 index 00000000..d3a0b9b6 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.blob.ts @@ -0,0 +1,115 @@ +import { expect } from "chai"; + +describe("Blob", function () { + let emptyBlobs: Blob[], helloBlobs: Blob[], stringBlob: Blob, typedArrayBlob: Blob, arrayBufferBlob: Blob, blobBlob: Blob; + + before(function () { + emptyBlobs = [new Blob([]), new Blob([])]; + stringBlob = new Blob(["Hello"]); + typedArrayBlob = new Blob([new Uint8Array([72, 101, 108, 108, 111])]), + arrayBufferBlob = new Blob([new Uint8Array([72, 101, 108, 108, 111]).buffer]), + blobBlob = new Blob([new Blob(["Hello"])]), + helloBlobs = [stringBlob, typedArrayBlob, arrayBufferBlob, blobBlob] + }); + + // -------------------------------- Blob Construction -------------------------------- + it("creates empty blobs", function () { + for (const blob of emptyBlobs) { + expect(blob.size).to.equal(0); + expect(blob.type).to.equal(""); + } + }); + + it("creates blob from string array", function () { + expect(stringBlob.size).to.equal(5); + expect(stringBlob.type).to.equal(""); + }); + + it("creates blob from TypedArray", function () { + expect(typedArrayBlob.size).to.equal(5); + expect(typedArrayBlob.type).to.equal(""); + }); + + it("creates blob from ArrayBuffer", function () { + expect(arrayBufferBlob.size).to.equal(5); + expect(arrayBufferBlob.type).to.equal(""); + }); + + it("creates blob from another Blob", function () { + expect(blobBlob.size).to.equal(5); + expect(blobBlob.type).to.equal(""); + }); + + it("applies MIME type from options", function () { + const modelGltfJson = new Blob(["glTF"], { type: "model/gltf+json" }) + expect(modelGltfJson.type).to.equal("model/gltf+json"); + }); + + // -------------------------------- Blob.text() -------------------------------- + it("returns empty string for empty blobs", async function () { + for (const blob of emptyBlobs) { + const text = await blob.text(); + expect(text).to.equal(""); + } + }); + + it("returns correct string content for non-empty blobs", async function () { + for (const blob of helloBlobs) { + const text = await blob.text(); + expect(text).to.equal("Hello"); + } + }); + + it("handles multi-byte UTF-8 characters", async function () { + const utf8Blob = new Blob(["你好, 世界"]); + const text = await utf8Blob.text(); + expect(text).to.equal("你好, 世界"); + }); + + it("preserves line endings like default transparent mode", async function () { + const lineEndingsBlob = new Blob(["Hello\nWorld"]); + const text = await lineEndingsBlob.text(); + expect(text).to.equal("Hello\nWorld"); + }); + + // -------------------------------- Blob.bytes() -------------------------------- + it("returns empty Uint8Array for empty blobs", async function () { + for (const blob of emptyBlobs) { + const bytes = await blob.bytes(); + expect(bytes).to.be.instanceOf(Uint8Array); + expect(bytes.length).to.equal(0); + } + }); + + it("returns correct byte content from non-empty blobs", async function () { + for (const blob of helloBlobs) { + const bytes = await blob.bytes(); + expect(bytes).to.be.instanceOf(Uint8Array); + expect(bytes.length).to.equal(5); + expect(bytes[0]).to.equal(72); // 'H' + expect(bytes[4]).to.equal(111); // 'o' + } + }); + + // -------------------------------- Blob.arrayBuffer() -------------------------------- + it("returns empty buffer for empty blobs", async function () { + for (const blob of emptyBlobs) { + const buffer = await blob.arrayBuffer(); + expect(buffer).to.be.instanceOf(ArrayBuffer); + expect(buffer.byteLength).to.equal(0); + } + }); + + it("returns correct buffer content for non-empty blobs", async function () { + for (const blob of helloBlobs) { + const buffer = await blob.arrayBuffer(); + expect(buffer).to.be.instanceOf(ArrayBuffer); + expect(buffer.byteLength).to.equal(5); + + const view = new Uint8Array(buffer); + expect(view[0]).to.equal(72); // 'H' + expect(view[4]).to.equal(111); // 'o' + + } + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.console.ts b/Tests/UnitTests/Source/Scripts/tests.console.ts new file mode 100644 index 00000000..850678d4 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.console.ts @@ -0,0 +1,38 @@ +import { expect } from "chai"; + +describe("Console", function () { + it("should log a simple console log string without error", function () { + expect(() => console.log("I am a test string")).to.not.throw(); + }); + it("should log sequence of strings", function () { + expect(() => console.log("I", "am", "a", "test", "string", 2, 2.345, { foo: "bar" })).to.not.throw(); + }); + it("Should log string substitutions", function () { + expect(() => console.log("String sub: %s, float sub: %f, int sub: %d", "string", 3.1457, 3.1457)).to.not.throw(); + }); + it("Should allow numbers to substitute strings", function () { + expect(() => console.log("Print these numbers! %s %s", 1.2345, 1)).to.not.throw(); + }); + it("Should allow strings to substitute numbers", function () { + expect(() => console.log("Print these strings! %f %d", "foo", "bar")).to.not.throw(); + }); + it("Should allow for less substitution arguments than parameters", function () { + expect(() => console.log("%s", "I am a string", 12345)).to.not.throw(); + }); + it("Should allow for more substitution arguments than parameters", function () { + expect(() => console.log("%s %s", "I am a string")).to.not.throw(); + }); + it("Should allow a substitution argument with no parameter", function () { + expect(() => console.log("%s")).to.not.throw(); + }); + it("Should ignore invalid substitution arguments", function () { + expect(() => console.log("%y %s %k", "I am a string")).to.not.throw(); + expect(() => console.log("%y%s%k", "I am a string")).to.not.throw(); + expect(() => console.log("%", 756)).to.not.throw(); + expect(() => console.log("%%", 756)).to.not.throw(); + }); + it("Should allow logging NaN", function () { + expect(() => console.log("%d %f %s", NaN, NaN, NaN)).to.not.throw(); + expect(() => console.log("%d %f %s", 0 / 0, 0 / 0, 0 / 0)).to.not.throw(); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.fetch.ts b/Tests/UnitTests/Source/Scripts/tests.fetch.ts new file mode 100644 index 00000000..903dfc78 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.fetch.ts @@ -0,0 +1,161 @@ +import { expect } from "chai"; + +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/"); + 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"); + 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/"); + expect(okResponse.statusText).to.equal("OK"); + const notFoundResponse = await fetch("https://github.com/babylonJS/BabylonNative404"); + expect(notFoundResponse.statusText).to.equal("Not Found"); + }); + + it("text() should return the body as a string", async function () { + const response = await fetch("app:///Assets/symlink_target.js"); + expect(await response.text()).to.equal("var symlink_target_js = true;"); + }); + + it("arrayBuffer() should return the body as bytes", async function () { + const response = await fetch("app:///Assets/symlink_target.js"); + const expected = new Uint8Array("var symlink_target_js = true;".split("").map(x => x.charCodeAt(0))); + expect(new Uint8Array(await response.arrayBuffer())).to.eql(expected); + }); + + it("json() should parse a JSON body", async function () { + const response = await fetch("app:///Assets/sample.json"); + const json = await response.json(); + expect(json.name).to.equal("fetch-polyfill-test"); + expect(json.value).to.equal(42); + expect(json.nested.items).to.eql([1, 2, 3]); + }); + + it("json() should reject when the body is not valid JSON", async function () { + const response = await fetch("app:///Assets/symlink_target.js"); + let rejected = false; + try { + await response.json(); + } catch { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + it("blob() should return a Blob with the body bytes", async function () { + const response = await fetch("app:///Assets/symlink_target.js"); + const blob = await response.blob(); + expect(blob.size).to.equal("var symlink_target_js = true;".length); + expect(await blob.text()).to.equal("var symlink_target_js = true;"); + }); + + it("headers.get() should be case-insensitive and headers.has() should work", async function () { + const response = await fetch("https://github.com/"); + expect(response.headers.has("Content-Type")).to.equal(true); + expect(response.headers.get("CONTENT-TYPE")).to.equal(response.headers.get("content-type")); + }); + + it("clone() should produce an independently readable response", async function () { + const response = await fetch("app:///Assets/symlink_target.js"); + const clone = response.clone(); + expect(await response.text()).to.equal("var symlink_target_js = true;"); + expect(await clone.text()).to.equal("var symlink_target_js = true;"); + }); + + it("should accept a method in the init object", async function () { + const response = await fetch("https://github.com/", { method: "GET" }); + expect(response.status).to.equal(200); + }); + + it("should reject when no arguments are provided", async function () { + let rejected = false; + try { + await (fetch as any)(); + } catch { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + it("should reject a transport failure with a TypeError carrying detail on cause", async function () { + this.timeout(30000); + let error: any; + try { + // Nothing listens on this loopback port, so the connection is refused -- a transport + // failure (status 0), distinct from an HTTP error status. + await fetch("http://127.0.0.1:1/"); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + // Spec-conformant shape: network errors reject with a TypeError whose message is stable + // (browsers/Node/undici all keep it constant so crash-report grouping stays intact). + expect(error).to.be.an.instanceof(TypeError); + expect(error.message).to.equal("fetch failed"); + // The variable detail rides on `cause` (Node/undici shape), never on the stable message. + expect(error.cause, "error.cause should be populated").to.be.an("object"); + expect(error.cause.url).to.contain("127.0.0.1"); + expect(error.cause.status).to.equal(0); + // On backends where UrlLib populates transport detail (Apple/Linux) `code`/`detail` are + // present stable tokens; on backends that don't yet (Windows/Android) they are absent -- + // the stable observable shape above is preserved either way. + if (error.cause.code !== undefined) { + expect(error.cause.code).to.be.a("string").and.not.equal(""); + expect(error.cause.detail).to.be.a("string").and.not.equal(""); + } + }); + + it("should reject a missing app:// asset with a TypeError (distinct from a network failure)", async function () { + let error: any; + try { + await fetch("app:///does_not_exist.js"); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error).to.be.an.instanceof(TypeError); + expect(error.message).to.equal("fetch failed"); + expect(error.cause.url).to.contain("does_not_exist.js"); + }); + + it("should reject immediately with an AbortError when the signal is already aborted", async function () { + const controller = new AbortController(); + controller.abort(); + + let error: any; + try { + await fetch("https://github.com/", { signal: controller.signal } as any); + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error.name).to.equal("AbortError"); + }); + + 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); + // Abort before the response can arrive. + controller.abort(); + + let error: any; + try { + await promise; + } catch (e) { + error = e; + } + expect(error, "fetch should have rejected").to.not.equal(undefined); + expect(error.name).to.equal("AbortError"); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.file.ts b/Tests/UnitTests/Source/Scripts/tests.file.ts new file mode 100644 index 00000000..b2273dcc --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.file.ts @@ -0,0 +1,124 @@ +import { expect } from "chai"; + +describe("File", function () { + // -------------------------------- Construction -------------------------------- + it("creates an empty File", function () { + const file = new File([], "empty.txt"); + expect(file.size).to.equal(0); + expect(file.type).to.equal(""); + expect(file.name).to.equal("empty.txt"); + }); + + it("creates a File from a string array", function () { + const file = new File(["Hello"], "hello.txt"); + expect(file.size).to.equal(5); + expect(file.name).to.equal("hello.txt"); + }); + + it("creates a File from a TypedArray", function () { + const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const file = new File([data], "typed.bin"); + expect(file.size).to.equal(5); + expect(file.name).to.equal("typed.bin"); + }); + + it("creates a File from an ArrayBuffer", function () { + const buffer = new Uint8Array([72, 101, 108, 108, 111]).buffer; + const file = new File([buffer], "buffer.bin"); + expect(file.size).to.equal(5); + }); + + it("creates a File from a Blob", function () { + const blob = new Blob(["Hello"]); + const file = new File([blob], "from-blob.txt"); + expect(file.size).to.equal(5); + }); + + it("applies MIME type from options", function () { + const file = new File(["{}"], "data.json", { type: "application/json" }); + expect(file.type).to.equal("application/json"); + }); + + it("defaults lastModified to a recent timestamp when not provided", function () { + const before = Date.now(); + const file = new File([], "x.txt"); + const after = Date.now(); + expect(file.lastModified).to.be.a("number"); + // Allow small clock-skew slack on either side. + expect(file.lastModified).to.be.at.least(before - 1000); + expect(file.lastModified).to.be.at.most(after + 1000); + }); + + it("honors lastModified from options", function () { + const file = new File([], "x.txt", { lastModified: 12345 }); + expect(file.lastModified).to.equal(12345); + }); + + it("coerces a non-string name to a string", function () { + const file = new File([], 42 as any); + expect(file.name).to.equal("42"); + }); + + it("coerces undefined and null name per WebIDL USVString", function () { + // Per the WHATWG File constructor's WebIDL signature, name is a + // non-optional USVString; ToString is applied regardless of input + // type, so passing undefined/null yields the string "undefined" / + // "null" rather than an empty string. + expect(new File([], undefined as any).name).to.equal("undefined"); + expect(new File([], null as any).name).to.equal("null"); + }); + + // TODO(JsRH#175): Re-enable once the Chakra Node-API shim surfaces + // exceptions thrown from class constructor callbacks back to JS. + // it("throws when fewer than 2 arguments are passed", function () { + // // File requires both fileBits and fileName per the WebIDL bindings. + // // Browsers throw TypeError on missing arguments; the native polyfill + // // must match that surface so consumers don't accidentally create a + // // File with empty name when their call site is misspelled. + // // Note: we only assert *that* it throws (not the specific error + // // type), because the JSI napi shim wraps thrown Napi::TypeError as + // // a generic JS Error when surfacing it across the host boundary. + // expect(() => new (File as any)()).to.throw(); + // expect(() => new (File as any)([])).to.throw(); + // }); + + // -------------------------------- Read API -------------------------------- + it("returns text via .text()", async function () { + const file = new File(["Hello"], "hello.txt"); + const text = await file.text(); + expect(text).to.equal("Hello"); + }); + + it("returns bytes via .bytes()", async function () { + const file = new File(["Hello"], "hello.txt"); + const bytes = await file.bytes(); + expect(bytes).to.be.instanceOf(Uint8Array); + expect(bytes.length).to.equal(5); + expect(bytes[0]).to.equal(72); // 'H' + expect(bytes[4]).to.equal(111); // 'o' + }); + + it("returns an ArrayBuffer via .arrayBuffer()", async function () { + const file = new File(["Hello"], "hello.txt"); + const buffer = await file.arrayBuffer(); + expect(buffer).to.be.instanceOf(ArrayBuffer); + expect(buffer.byteLength).to.equal(5); + }); + + it("handles multi-byte UTF-8 content", async function () { + const file = new File(["你好, 世界"], "utf8.txt"); + const text = await file.text(); + expect(text).to.equal("你好, 世界"); + }); + + // -------------------------------- Blob inheritance -------------------------------- + it("is an instance of Blob (prototype chain wired up)", function () { + // BJS core (fileTools, Offline/database, abstractEngine, + // thinNativeEngine) branches on `instanceof Blob`. File must + // satisfy that check for File inputs to take the Blob path, + // matching the WHATWG spec where File is a Blob subtype. + const file = new File(["x"], "x.txt"); + expect(file instanceof Blob).to.equal(true); + expect(file instanceof File).to.equal(true); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.fileReader.ts b/Tests/UnitTests/Source/Scripts/tests.fileReader.ts new file mode 100644 index 00000000..dbf64ad5 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.fileReader.ts @@ -0,0 +1,218 @@ +import { expect } from "chai"; + +describe("FileReader", function () { + // -------------------------------- State constants -------------------------------- + it("exposes EMPTY / LOADING / DONE as static constants", function () { + expect(FileReader.EMPTY).to.equal(0); + expect(FileReader.LOADING).to.equal(1); + expect(FileReader.DONE).to.equal(2); + }); + + it("exposes EMPTY / LOADING / DONE on instances", function () { + const reader = new FileReader(); + expect(reader.EMPTY).to.equal(0); + expect(reader.LOADING).to.equal(1); + expect(reader.DONE).to.equal(2); + }); + + it("does not pollute Object.prototype with EMPTY/LOADING/DONE", function () { + // Regression: in earlier drafts the JSC napi shim's + // func.Get("prototype") returns Object.prototype, so writing + // EMPTY/LOADING/DONE through it pollutes every plain object's + // for..in iteration and breaks consumers like Babylon.js's + // CameraInputsManager.attachElement. + const plain: any = {}; + const keys: string[] = []; + for (const k in plain) keys.push(k); + expect(keys).to.have.lengthOf(0); + + // And the keys must not be present as inherited enumerable + // properties on a fresh object either. + expect("EMPTY" in plain && !Object.prototype.hasOwnProperty.call(plain, "EMPTY")) + .to.equal(false); + }); + + // -------------------------------- Initial state -------------------------------- + it("initializes with EMPTY readyState and null result/error", function () { + const reader = new FileReader(); + expect(reader.readyState).to.equal(FileReader.EMPTY); + expect(reader.result).to.equal(null); + expect(reader.error).to.equal(null); + }); + + it("provides null on* event handler slots by default", function () { + const reader = new FileReader(); + expect(reader.onloadstart).to.equal(null); + expect(reader.onprogress).to.equal(null); + expect(reader.onload).to.equal(null); + expect(reader.onabort).to.equal(null); + expect(reader.onerror).to.equal(null); + expect(reader.onloadend).to.equal(null); + }); + + // -------------------------------- readAsText -------------------------------- + it("reads a Blob as text via onload", function (done) { + const reader = new FileReader(); + const blob = new Blob(["Hello"]); + reader.onload = function () { + try { + expect(reader.readyState).to.equal(FileReader.DONE); + expect(reader.result).to.equal("Hello"); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + it("reads a File as text via onload", function (done) { + const reader = new FileReader(); + const file = new File(["World"], "world.txt"); + reader.onload = function () { + try { + expect(reader.result).to.equal("World"); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(file); + }); + + it("fires onloadend after onload", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let loadFired = false; + reader.onload = function () { + loadFired = true; + }; + reader.onloadend = function () { + try { + expect(loadFired).to.equal(true); + expect(reader.readyState).to.equal(FileReader.DONE); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + // -------------------------------- readAsArrayBuffer -------------------------------- + it("reads a Blob as an ArrayBuffer via onload", function (done) { + const reader = new FileReader(); + const blob = new Blob([new Uint8Array([1, 2, 3])]); + reader.onload = function () { + try { + expect(reader.result).to.be.instanceOf(ArrayBuffer); + expect(reader.result.byteLength).to.equal(3); + const view = new Uint8Array(reader.result); + expect(view[0]).to.equal(1); + expect(view[2]).to.equal(3); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsArrayBuffer(blob); + }); + + // -------------------------------- readAsDataURL -------------------------------- + it("reads a Blob as a base64 data URL", function (done) { + const reader = new FileReader(); + // "Hello" -> base64 SGVsbG8= + const blob = new Blob(["Hello"], { type: "text/plain" }); + reader.onload = function () { + try { + expect(reader.result).to.be.a("string"); + expect(reader.result).to.equal("data:text/plain;base64,SGVsbG8="); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsDataURL(blob); + }); + + it("falls back to application/octet-stream when the source blob has no type", function (done) { + const reader = new FileReader(); + const blob = new Blob(["Hello"]); + reader.onload = function () { + try { + expect(reader.result).to.equal("data:application/octet-stream;base64,SGVsbG8="); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsDataURL(blob); + }); + + // -------------------------------- addEventListener -------------------------------- + it("dispatches 'load' events to addEventListener listeners", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let countA = 0; + let countB = 0; + const handlerA = function () { + countA++; + }; + const handlerB = function () { + countB++; + }; + reader.addEventListener("load", handlerA); + reader.addEventListener("load", handlerB); + // Per WHATWG, adding the same listener twice is a no-op, so handlerA + // should still fire exactly once. + reader.addEventListener("load", handlerA); + reader.onloadend = function () { + try { + expect(countA).to.equal(1); + expect(countB).to.equal(1); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + it("does not call a listener after removeEventListener", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + let called = false; + const handler = function () { + called = true; + }; + reader.addEventListener("load", handler); + reader.removeEventListener("load", handler); + reader.onloadend = function () { + try { + expect(called).to.equal(false); + done(); + } catch (e) { + done(e); + } + }; + reader.readAsText(blob); + }); + + // -------------------------------- abort -------------------------------- + it("transitions readyState to DONE after abort()", function (done) { + const reader = new FileReader(); + const blob = new Blob(["abc"]); + reader.readAsText(blob); + // Immediately abort before the queued read completes. + reader.abort(); + // Wait one microtask turn so any pending dispatch settles before we inspect state. + Promise.resolve().then(() => { + try { + expect(reader.readyState).to.equal(FileReader.DONE); + done(); + } catch (e) { + done(e); + } + }); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts b/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts new file mode 100644 index 00000000..be45b72c --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.javaScript.all.ts @@ -0,0 +1,34 @@ +import "./tests.setup"; +import "./tests.abortController"; +import "./tests.xmlHttpRequest"; +import "./tests.fetch"; +import "./tests.scheduling"; +import "./tests.webSocket"; +import "./tests.url"; +import "./tests.urlSearchParams"; +import "./tests.console"; +import "./tests.blob"; +import "./tests.nodeApi"; +import "./tests.performance"; +import "./tests.textDecoder"; +import "./tests.textEncoder"; +import "./tests.file"; +import "./tests.fileReader"; +import "./tests.webAssembly"; + +declare const setExitCode: (code: number) => void; + +function runTests() { + mocha.run((failures: number) => { + // Test program will wait for code to be set before exiting + if (failures > 0) { + // Failure + setExitCode(1); + } else { + // Success + setExitCode(0); + } + }); +} + +runTests(); diff --git a/Tests/UnitTests/Source/Scripts/tests.nodeApi.ts b/Tests/UnitTests/Source/Scripts/tests.nodeApi.ts new file mode 100644 index 00000000..03349fb5 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.nodeApi.ts @@ -0,0 +1,41 @@ +import { expect } from "chai"; + +describe("napi class prototype isolation (#172)", function () { + // Regression coverage for #172: napi-defined class constructors must each + // get a fresh per-class object as their `.prototype` property. Previously + // on JSC every napi class shared the global Object.prototype, so writes + // to one class's prototype polluted every object and every plain `{}` + // erroneously satisfied `instanceof` for every napi class. + + it("Blob.prototype is not the global Object.prototype", function () { + expect(Blob.prototype).to.not.equal(Object.prototype); + }); + + it("Blob.prototype chains to Object.prototype", function () { + expect(Object.getPrototypeOf(Blob.prototype)).to.equal(Object.prototype); + }); + + it("Blob.prototype.constructor points back to Blob", function () { + expect(Blob.prototype.constructor).to.equal(Blob); + }); + + it("instances inherit from Blob.prototype", function () { + const blob = new Blob([]); + expect(Object.getPrototypeOf(blob)).to.equal(Blob.prototype); + }); + + it("plain objects are not instanceof Blob", function () { + expect({} instanceof Blob).to.equal(false); + }); + + it("writes to Blob.prototype do not pollute Object.prototype", function () { + const KEY = "__jrh172_blob_prototype_marker__"; + const proto = Blob.prototype as any; + try { + proto[KEY] = 1; + expect(KEY in {}).to.equal(false); + } finally { + delete proto[KEY]; + } + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.performance.ts b/Tests/UnitTests/Source/Scripts/tests.performance.ts new file mode 100644 index 00000000..6bfee046 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.performance.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; + +describe("Performance", function () { + this.timeout(1000); + + it("should have performance global defined", function () { + expect(performance).to.not.be.undefined; + }); + + it("should return a number from performance.now()", function () { + const now = performance.now(); + expect(now).to.be.a("number"); + }); + + it("should return a non-negative value from performance.now()", function () { + const now = performance.now(); + expect(now).to.be.at.least(0); + }); + + it("should return increasing values from performance.now()", function (done) { + const first = performance.now(); + setTimeout(() => { + const second = performance.now(); + expect(second).to.be.greaterThan(first); + done(); + }, 10); + }); + + it("should measure elapsed time accurately", function (done) { + const start = performance.now(); + const delay = 50; + setTimeout(() => { + const elapsed = performance.now() - start; + // setTimeout guarantees a minimum delay, not a maximum. + // Only check the lower bound to avoid flakes on busy CI agents. + expect(elapsed).to.be.at.least(delay - 5); + done(); + }, delay); + }); + + it("should return sub-millisecond precision", function () { + // Call performance.now() multiple times rapidly and check if we get fractional values + const samples: number[] = []; + for (let i = 0; i < 100; i++) { + samples.push(performance.now()); + } + // At least some samples should have fractional parts (sub-millisecond precision) + const hasFractional = samples.some(s => s % 1 !== 0); + expect(hasFractional).to.equal(true); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.scheduling.ts b/Tests/UnitTests/Source/Scripts/tests.scheduling.ts new file mode 100644 index 00000000..8b813f10 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.scheduling.ts @@ -0,0 +1,235 @@ +import { expect } from "chai"; + +describe("setTimeout", function () { + this.timeout(5000); + + it("should return an id greater than zero", function () { + const id = setTimeout(() => { }, 0); + expect(id).to.be.greaterThan(0); + }); + + it("should return an id greater than zero when given an undefined function", function () { + const id = setTimeout(undefined as any, 0); + expect(id).to.be.greaterThan(0); + }); + + it("should call the given function after the given delay", function (done) { + const startTime = new Date().getTime(); + setTimeout(() => { + try { + expect(new Date().getTime() - startTime).to.be.at.least(10); + done(); + } + catch (e) { + done(e); + } + }, 10); + }); + + it("should call the given nested function after the given delay", function (done) { + const startTime = new Date().getTime(); + setTimeout(() => { + setTimeout(() => { + try { + expect(new Date().getTime() - startTime).to.be.at.least(20); + done(); + } + catch (e) { + done(e); + } + }, 10); + }, 10); + }); + + it("should call the given function after the given delay when the delay is a string representing a valid number", function (done) { + const startTime = new Date().getTime(); + setTimeout(() => { + try { + expect(new Date().getTime() - startTime).to.be.at.least(10); + done(); + } + catch (e) { + done(e); + } + }, "10" as any); + }); + + it("should call the given function after zero milliseconds when the delay is a string representing an invalid number", function (done) { + setTimeout(() => { + done(); + }, "a" as any); + }); + + it("should call the given function after other tasks execute when the given delay is zero", function (done) { + let trailingCodeExecuted = false; + setTimeout(() => { + try { + expect(trailingCodeExecuted).to.be.true; + done(); + } + catch (e) { + done(e); + } + }, 0); + trailingCodeExecuted = true; + }); + + it("should call the given function after other tasks execute when the given delay is undefined", function (done) { + let trailingCodeExecuted = false; + setTimeout(() => { + try { + expect(trailingCodeExecuted).to.be.true; + done(); + } + catch (e) { + done(e); + } + }, undefined); + trailingCodeExecuted = true; + }); + + // See https://github.com/BabylonJS/JsRuntimeHost/issues/9 + // it("should call the given functions in the correct order", function (done) { + // const called = []; + // for (let i = 9; i >= 0; i--) { + // setTimeout(() => { + // called.push(i); + // if (called.length === 10) { + // try { + // expect(called).to.deep.equal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + // done(); + // } + // catch (e) { + // done(e); + // } + // } + // }, i * 10); + // } + // }); +}); + +describe("clearTimeout", function () { + this.timeout(5000); + + it("should stop the timeout matching the given timeout id", function (done) { + const id = setTimeout(() => { + done(new Error("Timeout was not cleared")); + }, 0); + clearTimeout(id); + setTimeout(done, 100); + }); + + it("should do nothing if the given timeout id is undefined", function (done) { + setTimeout(() => { done(); }, 0); + clearTimeout(undefined); + }); + + it("should be interchangeable with clearInterval", function (done) { + const id = setTimeout(() => { + done(new Error("Interval was not cleared")); + }, 0); + clearInterval(id); + setTimeout(done, 100); + }); +}); + +describe("setInterval", function () { + this.timeout(5000); + + it("should return an id greater than zero", function () { + const id = setInterval(() => { }, 0); + clearInterval(id); + expect(id).to.be.greaterThan(0); + }); + + it("should call the given function at the given interval", function (done) { + let startTime = new Date().getTime(); + let tickCount = 0; + const id = setInterval(() => { + try { + tickCount++; + expect(new Date().getTime() - startTime).to.be.at.least(tickCount * 10); + if (tickCount > 2) { + clearInterval(id); + done(); + } + } + catch (e) { + console.log(`finished with error: ${e}`); + clearInterval(id); + done(e); + } + }, 10); + }); + + it("should not starve other queued work when the interval has no delay", function (done) { + // Regression test: a repeating timeout used to be re-armed on the timer + // thread immediately, before its callback had run on the JS thread. With a + // zero delay that produced an unbounded backlog of queued callbacks which + // starved every other item on the JS dispatch queue, so this setTimeout + // would never fire. + let finished = false; + const intervalId = setInterval(() => { }); + + const timeoutId = setTimeout(() => { + finished = true; + clearInterval(intervalId); + done(); + }, 100); + + setTimeout(() => { + if (!finished) { + clearInterval(intervalId); + clearTimeout(timeoutId); + done(new Error("setTimeout was starved by a zero delay setInterval")); + } + }, 2000); + }); + + it("should stop when cleared from within its own callback", function (done) { + // Exercises the re-arm path: a repeating timeout is now re-armed only + // after its callback returns, so a clear from inside the callback must + // win and no further ticks may occur. + let ticks = 0; + let id = 0; + id = setInterval(() => { + ticks++; + clearInterval(id); + }, 10); + + setTimeout(() => { + try { + expect(ticks).to.equal(1); + done(); + } + catch (e) { + done(e); + } + }, 200); + }); +}); + +describe("clearInterval", function () { + this.timeout(5000); + + it("should stop the interval matching the given interval id", function (done) { + const id = setInterval(() => { + done(new Error("Interval was not cleared")); + }, 0); + clearInterval(id); + setTimeout(done, 100); + }); + + it("should do nothing if the given interval id is undefined", function (done) { + setTimeout(() => { done(); }, 0); + clearInterval(undefined); + }); + + it("should be interchangeable with clearTimeout", function (done) { + const id = setInterval(() => { + done(new Error("Interval was not cleared")); + }, 0); + clearTimeout(id); + setTimeout(done, 100); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.setup.ts b/Tests/UnitTests/Source/Scripts/tests.setup.ts new file mode 100644 index 00000000..23b9bba6 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.setup.ts @@ -0,0 +1,5 @@ +import * as Mocha from "mocha"; + +Mocha.setup('bdd'); +// @ts-ignore +Mocha.reporter('spec'); diff --git a/Tests/UnitTests/Source/Scripts/tests.textDecoder.ts b/Tests/UnitTests/Source/Scripts/tests.textDecoder.ts new file mode 100644 index 00000000..122894fb --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.textDecoder.ts @@ -0,0 +1,131 @@ +import { expect } from "chai"; + +describe("TextDecoder", function () { + it("should decode a Uint8Array to a string", function () { + const decoder = new TextDecoder(); + const encoded = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = decoder.decode(encoded); + expect(result).to.equal("Hello"); + }); + + it("should decode an empty Uint8Array to an empty string", function () { + const decoder = new TextDecoder(); + const result = decoder.decode(new Uint8Array([])); + expect(result).to.equal(""); + }); + + it("should decode an ArrayBuffer to a string", function () { + const decoder = new TextDecoder(); + const buffer = new Uint8Array([87, 111, 114, 108, 100]).buffer; // "World" + const result = decoder.decode(buffer); + expect(result).to.equal("World"); + }); + + it("should decode a TypedArray subarray with non-zero byteOffset", function () { + const decoder = new TextDecoder(); + const full = new Uint8Array([88, 72, 105]); // "XHi" + const sub = full.subarray(1); // [72, 105] -> "Hi" + const result = decoder.decode(sub); + expect(result).to.equal("Hi"); + }); + + it("should decode a Uint8Array containing a null byte", function () { + const decoder = new TextDecoder(); + const encoded = new Uint8Array([72, 0, 105]); // "H\0i" + const result = decoder.decode(encoded); + expect(result).to.equal("H\0i"); + expect(result.length).to.equal(3); + }); + + it("throwing from the constructor repeatedly does not corrupt native state", function () { + // Regression for a Chakra N-API ObjectWrap bug: when a wrapped + // constructor throws, the native instance is destroyed during stack + // unwinding but the wrap finalizer stayed attached to `this`, so a + // later GC ran the finalizer on freed memory (heap corruption). Throw + // many times to create many dangling wraps, then allocate/decode to + // exercise the heap and surface any corruption within this test run. + for (let i = 0; i < 100; ++i) { + expect(() => new TextDecoder("iso-8859-2")).to.throw(); + } + const decoder = new TextDecoder("utf-8"); + expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); + }); + + it("should accept the WHATWG 'utf8' label (no hyphen)", function () { + const decoder = new TextDecoder("utf8"); + const result = decoder.decode(new Uint8Array([72, 105])); // "Hi" + expect(result).to.equal("Hi"); + }); + + it("should accept utf-8 labels case-insensitively and with surrounding whitespace", function () { + for (const label of ["UTF-8", "UTF8", " utf-8 ", "\tUtf8\n"]) { + const decoder = new TextDecoder(label); + expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); + } + }); + + it("should accept the other WHATWG utf-8 aliases", function () { + for (const label of ["unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8", "x-unicode20utf8"]) { + const decoder = new TextDecoder(label); + expect(decoder.decode(new Uint8Array([79, 75]))).to.equal("OK"); + } + }); + + it("should still throw for a genuinely unsupported encoding", function () { + expect(() => new TextDecoder("iso-8859-2")).to.throw(); + }); + + it("should decode utf-16le", function () { + const decoder = new TextDecoder("utf-16le"); + // "Hi" as UTF-16LE code units. + expect(decoder.decode(new Uint8Array([0x48, 0x00, 0x69, 0x00]))).to.equal("Hi"); + }); + + it("should decode utf-16be", function () { + const decoder = new TextDecoder("utf-16be"); + expect(decoder.decode(new Uint8Array([0x00, 0x48, 0x00, 0x69]))).to.equal("Hi"); + }); + + it("should accept the other WHATWG utf-16 aliases as little endian", function () { + for (const label of ["utf-16", "ucs-2", "unicode", "unicodeFEFF", "csunicode", "iso-10646-ucs-2"]) { + const decoder = new TextDecoder(label); + expect(decoder.decode(new Uint8Array([0x4F, 0x00, 0x4B, 0x00]))).to.equal("OK"); + } + expect(new TextDecoder("unicodeFFFE").decode(new Uint8Array([0x00, 0x4F, 0x00, 0x4B]))).to.equal("OK"); + }); + + it("should strip a leading byte order mark", function () { + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0xFF, 0xFE, 0x48, 0x00]))).to.equal("H"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xFE, 0xFF, 0x00, 0x48]))).to.equal("H"); + }); + + it("should decode utf-16 outside the BMP and preserve null code units", function () { + // U+1F600 as a surrogate pair, then U+0000, then "A". + const decoder = new TextDecoder("utf-16le"); + const result = decoder.decode(new Uint8Array([0x3D, 0xD8, 0x00, 0xDE, 0x00, 0x00, 0x41, 0x00])); + expect(result).to.equal("\u{1F600}\0A"); + expect(result.length).to.equal(4); + }); + + it("should replace a trailing odd UTF-16 byte with U+FFFD", function () { + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x48, 0x00, 0x00]))).to.equal("H\uFFFD"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0x00, 0x48, 0x00]))).to.equal("H\uFFFD"); + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00]))).to.equal("\uFFFD"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0x00]))).to.equal("\uFFFD"); + }); + + it("should replace unpaired UTF-16 surrogates with U+FFFD", function () { + // Lone lead U+D800. + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8]))).to.equal("\uFFFD"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00]))).to.equal("\uFFFD"); + // Lone trail U+DC00. + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xDC]))).to.equal("\uFFFD"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xDC, 0x00]))).to.equal("\uFFFD"); + // Lead followed by BMP 'A': replacement, then reprocess 'A'. + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8, 0x41, 0x00]))).to.equal("\uFFFDA"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00, 0x00, 0x41]))).to.equal("\uFFFDA"); + // Unpaired lead plus leftover odd byte is a single end-of-queue replacement. + expect(new TextDecoder("utf-16le").decode(new Uint8Array([0x00, 0xD8, 0x00]))).to.equal("\uFFFD"); + expect(new TextDecoder("utf-16be").decode(new Uint8Array([0xD8, 0x00, 0x00]))).to.equal("\uFFFD"); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.textEncoder.ts b/Tests/UnitTests/Source/Scripts/tests.textEncoder.ts new file mode 100644 index 00000000..8741217a --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.textEncoder.ts @@ -0,0 +1,39 @@ +import { expect } from "chai"; + +describe("TextEncoder", function () { + it("should expose encoding === 'utf-8'", function () { + const encoder = new TextEncoder(); + expect(encoder.encoding).to.equal("utf-8"); + }); + + it("should encode an ASCII string into UTF-8 bytes", function () { + const encoder = new TextEncoder(); + const bytes = encoder.encode("Hello"); + expect(Array.from(bytes)).to.eql([72, 101, 108, 108, 111]); + }); + + it("should return an empty Uint8Array when called with no argument", function () { + const encoder = new TextEncoder(); + const bytes = encoder.encode(); + expect(bytes.length).to.equal(0); + }); + + it("should return an empty Uint8Array for undefined input", function () { + const encoder = new TextEncoder(); + const bytes = encoder.encode(undefined); + expect(bytes.length).to.equal(0); + }); + + it("should encode a multi-byte UTF-8 string", function () { + const encoder = new TextEncoder(); + // "é" is U+00E9 -> 0xC3 0xA9 in UTF-8 + const bytes = encoder.encode("é"); + expect(Array.from(bytes)).to.eql([0xC3, 0xA9]); + }); + + it("should encode a string containing a null byte", function () { + const encoder = new TextEncoder(); + const bytes = encoder.encode("H\0i"); + expect(Array.from(bytes)).to.eql([72, 0, 105]); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.url.ts b/Tests/UnitTests/Source/Scripts/tests.url.ts new file mode 100644 index 00000000..edc72ad5 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.url.ts @@ -0,0 +1,522 @@ +import { expect } from "chai"; + +// URL +describe("URL", function () { + + // Currently all of the properties that the polyfill has implemented + interface URLCheckOptions { + href: string; + hostname: string; + origin: string; + pathname: string; + search: string; + } + + function checkURL(url: URL, { href, hostname, origin, pathname, search }: URLCheckOptions): void { + expect(url).to.have.property("hostname", hostname); + expect(url).to.have.property("href", href); + expect(url).to.have.property("origin", origin); + expect(url).to.have.property("pathname", pathname); + expect(url).to.have.property("search", search); + } + + it("should load URL with no pathname / search", function () { + // Standard URL (No pathname, no search) + const url = new URL("https://httpbin.org"); + checkURL(url, { + href: "https://httpbin.org/", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/", + search: "" + }); + }); + + it("should load URL with pathname (no search)", function () { + // Augment URL with pathname (no search) + const url = new URL("https://httpbin.org/en-US/docs"); + checkURL(url, { + href: "https://httpbin.org/en-US/docs", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/en-US/docs", + search: "" + }); + }); + + it("should load URL with pathname and search", function () { + // Augment URL with pathname and search + const url = new URL("https://httpbin.org/en-US/docs?foo=1&bar=2"); + checkURL(url, { + href: "https://httpbin.org/en-US/docs?foo=1&bar=2", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/en-US/docs", + search: "?foo=1&bar=2" + }); + }); + + it("should load URL with pathname and search with multiple key value pairs", function () { + const url = new URL("https://httpbin.org/en-US/docs?c=3&b=2&a=1&d=4"); + checkURL(url, { + href: "https://httpbin.org/en-US/docs?c=3&b=2&a=1&d=4", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/en-US/docs", + search: "?c=3&b=2&a=1&d=4" + }); + }); + + it("should update href after URLSearchParams are changed", function () { + // Augment URL with pathname and search + const url = new URL("https://httpbin.org/en-US/docs?foo=1&bar=2"); + url.searchParams.set("foo", 999 as any); + // href should change to reflect searchParams change + checkURL(url, { + href: "https://httpbin.org/en-US/docs?foo=999&bar=2", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/en-US/docs", + search: "?foo=999&bar=2" + }); + }); + + it("should update href after URLSearchParams are changed (Starting with 0 params)", function () { + // Augment URL with pathname and search + const url = new URL("https://httpbin.org/en-US/docs"); + url.searchParams.set("foo", "999"); + // href should change to reflect searchParams change + checkURL(url, { + href: "https://httpbin.org/en-US/docs?foo=999", + hostname: "httpbin.org", + origin: "https://httpbin.org", + pathname: "/en-US/docs", + search: "?foo=999" + }); + }); + + // -------------------------------- URL Properties -------------------------------- + + it("should parse protocol correctly", function () { + const url = new URL("https://example.com/path"); + expect(url.protocol).to.equal("https:"); + }); + + it("should parse port correctly", function () { + const url = new URL("https://example.com:8080/path"); + expect(url.port).to.equal("8080"); + expect(url.host).to.equal("example.com:8080"); + }); + + it("should return empty port when not specified", function () { + const url = new URL("https://example.com/path"); + expect(url.port).to.equal(""); + expect(url.host).to.equal("example.com"); + }); + + it("should parse hash correctly", function () { + const url = new URL("https://example.com/path#section1"); + expect(url.hash).to.equal("#section1"); + }); + + it("should return empty hash when not present", function () { + const url = new URL("https://example.com/path"); + expect(url.hash).to.equal(""); + }); + + it("should parse username and password correctly", function () { + const url = new URL("https://user:pass@example.com/path"); + expect(url.username).to.equal("user"); + expect(url.password).to.equal("pass"); + }); + + it("should return empty username and password when not present", function () { + const url = new URL("https://example.com/path"); + expect(url.username).to.equal(""); + expect(url.password).to.equal(""); + }); + + it("should parse URL with all components", function () { + const url = new URL("https://user:pass@example.com:8080/path/to/resource?foo=1&bar=2#section"); + expect(url.protocol).to.equal("https:"); + expect(url.username).to.equal("user"); + expect(url.password).to.equal("pass"); + expect(url.hostname).to.equal("example.com"); + expect(url.port).to.equal("8080"); + expect(url.host).to.equal("example.com:8080"); + expect(url.pathname).to.equal("/path/to/resource"); + expect(url.search).to.equal("?foo=1&bar=2"); + expect(url.hash).to.equal("#section"); + expect(url.origin).to.equal("https://example.com:8080"); + }); + + // -------------------------------- URL Property Setters -------------------------------- + + it("should update protocol via setter", function () { + const url = new URL("https://example.com/path"); + url.protocol = "http:"; + expect(url.protocol).to.equal("http:"); + expect(url.href).to.equal("http://example.com/path"); + }); + + it("should add colon to protocol if missing", function () { + const url = new URL("https://example.com/path"); + url.protocol = "http"; + expect(url.protocol).to.equal("http:"); + }); + + it("should update hostname via setter", function () { + const url = new URL("https://example.com/path"); + url.hostname = "newhost.com"; + expect(url.hostname).to.equal("newhost.com"); + expect(url.href).to.equal("https://newhost.com/path"); + }); + + it("should update host via setter (hostname only)", function () { + const url = new URL("https://example.com:8080/path"); + url.host = "newhost.com"; + expect(url.hostname).to.equal("newhost.com"); + expect(url.port).to.equal(""); + expect(url.host).to.equal("newhost.com"); + }); + + it("should update host via setter (hostname and port)", function () { + const url = new URL("https://example.com/path"); + url.host = "newhost.com:9090"; + expect(url.hostname).to.equal("newhost.com"); + expect(url.port).to.equal("9090"); + expect(url.host).to.equal("newhost.com:9090"); + }); + + it("should update port via setter", function () { + const url = new URL("https://example.com/path"); + url.port = "3000"; + expect(url.port).to.equal("3000"); + expect(url.href).to.equal("https://example.com:3000/path"); + }); + + it("should update pathname via setter", function () { + const url = new URL("https://example.com/path"); + url.pathname = "/new/path"; + expect(url.pathname).to.equal("/new/path"); + expect(url.href).to.equal("https://example.com/new/path"); + }); + + it("should add leading slash to pathname if missing", function () { + const url = new URL("https://example.com/path"); + url.pathname = "new/path"; + expect(url.pathname).to.equal("/new/path"); + }); + + it("should update search via setter", function () { + const url = new URL("https://example.com/path"); + url.search = "?newkey=newvalue"; + expect(url.search).to.equal("?newkey=newvalue"); + expect(url.href).to.equal("https://example.com/path?newkey=newvalue"); + }); + + it("should update search via setter without leading question mark", function () { + const url = new URL("https://example.com/path"); + url.search = "key=value"; + expect(url.search).to.equal("?key=value"); + }); + + it("should update hash via setter", function () { + const url = new URL("https://example.com/path"); + url.hash = "#newsection"; + expect(url.hash).to.equal("#newsection"); + expect(url.href).to.equal("https://example.com/path#newsection"); + }); + + it("should add leading hash if missing", function () { + const url = new URL("https://example.com/path"); + url.hash = "newsection"; + expect(url.hash).to.equal("#newsection"); + }); + + it("should update username via setter", function () { + const url = new URL("https://example.com/path"); + url.username = "newuser"; + expect(url.username).to.equal("newuser"); + expect(url.href).to.equal("https://newuser@example.com/path"); + }); + + it("should update password via setter", function () { + const url = new URL("https://user@example.com/path"); + url.password = "newpass"; + expect(url.password).to.equal("newpass"); + expect(url.href).to.equal("https://user:newpass@example.com/path"); + }); + + it("should update href via setter", function () { + const url = new URL("https://example.com/path"); + url.href = "https://newsite.com/newpath?q=1#hash"; + expect(url.hostname).to.equal("newsite.com"); + expect(url.pathname).to.equal("/newpath"); + expect(url.search).to.equal("?q=1"); + expect(url.hash).to.equal("#hash"); + }); + + // -------------------------------- URL Instance Methods -------------------------------- + + it("should return href from toString()", function () { + const url = new URL("https://example.com/path?foo=1#section"); + expect(url.toString()).to.equal("https://example.com/path?foo=1#section"); + }); + + it("should return href from toJSON()", function () { + const url = new URL("https://example.com/path?foo=1#section"); + expect(url.toJSON()).to.equal("https://example.com/path?foo=1#section"); + }); + + // -------------------------------- URL Static Methods -------------------------------- + + it("should return true from canParse() for valid absolute URL", function () { + expect(URL.canParse("https://example.com")).to.equal(true); + }); + + it("should return false from canParse() for invalid URL", function () { + expect(URL.canParse("not-a-url")).to.equal(false); + }); + + it("should return true from canParse() for relative URL with valid base", function () { + expect(URL.canParse("/path", "https://example.com")).to.equal(true); + }); + + it("should return false from canParse() for relative URL with invalid base", function () { + expect(URL.canParse("/path", "not-a-url")).to.equal(false); + }); + + it("should return false from canParse() with no arguments", function () { + // @ts-expect-error - testing no arguments + expect(URL.canParse()).to.equal(false); + }); + + it("should return URL object from parse() for valid URL", function () { + const url = URL.parse("https://example.com/path"); + expect(url).to.not.equal(null); + expect(url!.hostname).to.equal("example.com"); + expect(url!.pathname).to.equal("/path"); + }); + + // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. + // it("should return null from parse() for invalid URL", function () { + // const url = URL.parse("not-a-url"); + // expect(url).to.equal(null); + // }); + + it("should return URL object from parse() with valid base", function () { + const url = URL.parse("/path", "https://example.com"); + expect(url).to.not.equal(null); + expect(url!.hostname).to.equal("example.com"); + expect(url!.pathname).to.equal("/path"); + }); + + // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. + // it("should return null from parse() with invalid base", function () { + // const url = URL.parse("/path", "not-a-url"); + // expect(url).to.equal(null); + // }); + + // -------------------------------- URL Base URL Resolution -------------------------------- + + it("should resolve relative URL with base URL", function () { + const url = new URL("/path/to/resource", "https://example.com"); + expect(url.href).to.equal("https://example.com/path/to/resource"); + }); + + it("should resolve relative path against base URL", function () { + const url = new URL("resource.html", "https://example.com/path/to/"); + expect(url.href).to.equal("https://example.com/path/to/resource.html"); + }); + + it("should resolve relative path against base URL with file", function () { + const url = new URL("resource.html", "https://example.com/path/to/index.html"); + expect(url.href).to.equal("https://example.com/path/to/resource.html"); + }); + + it("should resolve query-only relative URL", function () { + const url = new URL("?newquery=1", "https://example.com/path"); + expect(url.href).to.equal("https://example.com/path?newquery=1"); + }); + + it("should resolve hash-only relative URL", function () { + const url = new URL("#newhash", "https://example.com/path?query=1"); + expect(url.href).to.equal("https://example.com/path?query=1#newhash"); + }); + + it("should resolve protocol-relative URL", function () { + const url = new URL("//other.com/path", "https://example.com"); + expect(url.href).to.equal("https://other.com/path"); + }); + + it("should use absolute URL and ignore base when URL is absolute", function () { + const url = new URL("https://other.com/otherpath", "https://example.com/path"); + expect(url.href).to.equal("https://other.com/otherpath"); + }); + + it("should resolve dot segments in path (single dot)", function () { + const url = new URL("https://example.com/a/b/./c"); + expect(url.pathname).to.equal("/a/b/c"); + }); + + it("should resolve dot segments in path (double dot)", function () { + const url = new URL("https://example.com/a/b/../c"); + expect(url.pathname).to.equal("/a/c"); + }); + + it("should resolve complex dot segments", function () { + const url = new URL("https://example.com/a/b/c/../d/./e/../f"); + expect(url.pathname).to.equal("/a/b/d/f"); + }); + + it("should resolve relative path with dot segments", function () { + const url = new URL("../sibling/file.html", "https://example.com/path/to/current/"); + expect(url.pathname).to.equal("/path/to/sibling/file.html"); + }); + + // -------------------------------- URL Error Handling -------------------------------- + + // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. + // it("should throw for invalid URL without base", function () { + // expect(() => new URL("not-a-valid-url")).to.throw(); + // }); + + // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. + // it("should throw for relative URL without base", function () { + // expect(() => new URL("/path/to/resource")).to.throw(); + // }); + + // TODO: Uncomment this once the the Node-API implementation for Chakra supports throwing errors from constructors. + // it("should throw for empty URL constructor", function () { + // // @ts-expect-error - testing no arguments + // expect(() => new URL()).to.throw(); + // }); + + // -------------------------------- URL Different Schemes -------------------------------- + + it("should parse file:// URL", function () { + const url = new URL("file:///path/to/file.txt"); + expect(url.protocol).to.equal("file:"); + expect(url.pathname).to.equal("/path/to/file.txt"); + }); + + it("should parse ftp:// URL", function () { + const url = new URL("ftp://ftp.example.com/file.zip"); + expect(url.protocol).to.equal("ftp:"); + expect(url.hostname).to.equal("ftp.example.com"); + }); + + it("should parse ws:// URL", function () { + const url = new URL("ws://example.com/socket"); + expect(url.protocol).to.equal("ws:"); + expect(url.hostname).to.equal("example.com"); + }); + + it("should parse wss:// URL", function () { + const url = new URL("wss://example.com/socket"); + expect(url.protocol).to.equal("wss:"); + expect(url.hostname).to.equal("example.com"); + }); + + it("should parse custom scheme URL", function () { + const url = new URL("app:///Assets/tests.js"); + expect(url.protocol).to.equal("app:"); + expect(url.pathname).to.equal("/Assets/tests.js"); + }); +}); + +// URL.createObjectURL / revokeObjectURL (blob: URL registry) +describe("URL.createObjectURL", function () { + this.timeout(0); + + it("mints a blob: URL for a Blob", function () { + const url = URL.createObjectURL(new Blob(["hello"], { type: "text/plain" })); + expect(url).to.be.a("string"); + expect(url.indexOf("blob:")).to.equal(0); + URL.revokeObjectURL(url); + }); + + it("throws when createObjectURL is given a non-Blob", function () { + expect(() => URL.createObjectURL({} as any)).to.throw(); + expect(() => URL.createObjectURL("not a blob" as any)).to.throw(); + }); + + it("resolves a blob: URL through fetch (text + content-type)", async function () { + const url = URL.createObjectURL(new Blob(["hello blob"], { type: "text/plain" })); + const response = await fetch(url); + expect(response.ok).to.equal(true); + expect(response.status).to.equal(200); + expect(response.headers.get("content-type")).to.equal("text/plain"); + expect(await response.text()).to.equal("hello blob"); + URL.revokeObjectURL(url); + }); + + it("resolves binary blob bytes through fetch", async function () { + const bytes = new Uint8Array([1, 2, 3, 4, 250]); + const url = URL.createObjectURL(new Blob([bytes])); + const response = await fetch(url); + const buffer = new Uint8Array(await response.arrayBuffer()); + expect(Array.from(buffer)).to.deep.equal([1, 2, 3, 4, 250]); + URL.revokeObjectURL(url); + }); + + it("resolves a blob: URL through XMLHttpRequest", async function () { + const url = URL.createObjectURL(new Blob(["xhr blob"], { type: "text/plain" })); + const xhr = await new Promise((resolve) => { + const req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("loadend", () => resolve(req)); + req.send(); + }); + expect(xhr.status).to.equal(200); + expect(xhr.statusText).to.equal("OK"); + expect(xhr.responseText).to.equal("xhr blob"); + expect(xhr.getResponseHeader("content-type")).to.equal("text/plain"); + URL.revokeObjectURL(url); + }); + + it("fetch rejects after the blob: URL is revoked", async function () { + const url = URL.createObjectURL(new Blob(["gone"])); + URL.revokeObjectURL(url); + let rejected = false; + try { + await fetch(url); + } catch (e) { + rejected = true; + } + expect(rejected).to.equal(true); + }); + + it("XMLHttpRequest reports status 0 and fires 'error' after revoke", async function () { + const url = URL.createObjectURL(new Blob(["gone"])); + URL.revokeObjectURL(url); + const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { + const req = new XMLHttpRequest(); + let errorFired = false; + req.addEventListener("error", () => { errorFired = true; }); + req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); + req.open("GET", url); + req.send(); + }); + expect(result.status).to.equal(0); + expect(result.errorFired).to.equal(true); + }); + + it("XMLHttpRequest honors a revoke between open() and send()", async function () { + const url = URL.createObjectURL(new Blob(["late revoke"])); + const result = await new Promise<{ status: number; errorFired: boolean }>((resolve) => { + const req = new XMLHttpRequest(); + let errorFired = false; + req.addEventListener("error", () => { errorFired = true; }); + req.addEventListener("loadend", () => resolve({ status: req.status, errorFired })); + req.open("GET", url); + // Revoked after open() but before send(): the store is re-checked at send() time, so + // this must surface as a network error rather than serving stale bytes. + URL.revokeObjectURL(url); + req.send(); + }); + expect(result.status).to.equal(0); + expect(result.errorFired).to.equal(true); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.urlSearchParams.ts b/Tests/UnitTests/Source/Scripts/tests.urlSearchParams.ts new file mode 100644 index 00000000..e9204067 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.urlSearchParams.ts @@ -0,0 +1,120 @@ +import { expect } from "chai"; + +// URLSearchParams +describe("URLSearchParams", function () { + + // -------------------------------- URLSearchParams Get -------------------------------- + + it("should retrieve null from empty searchParams", function () { + // Get Empty + const params = new URLSearchParams(""); + + expect(params.get("foo")).to.equal(null); + }); + + it("should retrieve value from searchParams", function () { + // Get Value + const params = new URLSearchParams("?foo=1"); + + expect(params.get("foo")).to.equal("1"); + }); + + // -------------------------------- URLSearchParams Set -------------------------------- + + const paramsSet = new URLSearchParams(""); + + it("should throw exception when trying to set with less than 2 parameters", function () { + // `set` expects parameters, none given. + // @ts-expect-error + expect(() => paramsSet.set()).to.throw(); + }); + + it("should preserve the type and message of an error thrown from native code", function () { + // A native throw must reach JS unchanged. When napi_throw reported failure, the + // error was replaced by an InternalError reading "Uncaught C++ exception: ...", + // built by stringifying an error whose handle scope had already closed. + let caught: any; + try { + // @ts-expect-error + paramsSet.set(); + } catch (e) { + caught = e; + } + expect(caught).to.be.an.instanceOf(Error); + expect(caught.name).to.equal("Error"); + // Not an equality check: the JSI backend prefixes "Exception in HostFunction: ". + expect(caught.message).to.contain( + "Failed to execute 'set' on 'URLSearchParams': 2 arguments required, but only 0 present" + ); + expect(caught.message).to.not.contain("Uncaught C++ exception"); + }); + + it("should add a number and retrieve it as a string from searchParams", function () { + // Set Number + paramsSet.set("foo", 400 as any); + expect(paramsSet.get("foo")).to.equal("400"); + }); + + it("should add a string and retrieve it as a string from searchParams", function () { + // Set String + paramsSet.set("bar", "50"); + expect(paramsSet.get("bar")).to.equal("50"); + }); + + it("should add a boolean and retrieve it as a string from searchParams", function () { + // Set Boolean + paramsSet.set("baz", true as any); + expect(paramsSet.get("baz")).to.equal("true"); + }); + + it("should set an existing number and retrieve it as a string from searchParams", function () { + // Set Existing Value + paramsSet.set("foo", 9999 as any); + expect(paramsSet.get("foo")).to.equal("9999"); + }); + + // -------------------------------- URLSearchParams Has -------------------------------- + + it("should check value is in searchParams (True)", function () { + // Check existing value + const paramsHas = new URLSearchParams("?foo=1"); + expect(paramsHas.has("foo")).to.equal(true); + }); + + it("should check value is in searchParams (False)", function () { + // Check non-existing value + const paramsHas = new URLSearchParams("?foo=1"); + expect(paramsHas.has("Microsoft")).to.equal(false); + }); + + it("should check empty searchParams for value (False)", function () { + // Check empty params + const paramsEmpty = new URLSearchParams(""); + expect(paramsEmpty.has("foo")).to.equal(false); + }); + + // -------------------------------- URLSearchParams Construction -------------------------------- + + it("should retrieve search params set at construction", function () { + // Retrieve params via url.search, passed into ctor + const url = new URL("https://example.com?foo=1&bar=2"); + const params1 = new URLSearchParams(url.search); + expect(params1.get("foo")).to.equal("1"); + expect(params1.get("bar")).to.equal("2"); + }); + + it("should retrieve search params from url.searchParams object", function () { + // Get the URLSearchParams object directly from an URL object + const url = new URL("https://example.com?foo=1&bar=2"); + const params1a = url.searchParams; + expect(params1a.get("foo")).to.equal("1"); + expect(params1a.get("bar")).to.equal("2"); + }); + + it("should retrieve search params string constructed URLSearchParams", function () { + // Pass in a string literal + const params2 = new URLSearchParams("foo=1&bar=2"); + expect(params2.get("foo")).to.equal("1"); + expect(params2.get("bar")).to.equal("2"); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.webAssembly.ts b/Tests/UnitTests/Source/Scripts/tests.webAssembly.ts new file mode 100644 index 00000000..9558bf9c --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.webAssembly.ts @@ -0,0 +1,37 @@ +import { expect } from "chai"; + +describe("WebAssembly", function () { + this.timeout(30000); + + // Only the V8 AppRuntime pumps V8's foreground task queue, which is what lets these promises + // settle. The other engines' runtimes have the same class of gap and hang here instead of + // failing, so scope the suite rather than leave a 30s timeout on every non-V8 leg. + beforeEach(function () { + if (hostEngine !== "V8" || typeof WebAssembly === "undefined") { + this.skip(); + } + }); + + // Minimal valid module: the 8-byte header (magic + version) and no sections. + const emptyModule = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]); + + it("should settle the promise returned by WebAssembly.compile", async function () { + const module = await WebAssembly.compile(emptyModule); + expect(module).to.be.an.instanceof(WebAssembly.Module); + }); + + it("should settle the promise returned by WebAssembly.instantiate", async function () { + const result = await WebAssembly.instantiate(emptyModule); + expect(result.instance).to.be.an.instanceof(WebAssembly.Instance); + }); + + it("should reject the promise returned by WebAssembly.compile for invalid bytes", async function () { + let threw = false; + try { + await WebAssembly.compile(new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0xFF])); + } catch (e) { + threw = true; + } + expect(threw).to.equal(true); + }); +}); diff --git a/Tests/UnitTests/Source/Scripts/tests.webSocket.ts b/Tests/UnitTests/Source/Scripts/tests.webSocket.ts new file mode 100644 index 00000000..1ba9e82c --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.webSocket.ts @@ -0,0 +1,169 @@ +import { expect } from "chai"; + +// Websocket +if (hostPlatform !== "Unix") { + describe("WebSocket", function () { + this.timeout(10000); + + it("should connect correctly with one websocket connection", function (done) { + const ws = new WebSocket("wss://ws.postman-echo.com/raw"); + const testMessage = "testMessage"; + let error: unknown; + + ws.onopen = () => { + try { + expect(ws).to.have.property("readyState", 1); + expect(ws).to.have.property("url", "wss://ws.postman-echo.com/raw"); + ws.send(testMessage); + } + catch (e) { + error = e; + ws.close(); + } + }; + + ws.onmessage = (msg) => { + try { + expect(msg.data).to.equal(testMessage); + } + catch (e) { + error = e; + } + ws.close(); + }; + + ws.onclose = () => { + if (!error) { + try { + expect(ws).to.have.property("readyState", 3); + } + catch (e) { + error = e; + } + } + done(error); + }; + + ws.onerror = () => { + error = new Error("WebSocket failed"); + }; + }); + + it("should connect correctly with multiple websocket connections", function (done) { + this.timeout(10000); + const testMessage1 = "testMessage1"; + const testMessage2 = "testMessage2"; + let error: unknown; + + const ws1 = new WebSocket("wss://ws.postman-echo.com/raw"); + ws1.onopen = () => { + const ws2 = new WebSocket("wss://ws.postman-echo.com/raw"); + ws2.onopen = () => { + try { + expect(ws2).to.have.property("readyState", 1); + expect(ws2).to.have.property("url", "wss://ws.postman-echo.com/raw"); + ws2.send(testMessage2); + } + catch (e) { + error = e; + ws2.close(); + } + }; + + ws2.onmessage = (msg) => { + try { + expect(msg.data).to.equal(testMessage2); + } + catch (e) { + error = e; + } + ws2.close(); + }; + + ws2.onclose = () => { + if (!error) { + try { + expect(ws2).to.have.property("readyState", 3); + ws1.send(testMessage1); + } + catch (e) { + error = e; + ws1.close(); + } + } + else { + ws1.close(); + } + }; + + ws2.onerror = () => { + error = new Error("WebSocket failed"); + }; + } + + ws1.onmessage = (msg) => { + try { + expect(msg.data).to.equal(testMessage1); + } + catch (e) { + error = e; + } + ws1.close(); + } + + ws1.onclose = () => { + if (!error) { + try { + expect(ws1).to.have.property("readyState", 3); + } + catch (e) { + error = e; + } + } + done(error); + } + + ws1.onerror = () => { + error = new Error("WebSocket failed"); + }; + }); + + it("should trigger error callback with invalid server", function (done) { + this.timeout(10000); + // Random UUID-based hostname so the domain is guaranteed unregistered + // (RFC-reserved `.invalid` causes a >10s DNS path on Win32 x86 Chakra). + const ws = new WebSocket("wss://caddddfd-ee88-4771-b293-8a8e13b330ee.com"); + let errorFired = false; + ws.onerror = () => { + errorFired = true; + }; + ws.onclose = () => { + try { + expect(errorFired).to.be.true; + done(); + } + catch (e) { + done(e); + } + }; + }); + + it("should trigger error callback with invalid domain", function (done) { + this.timeout(10000); + const ws = new WebSocket("wss://example"); + let errorFired = false; + ws.onerror = () => { + errorFired = true; + }; + ws.onclose = () => { + try { + expect(errorFired).to.be.true; + done(); + } + catch (e) { + done(e); + } + }; + }); + }) +} diff --git a/Tests/UnitTests/Source/Scripts/tests.xmlHttpRequest.ts b/Tests/UnitTests/Source/Scripts/tests.xmlHttpRequest.ts new file mode 100644 index 00000000..ab50e950 --- /dev/null +++ b/Tests/UnitTests/Source/Scripts/tests.xmlHttpRequest.ts @@ -0,0 +1,205 @@ +import { expect } from "chai"; + +describe("XMLHTTPRequest", function () { + function createRequest(method: string, url: string, body: any = undefined, responseType: any = undefined): Promise { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + if (responseType !== undefined) { + xhr.responseType = responseType; + } + xhr.addEventListener("loadend", () => resolve(xhr)); + xhr.send(body); + }); + } + + function createRequestWithHeaders(method: string, url: string, headers: any, body?: string): Promise { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + headers.forEach((value: string, key: string) => xhr.setRequestHeader(key, value)); + xhr.addEventListener("loadend", () => resolve(xhr)); + xhr.send(body); + }); + } + + this.timeout(0); + + it("should have readyState=4 when load ends", async function () { + const xhr = await createRequest("GET", "https://github.com/"); + 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/"); + 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"); + 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"); + 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"); + 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"); + expect(xhr.status).to.equal(404); + }); + + it("should expose statusText", async function () { + const okXhr = await createRequest("GET", "https://github.com/"); + expect(okXhr.statusText).to.equal("OK"); + const notFoundXhr = await createRequest("GET", "https://github.com/babylonJS/BabylonNative404"); + expect(notFoundXhr.statusText).to.equal("Not Found"); + }); + + it("should fire 'error' event for a remote URL that returns HTTP 404", async function () { + // Regression test: previously the success-only continuation in XMLHttpRequest::Send + // skipped 'error' on async failures including non-2xx HTTP responses, so onerror + // observers never ran. See https://github.com/BabylonJS/JsRuntimeHost/pull/165. + this.timeout(30000); + const result = await new Promise<{ errorFired: boolean; loadendFired: boolean; status: number; readyState: number }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let errorFired = false; + let loadendFired = false; + const guard = setTimeout(() => reject(new Error("XHR neither errored nor loadended within 25s")), 25000); + xhr.addEventListener("error", () => { errorFired = true; }); + xhr.addEventListener("loadend", () => { + loadendFired = true; + clearTimeout(guard); + resolve({ errorFired, loadendFired, status: xhr.status, readyState: xhr.readyState }); + }); + xhr.open("GET", "https://github.com/babylonJS/BabylonNative404"); + xhr.send(); + }); + expect(result.status).to.equal(404); + expect(result.errorFired).to.equal(true); + expect(result.loadendFired).to.equal(true); + expect(result.readyState).to.equal(4); + }); + + it("should expose errorCode/errorDetail diagnostics after a transport failure", async function () { + this.timeout(30000); + const xhr: any = await createRequest("GET", "http://127.0.0.1:1/"); + expect(xhr.status).to.equal(0); + // Non-standard, additive diagnostics: always strings; populated on Apple/Linux and empty + // on Windows/Android until those backends populate UrlLib's accessors. Either way the + // standard error event + status===0 behavior (asserted above) is unchanged. + expect(xhr.errorCode).to.be.a("string"); + expect(xhr.errorDetail).to.be.a("string"); + }); + + it("should expose empty errorCode/errorDetail after a successful request", async function () { + const xhr: any = await createRequest("GET", "app:///Assets/symlink_target.js"); + expect(xhr.errorCode).to.equal(""); + expect(xhr.errorDetail).to.equal(""); + }); + + it("should throw something when opening //", async function () { + function openDoubleSlash() { + const xhr = new XMLHttpRequest(); + xhr.open("GET", "//"); + xhr.send(); + } + expect(openDoubleSlash).to.throw(); + }); + + it("should throw something when opening a url with no scheme", function () { + function openNoProtocol() { + const xhr = new XMLHttpRequest(); + xhr.open("GET", "noscheme.glb"); + xhr.send(); + } + expect(openNoProtocol).to.throw(); + }); + + it("should throw something when sending before opening", function () { + function sendWithoutOpening() { + const xhr = new XMLHttpRequest(); + xhr.send(); + } + expect(sendWithoutOpening).to.throw(); + }); + + // TODO: httpbin server seems to be flaky right now. Re-enable these tests later. + // if (hostPlatform !== "Unix") { + // it("should make a POST request with no body successfully", async function () { + // const xhr = await createRequest("POST", "https://httpbin.org/post"); + // expect(xhr).to.have.property("readyState", 4); + // expect(xhr).to.have.property("status", 200); + // }); + + // it("should make a POST request with body successfully", async function () { + // const xhr = await createRequest("POST", "https://httpbin.org/post", "sampleBody"); + // expect(xhr).to.have.property("readyState", 4); + // expect(xhr).to.have.property("status", 200); + // }); + // } + + // it("should make a GET request with headers successfully", async function () { + // const headersMap = new Map([["foo", "3"], ["bar", "3"]]); + // const xhr = await createRequestWithHeaders("GET", "https://httpbin.org/get", headersMap); + // expect(xhr).to.have.property("readyState", 4); + // expect(xhr).to.have.property("status", 200); + // }); + + // if (hostPlatform !== "Unix") { + // it("should make a POST request with body and headers successfully", async function () { + // const headersMap = new Map([["foo", "3"], ["bar", "3"]]); + // const xhr = await createRequestWithHeaders("POST", "https://httpbin.org/post", headersMap, "testBody"); + // expect(xhr).to.have.property("readyState", 4); + // expect(xhr).to.have.property("status", 200); + // }); + // } + + if (hostPlatform === "macOS" || hostPlatform === "Unix" || hostPlatform === "Win32") { + it("should load URL pointing to symlink", async function () { + const xhr = await createRequest("GET", "app:///Assets/symlink_1.js"); + expect(xhr).to.have.property("responseText", "var symlink_target_js = true;"); + }); + + it("should load URL pointing to symlink that points to a symlink", async function () { + const xhr = await createRequest("GET", "app:///Assets/symlink_2.js"); + expect(xhr).to.have.property("responseText", "var symlink_target_js = true;"); + }); + } + + it("should load URL as array buffer", async function () { + const xhr = await createRequest("GET", "app:///Assets/symlink_target.js", undefined, "arraybuffer"); + var expected = new Uint8Array("var symlink_target_js = true;".split("").map(x => x.charCodeAt(0))); + var response = new Uint8Array(xhr.response); + expect(response).to.eql(expected); + }); + + it("should load a PLY file and parse vertex count from header using TextDecoder", async function () { + this.timeout(30000); + const xhr = await createRequest("GET", "app:///Assets/Halo_Believe.ply", undefined, "arraybuffer"); + expect(xhr.status).to.equal(200); + + const ubuf = new Uint8Array(xhr.response); + const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10)); + const headerEnd = "end_header\n"; + const headerEndIndex = header.indexOf(headerEnd); + expect(headerEndIndex).to.be.greaterThan(0); + + const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)![1]); + expect(vertexCount).to.equal(18713); + }); + + it("should not truncate responseText or response at an embedded null byte", async function () { + const xhr = await createRequest("GET", "app:///Assets/embedded_nulls.txt"); + expect(xhr.status).to.equal(200); + expect(xhr.responseText).to.equal("start\0middle\0end"); + expect(xhr.responseText.length).to.equal(16); + expect(xhr.response).to.equal(xhr.responseText); + }); +}); diff --git a/Tests/UnitTests/Source/Tests.AppRuntime.cpp b/Tests/UnitTests/Source/Tests.AppRuntime.cpp new file mode 100644 index 00000000..79e306f4 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.AppRuntime.cpp @@ -0,0 +1,104 @@ +#include +#include +#include +#include +#include +#include +#include + +TEST(AppRuntime, DestroyDoesNotDeadlock) +{ + // Regression test verifying AppRuntime destruction doesn't deadlock. + // Uses a global arcana hook to sleep while holding the queue mutex + // before wait(), ensuring the worker is in the vulnerable window + // when the destructor fires. See #147 for details on the bug and fix. + // + // The entire test runs on a separate thread so the gtest thread can + // detect a deadlock via timeout without hanging the process. + // + // Test flow: + // + // Test Thread Worker Thread + // ----------- ------------- + // 1. Create AppRuntime Worker starts, enters blocking_tick + // Wait for init to complete + // 2. Install hook + // Dispatch(no-op) Worker wakes, runs no-op, + // returns to blocking_tick + // Hook fires: + // signal workerInHook + // sleep 200ms (holding mutex!) + // 3. workerInHook.wait() + // Worker is sleeping in hook + // 4. ~AppRuntime(): + // cancel() + // Append(no-op): + // push() blocks ------> (worker holds mutex) + // 200ms sleep ends + // wait(lock) releases mutex + // push() acquires mutex + // pushes, notifies ---> wakes up! + // join() waits drains no-op, cancelled -> exit + // join() returns <----- thread exits + // 5. destroy completes -> PASS + + bool hookSignaled{false}; + std::promise workerInHook; + std::promise testDone; + + // Run the full lifecycle on a separate thread so the gtest thread + // can detect a deadlock via timeout. + std::thread testThread([&]() { + auto runtime = std::make_unique(); + + // Wait for the runtime to fully initialize. The constructor dispatches + // CreateForJavaScript which must complete before we install the hook + // so the worker is idle and ready to enter the hook on the next wait. + std::promise ready; + runtime->Dispatch([&ready](Napi::Env) { + ready.set_value(); + }); + ready.get_future().wait(); + + // Install the hook and dispatch a no-op to wake the worker, + // ensuring it cycles through the hook on its way back to idle. + arcana::test_hooks::blocking_concurrent_queue::set_before_wait_callback([&]() { + if (hookSignaled) + { + return; + } + hookSignaled = true; + workerInHook.set_value(); + // This sleep is not truly deterministic. Its purpose is to hold the + // mutex long enough for runtime.reset() (called by the test thread + // after workerInHook signals) to reach push() while the mutex is + // still held. When the sleep ends, the worker enters wait() which + // releases the mutex, allowing push() to acquire it and deliver the + // wake-up notification. If runtime.reset() hasn't reached push() + // by the time the sleep ends, the test still passes but doesn't + // exercise the intended contention window. + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + }); + runtime->Dispatch([](Napi::Env) {}); + + // Wait for the worker to be in the hook (holding mutex, sleeping) + workerInHook.get_future().wait(); + + // Destroy — if the fix works, the destructor completes. + // If broken, it deadlocks and the timeout detects it. + runtime.reset(); + testDone.set_value(); + }); + + auto status = testDone.get_future().wait_for(std::chrono::seconds(5)); + + arcana::test_hooks::blocking_concurrent_queue::set_before_wait_callback([]() {}); + + if (status == std::future_status::timeout) + { + testThread.detach(); + FAIL() << "Deadlock detected: AppRuntime destructor did not complete within 5 seconds"; + } + + testThread.join(); +} diff --git a/Tests/UnitTests/Source/Tests.Console.cpp b/Tests/UnitTests/Source/Tests.Console.cpp new file mode 100644 index 00000000..0fcdc424 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.Console.cpp @@ -0,0 +1,80 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TEST(Console, Log) +{ + Babylon::AppRuntime runtime{}; + + runtime.Dispatch([](Napi::Env env) mutable { + Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { + const char* test = "foo bar"; + if (strcmp(message, test) != 0) + { + std::cout << "Expected: " << test << std::endl; + std::cout << "Received: " << message << std::endl; + std::cout.flush(); + ADD_FAILURE(); + } + }); + }); + + std::promise done; + + Babylon::ScriptLoader loader{runtime}; + loader.Eval("console.log('foo', 'bar')", ""); + loader.Dispatch([&done](auto) { + done.set_value(); + }); + + done.get_future().get(); +} + +TEST(Console, CaptureCurrentJsStack) +{ + // Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from + // within a callback fired by `console.error`, and when called from `console.log` (any frame + // produced by JS execution). + Babylon::AppRuntime runtime{}; + + std::promise errorStackPromise; + std::promise logStackPromise; + + runtime.Dispatch([&errorStackPromise, &logStackPromise](Napi::Env env) mutable { + Babylon::Polyfills::Console::Initialize(env, [env, &errorStackPromise, &logStackPromise](const char* /*message*/, Babylon::Polyfills::Console::LogLevel logLevel) { + std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env); + if (logLevel == Babylon::Polyfills::Console::LogLevel::Error) + { + errorStackPromise.set_value(std::move(stack)); + } + else if (logLevel == Babylon::Polyfills::Console::LogLevel::Log) + { + logStackPromise.set_value(std::move(stack)); + } + }); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Eval("console.log('log message');", ""); + loader.Eval("function inner() { console.error('error message'); } inner();", ""); + + auto errorFuture = errorStackPromise.get_future(); + auto logFuture = logStackPromise.get_future(); + constexpr auto timeout = std::chrono::seconds(30); + ASSERT_EQ(errorFuture.wait_for(timeout), std::future_status::ready) + << "console.error callback did not fire within timeout"; + ASSERT_EQ(logFuture.wait_for(timeout), std::future_status::ready) + << "console.log callback did not fire within timeout"; + + std::string errorStack = errorFuture.get(); + std::string logStack = logFuture.get(); + + EXPECT_FALSE(errorStack.empty()) << "console.error path must capture a non-empty JS stack"; + EXPECT_FALSE(logStack.empty()) << "console.log path must capture a non-empty JS stack"; +} diff --git a/Tests/UnitTests/Shared/DelayedTaskScheduler.cpp b/Tests/UnitTests/Source/Tests.DelayedTaskScheduler.cpp similarity index 100% rename from Tests/UnitTests/Shared/DelayedTaskScheduler.cpp rename to Tests/UnitTests/Source/Tests.DelayedTaskScheduler.cpp diff --git a/Tests/UnitTests/Source/Tests.JavaScript.cpp b/Tests/UnitTests/Source/Tests.JavaScript.cpp new file mode 100644 index 00000000..2c4a2c66 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.JavaScript.cpp @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + const char* EnumToString(Babylon::Polyfills::Console::LogLevel logLevel) + { + switch (logLevel) + { + case Babylon::Polyfills::Console::LogLevel::Log: + return "log"; + case Babylon::Polyfills::Console::LogLevel::Warn: + return "warn"; + case Babylon::Polyfills::Console::LogLevel::Error: + return "error"; + } + + return "unknown"; + } +} + +TEST(JavaScript, All) +{ + // Change this to true to wait for the JavaScript debugger to attach (only applies to V8) + constexpr const bool waitForDebugger = false; + + std::promise exitCodePromise; + + Babylon::AppRuntime::Options options{}; + + options.UnhandledExceptionHandler = [&exitCodePromise](const Napi::Error& error) { + std::cerr << "[Uncaught Error] " << Napi::GetErrorString(error) << std::endl; + std::cerr.flush(); + + exitCodePromise.set_value(-1); + }; + + if (waitForDebugger) + { + std::cout << "Waiting for debugger..." << std::endl; + options.WaitForDebugger = true; + } + + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&exitCodePromise](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) + { + std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env); + if (!stack.empty()) + { + std::cout << std::endl << stack; + } + } + std::cout << std::endl; + std::cout.flush(); + }); + + Babylon::Polyfills::AbortController::Initialize(env); + Babylon::Polyfills::Performance::Initialize(env); + Babylon::Polyfills::Scheduling::Initialize(env); + Babylon::Polyfills::URL::Initialize(env); + Babylon::Polyfills::WebSocket::Initialize(env); + Babylon::Polyfills::XMLHttpRequest::Initialize(env); + Babylon::Polyfills::Fetch::Initialize(env); + Babylon::Polyfills::Blob::Initialize(env); + Babylon::Polyfills::File::Initialize(env); + Babylon::Polyfills::TextDecoder::Initialize(env); + Babylon::Polyfills::TextEncoder::Initialize(env); + + auto setExitCodeCallback = Napi::Function::New( + env, [&exitCodePromise](const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + exitCodePromise.set_value(info[0].As().Int32Value()); + }, + "setExitCode"); + env.Global().Set("setExitCode", setExitCodeCallback); + + env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); + env.Global().Set("hostEngine", Napi::Value::From(env, NAPI_JAVASCRIPT_ENGINE)); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Eval("location = { href: '' };", ""); // Required for Mocha.js as we do not have a location + loader.LoadScript("app:///Assets/tests.js"); + + auto exitCode{exitCodePromise.get_future().get()}; + + EXPECT_EQ(exitCode, 0); +} diff --git a/Tests/UnitTests/Shared/Shared.cpp b/Tests/UnitTests/Source/Tests.NodeApi.cpp similarity index 59% rename from Tests/UnitTests/Shared/Shared.cpp rename to Tests/UnitTests/Source/Tests.NodeApi.cpp index 1c7e9ff7..d0fc5016 100644 --- a/Tests/UnitTests/Shared/Shared.cpp +++ b/Tests/UnitTests/Source/Tests.NodeApi.cpp @@ -1,338 +1,8 @@ -#include "Shared.h" #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include #include #include -#include -#include - -namespace -{ - const char* EnumToString(Babylon::Polyfills::Console::LogLevel logLevel) - { - switch (logLevel) - { - case Babylon::Polyfills::Console::LogLevel::Log: - return "log"; - case Babylon::Polyfills::Console::LogLevel::Warn: - return "warn"; - case Babylon::Polyfills::Console::LogLevel::Error: - return "error"; - } - - return "unknown"; - } -} - -TEST(JavaScript, All) -{ - // Change this to true to wait for the JavaScript debugger to attach (only applies to V8) - constexpr const bool waitForDebugger = false; - - std::promise exitCodePromise; - - Babylon::AppRuntime::Options options{}; - - options.UnhandledExceptionHandler = [&exitCodePromise](const Napi::Error& error) { - std::cerr << "[Uncaught Error] " << Napi::GetErrorString(error) << std::endl; - std::cerr.flush(); - - exitCodePromise.set_value(-1); - }; - - if (waitForDebugger) - { - std::cout << "Waiting for debugger..." << std::endl; - options.WaitForDebugger = true; - } - - Babylon::AppRuntime runtime{options}; - - runtime.Dispatch([&exitCodePromise](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) - { - std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env); - if (!stack.empty()) - { - std::cout << std::endl << stack; - } - } - std::cout << std::endl; - std::cout.flush(); - }); - - Babylon::Polyfills::AbortController::Initialize(env); - Babylon::Polyfills::Performance::Initialize(env); - Babylon::Polyfills::Scheduling::Initialize(env); - Babylon::Polyfills::URL::Initialize(env); - Babylon::Polyfills::WebSocket::Initialize(env); - Babylon::Polyfills::XMLHttpRequest::Initialize(env); - Babylon::Polyfills::Fetch::Initialize(env); - Babylon::Polyfills::Blob::Initialize(env); - Babylon::Polyfills::File::Initialize(env); - Babylon::Polyfills::TextDecoder::Initialize(env); - Babylon::Polyfills::TextEncoder::Initialize(env); - - auto setExitCodeCallback = Napi::Function::New( - env, [&exitCodePromise](const Napi::CallbackInfo& info) { - Napi::Env env = info.Env(); - exitCodePromise.set_value(info[0].As().Int32Value()); - }, - "setExitCode"); - env.Global().Set("setExitCode", setExitCodeCallback); - - env.Global().Set("hostPlatform", Napi::Value::From(env, JSRUNTIMEHOST_PLATFORM)); - env.Global().Set("hostEngine", Napi::Value::From(env, NAPI_JAVASCRIPT_ENGINE)); - }); - - Babylon::ScriptLoader loader{runtime}; - loader.Eval("location = { href: '' };", ""); // Required for Mocha.js as we do not have a location - loader.LoadScript("app:///Scripts/tests.js"); - - auto exitCode{exitCodePromise.get_future().get()}; - - EXPECT_EQ(exitCode, 0); -} - -// The unit test host's UnhandledExceptionHandler fails the whole JavaScript -// suite, so a throwing timer callback cannot be exercised from tests.ts. This -// covers it natively instead. -TEST(Scheduling, IntervalSurvivesThrowingCallback) -{ - // Regression: repeating timeouts are re-armed after their callback returns - // rather than before it runs, so an exception escaping a tick must not - // silently stop the interval. Browsers keep the interval running and report - // the error, and that is also what this dispatcher did previously. - std::promise tickCountPromise; - std::atomic unhandledErrorCount{0}; - - Babylon::AppRuntime::Options options{}; - options.UnhandledExceptionHandler = [&unhandledErrorCount](const Napi::Error&) { - ++unhandledErrorCount; - }; - - Babylon::AppRuntime runtime{options}; - - runtime.Dispatch([&tickCountPromise](Napi::Env env) { - Babylon::Polyfills::Scheduling::Initialize(env); - - auto reportTicks = Napi::Function::New( - env, [&tickCountPromise](const Napi::CallbackInfo& info) { - tickCountPromise.set_value(info[0].As().Int32Value()); - }, - "reportTicks"); - env.Global().Set("reportTicks", reportTicks); - }); - - Babylon::ScriptLoader loader{runtime}; - loader.Eval(R"( - var ticks = 0; - var id = setInterval(function () { - ticks++; - if (ticks === 3) { - clearInterval(id); - reportTicks(ticks); - return; - } - throw new Error('tick failed'); - }, 1); - )", - ""); - - auto tickCountFuture{tickCountPromise.get_future()}; - ASSERT_EQ(tickCountFuture.wait_for(std::chrono::seconds(10)), std::future_status::ready) - << "the interval stopped after a tick threw"; - EXPECT_EQ(tickCountFuture.get(), 3); - - // The first two ticks threw, and those errors must still be surfaced. - EXPECT_EQ(unhandledErrorCount.load(), 2); -} - -TEST(Console, Log) -{ - Babylon::AppRuntime runtime{}; - - runtime.Dispatch([](Napi::Env env) mutable { - Babylon::Polyfills::Console::Initialize(env, [](const char* message, Babylon::Polyfills::Console::LogLevel logLevel) { - const char* test = "foo bar"; - if (strcmp(message, test) != 0) - { - std::cout << "Expected: " << test << std::endl; - std::cout << "Received: " << message << std::endl; - std::cout.flush(); - ADD_FAILURE(); - } - }); - }); - - std::promise done; - - Babylon::ScriptLoader loader{runtime}; - loader.Eval("console.log('foo', 'bar')", ""); - loader.Dispatch([&done](auto) { - done.set_value(); - }); - - done.get_future().get(); -} - -TEST(Console, CaptureCurrentJsStack) -{ - // Regression: Console::CaptureCurrentJsStack must return a non-empty stack when called from - // within a callback fired by `console.error`, and when called from `console.log` (any frame - // produced by JS execution). - Babylon::AppRuntime runtime{}; - - std::promise errorStackPromise; - std::promise logStackPromise; - - runtime.Dispatch([&errorStackPromise, &logStackPromise](Napi::Env env) mutable { - Babylon::Polyfills::Console::Initialize(env, [env, &errorStackPromise, &logStackPromise](const char* /*message*/, Babylon::Polyfills::Console::LogLevel logLevel) { - std::string stack = Babylon::Polyfills::Console::CaptureCurrentJsStack(env); - if (logLevel == Babylon::Polyfills::Console::LogLevel::Error) - { - errorStackPromise.set_value(std::move(stack)); - } - else if (logLevel == Babylon::Polyfills::Console::LogLevel::Log) - { - logStackPromise.set_value(std::move(stack)); - } - }); - }); - - Babylon::ScriptLoader loader{runtime}; - loader.Eval("console.log('log message');", ""); - loader.Eval("function inner() { console.error('error message'); } inner();", ""); - - auto errorFuture = errorStackPromise.get_future(); - auto logFuture = logStackPromise.get_future(); - constexpr auto timeout = std::chrono::seconds(30); - ASSERT_EQ(errorFuture.wait_for(timeout), std::future_status::ready) - << "console.error callback did not fire within timeout"; - ASSERT_EQ(logFuture.wait_for(timeout), std::future_status::ready) - << "console.log callback did not fire within timeout"; - - std::string errorStack = errorFuture.get(); - std::string logStack = logFuture.get(); - - EXPECT_FALSE(errorStack.empty()) << "console.error path must capture a non-empty JS stack"; - EXPECT_FALSE(logStack.empty()) << "console.log path must capture a non-empty JS stack"; -} - -TEST(AppRuntime, DestroyDoesNotDeadlock) -{ - // Regression test verifying AppRuntime destruction doesn't deadlock. - // Uses a global arcana hook to sleep while holding the queue mutex - // before wait(), ensuring the worker is in the vulnerable window - // when the destructor fires. See #147 for details on the bug and fix. - // - // The entire test runs on a separate thread so the gtest thread can - // detect a deadlock via timeout without hanging the process. - // - // Test flow: - // - // Test Thread Worker Thread - // ----------- ------------- - // 1. Create AppRuntime Worker starts, enters blocking_tick - // Wait for init to complete - // 2. Install hook - // Dispatch(no-op) Worker wakes, runs no-op, - // returns to blocking_tick - // Hook fires: - // signal workerInHook - // sleep 200ms (holding mutex!) - // 3. workerInHook.wait() - // Worker is sleeping in hook - // 4. ~AppRuntime(): - // cancel() - // Append(no-op): - // push() blocks ------> (worker holds mutex) - // 200ms sleep ends - // wait(lock) releases mutex - // push() acquires mutex - // pushes, notifies ---> wakes up! - // join() waits drains no-op, cancelled -> exit - // join() returns <----- thread exits - // 5. destroy completes -> PASS - - bool hookSignaled{false}; - std::promise workerInHook; - std::promise testDone; - - // Run the full lifecycle on a separate thread so the gtest thread - // can detect a deadlock via timeout. - std::thread testThread([&]() { - auto runtime = std::make_unique(); - - // Wait for the runtime to fully initialize. The constructor dispatches - // CreateForJavaScript which must complete before we install the hook - // so the worker is idle and ready to enter the hook on the next wait. - std::promise ready; - runtime->Dispatch([&ready](Napi::Env) { - ready.set_value(); - }); - ready.get_future().wait(); - - // Install the hook and dispatch a no-op to wake the worker, - // ensuring it cycles through the hook on its way back to idle. - arcana::test_hooks::blocking_concurrent_queue::set_before_wait_callback([&]() { - if (hookSignaled) - { - return; - } - hookSignaled = true; - workerInHook.set_value(); - // This sleep is not truly deterministic. Its purpose is to hold the - // mutex long enough for runtime.reset() (called by the test thread - // after workerInHook signals) to reach push() while the mutex is - // still held. When the sleep ends, the worker enters wait() which - // releases the mutex, allowing push() to acquire it and deliver the - // wake-up notification. If runtime.reset() hasn't reached push() - // by the time the sleep ends, the test still passes but doesn't - // exercise the intended contention window. - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - }); - runtime->Dispatch([](Napi::Env) {}); - - // Wait for the worker to be in the hook (holding mutex, sleeping) - workerInHook.get_future().wait(); - - // Destroy — if the fix works, the destructor completes. - // If broken, it deadlocks and the timeout detects it. - runtime.reset(); - testDone.set_value(); - }); - - auto status = testDone.get_future().wait_for(std::chrono::seconds(5)); - - arcana::test_hooks::blocking_concurrent_queue::set_before_wait_callback([]() {}); - - if (status == std::future_status::timeout) - { - testThread.detach(); - FAIL() << "Deadlock detected: AppRuntime destructor did not complete within 5 seconds"; - } - - testThread.join(); -} +#include // The V8JSI Node-API shim does not implement napi_create_dataview / // napi_get_dataview_info (its DataView::New throws "TODO"), so this native test @@ -828,9 +498,3 @@ TEST(NodeApi, AdjacentEscapableScopesEscapeIndependently) } #endif - -int RunTests() -{ - testing::InitGoogleTest(); - return RUN_ALL_TESTS(); -} diff --git a/Tests/UnitTests/Source/Tests.Scheduling.cpp b/Tests/UnitTests/Source/Tests.Scheduling.cpp new file mode 100644 index 00000000..6b5d8ed3 --- /dev/null +++ b/Tests/UnitTests/Source/Tests.Scheduling.cpp @@ -0,0 +1,62 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// The unit test host's UnhandledExceptionHandler fails the whole JavaScript +// suite, so a throwing timer callback cannot be exercised from the script tests. This +// covers it natively instead. +TEST(Scheduling, IntervalSurvivesThrowingCallback) +{ + // Regression: repeating timeouts are re-armed after their callback returns + // rather than before it runs, so an exception escaping a tick must not + // silently stop the interval. Browsers keep the interval running and report + // the error, and that is also what this dispatcher did previously. + std::promise tickCountPromise; + std::atomic unhandledErrorCount{0}; + + Babylon::AppRuntime::Options options{}; + options.UnhandledExceptionHandler = [&unhandledErrorCount](const Napi::Error&) { + ++unhandledErrorCount; + }; + + Babylon::AppRuntime runtime{options}; + + runtime.Dispatch([&tickCountPromise](Napi::Env env) { + Babylon::Polyfills::Scheduling::Initialize(env); + + auto reportTicks = Napi::Function::New( + env, [&tickCountPromise](const Napi::CallbackInfo& info) { + tickCountPromise.set_value(info[0].As().Int32Value()); + }, + "reportTicks"); + env.Global().Set("reportTicks", reportTicks); + }); + + Babylon::ScriptLoader loader{runtime}; + loader.Eval(R"( + var ticks = 0; + var id = setInterval(function () { + ticks++; + if (ticks === 3) { + clearInterval(id); + reportTicks(ticks); + return; + } + throw new Error('tick failed'); + }, 1); + )", + ""); + + auto tickCountFuture{tickCountPromise.get_future()}; + ASSERT_EQ(tickCountFuture.wait_for(std::chrono::seconds(10)), std::future_status::ready) + << "the interval stopped after a tick threw"; + EXPECT_EQ(tickCountFuture.get(), 3); + + // The first two ticks threw, and those errors must still be surfaced. + EXPECT_EQ(unhandledErrorCount.load(), 2); +} diff --git a/Tests/UnitTests/Shared/StandardStreamLogger.cpp b/Tests/UnitTests/Source/Tests.StandardStreamLogger.cpp similarity index 100% rename from Tests/UnitTests/Shared/StandardStreamLogger.cpp rename to Tests/UnitTests/Source/Tests.StandardStreamLogger.cpp diff --git a/Tests/UnitTests/Shared/TimeoutDispatcher.cpp b/Tests/UnitTests/Source/Tests.TimeoutDispatcher.cpp similarity index 100% rename from Tests/UnitTests/Shared/TimeoutDispatcher.cpp rename to Tests/UnitTests/Source/Tests.TimeoutDispatcher.cpp diff --git a/Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp b/Tests/UnitTests/Source/Tests.V8ForegroundTaskRunner.cpp similarity index 100% rename from Tests/UnitTests/Shared/V8ForegroundTaskRunner.cpp rename to Tests/UnitTests/Source/Tests.V8ForegroundTaskRunner.cpp diff --git a/Tests/UnitTests/Win32/App.cpp b/Tests/UnitTests/Source/Win32/App.cpp similarity index 92% rename from Tests/UnitTests/Win32/App.cpp rename to Tests/UnitTests/Source/Win32/App.cpp index 7e60a6df..47a9f84e 100644 --- a/Tests/UnitTests/Win32/App.cpp +++ b/Tests/UnitTests/Source/Win32/App.cpp @@ -1,4 +1,4 @@ -#include "../Shared/Shared.h" +#include "../App.h" #include #include "Babylon/DebugTrace.h" #include diff --git a/Tests/UnitTests/iOS/App.mm b/Tests/UnitTests/Source/iOS/App.mm similarity index 93% rename from Tests/UnitTests/iOS/App.mm rename to Tests/UnitTests/Source/iOS/App.mm index 23b74e23..d7fbe7bc 100644 --- a/Tests/UnitTests/iOS/App.mm +++ b/Tests/UnitTests/Source/iOS/App.mm @@ -1,4 +1,4 @@ -#include "../Shared/Shared.h" +#include "../App.h" #include "Babylon/DebugTrace.h" #include #import diff --git a/Tests/UnitTests/iOS/Info.plist b/Tests/UnitTests/Source/iOS/Info.plist similarity index 100% rename from Tests/UnitTests/iOS/Info.plist rename to Tests/UnitTests/Source/iOS/Info.plist diff --git a/Tests/UnitTests/macOS/App.mm b/Tests/UnitTests/Source/macOS/App.mm similarity index 89% rename from Tests/UnitTests/macOS/App.mm rename to Tests/UnitTests/Source/macOS/App.mm index 824709d6..f7e44f1d 100644 --- a/Tests/UnitTests/macOS/App.mm +++ b/Tests/UnitTests/Source/macOS/App.mm @@ -1,4 +1,4 @@ -#include "../Shared/Shared.h" +#include "../App.h" #include "Babylon/DebugTrace.h" #import diff --git a/Tests/package-lock.json b/Tests/package-lock.json index eccda69f..91a922f1 100644 --- a/Tests/package-lock.json +++ b/Tests/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "jsruntimehost", "version": "1.0.0", - "hasInstallScript": true, "dependencies": { "chai": "5.2.0", "mocha": "11.1.0" diff --git a/Tests/package.json b/Tests/package.json index 2da0b798..a9a2fb6a 100644 --- a/Tests/package.json +++ b/Tests/package.json @@ -4,8 +4,7 @@ "private": true, "scripts": { "build": "webpack", - "watch": "webpack --watch", - "postinstall": "npm run build" + "watch": "webpack --watch" }, "dependencies": { "chai": "5.2.0", diff --git a/Tests/webpack.config.js b/Tests/webpack.config.js index 02eb4873..1e4d0e26 100644 --- a/Tests/webpack.config.js +++ b/Tests/webpack.config.js @@ -6,7 +6,7 @@ module.exports = { mode: 'development', // or 'production' devtool: false, entry: { - tests: './UnitTests/Scripts/tests.ts', + tests: './UnitTests/Source/Scripts/tests.javaScript.all.ts', }, output: { filename: '[name].js',