diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc595841d3..6d1eba5015 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-bare-resource-fetcher --exclude react-native-executorch-expo-resource-fetcher --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 (dependency build ~2 min) but far below the 6 h + # default, so a stalled download fails with logs instead of hanging. + timeout-minutes: 30 + defaults: + run: + working-directory: packages/react-native-executorch + env: + # The package version on this branch is 0.0.0, which has no GitHub Release, + # so point the header download at the libs release that carries a + # headers.tar.gz. Keep this in sync with the ExecuTorch version pinned in + # scripts/build-native-test-deps.sh — the tests compile against these + # headers and link libraries built from that pin. + RNET_BASE_URL: https://github.com/software-mansion/react-native-executorch/releases/download/v0.10.0-libs + steps: + - name: Checkout + uses: actions/checkout@v6 + + # Only googletest is needed; the other submodules are unrelated to the + # host test build. + - name: Check out googletest + run: git submodule update --init --depth 1 third-party/googletest + 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 }} + + # ~486 KB, 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 .pte test fixtures + run: scripts/fetch-test-fixtures.sh + + - name: Run C++ unit tests + run: scripts/run-native-tests.sh diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 99c7a52caa..7b0d919ee9 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -46,8 +46,9 @@ jobs: # TODO(#1291, @msluszniak): drop this override once a real versioned # release exists. download-libs.js resolves the tag from the package # version (v${PACKAGE_VERSION} == v0.0.0), but the placeholder 0.0.0 has - # no release; headers.tar.gz currently lives only on the test pre-release. - RNET_BASE_URL: https://github.com/software-mansion/react-native-executorch/releases/download/v0.0.0-rewrite-libs-test + # no release, so point at a libs release that ships headers.tar.gz. + # Keep in sync with the same override in ci.yml's native-tests job. + RNET_BASE_URL: https://github.com/software-mansion/react-native-executorch/releases/download/v0.10.0-libs run: node scripts/download-libs.js - name: Run clang-tidy diff --git a/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore index 4498ff073d..2a5b7c9529 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/ + +# .pte 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 2c10ab04f7..09620a6d86 100644 --- a/packages/react-native-executorch/compile_flags.txt +++ b/packages/react-native-executorch/compile_flags.txt @@ -8,3 +8,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/tests/CMakeLists.txt b/packages/react-native-executorch/cpp/tests/CMakeLists.txt new file mode 100644 index 0000000000..77475e3ac1 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/CMakeLists.txt @@ -0,0 +1,289 @@ +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) + +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, and the LLM tokenizers used by the nlp extension. +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) + +set(ET_LIBS + 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 + abseil, built as part of the ExecuTorch +# tree. Glob them rather than naming ~20 abseil targets individually. +file(GLOB ET_RE2_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/re2/libre2.a") +file(GLOB ET_ABSEIL_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/abseil-cpp/absl/*/*.a") +file(GLOB ET_JSON_LIBS "${ET_BUILD_DIR}/extension/llm/tokenizers/third-party/*/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_RE2_LIBS} ${ET_ABSEIL_LIBS} ${ET_JSON_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 always, cv behind the +# OpenCV 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 OPENCV_SOURCES "${CPP_DIR}/extensions/cv/*.cpp") + +set(RNE_SOURCES + "${CPP_DIR}/RnExecutorch.cpp" + ${CORE_SOURCES} ${MATH_SOURCES} ${NLP_SOURCES} ${SPEECH_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() + +add_library(rne_under_test STATIC ${RNE_SOURCES}) + +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}") + +# --- 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() + +# --- 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/ConversionsTest.cpp + core/TensorTest.cpp + core/SchemaTest.cpp + extensions/MathOpsTest.cpp + extensions/SpeechOpsTest.cpp) + +if(RNE_TESTS_ENABLE_OPENCV) + list(APPEND TEST_SUITES extensions/CvOpsTest.cpp) +endif() + +if(RNE_HAVE_MODEL_FIXTURE) + list(APPEND TEST_SUITES core/ModelTest.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..72674e15f5 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/README.md @@ -0,0 +1,125 @@ +# 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 `jsi::JSError`. 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 — all the things a stub would have hidden. + +`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 | +| `core/` | `dtype`, `conversions`, `tensor`, `schema`, `model` | +| `extensions/` | `math`, `speech`, and (behind OpenCV) `cv` ops | +| `fixtures/` | Downloaded `.pte` programs (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). +# This branch's package version (0.0.0) has no release of its own, so point +# the download at a libs release — the same one the CI job uses. +RNET_HEADERS_ONLY=1 \ + RNET_BASE_URL=https://github.com/software-mansion/react-native-executorch/releases/download/v0.10.0-libs \ + node scripts/download-libs.js + +# 2. Hermes + a minimal ExecuTorch host build (~2 min, cached afterwards) +scripts/build-native-test-deps.sh + +# 3. Build and run (also fetches the .pte fixture, 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 that suite. + +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 — i.e. whichever libs release + `RNET_BASE_URL` points at above, 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. + +## The model fixture + +Anything reading ExecuTorch `MethodMeta` needs a real program, so +`scripts/fetch-test-fixtures.sh` downloads one: **selfie-segmentation** +(~486 KB, the smallest the org publishes), pinned to an exact Hugging Face +revision and checksum-verified. `run-native-tests.sh` fetches it automatically; +it lands in `fixtures/` and is gitignored rather than committed. + +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 the fixture is missing (offline, `RNE_SKIP_FIXTURES=1`), those suites 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. + +**`cpp/extensions/nlp/tokenizer.cpp`** is compiled and linked here (so it cannot +rot undetected) but has no suite yet — it needs tokenizer fixture files. 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..f83b0de095 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/ConversionsTest.cpp @@ -0,0 +1,173 @@ +#include +#include +#include +#include + +#include "support/JsiTestEnv.h" + +#include "core/conversions.h" + +namespace rnexecutorch::tests { +namespace { + +namespace conversions = rnexecutorch::core::conversions; +namespace jsi = facebook::jsi; +using ::testing::HasSubstr; + +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". + try { + conversions::asType(rt(), "sigmoid: src", jsi::Value(true)); + FAIL() << "expected a JSError"; + } catch (const jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("sigmoid: src")); + EXPECT_THAT(e.getMessage(), HasSubstr("must be a number")); + } + + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 1)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 1)), jsi::JSError); +} + +TEST_F(ConversionsTest, RejectsNonIntegralValuesForIntegerTypes) { + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 1.5)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 1.5)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 0.5)), jsi::JSError); +} + +TEST_F(ConversionsTest, RejectsNaNAndInfinity) { + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), nan)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), inf)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), -inf)), jsi::JSError); +} + +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_THROW(conversions::asType(rt(), "ctx", number(rt(), 2147483648.0)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), -2147483649.0)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), 256)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), -1)), jsi::JSError); + EXPECT_THROW(conversions::asType(rt(), "ctx", number(rt(), -1)), jsi::JSError); + + // 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];"); + try { + conversions::asVector(rt(), "shape", mixed); + FAIL() << "expected a JSError"; + } catch (const jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("shape[1]")); + } +} + +TEST_F(ConversionsTest, AsVectorRejectsNonArrays) { + EXPECT_THROW(conversions::asVector(rt(), "shape", eval("return {};")), jsi::JSError); + // A TypedArray is not a JS Array — asVector is the boxed path and must say so. + EXPECT_THROW(conversions::asVector(rt(), "shape", eval("return new Int32Array(3);")), jsi::JSError); +} + +TEST_F(ConversionsTest, RequiredPropertyIsEnforced) { + auto object = eval("return { a: 1 };").getObject(rt()); + EXPECT_EQ(conversions::getRequiredProperty(rt(), "opts", object, "a"), 1); + + try { + conversions::getRequiredProperty(rt(), "opts", object, "b"); + FAIL() << "expected a JSError"; + } catch (const jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("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_THROW(conversions::getOptionalProperty(rt(), "opts", object, "a"), jsi::JSError); +} + +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_THROW(conversions::fromJsiTypedArray(rt(), "ctx", view), jsi::JSError); +} + +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..9f5a4cf885 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/DTypeTest.cpp @@ -0,0 +1,57 @@ +#include + +#include + +#include "core/dtype.h" + +namespace { +using rnexecutorch::core::types::DType; +namespace types = rnexecutorch::core::types; + +// dtype is the one piece of the package with no JSI in its signature, so it is +// tested directly rather than through a runtime. + +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); +} + +TEST(DType, RejectsUnknownName) { + EXPECT_THROW(types::dtypeFromString("float64"), std::invalid_argument); + EXPECT_THROW(types::dtypeFromString(""), std::invalid_argument); + // Names are matched exactly — no case folding, no aliases. + EXPECT_THROW(types::dtypeFromString("Float32"), std::invalid_argument); + EXPECT_THROW(types::dtypeFromString("float"), std::invalid_argument); +} + +TEST(DType, StringRoundTrips) { + for (auto dtype : {DType::uint8, DType::int32, DType::int64, DType::float32}) { + EXPECT_EQ(types::dtypeFromString(types::dtypeToString(dtype)), dtype); + } +} + +TEST(DType, ScalarTypeRoundTrips) { + for (auto dtype : {DType::uint8, DType::int32, DType::int64, DType::float32}) { + 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_THROW(types::dtypeFromScalarType(executorch::aten::ScalarType::Double), + std::invalid_argument); + EXPECT_THROW(types::dtypeFromScalarType(executorch::aten::ScalarType::Bool), + std::invalid_argument); +} + +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); +} + +} // namespace 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..388b5801a8 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/ModelTest.cpp @@ -0,0 +1,205 @@ +#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, ReportsAMissingFileAsAJsError) { + auto message = evalThrowingMessage( + "__rnexecutorch_jsi__.loadModel('/definitely/not/a/model.pte');"); + EXPECT_THAT(message, HasSubstr("loadModel")); + EXPECT_THAT(message, HasSubstr("/definitely/not/a/model.pte")); +} + +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..1714feed93 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/SchemaTest.cpp @@ -0,0 +1,198 @@ +#include +#include +#include + +#include "support/JsiTestEnv.h" + +#include "core/schema.h" + +namespace rnexecutorch::tests { +namespace { + +namespace schema = rnexecutorch::core::schema; +using rnexecutorch::core::types::DType; +using ::testing::HasSubstr; + +// 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) { + EXPECT_THROW(schema::parseModelSpecJson("ctx", "{not json"), std::runtime_error); +} + +TEST(SchemaParse, RejectsUnknownKinds) { + EXPECT_THROW(schema::parseModelSpecJson( + "ctx", minimalSpecJson(R"([{"kind": "wobbly"}])")), + std::runtime_error); + EXPECT_THROW(schema::parseModelSpecJson( + "ctx", + R"({"forward": {"inputs": [{"kind": "Quaternion"}], "outputs": [], "runtimeConstraints": []}})"), + std::runtime_error); +} + +TEST(SchemaParse, ErrorMessageCarriesContext) { + try { + schema::parseModelSpecJson("my-model.pte", "{not json"); + FAIL() << "expected parseModelSpecJson to throw"; + } catch (const std::runtime_error &e) { + EXPECT_THAT(std::string(e.what()), HasSubstr("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)}}}; + + try { + schema::validateRuntimeConstraints(rt(), constraints, {{4, 16}, {8, 2}}, "forward"); + FAIL() << "expected a constraint violation"; + } catch (const facebook::jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("equality constraint violated")); + EXPECT_THAT(e.getMessage(), HasSubstr("forward constraint[0]")); + } +} + +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}}}; + + try { + schema::validateRuntimeConstraints(rt(), constraints, {{10}, {4}}, "forward"); + FAIL() << "expected a constraint violation"; + } catch (const facebook::jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("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}}}; + + try { + schema::validateRuntimeConstraints(rt(), constraints, {{4}, {4}}, "forward"); + FAIL() << "expected the second constraint to fail"; + } catch (const facebook::jsi::JSError &e) { + EXPECT_THAT(e.getMessage(), HasSubstr("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..cb158768f0 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/core/TensorTest.cpp @@ -0,0 +1,203 @@ +#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) { + EXPECT_THAT(evalThrowingMessage(std::format("{} rne.createTensor([2], 'float64');", kNs)), + HasSubstr("createTensor")); +} + +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/extensions/CvOpsTest.cpp b/packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp new file mode 100644 index 0000000000..5e7322afe9 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/CvOpsTest.cpp @@ -0,0 +1,314 @@ +#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")); +} + +} // 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..a0425f7743 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/extensions/MathOpsTest.cpp @@ -0,0 +1,202 @@ +#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); };"; + +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")); +} + +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)")); +} + +} // 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/support/JsiTestEnv.cpp b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp new file mode 100644 index 0000000000..5dc9080ce9 --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.cpp @@ -0,0 +1,129 @@ +#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_) : ""; +} + +std::string JsiTestEnv::evalThrowingMessage(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. + auto source = std::format(R"( + (function() {{ + try {{ + (function() {{ {} }})(); + }} catch (e) {{ + return String(e && e.message !== undefined ? e.message : e); + }} + return null; + }})() + )", + js); + + auto value = runtime_->evaluateJavaScript( + std::make_unique(source), kSourceUrl); + + if (value.isNull()) { + ADD_FAILURE() << "expected the snippet to throw, but it returned normally: " << js; + return ""; + } + return value.getString(*runtime_).utf8(*runtime_); +} + +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(); +} + +} // 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..b7ffadcbba --- /dev/null +++ b/packages/react-native-executorch/cpp/tests/support/JsiTestEnv.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +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); + + /** + * Evaluates a snippet of JavaScript expected to throw, and returns the + * thrown error's `message`. + * + * Native code signals misuse with `jsi::JSError`, which surfaces in JS as a + * regular catchable Error — this is the seam most negative tests assert on. + * + * @param js The JavaScript source expected to throw. + * @return The `message` of the thrown value. + */ + std::string evalThrowingMessage(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); + +} // 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..9b1555dc77 --- /dev/null +++ b/packages/react-native-executorch/scripts/build-native-test-deps.sh @@ -0,0 +1,106 @@ +#!/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 and the +# LLM tokenizers. +# +# 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 (headers.tar.gz). A mismatch shows up as +# link errors or, worse, ABI drift at runtime. +EXECUTORCH_VERSION="v1.3.1" +EXECUTORCH_REPO="https://github.com/pytorch/executorch.git" + +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 +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_PYBINDINGS=OFF \ + -DEXECUTORCH_BUILD_XNNPACK=OFF \ + -DEXECUTORCH_BUILD_TESTS=OFF +cmake --build "${DEPS_DIR}/executorch-build" \ + --target executorch extension_tensor extension_module_static tokenizers -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..c151f750ff --- /dev/null +++ b/packages/react-native-executorch/scripts/fetch-test-fixtures.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Downloads the .pte fixtures the C++ model/schema tests load. +# +# 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. +# +# 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 the fixture is already present and matches. +# +# Usage: +# scripts/fetch-test-fixtures.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +FIXTURE_DIR="cpp/tests/fixtures" + +HF_REPO="software-mansion/react-native-executorch-selfie-segmentation" +HF_REVISION="13a9494d8230279b47973b91c94b1aa902d307a6" +HF_PATH="xnnpack/selfie_segmentation_xnnpack_fp32.pte" +FIXTURE_NAME="selfie_segmentation_xnnpack_fp32.pte" +FIXTURE_SHA256="176aba6a0719b56391586a3d19396315305c7adb5c16aafda350fecc596cebf9" + +target="${FIXTURE_DIR}/${FIXTURE_NAME}" + +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 +} + +if [ -f "$target" ] && [ "$(sha256_of "$target")" = "$FIXTURE_SHA256" ]; then + echo "✓ ${FIXTURE_NAME} already present" + exit 0 +fi + +mkdir -p "$FIXTURE_DIR" +url="https://huggingface.co/${HF_REPO}/resolve/${HF_REVISION}/${HF_PATH}" +echo "↓ ${url}" +curl -fsSL -o "${target}.tmp" "$url" + +actual="$(sha256_of "${target}.tmp")" +if [ "$actual" != "$FIXTURE_SHA256" ]; then + rm -f "${target}.tmp" + echo "error: checksum mismatch for ${FIXTURE_NAME}" >&2 + echo " expected ${FIXTURE_SHA256}" >&2 + echo " actual ${actual}" >&2 + exit 1 +fi + +mv "${target}.tmp" "$target" +echo "✓ ${FIXTURE_NAME} ready" 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 "$@"