diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45c26403bc..c92ef035a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,86 @@ jobs: - name: Build all packages run: yarn workspaces foreach --all --exclude react-native-executorch-webrtc --topological-dev run prepare + + native-tests: + name: C++ unit tests + runs-on: ubuntu-latest + # Well clear of a cold run (the Hermes + ExecuTorch dependency build is the + # bulk of it) but far below the 6 h default, so a stalled download fails + # with logs instead of hanging. + timeout-minutes: 45 + defaults: + run: + working-directory: packages/react-native-executorch + steps: + - name: Checkout + uses: actions/checkout@v6 + + # googletest for the harness, phonemis because cpp/extensions/speech + # compiles it. GIT_LFS_SKIP_SMUDGE keeps phonemis' `data/` as pointer + # files: it is Git LFS, several tens of MB, and nothing in the host suite + # reads it (see cpp/tests/extensions/PhonemizerTest.cpp). + - name: Check out the submodules the tests build + run: | + git submodule update --init --depth 1 third-party/googletest + GIT_LFS_SKIP_SMUDGE=1 git submodule update --init --depth 1 \ + packages/react-native-executorch/third-party/common/phonemis + working-directory: ${{ github.workspace }} + + # download-libs.js is dependency-free, so this job needs node but not a + # yarn install. + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + # Only the two OpenCV modules the cv extension uses. The `libopencv-dev` + # meta-package hard-depends on the viz and contrib modules, which pull VTK, + # OpenMPI and ~220 packages — over 50 minutes on a throttled mirror. + # OpenCVConfig.cmake ships only in that meta-package, so cpp/tests + # falls back to locating the libraries directly. + - name: Install build tooling + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ninja-build libopencv-core-dev libopencv-imgproc-dev + working-directory: ${{ github.workspace }} + env: + DEBIAN_FRONTEND: noninteractive + + - name: Provision third-party headers + run: RNET_HEADERS_ONLY=1 node scripts/download-libs.js + + # Hermes and ExecuTorch are pinned to exact tags, so the cache only misses + # when scripts/build-native-test-deps.sh changes those pins. + # + # Split restore/save rather than actions/cache: the combined action only + # saves in a post step when the job succeeds, so a failing test would throw + # away the ~9 min dependency build and rebuild it on every retry. + - name: Restore native test dependencies + id: deps-cache + uses: actions/cache/restore@v5 + with: + path: packages/react-native-executorch/.native-test-deps + key: ${{ runner.os }}-native-test-deps-${{ hashFiles('packages/react-native-executorch/scripts/build-native-test-deps.sh') }} + + - name: Build native test dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + run: scripts/build-native-test-deps.sh + + - name: Save native test dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: packages/react-native-executorch/.native-test-deps + key: ${{ steps.deps-cache.outputs.cache-primary-key }} + + # ~3 MB in total (a .pte program and a tokenizer.json), each pinned to an + # exact HF revision and checksum-verified. Fetched as its own step so a + # Hugging Face outage is an obvious failure rather than a confusing one + # inside the test run. + - name: Fetch test fixtures + run: scripts/fetch-test-fixtures.sh + + - name: Run C++ unit tests + run: scripts/run-native-tests.sh diff --git a/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore index 4498ff073d..7a7c31fe50 100644 --- a/packages/react-native-executorch/.gitignore +++ b/packages/react-native-executorch/.gitignore @@ -4,3 +4,13 @@ rne-build-config.json # Generated by scripts/package-release-artifacts.sh dist-artifacts/ + +# Hermes + ExecuTorch host builds for the C++ tests, produced by +# scripts/build-native-test-deps.sh +.native-test-deps + +# C++ test build output (scripts/run-native-tests.sh) +cpp/tests/build/ + +# Model and tokenizer fixtures downloaded by scripts/fetch-test-fixtures.sh +cpp/tests/fixtures/ diff --git a/packages/react-native-executorch/compile_flags.txt b/packages/react-native-executorch/compile_flags.txt index 0645dc75ca..02a120dc77 100644 --- a/packages/react-native-executorch/compile_flags.txt +++ b/packages/react-native-executorch/compile_flags.txt @@ -9,3 +9,9 @@ -isystemthird-party/include/executorch/extension/llm/tokenizers/third-party/json/include -isystemthird-party/include/executorch/extension/llm/tokenizers/third-party/re2 -isystemthird-party/include/executorch/extension/llm/tokenizers/third-party/abseil-cpp +-Icpp/tests +-isystem../../third-party/googletest/googletest/include +-isystem../../third-party/googletest/googlemock/include +-isystem.native-test-deps/hermes/src/API +-isystem.native-test-deps/hermes/src/API/jsi +-isystem.native-test-deps/hermes/src/public diff --git a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp index 3d154b18ab..14ff4201ec 100644 --- a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp +++ b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp @@ -139,15 +139,20 @@ void install_extractDbnetTextQuads(jsi::Runtime &rt, jsi::Object &module) { const int32_t h = src->shape_[2]; const int32_t w = src->shape_[3]; + // Read the options outside the try below: a missing or mistyped one is an + // InvalidArgument from the caller, and rewrapping it as an OpenCV + // failure would both mislabel it and lose the code. const char *ctx = "extractDbnetTextQuads"; + const auto binThreshold = conversions::getRequiredProperty(rt, ctx, opts, "binThreshold"); + const auto boxThreshold = conversions::getRequiredProperty(rt, ctx, opts, "boxThreshold"); + const auto unclipRatio = conversions::getRequiredProperty(rt, ctx, opts, "unclipRatio"); + const auto minBoxSide = conversions::getRequiredProperty(rt, ctx, opts, "minBoxSide"); + const auto maxCandidates = conversions::getRequiredProperty(rt, ctx, opts, "maxCandidates"); + std::vector quads; try { ::cv::Mat prob(h, w, CV_32F, dataPtr); - quads = extractDbnet(prob, conversions::getRequiredProperty(rt, ctx, opts, "binThreshold"), - conversions::getRequiredProperty(rt, ctx, opts, "boxThreshold"), - conversions::getRequiredProperty(rt, ctx, opts, "unclipRatio"), - conversions::getRequiredProperty(rt, ctx, opts, "minBoxSide"), - conversions::getRequiredProperty(rt, ctx, opts, "maxCandidates")); + quads = extractDbnet(prob, binThreshold, boxThreshold, unclipRatio, minBoxSide, maxCandidates); } catch (const std::exception &e) { throw error::ExecutionFailed(std::format("extractDbnetTextQuads: OpenCV error: {}", e.what())); } diff --git a/packages/react-native-executorch/cpp/tests/CMakeLists.txt b/packages/react-native-executorch/cpp/tests/CMakeLists.txt new file mode 100644 index 0000000000..8de778f7d2 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -0,0 +1,385 @@ +cmake_minimum_required(VERSION 3.24) +project(RnExecutorchTests CXX) + +# Host-side unit tests for the package's C++ sources. +# +# The native code is entirely JSI-facing: every entry point takes a +# jsi::Runtime&. Rather than stub that boundary, the tests link a real Hermes +# runtime (the engine RN ships) and a host build of ExecuTorch, install the +# production `rnexecutorch` module into it, and drive it exactly the way the +# TypeScript layer does. See README.md for the provisioning story. + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(PACKAGE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../..") +set(CPP_DIR "${PACKAGE_DIR}/cpp") +set(REPO_ROOT "${PACKAGE_DIR}/../..") +set(INCLUDE_DIR "${PACKAGE_DIR}/third-party/include") + +# Prebuilt dependencies produced by scripts/build-native-test-deps.sh. Kept out +# of the CMake build so a dependency rebuild (~2 min) is not part of every +# configure, and so CI can cache the directory wholesale. +set(RNE_TEST_DEPS_DIR "${PACKAGE_DIR}/.native-test-deps" + CACHE PATH "Directory holding the prebuilt Hermes and ExecuTorch host dependencies") + +option(RNE_TESTS_ENABLE_OPENCV "Build the OpenCV-dependent extension tests" ON) +option(RNE_TESTS_ENABLE_PHONEMIS "Build the phonemis-dependent speech tests" ON) + +if(NOT EXISTS "${INCLUDE_DIR}") + message(FATAL_ERROR + "third-party/include is missing. Provision it with:\n" + " RNET_HEADERS_ONLY=1 node scripts/download-libs.js") +endif() + +set(HERMES_SRC_DIR "${RNE_TEST_DEPS_DIR}/hermes/src") +set(HERMES_BUILD_DIR "${RNE_TEST_DEPS_DIR}/hermes/build") +# ExecuTorch's own CMake requires its source directory to be named exactly +# `executorch`, so it sits flat rather than under a src/build pair. +set(ET_BUILD_DIR "${RNE_TEST_DEPS_DIR}/executorch-build") + +if(NOT EXISTS "${HERMES_BUILD_DIR}" OR NOT EXISTS "${ET_BUILD_DIR}") + message(FATAL_ERROR + "Test dependencies are missing from ${RNE_TEST_DEPS_DIR}. Build them with:\n" + " scripts/build-native-test-deps.sh") +endif() + +# --- GoogleTest ------------------------------------------------------------- +# Vendored as a submodule at the repo root, so the tests add no network +# dependency of their own beyond the two host toolchains above. +set(GTEST_DIR "${REPO_ROOT}/third-party/googletest") +if(NOT EXISTS "${GTEST_DIR}/CMakeLists.txt") + message(FATAL_ERROR + "third-party/googletest is empty. Initialise it with:\n" + " git submodule update --init third-party/googletest") +endif() +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +add_subdirectory("${GTEST_DIR}" "${CMAKE_BINARY_DIR}/googletest" EXCLUDE_FROM_ALL) + +# --- Hermes ----------------------------------------------------------------- +# Hermes vendors its own copy of JSI, so linking it supplies both the engine and +# the jsi headers/symbols the package compiles against. +add_library(hermes_host SHARED IMPORTED) +find_library(HERMES_VM_LIB + NAMES hermesvm + PATHS "${HERMES_BUILD_DIR}/lib" "${HERMES_BUILD_DIR}/API/hermes" + NO_DEFAULT_PATH REQUIRED) +set_target_properties(hermes_host PROPERTIES IMPORTED_LOCATION "${HERMES_VM_LIB}") +target_include_directories(hermes_host INTERFACE + "${HERMES_SRC_DIR}/API" + "${HERMES_SRC_DIR}/API/jsi" + "${HERMES_SRC_DIR}/public") + +find_library(HERMES_JSI_LIB + NAMES jsi + PATHS "${HERMES_BUILD_DIR}/jsi" "${HERMES_BUILD_DIR}/lib" + NO_DEFAULT_PATH REQUIRED) +add_library(hermes_jsi_host SHARED IMPORTED) +set_target_properties(hermes_jsi_host PROPERTIES IMPORTED_LOCATION "${HERMES_JSI_LIB}") + +# --- ExecuTorch ------------------------------------------------------------- +# Only the handful of libraries the package actually pulls in: tensor creation, +# module loading, the LLM tokenizers used by the nlp extension, and the LLM +# runner the llm extension wraps. +function(rne_import_et_lib target relative_path) + find_library(${target}_LIB + NAMES ${ARGN} + PATHS "${ET_BUILD_DIR}/${relative_path}" + NO_DEFAULT_PATH REQUIRED) + add_library(${target} STATIC IMPORTED) + set_target_properties(${target} PROPERTIES IMPORTED_LOCATION "${${target}_LIB}") +endfunction() + +rne_import_et_lib(et_core "" executorch_core) +rne_import_et_lib(et_full "" executorch) +rne_import_et_lib(et_tensor "extension/tensor" extension_tensor) +rne_import_et_lib(et_module "extension/module" extension_module_static extension_module) +rne_import_et_lib(et_data_loader "extension/data_loader" extension_data_loader) +rne_import_et_lib(et_flat_tensor "extension/flat_tensor" extension_flat_tensor) +rne_import_et_lib(et_named_data_map "extension/named_data_map" extension_named_data_map) +rne_import_et_lib(et_tokenizers "extension/llm/tokenizers" tokenizers) + +# The llm extension wraps ExecuTorch's LLM runner, which pulls in the sampler, +# the memory allocator and the portable-kernel utilities it shares with them. +rne_import_et_lib(et_llm_runner "extension/llm/runner" extension_llm_runner) +rne_import_et_lib(et_llm_sampler "extension/llm/sampler" extension_llm_sampler) +rne_import_et_lib(et_memory_allocator "extension/memory_allocator" extension_memory_allocator) +rne_import_et_lib(et_kernels_util "kernels/portable/cpu/util" kernels_util_all_deps) + +# Listed consumer-first: GNU ld resolves static archives left to right, so the +# runner has to precede the sampler/allocator/kernel archives it pulls from, and +# the ExecuTorch core has to follow all of them. +set(ET_LIBS + et_llm_runner et_llm_sampler et_memory_allocator et_kernels_util + et_module et_tensor et_flat_tensor et_named_data_map et_data_loader + et_full et_core et_tokenizers) + +# The tokenizers library links re2, pcre2, abseil and sentencepiece, all built +# as part of the ExecuTorch tree. Glob them rather than naming ~20 abseil +# targets individually. The two patterns must not overlap, or every archive they +# both match lands on the link line twice. +file(GLOB ET_TOKENIZER_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/*/lib*.a") +file(GLOB ET_ABSEIL_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/abseil-cpp/absl/*/*.a") + +# sentencepiece is built out of tree (ExternalProject), so it is not under +# third-party/ with the rest. tokenizers references it from SPTokenizer, which +# the LLM runner's load_tokenizer pulls in even for a HuggingFace tokenizer. +file(GLOB ET_SENTENCEPIECE_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/sp-build/src/lib*.a") + +# Attach them to et_tokenizers rather than to each test executable, so CMake +# emits them *after* libtokenizers.a. GNU ld resolves archives left to right and +# only pulls members that satisfy an already-undefined symbol, so listing re2 +# ahead of its consumer silently produces undefined references at link time; +# Apple's linker searches regardless of order, which hides this on macOS. +# +# --start-group additionally lets the linker rescan the set, covering the +# reference cycles between the abseil archives (the glob above cannot order them +# topologically). It is a GNU ld feature; Apple's linker neither needs nor +# accepts it. +set(TOKENIZER_DEP_LIBS ${ET_TOKENIZER_LIBS} ${ET_ABSEIL_LIBS} ${ET_SENTENCEPIECE_LIBS}) +if(NOT APPLE AND TOKENIZER_DEP_LIBS) + set(TOKENIZER_DEP_LIBS "-Wl,--start-group" ${TOKENIZER_DEP_LIBS} "-Wl,--end-group") +endif() +set_property(TARGET et_tokenizers APPEND PROPERTY + INTERFACE_LINK_LIBRARIES ${TOKENIZER_DEP_LIBS}) + +# --- Sources under test ----------------------------------------------------- +# Mirrors android/CMakeLists.txt: core/math/nlp/speech/llm always, cv behind the +# OpenCV flag and phonemizer.cpp behind the phonemis flag. Built as one static +# library so every test binary shares it. +file(GLOB CORE_SOURCES "${CPP_DIR}/core/*.cpp") +file(GLOB MATH_SOURCES "${CPP_DIR}/extensions/math/*.cpp") +file(GLOB NLP_SOURCES "${CPP_DIR}/extensions/nlp/*.cpp") +file(GLOB SPEECH_SOURCES "${CPP_DIR}/extensions/speech/*.cpp") +file(GLOB LLM_SOURCES "${CPP_DIR}/extensions/llm/*.cpp") +file(GLOB OPENCV_SOURCES "${CPP_DIR}/extensions/cv/*.cpp") + +# phonemizer.cpp is the one speech source gated on phonemis, exactly as in +# android/CMakeLists.txt. +set(PHONEMIS_SOURCES "${CPP_DIR}/extensions/speech/phonemizer.cpp") +list(FILTER SPEECH_SOURCES EXCLUDE REGEX "/phonemizer\\.cpp$") + +set(RNE_SOURCES + "${CPP_DIR}/RnExecutorch.cpp" + ${CORE_SOURCES} ${MATH_SOURCES} ${NLP_SOURCES} ${SPEECH_SOURCES} ${LLM_SOURCES}) + +# The cv extension uses only core and imgproc. Prefer OpenCV's CMake package +# when it is installed (Homebrew, or a full distro OpenCV), but fall back to +# locating the two libraries directly: on Debian/Ubuntu OpenCVConfig.cmake ships +# only in the `libopencv-dev` meta-package, which hard-depends on the viz and +# contrib modules and so drags in VTK, OpenMPI and ~220 packages. The fallback +# lets CI install just libopencv-{core,imgproc}-dev instead. +# Sets RNE_OPENCV_INCLUDE_DIRS in the caller's scope; the include directories are +# deliberately kept off the rne_opencv target so the caller can place them ahead +# of third-party/include (see the note where they are applied). +function(rne_find_opencv) + add_library(rne_opencv INTERFACE) + + find_package(OpenCV QUIET COMPONENTS core imgproc) + if(OpenCV_FOUND) + message(STATUS "OpenCV: using CMake package ${OpenCV_VERSION}") + # OpenCV's imported targets mark their include directory SYSTEM, which would + # put it on -isystem — searched only after the vendored opencv2 headers. + # Clearing that keeps it on -I, where it takes precedence. + foreach(lib IN LISTS OpenCV_LIBS) + if(TARGET ${lib}) + set_target_properties(${lib} PROPERTIES IMPORTED_NO_SYSTEM ON) + endif() + endforeach() + set(RNE_OPENCV_INCLUDE_DIRS ${OpenCV_INCLUDE_DIRS} PARENT_SCOPE) + target_link_libraries(rne_opencv INTERFACE ${OpenCV_LIBS}) + return() + endif() + + find_path(OPENCV_INCLUDE_DIR + NAMES opencv2/core.hpp + PATH_SUFFIXES opencv4) + find_library(OPENCV_CORE_LIB NAMES opencv_core) + find_library(OPENCV_IMGPROC_LIB NAMES opencv_imgproc) + + if(NOT OPENCV_INCLUDE_DIR OR NOT OPENCV_CORE_LIB OR NOT OPENCV_IMGPROC_LIB) + message(FATAL_ERROR + "OpenCV (core + imgproc) not found. Install it with:\n" + " brew install opencv\n" + " apt-get install libopencv-core-dev libopencv-imgproc-dev\n" + "or configure with -DRNE_TESTS_ENABLE_OPENCV=OFF to skip the cv suite.") + endif() + + message(STATUS "OpenCV: using ${OPENCV_CORE_LIB}") + set(RNE_OPENCV_INCLUDE_DIRS "${OPENCV_INCLUDE_DIR}" PARENT_SCOPE) + target_link_libraries(rne_opencv INTERFACE "${OPENCV_CORE_LIB}" "${OPENCV_IMGPROC_LIB}") +endfunction() + +if(RNE_TESTS_ENABLE_OPENCV) + rne_find_opencv() + list(APPEND RNE_SOURCES ${OPENCV_SOURCES}) +endif() + +# --- phonemis --------------------------------------------------------------- +# Built from the in-tree submodule, as on Android/iOS. Only `data/` is stored in +# Git LFS and nothing here reads it, so a GIT_LFS_SKIP_SMUDGE=1 checkout is +# enough — see the note in the CI job. +set(PHONEMIS_DIR "${PACKAGE_DIR}/third-party/common/phonemis") +if(RNE_TESTS_ENABLE_PHONEMIS AND NOT EXISTS "${PHONEMIS_DIR}/CMakeLists.txt") + message(WARNING + "phonemis submodule is empty, skipping the phonemizer suite. Initialise it with:\n" + " GIT_LFS_SKIP_SMUDGE=1 git submodule update --init --depth 1 \\\n" + " packages/react-native-executorch/third-party/common/phonemis") + set(RNE_TESTS_ENABLE_PHONEMIS OFF) +endif() + +if(RNE_TESTS_ENABLE_PHONEMIS) + add_subdirectory("${PHONEMIS_DIR}" "${CMAKE_BINARY_DIR}/phonemis" EXCLUDE_FROM_ALL) + list(APPEND RNE_SOURCES ${PHONEMIS_SOURCES}) + + # Two phonemis headers use std::optional and std::u32string without including + # / . libc++ pulls both in transitively, so the Android + # (NDK) and iOS builds never notice; libstdc++ does not, and the member + # declarations then fail to parse, leaving a pile of "no declaration matches" + # errors on their definitions. Force-include the two headers rather than patch + # a pinned submodule; drop this once upstream adds the includes. + # SHELL: keeps the two -include flags paired; without it CMake de-duplicates + # the repeated -include and the second header becomes an input file. + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(phonemis PRIVATE "SHELL:-include optional" "SHELL:-include string") + endif() +endif() + +add_library(rne_under_test STATIC ${RNE_SOURCES}) + +if(RNE_TESTS_ENABLE_PHONEMIS) + # ET_ON would switch phonemis to its ExecuTorch-backed NeuralPhonemizer. The + # host suite drives the rule-based path only (the neural weights live in the + # LFS data the checkout skips), so it is deliberately left off. + target_compile_definitions(rne_under_test PUBLIC RNE_ENABLE_PHONEMIS) + target_include_directories(rne_under_test PUBLIC "${PHONEMIS_DIR}/src") + target_link_libraries(rne_under_test PUBLIC phonemis) +endif() + +target_include_directories(rne_under_test PUBLIC "${CPP_DIR}") + +# Vendored third-party headers are SYSTEM, matching compile_flags.txt: it keeps +# their warnings (e.g. ExecuTorch's deprecated members) out of our build output, +# and it puts them on -isystem, which is searched only after every -I. That is +# what lets the installed OpenCV's -I below take precedence over the opencv2 +# headers that also live under ${INCLUDE_DIR}. +target_include_directories(rne_under_test SYSTEM PUBLIC + "${INCLUDE_DIR}" + "${INCLUDE_DIR}/executorch/extension/llm/tokenizers/include" + "${INCLUDE_DIR}/executorch/extension/llm/tokenizers/third-party/json/include" + "${INCLUDE_DIR}/executorch/extension/llm/tokenizers/third-party/re2" + "${INCLUDE_DIR}/executorch/extension/llm/tokenizers/third-party/abseil-cpp") + +target_link_libraries(rne_under_test PUBLIC hermes_host hermes_jsi_host ${ET_LIBS}) + +# Hermes' platform layer (time zones, unicode) is backed by CoreFoundation on +# Apple platforms. +if(APPLE) + target_link_libraries(rne_under_test PUBLIC "-framework CoreFoundation") +endif() + +if(RNE_TESTS_ENABLE_OPENCV) + target_compile_definitions(rne_under_test PUBLIC RNE_ENABLE_OPENCV) + target_link_libraries(rne_under_test PUBLIC rne_opencv) + + # third-party/include ships its own opencv2/ headers (currently 4.13) for the + # Android/iOS builds, which link matching vendored libraries. On the host we + # link whatever OpenCV is installed, so those headers must not win the include + # search, or we compile against one version and link another: Ubuntu's 4.6 has + # no cvtColor(..., AlgorithmHint) overload, which 4.10+ headers resolve to, and + # the mismatch surfaces only as an undefined symbol at link time. + # + # Added as a plain -I (not via the linked target, which would make it + # -isystem): every -I is searched before every -isystem, so this reliably wins + # over the vendored opencv2 headers regardless of listed order. + if(NOT RNE_OPENCV_INCLUDE_DIRS) + message(FATAL_ERROR "OpenCV include directory not resolved; cannot order it " + "ahead of the vendored opencv2 headers.") + endif() + target_include_directories(rne_under_test PUBLIC ${RNE_OPENCV_INCLUDE_DIRS}) +endif() + +# --- Test support ----------------------------------------------------------- +add_library(rne_test_support STATIC support/JsiTestEnv.cpp) +target_link_libraries(rne_test_support PUBLIC rne_under_test gtest gmock) +target_include_directories(rne_test_support PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") + +# core/error.h mirrors the code list in src/core/error.ts by hand. ErrorTest +# reads the TypeScript source to prove the two have not drifted, so it needs the +# path; it is in the tree, no provisioning involved. +target_compile_definitions(rne_test_support PUBLIC + RNE_ERROR_TS_SOURCE="${PACKAGE_DIR}/src/core/error.ts") + +# --- Model fixture ---------------------------------------------------------- +# Suites that read ExecuTorch MethodMeta need a real .pte. It is downloaded +# rather than committed (scripts/fetch-test-fixtures.sh), so the suites that +# need it are dropped with a warning when it is absent instead of failing the +# whole configure. +set(MODEL_FIXTURE "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/selfie_segmentation_xnnpack_fp32.pte") +if(EXISTS "${MODEL_FIXTURE}") + set(RNE_HAVE_MODEL_FIXTURE ON) + target_compile_definitions(rne_test_support PUBLIC + RNE_MODEL_FIXTURE="${MODEL_FIXTURE}") +else() + set(RNE_HAVE_MODEL_FIXTURE OFF) + message(WARNING + "Model fixture missing, skipping the ModelTest suite. Fetch it with:\n" + " scripts/fetch-test-fixtures.sh") +endif() + +# The nlp extension loads a HuggingFace tokenizer.json, so its suite needs a +# real one. Same story as the .pte: downloaded, not committed. +set(TOKENIZER_FIXTURE "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/tokenizer.json") +if(EXISTS "${TOKENIZER_FIXTURE}") + set(RNE_HAVE_TOKENIZER_FIXTURE ON) + target_compile_definitions(rne_test_support PUBLIC + RNE_TOKENIZER_FIXTURE="${TOKENIZER_FIXTURE}") +else() + set(RNE_HAVE_TOKENIZER_FIXTURE OFF) + message(WARNING + "Tokenizer fixture missing, skipping the TokenizerTest suite. Fetch it with:\n" + " scripts/fetch-test-fixtures.sh") +endif() + +# --- Test binaries ---------------------------------------------------------- +enable_testing() +include(GoogleTest) + +# One binary per suite keeps a crash in a single area from taking the whole run +# down, and lets `ctest -R` target a suite directly. +set(TEST_SUITES + core/DTypeTest.cpp + core/ErrorTest.cpp + core/ConversionsTest.cpp + core/TensorTest.cpp + core/SchemaTest.cpp + core/UtilsTest.cpp + extensions/MathOpsTest.cpp + extensions/SpeechOpsTest.cpp + extensions/LlmRunnerTest.cpp) + +if(RNE_TESTS_ENABLE_OPENCV) + list(APPEND TEST_SUITES extensions/CvOpsTest.cpp extensions/OcrOpsTest.cpp) +endif() + +if(RNE_TESTS_ENABLE_PHONEMIS) + list(APPEND TEST_SUITES extensions/PhonemizerTest.cpp) +endif() + +if(RNE_HAVE_MODEL_FIXTURE) + list(APPEND TEST_SUITES core/ModelTest.cpp) +endif() + +if(RNE_HAVE_TOKENIZER_FIXTURE) + list(APPEND TEST_SUITES extensions/TokenizerTest.cpp) +endif() + +foreach(suite_path IN LISTS TEST_SUITES) + get_filename_component(suite_name "${suite_path}" NAME_WE) + add_executable(${suite_name} "${suite_path}") + # re2/abseil come in transitively via et_tokenizers, which places them after + # their consumer — see the note by TOKENIZER_DEP_LIBS. + target_link_libraries(${suite_name} PRIVATE rne_test_support gtest_main) + gtest_discover_tests(${suite_name} DISCOVERY_TIMEOUT 60) +endforeach() diff --git a/packages/react-native-executorch/cpp/tests/README.md b/packages/react-native-executorch/cpp/tests/README.md new file mode 100644 index 0000000000..871fa99f32 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -0,0 +1,155 @@ +# C++ unit tests + +Host-side GoogleTest suites for the sources under `cpp/`. They run on a +developer machine or a CI runner — no simulator, emulator or device — and the +whole suite finishes in a couple of seconds. + +## Why a real JS engine + +Every entry point in `cpp/` is JSI-facing: `install_sigmoid(jsi::Runtime&, +jsi::Object&)` installs a host function whose body takes `jsi::Value*` +arguments, pulls tensors out of them via `tensor::fromJs`, and reports misuse by +throwing a coded `RnExecuTorchException` that `error::guarded` turns into a +JavaScript `Error` carrying `name`, `code` and sometimes `etRuntimeErrorCode`. +There is no pure-C++ layer underneath to test in isolation. + +Stubbing that boundary would mean reimplementing a JS runtime badly, and every +test would be asserting against the stub rather than the code. So the tests link +**Hermes** — the engine React Native actually ships — install the production +module into it under its real global name (`__rnexecutorch_jsi__`), and drive it +from JavaScript exactly the way `src/` does: + +```cpp +auto result = evalNumberArray(R"( + const t = __rnexecutorch_jsi__.createTensor([2, 2], 'float32'); + t.setData(new Float32Array([1.5, -2.5, 3.0, 4.25])); + ... +)"); +``` + +That covers the argument parsing, the HostObject plumbing, TypedArray/ArrayBuffer +handling and the exact error messages and codes — all the things a stub would +have hidden. Negative tests go through `isCodedError(evalThrowing(...), CODE, +substring)`, so a throw site that loses its code by raising a bare +`jsi::JSError`, or by escaping the guard, fails rather than passing on the +message alone. + +`ExecuTorch` is linked too, as a minimal host build: `cpp/core/tensor.cpp` calls +`executorch::extension::from_blob`, so tensors are backed by real ET storage +rather than a lookalike. + +## Layout + +| Path | Contents | +| --- | --- | +| `support/JsiTestEnv.*` | Fixture owning a Hermes runtime with the module installed, plus `eval*` helpers and the `isCodedError` / `throwsCoded` assertions | +| `core/` | `dtype`, `error`, `conversions`, `tensor`, `schema`, `model`, `utils` | +| `extensions/` | `math`, `speech`, `nlp` (tokenizer), `llm`, and — behind OpenCV — `cv` and `ocr` ops; `phonemizer` behind phonemis | +| `fixtures/` | Downloaded `.pte` program and `tokenizer.json` (gitignored) | + +One binary per suite, so a crash in one area cannot take the run down with it +and `ctest -R MathOpsTest` targets a single suite. + +## Running them + +Two one-time provisioning steps, then the runner: + +```bash +# 1. ExecuTorch/OpenCV/tokenizer headers (shared with clang-tidy and clangd). +# The release is resolved from `nativeLibsVersion` in package.json. +RNET_HEADERS_ONLY=1 node scripts/download-libs.js + +# 2. The submodules the tests compile. phonemis' `data/` is Git LFS and nothing +# here reads it, so the checkout skips it. +git submodule update --init --depth 1 ../../third-party/googletest +GIT_LFS_SKIP_SMUDGE=1 git submodule update --init --depth 1 third-party/common/phonemis + +# 3. Hermes + a minimal ExecuTorch host build (several minutes, cached afterwards) +scripts/build-native-test-deps.sh + +# 4. Build and run (also fetches the fixtures, see below) +scripts/run-native-tests.sh +scripts/run-native-tests.sh -R MathOpsTest # extra args go to ctest +``` + +Requires `cmake`, `ninja` and — for the `cv` suite — OpenCV's core and imgproc +modules: + +```bash +brew install opencv # macOS +apt-get install libopencv-core-dev libopencv-imgproc-dev # Debian/Ubuntu +``` + +Without OpenCV, run with `RNE_TESTS_ENABLE_OPENCV=OFF` to skip the `cv` and +`ocr` suites; without the phonemis submodule the phonemizer suite is dropped +with a CMake warning (or turn it off explicitly with +`-DRNE_TESTS_ENABLE_PHONEMIS=OFF`). + +Note the deliberately narrow apt packages. `libopencv-dev` is a meta-package +that hard-depends on the viz and contrib modules, so it drags in VTK, OpenMPI +and ~220 packages — it took over 50 minutes on a throttled CI mirror. Since +`OpenCVConfig.cmake` ships only in that meta-package, the build prefers OpenCV's +CMake package when present and otherwise locates the two libraries directly. + +## Keeping the pins honest + +`scripts/build-native-test-deps.sh` pins both dependencies: + +- `HERMES_VERSION` should match `node_modules/react-native/sdks/.hermesversion`, + so the tests run on the engine the apps run on. +- `EXECUTORCH_VERSION` should match the ExecuTorch release that + `third-party/include` is vendored from — the release tagged + `v${nativeLibsVersion}-libs`, currently ExecuTorch 1.3.1. The tests compile + against those vendored headers and link these host-built libraries, so a drift + between the two shows up as a link error — noisy, but at least not silent. +- `TOKENIZERS_COMMIT` pins `software-mansion-labs/pytorch-tokenizers`, which the + script swaps in for ExecuTorch's own tokenizers submodule. The shipped + libraries are built from `software-mansion-labs/executorch@rne-split-build`, + which does the same, and `third-party/include` carries that fork's headers + (they add the WordPiece/Unigram models and the NFC normalizer upstream has + not taken). This is the one drift that does *not* show up as a link error: + upstream's `libtokenizers.a` links fine and then `HFTokenizer::load` reads a + differently laid out object and segfaults inside `setup_pretokenizer`. + +## The fixtures + +`scripts/fetch-test-fixtures.sh` downloads two, both pinned to an exact Hugging +Face revision and checksum-verified. `run-native-tests.sh` fetches them +automatically; they land in `fixtures/` and are gitignored rather than committed. + +- A **.pte program** — selfie-segmentation, ~486 KB, the smallest the org + publishes. Anything reading ExecuTorch `MethodMeta` needs a real program. +- A **tokenizer.json** — Whisper tiny.en's, ~2.4 MB. A plain BPE vocabulary, so + the nlp extension's encode/decode path runs for real. + +The useful part is that this needs **no XNNPACK delegate**, even though the +fixture is XNNPACK-delegated. `ModelHostObject`'s constructor only calls +`Module::load()` and `Module::method_meta()`, and in ExecuTorch both parse the +program without initialising delegates — only `load_method()` resolves backends +(it fails with error 32, `NotFound`, in this build). So the entire load path is +testable on the host: + +- `schema::methodSpecFromMetadata`, `validateSpec`, `getUsedBackends` +- `loadModel`, and the `path` / `schema` / `backends` JS surface + +If a fixture is missing (offline, `RNE_SKIP_FIXTURES=1`), the suites that need +it are dropped from the build with a CMake warning rather than failing it. + +## What is deliberately not covered here + +**Model execution.** `model.cpp`'s `execute` path — running inference and +copying outputs back — needs the delegate the program was exported against, so a +host XNNPACK build. Argument validation ahead of it is covered; the rest belongs +in a device/emulator integration job, which remains the natural next step. + +**LLM generation.** `cpp/extensions/llm` wraps ExecuTorch's LLM runner, so +creating one loads a real model and running it needs that model's delegate — the +same limit as `execute`. `createLLMRunner`'s argument contract and how each load +failure is classified are covered; generation is not. + +**Phoneme output.** phonemis phonemizes through a lexicon with a neural +fallback, and both are files under the submodule's `data/`, which is Git LFS and +not fetched here. The phonemizer suite pins the JSI contract — construction, +argument checking, the lifecycle, error classification — while `phonemize()` +returns an empty string for want of a vocabulary. Actual phonemes belong with +the on-device tests that run against the shipped assets. diff --git a/packages/react-native-executorch/cpp/tests/core/ConversionsTest.cpp b/packages/react-native-executorch/cpp/tests/core/ConversionsTest.cpp new file mode 100644 index 0000000000..7e215f7274 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/ConversionsTest.cpp @@ -0,0 +1,166 @@ +#include +#include +#include +#include + +#include "support/JsiTestEnv.h" + +#include "core/conversions.h" +#include "core/error.h" + +namespace rnexecutorch::tests { +namespace { + +namespace conversions = rnexecutorch::core::conversions; +namespace error = rnexecutorch::core::error; +namespace jsi = facebook::jsi; +using ::testing::HasSubstr; + +// Everything conversions:: rejects is an InvalidArgument; the guard at the host +// function boundary is what later turns it into a coded JavaScript Error. +constexpr auto kInvalidArgument = error::RnExecuTorchErrorCode::InvalidArgument; + +using ConversionsTest = JsiTestEnv; + +// conversions:: is the argument-parsing layer every JSI entry point funnels +// through, so its range and type checks are what stop a bad JS call from +// reaching a reinterpret_cast. Tested against real jsi::Values. + +jsi::Value number(jsi::Runtime &rt, double v) { + return jsi::Value(v); +} + +TEST_F(ConversionsTest, AcceptsWellTypedScalars) { + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), 1.5)), 1.5); + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), -7)), -7); + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), 42)), 42u); + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), 255)), 255); + EXPECT_EQ(conversions::asType(rt(), "ctx", jsi::Value(true)), true); + EXPECT_EQ(conversions::asType(rt(), "ctx", jsi::Value(jsi::String::createFromUtf8(rt(), "hi"))), "hi"); +} + +TEST_F(ConversionsTest, RejectsWrongJsType) { + // The message must name the parameter so a JS-side error points at the + // offending argument rather than "something went wrong". + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "sigmoid: src", jsi::Value(true)); }, + kInvalidArgument, "sigmoid: src must be a number")); + + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 1)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 1)); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, RejectsNonIntegralValuesForIntegerTypes) { + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 1.5)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 1.5)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 0.5)); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, RejectsNaNAndInfinity) { + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), nan)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), inf)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), -inf)); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, RejectsOutOfRangeIntegers) { + // JS numbers are doubles, so a caller can easily hand over a value that does + // not fit the native type — that must be rejected, not truncated. + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 2147483648.0)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), -2147483649.0)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), 256)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), -1)); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([&] { conversions::asType(rt(), "ctx", number(rt(), -1)); }, kInvalidArgument)); + + // Boundaries themselves stay valid. + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), 2147483647.0)), 2147483647); + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), -2147483648.0)), + std::numeric_limits::min()); + EXPECT_EQ(conversions::asType(rt(), "ctx", number(rt(), 0)), 0); +} + +TEST_F(ConversionsTest, AsVectorConvertsElementwiseAndNamesTheBadIndex) { + auto array = eval("return [1, 2, 3];"); + EXPECT_EQ(conversions::asVector(rt(), "shape", array), (std::vector{1, 2, 3})); + + auto mixed = eval("return [1, 'two', 3];"); + EXPECT_TRUE(throwsCoded([&] { conversions::asVector(rt(), "shape", mixed); }, + kInvalidArgument, "shape[1]")); +} + +TEST_F(ConversionsTest, AsVectorRejectsNonArrays) { + EXPECT_TRUE(throwsCoded([&] { conversions::asVector(rt(), "shape", eval("return {};")); }, kInvalidArgument)); + // A TypedArray is not a JS Array — asVector is the boxed path and must say so. + EXPECT_TRUE(throwsCoded([&] { conversions::asVector(rt(), "shape", eval("return new Int32Array(3);")); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, RequiredPropertyIsEnforced) { + auto object = eval("return { a: 1 };").getObject(rt()); + EXPECT_EQ(conversions::getRequiredProperty(rt(), "opts", object, "a"), 1); + + EXPECT_TRUE(throwsCoded([&] { conversions::getRequiredProperty(rt(), "opts", object, "b"); }, + kInvalidArgument, "option 'b' is required")); +} + +TEST_F(ConversionsTest, OptionalPropertyTreatsNullAndUndefinedAsAbsent) { + auto object = eval("return { a: 1, b: null, c: undefined };").getObject(rt()); + + EXPECT_EQ(conversions::getOptionalProperty(rt(), "opts", object, "a").value_or(-1), 1); + EXPECT_FALSE(conversions::getOptionalProperty(rt(), "opts", object, "b").has_value()); + EXPECT_FALSE(conversions::getOptionalProperty(rt(), "opts", object, "c").has_value()); + EXPECT_FALSE(conversions::getOptionalProperty(rt(), "opts", object, "missing").has_value()); +} + +TEST_F(ConversionsTest, OptionalPropertyStillTypeChecksWhenPresent) { + auto object = eval("return { a: 'not a number' };").getObject(rt()); + EXPECT_TRUE(throwsCoded([&] { conversions::getOptionalProperty(rt(), "opts", object, "a"); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, TypedArrayRoundTrips) { + const std::vector source{1, -2, 3, -4}; + auto typedArray = conversions::toJsiTypedArray(rt(), source); + + // Comes back as the matching JS view, not a plain Array. + rt().global().setProperty(rt(), "roundTripped", typedArray); + EXPECT_EQ(evalString("return roundTripped.constructor.name;"), "Int32Array"); + EXPECT_EQ(evalNumber("return roundTripped.length;"), 4); + + auto readBack = conversions::fromJsiTypedArray( + rt(), "ctx", jsi::Value(rt(), rt().global().getProperty(rt(), "roundTripped"))); + EXPECT_EQ(readBack, source); +} + +TEST_F(ConversionsTest, TypedArrayReadHonoursViewWindow) { + // fromJsiTypedArray must respect byteOffset/byteLength, so a subarray view + // yields only its own window rather than the whole backing buffer. + auto view = eval("return new Int32Array([1, 2, 3, 4, 5]).subarray(1, 4);"); + EXPECT_EQ(conversions::fromJsiTypedArray(rt(), "ctx", view), + (std::vector{2, 3, 4})); +} + +TEST_F(ConversionsTest, TypedArrayReadRejectsMisalignedLength) { + // 3 bytes cannot be read as int32_t elements. + auto view = eval("return new Uint8Array([1, 2, 3]);"); + EXPECT_TRUE(throwsCoded([&] { conversions::fromJsiTypedArray(rt(), "ctx", view); }, kInvalidArgument)); +} + +TEST_F(ConversionsTest, EmptyTypedArrayRoundTrips) { + auto empty = conversions::toJsiTypedArray(rt(), std::vector{}); + rt().global().setProperty(rt(), "emptyArray", empty); + EXPECT_EQ(evalNumber("return emptyArray.length;"), 0); +} + +TEST_F(ConversionsTest, ToJsiArrayHandlesStringsAndNumbers) { + auto numbers = conversions::toJsiArray(rt(), std::vector{1, 2, 3}); + rt().global().setProperty(rt(), "numbers", numbers); + EXPECT_TRUE(evalBool("return Array.isArray(numbers);")); + EXPECT_EQ(evalNumber("return numbers[2];"), 3); + + auto strings = conversions::toJsiArray(rt(), std::vector{"a", "b"}); + rt().global().setProperty(rt(), "strings", strings); + EXPECT_EQ(evalString("return strings.join('');"), "ab"); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/DTypeTest.cpp b/packages/react-native-executorch/cpp/tests/core/DTypeTest.cpp new file mode 100644 index 0000000000..8b8289f968 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/DTypeTest.cpp @@ -0,0 +1,71 @@ +#include + +#include "core/dtype.h" +#include "core/error.h" +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using rnexecutorch::core::types::DType; +namespace error = rnexecutorch::core::error; +namespace types = rnexecutorch::core::types; + +constexpr auto kInvalidArgument = error::RnExecuTorchErrorCode::InvalidArgument; + +constexpr DType kAllDTypes[] = {DType::uint8, DType::int32, DType::int64, + DType::float32, DType::boolean}; + +TEST(DType, ParsesEverySupportedName) { + EXPECT_EQ(types::dtypeFromString("uint8"), DType::uint8); + EXPECT_EQ(types::dtypeFromString("int32"), DType::int32); + EXPECT_EQ(types::dtypeFromString("int64"), DType::int64); + EXPECT_EQ(types::dtypeFromString("float32"), DType::float32); + // The JS name is "bool"; the enumerator is `boolean` because `bool` is a + // keyword. A mismatch here would only surface as a rejected tensor dtype. + EXPECT_EQ(types::dtypeFromString("bool"), DType::boolean); + EXPECT_EQ(types::dtypeToString(DType::boolean), "bool"); +} + +TEST(DType, RejectsUnknownName) { + EXPECT_TRUE(throwsCoded([] { types::dtypeFromString("float64"); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([] { types::dtypeFromString(""); }, kInvalidArgument)); + // Names are matched exactly — no case folding, no aliases. + EXPECT_TRUE(throwsCoded([] { types::dtypeFromString("Float32"); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([] { types::dtypeFromString("float"); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded([] { types::dtypeFromString("boolean"); }, kInvalidArgument)); +} + +TEST(DType, StringRoundTrips) { + for (auto dtype : kAllDTypes) { + EXPECT_EQ(types::dtypeFromString(types::dtypeToString(dtype)), dtype); + } +} + +TEST(DType, ScalarTypeRoundTrips) { + for (auto dtype : kAllDTypes) { + EXPECT_EQ(types::dtypeFromScalarType(types::dtypeToScalarType(dtype)), dtype); + } +} + +TEST(DType, RejectsUnsupportedScalarType) { + // ExecuTorch models can declare types the JS layer has no representation + // for; those must be rejected rather than silently coerced. + EXPECT_TRUE(throwsCoded( + [] { types::dtypeFromScalarType(executorch::aten::ScalarType::Double); }, kInvalidArgument)); + EXPECT_TRUE(throwsCoded( + [] { types::dtypeFromScalarType(executorch::aten::ScalarType::Half); }, kInvalidArgument)); +} + +TEST(DType, ElementSizeMatchesScalarType) { + EXPECT_EQ(types::elementSize(DType::uint8), 1u); + EXPECT_EQ(types::elementSize(DType::int32), 4u); + EXPECT_EQ(types::elementSize(DType::int64), 8u); + EXPECT_EQ(types::elementSize(DType::float32), 4u); + // ExecuTorch stores Bool one byte per element, so a bool tensor's buffer is + // sized like a uint8 one. + EXPECT_EQ(types::elementSize(DType::boolean), 1u); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/ErrorTest.cpp b/packages/react-native-executorch/cpp/tests/core/ErrorTest.cpp new file mode 100644 index 0000000000..cb505ab1d8 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/ErrorTest.cpp @@ -0,0 +1,164 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/error.h" +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using ::testing::HasSubstr; + +namespace error = rnexecutorch::core::error; +using error::RnExecuTorchErrorCode; + +// Every code in the X-macro list, so a code added to error.h without a string +// mapping (or without its TypeScript counterpart) fails the suite below. +constexpr RnExecuTorchErrorCode kAllCodes[] = { + RnExecuTorchErrorCode::LoadFailed, + RnExecuTorchErrorCode::ExecutionFailed, + RnExecuTorchErrorCode::SchemaMismatch, + RnExecuTorchErrorCode::InvalidArgument, + RnExecuTorchErrorCode::InvalidState, + RnExecuTorchErrorCode::ResourceDisposed, + RnExecuTorchErrorCode::ResourceBusy, + RnExecuTorchErrorCode::DownloadFailed, + RnExecuTorchErrorCode::DownloadAborted, + RnExecuTorchErrorCode::Unknown, +}; + +TEST(Error, MapsEveryCodeToItsWireString) { + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::LoadFailed), "LOAD_FAILED"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::ExecutionFailed), "EXECUTION_FAILED"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::SchemaMismatch), "SCHEMA_MISMATCH"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::InvalidArgument), "INVALID_ARGUMENT"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::InvalidState), "INVALID_STATE"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::ResourceDisposed), "RESOURCE_DISPOSED"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::ResourceBusy), "RESOURCE_BUSY"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::DownloadFailed), "DOWNLOAD_FAILED"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::DownloadAborted), "DOWNLOAD_ABORTED"); + EXPECT_STREQ(error::errorCodeToString(RnExecuTorchErrorCode::Unknown), "UNKNOWN"); +} + +// error.h says src/core/error.ts is the source of truth and that the two lists +// are kept in sync by hand. This is what makes that claim checkable: a code +// added on one side and forgotten on the other fails here rather than reaching +// an app as an unmatchable string. +TEST(Error, CodeListMatchesTypeScript) { + std::ifstream file(RNE_ERROR_TS_SOURCE); + ASSERT_TRUE(file.is_open()) << "cannot open " << RNE_ERROR_TS_SOURCE; + + std::stringstream buffer; + buffer << file.rdbuf(); + const std::string source = buffer.str(); + + const auto listStart = source.find("VALID_ERROR_CODES = ["); + ASSERT_NE(listStart, std::string::npos) << "VALID_ERROR_CODES not found in error.ts"; + const auto listEnd = source.find(']', listStart); + ASSERT_NE(listEnd, std::string::npos); + const std::string list = source.substr(listStart, listEnd - listStart); + + std::vector tsCodes; + const std::regex entry(R"('([A-Z_]+)')"); + for (auto it = std::sregex_iterator(list.begin(), list.end(), entry); + it != std::sregex_iterator(); ++it) { + tsCodes.push_back((*it)[1].str()); + } + + std::vector cppCodes; + cppCodes.reserve(std::size(kAllCodes)); + for (auto code : kAllCodes) { + cppCodes.emplace_back(error::errorCodeToString(code)); + } + + // Order is not part of the contract, only membership. + std::ranges::sort(tsCodes); + std::ranges::sort(cppCodes); + EXPECT_EQ(cppCodes, tsCodes); +} + +TEST(Error, FactoriesCarryTheirCode) { + const auto invalid = error::InvalidArgument("bad input"); + EXPECT_EQ(invalid.code_, RnExecuTorchErrorCode::InvalidArgument); + EXPECT_STREQ(invalid.what(), "bad input"); + EXPECT_FALSE(invalid.etRuntimeErrorCode_.has_value()); + + // The ExecuTorch code is only attached when a failure actually came out of + // the runtime, so it stays apart from our own classification. + const auto load = error::LoadFailed("no file", executorch::runtime::Error::AccessFailed); + EXPECT_EQ(load.code_, RnExecuTorchErrorCode::LoadFailed); + ASSERT_TRUE(load.etRuntimeErrorCode_.has_value()); + EXPECT_EQ(*load.etRuntimeErrorCode_, + static_cast(executorch::runtime::Error::AccessFailed)); +} + +class ErrorJsTest : public JsiTestEnv {}; + +// The JS-visible shape of a failure: an Error named RnExecuTorchError carrying +// `code`. This is what isRnExecuTorchError narrows on. +TEST_F(ErrorJsTest, SurfacesAsCodedJavaScriptError) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.math.sigmoid();"); + + EXPECT_EQ(thrown.name, "RnExecuTorchError"); + EXPECT_EQ(thrown.code, "INVALID_ARGUMENT"); + EXPECT_THAT(thrown.message, HasSubstr("Usage: sigmoid(src, dst)")); + EXPECT_TRUE(evalBool("try { __rnexecutorch_jsi__.math.sigmoid(); } " + "catch (e) { return e instanceof Error; } return false;")); +} + +TEST_F(ErrorJsTest, AttachesExecuTorchCodeWhenTheRuntimeFailed) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.loadModel('/definitely/not/a/model.pte');"); + + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "/definitely/not/a/model.pte")); + // Diagnostic only — upstream's numbering moves independently of ours, so the + // contract is that the field is there, not what it equals. + EXPECT_TRUE(thrown.etRuntimeErrorCode.has_value()); +} + +TEST_F(ErrorJsTest, OmitsExecuTorchCodeForOurOwnFailures) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.loadModel(42);"); + + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "must be a string")); + EXPECT_FALSE(thrown.etRuntimeErrorCode.has_value()); +} + +// A JS error raised inside a callback the native layer invoked is already a +// JavaScript value; the guard rethrows it untouched rather than relabelling it +// as an RnExecuTorchError. `getRequiredProperty` reads options through JSI, so a +// throwing getter runs user code inside the host function. +TEST_F(ErrorJsTest, PassesJavaScriptErrorsThrough) { + auto thrown = evalThrowing(R"( + const waveform = __rnexecutorch_jsi__.createTensor([16], 'float32'); + const hann = __rnexecutorch_jsi__.createTensor([4], 'float32'); + const dst = __rnexecutorch_jsi__.createTensor([2, 4], 'float32'); + const options = { + hopLength: 2, + preemphasis: 0, + get numFrames() { throw new TypeError('from JavaScript'); }, + }; + __rnexecutorch_jsi__.speech.extractFrames(waveform, hann, dst, options); + )"); + + EXPECT_EQ(thrown.message, "from JavaScript"); + EXPECT_EQ(thrown.name, "TypeError"); + EXPECT_EQ(thrown.code, ""); +} + +// dtype parsing throws from deep inside createTensor rather than at the host +// function boundary, so this covers the guard catching an exception raised +// below the call site. +TEST_F(ErrorJsTest, CodesFailuresRaisedBelowTheHostFunction) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.createTensor([2], 'float64');"); + + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "Unsupported dtype: 'float64'")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/ModelTest.cpp b/packages/react-native-executorch/cpp/tests/core/ModelTest.cpp new file mode 100644 index 0000000000..af4f6a3b0b --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/ModelTest.cpp @@ -0,0 +1,208 @@ +#include +#include +#include + +#include "support/JsiTestEnv.h" + +#include "core/schema.h" + +#include + +namespace rnexecutorch::tests { +namespace { + +namespace schema = rnexecutorch::core::schema; +using rnexecutorch::core::types::DType; +using ::testing::HasSubstr; + +// These suites need a real ExecuTorch program, because MethodMeta only exists +// once one is loaded. The fixture is selfie-segmentation (~486 KB, the smallest +// model the org publishes), fetched by scripts/fetch-test-fixtures.sh. +// +// Note what this does NOT need: the XNNPACK delegate. ModelHostObject's +// constructor only calls Module::load() and Module::method_meta(), both of which +// parse the program without initialising delegates. Executing the model would +// need an XNNPACK host build; that stays out of scope here, so these tests cover +// the load path only. +// +// The fixture's shape contract, from its published config.json: +// forward: input [1, 3, 256, 256] float32 -> output [1, 1, 256, 256] float32 + +constexpr const char *kFixture = RNE_MODEL_FIXTURE; + +std::string loadFixtureJs() { + return std::format("const model = __rnexecutorch_jsi__.loadModel('{}');", kFixture); +} + +// --- Metadata reflection, exercised directly --------------------------------- + +class MethodMetaTest : public JsiTestEnv { +protected: + void SetUp() override { + JsiTestEnv::SetUp(); + module_ = std::make_unique(kFixture); + ASSERT_EQ(module_->load(), executorch::runtime::Error::Ok); + } + + executorch::runtime::MethodMeta meta(const std::string &method = "forward") { + auto result = module_->method_meta(method); + EXPECT_TRUE(result.ok()); + return result.get(); + } + + std::unique_ptr module_; +}; + +TEST_F(MethodMetaTest, DerivesSpecFromMetadata) { + auto spec = schema::methodSpecFromMetadata(meta()); + + ASSERT_EQ(spec.inputs.size(), 1u); + ASSERT_EQ(spec.outputs.size(), 1u); + + const auto &input = spec.inputs.at(0); + EXPECT_EQ(input.tag, executorch::runtime::Tag::Tensor); + EXPECT_EQ(input.dtype, DType::float32); + ASSERT_EQ(input.shape.size(), 4u); + // MethodMeta only carries the static export shape, so every dim is constant. + EXPECT_EQ(std::get(input.shape.at(0)), 1); + EXPECT_EQ(std::get(input.shape.at(1)), 3); + EXPECT_EQ(std::get(input.shape.at(2)), 256); + EXPECT_EQ(std::get(input.shape.at(3)), 256); + + const auto &output = spec.outputs.at(0); + EXPECT_EQ(output.dtype, DType::float32); + ASSERT_EQ(output.shape.size(), 4u); + EXPECT_EQ(std::get(output.shape.at(1)), 1); + EXPECT_EQ(std::get(output.shape.at(3)), 256); +} + +TEST_F(MethodMetaTest, DerivedSpecCarriesNoRuntimeConstraints) { + // Constraints only come from the JSON companion; metadata alone has none. + EXPECT_TRUE(schema::methodSpecFromMetadata(meta()).runtimeConstraints.empty()); +} + +TEST_F(MethodMetaTest, ReportsUsedBackendsDeduplicated) { + // The fixture is partitioned into many XNNPACK delegate segments; the + // reported list must name each backend once, not once per segment. + auto backends = schema::getUsedBackends(meta()); + EXPECT_EQ(backends, std::vector{"XnnpackBackend"}); + EXPECT_GT(meta().num_backends(), 1u) << "fixture no longer has multiple segments; " + "the dedup assertion above is now vacuous"; +} + +TEST_F(MethodMetaTest, ValidateSpecAcceptsTheMetadataDerivedSpec) { + // The spec read straight out of the program must satisfy its own validation. + auto spec = schema::methodSpecFromMetadata(meta()); + EXPECT_NO_THROW(schema::validateSpec(spec, meta(), "forward")); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsWrongDtype) { + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.at(0).dtype = DType::int32; + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsWrongStaticDimension) { + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.at(0).shape.at(2) = 128; // the program says 256 + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsWrongRank) { + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.at(0).shape.pop_back(); + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsWrongParameterCount) { + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.push_back(spec.inputs.at(0)); + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsDynamicDimensionAboveTheCompiledBound) { + // A range whose max exceeds the exported allocation bound would let a + // caller drive the model past the memory the program reserved. + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.at(0).shape.at(2) = schema::RangeDim{.min = 1, .max = 4096, .step = 1}; + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); +} + +TEST_F(MethodMetaTest, ValidateSpecRejectsMalformedDimensionDomains) { + auto spec = schema::methodSpecFromMetadata(meta()); + spec.inputs.at(0).shape.at(2) = schema::RangeDim{.min = 10, .max = 1, .step = 1}; + EXPECT_THROW(schema::validateSpec(spec, meta(), "forward"), std::runtime_error); + + auto emptyEnum = schema::methodSpecFromMetadata(meta()); + emptyEnum.inputs.at(0).shape.at(2) = schema::EnumDim{.choices = {}}; + EXPECT_THROW(schema::validateSpec(emptyEnum, meta(), "forward"), std::runtime_error); +} + +// --- The load path, exercised through JS ------------------------------------- + +using ModelTest = JsiTestEnv; + +TEST_F(ModelTest, LoadsAProgramAndExposesItsPath) { + EXPECT_EQ(evalString(std::format("{} return model.path;", loadFixtureJs())), kFixture); +} + +TEST_F(ModelTest, ExposesTheSchemaToJs) { + EXPECT_TRUE(evalBool(std::format( + "{} return typeof model.schema.forward === 'object';", loadFixtureJs()))); + + EXPECT_EQ(evalString(std::format( + "{} return model.schema.forward.inputs[0].kind;", loadFixtureJs())), + "Tensor"); + EXPECT_EQ(evalString(std::format( + "{} return model.schema.forward.inputs[0].dtype;", loadFixtureJs())), + "float32"); +} + +TEST_F(ModelTest, SerialisesConstantDimensionsInTheJsSchema) { + auto shape = evalNumberArray(std::format(R"( + {} + return model.schema.forward.inputs[0].shape.map(d => d.value); + )", + loadFixtureJs())); + EXPECT_TRUE(almostEqual(shape, {1, 3, 256, 256})); + + EXPECT_EQ(evalString(std::format( + "{} return model.schema.forward.inputs[0].shape[0].kind;", loadFixtureJs())), + "constant"); +} + +TEST_F(ModelTest, ExposesBackendsToJs) { + EXPECT_EQ(evalString(std::format( + "{} return model.backends.forward.join(',');", loadFixtureJs())), + "XnnpackBackend"); +} + +TEST_F(ModelTest, ReportsAMissingFileAsLoadFailed) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.loadModel('/definitely/not/a/model.pte');"); + + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "Failed to load model from")); + EXPECT_THAT(thrown.message, HasSubstr("/definitely/not/a/model.pte")); + // The ExecuTorch error is carried through so a crash report can tell a + // missing file from a corrupt program. + EXPECT_TRUE(thrown.etRuntimeErrorCode.has_value()); +} + +TEST_F(ModelTest, RejectsWrongArgumentCount) { + EXPECT_THAT(evalThrowingMessage("__rnexecutorch_jsi__.loadModel();"), + HasSubstr("Usage: loadModel(path)")); +} + +TEST_F(ModelTest, RejectsANonStringPath) { + EXPECT_THAT(evalThrowingMessage("__rnexecutorch_jsi__.loadModel(42);"), + HasSubstr("must be a string")); +} + +TEST_F(ModelTest, ExecuteRejectsWrongArgumentCount) { + // Executing for real needs the XNNPACK delegate, but argument validation + // happens before any of that. + EXPECT_THAT(evalThrowingMessage(std::format("{} model.execute('forward');", loadFixtureJs())), + HasSubstr("Usage: execute(methodName, inputs, outputTensors)")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/SchemaTest.cpp b/packages/react-native-executorch/cpp/tests/core/SchemaTest.cpp new file mode 100644 index 0000000000..4af4d8b5dd --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/SchemaTest.cpp @@ -0,0 +1,195 @@ +#include +#include + +#include "support/JsiTestEnv.h" + +#include "core/error.h" +#include "core/schema.h" + +namespace rnexecutorch::tests { +namespace { + +namespace error = rnexecutorch::core::error; +namespace schema = rnexecutorch::core::schema; +using rnexecutorch::core::types::DType; +using ::testing::HasSubstr; + +constexpr auto kInvalidArgument = error::RnExecuTorchErrorCode::InvalidArgument; +constexpr auto kSchemaMismatch = error::RnExecuTorchErrorCode::SchemaMismatch; + +// Schema is the contract between an exported .pte and the JS caller. The parse +// and runtime-constraint halves need no ExecuTorch program, so they are covered +// here; validateSpec/methodSpecFromMetadata need a real MethodMeta and belong +// with the on-device integration tests instead (see README.md). + +std::string minimalSpecJson(const std::string &shape) { + return R"({"forward": {"inputs": [{"kind": "Tensor", "dtype": "float32", "shape": )" + + shape + R"(}], "outputs": [], "runtimeConstraints": []}})"; +} + +TEST(SchemaParse, ParsesConstantDims) { + auto spec = schema::parseModelSpecJson( + "ctx", minimalSpecJson(R"([{"kind": "constant", "value": 3}])")); + + ASSERT_TRUE(spec.contains("forward")); + const auto &input = spec.at("forward").inputs.at(0); + EXPECT_EQ(input.tag, executorch::runtime::Tag::Tensor); + EXPECT_EQ(input.dtype, DType::float32); + ASSERT_EQ(input.shape.size(), 1u); + EXPECT_EQ(std::get(input.shape.at(0)), 3); +} + +TEST(SchemaParse, ParsesRangeDims) { + auto spec = schema::parseModelSpecJson( + "ctx", + minimalSpecJson(R"([{"kind": "range", "range": {"min": 1, "max": 512, "step": 8}}])")); + + const auto &dim = std::get(spec.at("forward").inputs.at(0).shape.at(0)); + EXPECT_EQ(dim.min, 1); + EXPECT_EQ(dim.max, 512); + EXPECT_EQ(dim.step, 8); +} + +TEST(SchemaParse, ParsesEnumDims) { + auto spec = schema::parseModelSpecJson( + "ctx", minimalSpecJson(R"([{"kind": "enum", "choices": [80, 128]}])")); + + const auto &dim = std::get(spec.at("forward").inputs.at(0).shape.at(0)); + EXPECT_EQ(dim.choices, (std::vector{80, 128})); +} + +TEST(SchemaParse, ParsesNonTensorParamsWithoutDtypeOrShape) { + auto spec = schema::parseModelSpecJson( + "ctx", + R"({"forward": {"inputs": [{"kind": "Int"}], "outputs": [], "runtimeConstraints": []}})"); + + EXPECT_EQ(spec.at("forward").inputs.at(0).tag, executorch::runtime::Tag::Int); +} + +TEST(SchemaParse, ParsesRuntimeConstraints) { + auto spec = schema::parseModelSpecJson("ctx", R"({ + "forward": { + "inputs": [], "outputs": [], + "runtimeConstraints": [ + {"kind": "equality", + "dims": [{"paramSide": "input", "tensorIdx": 0, "dimIdx": 1}, + {"paramSide": "input", "tensorIdx": 1, "dimIdx": 0}]}, + {"kind": "linear", + "dimLhs": {"paramSide": "input", "tensorIdx": 0, "dimIdx": 0}, + "dimRhs": {"paramSide": "output", "tensorIdx": 0, "dimIdx": 0}, + "coefficients": [2, 1]} + ] + } + })"); + + const auto &constraints = spec.at("forward").runtimeConstraints; + ASSERT_EQ(constraints.size(), 2u); + + const auto &equality = std::get(constraints.at(0)); + ASSERT_EQ(equality.dims.size(), 2u); + EXPECT_EQ(equality.dims.at(1).tensorIdx, 1); + + const auto &linear = std::get(constraints.at(1)); + EXPECT_EQ(linear.dimRhs.paramSide, schema::ParamSide::output); + EXPECT_EQ(linear.coefficients.at(0), 2); +} + +TEST(SchemaParse, RejectsMalformedJson) { + // A spec that does not parse is a mismatch between the .pte and what the + // runtime expects, not a bad argument from JavaScript. + EXPECT_TRUE(throwsCoded([] { schema::parseModelSpecJson("ctx", "{not json"); }, kSchemaMismatch)); +} + +TEST(SchemaParse, RejectsUnknownKinds) { + EXPECT_TRUE(throwsCoded( + [] { schema::parseModelSpecJson("ctx", minimalSpecJson(R"([{"kind": "wobbly"}])")); }, + kSchemaMismatch)); + EXPECT_TRUE(throwsCoded( + [] { + schema::parseModelSpecJson( + "ctx", + R"({"forward": {"inputs": [{"kind": "Quaternion"}], "outputs": [], "runtimeConstraints": []}})"); + }, + kSchemaMismatch)); +} + +TEST(SchemaParse, ErrorMessageCarriesContext) { + EXPECT_TRUE(throwsCoded([] { schema::parseModelSpecJson("my-model.pte", "{not json"); }, + kSchemaMismatch, "my-model.pte")); +} + +// --- Runtime constraints ---------------------------------------------------- + +using SchemaConstraintTest = JsiTestEnv; + +schema::DimRef inputDim(int32_t tensorIdx, int32_t dimIdx) { + return schema::DimRef{.paramSide = schema::ParamSide::input, .tensorIdx = tensorIdx, .dimIdx = dimIdx}; +} + +schema::DimRef outputDim(int32_t tensorIdx, int32_t dimIdx) { + return schema::DimRef{.paramSide = schema::ParamSide::output, .tensorIdx = tensorIdx, .dimIdx = dimIdx}; +} + +TEST_F(SchemaConstraintTest, EqualityPassesWhenDimensionsMatch) { + std::vector constraints{ + schema::EqualityConstraint{.dims = {inputDim(0, 1), inputDim(1, 0)}}}; + + EXPECT_NO_THROW(schema::validateRuntimeConstraints(rt(), constraints, {{4, 16}, {16, 2}}, "forward")); +} + +TEST_F(SchemaConstraintTest, EqualityThrowsWhenDimensionsDiffer) { + std::vector constraints{ + schema::EqualityConstraint{.dims = {inputDim(0, 1), inputDim(1, 0)}}}; + + // A violated constraint is caused by the shapes the caller passed in, so it + // is classified as an invalid argument rather than a schema mismatch. + EXPECT_TRUE(throwsCoded( + [&] { schema::validateRuntimeConstraints(rt(), constraints, {{4, 16}, {8, 2}}, "forward"); }, + kInvalidArgument, "forward constraint[0]: equality constraint violated")); +} + +TEST_F(SchemaConstraintTest, EqualityIgnoresOutputSideDimensions) { + // Output shapes are unknown before execution, so a constraint that reduces + // to fewer than two input dimensions must be skipped, not guessed at. + std::vector constraints{ + schema::EqualityConstraint{.dims = {inputDim(0, 0), outputDim(0, 0)}}}; + + EXPECT_NO_THROW(schema::validateRuntimeConstraints(rt(), constraints, {{4}}, "forward")); +} + +TEST_F(SchemaConstraintTest, LinearPassesWhenSatisfied) { + // lhs == 2 * rhs + 1 -> 9 == 2 * 4 + 1 + std::vector constraints{ + schema::LinearConstraint{.dimLhs = inputDim(0, 0), .dimRhs = inputDim(1, 0), .coefficients = {2, 1}}}; + + EXPECT_NO_THROW(schema::validateRuntimeConstraints(rt(), constraints, {{9}, {4}}, "forward")); +} + +TEST_F(SchemaConstraintTest, LinearThrowsWhenViolated) { + std::vector constraints{ + schema::LinearConstraint{.dimLhs = inputDim(0, 0), .dimRhs = inputDim(1, 0), .coefficients = {2, 1}}}; + + EXPECT_TRUE(throwsCoded( + [&] { schema::validateRuntimeConstraints(rt(), constraints, {{10}, {4}}, "forward"); }, + kInvalidArgument, "linear constraint violated")); +} + +TEST_F(SchemaConstraintTest, LinearSkippedWhenEitherSideIsAnOutput) { + std::vector constraints{ + schema::LinearConstraint{.dimLhs = inputDim(0, 0), .dimRhs = outputDim(0, 0), .coefficients = {2, 1}}}; + + EXPECT_NO_THROW(schema::validateRuntimeConstraints(rt(), constraints, {{10}}, "forward")); +} + +TEST_F(SchemaConstraintTest, ReportsTheOffendingConstraintIndex) { + std::vector constraints{ + schema::EqualityConstraint{.dims = {inputDim(0, 0), inputDim(1, 0)}}, + schema::LinearConstraint{.dimLhs = inputDim(0, 0), .dimRhs = inputDim(1, 0), .coefficients = {5, 0}}}; + + EXPECT_TRUE(throwsCoded( + [&] { schema::validateRuntimeConstraints(rt(), constraints, {{4}, {4}}, "forward"); }, + kInvalidArgument, "constraint[1]")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/TensorTest.cpp b/packages/react-native-executorch/cpp/tests/core/TensorTest.cpp new file mode 100644 index 0000000000..cb988e4c9f --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/TensorTest.cpp @@ -0,0 +1,212 @@ +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using TensorTest = JsiTestEnv; +using ::testing::HasSubstr; + +// The tensor HostObject is the type every extension op takes and returns, so +// its property surface, bounds checks and disposal semantics are exercised +// through JS exactly as the TypeScript layer uses them. + +constexpr const char *kNs = "const rne = __rnexecutorch_jsi__;"; + +TEST_F(TensorTest, InstallsUnderTheProductionGlobal) { + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__;"), "object"); + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__.createTensor;"), "function"); +} + +TEST_F(TensorTest, ExposesShapeDtypeAndNumel) { + EXPECT_TRUE(almostEqual( + evalNumberArray(std::format("{} return rne.createTensor([2, 3, 4], 'float32').shape;", kNs)), + {2, 3, 4})); + EXPECT_EQ(evalString(std::format("{} return rne.createTensor([2, 3], 'int32').dtype;", kNs)), "int32"); + EXPECT_EQ(evalNumber(std::format("{} return rne.createTensor([2, 3, 4], 'float32').numel;", kNs)), 24); +} + +TEST_F(TensorTest, ScalarShapeIsASingleElement) { + // An empty shape is a rank-0 tensor: one element, not zero. + EXPECT_EQ(evalNumber(std::format("{} return rne.createTensor([], 'float32').numel;", kNs)), 1); +} + +TEST_F(TensorTest, RejectsNonPositiveDimensions) { + EXPECT_THAT(evalThrowingMessage(std::format("{} rne.createTensor([2, 0], 'float32');", kNs)), + HasSubstr("Shape dimensions must be positive")); + EXPECT_THAT(evalThrowingMessage(std::format("{} rne.createTensor([-1], 'float32');", kNs)), + HasSubstr("Shape dimensions must be positive")); +} + +TEST_F(TensorTest, RejectsUnknownDtype) { + // dtypeFromString names the dtypes it does accept, which is what a caller + // needs; the context prefix is not added on this path. + EXPECT_TRUE(isCodedError(evalThrowing(std::format("{} rne.createTensor([2], 'float64');", kNs)), + "INVALID_ARGUMENT", "Unsupported dtype: 'float64'")); +} + +TEST_F(TensorTest, AcceptsTheBoolDtype) { + // bool tensors back the mask outputs of the segmentation models; one byte + // per element, like uint8. + EXPECT_EQ(evalNumber(std::format("{} return rne.createTensor([2, 3], 'bool').numel;", kNs)), 6); + EXPECT_EQ(evalString(std::format("{} return rne.createTensor([2, 3], 'bool').dtype;", kNs)), "bool"); +} + +TEST_F(TensorTest, RejectsWrongArgumentCount) { + EXPECT_THAT(evalThrowingMessage(std::format("{} rne.createTensor([2]);", kNs)), + HasSubstr("Usage: createTensor(shape, dtype)")); +} + +TEST_F(TensorTest, SetDataAndGetDataRoundTrip) { + auto result = evalNumberArray(std::format(R"( + {} + const t = rne.createTensor([2, 2], 'float32'); + t.setData(new Float32Array([1.5, -2.5, 3.0, 4.25])); + const out = new Float32Array(4); + t.getData(out); + return Array.from(out); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1.5, -2.5, 3.0, 4.25})); +} + +TEST_F(TensorTest, SetDataRejectsSizeMismatch) { + // The tensor holds 4 float32s (16 bytes); a 3-element array is 12. + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const t = rne.createTensor([2, 2], 'float32'); + t.setData(new Float32Array([1, 2, 3])); + )", + kNs)), + HasSubstr("Data size mismatch")); +} + +TEST_F(TensorTest, SetDataRespectsTypedArrayViewOffset) { + // A subarray view must copy only its own window, not the whole buffer. + auto result = evalNumberArray(std::format(R"( + {} + const backing = new Float32Array([9, 9, 1, 2, 3, 4]); + const t = rne.createTensor([4], 'float32'); + t.setData(backing.subarray(2)); + const out = new Float32Array(4); + t.getData(out); + return Array.from(out); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1, 2, 3, 4})); +} + +TEST_F(TensorTest, CopyToDuplicatesContents) { + auto result = evalNumberArray(std::format(R"( + {} + const src = rne.createTensor([3], 'int32'); + src.setData(new Int32Array([7, 8, 9])); + const dst = rne.createTensor([3], 'int32'); + src.copyTo(dst); + const out = new Int32Array(3); + dst.getData(out); + return Array.from(out); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {7, 8, 9})); +} + +TEST_F(TensorTest, CopyToHonoursOffsetAndLength) { + auto result = evalNumberArray(std::format(R"( + {} + const src = rne.createTensor([5], 'int32'); + src.setData(new Int32Array([1, 2, 3, 4, 5])); + const dst = rne.createTensor([2], 'int32'); + src.copyTo(dst, {{ offset: 1, length: 2 }}); + const out = new Int32Array(2); + dst.getData(out); + return Array.from(out); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {2, 3})); +} + +TEST_F(TensorTest, CopyToRejectsOutOfBoundsWindow) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const src = rne.createTensor([3], 'int32'); + const dst = rne.createTensor([3], 'int32'); + src.copyTo(dst, {{ offset: 2, length: 3 }}); + )", + kNs)), + HasSubstr("out of bounds")); +} + +TEST_F(TensorTest, CopyToRejectsAliasingItself) { + // Aliased src/dst would memcpy a buffer onto itself under two locks; the + // guard must reject it rather than deadlock or corrupt. + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const t = rne.createTensor([3], 'int32'); + t.copyTo(t); + )", + kNs)), + HasSubstr("copyTo")); +} + +TEST_F(TensorTest, DisposeIsNotIdempotent) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const t = rne.createTensor([2], 'float32'); + t.dispose(); + t.dispose(); + )", + kNs)), + HasSubstr("already been disposed")); +} + +TEST_F(TensorTest, OperationsOnDisposedTensorThrow) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const t = rne.createTensor([2], 'float32'); + t.dispose(); + t.setData(new Float32Array([1, 2])); + )", + kNs)), + HasSubstr("disposed")); +} + +TEST_F(TensorTest, ThroughPipesTensorIntoCallback) { + // `through` exists so JS can chain ops; it must pass the tensor as the first + // argument and forward the rest. + EXPECT_EQ(evalNumber(std::format(R"( + {} + const t = rne.createTensor([4], 'float32'); + return t.through((tensor, extra) => tensor.numel + extra, 10); + )", + kNs)), + 14); +} + +TEST_F(TensorTest, ThroughIfSkipsWhenPredicateIsFalse) { + EXPECT_EQ(evalNumber(std::format(R"( + {} + const t = rne.createTensor([4], 'float32'); + const out = t.throughIf(false, () => 99); + return out.numel; + )", + kNs)), + 4); + + EXPECT_EQ(evalNumber(std::format(R"( + {} + const t = rne.createTensor([4], 'float32'); + return t.throughIf(true, () => 99); + )", + kNs)), + 99); +} + +TEST_F(TensorTest, UnknownPropertyIsUndefined) { + EXPECT_EQ(evalString(std::format("{} return typeof rne.createTensor([2], 'float32').notAThing;", kNs)), + "undefined"); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/core/UtilsTest.cpp b/packages/react-native-executorch/cpp/tests/core/UtilsTest.cpp new file mode 100644 index 0000000000..d198a82fb2 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/UtilsTest.cpp @@ -0,0 +1,68 @@ +#include + +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using ::testing::HasSubstr; + +class UtilsTest : public JsiTestEnv {}; + +TEST_F(UtilsTest, ReportsRegisteredBackends) { + // The host build links no delegate, so the registry is empty. What is under + // test is the JSI shape the TS layer reads (a real Array of strings), which + // an app queries to decide whether a Core ML / XNNPACK model can run at all. + EXPECT_TRUE(evalBool("return Array.isArray(__rnexecutorch_jsi__.getExecuTorchRegisteredBackends());")); + EXPECT_TRUE(evalBool(R"( + const backends = __rnexecutorch_jsi__.getExecuTorchRegisteredBackends(); + return backends.every((name) => typeof name === 'string'); + )")); +} + +TEST_F(UtilsTest, RejectsArgumentsToRegisteredBackends) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.getExecuTorchRegisteredBackends(1);"); + + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", + "Usage: getExecuTorchRegisteredBackends()")); +} + +// isEmulator is a plain boolean property, not a function: the value is fixed for +// the lifetime of the process, and the download analytics path reads it on every +// fetch. +TEST_F(UtilsTest, ExposesIsEmulatorAsABoolean) { + EXPECT_TRUE(evalBool("return typeof __rnexecutorch_jsi__.isEmulator === 'boolean';")); + // Neither an Android emulator nor an iOS simulator: a host build is a real + // machine, and the Apple branch resolves at compile time. + EXPECT_FALSE(evalBool("return __rnexecutorch_jsi__.isEmulator;")); +} + +TEST_F(UtilsTest, InstallsTheModuleUnderItsProductionName) { + EXPECT_TRUE(evalBool("return typeof __rnexecutorch_jsi__ === 'object';")); + + // Every extension namespace the TS layer reaches for. A missing install() + // call shows up here rather than as an undefined-is-not-a-function further + // down some pipeline. `cv` is compiled in only with OpenCV, exactly as on + // device. +#ifdef RNE_ENABLE_OPENCV + EXPECT_TRUE(evalBool("return typeof __rnexecutorch_jsi__.cv === 'object';")); +#else + EXPECT_TRUE(evalBool("return __rnexecutorch_jsi__.cv === undefined;")); +#endif + + for (const auto *name : {"math", "nlp", "speech", "llm"}) { + EXPECT_TRUE(evalBool(std::string("return typeof __rnexecutorch_jsi__.") + name + " === 'object';")) + << "missing extension namespace: " << name; + } + + for (const auto *name : {"loadModel", "createTensor", "getExecuTorchRegisteredBackends"}) { + EXPECT_TRUE(evalBool(std::string("return typeof __rnexecutorch_jsi__.") + name + " === 'function';")) + << "missing core entry point: " << name; + } +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp new file mode 100644 index 0000000000..658389c5b9 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp @@ -0,0 +1,419 @@ +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using CvOpsTest = JsiTestEnv; +using ::testing::HasSubstr; + +// The cv extension wraps OpenCV for the image pre/post-processing every vision +// pipeline runs. Layout conversions and box decoding are where an off-by-one +// silently produces a plausible-but-wrong tensor, so they are pinned exactly. + +constexpr const char *kNs = + "const cv = __rnexecutorch_jsi__.cv;" + "const createTensor = __rnexecutorch_jsi__.createTensor;" + "const fillU8 = (t, v) => { t.setData(new Uint8Array(v)); return t; };" + "const fillF32 = (t, v) => { t.setData(new Float32Array(v)); return t; };" + "const readU8 = (t) => { const o = new Uint8Array(t.numel); t.getData(o); return Array.from(o); };" + "const readF32 = (t) => { const o = new Float32Array(t.numel); t.getData(o); return Array.from(o); };"; + +TEST_F(CvOpsTest, InstallsTheCvNamespace) { + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__.cv.resize;"), "function"); + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__.cv.nms;"), "function"); +} + +// --- Layout conversions ----------------------------------------------------- + +TEST_F(CvOpsTest, ToChannelsFirstDeinterleaves) { + // A 1x2 HWC image with 3 channels: [r0,g0,b0, r1,g1,b1] becomes planar + // [r0,r1, g0,g1, b0,b1]. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 2, 3], 'uint8'), [1, 2, 3, 4, 5, 6]); + const dst = createTensor([3, 1, 2], 'uint8'); + cv.toChannelsFirst(src, dst); + return readU8(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1, 4, 2, 5, 3, 6})); +} + +TEST_F(CvOpsTest, ToChannelsLastInterleaves) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([3, 1, 2], 'uint8'), [1, 4, 2, 5, 3, 6]); + const dst = createTensor([1, 2, 3], 'uint8'); + cv.toChannelsLast(src, dst); + return readU8(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1, 2, 3, 4, 5, 6})); +} + +TEST_F(CvOpsTest, ChannelOrderRoundTrips) { + auto result = evalNumberArray(std::format(R"( + {} + const original = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + const src = fillU8(createTensor([2, 2, 3], 'uint8'), original); + const planar = createTensor([3, 2, 2], 'uint8'); + const back = createTensor([2, 2, 3], 'uint8'); + cv.toChannelsFirst(src, planar); + cv.toChannelsLast(planar, back); + return readU8(back); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120})); +} + +TEST_F(CvOpsTest, ToChannelsFirstRejectsMismatchedDestination) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + cv.toChannelsFirst(createTensor([1, 2, 3], 'uint8'), createTensor([1, 2, 3], 'uint8')); + )", + kNs)), + HasSubstr("toChannelsFirst: dst")); +} + +// --- Normalize -------------------------------------------------------------- + +TEST_F(CvOpsTest, NormalizeAppliesScaleAndOffset) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 1, 4], 'uint8'), [0, 50, 100, 200]); + const dst = createTensor([1, 1, 4], 'float32'); + cv.normalize(src, dst, {{ alpha: 0.5, beta: 1 }}); + return readF32(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1, 26, 51, 101})); +} + +TEST_F(CvOpsTest, NormalizeAcceptsPerChannelValues) { + // Two channels of one pixel each, scaled differently. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([2, 1, 1], 'uint8'), [10, 10]); + const dst = createTensor([2, 1, 1], 'float32'); + cv.normalize(src, dst, {{ alpha: [1, 2], beta: [0, 5] }}); + return readF32(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {10, 25})); +} + +TEST_F(CvOpsTest, NormalizeRejectsWrongPerChannelLength) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const src = createTensor([2, 1, 1], 'uint8'); + const dst = createTensor([2, 1, 1], 'float32'); + cv.normalize(src, dst, {{ alpha: [1, 2, 3], beta: 0 }}); + )", + kNs)), + HasSubstr("array length must be exactly equal to channels")); +} + +// --- Resize ----------------------------------------------------------------- + +TEST_F(CvOpsTest, ResizeStretchesToDestinationSize) { + // Nearest-neighbour upscale of a 1x1 image fills the destination. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 1, 1], 'uint8'), [7]); + const dst = createTensor([2, 2, 1], 'uint8'); + cv.resize(src, dst, {{ mode: 'stretch', interpolation: 'nearest', padValue: 0 }}); + return readU8(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {7, 7, 7, 7})); +} + +TEST_F(CvOpsTest, LetterboxPadsWithPadValue) { + // A 1x2 source into a 2x2 destination scales by 1 (the width already fits), + // so the content lands on the first row and the second stays padding. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 2, 1], 'uint8'), [5, 5]); + const dst = createTensor([2, 2, 1], 'uint8'); + cv.resize(src, dst, {{ mode: 'letterbox', interpolation: 'nearest', padValue: 9 }}); + return readU8(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {5, 5, 9, 9})); +} + +TEST_F(CvOpsTest, ResizeRejectsUnknownMode) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + cv.resize(createTensor([1, 1, 1], 'uint8'), createTensor([2, 2, 1], 'uint8'), + {{ mode: 'squish', interpolation: 'nearest', padValue: 0 }}); + )", + kNs)), + HasSubstr("unknown mode")); +} + +TEST_F(CvOpsTest, ResizeRequiresMatchingChannelCount) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + cv.resize(createTensor([1, 1, 3], 'uint8'), createTensor([2, 2, 1], 'uint8'), + {{ mode: 'stretch', interpolation: 'nearest', padValue: 0 }}); + )", + kNs)), + HasSubstr("resize: dst")); +} + +// --- NMS -------------------------------------------------------------------- + +constexpr const char *kNmsOpts = + "{ nmsType: 'standard', boxFormat: 'xyxy', iouThreshold: 0.5, confidenceThreshold: 0.1 }"; + +TEST_F(CvOpsTest, NmsSuppressesOverlappingBoxes) { + // Two nearly identical boxes plus one far away: the lower-scoring duplicate + // is dropped, the distant box survives. + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([3, 4], 'float32'), [ + 0, 0, 10, 10, + 0, 0, 9, 9, + 100, 100, 110, 110 + ]); + const scores = fillF32(createTensor([3], 'float32'), [0.9, 0.8, 0.7]); + return cv.nms(boxes, scores, {}); + )", + kNs, kNmsOpts)); + EXPECT_TRUE(almostEqual(result, {0, 2})); +} + +TEST_F(CvOpsTest, NmsKeepsBoxesBelowTheIouThreshold) { + // Boxes touching at a corner have IoU 0, so both survive. + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([2, 4], 'float32'), [ + 0, 0, 10, 10, + 10, 10, 20, 20 + ]); + const scores = fillF32(createTensor([2], 'float32'), [0.9, 0.8]); + return cv.nms(boxes, scores, {}); + )", + kNs, kNmsOpts)); + EXPECT_TRUE(almostEqual(result, {0, 1})); +} + +TEST_F(CvOpsTest, NmsDropsBoxesBelowTheConfidenceThreshold) { + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([2, 4], 'float32'), [ + 0, 0, 10, 10, + 100, 100, 110, 110 + ]); + const scores = fillF32(createTensor([2], 'float32'), [0.9, 0.05]); + return cv.nms(boxes, scores, {}); + )", + kNs, kNmsOpts)); + EXPECT_TRUE(almostEqual(result, {0})); +} + +TEST_F(CvOpsTest, NmsReturnsEmptyWhenNothingClearsConfidence) { + EXPECT_EQ(evalNumber(std::format(R"( + {} + const boxes = fillF32(createTensor([1, 4], 'float32'), [0, 0, 10, 10]); + const scores = fillF32(createTensor([1], 'float32'), [0.01]); + return cv.nms(boxes, scores, {}).length; + )", + kNs, kNmsOpts)), + 0); +} + +TEST_F(CvOpsTest, WeightedNmsReturnsGroupsOfIndices) { + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([3, 4], 'float32'), [ + 0, 0, 10, 10, + 0, 0, 9, 9, + 100, 100, 110, 110 + ]); + const scores = fillF32(createTensor([3], 'float32'), [0.9, 0.8, 0.7]); + const groups = cv.nms(boxes, scores, {{ nmsType: 'weighted', boxFormat: 'xyxy', + iouThreshold: 0.5, confidenceThreshold: 0.1 }}); + // Flatten to [groupCount, ...group0, ...group1] for easy assertion. + return [groups.length].concat(groups[0]).concat(groups[1]); + )", + kNs)); + // Two groups: the first merges the duplicate pair, the second is the distant box. + EXPECT_TRUE(almostEqual(result, {2, 0, 1, 2})); +} + +TEST_F(CvOpsTest, NmsDecodesXywhBoxes) { + // Same geometry as the xyxy case, expressed as x/y/width/height. + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([2, 4], 'float32'), [ + 0, 0, 10, 10, + 0, 0, 9, 9 + ]); + const scores = fillF32(createTensor([2], 'float32'), [0.9, 0.8]); + return cv.nms(boxes, scores, {{ nmsType: 'standard', boxFormat: 'xywh', + iouThreshold: 0.5, confidenceThreshold: 0.1 }}); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {0})); +} + +TEST_F(CvOpsTest, NmsDecodesCxcywhBoxes) { + // Centre-based boxes covering the same area overlap fully. + auto result = evalNumberArray(std::format(R"( + {} + const boxes = fillF32(createTensor([2, 4], 'float32'), [ + 5, 5, 10, 10, + 5, 5, 9, 9 + ]); + const scores = fillF32(createTensor([2], 'float32'), [0.9, 0.8]); + return cv.nms(boxes, scores, {{ nmsType: 'standard', boxFormat: 'cxcywh', + iouThreshold: 0.5, confidenceThreshold: 0.1 }}); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {0})); +} + +TEST_F(CvOpsTest, NmsRejectsUnknownEnums) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const boxes = createTensor([1, 4], 'float32'); + const scores = createTensor([1], 'float32'); + cv.nms(boxes, scores, {{ nmsType: 'soft', boxFormat: 'xyxy', + iouThreshold: 0.5, confidenceThreshold: 0.1 }}); + )", + kNs)), + HasSubstr("unsupported nmsType")); + + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const boxes = createTensor([1, 4], 'float32'); + const scores = createTensor([1], 'float32'); + cv.nms(boxes, scores, {{ nmsType: 'standard', boxFormat: 'yxyx', + iouThreshold: 0.5, confidenceThreshold: 0.1 }}); + )", + kNs)), + HasSubstr("unsupported boxFormat")); +} + +TEST_F(CvOpsTest, NmsRequiresScoresToMatchBoxCount) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const boxes = createTensor([3, 4], 'float32'); + const scores = createTensor([2], 'float32'); + cv.nms(boxes, scores, {}); + )", + kNs, kNmsOpts)), + HasSubstr("nms: scores")); +} + +// --- rectifyQuad ------------------------------------------------------------ +// Warps a detected text quad onto a fixed-height canvas, the step between OCR +// detection and recognition. Content is rendered at `contentWidth` and the rest +// of the canvas is flat padding, so the padding and the alignment are as much +// part of the contract as the warp itself. + +TEST_F(CvOpsTest, RectifyQuadWarpsAnAxisAlignedQuadUnchanged) { + // A quad that already matches the destination rectangle is an identity warp, + // so the pixels come through as they went in. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([2, 2, 1], 'uint8'), [10, 20, 30, 40]); + const dst = createTensor([2, 2, 1], 'uint8'); + cv.rectifyQuad(src, dst, [0, 0, 2, 0, 2, 2, 0, 2], + {{ contentWidth: 2, padValue: 0, align: 'left' }}); + return readU8(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {10, 20, 30, 40})); +} + +TEST_F(CvOpsTest, RectifyQuadPadsTheRestOfTheCanvas) { + // contentWidth 2 on a 4-wide canvas leaves two columns of padding, which + // must be the requested pad value rather than whatever the warp produced. + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 2, 1], 'uint8'), [10, 20]); + const dst = createTensor([1, 4, 1], 'uint8'); + cv.rectifyQuad(src, dst, [0, 0, 2, 0, 2, 1, 0, 1], + {{ contentWidth: 2, padValue: 7, align: 'left' }}); + return readU8(dst); + )", + kNs)); + EXPECT_EQ(result.size(), 4u); + EXPECT_EQ(result[2], 7); + EXPECT_EQ(result[3], 7); +} + +TEST_F(CvOpsTest, RectifyQuadCentresContentWhenAsked) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fillU8(createTensor([1, 2, 1], 'uint8'), [10, 20]); + const dst = createTensor([1, 4, 1], 'uint8'); + cv.rectifyQuad(src, dst, [0, 0, 2, 0, 2, 1, 0, 1], + {{ contentWidth: 2, padValue: 7, align: 'center' }}); + return readU8(dst); + )", + kNs)); + // offsetX = (4 - 2) / 2 = 1, so the padding sits on both sides. + ASSERT_EQ(result.size(), 4u); + EXPECT_EQ(result[0], 7); + EXPECT_EQ(result[3], 7); +} + +TEST_F(CvOpsTest, RectifyQuadClampsContentWidthToTheCanvas) { + // A contentWidth wider than the canvas would run the blit off the end; it is + // clamped rather than rejected, because the caller derives it from the quad's + // aspect ratio. + EXPECT_TRUE(evalBool(std::format(R"( + {} + const src = fillU8(createTensor([1, 2, 1], 'uint8'), [10, 20]); + const dst = createTensor([1, 2, 1], 'uint8'); + cv.rectifyQuad(src, dst, [0, 0, 2, 0, 2, 1, 0, 1], + {{ contentWidth: 99, padValue: 0, align: 'left' }}); + return true; + )", + kNs))); +} + +TEST_F(CvOpsTest, RectifyQuadReturnsTheDestinationTensor) { + EXPECT_TRUE(evalBool(std::format(R"( + {} + const src = fillU8(createTensor([2, 2, 1], 'uint8'), [1, 2, 3, 4]); + const dst = createTensor([2, 2, 1], 'uint8'); + return cv.rectifyQuad(src, dst, [0, 0, 2, 0, 2, 2, 0, 2], + {{ contentWidth: 2, padValue: 0, align: 'left' }}) === dst; + )", + kNs))); +} + +TEST_F(CvOpsTest, RectifyQuadRejectsAQuadOfTheWrongLength) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + cv.rectifyQuad(createTensor([2, 2, 1], 'uint8'), createTensor([2, 2, 1], 'uint8'), + [0, 0, 2, 0, 2, 2], {{ contentWidth: 2, padValue: 0, align: 'left' }}); + )", + kNs)), + "INVALID_ARGUMENT", "quad must have exactly 8 numbers")); +} + +TEST_F(CvOpsTest, RectifyQuadRejectsAChannelMismatch) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + cv.rectifyQuad(createTensor([2, 2, 3], 'uint8'), createTensor([2, 2, 1], 'uint8'), + [0, 0, 2, 0, 2, 2, 0, 2], {{ contentWidth: 2, padValue: 0, align: 'left' }}); + )", + kNs)), + "INVALID_ARGUMENT", "rectifyQuad: dst")); +} + +TEST_F(CvOpsTest, RectifyQuadRejectsWrongArgumentCounts) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format("{} cv.rectifyQuad();", kNs)), + "INVALID_ARGUMENT", "Usage: rectifyQuad(src, dst, quad, options)")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/LlmRunnerTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/LlmRunnerTest.cpp new file mode 100644 index 0000000000..6fdb55268c --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/LlmRunnerTest.cpp @@ -0,0 +1,99 @@ +#include + +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using ::testing::HasSubstr; + +// The llm extension wraps ExecuTorch's LLM runner. Creating one loads a real +// model and a real tokenizer, and running one needs the delegate the model was +// exported against — the same limit ModelTest documents for execute(). What is +// host-testable is everything up to that: the argument contract of +// createLLMRunner and how each failure to load is classified. +class LlmRunnerTest : public JsiTestEnv {}; + +TEST_F(LlmRunnerTest, IsInstalledUnderTheLlmNamespace) { + EXPECT_TRUE(evalBool("return typeof __rnexecutorch_jsi__.llm.createLLMRunner === 'function';")); + // Arity is what the TS layer's `.length` checks and what JS engines report; + // the two optional arguments are not counted. + EXPECT_EQ(evalNumber("return __rnexecutorch_jsi__.llm.createLLMRunner.length;"), 2); +} + +TEST_F(LlmRunnerTest, RejectsWrongArgumentCounts) { + const std::string usage = "Usage: createLLMRunner(modelPath, tokenizerPath, modalities?)"; + + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner();"), + "INVALID_ARGUMENT", usage)); + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner('model.pte');"), + "INVALID_ARGUMENT", usage)); + // modalities is the last accepted argument, so a fourth is a mistake rather + // than something to ignore. + EXPECT_TRUE(isCodedError( + evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner('a.pte', 'b.json', [], 'extra');"), + "INVALID_ARGUMENT", usage)); +} + +TEST_F(LlmRunnerTest, RejectsWronglyTypedArguments) { + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner(1, 'b.json');"), + "INVALID_ARGUMENT", "modelPath must be a string")); + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner('a.pte', 2);"), + "INVALID_ARGUMENT", "tokenizerPath must be a string")); + EXPECT_TRUE(isCodedError( + evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner('a.pte', 'b.json', 'image');"), + "INVALID_ARGUMENT", "modalities must be an Array")); + EXPECT_TRUE(isCodedError( + evalThrowing("__rnexecutorch_jsi__.llm.createLLMRunner('a.pte', 'b.json', [7]);"), + "INVALID_ARGUMENT", "must be a string")); +} + +// null and undefined mean "text only", so they must reach the tokenizer load +// rather than being rejected as a bad modalities list. +TEST_F(LlmRunnerTest, TreatsAbsentModalitiesAsTextOnly) { + for (const auto *modalities : {"null", "undefined"}) { + auto thrown = evalThrowing( + std::string("__rnexecutorch_jsi__.llm.createLLMRunner('/no/model.pte', '/no/tokenizer.json', ") + + modalities + ");"); + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "/no/tokenizer.json")) << "modalities: " << modalities; + } +} + +// The tokenizer is loaded before the model, so a missing one is reported as +// such instead of as a confusing model failure. +TEST_F(LlmRunnerTest, ReportsAMissingTokenizerAsLoadFailed) { + auto thrown = evalThrowing( + "__rnexecutorch_jsi__.llm.createLLMRunner('/no/model.pte', '/no/tokenizer.json');"); + + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "Failed to load runner tokenizer")); + EXPECT_THAT(thrown.message, HasSubstr("/no/tokenizer.json")); +} + +#ifdef RNE_TOKENIZER_FIXTURE +// With a loadable tokenizer the failure moves on to the model, which is the +// point: the two load steps are classified separately rather than collapsed. +TEST_F(LlmRunnerTest, ReportsAMissingModelAsLoadFailed) { + auto thrown = evalThrowing( + std::string("__rnexecutorch_jsi__.llm.createLLMRunner('/no/model.pte', '") + + RNE_TOKENIZER_FIXTURE + "');"); + + EXPECT_EQ(thrown.code, "LOAD_FAILED"); + EXPECT_THAT(thrown.message, HasSubstr("LLMRunner:")); + EXPECT_THAT(thrown.message, ::testing::Not(HasSubstr("tokenizer"))); +} + +TEST_F(LlmRunnerTest, ReportsAMissingMultimodalModelAsLoadFailed) { + auto thrown = evalThrowing( + std::string("__rnexecutorch_jsi__.llm.createLLMRunner('/no/model.pte', '") + + RNE_TOKENIZER_FIXTURE + "', ['image']);"); + + EXPECT_EQ(thrown.code, "LOAD_FAILED"); + EXPECT_THAT(thrown.message, HasSubstr("LLMRunner:")); +} +#endif + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/MathOpsTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/MathOpsTest.cpp new file mode 100644 index 0000000000..a404ef4069 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/MathOpsTest.cpp @@ -0,0 +1,314 @@ +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using MathOpsTest = JsiTestEnv; +using ::testing::HasSubstr; + +// The math ops back the post-processing steps of the CV pipelines (softmax over +// logits, argmax for class ids, threshold for masks). They write into a caller +// supplied `dst` tensor, so both the numerics and the shape/aliasing guards +// matter. + +constexpr const char *kNs = "const m = __rnexecutorch_jsi__.math;" + "const createTensor = __rnexecutorch_jsi__.createTensor;" + "const fill = (t, values) => { t.setData(new Float32Array(values)); return t; };" + "const read = (t) => { const o = new Float32Array(t.numel); t.getData(o); return Array.from(o); };" + "const readInt = (t) => { const o = new Int32Array(t.numel); t.getData(o); return Array.from(o); };" + "const fillInt = (t, values) => { t.setData(new Int32Array(values)); return t; };"; + +double sigmoidOf(double x) { return 1.0 / (1.0 + std::exp(-x)); } + +TEST_F(MathOpsTest, SigmoidMapsElementwise) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([4], 'float32'), [-2, -0.5, 0, 3]); + const dst = createTensor([4], 'float32'); + m.sigmoid(src, dst); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {sigmoidOf(-2), sigmoidOf(-0.5), 0.5, sigmoidOf(3)})); +} + +TEST_F(MathOpsTest, SigmoidReturnsTheDestinationTensor) { + // Ops return dst so JS can chain them; losing that breaks the pipeline API. + EXPECT_EQ(evalNumber(std::format(R"( + {} + const src = createTensor([4], 'float32'); + const dst = createTensor([4], 'float32'); + return m.sigmoid(src, dst).numel; + )", + kNs)), + 4); +} + +TEST_F(MathOpsTest, SigmoidRejectsShapeMismatch) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.sigmoid(createTensor([4], 'float32'), createTensor([3], 'float32')); + )", + kNs)), + HasSubstr("sigmoid: dst")); +} + +TEST_F(MathOpsTest, SigmoidRejectsAliasedSourceAndDestination) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const t = createTensor([4], 'float32'); + m.sigmoid(t, t); + )", + kNs)), + HasSubstr("sigmoid")); +} + +TEST_F(MathOpsTest, SigmoidRejectsWrongDtype) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.sigmoid(createTensor([4], 'int32'), createTensor([4], 'float32')); + )", + kNs)), + HasSubstr("sigmoid: src")); +} + +TEST_F(MathOpsTest, SoftmaxNormalisesTheLastAxis) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 3], 'float32'), [1, 2, 3, 1, 1, 1]); + const dst = createTensor([2, 3], 'float32'); + m.softmax(src, dst, -1); + return read(dst); + )", + kNs)); + + const double e1 = std::exp(1.0 - 3.0), e2 = std::exp(2.0 - 3.0), e3 = 1.0; + const double sum = e1 + e2 + e3; + EXPECT_TRUE(almostEqual(result, + {e1 / sum, e2 / sum, e3 / sum, 1.0 / 3, 1.0 / 3, 1.0 / 3})); +} + +TEST_F(MathOpsTest, SoftmaxHandlesANonTrailingAxis) { + // axis=0 on a [2,2] tensor exercises the strided (inner != 1) path. + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 2], 'float32'), [1, 2, 1, 2]); + const dst = createTensor([2, 2], 'float32'); + m.softmax(src, dst, 0); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {0.5, 0.5, 0.5, 0.5})); +} + +TEST_F(MathOpsTest, SoftmaxIsNumericallyStableForLargeInputs) { + // Without max-subtraction exp(1000) overflows to inf and the result is NaN. + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([3], 'float32'), [1000, 1000, 1000]); + const dst = createTensor([3], 'float32'); + m.softmax(src, dst, 0); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1.0 / 3, 1.0 / 3, 1.0 / 3})); +} + +TEST_F(MathOpsTest, SoftmaxRejectsOutOfRangeAxis) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.softmax(createTensor([2, 3], 'float32'), createTensor([2, 3], 'float32'), 2); + )", + kNs)), + HasSubstr("axis 2 out of range")); + + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.softmax(createTensor([2, 3], 'float32'), createTensor([2, 3], 'float32'), -3); + )", + kNs)), + HasSubstr("out of range")); +} + +TEST_F(MathOpsTest, ArgmaxPicksTheMaximumIndex) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 3], 'float32'), [1, 9, 2, 5, 4, 3]); + const dst = createTensor([2, 1], 'int32'); + m.argmax(src, dst, -1); + return readInt(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1, 0})); +} + +TEST_F(MathOpsTest, ArgmaxReturnsTheFirstOfTiedMaxima) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([1, 4], 'float32'), [3, 7, 7, 1]); + const dst = createTensor([1, 1], 'int32'); + m.argmax(src, dst, -1); + return readInt(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {1})); +} + +TEST_F(MathOpsTest, ArgmaxRequiresDestinationWithReducedAxis) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.argmax(createTensor([2, 3], 'float32'), createTensor([2, 3], 'int32'), -1); + )", + kNs)), + HasSubstr("dst shape must match src shape but with axis dimension 1")); +} + +TEST_F(MathOpsTest, ArgmaxRequiresInt32Destination) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + m.argmax(createTensor([2, 3], 'float32'), createTensor([2, 1], 'float32'), -1); + )", + kNs)), + HasSubstr("argmax: dst")); +} + +// gather reads one value per lane at the index argmax produced, which is how a +// classifier turns its logits into a confidence alongside a label. Its shape +// contract is argmax's: indices and dst carry src's shape with the gathered axis +// collapsed to 1. +TEST_F(MathOpsTest, GatherPicksTheIndexedValuePerLane) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 3], 'float32'), [10, 11, 12, 20, 21, 22]); + const indices = fillInt(createTensor([2, 1], 'int32'), [2, 0]); + const dst = createTensor([2, 1], 'float32'); + m.gather(src, indices, dst, -1); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {12, 20})); +} + +TEST_F(MathOpsTest, GatherPairsWithArgmax) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 4], 'float32'), [1, 9, 3, 2, 8, 0, 4, 7]); + const indices = createTensor([2, 1], 'int32'); + const dst = createTensor([2, 1], 'float32'); + m.argmax(src, indices, -1); + m.gather(src, indices, dst, -1); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {9, 8})); +} + +TEST_F(MathOpsTest, GatherHandlesANonTrailingAxis) { + // A [2,2,2] tensor gathered along axis 1: inner is 2, so consecutive lanes + // are strided rather than adjacent. + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([2, 2, 2], 'float32'), [1, 2, 3, 4, 5, 6, 7, 8]); + const indices = fillInt(createTensor([2, 1, 2], 'int32'), [1, 0, 0, 1]); + const dst = createTensor([2, 1, 2], 'float32'); + m.gather(src, indices, dst, 1); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {3, 2, 5, 8})); +} + +TEST_F(MathOpsTest, GatherAcceptsANegativeAxis) { + auto explicitAxis = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([1, 3], 'float32'), [4, 5, 6]); + const indices = fillInt(createTensor([1, 1], 'int32'), [1]); + const dst = createTensor([1, 1], 'float32'); + m.gather(src, indices, dst, 1); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(explicitAxis, {5})); +} + +TEST_F(MathOpsTest, GatherRejectsMismatchedShapes) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + m.gather(createTensor([2, 3], 'float32'), createTensor([2, 3], 'int32'), + createTensor([2, 1], 'float32'), -1); + )", + kNs)), + "INVALID_ARGUMENT", + "gather: indices shape must match src shape but with axis dimension 1")); + + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + m.gather(createTensor([2, 3], 'float32'), createTensor([2, 1], 'int32'), + createTensor([2, 3], 'float32'), -1); + )", + kNs)), + "INVALID_ARGUMENT", + "gather: dst shape must match src shape but with axis dimension 1")); +} + +TEST_F(MathOpsTest, GatherRejectsAnOutOfRangeAxis) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + m.gather(createTensor([2, 3], 'float32'), createTensor([2, 1], 'int32'), + createTensor([2, 1], 'float32'), 5); + )", + kNs)), + "INVALID_ARGUMENT", "axis 5 out of range")); +} + +TEST_F(MathOpsTest, GatherRejectsAliasedSourceAndDestination) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + const t = createTensor([1, 1], 'float32'); + m.gather(t, createTensor([1, 1], 'int32'), t, -1); + )", + kNs)), + "INVALID_ARGUMENT", "gather: src")); +} + +TEST_F(MathOpsTest, GatherRequiresInt32Indices) { + EXPECT_TRUE(isCodedError(evalThrowing(std::format(R"( + {} + m.gather(createTensor([2, 3], 'float32'), createTensor([2, 1], 'float32'), + createTensor([2, 1], 'float32'), -1); + )", + kNs)), + "INVALID_ARGUMENT", "gather: indices")); +} + +TEST_F(MathOpsTest, ThresholdBinarises) { + auto result = evalNumberArray(std::format(R"( + {} + const src = fill(createTensor([5], 'float32'), [0.1, 0.5, 0.49, 0.9, 0]); + const dst = createTensor([5], 'float32'); + m.threshold(src, dst, 0.5); + return read(dst); + )", + kNs)); + // The comparison is `>=`, so a value exactly on the threshold passes. + EXPECT_TRUE(almostEqual(result, {0, 1, 0, 1, 0})); +} + +TEST_F(MathOpsTest, OpsRejectWrongArgumentCounts) { + EXPECT_THAT(evalThrowingMessage(std::format("{} m.sigmoid(createTensor([1], 'float32'));", kNs)), + HasSubstr("Usage: sigmoid(src, dst)")); + EXPECT_THAT(evalThrowingMessage(std::format("{} m.softmax(createTensor([1], 'float32'));", kNs)), + HasSubstr("Usage: softmax(src, dst, axis)")); + EXPECT_THAT(evalThrowingMessage(std::format("{} m.argmax(createTensor([1], 'float32'));", kNs)), + HasSubstr("Usage: argmax(src, dst, axis)")); + EXPECT_THAT(evalThrowingMessage(std::format("{} m.threshold(createTensor([1], 'float32'));", kNs)), + HasSubstr("Usage: threshold(src, dst, threshold)")); + EXPECT_THAT(evalThrowingMessage(std::format("{} m.gather(createTensor([1], 'float32'));", kNs)), + HasSubstr("Usage: gather(src, indices, dst, axis)")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/OcrOpsTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/OcrOpsTest.cpp new file mode 100644 index 0000000000..b97b394ebe --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/OcrOpsTest.cpp @@ -0,0 +1,187 @@ +#include +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using OcrOpsTest = JsiTestEnv; +using ::testing::HasSubstr; + +// extractDbnetTextQuads turns a DBNet probability map into oriented text quads. +// It is the one cv op whose output count is data-dependent, so the tests pin +// what each option actually filters rather than only the happy path. + +// Builds a [1,1,size,size] probability map with `value` inside the axis-aligned +// rectangle [x0,x1] x [y0,y1] and 0 elsewhere, plus the option object the op +// takes. Kept as JS so the tensor is created and filled the way the TS +// pipeline does it. +constexpr const char *kNs = R"( + const cv = __rnexecutorch_jsi__.cv; + const probMap = (size, boxes) => { + const data = new Float32Array(size * size); + for (const [x0, y0, x1, y1, value] of boxes) { + for (let y = y0; y <= y1; ++y) { + for (let x = x0; x <= x1; ++x) { data[y * size + x] = value; } + } + } + const tensor = __rnexecutorch_jsi__.createTensor([1, 1, size, size], 'float32'); + tensor.setData(data); + return tensor; + }; + const options = (overrides) => Object.assign({ + binThreshold: 0.3, + boxThreshold: 0.5, + unclipRatio: 1.5, + minBoxSide: 3, + maxCandidates: 16, + }, overrides || {}); + const quads = (src, opts) => Array.from(cv.extractDbnetTextQuads(src, options(opts))); +)"; + +TEST_F(OcrOpsTest, IsInstalledUnderTheCvNamespace) { + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__.cv.extractDbnetTextQuads;"), "function"); +} + +TEST_F(OcrOpsTest, ReturnsEightNumbersPerDetectedQuad) { + auto result = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [[8, 8, 23, 23, 1]])); + )", + kNs)); + ASSERT_EQ(result.size(), 8u); + + // Corner order is unspecified (the TS pipeline derives reading order + // geometrically), so the assertion is on the extent: the unclipped box + // covers the blob and stays inside the map. + double minX = result[0]; + double maxX = result[0]; + double minY = result[1]; + double maxY = result[1]; + for (size_t i = 0; i < result.size(); i += 2) { + minX = std::min(minX, result[i]); + maxX = std::max(maxX, result[i]); + minY = std::min(minY, result[i + 1]); + maxY = std::max(maxY, result[i + 1]); + } + EXPECT_LE(minX, 8.0); + EXPECT_GE(maxX, 23.0); + EXPECT_LE(minY, 8.0); + EXPECT_GE(maxY, 23.0); + // Clamped to the last valid pixel index, not to the map size. + EXPECT_GE(minX, 0.0); + EXPECT_GE(minY, 0.0); + EXPECT_LE(maxX, 31.0); + EXPECT_LE(maxY, 31.0); +} + +TEST_F(OcrOpsTest, FindsEveryDisjointBlob) { + auto result = evalNumberArray(std::format(R"( + {} + return quads(probMap(48, [[4, 4, 15, 15, 1], [30, 30, 43, 43, 1]])); + )", + kNs)); + EXPECT_EQ(result.size(), 16u); +} + +TEST_F(OcrOpsTest, ReturnsNothingForAnEmptyMap) { + auto result = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [])); + )", + kNs)); + EXPECT_TRUE(result.empty()); +} + +// binThreshold binarises the map; a blob whose probability sits below it never +// becomes a contour in the first place. +TEST_F(OcrOpsTest, DropsBlobsBelowTheBinarisationThreshold) { + auto kept = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [[8, 8, 23, 23, 0.4]]), {{ binThreshold: 0.3, boxThreshold: 0.2 }}); + )", + kNs)); + EXPECT_EQ(kept.size(), 8u); + + auto dropped = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [[8, 8, 23, 23, 0.4]]), {{ binThreshold: 0.6, boxThreshold: 0.2 }}); + )", + kNs)); + EXPECT_TRUE(dropped.empty()); +} + +// boxThreshold scores the mean probability inside the contour, so a blob that +// binarises but scores weakly is dropped after the fact. +TEST_F(OcrOpsTest, DropsBlobsBelowTheBoxScore) { + auto dropped = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [[8, 8, 23, 23, 0.4]]), {{ binThreshold: 0.3, boxThreshold: 0.9 }}); + )", + kNs)); + EXPECT_TRUE(dropped.empty()); +} + +TEST_F(OcrOpsTest, DropsBlobsThinnerThanMinBoxSide) { + auto dropped = evalNumberArray(std::format(R"( + {} + return quads(probMap(32, [[8, 8, 23, 10, 1]]), {{ minBoxSide: 12 }}); + )", + kNs)); + EXPECT_TRUE(dropped.empty()); +} + +TEST_F(OcrOpsTest, StopsAtMaxCandidates) { + auto result = evalNumberArray(std::format(R"( + {} + return quads(probMap(48, [[4, 4, 15, 15, 1], [30, 30, 43, 43, 1]]), {{ maxCandidates: 1 }}); + )", + kNs)); + EXPECT_EQ(result.size(), 8u); +} + +// The DBNet head always emits [1,1,H,W], so the rank is part of the contract +// rather than something to re-derive from the data. +TEST_F(OcrOpsTest, RejectsAMapOfTheWrongRank) { + auto thrown = evalThrowing(std::format(R"( + {} + const src = __rnexecutorch_jsi__.createTensor([32, 32], 'float32'); + cv.extractDbnetTextQuads(src, options()); + )", + kNs)); + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "extractDbnetTextQuads: src")); +} + +TEST_F(OcrOpsTest, RejectsAMapOfTheWrongDtype) { + auto thrown = evalThrowing(std::format(R"( + {} + const src = __rnexecutorch_jsi__.createTensor([1, 1, 32, 32], 'uint8'); + cv.extractDbnetTextQuads(src, options()); + )", + kNs)); + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "extractDbnetTextQuads: src")); +} + +TEST_F(OcrOpsTest, RequiresEveryOption) { + for (const auto *option : {"binThreshold", "boxThreshold", "unclipRatio", "minBoxSide", "maxCandidates"}) { + auto thrown = evalThrowing(std::format(R"( + {} + const opts = options(); + delete opts['{}']; + cv.extractDbnetTextQuads(probMap(32, [[8, 8, 23, 23, 1]]), opts); + )", + kNs, option)); + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", + std::format("option '{}' is required", option))); + } +} + +TEST_F(OcrOpsTest, RejectsWrongArgumentCounts) { + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.cv.extractDbnetTextQuads();"), + "INVALID_ARGUMENT", "Usage: extractDbnetTextQuads(src, options)")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/PhonemizerTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/PhonemizerTest.cpp new file mode 100644 index 0000000000..bba3b904cb --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/PhonemizerTest.cpp @@ -0,0 +1,125 @@ +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using PhonemizerTest = JsiTestEnv; +using ::testing::HasSubstr; + +// createPhonemizer wraps phonemis, the G2P front end the Kokoro TTS pipeline +// feeds. Every language phonemizes through a lexicon with a neural fallback, +// and both of those are files under the submodule's `data/`, which lives in Git +// LFS and is not fetched here. So what these tests pin is the JSI contract — +// construction, argument checking, the lifecycle and how each failure is +// classified — while phonemize() returns an empty string because it has no +// vocabulary to work from. Actual phoneme output belongs with the on-device +// tests that run against the shipped assets. + +TEST_F(PhonemizerTest, IsInstalledUnderTheSpeechNamespace) { + EXPECT_EQ(evalString("return typeof __rnexecutorch_jsi__.speech.createPhonemizer;"), "function"); +} + +TEST_F(PhonemizerTest, AcceptsEverySupportedLanguage) { + // The language string is passed straight through to phonemis, which picks + // the pipeline from it. A profile it does not know throws (below), so this + // is what catches a name drifting apart from the TS side. + for (const auto *lang : {"en-us", "en-gb", "de", "fr", "es", "it", "pl", "pt", "hi"}) { + EXPECT_TRUE(evalBool(std::string("__rnexecutorch_jsi__.speech.createPhonemizer({ lang: '") + + lang + "' }); return true;")) + << "rejected language: " << lang; + } +} + +TEST_F(PhonemizerTest, ReturnsAStringForAnyInput) { + // Without lexicon data the result is empty, but it must still come back as a + // JS string: the bridge converts utf-8 to utf-32 and back, and a multi-byte + // grapheme is where a naive std::string walk would corrupt the input. + EXPECT_TRUE(evalBool(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + return typeof p.phonemize('kot') === 'string' && typeof p.phonemize('żółw') === 'string'; + )")); +} + +TEST_F(PhonemizerTest, IsStableAcrossCalls) { + EXPECT_TRUE(evalBool(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + return p.phonemize('dom') === p.phonemize('dom'); + )")); +} + +TEST_F(PhonemizerTest, ExposesItsMethods) { + EXPECT_TRUE(evalBool(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + return typeof p.phonemize === 'function' && typeof p.dispose === 'function'; + )")); + // Unknown properties read as undefined rather than throwing, which is what + // lets the TS layer feature-detect. + EXPECT_TRUE(evalBool(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + return p.notAMethod === undefined; + )")); +} + +TEST_F(PhonemizerTest, RejectsAnUnsupportedLanguage) { + auto thrown = evalThrowing( + "__rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'xx-yy' });"); + + // The failure comes out of phonemis as a std::exception, so this also covers + // createPhonemizer classifying it rather than letting it reach JS as UNKNOWN. + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "createPhonemizer:")); + EXPECT_THAT(thrown.message, HasSubstr("xx-yy")); +} + +TEST_F(PhonemizerTest, RequiresALanguage) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.speech.createPhonemizer({});"); + + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "option 'lang' is required")); +} + +TEST_F(PhonemizerTest, RejectsWrongArgumentCounts) { + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.speech.createPhonemizer();"), + "INVALID_ARGUMENT", "Usage: createPhonemizer(config)")); + EXPECT_TRUE(isCodedError(evalThrowing("__rnexecutorch_jsi__.speech.createPhonemizer('pl');"), + "INVALID_ARGUMENT", "config must be an object")); +} + +TEST_F(PhonemizerTest, RejectsWrongArgumentCountsOnPhonemize) { + auto thrown = evalThrowing(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + p.phonemize(); + )"); + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "Usage: phonemize(text)")); +} + +TEST_F(PhonemizerTest, RejectsNonStringText) { + auto thrown = evalThrowing(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + p.phonemize(42); + )"); + EXPECT_TRUE(isCodedError(thrown, "INVALID_ARGUMENT", "phonemize: text must be a string")); +} + +// dispose frees the pipeline eagerly rather than waiting for GC, so using the +// object afterwards has to be reported rather than crashing. +TEST_F(PhonemizerTest, RejectsUseAfterDispose) { + auto thrown = evalThrowing(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + p.dispose(); + p.phonemize('kot'); + )"); + EXPECT_TRUE(isCodedError(thrown, "RESOURCE_DISPOSED", "has been disposed")); +} + +TEST_F(PhonemizerTest, DisposeIsIdempotent) { + EXPECT_TRUE(evalBool(R"( + const p = __rnexecutorch_jsi__.speech.createPhonemizer({ lang: 'pl' }); + p.dispose(); + p.dispose(); + return true; + )")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/SpeechOpsTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/SpeechOpsTest.cpp new file mode 100644 index 0000000000..d3b89f614a --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/SpeechOpsTest.cpp @@ -0,0 +1,195 @@ +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using SpeechOpsTest = JsiTestEnv; +using ::testing::HasSubstr; + +// extractFrames is the framing stage of the Whisper/VAD front-end: it slices a +// waveform into overlapping frames, removes each frame's mean, applies +// pre-emphasis and a Hann window, and centre-pads each frame into an FFT-length +// row. It was moved to C++ because doing it in JS dominated the runtime, so the +// numerics here are worth pinning down precisely. + +constexpr const char *kNs = + "const s = __rnexecutorch_jsi__.speech;" + "const createTensor = __rnexecutorch_jsi__.createTensor;" + "const fill = (t, values) => { t.setData(new Float32Array(values)); return t; };" + "const read = (t) => { const o = new Float32Array(t.numel); t.getData(o); return Array.from(o); };"; + +TEST_F(SpeechOpsTest, IdentityWindowLeavesMeanRemovedSamples) { + // preemphasis = 0 and an all-ones window reduce the transform to plain + // mean subtraction, which is easy to verify by hand. + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([4], 'float32'), [1, 2, 3, 4]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([2, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 2, hopLength: 2, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + // Frame 0 = [1,2] (mean 1.5), frame 1 = [3,4] (mean 3.5). + EXPECT_TRUE(almostEqual(result, {-0.5, 0.5, -0.5, 0.5})); +} + +TEST_F(SpeechOpsTest, AppliesTheWindowElementwise) { + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([2], 'float32'), [1, 3]); + const hann = fill(createTensor([2], 'float32'), [0, 2]); + const dst = createTensor([1, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + // Mean 2 -> [-1, 1], windowed by [0, 2] -> [0, 2]. + EXPECT_TRUE(almostEqual(result, {0, 2})); +} + +TEST_F(SpeechOpsTest, AppliesPreemphasisFromTheSecondSample) { + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([2], 'float32'), [1, 3]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([1, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0.5 }}); + return read(dst); + )", + kNs)); + // mean = 2, meanBias = 2 * (1 - 0.5) = 1. + // out[0] = (1 - 2) * 1 = -1 + // out[1] = (3 - 0.5 * 1 - 1) * 1 = 1.5 + EXPECT_TRUE(almostEqual(result, {-1, 1.5})); +} + +TEST_F(SpeechOpsTest, CentrePadsFramesIntoTheFftRow) { + // frameLength 2 into fftLength 4 -> leftPad = 1, so the frame sits in the + // middle and the surrounding cells stay zero. + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([2], 'float32'), [1, 3]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([1, 4], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {0, -1, 1, 0})); +} + +TEST_F(SpeechOpsTest, ZeroesUnusedTrailingRows) { + // dst has capacity for 3 frames but only 1 is written; the rest must be + // cleared rather than left with whatever the buffer held. + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([4], 'float32'), [1, 3, 5, 7]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([3, 2], 'float32'); + dst.setData(new Float32Array([9, 9, 9, 9, 9, 9])); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {-1, 1, 0, 0, 0, 0})); +} + +TEST_F(SpeechOpsTest, OverlappingHopsShareSamples) { + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([4], 'float32'), [1, 2, 3, 4]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([3, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 3, hopLength: 1, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + // Frames [1,2], [2,3], [3,4] — each mean-removed to [-0.5, 0.5]. + EXPECT_TRUE(almostEqual(result, {-0.5, 0.5, -0.5, 0.5, -0.5, 0.5})); +} + +TEST_F(SpeechOpsTest, ZeroFramesLeavesDestinationCleared) { + auto result = evalNumberArray(std::format(R"( + {} + const waveform = fill(createTensor([4], 'float32'), [1, 2, 3, 4]); + const hann = fill(createTensor([2], 'float32'), [1, 1]); + const dst = createTensor([2, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 0, hopLength: 1, preemphasis: 0 }}); + return read(dst); + )", + kNs)); + EXPECT_TRUE(almostEqual(result, {0, 0, 0, 0})); +} + +TEST_F(SpeechOpsTest, RejectsFrameWindowRunningPastTheWaveform) { + // The last frame would need sample index 4 of a 4-sample waveform. + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const waveform = createTensor([4], 'float32'); + const hann = createTensor([2], 'float32'); + const dst = createTensor([4, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 4, hopLength: 1, preemphasis: 0 }}); + )", + kNs)), + HasSubstr("exceeds waveform bounds")); +} + +TEST_F(SpeechOpsTest, RejectsMoreFramesThanDestinationCapacity) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const waveform = createTensor([16], 'float32'); + const hann = createTensor([2], 'float32'); + const dst = createTensor([2, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 3, hopLength: 1, preemphasis: 0 }}); + )", + kNs)), + HasSubstr("exceeds dst frame capacity")); +} + +TEST_F(SpeechOpsTest, RejectsWindowLongerThanTheFftLength) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const waveform = createTensor([16], 'float32'); + const hann = createTensor([4], 'float32'); + const dst = createTensor([2, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0 }}); + )", + kNs)), + HasSubstr("exceeds dst fftLength")); +} + +TEST_F(SpeechOpsTest, RequiresAllOptions) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const waveform = createTensor([16], 'float32'); + const hann = createTensor([2], 'float32'); + const dst = createTensor([2, 2], 'float32'); + s.extractFrames(waveform, hann, dst, {{ hopLength: 1, preemphasis: 0 }}); + )", + kNs)), + HasSubstr("'numFrames' is required")); +} + +TEST_F(SpeechOpsTest, RequiresATwoDimensionalDestination) { + EXPECT_THAT(evalThrowingMessage(std::format(R"( + {} + const waveform = createTensor([16], 'float32'); + const hann = createTensor([2], 'float32'); + const dst = createTensor([4], 'float32'); + s.extractFrames(waveform, hann, dst, {{ numFrames: 1, hopLength: 1, preemphasis: 0 }}); + )", + kNs)), + HasSubstr("extractFrames: dst")); +} + +TEST_F(SpeechOpsTest, RejectsWrongArgumentCount) { + EXPECT_THAT(evalThrowingMessage(std::format("{} s.extractFrames(createTensor([4], 'float32'));", kNs)), + HasSubstr("Usage: extractFrames(waveform, hann, dst, options)")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/extensions/TokenizerTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/TokenizerTest.cpp new file mode 100644 index 0000000000..fe49d1d04e --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/TokenizerTest.cpp @@ -0,0 +1,122 @@ +#include +#include + +#include "support/JsiTestEnv.h" + +namespace rnexecutorch::tests { +namespace { + +using TokenizerTest = JsiTestEnv; +using ::testing::HasSubstr; + +// The nlp extension wraps ExecuTorch's HFTokenizer. The fixture is Whisper +// tiny.en's tokenizer.json, a plain BPE vocabulary +// (scripts/fetch-test-fixtures.sh). +// +// What is under test is the JSI boundary, not the tokenizer itself: the typed +// array in and out, the id/piece lookups, and the disposal contract. Assertions +// avoid pinning particular token ids, which belong to the vocabulary rather than +// to this layer. + +std::string load(const std::string &body) { + return std::format(R"( + const t = __rnexecutorch_jsi__.nlp.loadTokenizer('{}'); + {} + )", + RNE_TOKENIZER_FIXTURE, body); +} + +TEST_F(TokenizerTest, LoadsAHuggingFaceTokenizer) { + EXPECT_TRUE(evalBool(load("return typeof t === 'object';"))); + EXPECT_TRUE(evalBool(load(R"( + return ['encode', 'decode', 'getVocabSize', 'idToToken', 'tokenToId', 'dispose'] + .every((name) => typeof t[name] === 'function'); + )"))); +} + +TEST_F(TokenizerTest, ReportsTheVocabularySize) { + auto size = evalNumber(load("return t.getVocabSize();")); + EXPECT_GT(size, 0); +} + +// encode returns an Int32Array, not a plain Array: the typed-array path is the +// one that crosses the worklet boundary with a single memcpy. +TEST_F(TokenizerTest, EncodesToAnInt32Array) { + EXPECT_TRUE(evalBool(load("return t.encode('hello world') instanceof Int32Array;"))); + EXPECT_GT(evalNumber(load("return t.encode('hello world').length;")), 0); +} + +TEST_F(TokenizerTest, RoundTripsText) { + // Detokenisation of a BPE vocabulary is exact for plain ASCII words, so the + // decoded text is compared directly rather than loosely. + EXPECT_EQ(evalString(load("return t.decode(t.encode(' hello world'));")), " hello world"); +} + +TEST_F(TokenizerTest, EncodesTheEmptyStringToNothing) { + EXPECT_EQ(evalNumber(load("return t.encode('').length;")), 0); +} + +TEST_F(TokenizerTest, DecodesAnEmptyTokenListWithoutTouchingTheTokenizer) { + EXPECT_EQ(evalString(load("return t.decode(new Int32Array(0));")), ""); +} + +TEST_F(TokenizerTest, ConvertsBetweenIdsAndPieces) { + EXPECT_TRUE(evalBool(load(R"( + const ids = t.encode('hello'); + const piece = t.idToToken(ids[0]); + return typeof piece === 'string' && t.tokenToId(piece) === ids[0]; + )"))); +} + +TEST_F(TokenizerTest, ReportsUnknownPiecesAsExecutionFailures) { + // A piece that is not in the vocabulary is a lookup failure inside the + // tokenizer, not a malformed argument. + auto thrown = evalThrowing(load("t.tokenToId('\\u0000not-a-real-piece\\u0000');")); + EXPECT_TRUE(isCodedError(thrown, "EXECUTION_FAILED", "tokenToId:")); +} + +TEST_F(TokenizerTest, ReportsOutOfRangeIdsAsExecutionFailures) { + auto thrown = evalThrowing(load("t.idToToken(t.getVocabSize() + 1000);")); + EXPECT_TRUE(isCodedError(thrown, "EXECUTION_FAILED", "idToToken:")); +} + +TEST_F(TokenizerTest, ReportsAMissingFileAsLoadFailed) { + auto thrown = evalThrowing("__rnexecutorch_jsi__.nlp.loadTokenizer('/no/such/tokenizer.json');"); + + EXPECT_TRUE(isCodedError(thrown, "LOAD_FAILED", "/no/such/tokenizer.json")); +} + +TEST_F(TokenizerTest, RejectsWrongArgumentTypes) { + EXPECT_TRUE(isCodedError(evalThrowing(load("t.encode(42);")), + "INVALID_ARGUMENT", "encode: text must be a string")); + EXPECT_TRUE(isCodedError(evalThrowing(load("t.tokenToId(1);")), + "INVALID_ARGUMENT", "tokenToId: token must be a string")); +} + +TEST_F(TokenizerTest, RejectsWrongArgumentCounts) { + EXPECT_TRUE(isCodedError(evalThrowing(load("t.encode();")), + "INVALID_ARGUMENT", "Usage: encode(text)")); + EXPECT_TRUE(isCodedError(evalThrowing(load("t.getVocabSize(1);")), + "INVALID_ARGUMENT", "Usage: getVocabSize()")); + EXPECT_TRUE(isCodedError(evalThrowing(load("t.decode(t.encode('hi'), true, 'extra');")), + "INVALID_ARGUMENT", "Usage: decode(tokens, skipSpecialTokens?)")); +} + +TEST_F(TokenizerTest, RejectsUseAfterDispose) { + auto thrown = evalThrowing(load(R"( + t.dispose(); + t.encode('hello'); + )")); + EXPECT_TRUE(isCodedError(thrown, "RESOURCE_DISPOSED", "has been disposed")); +} + +TEST_F(TokenizerTest, RejectsASecondDispose) { + auto thrown = evalThrowing(load(R"( + t.dispose(); + t.dispose(); + )")); + EXPECT_TRUE(isCodedError(thrown, "RESOURCE_DISPOSED", "already been disposed")); +} + +} // namespace +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp new file mode 100644 index 0000000000..e507444886 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp @@ -0,0 +1,209 @@ +#include "JsiTestEnv.h" + +#include +#include +#include + +#include "RnExecutorch.h" + +namespace rnexecutorch::tests { + +namespace { +/** + * Hermes requires a source URL for stack traces; tests have no real file so a + * stable placeholder keeps error messages readable. + */ +constexpr const char *kSourceUrl = "rnexecutorch-tests.js"; +} // namespace + +void JsiTestEnv::SetUp() { + runtime_ = facebook::hermes::makeHermesRuntime(); + rnexecutorch::install(*runtime_); +} + +void JsiTestEnv::TearDown() { + // Drop the runtime between tests so each case gets a clean global object and + // any HostObject the previous test leaked is collected here rather than + // surfacing as an unrelated failure later. + runtime_.reset(); +} + +jsi::Value JsiTestEnv::eval(const std::string &js) { + // Wrapping in an IIFE lets tests use `const`/`return` freely and makes the + // final expression the completion value regardless of statement form. + auto source = std::format("(function() {{ {} }})()", js); + return runtime_->evaluateJavaScript( + std::make_unique(source), kSourceUrl); +} + +double JsiTestEnv::evalNumber(const std::string &js) { + auto value = eval(js); + EXPECT_TRUE(value.isNumber()) << "expected a number from: " << js; + return value.isNumber() ? value.getNumber() : std::nan(""); +} + +bool JsiTestEnv::evalBool(const std::string &js) { + auto value = eval(js); + EXPECT_TRUE(value.isBool()) << "expected a boolean from: " << js; + return value.isBool() && value.getBool(); +} + +std::string JsiTestEnv::evalString(const std::string &js) { + auto value = eval(js); + EXPECT_TRUE(value.isString()) << "expected a string from: " << js; + return value.isString() ? value.getString(*runtime_).utf8(*runtime_) : ""; +} + +JsiTestEnv::ThrownError JsiTestEnv::evalThrowing(const std::string &js) { + // Catching in JS rather than around evaluateJavaScript keeps the assertion + // on the JS-visible error, which is exactly what the TS layer sees: the + // guard in core/error.h throws a constructed Error object, so `name`, + // `code` and `etRuntimeErrorCode` are readable only from JavaScript. + auto source = std::format(R"( + (function() {{ + try {{ + (function() {{ {} }})(); + }} catch (e) {{ + if (e === null || typeof e !== 'object') {{ + return {{ name: '', message: String(e), code: '' }}; + }} + return {{ + name: e.name === undefined ? '' : String(e.name), + message: e.message === undefined ? String(e) : String(e.message), + code: e.code === undefined ? '' : String(e.code), + etRuntimeErrorCode: e.etRuntimeErrorCode, + }}; + }} + return null; + }})() + )", + js); + + auto value = runtime_->evaluateJavaScript( + std::make_unique(source), kSourceUrl); + + if (!value.isObject()) { + ADD_FAILURE() << "expected the snippet to throw, but it returned normally: " << js; + return {}; + } + + auto object = value.getObject(*runtime_); + auto readString = [&](const char *prop) { + auto property = object.getProperty(*runtime_, prop); + return property.isString() ? property.getString(*runtime_).utf8(*runtime_) : std::string(); + }; + + ThrownError error; + error.name = readString("name"); + error.message = readString("message"); + error.code = readString("code"); + + auto etCode = object.getProperty(*runtime_, "etRuntimeErrorCode"); + if (etCode.isNumber()) { + error.etRuntimeErrorCode = static_cast(etCode.getNumber()); + } + return error; +} + +std::string JsiTestEnv::evalThrowingMessage(const std::string &js) { + return evalThrowing(js).message; +} + +std::string JsiTestEnv::evalThrowingCode(const std::string &js) { + return evalThrowing(js).code; +} + +std::vector JsiTestEnv::evalNumberArray(const std::string &js) { + auto value = eval(js); + if (!value.isObject()) { + ADD_FAILURE() << "expected an array-like object from: " << js; + return {}; + } + + auto object = value.getObject(*runtime_); + // TypedArrays are not jsi::Array, so read through the generic `length` + + // indexed-property path which works for both. + auto lengthValue = object.getProperty(*runtime_, "length"); + if (!lengthValue.isNumber()) { + ADD_FAILURE() << "expected an array-like object with a numeric length from: " << js; + return {}; + } + + const auto length = static_cast(lengthValue.getNumber()); + std::vector result; + result.reserve(length); + for (size_t i = 0; i < length; ++i) { + auto element = object.getProperty(*runtime_, jsi::PropNameID::forUtf8(*runtime_, std::to_string(i))); + result.push_back(element.isNumber() ? element.getNumber() : std::nan("")); + } + return result; +} + +::testing::AssertionResult almostEqual(const std::vector &actual, + const std::vector &expected, + double tolerance) { + if (actual.size() != expected.size()) { + return ::testing::AssertionFailure() + << "size mismatch: actual " << actual.size() + << " vs expected " << expected.size(); + } + + for (size_t i = 0; i < actual.size(); ++i) { + if (std::isnan(actual[i]) != std::isnan(expected[i]) || + (!std::isnan(expected[i]) && std::abs(actual[i] - expected[i]) > tolerance)) { + return ::testing::AssertionFailure() + << "element " << i << " differs: actual " << actual[i] + << " vs expected " << expected[i] + << " (tolerance " << tolerance << ")"; + } + } + return ::testing::AssertionSuccess(); +} + +::testing::AssertionResult isCodedError(const JsiTestEnv::ThrownError &error, + std::string_view expectedCode, + std::string_view messageSubstring) { + if (error.name != "RnExecuTorchError") { + return ::testing::AssertionFailure() + << "expected an RnExecuTorchError, got name \"" << error.name + << "\" with message \"" << error.message << "\""; + } + if (error.code != expectedCode) { + return ::testing::AssertionFailure() + << "expected code \"" << expectedCode << "\", got \"" << error.code + << "\" with message \"" << error.message << "\""; + } + if (error.message.find(messageSubstring) == std::string::npos) { + return ::testing::AssertionFailure() + << "expected the message to contain \"" << messageSubstring + << "\", got \"" << error.message << "\""; + } + return ::testing::AssertionSuccess(); +} + +::testing::AssertionResult throwsCoded(const std::function &fn, + core::error::RnExecuTorchErrorCode expectedCode, + std::string_view messageSubstring) { + try { + fn(); + } catch (const core::error::RnExecuTorchException &e) { + if (e.code_ != expectedCode) { + return ::testing::AssertionFailure() + << "expected code " << core::error::errorCodeToString(expectedCode) + << ", got " << core::error::errorCodeToString(e.code_) + << " (" << e.what() << ")"; + } + if (std::string_view(e.what()).find(messageSubstring) == std::string_view::npos) { + return ::testing::AssertionFailure() + << "expected the message to contain \"" << messageSubstring + << "\", got \"" << e.what() << "\""; + } + return ::testing::AssertionSuccess(); + } catch (const std::exception &e) { + return ::testing::AssertionFailure() + << "expected an RnExecuTorchException, got: " << e.what(); + } + return ::testing::AssertionFailure() << "expected a throw, but none happened"; +} + +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.h b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.h new file mode 100644 index 0000000000..4c5e40193f --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.h @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "core/error.h" + +namespace rnexecutorch::tests { +namespace jsi = facebook::jsi; + +/** + * Test fixture that owns a real Hermes JavaScript runtime with the full + * `rnexecutorch` native module installed under its production global name + * (`__rnexecutorch_jsi__`). + * + * Tests drive the native code the same way the TypeScript layer does — through + * JSI — so the JSI argument parsing, HostObject plumbing and JSError messages + * are all under test, not bypassed. + */ +class JsiTestEnv : public ::testing::Test { +public: + /** + * Evaluates a snippet of JavaScript in the fixture's runtime and returns the + * value of its final expression. + * + * @param js The JavaScript source to evaluate. + * @return The resulting JSI value. + */ + jsi::Value eval(const std::string &js); + + /** + * Evaluates a snippet of JavaScript and returns the result as a double. + * Fails the test if the result is not a number. + */ + double evalNumber(const std::string &js); + + /** + * Evaluates a snippet of JavaScript and returns the result as a bool. + * Fails the test if the result is not a boolean. + */ + bool evalBool(const std::string &js); + + /** + * Evaluates a snippet of JavaScript and returns the result as a UTF-8 + * string. Fails the test if the result is not a string. + */ + std::string evalString(const std::string &js); + + /** + * The JavaScript-visible shape of a failure raised by the native layer. + * + * `core/error.h` is the only place that turns a native exception into a JS + * value, and it always produces an Error carrying `name`, `code` and — when + * the failure came out of the ExecuTorch runtime — `etRuntimeErrorCode`. + * Those fields are the contract `isRnExecuTorchError` reads on the + * TypeScript side, so tests assert on them rather than on the message alone. + */ + struct ThrownError { + /** `error.name`; "RnExecuTorchError" for anything raised by the guard. */ + std::string name; + /** `error.message`. */ + std::string message; + /** `error.code`, or empty when the thrown value carries none. */ + std::string code; + /** `error.etRuntimeErrorCode`, present only for ExecuTorch failures. */ + std::optional etRuntimeErrorCode; + }; + + /** + * Evaluates a snippet of JavaScript expected to throw, and returns the + * thrown error's JS-visible fields. + * + * @param js The JavaScript source expected to throw. + * @return The thrown value's name, message, code and ExecuTorch error code. + */ + ThrownError evalThrowing(const std::string &js); + + /** + * Evaluates a snippet of JavaScript expected to throw, and returns the + * thrown error's `message`. Shorthand for `evalThrowing(js).message`. + * + * @param js The JavaScript source expected to throw. + * @return The `message` of the thrown value. + */ + std::string evalThrowingMessage(const std::string &js); + + /** + * Evaluates a snippet of JavaScript expected to throw, and returns the + * thrown error's `code`. Shorthand for `evalThrowing(js).code`. + * + * @param js The JavaScript source expected to throw. + * @return The `code` of the thrown value, or "" when it carries none. + */ + std::string evalThrowingCode(const std::string &js); + + /** + * Evaluates a JavaScript expression yielding a numeric array (or TypedArray) + * and returns its elements as a vector of doubles. + */ + std::vector evalNumberArray(const std::string &js); + + /** + * The underlying Hermes runtime, for tests that need to touch JSI directly + * rather than going through JavaScript source. + */ + jsi::Runtime &rt() { return *runtime_; } + +protected: + void SetUp() override; + void TearDown() override; + +private: + std::unique_ptr runtime_; +}; + +/** + * Asserts that two floating point values are equal within `tolerance`, with a + * failure message naming the index — intended for element-wise comparison of + * tensor contents. + */ +::testing::AssertionResult almostEqual(const std::vector &actual, + const std::vector &expected, + double tolerance = 1e-6); + +/** + * Asserts that `error` is an `RnExecuTorchError` carrying `expectedCode`, whose + * message contains `messageSubstring`. + * + * Every negative test in the suite goes through this so a throw site that loses + * its code — by throwing a bare `jsi::JSError`, or by escaping the guard — fails + * the assertion instead of passing on the message alone. + */ +::testing::AssertionResult isCodedError(const JsiTestEnv::ThrownError &error, + std::string_view expectedCode, + std::string_view messageSubstring); + +/** + * Asserts that `fn` throws an `RnExecuTorchException` carrying `expectedCode`, + * whose message contains `messageSubstring`. + * + * The JSI counterpart of this is `isCodedError`. This one is for the pieces + * called directly rather than through a guarded host function — conversions, + * dtype parsing and schema validation all throw before any runtime is involved. + */ +::testing::AssertionResult throwsCoded(const std::function &fn, + core::error::RnExecuTorchErrorCode expectedCode, + std::string_view messageSubstring = ""); + +} // namespace rnexecutorch::tests diff --git a/packages/react-native-executorch/scripts/build-native-test-deps.sh b/packages/react-native-executorch/scripts/build-native-test-deps.sh new file mode 100755 index 0000000000..de118ce439 --- /dev/null +++ b/packages/react-native-executorch/scripts/build-native-test-deps.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# Builds the host-side dependencies the C++ unit tests link against: +# +# * Hermes -- the JS engine RN ships. The package's native code is entirely +# JSI-facing, so a real runtime is what makes it callable at +# all. Hermes vendors JSI, so this covers both. +# * ExecuTorch -- a minimal host build (no backends, no kernels beyond +# portable) providing tensor creation, module loading, the LLM +# tokenizers and the LLM runner the llm extension wraps. +# +# Both land in .native-test-deps/ next to the package, which is gitignored and +# safe for CI to cache wholesale — the pinned versions below are the cache key. +# Re-running is cheap: each build is incremental and a no-op when up to date. +# +# Usage: +# scripts/build-native-test-deps.sh [--clean] +# +# Environment: +# RNE_TEST_DEPS_DIR=/path -- override the output directory +# JOBS=8 -- parallelism (defaults to the CPU count) +set -euo pipefail + +# Keep HERMES_VERSION in sync with node_modules/react-native/sdks/.hermesversion +# so the tests run on the same engine as the apps. +HERMES_VERSION="hermes-v0.14.1" +HERMES_REPO="https://github.com/facebook/hermes.git" + +# Keep EXECUTORCH_VERSION in sync with the ExecuTorch release that +# third-party/include is vendored from — that is the release tagged +# `v${nativeLibsVersion}-libs` in package.json. A mismatch shows up as link +# errors or, worse, ABI drift at runtime. cpp/extensions/llm additionally reads +# private members of TextLLMRunner/MultimodalRunner, so a version skew there +# fails to compile rather than silently misbehaving. +EXECUTORCH_VERSION="v1.3.1" +EXECUTORCH_REPO="https://github.com/pytorch/executorch.git" + +# The shipped native libraries are built from software-mansion-labs/executorch +# @rne-split-build, which is ExecuTorch 1.3.1 with the tokenizers submodule +# swapped for the fork below (it adds the WordPiece/Unigram models and the NFC +# normalizer that upstream has not taken). third-party/include carries that +# fork's headers, so linking upstream's libtokenizers.a here would compile +# against one class layout and link another — HFTokenizer::load then crashes +# inside setup_pretokenizer rather than failing to link. Swapping the submodule +# reproduces the shipped configuration with a single tokenizers in the build. +# +# Keep this commit in sync with the tokenizers submodule of the fork commit that +# produced the current headers.tar.gz. +TOKENIZERS_REPO="https://github.com/software-mansion-labs/pytorch-tokenizers.git" +TOKENIZERS_COMMIT="56a30afbe2e6b4ca881d0fb7b961b9f9da156be4" + +cd "$(dirname "$0")/.." +PACKAGE_DIR="$(pwd)" + +DEPS_DIR="${RNE_TEST_DEPS_DIR:-${PACKAGE_DIR}/.native-test-deps}" +JOBS="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}" + +if [ "${1:-}" = "--clean" ]; then + echo "Removing ${DEPS_DIR}" + rm -rf "${DEPS_DIR}" +fi + +for tool in cmake ninja git; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: '$tool' is required but not installed." >&2 + exit 127 + fi +done + +# Clones a repo at an exact tag if it is not already present at that tag. +# Shallow, single-branch: the ExecuTorch tree is large and history is useless here. +clone_pinned() { + local repo="$1" tag="$2" dest="$3" recurse="$4" + local stamp="${dest}/.rne-pinned-version" + + if [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$tag" ]; then + echo " ✓ ${dest##*/} already at ${tag}" + return + fi + + echo " ↓ cloning ${repo} @ ${tag}" + rm -rf "$dest" + mkdir -p "$(dirname "$dest")" + if [ "$recurse" = "recurse" ]; then + git clone --depth 1 --branch "$tag" --recurse-submodules --shallow-submodules "$repo" "$dest" + else + git clone --depth 1 --branch "$tag" "$repo" "$dest" + fi + echo "$tag" > "$stamp" +} + +echo "==> Hermes (${HERMES_VERSION})" +clone_pinned "$HERMES_REPO" "$HERMES_VERSION" "${DEPS_DIR}/hermes/src" no +cmake -S "${DEPS_DIR}/hermes/src" -B "${DEPS_DIR}/hermes/build" -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DHERMES_BUILD_APPLE_FRAMEWORK=OFF \ + -DHERMES_ENABLE_TEST_SUITE=OFF \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON +# `hermesvm` is the JSI-facing engine target; it pulls in the vendored jsi too. +cmake --build "${DEPS_DIR}/hermes/build" --target hermesvm -j "${JOBS}" + +echo "==> ExecuTorch (${EXECUTORCH_VERSION})" +# ExecuTorch's CMake refuses to configure unless its source directory is named +# exactly `executorch` (pytorch/executorch#6475), hence the flat layout here +# rather than the src/build pair used for Hermes. +clone_pinned "$EXECUTORCH_REPO" "$EXECUTORCH_VERSION" "${DEPS_DIR}/executorch" recurse + +# Swap in the fork's tokenizers, pinned by commit. A shallow fetch of one object +# rather than a clone: the fork's history is as large as upstream's. +TOKENIZERS_DIR="${DEPS_DIR}/executorch/extension/llm/tokenizers" +TOKENIZERS_STAMP="${TOKENIZERS_DIR}/.rne-pinned-version" +if [ ! -f "$TOKENIZERS_STAMP" ] || [ "$(cat "$TOKENIZERS_STAMP")" != "$TOKENIZERS_COMMIT" ]; then + echo " ↓ ${TOKENIZERS_REPO} @ ${TOKENIZERS_COMMIT}" + rm -rf "$TOKENIZERS_DIR" + mkdir -p "$TOKENIZERS_DIR" + git -C "$TOKENIZERS_DIR" init -q + git -C "$TOKENIZERS_DIR" remote add origin "$TOKENIZERS_REPO" + git -C "$TOKENIZERS_DIR" fetch -q --depth 1 origin "$TOKENIZERS_COMMIT" + git -C "$TOKENIZERS_DIR" checkout -q FETCH_HEAD + git -C "$TOKENIZERS_DIR" submodule update --init --depth 1 --recursive + echo "$TOKENIZERS_COMMIT" > "$TOKENIZERS_STAMP" +else + echo " ✓ tokenizers already at ${TOKENIZERS_COMMIT}" +fi + +cmake -S "${DEPS_DIR}/executorch" -B "${DEPS_DIR}/executorch-build" -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON \ + -DEXECUTORCH_BUILD_EXTENSION_MODULE=ON \ + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON \ + -DEXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP=ON \ + -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON \ + -DEXECUTORCH_BUILD_EXTENSION_LLM=ON \ + -DEXECUTORCH_BUILD_EXTENSION_LLM_RUNNER=ON \ + -DEXECUTORCH_BUILD_PYBINDINGS=OFF \ + -DEXECUTORCH_BUILD_XNNPACK=OFF \ + -DEXECUTORCH_BUILD_TESTS=OFF +cmake --build "${DEPS_DIR}/executorch-build" \ + --target executorch extension_tensor extension_module_static tokenizers \ + extension_llm_runner -j "${JOBS}" + +echo +echo "Test dependencies ready in ${DEPS_DIR}" diff --git a/packages/react-native-executorch/scripts/clang-tidy.sh b/packages/react-native-executorch/scripts/clang-tidy.sh index 0c9eae9c14..c60614cd46 100755 --- a/packages/react-native-executorch/scripts/clang-tidy.sh +++ b/packages/react-native-executorch/scripts/clang-tidy.sh @@ -25,8 +25,11 @@ fi if [ "$#" -gt 0 ]; then files=("$@") else + # cpp/tests is excluded: its sources need the Hermes and GoogleTest headers + # that only scripts/build-native-test-deps.sh provisions, which is not one of + # this script's prerequisites. Pass test files explicitly to check them anyway. files=() - while IFS= read -r f; do files+=("$f"); done < <(find cpp -name '*.cpp' | sort) + while IFS= read -r f; do files+=("$f"); done < <(find cpp -path cpp/tests -prune -o -name '*.cpp' -print | sort) fi if [ "${#files[@]}" -eq 0 ]; then diff --git a/packages/react-native-executorch/scripts/fetch-test-fixtures.sh b/packages/react-native-executorch/scripts/fetch-test-fixtures.sh new file mode 100755 index 0000000000..40b3de57bf --- /dev/null +++ b/packages/react-native-executorch/scripts/fetch-test-fixtures.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Downloads the fixtures the C++ model, schema and tokenizer tests load. +# +# * A .pte program. Anything that reads ExecuTorch MethodMeta +# (schema::methodSpecFromMetadata, validateSpec, getUsedBackends, and +# ModelHostObject's whole load path) needs a real program to read it from. +# This fetches the smallest one the org publishes: selfie-segmentation, +# ~486 KB. Note this only covers *loading*. Executing the model additionally +# needs an XNNPACK host build, which these tests deliberately do not require +# — see cpp/tests/README.md. +# * A HuggingFace tokenizer.json, which the nlp extension loads through +# ExecuTorch's HFTokenizer. Whisper tiny.en's is a plain BPE vocabulary, +# ~2.4 MB. +# +# Both are pinned to an exact Hugging Face revision and verified against a +# recorded sha256, so a re-tag upstream cannot silently change what the tests +# assert. Idempotent: a no-op when a fixture is already present and matches. +# +# Usage: +# scripts/fetch-test-fixtures.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +FIXTURE_DIR="cpp/tests/fixtures" + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# fetch_fixture +fetch_fixture() { + local name="$1" repo="$2" revision="$3" path="$4" expected="$5" + local target="${FIXTURE_DIR}/${name}" + + if [ -f "$target" ] && [ "$(sha256_of "$target")" = "$expected" ]; then + echo "✓ ${name} already present" + return + fi + + mkdir -p "$FIXTURE_DIR" + local url="https://huggingface.co/${repo}/resolve/${revision}/${path}" + echo "↓ ${url}" + curl -fsSL -o "${target}.tmp" "$url" + + local actual + actual="$(sha256_of "${target}.tmp")" + if [ "$actual" != "$expected" ]; then + rm -f "${target}.tmp" + echo "error: checksum mismatch for ${name}" >&2 + echo " expected ${expected}" >&2 + echo " actual ${actual}" >&2 + exit 1 + fi + + mv "${target}.tmp" "$target" + echo "✓ ${name} ready" +} + +fetch_fixture \ + "selfie_segmentation_xnnpack_fp32.pte" \ + "software-mansion/react-native-executorch-selfie-segmentation" \ + "13a9494d8230279b47973b91c94b1aa902d307a6" \ + "xnnpack/selfie_segmentation_xnnpack_fp32.pte" \ + "176aba6a0719b56391586a3d19396315305c7adb5c16aafda350fecc596cebf9" + +fetch_fixture \ + "tokenizer.json" \ + "software-mansion/react-native-executorch-whisper-tiny.en" \ + "c99612ff807ef223f6316f33a4be7c587835a5ce" \ + "tokenizer.json" \ + "5eb60cec1e77aeeb6869a2bb5a8e01a84c3fe5d072d75369343021fe6f5310d0" diff --git a/packages/react-native-executorch/scripts/run-native-tests.sh b/packages/react-native-executorch/scripts/run-native-tests.sh new file mode 100755 index 0000000000..cf2459a76b --- /dev/null +++ b/packages/react-native-executorch/scripts/run-native-tests.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Configures, builds and runs the C++ unit tests. +# +# Usage: +# scripts/run-native-tests.sh # build + run everything +# scripts/run-native-tests.sh -R MathOpsTest # only suites matching a regex +# +# Any additional arguments are forwarded to ctest. +# +# Prerequisites, both of which this script checks for and explains: +# * third-party/include -- RNET_HEADERS_ONLY=1 node scripts/download-libs.js +# * .native-test-deps -- scripts/build-native-test-deps.sh +# +# Environment: +# BUILD_DIR=/path -- build directory (default: cpp/tests/build) +# BUILD_TYPE=Debug -- CMake build type (default: Debug, for usable asserts) +# RNE_TESTS_ENABLE_OPENCV=OFF -- skip the OpenCV-dependent suites +# JOBS=8 -- parallelism (defaults to the CPU count) +set -euo pipefail + +cd "$(dirname "$0")/.." +PACKAGE_DIR="$(pwd)" +REPO_ROOT="${PACKAGE_DIR}/../.." + +BUILD_DIR="${BUILD_DIR:-${PACKAGE_DIR}/cpp/tests/build}" +BUILD_TYPE="${BUILD_TYPE:-Debug}" +ENABLE_OPENCV="${RNE_TESTS_ENABLE_OPENCV:-ON}" +JOBS="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}" + +if [ ! -d "${PACKAGE_DIR}/third-party/include" ]; then + echo "error: third-party/include is missing. Provision the headers with:" >&2 + echo " RNET_HEADERS_ONLY=1 node scripts/download-libs.js" >&2 + exit 1 +fi + +if [ ! -d "${PACKAGE_DIR}/.native-test-deps" ]; then + echo "error: test dependencies are missing. Build them once with:" >&2 + echo " scripts/build-native-test-deps.sh" >&2 + exit 1 +fi + +if [ ! -f "${REPO_ROOT}/third-party/googletest/CMakeLists.txt" ]; then + echo "error: googletest submodule is empty. Initialise it with:" >&2 + echo " git submodule update --init third-party/googletest" >&2 + exit 1 +fi + +# The .pte fixture is small and the fetch is a checksum-verified no-op once it +# is present, so provision it here rather than making it another manual step. +# Set RNE_SKIP_FIXTURES=1 to work offline; the suites that need it are then +# dropped from the build with a warning. +if [ "${RNE_SKIP_FIXTURES:-}" != "1" ]; then + "${PACKAGE_DIR}/scripts/fetch-test-fixtures.sh" +fi + +cmake -S "${PACKAGE_DIR}/cpp/tests" -B "${BUILD_DIR}" -GNinja \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DRNE_TESTS_ENABLE_OPENCV="${ENABLE_OPENCV}" + +cmake --build "${BUILD_DIR}" -j "${JOBS}" + +cd "${BUILD_DIR}" +# --output-on-failure keeps passing runs quiet but prints the full gtest report +# for anything that fails, which is what CI logs need. +exec ctest --output-on-failure "$@"